592 lines
18 KiB
Plaintext
592 lines
18 KiB
Plaintext
<%- 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") %>
|