mirror of
https://github.com/horizon-rust/horizon-site.git
synced 2026-11-07 10:05:58 -10:00
Merge pull request #19 from horizon-rust/feat/staff-mod
Staff dashboards: the Moderation dashboard (applications, moderation stats)
This commit is contained in:
6 files changed
+100
-12
No files matched your search
@@ -288,7 +288,7 @@ await writeFile(join(dist, "favicon.ico"), Buffer.concat([ico, png32]));
|
||||
const names = {};
|
||||
const rewrite = (code) => code.replace(/"\.\/([\w-]+\.js)"/g, (all, file) => (names[file] ? `"./${names[file]}"` : all));
|
||||
// Dependencies before what imports them.
|
||||
const modules = ["markdown.js", "versions.js", "chrome.js", "hardware.js", "estimate.js", "releases.js", "contributors.js", "bench.js", "hub.js", "main.js", "releases-page.js", "benchmarks-page.js", "docs-page.js", "hub-page.js", "mods.js", "mods-page.js", "mods-submit.js", "staff-api.js", "staff-apps.js", "staff-stats.js", "staff-team.js", "staff-page.js", "apply-page.js"];
|
||||
const modules = ["markdown.js", "versions.js", "chrome.js", "hardware.js", "estimate.js", "releases.js", "contributors.js", "bench.js", "hub.js", "main.js", "releases-page.js", "benchmarks-page.js", "docs-page.js", "hub-page.js", "mods.js", "mods-page.js", "mods-submit.js", "staff-api.js", "staff-apps.js", "staff-stats.js", "staff-team.js", "staff-mod.js", "staff-page.js", "apply-page.js"];
|
||||
for (const file of modules) {
|
||||
const code = rewrite(await readFile(join(site, "src", file), "utf8"));
|
||||
names[file] = `${file.replace(".js", "")}.${fingerprint(code)}.js`;
|
||||
|
||||
+9
-4
@@ -37,7 +37,7 @@ function voteHtml(a, people) {
|
||||
const head = open
|
||||
? `<p class="meta">${v.cast} ballot${v.cast === 1 ? "" : "s"} cast · quorum ${v.quorum} of ${v.active} active voter${v.active === 1 ? "" : "s"} · ${v.firstBallotAt ? `closes ${relative(v.closesAt)}` : `no ballot yet: closes ${relative(v.closesAt)} without one`}${v.extended ? " (extended)" : ""}. Ballots stay hidden until it closes.</p>`
|
||||
: a.tally ? `<p class="meta">${a.tally.for} for, ${a.tally.against} against, ${a.tally.abstain} abstain (quorum ${a.tally.quorum}).</p>` : "";
|
||||
const mine = v.mine ? `<p>Your ballot: <strong>${CHOICE[v.mine.choice]}</strong>${v.mine.comment ? `: ${escape(v.mine.comment)}` : ""}. You can change it until it closes.</p>` : v.recused ? `<p>You recused yourself.</p>` : "";
|
||||
const mine = !open ? "" : v.mine ? `<p>Your ballot: <strong>${CHOICE[v.mine.choice]}</strong>${v.mine.comment ? `: ${escape(v.mine.comment)}` : ""}. You can change it until it closes.</p>` : v.recused ? `<p>You recused yourself.</p>` : "";
|
||||
const form = v.canVote
|
||||
? `<form class="app-ballot" data-ballot="${escape(a.id)}">
|
||||
<div class="app-choices" role="group" aria-label="Your ballot">
|
||||
@@ -68,7 +68,7 @@ function resultHtml(a) {
|
||||
const r = a.result;
|
||||
const lines = [];
|
||||
if (a.status === "veto-window") lines.push(`Goes through ${relative(a.veto.until)} unless Ludovic vetoes it${a.veto.dm ? ` (DM to Ludovic: ${a.veto.dm.sent ? "sent" : `not sent, ${escape(a.veto.dm.error || "")}`})` : ""}.`);
|
||||
if (r) lines.push(`Role ${r.granted ? "given" : a.status === "accepted" ? "<strong>not given</strong>" : "–"} · DM ${r.dmSent ? "sent" : "<strong>not sent</strong>"}${r.error ? ` · ${escape(r.error)}` : ""}`);
|
||||
if (r) lines.push(`${a.status === "accepted" ? `Role ${r.granted ? "given" : "<strong>not given</strong>"} · ` : ""}DM ${r.dmSent ? "sent" : "<strong>not sent</strong>"}${r.error ? ` · ${escape(r.error)}` : ""}`);
|
||||
if (a.revoked) lines.push(`Role taken back ${when(a.revoked.at)}${a.revoked.result?.error ? `: ${escape(a.revoked.result.error)}` : ""}.`);
|
||||
if (a.githubManual && ["veto-window", "accepted"].includes(a.status)) lines.push("Accepting grants only the Discord role: GitHub organisation or team access is Ludovic's to give, by hand.");
|
||||
return lines.length ? `<p class="app-result">${lines.join("<br>")}</p>` : "";
|
||||
@@ -89,7 +89,8 @@ function cardHtml(a, people) {
|
||||
</article>`;
|
||||
}
|
||||
|
||||
// Renders the dashboard's applications into `root`. people: { id: name } for names in ballots and history.
|
||||
// Renders the dashboard's applications into `root`. people: { id: name } known beforehand; Kira adds the names behind
|
||||
// the ids in ballots and history.
|
||||
export async function renderApplications(root, dashboard, people = {}) {
|
||||
const draw = (list) => {
|
||||
const open = list.filter((a) => !["accepted", "declined"].includes(a.status));
|
||||
@@ -100,7 +101,11 @@ export async function renderApplications(root, dashboard, people = {}) {
|
||||
const target = location.hash.startsWith("#application-") && document.getElementById(location.hash.slice(1));
|
||||
if (target) target.scrollIntoView({ block: "start" });
|
||||
};
|
||||
const load = async () => draw((await api("GET", `${dashboard}/applications`)).applications);
|
||||
const load = async () => {
|
||||
const res = await api("GET", `${dashboard}/applications`);
|
||||
people = { ...people, ...res.people };
|
||||
draw(res.applications);
|
||||
};
|
||||
root.onsubmit = async (event) => {
|
||||
const form = event.target.closest("[data-ballot]");
|
||||
if (!form) return;
|
||||
|
||||
@@ -0,0 +1,83 @@
|
||||
// The Moderation dashboard (/staff/mod): Mod and Support applications, and the moderation counters (reports, mod-mail,
|
||||
// warns, timeouts, kicks, bans, AutoMod, joins and leaves; counts only) from Kira's mod/stats. Nothing of the Team's.
|
||||
import { api, escape, placePill } from "./staff-api.js";
|
||||
import { renderApplications } from "./staff-apps.js";
|
||||
import { barChart, tile, tableHtml, download, fmt, toCsv } from "./staff-stats.js";
|
||||
|
||||
const TABS = [["applications", "Applications"], ["stats", "Moderation stats"]];
|
||||
const LABELS = { reports: "Reports", modmail: "Mod-mail threads", warns: "Warnings", timeouts: "Timeouts", kicks: "Kicks", bans: "Bans", automod: "AutoMod actions", joins: "Joins", leaves: "Leaves" };
|
||||
const day = (iso) => new Date(`${iso}T00:00:00Z`).toLocaleDateString("en", { day: "numeric", month: "long", year: "numeric", timeZone: "UTC" });
|
||||
|
||||
function statsHtml(s) {
|
||||
const w = s.weeks.map((x) => x.week);
|
||||
const col = (k) => s.weeks.map((x) => x[k]);
|
||||
const a = s.applications;
|
||||
return `<p class="hub-intro">Kira's daily counters: counts only, nothing of what was said.${s.countingSince ? ` Counting since ${day(s.countingSince)}.` : " Counting starts with the next event."}</p>
|
||||
<div class="stat-tiles">
|
||||
${tile(fmt(s.last7.reports), "reports, last 7 days", `${fmt(s.last30.reports)} in 30 days`)}
|
||||
${tile(fmt(s.last7.modmail), "mod-mail threads, last 7 days", `${fmt(s.last30.modmail)} in 30 days`)}
|
||||
${tile(`${fmt(s.last7.warns)} / ${fmt(s.last7.timeouts)}`, "warnings / timeouts, last 7 days", `${fmt(s.last30.warns)} / ${fmt(s.last30.timeouts)} in 30 days`)}
|
||||
${tile(`${fmt(s.last7.kicks)} / ${fmt(s.last7.bans)}`, "kicks / bans, last 7 days", `${fmt(s.last30.kicks)} / ${fmt(s.last30.bans)} in 30 days`)}
|
||||
${tile(fmt(s.last7.automod), "AutoMod actions, last 7 days", `${fmt(s.last30.automod)} in 30 days`)}
|
||||
${tile(`${fmt(a.open.mod)} / ${fmt(a.open.support)}`, "Mod / Support applications waiting", `${fmt(a.accepted30)} accepted, ${fmt(a.declined30)} declined in 30 days`)}
|
||||
</div>
|
||||
<div class="chart-grid">
|
||||
${barChart("Reports and mod-mail per week", w, [{ name: "Reports", values: col("reports") }, { name: "Mod-mail", values: col("modmail") }])}
|
||||
${barChart("Warnings and timeouts per week", w, [{ name: "Warnings", values: col("warns") }, { name: "Timeouts", values: col("timeouts") }])}
|
||||
${barChart("Kicks and bans per week", w, [{ name: "Kicks", values: col("kicks") }, { name: "Bans", values: col("bans") }])}
|
||||
${barChart("AutoMod actions per week", w, [{ name: "AutoMod", values: col("automod") }])}
|
||||
${barChart("Joins and leaves per week", w, [{ name: "Joins", values: col("joins") }, { name: "Leaves", values: col("leaves") }])}
|
||||
</div>
|
||||
${tableHtml({ id: "moderation-weeks", title: "Weekly numbers", rows: weekRows(s) })}`;
|
||||
}
|
||||
const weekRows = (s) => s.weeks.map((x) => ({ week: x.week, ...Object.fromEntries(Object.keys(LABELS).map((k) => [k, x[k]])) }));
|
||||
|
||||
export async function render(root) {
|
||||
root.innerHTML = `<div class="tabs hub-tabs staff-tabs" role="tablist" aria-label="Moderation dashboard"><span class="tab-pill" aria-hidden="true"></span>${TABS.map(([id, label]) => `<button type="button" role="tab" id="tab-${id}" aria-controls="panel-${id}">${label}</button>`).join("")}</div>
|
||||
${TABS.map(([id]) => `<div class="hub-panel" role="tabpanel" id="panel-${id}" aria-labelledby="tab-${id}"></div>`).join("")}`;
|
||||
const tabs = root.querySelector("[role=tablist]");
|
||||
const panel = (id) => root.querySelector(`#panel-${id}`);
|
||||
let stats = null;
|
||||
const loadStats = async () => {
|
||||
try {
|
||||
stats = await api("GET", "mod/stats");
|
||||
panel("stats").innerHTML = statsHtml(stats);
|
||||
} catch (e) {
|
||||
panel("stats").innerHTML = `<p class="lede">${escape(e.message)}</p>`;
|
||||
}
|
||||
};
|
||||
const select = (id) => {
|
||||
for (const [t] of TABS) {
|
||||
const on = t === id;
|
||||
root.querySelector(`#tab-${t}`).setAttribute("aria-selected", String(on));
|
||||
root.querySelector(`#tab-${t}`).tabIndex = on ? 0 : -1;
|
||||
panel(t).classList.toggle("is-active", on);
|
||||
}
|
||||
placePill(tabs);
|
||||
if (id === "stats" && !stats) loadStats();
|
||||
if (!location.hash.startsWith(`#${id}`) && !(id === "applications" && location.hash.startsWith("#application-"))) history.replaceState(null, "", `#${id}`);
|
||||
};
|
||||
tabs.addEventListener("click", (e) => {
|
||||
const b = e.target.closest("[role=tab]");
|
||||
if (b) select(b.id.slice(4));
|
||||
});
|
||||
panel("stats").addEventListener("click", (e) => {
|
||||
const b = e.target.closest("[data-export]");
|
||||
if (!b || !stats) return;
|
||||
const rows = weekRows(stats);
|
||||
const stamp = new Date().toISOString().slice(0, 10);
|
||||
if (b.dataset.format === "json") download(`horizon-moderation-${stamp}.json`, JSON.stringify(rows, null, 2), "application/json");
|
||||
else download(`horizon-moderation-${stamp}.csv`, toCsv(rows), "text/csv");
|
||||
});
|
||||
await renderApplications(panel("applications"), "mod");
|
||||
// A link to a tab or an application (#stats, #application-<id>, as in Kira's DMs) opens it, also once open.
|
||||
const fromHash = () => {
|
||||
const id = location.hash.slice(1).replace(/^application-.*/, "applications");
|
||||
select(TABS.some(([t]) => t === id) ? id : "applications");
|
||||
const target = location.hash.startsWith("#application-") && document.getElementById(location.hash.slice(1));
|
||||
if (target) target.scrollIntoView({ block: "start" });
|
||||
};
|
||||
fromHash();
|
||||
addEventListener("hashchange", fromHash);
|
||||
addEventListener("resize", () => placePill(tabs));
|
||||
}
|
||||
+1
-1
@@ -8,7 +8,7 @@ const NAMES = { team: "Team", mod: "Moderation" };
|
||||
const TITLES = { team: "Team dashboard", mod: "Moderation dashboard" };
|
||||
const LOADERS = {
|
||||
team: () => import("./staff-team.js"),
|
||||
mod: null,
|
||||
mod: () => import("./staff-mod.js"),
|
||||
};
|
||||
|
||||
const $ = (sel) => document.querySelector(sel);
|
||||
|
||||
+5
-5
@@ -3,7 +3,7 @@
|
||||
// every bar and the numbers in a table under each chart. Data lists the same facts as tables, exportable as CSV or JSON.
|
||||
import { api, escape, when, relative } from "./staff-api.js";
|
||||
|
||||
const fmt = (n) => (n == null ? "–" : Number.isInteger(n) ? n.toLocaleString("en") : n.toLocaleString("en", { maximumFractionDigits: 1 }));
|
||||
export const fmt = (n) => (n == null ? "–" : Number.isInteger(n) ? n.toLocaleString("en") : n.toLocaleString("en", { maximumFractionDigits: 1 }));
|
||||
const day = (iso) => new Date(`${iso}T00:00:00Z`).toLocaleDateString("en", { day: "numeric", month: "short", timeZone: "UTC" });
|
||||
|
||||
// A round top for the axis: 1, 2 or 5 times a power of ten, at or above the largest value.
|
||||
@@ -14,7 +14,7 @@ function niceMax(v) {
|
||||
}
|
||||
|
||||
// Bars per week. series: [{ name, values }] (one or two, colors in that order); unit: what a value counts.
|
||||
function barChart(title, weeks, series, unit = "") {
|
||||
export function barChart(title, weeks, series, unit = "") {
|
||||
const W = 640, H = 200, left = 40, bottom = 24, top = 8;
|
||||
const max = niceMax(Math.max(...series.flatMap((s) => s.values.filter((v) => v != null))));
|
||||
const y = (v) => top + (H - top - bottom) * (1 - v / max);
|
||||
@@ -46,7 +46,7 @@ function rankChart(title, entries) {
|
||||
return `<figure class="chart"><figcaption class="chart-title">${escape(title)}</figcaption><ul class="rank">${rows || `<li class="empty">None.</li>`}</ul></figure>`;
|
||||
}
|
||||
|
||||
const tile = (value, label, note = "") => `<div class="stat-tile"><span class="stat-value">${value}</span><span class="stat-label">${label}</span>${note ? `<span class="meta">${note}</span>` : ""}</div>`;
|
||||
export const tile = (value, label, note = "") => `<div class="stat-tile"><span class="stat-value">${value}</span><span class="stat-label">${label}</span>${note ? `<span class="meta">${note}</span>` : ""}</div>`;
|
||||
|
||||
function statsHtml(s) {
|
||||
const w = s.weeks.map((x) => x.week);
|
||||
@@ -103,7 +103,7 @@ const csvCell = (v) => {
|
||||
};
|
||||
export const toCsv = (rows) => (rows.length ? [Object.keys(rows[0]), ...rows.map(Object.values)].map((r) => r.map(csvCell).join(",")).join("\r\n") + "\r\n" : "");
|
||||
|
||||
function download(file, text, type) {
|
||||
export function download(file, text, type) {
|
||||
const url = URL.createObjectURL(new Blob([text], { type }));
|
||||
const a = Object.assign(document.createElement("a"), { href: url, download: file });
|
||||
document.body.append(a);
|
||||
@@ -112,7 +112,7 @@ function download(file, text, type) {
|
||||
setTimeout(() => URL.revokeObjectURL(url), 1000);
|
||||
}
|
||||
|
||||
function tableHtml(set) {
|
||||
export function tableHtml(set) {
|
||||
const cols = set.rows.length ? Object.keys(set.rows[0]) : [];
|
||||
const body = set.rows.map((r) => `<tr>${cols.map((c) => `<td>${escape(r[c] == null ? "" : String(r[c]))}</td>`).join("")}</tr>`).join("");
|
||||
return `<section class="hub-block staff-data" id="data-${set.id}">
|
||||
|
||||
+1
-1
@@ -1007,7 +1007,7 @@ code { border-radius: 0; background: var(--well); box-shadow: 0 0 0 1px var(--ou
|
||||
.app-result { padding: 10px 14px; background: color-mix(in srgb, var(--accent) 10%, var(--panel)); font-size: var(--text-label); }
|
||||
.app-owner { display: flex; flex-wrap: wrap; align-items: center; gap: 8px; margin: 0; }
|
||||
.app-audit ul { margin-top: 6px; padding-left: 18px; list-style: square; font: var(--text-micro) / 1.6 var(--mono); color: var(--muted); }
|
||||
.app-done-title { margin: 40px 0 12px; font-size: var(--text-heading); }
|
||||
.staff .app-done-title { margin: 40px 0 12px; font-size: calc(var(--text-title) * 0.6); line-height: 1.1; }
|
||||
@media (max-width: 900px) { .staff-board-3 { grid-template-columns: minmax(0, 1fr); } }
|
||||
.staff-app .hub-block > .title { font-size: calc(var(--text-title) * 0.6); line-height: 1.1; }
|
||||
.staff-app .hub-block { margin-top: 48px; }
|
||||
|
||||
Reference in new issue
Block a user