SkateSpots/src/pages/AdminReviewPage.tsx

768 lines
29 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 { labProfilePath } from '@/lib/labRoutes';
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, useAdminApproveClaim } from '@/hooks/useAdminHide';
import { useAllSpotClaims, useApprovedSpotClaims } from '@/hooks/useSpotClaims';
import { useAdminModeration } from '@/hooks/useAdminModeration';
import { useNotASpotReport } from '@/hooks/useNotASpotReport';
import { useCurrentUser } from '@/hooks/useCurrentUser';
import { useToast } from '@/hooks/useToast';
import { useTranslation } from '@/contexts/language';
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' | 'claims';
const TABS: Array<{ id: QueueKind; labelKey: string }> = [
{ id: 'reports', labelKey: 'walletUi.admin.tabReports' },
{ id: 'spots', labelKey: 'walletUi.admin.tabSpots' },
{ id: 'shops', labelKey: 'walletUi.admin.tabShops' },
{ id: 'events', labelKey: 'walletUi.admin.tabEvents' },
{ id: 'tours', labelKey: 'walletUi.admin.tabTours' },
{ id: 'gear', labelKey: 'walletUi.admin.tabGear' },
{ id: 'crews', labelKey: 'walletUi.admin.tabCrews' },
{ id: 'lab', labelKey: 'walletUi.admin.tabLab' },
{ id: 'claims', labelKey: 'walletUi.admin.tabClaims' },
];
/** 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 { t } = useTranslation();
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">
{t('walletUi.admin.nothingToReview')}
</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 ? t('walletUi.admin.hideActioned') : t('walletUi.admin.showActioned', { count: 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" />}
{t('walletUi.admin.undoAll', { count: actioned.length })}
</Button>
</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>{t('walletUi.admin.undoAllTitle')}</AlertDialogTitle>
<AlertDialogDescription>
{t('walletUi.admin.undoAllDesc', { count: actioned.length })}
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>{t('common.cancel')}</AlertDialogCancel>
<AlertDialogAction onClick={() => void undoAll()}>
{t('walletUi.admin.undoCount', { count: 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" />
{t('walletUi.admin.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" />
{t('walletUi.admin.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: t('toastsUi.undone'), description: t('toastsUi.undoneDesc') },
t('toastsUi.couldNotUndo'),
)
}
>
{clearing ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : <Undo2 className="h-3.5 w-3.5" />}
{t('walletUi.admin.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: t('toastsUi.confirmed'), description: t('toastsUi.confirmedDesc') },
t('toastsUi.couldNotConfirm'),
)
}
>
{confirming ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : <BadgeCheck className="h-3.5 w-3.5" />}
{t('walletUi.admin.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" />}
{t('walletUi.admin.hide')}
</Button>
</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>{t('walletUi.admin.hideForEveryone')}</AlertDialogTitle>
<AlertDialogDescription>
{confirmHideNote ??
t('walletUi.admin.hideForEveryoneDesc')}
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>{t('common.cancel')}</AlertDialogCancel>
<AlertDialogAction
onClick={() =>
void act(
() => hideContent({ coord, id: row.id, reason: `auto-flagged: ${flags.join(', ')}` }),
{ title: t('walletUi.admin.hidden'), description: t('walletUi.admin.hiddenDesc') },
t('walletUi.admin.couldNotHide'),
)
}
>
{t('walletUi.admin.hideIt')}
</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: t('walletUi.admin.hidden'), description: t('walletUi.admin.hiddenDesc') },
t('walletUi.admin.couldNotHide'),
)
}
>
{hiding ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : <EyeOff className="h-3.5 w-3.5" />}
{t('walletUi.admin.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" />
{t('walletUi.admin.notASpot')}
</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" />
{t('walletUi.admin.lookAtIt')}
</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: labProfilePath(p.profileType, 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."
/>
);
}
/**
* Private-spot ownership claims awaiting a moderator. Approving publishes the
* `claim-approved` label tagged with the claimant, who then finishes the claim.
*/
function SpotClaimsQueue() {
const { t } = useTranslation();
const { data: claims = [] } = useAllSpotClaims();
const { data: approvals } = useApprovedSpotClaims();
const { approveClaim, isPending } = useAdminApproveClaim();
const { toast } = useToast();
const pending = claims.filter((c) => !approvals?.get(c.coord)?.has(c.claimant.toLowerCase()));
if (pending.length === 0) {
return <p className="text-sm text-gray-500">{t('walletUi.admin.nothingToReview')}</p>;
}
const approve = async (coord: string, claimant: string) => {
try {
await approveClaim({ coord, subject: claimant, reason: 'private-spot-claim' });
toast({ title: t('spotUi.claim.adminApproved') });
} catch {
toast({ title: t('spotUi.claim.adminApproveFailed'), variant: 'destructive' });
}
};
return (
<Card className="mb-4 bg-purple-500/10 border-2 border-purple-500/30">
<CardContent className="p-4">
<h3 className="mb-3 text-sm font-bold uppercase text-purple-300">
{t('spotUi.claim.adminTitle')} ({pending.length})
</h3>
<div className="space-y-3">
{pending.map((c) => (
<div key={`${c.coord}:${c.claimant}`} className="flex items-center justify-between gap-3 text-xs">
<div className="min-w-0">
<Link to={`/spot/${c.ownerPubkey}/${c.identifier}`} className="block truncate text-[#ccff00] hover:underline">
{c.identifier}
</Link>
<span className="font-mono text-gray-500">{c.nip05 || `${c.claimant.slice(0, 12)}…`}</span>
</div>
<Button
size="sm"
className="h-7 shrink-0 gap-1.5 bg-purple-500 text-white hover:bg-purple-600"
disabled={isPending}
onClick={() => approve(c.coord, c.claimant)}
>
{isPending ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : <BadgeCheck className="h-3.5 w-3.5" />}
{t('spotUi.claim.adminApprove')}
</Button>
</div>
))}
</div>
</CardContent>
</Card>
);
}
export default function AdminReviewPage() { const { t } = useTranslation();
const { user } = useCurrentUser();
const [kind, setKind] = useState<QueueKind>('reports');
useSeoMeta({ title: t('seoUi.adminReviewTitle') });
// 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={t('walletUi.admin.title')}
accent="#ff6600"
subtitle={t('walletUi.admin.subtitle')}
/>
<div className="mb-4 flex flex-wrap gap-1.5">
{TABS.map((tab) => (
<button
key={tab.id}
type="button"
onClick={() => setKind(tab.id)}
className={
kind === tab.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 === tab.id}
>
{t(tab.labelKey)}
</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 />}
{kind === 'claims' && <SpotClaimsQueue />}
<p className="mt-6 text-[11px] leading-relaxed text-gray-600">
{t('legacyMiscUi.modHelp')}
</p>
</div>
);
}