Add account restrictions, appeals, and admin moderation tools
This commit is contained in:
@@ -12,6 +12,7 @@ import { ModeratorSettings } from "./ModeratorSettings";
|
||||
import { ModeratorPrefixes } from "./ModeratorPrefixes";
|
||||
import { isAdministrator, type StaffRole } from "@/shared/permissions";
|
||||
import { DiscordIntegration } from "./DiscordIntegration";
|
||||
import { AccountControls } from "./AccountControls";
|
||||
import { PrometheusIntegration } from "./PrometheusIntegration";
|
||||
type ReviewMap = {
|
||||
id: string;
|
||||
@@ -26,7 +27,11 @@ type ReviewMap = {
|
||||
report_strikes: number;
|
||||
banned_at: string | null;
|
||||
};
|
||||
export function Moderation() {
|
||||
export function Moderation({
|
||||
mode = "moderation",
|
||||
}: {
|
||||
mode?: "moderation" | "admin";
|
||||
}) {
|
||||
const { t, locale } = useI18n();
|
||||
const [maps, setMaps] = useState<ReviewMap[]>([]),
|
||||
[reports, setReports] = useState<
|
||||
@@ -49,7 +54,7 @@ export function Moderation() {
|
||||
const [preview, setPreview] = useState<string | null>(null),
|
||||
[loading, setLoading] = useState(true);
|
||||
const [role, setRole] = useState<StaffRole | null>(null);
|
||||
const administrator = isAdministrator(role ?? undefined);
|
||||
const administrator = mode === "admin" && isAdministrator(role ?? undefined);
|
||||
const [reportPreview, setReportPreview] = useState<{
|
||||
id: string;
|
||||
panorama: string | null;
|
||||
@@ -61,7 +66,7 @@ export function Moderation() {
|
||||
maps: ReviewMap[];
|
||||
reports: typeof reports;
|
||||
appeals: Appeal[];
|
||||
}>("moderation");
|
||||
}>(mode === "admin" ? "moderation?view=admin" : "moderation");
|
||||
setMaps(d.maps);
|
||||
setRole(d.role);
|
||||
setReports(d.reports);
|
||||
@@ -103,7 +108,7 @@ export function Moderation() {
|
||||
}
|
||||
useEffect(() => {
|
||||
load();
|
||||
}, []);
|
||||
}, [mode]);
|
||||
async function decide(datasetId: string, decision: string) {
|
||||
setBusy(true);
|
||||
setError("");
|
||||
@@ -122,6 +127,12 @@ export function Moderation() {
|
||||
}
|
||||
return (
|
||||
<>
|
||||
{role && (
|
||||
<AccountControls
|
||||
administrator={administrator}
|
||||
showAppeals={mode === "moderation"}
|
||||
/>
|
||||
)}
|
||||
{administrator && <ModeratorSettings />}
|
||||
{administrator && <ModeratorPrefixes />}
|
||||
{administrator && <DiscordIntegration />}
|
||||
@@ -129,7 +140,7 @@ export function Moderation() {
|
||||
{role === "moderator" && (
|
||||
<p className="muted">
|
||||
{t(
|
||||
"Moderators can inspect and review pending maps and updates. Other controls require Admin permission.",
|
||||
"Moderators can review pending maps, updates and account ban appeals. Other controls require Admin permission.",
|
||||
)}
|
||||
</p>
|
||||
)}
|
||||
@@ -142,78 +153,81 @@ export function Moderation() {
|
||||
{!loading && !maps.length && !error && (
|
||||
<p className="muted">{t("The review queue is clear.")}</p>
|
||||
)}
|
||||
{maps.map((m) => (
|
||||
<article className="tool-card" key={m.id}>
|
||||
<span className="badge">{t(statusMessage(m.state))}</span>
|
||||
{m.update_state === "review" && (
|
||||
<span className="badge">{t("Map update awaiting review")}</span>
|
||||
)}
|
||||
<h2>{m.name}</h2>
|
||||
{m.report_strikes > 0 && (
|
||||
<p className={m.banned_at ? "error-message" : "muted"}>
|
||||
{m.report_strikes}
|
||||
{t("/3 confirmed reports")}
|
||||
{m.banned_at ? t(" · Banned; owner appeal required") : ""}
|
||||
{maps
|
||||
.filter((m) => mode !== "admin" || m.state !== "review")
|
||||
.map((m) => (
|
||||
<article className="tool-card" key={m.id}>
|
||||
<span className="badge">{t(statusMessage(m.state))}</span>
|
||||
{m.update_state === "review" && (
|
||||
<span className="badge">{t("Map update awaiting review")}</span>
|
||||
)}
|
||||
<h2>{m.name}</h2>
|
||||
{m.report_strikes > 0 && (
|
||||
<p className={m.banned_at ? "error-message" : "muted"}>
|
||||
{m.report_strikes}
|
||||
{t("/3 confirmed reports")}
|
||||
{m.banned_at ? t(" · Banned; owner appeal required") : ""}
|
||||
</p>
|
||||
)}
|
||||
<p className="muted">
|
||||
{m.source} · {m.panorama_count} {t("locations ·")} {m.description}
|
||||
</p>
|
||||
)}
|
||||
<p className="muted">
|
||||
{m.source} · {m.panorama_count} {t("locations ·")} {m.description}
|
||||
</p>
|
||||
{m.map && (
|
||||
<Image
|
||||
src={m.map}
|
||||
width={512}
|
||||
height={512}
|
||||
alt={t("Map for moderator review")}
|
||||
unoptimized
|
||||
style={{ maxWidth: "100%", height: "auto" }}
|
||||
/>
|
||||
)}
|
||||
<label className="field">
|
||||
{t("Review reason")}
|
||||
<textarea
|
||||
value={reasons[m.id] || ""}
|
||||
onChange={(e) =>
|
||||
setReasons((s) => ({ ...s, [m.id]: e.target.value }))
|
||||
}
|
||||
/>
|
||||
</label>
|
||||
<div className="inline-actions">
|
||||
<button className="button" onClick={() => setPreview(m.id)}>
|
||||
{t("Inspect 360° locations")}
|
||||
</button>
|
||||
{m.state === "review" || m.update_state === "review" ? (
|
||||
<>
|
||||
<button
|
||||
className="button primary"
|
||||
disabled={busy}
|
||||
onClick={() => decide(m.id, "approve")}
|
||||
>
|
||||
{m.update_state === "review"
|
||||
? t("Approve update")
|
||||
: t("Approve map")}
|
||||
</button>
|
||||
{m.map && (
|
||||
<Image
|
||||
src={m.map}
|
||||
width={512}
|
||||
height={512}
|
||||
alt={t("Map for moderator review")}
|
||||
unoptimized
|
||||
style={{ maxWidth: "100%", height: "auto" }}
|
||||
/>
|
||||
)}
|
||||
<label className="field">
|
||||
{t("Review reason")}
|
||||
<textarea
|
||||
value={reasons[m.id] || ""}
|
||||
onChange={(e) =>
|
||||
setReasons((s) => ({ ...s, [m.id]: e.target.value }))
|
||||
}
|
||||
/>
|
||||
</label>
|
||||
<div className="inline-actions">
|
||||
<button className="button" onClick={() => setPreview(m.id)}>
|
||||
{t("Inspect 360° locations")}
|
||||
</button>
|
||||
{mode === "moderation" &&
|
||||
(m.state === "review" || m.update_state === "review") ? (
|
||||
<>
|
||||
<button
|
||||
className="button primary"
|
||||
disabled={busy}
|
||||
onClick={() => decide(m.id, "approve")}
|
||||
>
|
||||
{m.update_state === "review"
|
||||
? t("Approve update")
|
||||
: t("Approve map")}
|
||||
</button>
|
||||
<button
|
||||
className="button"
|
||||
disabled={busy || !reasons[m.id]?.trim()}
|
||||
onClick={() => decide(m.id, "reject")}
|
||||
>
|
||||
{t("Return with feedback")}
|
||||
</button>
|
||||
</>
|
||||
) : null}
|
||||
{administrator && m.state === "published" && (
|
||||
<button
|
||||
className="button"
|
||||
disabled={busy || !reasons[m.id]?.trim()}
|
||||
onClick={() => decide(m.id, "reject")}
|
||||
disabled={busy}
|
||||
onClick={() => decide(m.id, "unpublish")}
|
||||
>
|
||||
{t("Return with feedback")}
|
||||
{t("Unpublish")}
|
||||
</button>
|
||||
</>
|
||||
) : null}
|
||||
{administrator && m.state === "published" && (
|
||||
<button
|
||||
className="button"
|
||||
disabled={busy}
|
||||
onClick={() => decide(m.id, "unpublish")}
|
||||
>
|
||||
{t("Unpublish")}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</article>
|
||||
))}
|
||||
)}
|
||||
</div>
|
||||
</article>
|
||||
))}
|
||||
{administrator && reports.length > 0 && (
|
||||
<section className="token-box">
|
||||
<h2>{t("Community reports")}</h2>
|
||||
|
||||
Reference in New Issue
Block a user