feat(moderation): reversible verdicts (undo) + community photos as a signal

Undo (latest verdict wins):
- MODERATION_LABEL_CLEARED = 'cleared' — the neutral undo verdict.
- useAdminModeration no longer UNIONS labels (which made a hide permanent in the
  UI); it keeps the NEWEST verdict per target (created_at, with device-local
  verdicts overlaid by timestamp), so 'cleared' supersedes an earlier
  'hidden'/'verified'. useAdminClear() publishes it.
- Local mirror is now a verdict map (skatespots_local_admin_verdicts_v1: key ->
  {verdict, at}) with migration from the legacy hidden/confirmed arrays.
- useSkateSpots(limit, { includeHidden: true }) — ADMIN TOOLING ONLY: without it
  a hidden spot leaves the list, so a verdict could never be undone. The review
  queue uses it, keeps actioned rows (Hidden/Confirmed badge + Undo), and sorts
  un-actioned first.

Community photos as a signal:
- useBatchSpotPhotos: ONE #a query for every spot (the single-spot hook already
  queried by #a, so batching is free), counting only photos from people OTHER
  than the mapper. Folded into the 'no community signals' flag.

Verified by seeding local verdicts: none -> flags + Confirm/Hide; hidden ->
Undo; verified -> Undo; cleared -> back to neutral. tsc/eslint clean, 101 tests,
smoke PASS.
This commit is contained in:
SkateSpots 2026-09-28 01:06:04 +07:00
parent 0419c18810
commit e44f8b81a7
9 changed files with 358 additions and 117 deletions

View file

@ -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:<id>`, 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/* →

View file

@ -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

View file

@ -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 };
}

View file

@ -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<string>();
const ids = new Set<string>();
const verifiedCoords = new Set<string>();
const verifiedIds = new Set<string>();
// 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<string, { verdict: LocalVerdict; at: number }>();
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<string>();
const ids = new Set<string>();
const verifiedCoords = new Set<string>();
const verifiedIds = new Set<string>();
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<string>();
const ids = new Set<string>();
const verifiedCoords = new Set<string>();
const verifiedIds = new Set<string>();
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)),
};
}

View file

@ -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()) {

View file

@ -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<string, string>();
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<string, number>();
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,
};
}

View file

@ -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<string, LocalVerdictEntry>;
/** One key per target: the addressable coord, or `e:<id>` 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<LocalHidden>;
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<string>; ids: Set<string> } {
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<string, LocalVerdictEntry> {
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<string>; ids: Set<string> } {
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');
}

View file

@ -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[] {

View file

@ -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() {
</Card>
) : (
<div className="space-y-3">
{rows.map(({ spot, coord, flags, trust, reporters }) => (
<Card key={spot.id} className="border-2 border-white/10 bg-white/[0.03]">
{rows.map(({ spot, coord, flags, trust, reporters, photos, hidden, verified }) => (
<Card
key={spot.id}
className={
hidden
? 'border-2 border-red-500/25 bg-red-500/[0.04]'
: verified
? 'border-2 border-emerald-500/25 bg-emerald-500/[0.04]'
: 'border-2 border-white/10 bg-white/[0.03]'
}
>
<CardContent className="space-y-3 p-4">
<div className="flex items-start gap-3">
{spot.images?.[0] ? (
@ -167,12 +208,26 @@ export default function AdminReviewPage() {
</div>
)}
<div className="min-w-0 flex-1">
<p className="truncate font-bold text-white">{spot.title || '(untitled)'}</p>
<p className="flex items-center gap-2 truncate font-bold text-white">
{hidden && (
<Badge className="border border-red-500/50 bg-red-500/20 text-[10px] font-black uppercase tracking-wide text-red-300">
<EyeOff className="mr-1 h-3 w-3" />
Hidden
</Badge>
)}
{verified && (
<Badge className="border border-emerald-500/50 bg-emerald-500/20 text-[10px] font-black uppercase tracking-wide text-emerald-300">
<BadgeCheck className="mr-1 h-3 w-3" />
Confirmed
</Badge>
)}
<span className="truncate">{spot.title || '(untitled)'}</span>
</p>
<p className="truncate text-xs text-gray-500">
{spot.description?.trim() || 'no description'}
</p>
<div className="mt-1.5 flex flex-wrap gap-1">
{flags.map((f) => (
{!hidden && !verified && flags.map((f) => (
<Badge
key={f}
className="border border-amber-500/40 bg-amber-500/15 text-[10px] font-bold uppercase tracking-wide text-amber-300"
@ -192,8 +247,13 @@ export default function AdminReviewPage() {
</span>
<span className="flex items-center gap-1">
<Users className="h-3 w-3" />
{reporters > 0
? `${reporters} status report${reporters === 1 ? '' : 's'}`
{reporters > 0 || photos > 0
? [
reporters > 0 ? `${reporters} status report${reporters === 1 ? '' : 's'}` : null,
photos > 0 ? `${photos} community photo${photos === 1 ? '' : 's'}` : null,
]
.filter(Boolean)
.join(' · ')
: 'no community signals'}
</span>
</div>
@ -201,6 +261,23 @@ export default function AdminReviewPage() {
</div>
<div className="flex flex-wrap gap-2">
{(hidden || verified) ? (
<Button
size="sm"
variant="outline"
className="gap-1.5 border-white/20 text-gray-200 hover:bg-white/10"
disabled={clearing}
onClick={() => void handleUndo(coord, spot.id)}
>
{clearing ? (
<Loader2 className="h-3.5 w-3.5 animate-spin" />
) : (
<Undo2 className="h-3.5 w-3.5" />
)}
Undo
</Button>
) : (
<>
<Button
size="sm"
className="gap-1.5 bg-emerald-500 font-bold text-black hover:bg-emerald-400"
@ -243,6 +320,8 @@ export default function AdminReviewPage() {
<Flag className="h-3.5 w-3.5" />
Not a skate spot
</Button>
</>
)}
<Button asChild size="sm" variant="ghost" className="gap-1.5 text-gray-400">
<Link to={`/spot/${spot.pubkey}/${spot.identifier}`}>
<ExternalLink className="h-3.5 w-3.5" />