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:
parent
f1ddf5fdf1
commit
3c0a91a379
7 changed files with 554 additions and 24 deletions
14
AGENTS.md
14
AGENTS.md
|
|
@ -461,6 +461,20 @@ session with **no other memory of this project**. So:
|
|||
covers a genuinely empty map (`allLocations.length === 0`) with Add-spot +
|
||||
Near-me CTAs. ⚠️ Don't key it off `mapSpots`: that is derived FROM
|
||||
`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
|
||||
**MintRadar** project. MintRadar has **no CORS**, so it's fetched same-origin
|
||||
at `/api/mints/*` — prod proxies via `public/_redirects` (`/api/mints/* →
|
||||
|
|
|
|||
|
|
@ -25,8 +25,10 @@ v1.5.4
|
|||
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
|
||||
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
|
||||
just spots.
|
||||
- The admin review queue now covers **shops, events, tours, gear, crews and Lab
|
||||
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)
|
||||
- Free to print, no sats needed: a 55 × 86 mm "DO YOU EVEN SKATE BRO?" sticker
|
||||
|
|
|
|||
223
src/components/ReportsInbox.tsx
Normal file
223
src/components/ReportsInbox.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
|
@ -10,13 +10,16 @@ import {
|
|||
import { useSkateRelays } from '@/hooks/useSkateRelays';
|
||||
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 hidden = useAdminModeration();
|
||||
|
||||
return useQuery({
|
||||
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 () => {
|
||||
const signal = AbortSignal.timeout(10000);
|
||||
|
||||
|
|
|
|||
|
|
@ -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 hidden = useAdminModeration();
|
||||
|
||||
return useQuery({
|
||||
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 () => {
|
||||
const signal = AbortSignal.timeout(15000);
|
||||
|
||||
|
|
|
|||
120
src/hooks/useRecentReports.ts
Normal file
120
src/hooks/useRecentReports.ts
Normal 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}` };
|
||||
}
|
||||
}
|
||||
|
|
@ -17,12 +17,26 @@ 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';
|
||||
|
|
@ -40,6 +54,8 @@ import {
|
|||
CALENDAR_EVENT_KIND,
|
||||
SKATE_TOUR_KIND,
|
||||
MARKETPLACE_ITEM_KIND,
|
||||
SKATE_CREW_KIND,
|
||||
SKATE_COACH_KIND,
|
||||
} from '@/lib/types';
|
||||
import NotFound from '@/pages/NotFound';
|
||||
|
||||
|
|
@ -57,14 +73,17 @@ import NotFound from '@/pages/NotFound';
|
|||
* 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 }> = [
|
||||
{ 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. */
|
||||
|
|
@ -89,9 +108,14 @@ interface QueueRow {
|
|||
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();
|
||||
|
|
@ -100,6 +124,7 @@ function QueueList({
|
|||
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);
|
||||
|
|
@ -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 (
|
||||
<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}
|
||||
|
|
@ -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" />}
|
||||
Confirm
|
||||
</Button>
|
||||
<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>
|
||||
{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"
|
||||
|
|
@ -462,9 +576,57 @@ function GearQueue() {
|
|||
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>('spots');
|
||||
const [kind, setKind] = useState<QueueKind>('reports');
|
||||
|
||||
useSeoMeta({ title: 'Review queue — SkateSpots' });
|
||||
|
||||
|
|
@ -498,11 +660,14 @@ export default function AdminReviewPage() {
|
|||
))}
|
||||
</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{' '}
|
||||
|
|
|
|||
Loading…
Reference in a new issue