- Review queue: past verdicts (e.g. the 13 hidden labels from an Aug/Sep cleanup
sweep) are correct but noisy — they buried the items that still need a
decision. They are now COLLAPSED behind a 'Show actioned (N)' toggle, with the
verdicts untouched (still hidden) and 'Undo all N' still available. Verified:
the Spots tab renders 0 hidden cards by default with the toggle + Undo all.
- public/images/screenshots/{map,spot,marketplace}.png (1280x800, quantised) for
the app-listing gallery.
710 lines
27 KiB
TypeScript
710 lines
27 KiB
TypeScript
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<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 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 (
|
||
<div className="space-y-3">
|
||
{actioned.length > 0 && (
|
||
<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} already actioned in this tab (hidden or confirmed).
|
||
</p>
|
||
<div className="flex flex-wrap items-center gap-2">
|
||
<Button
|
||
size="sm"
|
||
variant="ghost"
|
||
className="gap-1.5 text-gray-400 hover:text-white"
|
||
onClick={() => setShowActioned((v) => !v)}
|
||
>
|
||
<EyeOff className="h-3.5 w-3.5" />
|
||
{showActioned ? 'Hide actioned' : `Show actioned (${actioned.length})`}
|
||
</Button>
|
||
{actioned.length > 1 && (
|
||
<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>
|
||
</div>
|
||
)}
|
||
{visible.length === 0 && (
|
||
<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 waiting on you. {actioned.length} earlier verdict
|
||
{actioned.length === 1 ? '' : 's'} {actioned.length === 1 ? 'is' : 'are'} hidden above.
|
||
</p>
|
||
</CardContent>
|
||
</Card>
|
||
)}
|
||
{visible.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>
|
||
);
|
||
}
|