diff --git a/app/globals.css b/app/globals.css index 7d17839..ce956ed 100644 --- a/app/globals.css +++ b/app/globals.css @@ -1005,6 +1005,32 @@ pre { .game-header > * { pointer-events: auto; } +.report-dialog { + width: min(540px, calc(100vw - 32px)); + max-height: calc(100dvh - 40px); + overflow-y: auto; + margin: auto; + padding: 28px; + border: 2px solid var(--line); + border-radius: 0; + background: var(--panel); + color: var(--text); +} +.report-dialog .field select { + display: block; + width: 100%; + padding: 12px; + margin-top: 6px; + background: var(--bg); + border: 1px solid var(--line); + color: var(--text); +} +.report-dialog::backdrop { + background: #0009; +} +.game-header .report-dialog { + pointer-events: auto; +} .game-header .brand { font-size: 24px; filter: drop-shadow(0 2px 2px #000); diff --git a/app/servers/[id]/page.tsx b/app/servers/[id]/page.tsx index c3d99d4..5c5bb99 100644 --- a/app/servers/[id]/page.tsx +++ b/app/servers/[id]/page.tsx @@ -4,6 +4,7 @@ import { Header, Footer } from "@/components/Shell"; import { StartGame } from "@/components/StartGame"; import { getMap } from "@/lib/catalog"; import { ContributionInstructions } from "@/components/ContributionInstructions"; +import { ReportContent } from "@/components/ReportContent"; export default async function ServerPage({ params, }: { @@ -23,6 +24,7 @@ export default async function ServerPage({

{map.name}

{map.description}

Created by {map.credit}

+

Map ID: {map.id}

diff --git a/components/Dashboard.tsx b/components/Dashboard.tsx index edaac3a..54ce598 100644 --- a/components/Dashboard.tsx +++ b/components/Dashboard.tsx @@ -78,6 +78,8 @@ export function Dashboard() {

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.

@@ -120,6 +122,14 @@ export function Dashboard() { : m.state} {" "} {m.panorama_count} locations + {m.banned_at ? ( +

+ Map banned after three confirmed reports. Open your map to + appeal. +

+ ) : m.report_strikes > 0 ? ( +

{m.report_strikes}/3 confirmed reports

+ ) : null} {m.rejection_reason && (

{m.rejection_reason}

diff --git a/components/DraftEditor.tsx b/components/DraftEditor.tsx index 7c8542d..26258f7 100644 --- a/components/DraftEditor.tsx +++ b/components/DraftEditor.tsx @@ -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 && Map tools}
{draft.state} + {!draft.parent_dataset_id && + (draft.report_strikes > 0 || draft.banned_at) && ( + setVersion((v) => v + 1)} + /> + )} {draft.update_state && ( Update: {draft.update_state} )} diff --git a/components/Game.tsx b/components/Game.tsx index 2dc067d..8e3f720 100644 --- a/components/Game.tsx +++ b/components/Game.tsx @@ -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 }) { )}
+ {session && ( + + )}
ROUND diff --git a/components/MapAppeal.tsx b/components/MapAppeal.tsx new file mode 100644 index 0000000..c910f30 --- /dev/null +++ b/components/MapAppeal.tsx @@ -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([]); + 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 ( +
+

+ {bannedAt + ? "Map unpublished · appeal required" + : "Community report strikes"} +

+

+ {strikes}/3 confirmed reports.{" "} + {bannedAt + ? "This map cannot be published until a moderator approves your appeal." + : "Three confirmed reports automatically unpublish this map."} +

+ {banReason &&

{banReason}

} + {appeals.map((a) => ( +
+ Appeal: {a.state} +

{a.reason}

+ {a.review_reason &&

Moderator feedback: {a.review_reason}

} +
+ ))} + {bannedAt && + (appeals.some((a) => a.state === "open") ? ( +

Your appeal is waiting for moderator review.

+ ) : ( +
+