Lumi/plugins/moderation/views/moderation.ejs
2026-06-18 23:13:16 +02:00

431 lines
14 KiB
Plaintext
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<%- include("../../../src/web/views/partials/layout-top", { title }) %>
<style>
.moderation-grid {
display: grid;
gap: 18px;
}
.moderation-card {
background: var(--surface-2);
border: 1px solid var(--border);
border-radius: 16px;
padding: 16px;
}
.moderation-row {
background: var(--surface-2);
border: 1px solid transparent;
border-radius: 16px;
padding: 16px;
}
.moderation-row[open] {
border-color: var(--border);
}
.moderation-row summary {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
cursor: pointer;
list-style: none;
}
.moderation-row summary::-webkit-details-marker {
display: none;
}
.moderation-row .row-title {
display: flex;
flex-direction: column;
gap: 4px;
}
.moderation-row .row-title strong {
font-size: 1.1rem;
}
.row-chevron {
width: 28px;
height: 28px;
border-radius: 999px;
display: grid;
place-items: center;
border: 1px solid var(--border);
color: var(--ink-soft);
transition: transform 0.2s ease, background 0.2s ease;
}
.moderation-row[open] .row-chevron {
transform: rotate(90deg);
background: var(--surface-3);
}
.moderation-row-body {
padding-top: 14px;
margin-top: 14px;
border-top: 1px solid var(--border);
}
.pill-list {
display: flex;
flex-wrap: wrap;
gap: 8px;
}
.pill {
padding: 4px 10px;
border-radius: 999px;
background: var(--surface-3);
border: 1px solid var(--border);
font-size: 0.82rem;
font-weight: 600;
}
.pill.ban {
color: #ff7a7a;
border-color: rgba(255, 122, 122, 0.4);
background: rgba(255, 122, 122, 0.12);
}
.pill.timeout {
color: #f1b765;
border-color: rgba(241, 183, 101, 0.4);
background: rgba(241, 183, 101, 0.12);
}
.pill.kick {
color: #9aa1ad;
border-color: rgba(154, 161, 173, 0.4);
background: rgba(154, 161, 173, 0.12);
}
.pill.note {
color: #7c8dff;
border-color: rgba(124, 141, 255, 0.4);
background: rgba(124, 141, 255, 0.12);
}
.inline-actions {
display: flex;
align-items: center;
gap: 8px;
flex-wrap: wrap;
}
.duration-inputs {
display: grid;
grid-template-columns: 1fr 140px;
gap: 8px;
}
.duration-inputs input,
.duration-inputs select {
width: 100%;
}
.ban-pot {
display: flex;
align-items: center;
gap: 12px;
padding: 12px;
border-radius: 14px;
border: 1px solid var(--border);
background: var(--surface-3);
}
.ban-pot strong {
font-size: 1.2rem;
}
</style>
<section class="card">
<div class="section-header">
<div>
<h1>Moderation Center</h1>
<p class="command-subtitle">Global moderation actions, notes, and audit tracking.</p>
</div>
</div>
</section>
<div class="moderation-grid">
<details class="moderation-row" open>
<summary>
<div class="row-title">
<strong>Issue action</strong>
<span class="hint">Global bans and timeouts with required reasoning.</span>
</div>
<span class="row-chevron" aria-hidden="true"></span>
</summary>
<div class="moderation-row-body">
<form method="post" action="/plugins/moderation/actions" enctype="multipart/form-data" class="form-grid" data-duration-group>
<div class="field full lumi-user-lookup" data-user-lookup>
<label>Target internal username (optional)</label>
<input type="search" name="target_username" placeholder="Search internal username or linked account" autocomplete="off" data-user-lookup-search />
<input type="hidden" name="target_user_id" data-user-lookup-id />
<div class="lumi-user-lookup-results" data-user-lookup-results hidden></div>
<div class="lumi-user-lookup-preview" data-user-lookup-preview hidden></div>
<span class="hint">Search internal usernames or linked accounts.</span>
</div>
<div class="field">
<label>Target platform</label>
<select name="target_platform">
<option value="">Select platform</option>
<option value="discord">Discord</option>
<option value="twitch">Twitch</option>
<option value="youtube">YouTube</option>
<option value="kick" disabled>Kick (coming soon)</option>
</select>
</div>
<div class="field">
<label>Target platform ID</label>
<input name="target_platform_id" placeholder="User ID / username" />
<span class="hint">Use platform IDs when possible. Twitch can use username.</span>
</div>
<div class="field">
<label>Target platform username (optional)</label>
<input name="target_platform_username" placeholder="Display name" />
</div>
<div class="field">
<label>Action</label>
<select name="action_type">
<option value="ban">Ban (global)</option>
<option value="timeout">Timeout (global)</option>
<option value="kick" disabled>Kick (coming soon)</option>
</select>
</div>
<% if (!isAdmin) { %>
<div class="field">
<label>Duration preset (mods)</label>
<select name="duration_preset" data-duration-field>
<% presets.forEach((preset) => { %>
<option value="<%= preset.seconds %>"><%= preset.label %></option>
<% }) %>
</select>
</div>
<% } %>
<% if (isAdmin) { %>
<div class="field full">
<label>Custom duration (admins)</label>
<div class="duration-inputs">
<input name="duration_value" placeholder="Enter number" data-duration-field />
<select name="duration_unit" data-duration-field>
<option value="hours">Hours</option>
<option value="days">Days</option>
<option value="weeks">Weeks</option>
<option value="months">Months</option>
<option value="years">Years</option>
</select>
</div>
</div>
<% } %>
<div class="field">
<label>Permanent</label>
<label class="switch">
<input type="checkbox" class="switch-input" name="permanent" data-duration-permanent />
<span class="switch-track" aria-hidden="true"></span>
<span class="switch-text">Permanent</span>
</label>
</div>
<div class="field full">
<label>Reason summary</label>
<input name="reason_short" required />
</div>
<div class="field full">
<label>Reason details</label>
<textarea name="reason_detail" rows="4" required></textarea>
</div>
<div class="field full">
<label>Evidence (optional)</label>
<input type="file" name="evidence_files" accept="image/*" multiple />
</div>
<button type="submit" class="button">Submit action</button>
</form>
</div>
</details>
<section class="moderation-card">
<h2>Ban pot</h2>
<div class="ban-pot">
<span>Current balance</span>
<strong><%= banPot %></strong>
<span class="hint">Funds from bans are collected here.</span>
</div>
</section>
<section class="moderation-card">
<div class="section-header">
<div>
<h2>User notes</h2>
<p class="hint">Search or filter notes and keep context handy.</p>
</div>
<button type="button" class="button" data-note-open>Add user note</button>
</div>
<div class="table-tools">
<input
class="table-search"
type="search"
placeholder="Search notes"
aria-label="Search notes"
data-table-filter="moderation-notes"
/>
<div class="table-controls">
<% const noteUsers = Array.from(new Set(notes.map((note) => (note.internal_user_id || note.display_name || note.subject_id)).filter(Boolean))).sort((a, b) => a.localeCompare(b)); %>
<label class="table-page-size">
User
<select data-table-filter-select="moderation-notes" data-filter-key="user">
<option value="">All</option>
<% noteUsers.forEach((name) => { %>
<option value="<%= name.toLowerCase() %>"><%= name %></option>
<% }) %>
</select>
</label>
<label class="table-page-size">
Show
<select data-table-size="moderation-notes">
<option value="25">25</option>
<option value="50">50</option>
<option value="100">100</option>
<option value="250">250</option>
</select>
</label>
</div>
</div>
<% if (!notes.length) { %>
<p>No notes yet.</p>
<% } else { %>
<div class="table-wrap">
<table
class="table"
data-table="moderation-notes"
data-pageable="true"
data-page-size="25"
data-page-sizes="25,50,100,250"
>
<thead>
<tr>
<th data-sort="user">User</th>
<th>Note</th>
<th data-sort="by">By</th>
<th data-sort="date">Date</th>
<% if (isAdmin) { %><th>Actions</th><% } %>
</tr>
</thead>
<tbody>
<% notes.forEach((note) => { %>
<% const noteName = note.display_name || note.subject_id; %>
<% const noteUser = (note.internal_user_id || noteName || '').toLowerCase(); %>
<tr
data-search="<%= `${noteName} ${note.note} ${note.created_by_name || ''}`.toLowerCase() %>"
data-user="<%= noteUser %>"
data-by="<%= note.created_by_name || '' %>"
data-date="<%= note.created_at %>"
>
<td><%= noteName %></td>
<td><%= note.note %></td>
<td><%= note.created_by_name || 'Staff' %></td>
<td><%= new Date(note.created_at).toLocaleString() %></td>
<% if (isAdmin) { %>
<td>
<form method="post" action="/plugins/moderation/notes/<%= note.id %>/delete" class="inline-form" data-confirm-mode="modal" data-confirm-text="Delete this moderation note? This removes the note permanently.">
<button type="submit" class="button danger">Delete</button>
</form>
</td>
<% } %>
</tr>
<% }) %>
</tbody>
</table>
</div>
<div class="table-pagination" data-table-pagination="moderation-notes">
<button type="button" class="button subtle" data-page-prev>Previous</button>
<span class="table-page-label" data-page-label>Page 1 of 1</span>
<button type="button" class="button subtle" data-page-next>Next</button>
</div>
<% } %>
</section>
</div>
<div class="modal-backdrop" data-note-modal aria-hidden="true">
<div class="modal">
<div class="modal-header">
<h2>Add user note</h2>
<button type="button" class="icon-button" data-note-close aria-label="Close">
<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M6 6l12 12M18 6l-12 12" stroke="currentColor" stroke-width="2" stroke-linecap="round"/></svg>
</button>
</div>
<form method="post" action="/plugins/moderation/notes" class="form-grid">
<div class="field full lumi-user-lookup" data-user-lookup>
<label>Target internal username (optional)</label>
<input type="search" name="target_username" placeholder="Search internal username or linked account" autocomplete="off" data-user-lookup-search />
<input type="hidden" name="target_user_id" data-user-lookup-id />
<div class="lumi-user-lookup-results" data-user-lookup-results hidden></div>
<div class="lumi-user-lookup-preview" data-user-lookup-preview hidden></div>
<span class="hint">Search by internal username or linked account.</span>
</div>
<div class="field">
<label>Target platform</label>
<select name="target_platform">
<option value="">Select platform</option>
<option value="discord">Discord</option>
<option value="twitch">Twitch</option>
<option value="youtube">YouTube</option>
<option value="kick" disabled>Kick (coming soon)</option>
</select>
</div>
<div class="field">
<label>Target platform ID</label>
<input name="target_platform_id" placeholder="User ID / username" />
</div>
<div class="field full">
<label>Note</label>
<textarea name="note" rows="3" required></textarea>
</div>
<div class="modal-actions">
<button type="button" class="button subtle" data-note-close>Cancel</button>
<button type="submit" class="button">Save note</button>
</div>
</form>
</div>
</div>
<script>
(() => {
const attachDurationToggle = (group) => {
const toggle = group.querySelector("[data-duration-permanent]");
const fields = group.querySelectorAll("[data-duration-field]");
if (!toggle || !fields.length) {
return;
}
const sync = () => {
const disabled = toggle.checked;
fields.forEach((field) => {
field.disabled = disabled;
});
};
toggle.addEventListener("change", sync);
sync();
};
document.querySelectorAll("[data-duration-group]").forEach(attachDurationToggle);
const modal = document.querySelector("[data-note-modal]");
const openButton = document.querySelector("[data-note-open]");
const closeButtons = modal?.querySelectorAll("[data-note-close]") || [];
const openModal = () => {
if (!modal) {
return;
}
modal.classList.add("is-open");
modal.setAttribute("aria-hidden", "false");
};
const closeModal = () => {
if (!modal) {
return;
}
modal.classList.remove("is-open");
modal.setAttribute("aria-hidden", "true");
};
if (openButton) {
openButton.addEventListener("click", openModal);
}
closeButtons.forEach((button) => {
button.addEventListener("click", closeModal);
});
modal?.addEventListener("click", (event) => {
if (event.target === modal) {
closeModal();
}
});
window.addEventListener("keydown", (event) => {
if (event.key === "Escape" && modal?.classList.contains("is-open")) {
closeModal();
}
});
})();
</script>
<%- include("../../../src/web/views/partials/layout-bottom") %>