import { useMemo, useState } from 'react'; import { Link } from 'react-router-dom'; import { useSeoMeta } from '@unhead/react'; import { ShieldAlert, Flag, EyeOff, ExternalLink, Loader2, CheckCircle2, BadgeCheck, Users, ShieldQuestion, Undo2, } from 'lucide-react'; import { PageHeader } from '@/components/PageHeader'; import { Card, CardContent } from '@/components/ui/card'; import { Badge } from '@/components/ui/badge'; import { Button } from '@/components/ui/button'; import { AlertDialog, AlertDialogAction, AlertDialogCancel, AlertDialogContent, AlertDialogDescription, AlertDialogFooter, AlertDialogHeader, AlertDialogTitle, AlertDialogTrigger, } from '@/components/ui/alert-dialog'; import { ReportsInbox } from '@/components/ReportsInbox'; import { Skeleton } from '@/components/ui/skeleton'; import { useSkateSpots } from '@/hooks/useSkateSpots'; import { useSkateShops } from '@/hooks/useSkateShops'; import { useSkateEvents } from '@/hooks/useSkateEvents'; import { useTours } from '@/hooks/useTours'; import { useMarketplaceItems } from '@/hooks/useMarketplace'; import { useCrews } from '@/hooks/useCrews'; import { useLabProfiles } from '@/hooks/useLabProfiles'; import { useBatchTrustScores } from '@/hooks/useBatchTrustScores'; import { useBatchSpotStatus } from '@/hooks/useSpotStatus'; import { useBatchSpotPhotos } from '@/hooks/useSpotContributions'; import { useSpotReports } from '@/hooks/useSpotReports'; import { useAdminHide, useAdminVerify, useAdminClear } from '@/hooks/useAdminHide'; import { useAdminModeration } from '@/hooks/useAdminModeration'; import { useNotASpotReport } from '@/hooks/useNotASpotReport'; import { useCurrentUser } from '@/hooks/useCurrentUser'; import { useToast } from '@/hooks/useToast'; import { isAdminPubkey } from '@/lib/admins'; import { contentFlags } from '@/lib/spotQuality'; import { SKATE_SPOT_KIND, SKATE_SHOP_KIND, CALENDAR_EVENT_KIND, SKATE_TOUR_KIND, MARKETPLACE_ITEM_KIND, SKATE_CREW_KIND, SKATE_COACH_KIND, } from '@/lib/types'; import NotFound from '@/pages/NotFound'; /** * Admin review queue. * * Covers EVERY content kind we can hide (spots, shops, events, tours, gear) — * the hide/verify labels were always kind-agnostic (`a`/`e` tags), so the queue * just needed to stop being spot-only. * * Nothing here deletes: **Hide** publishes a public, reversible NIP-32 label, * **Confirm** publishes the opposite (`verified`) so a review can IMPROVE an * item's standing, and **Undo** retracts the verdict back to neutral. Human * reports (NIP-56) always rank above the automated heuristics. Flags are * hypotheses — a human decides. */ type QueueKind = 'reports' | 'spots' | 'shops' | 'events' | 'tours' | 'gear' | 'crews' | 'lab'; const TABS: Array<{ id: QueueKind; label: string }> = [ { id: 'reports', label: 'Reports' }, { id: 'spots', label: 'Spots' }, { id: 'shops', label: 'Shops' }, { id: 'events', label: 'Events' }, { id: 'tours', label: 'Tours' }, { id: 'gear', label: 'Gear' }, { id: 'crews', label: 'Crews' }, { id: 'lab', label: 'Lab' }, ]; /** One reviewable item, flattened from whatever kind it came from. */ interface QueueRow { id: string; pubkey: string; identifier: string; kind: number; title: string; description: string; image?: string; imageCount: number; hasLocation: boolean; href: string; /** Spot-only signals (others have no status/photo data). */ communityStatusReports?: number; communityPhotos?: number; } // ─── Shared rendering: trust + reports + verdicts + actions ────────────────── function QueueList({ rows, isLoading, /** Hiding these removes a community/identity, not just a pin — confirm first. */ confirmHide = false, confirmHideNote, }: { rows: QueueRow[]; isLoading: boolean; confirmHide?: boolean; confirmHideNote?: string; }) { const { hideContent, isPending: hiding } = useAdminHide(); const { confirmContent, isPending: confirming } = useAdminVerify(); const { clearVerdict, isPending: clearing } = useAdminClear(); const moderation = useAdminModeration(); const { reportNotASpot } = useNotASpotReport(); const { toast } = useToast(); const [bulkBusy, setBulkBusy] = useState(false); // Old verdicts (e.g. a cleanup sweep from weeks ago) are correct but noisy — // they must not bury the items that still need a decision. const [showActioned, setShowActioned] = useState(false); const mappers = useMemo(() => [...new Set(rows.map((r) => r.pubkey))], [rows]); const { data: trustScores } = useBatchTrustScores(mappers); const { getCounts } = useSpotReports(rows); const prepared = useMemo( () => rows .map((row) => { const coord = `${row.kind}:${row.pubkey}:${row.identifier}`; const hidden = moderation.has({ coord, id: row.id }); const verified = moderation.isVerified({ coord, id: row.id }); const flags = contentFlags({ title: row.title, description: row.description, imageCount: row.imageCount, hasLocation: row.hasLocation, }); const trust = trustScores?.get(row.pubkey); if (typeof trust === 'number' && trust <= 0) { flags.push('mapper not in your web of trust'); } if (row.communityStatusReports !== undefined || row.communityPhotos !== undefined) { const signals = (row.communityStatusReports ?? 0) + (row.communityPhotos ?? 0); if (signals === 0 && flags.length > 0) flags.push('no community signals'); } const reports = getCounts(row.id); if (reports.total > 0) { flags.unshift( reports.notASpot > 0 && row.kind === SKATE_SPOT_KIND ? 'reported: not a skate spot' : `reported ×${reports.total}`, ); } return { row, coord, hidden, verified, actioned: hidden || verified, trust, reports, flags }; }) .filter((r) => r.actioned || r.flags.length > 0) .sort((a, b) => { if (a.actioned !== b.actioned) return a.actioned ? 1 : -1; if (a.reports.notASpot !== b.reports.notASpot) return b.reports.notASpot - a.reports.notASpot; if (a.reports.total !== b.reports.total) return b.reports.total - a.reports.total; return b.flags.length - a.flags.length; }), [rows, moderation, trustScores, getCounts], ); const act = async ( fn: () => Promise, ok: { title: string; description: string }, fail: string, ) => { try { await fn(); toast(ok); } catch (e) { toast({ title: fail, description: e instanceof Error ? e.message : 'Try again.', variant: 'destructive', }); } }; if (isLoading) { return (
); } if (prepared.length === 0) { return (

Nothing to review — nothing of this kind trips the junk signals.

); } const actioned = prepared.filter((r) => r.actioned); const open = prepared.filter((r) => !r.actioned); const visible = showActioned ? prepared : open; const undoAll = async () => { setBulkBusy(true); let done = 0; for (const r of actioned) { try { await clearVerdict({ coord: r.coord, id: r.row.id, reason: 'bulk undo' }); done += 1; } catch { // Keep going: one failure shouldn't abandon the rest. } } setBulkBusy(false); toast({ title: `Undid ${done} verdict${done === 1 ? '' : 's'}`, description: 'Each one is a separate label publish — on Amber that is one approval each.', }); }; return (
{actioned.length > 0 && (

{actioned.length} already actioned in this tab (hidden or confirmed).

{actioned.length > 1 && ( Undo every verdict in this tab? This sets each item back to neutral — not hidden, not vouched. It publishes one public label per item, so with Amber you'll get {actioned.length} approval screens. Cancel void undoAll()}> Undo {actioned.length} )}
)} {visible.length === 0 && (

Nothing waiting on you. {actioned.length} earlier verdict {actioned.length === 1 ? '' : 's'} {actioned.length === 1 ? 'is' : 'are'} hidden above.

)} {visible.map(({ row, coord, hidden, verified, trust, reports, flags }) => ( ))}
); } // ─── Per-kind loaders (one hook each, so only the open tab queries) ────────── function SpotsQueue() { // includeHidden: a hidden item must stay listed, or its verdict can't be undone. const { data, isLoading } = useSkateSpots(300, { includeHidden: true }); const spots = useMemo(() => data ?? [], [data]); // Spot-only signals. const statusInput = useMemo( () => spots.map((s) => ({ pubkey: s.pubkey, identifier: s.identifier })), [spots], ); const { getStatus } = useBatchSpotStatus(statusInput); const { getCommunityPhotoCount } = useBatchSpotPhotos(statusInput); const rows: QueueRow[] = spots.map((s) => ({ id: s.id, pubkey: s.pubkey, identifier: s.identifier, kind: SKATE_SPOT_KIND, title: s.title, description: s.description, image: s.images?.[0], imageCount: s.images?.length ?? 0, hasLocation: !!s.geohash, href: `/spot/${s.pubkey}/${s.identifier}`, communityStatusReports: getStatus(s.pubkey, s.identifier)?.reporterCount ?? 0, communityPhotos: getCommunityPhotoCount(s.pubkey, s.identifier), })); return ; } function ShopsQueue() { const { data, isLoading } = useSkateShops(300, { includeHidden: true }); const rows: QueueRow[] = (data ?? []).map((s) => ({ id: s.id, pubkey: s.pubkey, identifier: s.identifier, kind: SKATE_SHOP_KIND, title: s.name, description: s.description, image: s.images?.[0], imageCount: s.images?.length ?? 0, hasLocation: !!s.geohash, href: `/shop/${s.pubkey}/${s.identifier}`, })); return ; } function EventsQueue() { const { data, isLoading } = useSkateEvents(200, { includeHidden: true }); const rows: QueueRow[] = (data ?? []).map((e) => ({ id: e.id, pubkey: e.pubkey, identifier: e.identifier, kind: CALENDAR_EVENT_KIND, title: e.title, description: e.description, image: e.image, imageCount: e.image ? 1 : 0, hasLocation: !!e.geohash, href: `/event/${e.pubkey}/${e.identifier}`, })); return ; } function ToursQueue() { const { data, isLoading } = useTours(200, { includeHidden: true }); const rows: QueueRow[] = (data ?? []).map((t) => ({ id: t.id, pubkey: t.pubkey, identifier: t.identifier, kind: SKATE_TOUR_KIND, title: t.title, description: t.description, image: t.image, imageCount: t.image ? 1 : 0, hasLocation: !!t.geohash, href: `/tour/${t.pubkey}/${t.identifier}`, })); return ; } function GearQueue() { const { data, isLoading } = useMarketplaceItems(200, { includeHidden: true }); const rows: QueueRow[] = (data ?? []).map((i) => ({ id: i.id, pubkey: i.pubkey, identifier: i.identifier, kind: MARKETPLACE_ITEM_KIND, title: i.title, description: i.description, image: i.images?.[0], imageCount: i.images?.length ?? 0, hasLocation: !!i.geohash, href: `/listing/${i.pubkey}/${i.identifier}`, })); return ; } function CrewsQueue() { const { data, isLoading } = useCrews(300, { includeHidden: true }); const rows: QueueRow[] = (data ?? []).map((c) => ({ id: c.id, pubkey: c.pubkey, identifier: c.identifier, kind: SKATE_CREW_KIND, title: c.name, description: c.bio, image: c.image, imageCount: c.image ? 1 : 0, hasLocation: !!c.geohash, href: `/crew/${c.pubkey}/${c.identifier}`, })); return ( ); } function LabQueue() { const { data, isLoading } = useLabProfiles(300, { includeHidden: true }); const rows: QueueRow[] = (data ?? []).map((p) => ({ id: p.id, pubkey: p.pubkey, identifier: p.identifier, kind: p.event?.kind ?? SKATE_COACH_KIND, title: p.title, description: p.bio, image: p.image, imageCount: p.image ? 1 : 0, hasLocation: !!p.geohash, href: `/coach/${p.pubkey}/${p.identifier}`, })); return ( ); } export default function AdminReviewPage() { const { user } = useCurrentUser(); const [kind, setKind] = useState('reports'); useSeoMeta({ title: 'Review queue — SkateSpots' }); // Admins only. Everyone else sees a 404 rather than a hint that this exists. if (!isAdminPubkey(user?.pubkey)) return ; return (
{TABS.map((t) => ( ))}
{kind === 'reports' && } {kind === 'spots' && } {kind === 'shops' && } {kind === 'events' && } {kind === 'tours' && } {kind === 'gear' && } {kind === 'crews' && } {kind === 'lab' && }

Hide publishes{' '} org.skatespots.moderation / hidden;{' '} Confirm publishes … / verified;{' '} Undo publishes … / cleared (back to neutral). All three are public, reversible labels — nothing is ever deleted from relays.

); }