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.
This commit is contained in:
SkateSpots 2026-09-28 10:20:54 +07:00
parent f1ddf5fdf1
commit 3c0a91a379
7 changed files with 554 additions and 24 deletions

View file

@ -461,6 +461,20 @@ session with **no other memory of this project**. So:
covers a genuinely empty map (`allLocations.length === 0`) with Add-spot + covers a genuinely empty map (`allLocations.length === 0`) with Add-spot +
Near-me CTAs. ⚠️ Don't key it off `mapSpots`: that is derived FROM Near-me CTAs. ⚠️ Don't key it off `mapSpots`: that is derived FROM
`filteredSpots`, so it is 0 whenever filters hide everything. `filteredSpots`, so it is 0 whenever filters hide everything.
- **Report inbox + bulk undo + crews/Lab moderation (2026-09-28):** finishing the
moderation gaps. `/review` now defaults to a **Reports** tab: `useRecentReports`
fetches recent NIP-56 (1984) reports and **resolves the referenced event by id**
(one extra `ids:` query) so ANY report is actionable — including content that
isn't flagged and comments/profiles that never appear in the heuristic queue.
`reportTargetMeta(kind)` maps a kind to its label + route for the "Look at it"
link. The queue also has **crews** and **lab** tabs (added `includeHidden` to
`useCrews`/`useLabProfiles`); because hiding those removes a community or a
person's listing rather than one pin, their Hide button goes through an
`AlertDialog` confirmation (`confirmHide`/`confirmHideNote` on `QueueList`).
When a tab holds more than one verdict, a toolbar offers **Undo all N** (also
AlertDialog-gated, and it warns that each undo is a separate label publish —
one Amber approval each). Verified in the browser: 8 tabs render, the inbox
shows and resolves a real report, and the Spots tab shows the bulk-undo toolbar.
- **Mints directory**: `/mints` page lists Cashu mints from the community - **Mints directory**: `/mints` page lists Cashu mints from the community
**MintRadar** project. MintRadar has **no CORS**, so it's fetched same-origin **MintRadar** project. MintRadar has **no CORS**, so it's fetched same-origin
at `/api/mints/*` — prod proxies via `public/_redirects` (`/api/mints/* → at `/api/mints/*` — prod proxies via `public/_redirects` (`/api/mints/* →

View file

@ -25,8 +25,10 @@ v1.5.4
now? Publish anyway — the spot just stays marked Unverified until one lands. now? Publish anyway — the spot just stays marked Unverified until one lands.
- **Empty map? It now says so and offers to fix it**: "No spots mapped yet — be - **Empty map? It now says so and offers to fix it**: "No spots mapped yet — be
the first to map your park", with a one-tap Add spot. the first to map your park", with a one-tap Add spot.
- The admin review queue now covers **shops, events, tours and gear** too, not - The admin review queue now covers **shops, events, tours, gear, crews and Lab
just spots. profiles** too, not just spots — and has a **report inbox** (every report, with
the reported item resolved, not just whatever's already flagged) plus **bulk
undo** for clearing old verdicts.
🛍 App Shop — a new section (empty on purpose, with free printables) 🛍 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 - Free to print, no sats needed: a 55 × 86 mm "DO YOU EVEN SKATE BRO?" sticker

View file

@ -0,0 +1,223 @@
import { Link } from 'react-router-dom';
import {
Flag,
EyeOff,
BadgeCheck,
Undo2,
Loader2,
ExternalLink,
CheckCircle2,
ShieldQuestion,
} from 'lucide-react';
import { Card, CardContent } from '@/components/ui/card';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import { Skeleton } from '@/components/ui/skeleton';
import { useAuthor } from '@/hooks/useAuthor';
import { useRecentReports, reportTargetMeta, type RecentReport } from '@/hooks/useRecentReports';
import { useAdminHide, useAdminVerify, useAdminClear } from '@/hooks/useAdminHide';
import { useAdminModeration } from '@/hooks/useAdminModeration';
import { useToast } from '@/hooks/useToast';
import { genUserName } from '@/lib/genUserName';
/** "3 h ago" — small enough to keep local. */
function ago(unixSeconds: number): string {
const mins = Math.round((Date.now() / 1000 - unixSeconds) / 60);
if (mins < 1) return 'just now';
if (mins < 60) return `${mins} min ago`;
const hours = Math.round(mins / 60);
if (hours < 24) return `${hours} h ago`;
return `${Math.round(hours / 24)} d ago`;
}
function Reporter({ pubkey }: { pubkey: string }) {
const author = useAuthor(pubkey);
const name = author.data?.metadata?.name ?? author.data?.metadata?.display_name ?? genUserName(pubkey);
return <span className="text-gray-400">{name}</span>;
}
function ReportRow({ report }: { report: RecentReport }) {
const { hideContent, isPending: hiding } = useAdminHide();
const { confirmContent, isPending: confirming } = useAdminVerify();
const { clearVerdict, isPending: clearing } = useAdminClear();
const moderation = useAdminModeration();
const { toast } = useToast();
const { target } = report;
const meta = target ? reportTargetMeta(target.kind) : null;
const coord = target?.coord;
const hidden = !!target && moderation.has({ coord, id: target.id });
const verified = !!target && moderation.isVerified({ coord, id: target.id });
const busy = hiding || confirming || clearing;
const act = async (fn: () => Promise<void>, title: string) => {
try {
await fn();
toast({ title });
} catch (e) {
toast({
title: 'Could not complete that',
description: e instanceof Error ? e.message : 'Try again.',
variant: 'destructive',
});
}
};
return (
<Card className="border-2 border-white/10 bg-white/[0.03]">
<CardContent className="space-y-3 p-4">
<div className="flex flex-wrap items-center gap-2">
<Badge className="border border-red-500/50 bg-red-500/20 text-[10px] font-black uppercase tracking-wide text-red-300">
<Flag className="mr-1 h-3 w-3" />
{report.reason}
</Badge>
{hidden && (
<Badge className="border border-red-500/40 bg-red-500/10 text-[10px] font-bold uppercase tracking-wide text-red-300">
<EyeOff className="mr-1 h-3 w-3" />
Hidden
</Badge>
)}
{verified && (
<Badge className="border border-emerald-500/40 bg-emerald-500/10 text-[10px] font-bold uppercase tracking-wide text-emerald-300">
<BadgeCheck className="mr-1 h-3 w-3" />
Confirmed
</Badge>
)}
<span className="text-[11px] text-gray-500">
reported by <Reporter pubkey={report.reporter} /> · {ago(report.createdAt)}
</span>
</div>
{/* What was reported */}
<div className="rounded-xl border border-white/8 bg-black/30 p-3">
{target ? (
<>
<p className="text-sm font-bold text-white">
{target.title || '(untitled)'}
{meta?.label && (
<span className="ml-2 text-[10px] font-bold uppercase tracking-widest text-gray-500">
{meta.label}
</span>
)}
</p>
{report.content && (
<p className="mt-1 text-xs leading-relaxed text-gray-400">{report.content}</p>
)}
</>
) : (
<p className="flex items-center gap-2 text-xs text-gray-500">
<ShieldQuestion className="h-3.5 w-3.5" />
Couldn't resolve the reported event (it may have been deleted, or the report targets a
profile rather than a post).
{report.content && <span className="text-gray-400">“{report.content}”</span>}
</p>
)}
</div>
<div className="flex flex-wrap gap-2">
{target && !hidden && !verified && (
<Button
size="sm"
variant="outline"
className="gap-1.5 border-red-500/40 text-red-400 hover:bg-red-500/10"
disabled={busy}
onClick={() =>
void act(
() =>
hideContent({
coord,
id: target.id,
reason: `reported (${report.reason}) by ${report.reporter}`,
}),
'Hidden',
)
}
>
{hiding ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : <EyeOff className="h-3.5 w-3.5" />}
Hide
</Button>
)}
{target && !hidden && !verified && (
<Button
size="sm"
className="gap-1.5 bg-emerald-500 font-bold text-black hover:bg-emerald-400"
disabled={busy}
onClick={() =>
void act(
() => confirmContent({ coord, id: target.id, reason: 'report reviewed — no action' }),
'Confirmed',
)
}
>
{confirming ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : <BadgeCheck className="h-3.5 w-3.5" />}
Dismiss report
</Button>
)}
{target && (hidden || verified) && (
<Button
size="sm"
variant="outline"
className="gap-1.5 border-white/20 text-gray-200 hover:bg-white/10"
disabled={busy}
onClick={() =>
void act(
() => clearVerdict({ coord, id: target.id, reason: 'verdict retracted' }),
'Undone',
)
}
>
{clearing ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : <Undo2 className="h-3.5 w-3.5" />}
Undo
</Button>
)}
{target && meta?.path && target.identifier && (
<Button asChild size="sm" variant="ghost" className="gap-1.5 text-gray-400">
<Link to={`${meta.path}/${target.pubkey}/${target.identifier}`}>
<ExternalLink className="h-3.5 w-3.5" />
Look at it
</Link>
</Button>
)}
</div>
</CardContent>
</Card>
);
}
/**
* The report inbox tab. Independent of the heuristic queue: anything anyone
* reported shows up here, resolvable or not.
*/
export function ReportsInbox() {
const { data: reports, isLoading } = useRecentReports(200);
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 (!reports || reports.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">
No reports yet. Either everything is fine, or nobody's found the button.
</p>
</CardContent>
</Card>
);
}
return (
<div className="space-y-3">
{reports.map((r) => (
<ReportRow key={r.id} report={r} />
))}
</div>
);
}

View file

@ -10,13 +10,16 @@ import {
import { useSkateRelays } from '@/hooks/useSkateRelays'; import { useSkateRelays } from '@/hooks/useSkateRelays';
import { useAdminModeration, filterAdminHidden } from '@/hooks/useAdminModeration'; import { useAdminModeration, filterAdminHidden } from '@/hooks/useAdminModeration';
export function useCrews(limit = 200) { export function useCrews(limit = 200, options: { includeHidden?: boolean } = {}) {
const { includeHidden = false } = options;
const { querySkateContent, getQueryKeySuffix } = useSkateRelays(); const { querySkateContent, getQueryKeySuffix } = useSkateRelays();
const hidden = useAdminModeration(); const hidden = useAdminModeration();
return useQuery({ return useQuery({
queryKey: ['skate-crews', limit, getQueryKeySuffix()], queryKey: ['skate-crews', limit, getQueryKeySuffix()],
select: (crews: SkateCrew[]) => filterAdminHidden(crews, SKATE_CREW_KIND, hidden), // `includeHidden`: admin tooling only (review queue + undo).
select: (crews: SkateCrew[]) =>
includeHidden ? crews : filterAdminHidden(crews, SKATE_CREW_KIND, hidden),
queryFn: async () => { queryFn: async () => {
const signal = AbortSignal.timeout(10000); const signal = AbortSignal.timeout(10000);

View file

@ -99,13 +99,16 @@ export function parseLabProfile(event: NostrEvent): LabProfile | null {
} }
} }
export function useLabProfiles(limit = 100) { export function useLabProfiles(limit = 100, options: { includeHidden?: boolean } = {}) {
const { includeHidden = false } = options;
const { querySkateContent, getQueryKeySuffix } = useSkateRelays(); const { querySkateContent, getQueryKeySuffix } = useSkateRelays();
const hidden = useAdminModeration(); const hidden = useAdminModeration();
return useQuery({ return useQuery({
queryKey: ['lab-profiles', limit, getQueryKeySuffix()], queryKey: ['lab-profiles', limit, getQueryKeySuffix()],
select: (profiles: LabProfile[]) => filterAdminHiddenMixed(profiles, hidden), // `includeHidden`: admin tooling only (review queue + undo).
select: (profiles: LabProfile[]) =>
includeHidden ? profiles : filterAdminHiddenMixed(profiles, hidden),
queryFn: async () => { queryFn: async () => {
const signal = AbortSignal.timeout(15000); const signal = AbortSignal.timeout(15000);

View file

@ -0,0 +1,120 @@
import { useQuery } from '@tanstack/react-query';
import { useSkateRelays } from '@/hooks/useSkateRelays';
import type { NostrEvent } from '@nostrify/nostrify';
/** NIP-56 report kind. */
export const REPORT_KIND = 1984;
export interface ReportTarget {
id: string;
kind: number;
pubkey: string;
identifier?: string;
/** `kind:pubkey:d` when the target is addressable (what the labels use). */
coord?: string;
title?: string;
}
export interface RecentReport {
id: string;
reporter: string;
createdAt: number;
/** NIP-56 reason from the `e` tag: spam | illegal | impersonation | other … */
reason: string;
content: string;
/** The reported event, when we could resolve it. */
target?: ReportTarget;
}
/**
* The report inbox: recent NIP-56 reports, with their targets RESOLVED.
*
* WHY: reports were only ever visible for items that already happened to be in
* the review queue (and only as a count). A report about a comment, a profile, a
* crew or anything not currently flagged had nowhere to land. This reads recent
* reports and resolves the referenced event so a human can act on any of them —
* no heuristics involved.
*/
export function useRecentReports(limit = 200) {
const { querySkateContent, getQueryKeySuffix } = useSkateRelays();
return useQuery({
queryKey: ['recent-reports', limit, getQueryKeySuffix()],
staleTime: 60_000,
queryFn: async (c): Promise<RecentReport[]> => {
const signal = AbortSignal.any([c.signal, AbortSignal.timeout(9000)]);
const reports = await querySkateContent([{ kinds: [REPORT_KIND], limit }], { signal });
if (reports.length === 0) return [];
// Resolve the reported events: one extra query for every referenced id.
const ids = [
...new Set(
reports
.flatMap((r) => r.tags.filter(([n]) => n === 'e').map(([, v]) => v))
.filter(Boolean),
),
].slice(0, 200);
const targets = new Map<string, NostrEvent>();
if (ids.length > 0) {
try {
const events = await querySkateContent([{ ids, limit: ids.length }], { signal });
for (const ev of events) targets.set(ev.id, ev);
} catch {
// Resolution is best-effort — an unresolvable report is still shown.
}
}
return reports
.map((r): RecentReport => {
const eTag = r.tags.find(([n]) => n === 'e');
const targetEvent = eTag?.[1] ? targets.get(eTag[1]) : undefined;
const d = targetEvent?.tags.find(([n]) => n === 'd')?.[1];
const title = targetEvent?.tags.find(([n]) => n === 'title' || n === 'name')?.[1];
return {
id: r.id,
reporter: r.pubkey,
createdAt: r.created_at,
reason: eTag?.[2] || 'other',
content: r.content,
target: targetEvent
? {
id: targetEvent.id,
kind: targetEvent.kind,
pubkey: targetEvent.pubkey,
identifier: d,
coord: d ? `${targetEvent.kind}:${targetEvent.pubkey}:${d}` : undefined,
title,
}
: undefined,
};
})
.sort((a, b) => b.createdAt - a.createdAt);
},
});
}
/** Human label + route for a reported kind. */
export function reportTargetMeta(kind: number): { label: string; path?: string } {
switch (kind) {
case 30818:
return { label: 'spot', path: '/spot' };
case 30017:
return { label: 'shop', path: '/shop' };
case 31923:
case 31925:
return { label: 'event', path: '/event' };
case 30823:
return { label: 'tour', path: '/tour' };
case 30402:
return { label: 'listing', path: '/listing' };
case 35006:
return { label: 'crew', path: '/crew' };
case 35000:
return { label: 'skater profile' };
default:
if (kind >= 35001 && kind <= 35014) return { label: 'Lab profile' };
return { label: `kind ${kind}` };
}
}

View file

@ -17,12 +17,26 @@ import { PageHeader } from '@/components/PageHeader';
import { Card, CardContent } from '@/components/ui/card'; import { Card, CardContent } from '@/components/ui/card';
import { Badge } from '@/components/ui/badge'; import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button'; 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 { Skeleton } from '@/components/ui/skeleton';
import { useSkateSpots } from '@/hooks/useSkateSpots'; import { useSkateSpots } from '@/hooks/useSkateSpots';
import { useSkateShops } from '@/hooks/useSkateShops'; import { useSkateShops } from '@/hooks/useSkateShops';
import { useSkateEvents } from '@/hooks/useSkateEvents'; import { useSkateEvents } from '@/hooks/useSkateEvents';
import { useTours } from '@/hooks/useTours'; import { useTours } from '@/hooks/useTours';
import { useMarketplaceItems } from '@/hooks/useMarketplace'; import { useMarketplaceItems } from '@/hooks/useMarketplace';
import { useCrews } from '@/hooks/useCrews';
import { useLabProfiles } from '@/hooks/useLabProfiles';
import { useBatchTrustScores } from '@/hooks/useBatchTrustScores'; import { useBatchTrustScores } from '@/hooks/useBatchTrustScores';
import { useBatchSpotStatus } from '@/hooks/useSpotStatus'; import { useBatchSpotStatus } from '@/hooks/useSpotStatus';
import { useBatchSpotPhotos } from '@/hooks/useSpotContributions'; import { useBatchSpotPhotos } from '@/hooks/useSpotContributions';
@ -40,6 +54,8 @@ import {
CALENDAR_EVENT_KIND, CALENDAR_EVENT_KIND,
SKATE_TOUR_KIND, SKATE_TOUR_KIND,
MARKETPLACE_ITEM_KIND, MARKETPLACE_ITEM_KIND,
SKATE_CREW_KIND,
SKATE_COACH_KIND,
} from '@/lib/types'; } from '@/lib/types';
import NotFound from '@/pages/NotFound'; import NotFound from '@/pages/NotFound';
@ -57,14 +73,17 @@ import NotFound from '@/pages/NotFound';
* hypotheses — a human decides. * hypotheses — a human decides.
*/ */
type QueueKind = 'spots' | 'shops' | 'events' | 'tours' | 'gear'; type QueueKind = 'reports' | 'spots' | 'shops' | 'events' | 'tours' | 'gear' | 'crews' | 'lab';
const TABS: Array<{ id: QueueKind; label: string }> = [ const TABS: Array<{ id: QueueKind; label: string }> = [
{ id: 'reports', label: 'Reports' },
{ id: 'spots', label: 'Spots' }, { id: 'spots', label: 'Spots' },
{ id: 'shops', label: 'Shops' }, { id: 'shops', label: 'Shops' },
{ id: 'events', label: 'Events' }, { id: 'events', label: 'Events' },
{ id: 'tours', label: 'Tours' }, { id: 'tours', label: 'Tours' },
{ id: 'gear', label: 'Gear' }, { id: 'gear', label: 'Gear' },
{ id: 'crews', label: 'Crews' },
{ id: 'lab', label: 'Lab' },
]; ];
/** One reviewable item, flattened from whatever kind it came from. */ /** One reviewable item, flattened from whatever kind it came from. */
@ -89,9 +108,14 @@ interface QueueRow {
function QueueList({ function QueueList({
rows, rows,
isLoading, isLoading,
/** Hiding these removes a community/identity, not just a pin — confirm first. */
confirmHide = false,
confirmHideNote,
}: { }: {
rows: QueueRow[]; rows: QueueRow[];
isLoading: boolean; isLoading: boolean;
confirmHide?: boolean;
confirmHideNote?: string;
}) { }) {
const { hideContent, isPending: hiding } = useAdminHide(); const { hideContent, isPending: hiding } = useAdminHide();
const { confirmContent, isPending: confirming } = useAdminVerify(); const { confirmContent, isPending: confirming } = useAdminVerify();
@ -100,6 +124,7 @@ function QueueList({
const { reportNotASpot } = useNotASpotReport(); const { reportNotASpot } = useNotASpotReport();
const { toast } = useToast(); const { toast } = useToast();
const [bulkBusy, setBulkBusy] = useState(false);
const mappers = useMemo(() => [...new Set(rows.map((r) => r.pubkey))], [rows]); const mappers = useMemo(() => [...new Set(rows.map((r) => r.pubkey))], [rows]);
const { data: trustScores } = useBatchTrustScores(mappers); const { data: trustScores } = useBatchTrustScores(mappers);
const { getCounts } = useSpotReports(rows); const { getCounts } = useSpotReports(rows);
@ -188,8 +213,58 @@ function QueueList({
); );
} }
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 ( return (
<div className="space-y-3"> <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 }) => ( {prepared.map(({ row, coord, hidden, verified, trust, reports, flags }) => (
<Card <Card
key={row.id} key={row.id}
@ -316,22 +391,61 @@ function QueueList({
{confirming ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : <BadgeCheck className="h-3.5 w-3.5" />} {confirming ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : <BadgeCheck className="h-3.5 w-3.5" />}
Confirm Confirm
</Button> </Button>
<Button {confirmHide ? (
size="sm" <AlertDialog>
variant="outline" <AlertDialogTrigger asChild>
className="gap-1.5 border-red-500/40 text-red-400 hover:bg-red-500/10" <Button
disabled={hiding} size="sm"
onClick={() => variant="outline"
void act( className="gap-1.5 border-red-500/40 text-red-400 hover:bg-red-500/10"
() => hideContent({ coord, id: row.id, reason: `auto-flagged: ${flags.join(', ')}` }), disabled={hiding}
{ 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>
> </AlertDialogTrigger>
{hiding ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : <EyeOff className="h-3.5 w-3.5" />} <AlertDialogContent>
Hide <AlertDialogHeader>
</Button> <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 && ( {row.kind === SKATE_SPOT_KIND && (
<Button <Button
size="sm" size="sm"
@ -462,9 +576,57 @@ function GearQueue() {
return <QueueList rows={rows} isLoading={isLoading} />; 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() { export default function AdminReviewPage() {
const { user } = useCurrentUser(); const { user } = useCurrentUser();
const [kind, setKind] = useState<QueueKind>('spots'); const [kind, setKind] = useState<QueueKind>('reports');
useSeoMeta({ title: 'Review queue — SkateSpots' }); useSeoMeta({ title: 'Review queue — SkateSpots' });
@ -498,11 +660,14 @@ export default function AdminReviewPage() {
))} ))}
</div> </div>
{kind === 'reports' && <ReportsInbox />}
{kind === 'spots' && <SpotsQueue />} {kind === 'spots' && <SpotsQueue />}
{kind === 'shops' && <ShopsQueue />} {kind === 'shops' && <ShopsQueue />}
{kind === 'events' && <EventsQueue />} {kind === 'events' && <EventsQueue />}
{kind === 'tours' && <ToursQueue />} {kind === 'tours' && <ToursQueue />}
{kind === 'gear' && <GearQueue />} {kind === 'gear' && <GearQueue />}
{kind === 'crews' && <CrewsQueue />}
{kind === 'lab' && <LabQueue />}
<p className="mt-6 text-[11px] leading-relaxed text-gray-600"> <p className="mt-6 text-[11px] leading-relaxed text-gray-600">
<strong className="text-gray-500">Hide</strong> publishes{' '} <strong className="text-gray-500">Hide</strong> publishes{' '}