diff --git a/AGENTS.md b/AGENTS.md index 17f60bb..cd5e1c3 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -403,7 +403,20 @@ session with **no other memory of this project**. So: The flag logic is shared in **`src/lib/spotQuality.ts`** (used by both the page and `AdminReviewCard`, so they can't drift) and is deliberately heuristic — 'no community signals' only fires alongside another flag, so a brand-new real - spot isn't punished for being new. + spot isn't punished for being new. Community signals now include **other + skaters' photos**: `useBatchSpotPhotos` (`useSpotContributions.ts`) does ONE + `#a` query for every spot and ignores the mapper's own photos. + - **Verdicts are reversible — latest wins (2026-09-28):** `MODERATION_LABEL_CLEARED` + ('cleared') is the neutral undo verdict. `useAdminModeration` no longer unions + labels; it keeps the **newest verdict per target** (by `created_at`, with + device-local verdicts overlaid by timestamp), so publishing `cleared` + supersedes an earlier `hidden`/`verified`. `useAdminClear()` publishes it. + The local mirror is now a **verdict map** (`skatespots_local_admin_verdicts_v1`, + key = coord or `e:`, value `{verdict, at}`), migrating the legacy + hidden/confirmed arrays on read. `useSkateSpots(limit, { includeHidden: true })` + is **admin-tooling only** — without it a hidden spot leaves the list and can + never be un-hidden. The review queue keeps actioned rows (badge + **Undo**) + so both verbiage and verdicts are reversible in the UI. - **Mints directory**: `/mints` page lists Cashu mints from the community **MintRadar** project. MintRadar has **no CORS**, so it's fetched same-origin at `/api/mints/*` — prod proxies via `public/_redirects` (`/api/mints/* → diff --git a/release-notes.txt b/release-notes.txt index 8491db8..8df98c7 100644 --- a/release-notes.txt +++ b/release-notes.txt @@ -10,9 +10,11 @@ v1.5.4 verdict on whether a place is a real spot. - One-tap "Not a skate spot" for off-topic pins (test entries, airports, malls, private land), plus an admin review queue that surfaces them for a human. -- The queue also weighs mapper trust and whether any skater has actually - reported on the spot, and a review can now **Confirm** a spot as well as hide - it — a staff-vouched spot gets a badge and drops off the queue. +- The queue also weighs mapper trust, whether any skater has reported on the + spot, and whether anyone has added a community photo — and a review can now + **Confirm** a spot as well as hide it (a staff-vouched spot gets a badge). +- Moderation is **reversible**: Hide and Confirm can both be undone, returning the + spot to neutral without hiding or vouching for it. 🛍 App Shop — a new section (empty on purpose, with free printables) - Free to print, no sats needed: a 55 × 86 mm "DO YOU EVEN SKATE BRO?" sticker diff --git a/src/hooks/useAdminHide.ts b/src/hooks/useAdminHide.ts index 11fa342..0cd2600 100644 --- a/src/hooks/useAdminHide.ts +++ b/src/hooks/useAdminHide.ts @@ -2,11 +2,12 @@ import { useMutation, useQueryClient } from '@tanstack/react-query'; import { useNostrPublish } from '@/hooks/useNostrPublish'; import { useCurrentUser } from '@/hooks/useCurrentUser'; import { isAdminPubkey } from '@/lib/admins'; -import { addLocalHidden, addLocalConfirmed } from '@/lib/localModeration'; +import { setLocalVerdict } from '@/lib/localModeration'; import { MODERATION_LABEL_NAMESPACE, MODERATION_LABEL_HIDDEN, MODERATION_LABEL_VERIFIED, + MODERATION_LABEL_CLEARED, } from '@/lib/types'; /** @@ -18,7 +19,8 @@ import { * * Two symmetric verdicts share one code path: * • `hidden` — take it out of the app (soft, reversible); - * • `verified` — vouch for it, which also clears it from the review queue. + * • `verified` — vouch for it, which also clears it from the review queue; + * • `cleared` — UNDO: retract a verdict, back to neutral (newest wins). * * The label is public and authored by an admin key, so every SkateSpots client * filters/boosts accordingly. Nothing is ever deleted from relays — that is the @@ -58,9 +60,14 @@ function usePublishModerationLabel(label: string) { await publish({ kind: 1985, content: reason ?? '', tags }); - // Optimistic, this device. - if (label === MODERATION_LABEL_HIDDEN) addLocalHidden({ coord, id }); - else addLocalConfirmed({ coord, id }); + // Optimistic, this device — newest verdict wins on merge. + const verdict = + label === MODERATION_LABEL_HIDDEN + ? 'hidden' + : label === MODERATION_LABEL_VERIFIED + ? 'verified' + : 'cleared'; + setLocalVerdict({ coord, id }, verdict); queryClient.invalidateQueries({ queryKey: ['admin-moderation'] }); }, }); @@ -79,3 +86,13 @@ export function useAdminVerify() { const { run, isPending } = usePublishModerationLabel(MODERATION_LABEL_VERIFIED); return { confirmContent: run, isPending }; } + +/** + * UNDO: retract whatever verdict was on the target so it is merely neutral + * again — not hidden, not vouched. The spot stays exactly where it was; only the + * verdict goes away. + */ +export function useAdminClear() { + const { run, isPending } = usePublishModerationLabel(MODERATION_LABEL_CLEARED); + return { clearVerdict: run, isPending }; +} diff --git a/src/hooks/useAdminModeration.ts b/src/hooks/useAdminModeration.ts index cbcbc80..d8baa14 100644 --- a/src/hooks/useAdminModeration.ts +++ b/src/hooks/useAdminModeration.ts @@ -5,8 +5,9 @@ import { MODERATION_LABEL_NAMESPACE, MODERATION_LABEL_HIDDEN, MODERATION_LABEL_VERIFIED, + MODERATION_LABEL_CLEARED, } from '@/lib/types'; -import { getLocalHidden, getLocalConfirmed } from '@/lib/localModeration'; +import { getLocalVerdicts, type LocalVerdict } from '@/lib/localModeration'; /** * App-wide admin moderation layer. @@ -107,10 +108,12 @@ export function useAdminModeration() { ); const adminSet = new Set(ADMIN_PUBKEYS.map((p) => p.toLowerCase())); - const coords = new Set(); - const ids = new Set(); - const verifiedCoords = new Set(); - const verifiedIds = new Set(); + + // NEWEST VERDICT PER TARGET WINS. Kind 1985 is a regular event, so there + // can be several labels for one target; the latest one is the current + // truth. That is what makes a verdict reversible: an undo publishes + // `cleared`, which supersedes an earlier `hidden`/`verified`. + const verdicts = new Map(); for (const ev of events) { // Defense in depth: re-verify author + namespace + label client-side. @@ -119,28 +122,47 @@ export function useAdminModeration() { ([n, v]) => n === 'L' && v === MODERATION_LABEL_NAMESPACE, ); if (!hasNamespace) continue; - const isHidden = ev.tags.some( - ([n, v]) => n === 'l' && v === MODERATION_LABEL_HIDDEN, - ); - const isVerified = ev.tags.some( - ([n, v]) => n === 'l' && v === MODERATION_LABEL_VERIFIED, - ); - if (!isHidden && !isVerified) continue; + + const labelOf = (want: string) => + ev.tags.some(([n, v]) => n === 'l' && v === want); + const verdict: LocalVerdict | null = labelOf(MODERATION_LABEL_HIDDEN) + ? 'hidden' + : labelOf(MODERATION_LABEL_VERIFIED) + ? 'verified' + : labelOf(MODERATION_LABEL_CLEARED) + ? 'cleared' + : null; + if (!verdict) continue; for (const [n, v] of ev.tags) { - if (n === 'a' && v) (isHidden ? coords : verifiedCoords).add(v); - if (n === 'e' && v) (isHidden ? ids : verifiedIds).add(v); + const key = n === 'a' ? v : n === 'e' ? `e:${v}` : null; + if (!key) continue; + const current = verdicts.get(key); + if (!current || ev.created_at > current.at) { + verdicts.set(key, { verdict, at: ev.created_at }); + } } } - // Merge in this device's OPTIMISTIC local hides so admin-removed content - // disappears instantly, before the background kind:1985 label propagates. - const local = getLocalHidden(); - for (const c of local.coords) coords.add(c); - for (const i of local.ids) ids.add(i); - const localConfirmed = getLocalConfirmed(); - for (const c of localConfirmed.coords) verifiedCoords.add(c); - for (const i of localConfirmed.ids) verifiedIds.add(i); + // Overlay this device's OPTIMISTIC verdicts (just-made ones win until the + // relay catches up, and an undo written locally replaces the old verdict). + for (const [key, entry] of getLocalVerdicts()) { + const current = verdicts.get(key); + if (!current || entry.at >= current.at) verdicts.set(key, entry); + } + + const coords = new Set(); + const ids = new Set(); + const verifiedCoords = new Set(); + const verifiedIds = new Set(); + + for (const [key, { verdict }] of verdicts) { + const isId = key.startsWith('e:'); + const value = isId ? key.slice(2) : key; + if (verdict === 'hidden') (isId ? ids : coords).add(value); + else if (verdict === 'verified') (isId ? verifiedIds : verifiedCoords).add(value); + // `cleared` adds nothing — the target is simply neutral again. + } return { coords, @@ -161,26 +183,31 @@ export function useAdminModeration() { // Even if the relay query is still loading/empty, honor local optimistic // hides so the takedown is reflected immediately on this device. if (!query.data) { - const local = getLocalHidden(); - const localConfirmed = getLocalConfirmed(); - if ( - local.coords.size === 0 && - local.ids.size === 0 && - localConfirmed.coords.size === 0 && - localConfirmed.ids.size === 0 - ) { + const coords = new Set(); + const ids = new Set(); + const verifiedCoords = new Set(); + const verifiedIds = new Set(); + + for (const [key, entry] of getLocalVerdicts()) { + const isId = key.startsWith('e:'); + const value = isId ? key.slice(2) : key; + if (entry.verdict === 'hidden') (isId ? ids : coords).add(value); + else if (entry.verdict === 'verified') (isId ? verifiedIds : verifiedCoords).add(value); + } + + if (coords.size === 0 && ids.size === 0 && verifiedCoords.size === 0 && verifiedIds.size === 0) { return EMPTY; } + return { - coords: local.coords, - ids: local.ids, + coords, + ids, has: ({ coord, id }) => - (!!coord && local.coords.has(coord)) || (!!id && local.ids.has(id)), - verifiedCoords: localConfirmed.coords, - verifiedIds: localConfirmed.ids, + (!!coord && coords.has(coord)) || (!!id && ids.has(id)), + verifiedCoords, + verifiedIds, isVerified: ({ coord, id }) => - (!!coord && localConfirmed.coords.has(coord)) || - (!!id && localConfirmed.ids.has(id)), + (!!coord && verifiedCoords.has(coord)) || (!!id && verifiedIds.has(id)), }; } diff --git a/src/hooks/useSkateSpots.ts b/src/hooks/useSkateSpots.ts index 0454841..3d45880 100644 --- a/src/hooks/useSkateSpots.ts +++ b/src/hooks/useSkateSpots.ts @@ -9,13 +9,22 @@ const SPOTS_STALE_TIME = 5 * 60 * 1000; // 5 min const SPOTS_GC_TIME = 30 * 60 * 1000; // 30 min const QUERY_TIMEOUT = 15000; // 15s — mobile cold-start needs more time -export function useSkateSpots(limit = 100) { +/** + * Spots, newest first. + * + * `includeHidden` is for ADMIN TOOLING ONLY (the review queue): without it a spot + * you just hid vanishes from the list, so there is no way to undo the verdict. + * Normal callers must leave it off so hidden spots stay hidden. + */ +export function useSkateSpots(limit = 100, options: { includeHidden?: boolean } = {}) { + const { includeHidden = false } = options; const { querySkateContent, getQueryKeySuffix } = useSkateRelays(); const hidden = useAdminModeration(); return useQuery({ - queryKey: ['skate-spots', limit, getQueryKeySuffix()], - select: (spots: SkateSpot[]) => filterAdminHidden(spots, SKATE_SPOT_KIND, hidden), + queryKey: ['skate-spots', limit, includeHidden, getQueryKeySuffix()], + select: (spots: SkateSpot[]) => + includeHidden ? spots : filterAdminHidden(spots, SKATE_SPOT_KIND, hidden), queryFn: async () => { // If offline, return cached spots if (isOffline()) { diff --git a/src/hooks/useSpotContributions.ts b/src/hooks/useSpotContributions.ts index 129527a..3cfa05b 100644 --- a/src/hooks/useSpotContributions.ts +++ b/src/hooks/useSpotContributions.ts @@ -173,3 +173,64 @@ export function useAggregatedSpotTags(spotPubkey: string, spotIdentifier: string .sort((a, b) => b.count - a.count); }, [tagContributions]); } + +/** + * Community photo counts for MANY spots in one query. + * + * `useSpotPhotos` already queries by the `a` tag, so the batch is the same query + * with every ref at once (relays index `a`, so this is one request) — that is + * what makes it cheap enough for the review queue, where a per-spot query would + * mean hundreds of round-trips. + * + * Only photos from people OTHER than the mapper count: the mapper supplying their + * own picture is expected, not a community signal. + */ +export function useBatchSpotPhotos(spots: Array<{ pubkey: string; identifier: string }>) { + const { nostr } = useNostr(); + + const refs = useMemo( + () => spots.map((s) => `${SKATE_SPOT_KIND}:${s.pubkey}:${s.identifier}`), + [spots], + ); + + const query = useQuery({ + queryKey: ['batch-spot-photos', refs.slice().sort().join(',')], + enabled: refs.length > 0, + staleTime: 60_000, + queryFn: async (c) => { + const signal = AbortSignal.any([c.signal, AbortSignal.timeout(8000)]); + const events = await nostr.query( + [{ kinds: [PHOTO_CONTRIBUTION_KIND], '#a': refs, limit: 1000 }], + { signal }, + ); + return events + .map(parsePhotoContribution) + .filter((p): p is PhotoContribution => p !== null); + }, + }); + + const mapperByRef = useMemo(() => { + const map = new Map(); + for (const s of spots) map.set(`${SKATE_SPOT_KIND}:${s.pubkey}:${s.identifier}`, s.pubkey); + return map; + }, [spots]); + + const counts = useMemo(() => { + const map = new Map(); + for (const photo of query.data ?? []) { + // The contribution event carries the spot's `a` coordinate. + const ref = photo.event.tags.find(([n]) => n === 'a')?.[1]; + if (!ref) continue; + const mapper = mapperByRef.get(ref); + if (mapper && photo.pubkey === mapper) continue; // mapper's own photo + map.set(ref, (map.get(ref) ?? 0) + 1); + } + return map; + }, [query.data, mapperByRef]); + + return { + isLoading: query.isLoading, + getCommunityPhotoCount: (pubkey: string, identifier: string) => + counts.get(`${SKATE_SPOT_KIND}:${pubkey}:${identifier}`) ?? 0, + }; +} diff --git a/src/lib/localModeration.ts b/src/lib/localModeration.ts index 9c45991..4b8b98b 100644 --- a/src/lib/localModeration.ts +++ b/src/lib/localModeration.ts @@ -3,82 +3,109 @@ * * WHY THIS EXISTS * --------------- - * An admin takedown publishes a kind:1985 `hidden` label so the content is - * hidden across devices (see useAdminModeration). But publishing that label - * requires a SECOND Amber approval, which on mobile can be slow or require a - * second app switch. We must NOT make the delete UI wait on it. + * An admin verdict publishes a kind:1985 label so it applies across devices (see + * `useAdminModeration`). But publishing requires an Amber approval, which can be + * slow. We must not make the admin UI wait on it, so every verdict is ALSO + * mirrored here and merged in, so it takes effect on this device instantly. * - * So the moment the required kind:5 deletion succeeds, we record the target in - * this local store. useAdminModeration merges it, so the content disappears - * from the app immediately on THIS device. The kind:1985 label still publishes - * in the background for cross-device / other-user persistence. + * VERDICTS (latest wins) + * ---------------------- + * A target has ONE current verdict, and the newest one wins — both here and on + * the relay side. That is what makes moderation reversible: + * • `hidden` — out of the app; + * • `verified` — admin-vouched (badge, and cleared from the review queue); + * • `cleared` — UNDO: back to neutral, as if nobody had said anything. * - * Persisted to localStorage so an optimistic hide survives a reload even if the - * background label publish hasn't propagated yet. + * Entries carry a timestamp so a device-local verdict (just now) can be compared + * against a relay label (created_at) instead of blindly winning forever. */ -const STORAGE_KEY = 'skatespots_local_admin_hidden_v1'; -/** Same idea, for the POSITIVE action: an admin vouching for a spot. */ -const CONFIRMED_KEY = 'skatespots_local_admin_confirmed_v1'; +/** A moderation verdict about one target. */ +export type LocalVerdict = 'hidden' | 'verified' | 'cleared'; -interface LocalHidden { - coords: string[]; - ids: string[]; +export interface LocalVerdictEntry { + verdict: LocalVerdict; + /** Unix seconds. */ + at: number; } -function readAt(key: string): LocalHidden { +const STORAGE_KEY = 'skatespots_local_admin_verdicts_v1'; +// Older versions stored hidden and confirmed as two separate arrays. +const LEGACY_HIDDEN_KEY = 'skatespots_local_admin_hidden_v1'; +const LEGACY_CONFIRMED_KEY = 'skatespots_local_admin_confirmed_v1'; + +type Store = Record; + +/** One key per target: the addressable coord, or `e:` for a concrete event. */ +export function targetKey(opts: { coord?: string; id?: string }): string | null { + if (opts.coord) return opts.coord; + if (opts.id) return `e:${opts.id}`; + return null; +} + +function readLegacy(key: string): string[] { try { const raw = localStorage.getItem(key); - if (!raw) return { coords: [], ids: [] }; - const parsed = JSON.parse(raw) as Partial; - return { - coords: Array.isArray(parsed.coords) ? parsed.coords : [], - ids: Array.isArray(parsed.ids) ? parsed.ids : [], - }; + if (!raw) return []; + const parsed = JSON.parse(raw) as { coords?: unknown; ids?: unknown }; + const coords = Array.isArray(parsed.coords) ? (parsed.coords as string[]) : []; + const ids = Array.isArray(parsed.ids) ? (parsed.ids as string[]) : []; + return [...coords, ...ids.map((id) => `e:${id}`)]; } catch { - return { coords: [], ids: [] }; + return []; } } -function writeAt(key: string, data: LocalHidden): void { +function read(): Store { try { - localStorage.setItem(key, JSON.stringify(data)); + const raw = localStorage.getItem(STORAGE_KEY); + if (raw) return JSON.parse(raw) as Store; + // Migrate the legacy two-array format. `at: 0` means "no idea when", so any + // real relay label supersedes it — but it still applies while the label + // hasn't propagated. + const migrated: Store = {}; + for (const key of readLegacy(LEGACY_HIDDEN_KEY)) migrated[key] = { verdict: 'hidden', at: 0 }; + for (const key of readLegacy(LEGACY_CONFIRMED_KEY)) migrated[key] = { verdict: 'verified', at: 0 }; + if (Object.keys(migrated).length > 0) write(migrated); + return migrated; + } catch { + return {}; + } +} + +function write(store: Store): void { + try { + localStorage.setItem(STORAGE_KEY, JSON.stringify(store)); } catch { /* storage full / unavailable — the optimistic mirror is best-effort */ } } -const read = () => readAt(STORAGE_KEY); -const write = (data: LocalHidden) => writeAt(STORAGE_KEY, data); - -/** - * The admin's POSITIVE action: they looked at a spot and vouched for it. Stored - * locally so the review queue stops nagging about it instantly, and mirrored by - * a public `verified` label (see useAdminVerify). - */ -export function addLocalConfirmed(opts: { coord?: string; id?: string }): void { - const data = readAt(CONFIRMED_KEY); - if (opts.coord && !data.coords.includes(opts.coord)) data.coords.push(opts.coord); - if (opts.id && !data.ids.includes(opts.id)) data.ids.push(opts.id); - writeAt(CONFIRMED_KEY, data); +/** Record the admin's verdict for a target (device-local, newest wins). */ +export function setLocalVerdict( + opts: { coord?: string; id?: string }, + verdict: LocalVerdict, +): void { + const key = targetKey(opts); + if (!key) return; + const store = read(); + store[key] = { verdict, at: Math.floor(Date.now() / 1000) }; + write(store); } -/** Read the current local confirmed sets. */ -export function getLocalConfirmed(): { coords: Set; ids: Set } { - const data = readAt(CONFIRMED_KEY); - return { coords: new Set(data.coords), ids: new Set(data.ids) }; +/** Read every locally-recorded verdict, keyed by `targetKey`. */ +export function getLocalVerdicts(): Map { + return new Map(Object.entries(read())); } -/** Record a coordinate + event id as locally hidden by the admin. */ +// ─── Thin wrappers, kept for existing callers ──────────────────────────────── + +/** @see setLocalVerdict */ export function addLocalHidden(opts: { coord?: string; id?: string }): void { - const data = read(); - if (opts.coord && !data.coords.includes(opts.coord)) data.coords.push(opts.coord); - if (opts.id && !data.ids.includes(opts.id)) data.ids.push(opts.id); - write(data); + setLocalVerdict(opts, 'hidden'); } -/** Read the current local hidden sets. */ -export function getLocalHidden(): { coords: Set; ids: Set } { - const data = read(); - return { coords: new Set(data.coords), ids: new Set(data.ids) }; +/** @see setLocalVerdict */ +export function addLocalConfirmed(opts: { coord?: string; id?: string }): void { + setLocalVerdict(opts, 'verified'); } diff --git a/src/lib/types.ts b/src/lib/types.ts index 79abaf8..e07ad9c 100644 --- a/src/lib/types.ts +++ b/src/lib/types.ts @@ -230,6 +230,12 @@ export const MODERATION_LABEL_NAMESPACE = 'org.skatespots.moderation'; export const MODERATION_LABEL_HIDDEN = 'hidden'; /** Admin VOUCHES for the target (the positive counterpart to `hidden`). */ export const MODERATION_LABEL_VERIFIED = 'verified'; +/** + * UNDO: the admin retracted a previous verdict. Nothing is hidden or vouched — + * the target goes back to neutral. Newest verdict wins, so this supersedes an + * earlier `hidden`/`verified` label. + */ +export const MODERATION_LABEL_CLEARED = 'cleared'; /** Extract member pubkeys (hex) claimed by a Lab profile via `p` tags. */ export function parseMemberPubkeys(event: NostrEvent): string[] { diff --git a/src/pages/AdminReviewPage.tsx b/src/pages/AdminReviewPage.tsx index 1475752..8873e96 100644 --- a/src/pages/AdminReviewPage.tsx +++ b/src/pages/AdminReviewPage.tsx @@ -9,6 +9,7 @@ import { Loader2, CheckCircle2, BadgeCheck, + Undo2, Users, ShieldQuestion, } from 'lucide-react'; @@ -20,7 +21,8 @@ import { Skeleton } from '@/components/ui/skeleton'; import { useSkateSpots } from '@/hooks/useSkateSpots'; import { useBatchTrustScores } from '@/hooks/useBatchTrustScores'; import { useBatchSpotStatus } from '@/hooks/useSpotStatus'; -import { useAdminHide, useAdminVerify } from '@/hooks/useAdminHide'; +import { useBatchSpotPhotos } from '@/hooks/useSpotContributions'; +import { useAdminHide, useAdminVerify, useAdminClear } from '@/hooks/useAdminHide'; import { useAdminModeration } from '@/hooks/useAdminModeration'; import { useNotASpotReport } from '@/hooks/useNotASpotReport'; import { useCurrentUser } from '@/hooks/useCurrentUser'; @@ -44,9 +46,12 @@ import NotFound from '@/pages/NotFound'; */ export default function AdminReviewPage() { const { user } = useCurrentUser(); - const { data: spots, isLoading } = useSkateSpots(300); + // includeHidden: a spot you just hid must stay listed here, or there is no + // way to undo the verdict. + const { data: spots, isLoading } = useSkateSpots(300, { includeHidden: true }); const { hideContent, isPending: hiding } = useAdminHide(); const { confirmContent, isPending: confirming } = useAdminVerify(); + const { clearVerdict, isPending: clearing } = useAdminClear(); const moderation = useAdminModeration(); const { reportNotASpot } = useNotASpotReport(); const { toast } = useToast(); @@ -63,6 +68,7 @@ export default function AdminReviewPage() { [spots], ); const { getStatus } = useBatchSpotStatus(statusInput); + const { getCommunityPhotoCount } = useBatchSpotPhotos(statusInput); const rows = useMemo( () => @@ -70,23 +76,33 @@ export default function AdminReviewPage() { .map((spot) => { const coord = `${SKATE_SPOT_KIND}:${spot.pubkey}:${spot.identifier}`; const status = getStatus(spot.pubkey, spot.identifier); + const hidden = moderation.has({ coord, id: spot.id }); + const verified = moderation.isVerified({ coord, id: spot.id }); + const photos = getCommunityPhotoCount(spot.pubkey, spot.identifier); + const reporters = status?.reporterCount ?? 0; return { spot, coord, - verified: moderation.isVerified({ coord, id: spot.id }), + hidden, + verified, + // An already-actioned row is kept so the admin can UNDO it. + actioned: hidden || verified, trust: trustScores?.get(spot.pubkey), - reporters: status?.reporterCount ?? 0, + reporters, + photos, flags: spotFlags(spot, { mapperTrust: trustScores?.get(spot.pubkey), - communityStatusReports: status?.reporterCount ?? 0, + communityStatusReports: reporters, + communityPhotos: photos, }), }; }) - // Admin-vouched spots are done — and confirming one should visibly - // remove it from the pile. - .filter((row) => !row.verified && row.flags.length > 0) - .sort((a, b) => b.flags.length - a.flags.length), - [spots, moderation, trustScores, getStatus], + .filter((row) => row.actioned || row.flags.length > 0) + // Un-actioned rows first (most flags first); actioned rows sink below. + .sort((a, b) => + a.actioned === b.actioned ? b.flags.length - a.flags.length : a.actioned ? 1 : -1, + ), + [spots, moderation, trustScores, getStatus, getCommunityPhotoCount], ); useSeoMeta({ title: 'Review queue — SkateSpots' }); @@ -110,6 +126,22 @@ export default function AdminReviewPage() { } }; + const handleUndo = async (coord: string, id: string) => { + try { + await clearVerdict({ coord, id, reason: 'verdict retracted' }); + toast({ + title: 'Undone', + description: 'Back to neutral — not hidden, not vouched.', + }); + } catch (e) { + toast({ + title: 'Could not undo', + description: e instanceof Error ? e.message : 'Try again.', + variant: 'destructive', + }); + } + }; + const handleConfirm = async (coord: string, id: string) => { try { await confirmContent({ coord, id, reason: 'looks legit after review' }); @@ -151,8 +183,17 @@ export default function AdminReviewPage() { ) : (
- {rows.map(({ spot, coord, flags, trust, reporters }) => ( - + {rows.map(({ spot, coord, flags, trust, reporters, photos, hidden, verified }) => ( +