Add community content reports, map bans, and appeals

This commit is contained in:
2026-10-06 13:00:10 +05:00
parent 5f43e97d32
commit dd67b5c0de
18 changed files with 1167 additions and 50 deletions

View File

@@ -78,6 +78,8 @@ export function Dashboard() {
<div className="dashboard-toolbar">
<p className="muted">
Your maps start privately. You decide when they’re ready for review.
Contributions are hidden here after five days. Their panoramas and
direct links stay available.
</p>
<div className="inline-actions">
<Link className="button" href="/settings">
@@ -120,6 +122,14 @@ export function Dashboard() {
: m.state}
</span>{" "}
<span className="muted">{m.panorama_count} locations</span>
{m.banned_at ? (
<p className="error-message">
Map banned after three confirmed reports. Open your map to
appeal.
</p>
) : m.report_strikes > 0 ? (
<p className="muted">{m.report_strikes}/3 confirmed reports</p>
) : null}
<ProcessingProgress job={m.upload_jobs} />
{m.rejection_reason && (
<p className="error-message">{m.rejection_reason}</p>

View File

@@ -9,6 +9,7 @@ import { PanoramaPreview } from "./PanoramaPreview";
import { OwnerContributions } from "./OwnerContributions";
import Link from "next/link";
import { MapRefresh } from "./MapRefresh";
import { MapAppeal } from "./MapAppeal";
type Draft = {
id: string;
name: string;
@@ -25,6 +26,9 @@ type Draft = {
allow_contributions: boolean;
contribution_server_address: string | null;
update_state: string | null;
report_strikes: number;
banned_at: string | null;
ban_reason: string | null;
};
export function DraftEditor({ id }: { id: string }) {
const dirty = useRef(false);
@@ -52,6 +56,7 @@ export function DraftEditor({ id }: { id: string }) {
setSelected(d.manifest.viewpoints.map((p) => p.id));
}
if (
d.banned_at ||
["uploading", "processing"].includes(d.state) ||
["queued", "running"].includes(d.upload_jobs?.state ?? "")
)
@@ -139,6 +144,16 @@ export function DraftEditor({ id }: { id: string }) {
{!draft.parent_dataset_id && <a href="#map-tools">Map tools</a>}
</div>
<span className="badge">{draft.state}</span>
{!draft.parent_dataset_id &&
(draft.report_strikes > 0 || draft.banned_at) && (
<MapAppeal
id={id}
strikes={draft.report_strikes}
bannedAt={draft.banned_at}
banReason={draft.ban_reason}
onChange={() => setVersion((v) => v + 1)}
/>
)}
{draft.update_state && (
<span className="badge">Update: {draft.update_state}</span>
)}

View File

@@ -14,6 +14,7 @@ import { GuessMap, type Pin } from "./GuessMap";
import { api } from "./StartGame";
import { difficulties, type Difficulty, type Bounds } from "@/shared/protocol";
import { roundClock } from "@/shared/round-clock";
import { ReportContent } from "./ReportContent";
export type RoundResult = {
score: number;
distance: number | null;
@@ -197,6 +198,13 @@ export function Game({ id }: { id: string }) {
)}
<div className="game-header">
<Brand />
{session && (
<ReportContent
key={session.round}
sessionId={id}
round={session.round}
/>
)}
<div className="game-info">
<div>
<small>ROUND</small>

111
components/MapAppeal.tsx Normal file
View File

@@ -0,0 +1,111 @@
"use client";
import { useCallback, useEffect, useState } from "react";
import { api } from "./StartGame";
export type Appeal = {
id: string;
dataset_id: string;
reason: string;
state: string;
review_reason: string | null;
created_at: string;
reviewed_at: string | null;
};
export function MapAppeal({
id,
strikes,
bannedAt,
banReason,
onChange,
}: {
id: string;
strikes: number;
bannedAt: string | null;
banReason: string | null;
onChange: () => void;
}) {
const [appeals, setAppeals] = useState<Appeal[]>([]);
const [reason, setReason] = useState("");
const [error, setError] = useState("");
const [busy, setBusy] = useState(false);
const load = useCallback(async () => {
try {
setAppeals(
(await api<{ appeals: Appeal[] }>(`datasets/${id}/appeals`)).appeals,
);
} catch (e) {
setError((e as Error).message);
}
}, [id]);
useEffect(() => {
load();
const timer = setInterval(load, 10000);
return () => clearInterval(timer);
}, [load]);
async function submit(event: React.FormEvent) {
event.preventDefault();
setBusy(true);
setError("");
try {
await api(`datasets/${id}/appeals`, { reason });
setReason("");
await load();
onChange();
} catch (e) {
setError((e as Error).message);
} finally {
setBusy(false);
}
}
return (
<section className="tool-card">
<h2>
{bannedAt
? "Map unpublished · appeal required"
: "Community report strikes"}
</h2>
<p>
{strikes}/3 confirmed reports.{" "}
{bannedAt
? "This map cannot be published until a moderator approves your appeal."
: "Three confirmed reports automatically unpublish this map."}
</p>
{banReason && <p>{banReason}</p>}
{appeals.map((a) => (
<div key={a.id}>
<span className="badge">Appeal: {a.state}</span>
<p>{a.reason}</p>
{a.review_reason && <p>Moderator feedback: {a.review_reason}</p>}
</div>
))}
{bannedAt &&
(appeals.some((a) => a.state === "open") ? (
<p role="status">Your appeal is waiting for moderator review.</p>
) : (
<form onSubmit={submit}>
<label className="field">
Explain why the map ban should be lifted
<textarea
value={reason}
onChange={(e) => setReason(e.target.value)}
required
minLength={10}
maxLength={2000}
/>
</label>
<button
className="button primary"
disabled={busy || reason.trim().length < 10}
>
{busy ? "Sending…" : "Appeal map ban"}
</button>
</form>
))}
{error && (
<p role="alert" className="error-message">
{error}
</p>
)}
</section>
);
}

View File

@@ -3,6 +3,8 @@ import { useEffect, useState } from "react";
import Image from "next/image";
import { api } from "./StartGame";
import { PanoramaPreview } from "./PanoramaPreview";
import { reportCategories } from "@/shared/moderation";
import type { Appeal } from "./MapAppeal";
type ReviewMap = {
id: string;
name: string;
@@ -13,24 +15,44 @@ type ReviewMap = {
panorama_count: number;
map: string | null;
image: string | null;
report_strikes: number;
banned_at: string | null;
};
export function Moderation() {
const [maps, setMaps] = useState<ReviewMap[]>([]),
[reports, setReports] = useState<
{ id: string; dataset_id: string; reason: string }[]
{
id: string;
dataset_id: string;
reason: string;
category: keyof typeof reportCategories;
panorama_id: string | null;
evidence_url: string | null;
created_at: string;
mapName: string;
map: string | null;
}[]
>([]),
[appeals, setAppeals] = useState<Appeal[]>([]),
[error, setError] = useState(""),
[busy, setBusy] = useState(false),
[reasons, setReasons] = useState<Record<string, string>>({});
const [preview, setPreview] = useState<string | null>(null),
[loading, setLoading] = useState(true);
const [reportPreview, setReportPreview] = useState<{
id: string;
panorama: string | null;
} | null>(null);
async function load() {
try {
const d = await api<{ maps: ReviewMap[]; reports: typeof reports }>(
"moderation",
);
const d = await api<{
maps: ReviewMap[];
reports: typeof reports;
appeals: Appeal[];
}>("moderation");
setMaps(d.maps);
setReports(d.reports);
setAppeals(d.appeals);
setError("");
} catch (e) {
setError((e as Error).message);
@@ -38,11 +60,23 @@ export function Moderation() {
setLoading(false);
}
}
async function resolveReport(id: string) {
async function resolveReport(id: string, decision: "confirm" | "dismiss") {
setBusy(true);
setError("");
try {
await api(`reports/${id}`, { state: "resolved" }, "PATCH");
await api(`reports/${id}`, { decision, reason: reasons[id] }, "PATCH");
await load();
} catch (e) {
setError((e as Error).message);
} finally {
setBusy(false);
}
}
async function reviewAppeal(id: string, decision: "approve" | "reject") {
setBusy(true);
setError("");
try {
await api(`moderation/appeals/${id}`, { decision, reason: reasons[id] });
await load();
} catch (e) {
setError((e as Error).message);
@@ -87,6 +121,12 @@ export function Moderation() {
<span className="badge">Map update awaiting review</span>
)}
<h2>{m.name}</h2>
{m.report_strikes > 0 && (
<p className={m.banned_at ? "error-message" : "muted"}>
{m.report_strikes}/3 confirmed reports
{m.banned_at ? " · Banned; owner appeal required" : ""}
</p>
)}
<p className="muted">
{m.source} · {m.panorama_count} locations · {m.description}
</p>
@@ -148,23 +188,137 @@ export function Moderation() {
{reports.length > 0 && (
<section className="token-box">
<h2>Community reports</h2>
<p className="muted">
Confirming a valid report adds one strike to the map. Three strikes
automatically unpublish it. Dismissed reports add no strikes.
</p>
{reports.map((r) => (
<article className="draft-row" key={r.id}>
<div>
<h3>{r.mapName}</h3>
<code>{r.dataset_id}</code>
<p>
{r.panorama_id ? `Panorama: ${r.panorama_id}` : "Entire map"}{" "}
· {reportCategories[r.category]} ·{" "}
{new Date(r.created_at).toLocaleString()}
</p>
<p>{r.reason}</p>
{r.evidence_url && (
<p>
<a
href={r.evidence_url}
target="_blank"
rel="noopener noreferrer"
>
Open evidence
</a>
</p>
)}
{r.map && (
<Image
src={r.map}
width={320}
height={320}
alt="Reported map version"
unoptimized
style={{ maxWidth: "100%", height: "auto" }}
/>
)}
<button
className="button"
onClick={() =>
setReportPreview({ id: r.id, panorama: r.panorama_id })
}
>
Inspect reported content
</button>
<label className="field">
Decision reason
<textarea
maxLength={2000}
value={reasons[r.id] ?? ""}
onChange={(e) =>
setReasons((s) => ({ ...s, [r.id]: e.target.value }))
}
/>
</label>
<div className="inline-actions">
<button
className="button primary"
disabled={
busy ||
(reasons[r.id]?.trim().length ?? 0) < 10 ||
!!maps.find((m) => m.id === r.dataset_id)?.banned_at
}
onClick={() => resolveReport(r.id, "confirm")}
>
Confirm report · add strike
</button>
<button
className="button"
disabled={busy || (reasons[r.id]?.trim().length ?? 0) < 10}
onClick={() => resolveReport(r.id, "dismiss")}
>
Dismiss report
</button>
</div>
</div>
<button
className="button"
disabled={busy}
onClick={() => resolveReport(r.id)}
>
Resolve report
</button>
</article>
))}
</section>
)}
{appeals.length > 0 && (
<section className="token-box">
<h2>Map ban appeals</h2>
{appeals.map((a) => (
<article className="tool-card" key={a.id}>
<h3>
{maps.find((m) => m.id === a.dataset_id)?.name ?? a.dataset_id}
</h3>
<p>{a.reason}</p>
<button
className="button"
onClick={() => setPreview(a.dataset_id)}
>
Inspect map panoramas
</button>
<label className="field">
Appeal decision reason
<textarea
maxLength={2000}
value={reasons[a.id] ?? ""}
onChange={(e) =>
setReasons((s) => ({ ...s, [a.id]: e.target.value }))
}
/>
</label>
<div className="inline-actions">
<button
className="button primary"
disabled={busy || (reasons[a.id]?.trim().length ?? 0) < 10}
onClick={() => reviewAppeal(a.id, "approve")}
>
Approve appeal · restore map
</button>
<button
className="button"
disabled={busy || (reasons[a.id]?.trim().length ?? 0) < 10}
onClick={() => reviewAppeal(a.id, "reject")}
>
Reject appeal · keep ban
</button>
</div>
</article>
))}
</section>
)}
{reportPreview && (
<PanoramaPreview
endpoint={`moderation/reports/${reportPreview.id}/panoramas`}
initialId={reportPreview.panorama ?? undefined}
onClose={() => setReportPreview(null)}
/>
)}
{preview && (
<PanoramaPreview
endpoint={`moderation/${preview}/panoramas`}

View File

@@ -176,13 +176,19 @@ export function OwnerContributions({
isOwner
/>
<h3>Player submissions</h3>
<p className="muted">
Submissions older than five days are hidden from this list. Accepted
panoramas stay in your map.
</p>
{locked && (
<p className="muted">
Wait for the current moderator review before accepting another
submission.
</p>
)}
{!items.length && <p className="muted">No contributions yet.</p>}
{!items.length && (
<p className="muted">No contributions from the last five days.</p>
)}
{items.map((c) => (
<article className="tool-card" key={c.id}>
<h3>{c.name}</h3>

View File

@@ -0,0 +1,168 @@
"use client";
import { useEffect, useRef, useState } from "react";
import { api } from "./StartGame";
import { reportCategories } from "@/shared/moderation";
export function ReportContent({
datasetId,
sessionId,
round,
}: {
datasetId?: string;
sessionId?: string;
round?: number;
}) {
const [open, setOpen] = useState(false);
const [target, setTarget] = useState("map");
const [category, setCategory] = useState("broken");
const [reason, setReason] = useState("");
const [evidenceUrl, setEvidenceUrl] = useState("");
const [busy, setBusy] = useState(false);
const [error, setError] = useState("");
const [message, setMessage] = useState("");
const dialog = useRef<HTMLDialogElement>(null);
useEffect(() => {
if (!open) return;
const element = dialog.current;
const previousFocus =
document.activeElement instanceof HTMLElement
? document.activeElement
: null;
element?.showModal();
return () => {
element?.close();
previousFocus?.focus();
};
}, [open]);
async function submit(event: React.FormEvent) {
event.preventDefault();
setBusy(true);
setError("");
try {
await api("reports", {
datasetId,
sessionId,
round,
target,
category,
reason,
evidenceUrl: evidenceUrl.trim() || undefined,
});
setMessage(
"Report sent for moderator review. Only confirmed reports count toward the three-report map ban.",
);
setOpen(false);
setReason("");
setEvidenceUrl("");
} catch (e) {
setError((e as Error).message);
} finally {
setBusy(false);
}
}
return (
<>
<button
className="button"
onClick={() => {
setOpen(true);
setError("");
setMessage("");
}}
>
Report content
</button>
{message && (
<p role="status" className="success-message">
{message}
</p>
)}
{open && (
<dialog
ref={dialog}
className="report-dialog"
aria-labelledby="report-title"
onCancel={() => setOpen(false)}
>
<form onSubmit={submit}>
<h2 id="report-title">Report content</h2>
<p className="muted">
Sign in to send a report. A moderator reviews the content and your
explanation. Three confirmed reports unpublish the map until an
owner appeal is approved.
</p>
<label className="field">
What are you reporting?
<select
value={target}
onChange={(e) => setTarget(e.target.value)}
>
<option value="map">Entire map</option>
{sessionId && (
<option value="panorama">
This panorama (round {(round ?? 0) + 1})
</option>
)}
</select>
</label>
<label className="field">
Reason
<select
value={category}
onChange={(e) => setCategory(e.target.value)}
>
{Object.entries(reportCategories).map(([value, label]) => (
<option key={value} value={value}>
{label}
</option>
))}
</select>
</label>
<label className="field">
What happened?
<textarea
required
minLength={10}
maxLength={2000}
value={reason}
onChange={(e) => setReason(e.target.value)}
placeholder="Describe the problem and where to find it."
/>
</label>
<label className="field">
Evidence link (optional)
<input
type="url"
maxLength={1000}
value={evidenceUrl}
onChange={(e) => setEvidenceUrl(e.target.value)}
placeholder="https://…"
/>
</label>
{error && (
<p role="alert" className="error-message">
{error}
</p>
)}
<div className="inline-actions">
<button
className="button primary"
disabled={busy || reason.trim().length < 10}
>
{busy ? "Sending…" : "Send report"}
</button>
<button
type="button"
className="button"
disabled={busy}
onClick={() => setOpen(false)}
>
Cancel
</button>
</div>
</form>
</dialog>
)}
</>
);
}