Add community content reports, map bans, and appeals
This commit is contained in:
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
@@ -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
111
components/MapAppeal.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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`}
|
||||
|
||||
@@ -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>
|
||||
|
||||
168
components/ReportContent.tsx
Normal file
168
components/ReportContent.tsx
Normal 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>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user