Lumi/plugins/moderation/views/moderation.ejs
2026-05-30 20:37:42 +02:00

592 lines
18 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;
}
.user-search {
display: grid;
gap: 10px;
}
.user-results {
display: grid;
gap: 6px;
}
.user-row {
display: flex;
align-items: center;
justify-content: space-between;
gap: 10px;
padding: 6px 10px;
border-radius: 12px;
border: 1px solid var(--border);
background: var(--surface-3);
}
.user-row.is-selected {
border-color: var(--sea);
}
.user-main {
display: flex;
align-items: center;
gap: 8px;
flex-wrap: wrap;
}
.user-name {
font-weight: 600;
}
.user-pills {
display: flex;
gap: 6px;
flex-wrap: wrap;
}
.user-pill {
font-size: 0.7rem;
padding: 2px 6px;
border-radius: 999px;
background: var(--surface-3);
color: var(--ink-soft);
}
.user-expand {
background: transparent;
border: none;
color: var(--ink-soft);
cursor: pointer;
font-size: 0.75rem;
}
.user-details {
display: none;
font-size: 0.78rem;
color: var(--ink-soft);
margin-left: 4px;
}
.user-row.is-open .user-details {
display: inline;
}
.user-select {
background: var(--sea);
color: white;
border: none;
border-radius: 999px;
padding: 4px 10px;
cursor: pointer;
font-size: 0.75rem;
}
</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 user-search">
<label>Target internal username (optional)</label>
<input name="target_username" id="moderation-target-username" placeholder="ookamikuntv" autocomplete="off" />
<div class="user-results" id="moderation-target-results"></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>
</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>
</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 user-search">
<label>Target internal username (optional)</label>
<input name="target_username" id="moderation-note-username" placeholder="ookamikuntv" autocomplete="off" />
<div class="user-results" id="moderation-note-results"></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 users = <%- JSON.stringify(userDirectory || []) %>;
const normalize = (value) => (value || "").toString().toLowerCase();
const buildMatch = (user, term) => {
const internal = user.internal || "";
const internalMatch = normalize(internal).includes(term);
const identityMatches = (user.identities || []).filter((identity) =>
normalize(identity.display).includes(term)
);
if (!term) {
return null;
}
const display = internalMatch ? internal : identityMatches[0]?.display || internal;
const pills = [];
if (internalMatch) {
pills.push("Internal");
}
identityMatches.forEach((identity) => {
if (!pills.includes(identity.label)) {
pills.push(identity.label);
}
});
return {
id: user.id,
display,
internal,
pills,
identities: user.identities || []
};
};
const bindUserLookup = ({ input, results }) => {
if (!input || !results) {
return;
}
const renderResults = (term) => {
results.innerHTML = "";
if (!term) {
return;
}
const matches = users
.map((user) => buildMatch(user, term))
.filter(Boolean)
.slice(0, 8);
if (!matches.length) {
return;
}
matches.forEach((match) => {
const row = document.createElement("div");
row.className = "user-row";
row.dataset.userId = match.id;
row.dataset.internal = match.internal;
const details = match.identities
.map((identity) => `${identity.label}: ${identity.display}`)
.join(" · ");
const internalLabel = match.internal && match.internal !== match.display
? `Internal: ${match.internal}`
: "";
row.innerHTML = `
<div class="user-main">
<span class="user-name">${match.display || match.internal || ""}</span>
<div class="user-pills">
${match.pills.map((pill) => `<span class="user-pill">${pill}</span>`).join("")}
</div>
${match.identities.length ? '<button type="button" class="user-expand">Linked</button>' : ""}
<span class="user-details">${[internalLabel, details].filter(Boolean).join(" · ")}</span>
</div>
<button type="button" class="user-select">Select</button>
`;
results.appendChild(row);
});
};
input.addEventListener("input", () => {
renderResults(normalize(input.value));
});
results.addEventListener("click", (event) => {
const row = event.target.closest(".user-row");
if (!row) {
return;
}
if (event.target.closest(".user-expand")) {
row.classList.toggle("is-open");
return;
}
if (event.target.closest(".user-select")) {
const internal = row.dataset.internal;
if (internal) {
input.value = internal;
results.innerHTML = "";
}
}
});
};
bindUserLookup({
input: document.getElementById("moderation-target-username"),
results: document.getElementById("moderation-target-results")
});
bindUserLookup({
input: document.getElementById("moderation-note-username"),
results: document.getElementById("moderation-note-results")
});
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") %>