SkateSpots/src/pages/AdminReviewPage.tsx
SkateSpots 3c0a91a379 feat(moderation): report inbox, bulk undo, crews + Lab in the queue
- Report INBOX: /review now defaults to a Reports tab. useRecentReports fetches
  recent NIP-56 (1984) and resolves the referenced event by id (one extra ids:
  query), so ANY report is actionable — content that isn't flagged, comments,
  profiles, anything. reportTargetMeta maps kind -> label + route for the link.
  Actions: Hide / Dismiss report (verify) / Undo / Look at it.
- Bulk undo: when a tab holds >1 verdict, a toolbar offers 'Undo all N' with an
  AlertDialog that warns each undo is its own label publish (one Amber approval
  each). Built for the 13 old hidden labels from Sep 3.
- Crews + Lab tabs: added includeHidden to useCrews/useLabProfiles. Hiding these
  removes a community / a person's listing, so their Hide goes through a
  confirmation (confirmHide/confirmHideNote on QueueList).

Verified in the browser: 8 tabs render; the inbox shows and resolves a real
report (target + reason); the Spots tab shows the bulk-undo toolbar.
tsc/eslint clean, 101 tests, smoke PASS.
2026-09-28 10:20:54 +07:00

681 lines
26 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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);
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<void>,
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 (
<div className="space-y-3">
<Skeleton className="h-24 w-full rounded-2xl" />
<Skeleton className="h-24 w-full rounded-2xl" />
</div>
);
}
if (prepared.length === 0) {
return (
<Card className="border-dashed border-white/10">
<CardContent className="space-y-3 py-12 text-center">
<CheckCircle2 className="mx-auto h-8 w-8 text-emerald-500" />
<p className="text-sm text-gray-400">
Nothing to review — nothing of this kind trips the junk signals.
</p>
</CardContent>
</Card>
);
}
const actioned = prepared.filter((r) => r.actioned);
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 (
<div className="space-y-3">
{actioned.length > 1 && (
<div className="flex flex-wrap items-center justify-between gap-2 rounded-2xl border border-white/10 bg-white/[0.02] px-4 py-3">
<p className="text-xs text-gray-400">
{actioned.length} item{actioned.length === 1 ? '' : 's'} hidden or confirmed in this tab.
</p>
<AlertDialog>
<AlertDialogTrigger asChild>
<Button size="sm" variant="outline" className="gap-1.5 border-white/20" disabled={bulkBusy}>
{bulkBusy ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : <Undo2 className="h-3.5 w-3.5" />}
Undo all {actioned.length}
</Button>
</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Undo every verdict in this tab?</AlertDialogTitle>
<AlertDialogDescription>
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.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Cancel</AlertDialogCancel>
<AlertDialogAction onClick={() => void undoAll()}>
Undo {actioned.length}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</div>
)}
{prepared.map(({ row, coord, hidden, verified, trust, reports, flags }) => (
<Card
key={row.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">
{row.image ? (
<img src={row.image} alt="" className="h-14 w-14 shrink-0 rounded-lg object-cover" />
) : (
<div className="flex h-14 w-14 shrink-0 items-center justify-center rounded-lg bg-white/10 text-xl">
🛹
</div>
)}
<div className="min-w-0 flex-1">
<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">{row.title || '(untitled)'}</span>
</p>
<p className="truncate text-xs text-gray-500">
{row.description?.trim() || 'no description'}
</p>
<div className="mt-1.5 flex flex-wrap gap-1">
{!hidden &&
!verified &&
flags.map((f) => (
<Badge
key={f}
className={
f.startsWith('reported')
? 'border-red-500/50 bg-red-500/20 text-[10px] font-black uppercase tracking-wide text-red-300'
: 'border border-amber-500/40 bg-amber-500/15 text-[10px] font-bold uppercase tracking-wide text-amber-300'
}
>
{f}
</Badge>
))}
</div>
<div className="mt-2 flex flex-wrap items-center gap-x-3 gap-y-1 text-[10px] text-gray-500">
<span className="flex items-center gap-1">
{typeof trust === 'number' && trust > 0 ? (
<BadgeCheck className="h-3 w-3 text-emerald-500" />
) : (
<ShieldQuestion className="h-3 w-3 text-gray-600" />
)}
mapper trust {typeof trust === 'number' ? trust : '—'}
</span>
{row.communityStatusReports !== undefined && (
<span className="flex items-center gap-1">
<Users className="h-3 w-3" />
{row.communityStatusReports + (row.communityPhotos ?? 0) > 0
? [
row.communityStatusReports > 0
? `${row.communityStatusReports} status report${row.communityStatusReports === 1 ? '' : 's'}`
: null,
(row.communityPhotos ?? 0) > 0
? `${row.communityPhotos} community photo${row.communityPhotos === 1 ? '' : 's'}`
: null,
]
.filter(Boolean)
.join(' · ')
: 'no community signals'}
</span>
)}
{reports.total > 0 && (
<span className="flex items-center gap-1 text-red-400">
<Flag className="h-3 w-3" />
{reports.total} report{reports.total === 1 ? '' : 's'}
</span>
)}
</div>
</div>
</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 act(
() => clearVerdict({ coord, id: row.id, reason: 'verdict retracted' }),
{ title: 'Undone', description: 'Back to neutral — not hidden, not vouched.' },
'Could not undo',
)
}
>
{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"
disabled={confirming}
onClick={() =>
void act(
() => confirmContent({ coord, id: row.id, reason: 'looks legit after review' }),
{ title: 'Confirmed', description: 'Vouched for — it drops off this queue.' },
'Could not confirm',
)
}
>
{confirming ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : <BadgeCheck className="h-3.5 w-3.5" />}
Confirm
</Button>
{confirmHide ? (
<AlertDialog>
<AlertDialogTrigger asChild>
<Button
size="sm"
variant="outline"
className="gap-1.5 border-red-500/40 text-red-400 hover:bg-red-500/10"
disabled={hiding}
>
{hiding ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : <EyeOff className="h-3.5 w-3.5" />}
Hide
</Button>
</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Hide this for everyone?</AlertDialogTitle>
<AlertDialogDescription>
{confirmHideNote ??
'This removes the item from browse and search for every SkateSpots user. It is a public, reversible label — you can Undo it from this queue.'}
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Cancel</AlertDialogCancel>
<AlertDialogAction
onClick={() =>
void act(
() => hideContent({ coord, id: row.id, reason: `auto-flagged: ${flags.join(', ')}` }),
{ title: 'Hidden', description: 'Removed from the app via a public label. Reversible.' },
'Could not hide',
)
}
>
Hide it
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
) : (
<Button
size="sm"
variant="outline"
className="gap-1.5 border-red-500/40 text-red-400 hover:bg-red-500/10"
disabled={hiding}
onClick={() =>
void act(
() => hideContent({ coord, id: row.id, reason: `auto-flagged: ${flags.join(', ')}` }),
{ title: 'Hidden', description: 'Removed from the app via a public label. Reversible.' },
'Could not hide',
)
}
>
{hiding ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : <EyeOff className="h-3.5 w-3.5" />}
Hide
</Button>
)}
{row.kind === SKATE_SPOT_KIND && (
<Button
size="sm"
variant="outline"
className="gap-1.5 border-amber-500/40 text-amber-400 hover:bg-amber-500/10"
onClick={() =>
void reportNotASpot({ eventId: row.id, pubkey: row.pubkey, title: row.title })
}
>
<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={row.href}>
<ExternalLink className="h-3.5 w-3.5" />
Look at it
</Link>
</Button>
</div>
</CardContent>
</Card>
))}
</div>
);
}
// ─── 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 <QueueList rows={rows} isLoading={isLoading} />;
}
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 <QueueList rows={rows} isLoading={isLoading} />;
}
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 <QueueList rows={rows} isLoading={isLoading} />;
}
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 <QueueList rows={rows} isLoading={isLoading} />;
}
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 <QueueList rows={rows} isLoading={isLoading} />;
}
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 (
<QueueList
rows={rows}
isLoading={isLoading}
confirmHide
confirmHideNote="Hiding a crew removes the whole community from browse for everyone, not just one pin."
/>
);
}
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 (
<QueueList
rows={rows}
isLoading={isLoading}
confirmHide
confirmHideNote="Hiding a Lab profile takes a person's listing down for everyone, not just one pin."
/>
);
}
export default function AdminReviewPage() {
const { user } = useCurrentUser();
const [kind, setKind] = useState<QueueKind>('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 <NotFound />;
return (
<div className="container mx-auto max-w-3xl px-4 py-8 pb-24 md:pb-14">
<PageHeader
icon={ShieldAlert}
title="Review queue"
accent="#ff6600"
subtitle="Spots, shops, events, tours and gear that trip the junk signals. Hide and Confirm are soft, reversible labels; reports rank first. Nothing is deleted."
/>
<div className="mb-4 flex flex-wrap gap-1.5">
{TABS.map((t) => (
<button
key={t.id}
type="button"
onClick={() => setKind(t.id)}
className={
kind === t.id
? 'rounded-full border border-[#ff6600]/60 bg-[#ff6600]/15 px-3 py-1.5 text-[11px] font-black uppercase tracking-wide text-[#ff6600]'
: 'rounded-full border border-white/12 px-3 py-1.5 text-[11px] font-bold uppercase tracking-wide text-gray-400 hover:text-white'
}
aria-pressed={kind === t.id}
>
{t.label}
</button>
))}
</div>
{kind === 'reports' && <ReportsInbox />}
{kind === 'spots' && <SpotsQueue />}
{kind === 'shops' && <ShopsQueue />}
{kind === 'events' && <EventsQueue />}
{kind === 'tours' && <ToursQueue />}
{kind === 'gear' && <GearQueue />}
{kind === 'crews' && <CrewsQueue />}
{kind === 'lab' && <LabQueue />}
<p className="mt-6 text-[11px] leading-relaxed text-gray-600">
<strong className="text-gray-500">Hide</strong> publishes{' '}
<code>org.skatespots.moderation / hidden</code>;{' '}
<strong className="text-gray-500">Confirm</strong> publishes <code>… / verified</code>;{' '}
<strong className="text-gray-500">Undo</strong> publishes <code>… / cleared</code> (back to
neutral). All three are public, reversible labels — nothing is ever deleted from relays.
</p>
</div>
);
}