feat(quality): review queue for every kind, soft photo gate, empty-map nudge

(a) Review queue covers spots/shops/events/tours/gear: added { includeHidden }
    (admin-tooling only) to useSkateShops/useSkateEvents/useTours/
    useMarketplaceItems; generalised the flags to contentFlags() in spotQuality.ts
    (trust/status/photo signals stay spot-only); one component per kind so only the
    OPEN tab queries. Reports + trust are kind-agnostic; the label mechanism never
    cared about kind.
(b) Photo gate softened: removed the hard block (it punished an honest mapper with
    no photo). Publish without a photo is allowed; the spot stays Unverified, the
    success toast says so, and the queue's 'no photo' flag lets a human look.
(c) Empty-map nudge: MapPage now distinguishes a FILTER empty ('No spots match')
    from a genuinely EMPTY map ('No spots mapped yet — be the first to map your
    park', with Add-spot + Near-me). Note: must key off allLocations, not mapSpots
    (which derives from filteredSpots).

Verified in the browser: the empty-map nudge renders with its CTAs when no spots
load; the review queue renders 15 spot rows and the tabs switch (Gear shows its
own empty state). tsc/eslint clean, 101 tests, smoke PASS.
This commit is contained in:
SkateSpots 2026-09-28 09:59:09 +07:00
parent e549aeee3a
commit f1ddf5fdf1
10 changed files with 547 additions and 303 deletions

View file

@ -438,6 +438,29 @@ session with **no other memory of this project**. So:
404s for the person who made it. `SpotDetailPage` shows an author-only banner
("Hidden by a moderator" / "Verified by SkateSpots"). The verdicts are public
labels, so there is nothing secret being revealed.
- **Review queue covers every content kind (2026-09-28):** `/review` now has tabs
for **spots / shops / events / tours / gear**. The hide/verify labels were
always kind-agnostic (`a`/`e` tags), so the work was: (1) add
`{ includeHidden }` (admin-tooling only) to `useSkateShops`, `useSkateEvents`,
`useTours` and `useMarketplaceItems` — same reason as spots; (2) generalise the
flags with **`contentFlags()`** in `src/lib/spotQuality.ts` (spot-specific
signals — trust/status/photos — stay in `spotFlags`); (3) one small component
per kind so only the OPEN tab queries (five hooks in one component would fire
five relay queries on every visit). Reports (`useSpotReports`) and trust
(`useBatchTrustScores`) are kind-agnostic; status/photo signals are spot-only.
Status/go-lab/crew queues are still out — profiles are less spam-prone and
hiding a crew hides a community.
- **Photo is recommended, not required (2026-09-28):** the hard block in
`CreateSpotPage` was removed — it punished an honest mapper with no photo on
hand. Publishing with no photo is allowed; the spot stays **Unverified**
(`SpotDetailPage` badge), the success toast says so, and the review queue's
'no photo' flag lets a human look later. The pre-submit checklist still nudges.
- **Empty-map nudge (2026-09-28):** `MapPage` distinguishes the two empties — the
existing "No spots match" card is for *filters* (`filteredSpots.length === 0 &&
allLocations.length > 0`) and a new "No spots mapped yet — be the first" card
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.
- **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/* →

View file

@ -21,6 +21,12 @@ v1.5.4
straight to it instead of letting you create a duplicate.
- **If a moderator hides or vouches for your spot, you're told** — right there on
the spot page (your own spots stay visible to you either way).
- **A photo is now strongly recommended, not required.** No picture on hand right
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.
🛍 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

View file

@ -7,13 +7,16 @@ const STALE_TIME = 5 * 60 * 1000; // 5 min
const GC_TIME = 30 * 60 * 1000; // 30 min
const TIMEOUT = 8000;
export function useMarketplaceItems(limit = 100) {
export function useMarketplaceItems(limit = 100, options: { includeHidden?: boolean } = {}) {
const { includeHidden = false } = options;
const { querySkateContent, getQueryKeySuffix } = useSkateRelays();
const hidden = useAdminModeration();
return useQuery({
queryKey: ['marketplace-items', limit, getQueryKeySuffix()],
select: (items: MarketplaceItem[]) => filterAdminHidden(items, MARKETPLACE_ITEM_KIND, hidden),
// `includeHidden`: admin tooling only (review queue + undo).
select: (items: MarketplaceItem[]) =>
includeHidden ? items : filterAdminHidden(items, MARKETPLACE_ITEM_KIND, hidden),
queryFn: async () => {
const signal = AbortSignal.timeout(TIMEOUT);

View file

@ -3,13 +3,16 @@ import { CALENDAR_EVENT_KIND, parseSkateEvent, isFeaturedActive, type SkateEvent
import { useSkateRelays } from '@/hooks/useSkateRelays';
import { useAdminModeration, filterAdminHidden } from '@/hooks/useAdminModeration';
export function useSkateEvents(limit = 50) {
export function useSkateEvents(limit = 50, options: { includeHidden?: boolean } = {}) {
const { includeHidden = false } = options;
const { querySkateContent, getQueryKeySuffix } = useSkateRelays();
const hidden = useAdminModeration();
return useQuery({
queryKey: ['skate-events', limit, getQueryKeySuffix()],
select: (events: SkateEvent[]) => filterAdminHidden(events, CALENDAR_EVENT_KIND, hidden),
// `includeHidden`: admin tooling only (review queue + undo).
select: (events: SkateEvent[]) =>
includeHidden ? events : filterAdminHidden(events, CALENDAR_EVENT_KIND, hidden),
queryFn: async () => {
const signal = AbortSignal.timeout(15000);

View file

@ -6,13 +6,17 @@ import { useAdminModeration, filterAdminHidden } from '@/hooks/useAdminModeratio
/**
* Hook to fetch skate shops using the skate relay logic
*/
export function useSkateShops(limit: number = 100) {
export function useSkateShops(limit: number = 100, options: { includeHidden?: boolean } = {}) {
const { includeHidden = false } = options;
const { querySkateContent, getQueryKeySuffix } = useSkateRelays();
const hidden = useAdminModeration();
return useQuery<SkateShop[], Error, SkateShop[]>({
queryKey: ['skate-shops', limit, getQueryKeySuffix()],
select: (shops: SkateShop[]) => filterAdminHidden(shops, SKATE_SHOP_KIND, hidden),
// `includeHidden` is for ADMIN TOOLING only (the review queue) so a hidden
// item stays visible and its verdict can be undone.
select: (shops: SkateShop[]) =>
includeHidden ? shops : filterAdminHidden(shops, SKATE_SHOP_KIND, hidden),
queryFn: async () => {
const signal = AbortSignal.timeout(10000);

View file

@ -15,13 +15,16 @@ const STALE_TIME = 5 * 60 * 1000; // 5 min
const GC_TIME = 30 * 60 * 1000; // 30 min
const TIMEOUT = 8000;
export function useTours(limit = 100) {
export function useTours(limit = 100, options: { includeHidden?: boolean } = {}) {
const { includeHidden = false } = options;
const { querySkateContent, getQueryKeySuffix } = useSkateRelays();
const hidden = useAdminModeration();
return useQuery({
queryKey: ['skate-tours', limit, getQueryKeySuffix()],
select: (tours: SkateTour[]) => filterAdminHidden(tours, SKATE_TOUR_KIND, hidden),
// `includeHidden`: admin tooling only (review queue + undo).
select: (tours: SkateTour[]) =>
includeHidden ? tours : filterAdminHidden(tours, SKATE_TOUR_KIND, hidden),
queryFn: async () => {
const signal = AbortSignal.timeout(TIMEOUT);

View file

@ -12,6 +12,34 @@ import type { SkateSpot } from '@/lib/types';
const SUSPICIOUS_TITLE =
/(^|\b)(test|testing|asdf|asd|qwerty|foo|bar|untitled|demo|sample|example|secret|airport|my (house|home|flat|apartment))(\b|$)/i;
/** The minimum any reviewable entity can tell us (any kind). */
export interface ContentQualityInput {
title?: string;
description?: string;
/** How many pictures the entity carries (0 = none). */
imageCount?: number;
/** Whether it has a usable location (spots/shops/events/tours). */
hasLocation?: boolean;
}
/**
* Kind-agnostic junk signals, so the review queue can cover shops, events,
* tours and listings without a copy of the logic per kind.
*/
export function contentFlags(input: ContentQualityInput): string[] {
const flags: string[] = [];
const title = (input.title ?? '').trim();
const description = (input.description ?? '').trim();
if (SUSPICIOUS_TITLE.test(title)) flags.push('suspicious name');
if (title.length <= 3) flags.push('name too short');
if ((input.imageCount ?? 0) === 0) flags.push('no photo');
if (description.length < 10) flags.push('no description');
if (input.hasLocation === false) flags.push('no location');
return flags;
}
export interface SpotQualitySignals {
/** Mapper's web-of-trust score (0–100), when known. */
mapperTrust?: number;
@ -23,15 +51,12 @@ export interface SpotQualitySignals {
/** Every flag that applies, as short human-readable labels. */
export function spotFlags(spot: SkateSpot, extra: SpotQualitySignals = {}): string[] {
const flags: string[] = [];
const title = (spot.title ?? '').trim();
const description = (spot.description ?? '').trim();
if (SUSPICIOUS_TITLE.test(title)) flags.push('suspicious name');
if (title.length <= 3) flags.push('name too short');
if (!spot.images || spot.images.length === 0) flags.push('no photo');
if (description.length < 10) flags.push('no description');
if (!spot.geohash) flags.push('no location precision');
const flags = contentFlags({
title: spot.title,
description: spot.description,
imageCount: spot.images?.length ?? 0,
hasLocation: !!spot.geohash,
});
// Signals that need extra data (passed in by the review queue).
if (typeof extra.mapperTrust === 'number' && extra.mapperTrust <= 0) {

View file

@ -1,4 +1,4 @@
import { useMemo } from 'react';
import { useMemo, useState } from 'react';
import { Link } from 'react-router-dom';
import { useSeoMeta } from '@unhead/react';
import {
@ -9,9 +9,9 @@ import {
Loader2,
CheckCircle2,
BadgeCheck,
Undo2,
Users,
ShieldQuestion,
Undo2,
} from 'lucide-react';
import { PageHeader } from '@/components/PageHeader';
import { Card, CardContent } from '@/components/ui/card';
@ -19,6 +19,10 @@ import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
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 { useBatchTrustScores } from '@/hooks/useBatchTrustScores';
import { useBatchSpotStatus } from '@/hooks/useSpotStatus';
import { useBatchSpotPhotos } from '@/hooks/useSpotContributions';
@ -29,27 +33,66 @@ import { useNotASpotReport } from '@/hooks/useNotASpotReport';
import { useCurrentUser } from '@/hooks/useCurrentUser';
import { useToast } from '@/hooks/useToast';
import { isAdminPubkey } from '@/lib/admins';
import { spotFlags } from '@/lib/spotQuality';
import { SKATE_SPOT_KIND } from '@/lib/types';
import { contentFlags } from '@/lib/spotQuality';
import {
SKATE_SPOT_KIND,
SKATE_SHOP_KIND,
CALENDAR_EVENT_KIND,
SKATE_TOUR_KIND,
MARKETPLACE_ITEM_KIND,
} from '@/lib/types';
import NotFound from '@/pages/NotFound';
/**
* Admin review queue.
*
* Surfaces spots that LOOK like junk using cheap, explainable signals — a test
* entry or an off-topic pin trips several at once ("test of secret island
* airport": suspicious name + no photo + no description + no community signal).
* 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 human review can IMPROVE
* a spot's standing and clear it from this queue, and the report sends NIP-56.
* Flags are heuristics — a human decides.
* **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.
*/
export default function AdminReviewPage() {
const { user } = useCurrentUser();
// includeHidden: a spot you just hid must stay listed here, or there is no
// way to undo the verdict.
const { data: spots, isLoading } = useSkateSpots(300, { includeHidden: true });
type QueueKind = 'spots' | 'shops' | 'events' | 'tours' | 'gear';
const TABS: Array<{ id: QueueKind; label: string }> = [
{ id: 'spots', label: 'Spots' },
{ id: 'shops', label: 'Shops' },
{ id: 'events', label: 'Events' },
{ id: 'tours', label: 'Tours' },
{ id: 'gear', label: 'Gear' },
];
/** 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,
}: {
rows: QueueRow[];
isLoading: boolean;
}) {
const { hideContent, isPending: hiding } = useAdminHide();
const { confirmContent, isPending: confirming } = useAdminVerify();
const { clearVerdict, isPending: clearing } = useAdminClear();
@ -57,255 +100,220 @@ export default function AdminReviewPage() {
const { reportNotASpot } = useNotASpotReport();
const { toast } = useToast();
// Signals that need batch data.
const mappers = useMemo(
() => [...new Set((spots ?? []).map((s) => s.pubkey))],
[spots],
);
const mappers = useMemo(() => [...new Set(rows.map((r) => r.pubkey))], [rows]);
const { data: trustScores } = useBatchTrustScores(mappers);
const { getCounts } = useSpotReports(rows);
const statusInput = useMemo(
() => (spots ?? []).map((s) => ({ pubkey: s.pubkey, identifier: s.identifier })),
[spots],
);
const { getStatus } = useBatchSpotStatus(statusInput);
const { getCommunityPhotoCount } = useBatchSpotPhotos(statusInput);
// What people actually reported — the strongest signal we have.
const { getCounts: getReportCounts } = useSpotReports(spots ?? []);
const rows = useMemo(
const prepared = useMemo(
() =>
(spots ?? [])
.map((spot) => {
const coord = `${SKATE_SPOT_KIND}:${spot.pubkey}:${spot.identifier}`;
const status = getStatus(spot.pubkey, spot.identifier);
const hidden = moderation.has({ coord, id: spot.id });
const verified = moderation.isVerified({ coord, id: spot.id });
const photos = getCommunityPhotoCount(spot.pubkey, spot.identifier);
const reporters = status?.reporterCount ?? 0;
const reports = getReportCounts(spot.id);
const flags = spotFlags(spot, {
mapperTrust: trustScores?.get(spot.pubkey),
communityStatusReports: reporters,
communityPhotos: photos,
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,
});
// Human reports outrank heuristics: someone looked and objected.
if (reports.notASpot > 0) flags.unshift('reported: not a skate spot');
else if (reports.total > 0) flags.unshift(`reported ×${reports.total}`);
return {
spot,
coord,
hidden,
verified,
// An already-actioned row is kept so the admin can UNDO it.
actioned: hidden || verified,
trust: trustScores?.get(spot.pubkey),
reporters,
photos,
reports,
flags,
};
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((row) => row.actioned || row.flags.length > 0)
// Un-actioned first; within that, human reports before heuristics;
// actioned rows sink to the bottom (they are done, but undoable).
.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;
}),
[spots, moderation, trustScores, getStatus, getCommunityPhotoCount, getReportCounts],
[rows, moderation, trustScores, getCounts],
);
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 />;
const handleHide = async (coord: string, id: string, flags: string[]) => {
const act = async (
fn: () => Promise<void>,
ok: { title: string; description: string },
fail: string,
) => {
try {
await hideContent({ coord, id, reason: `auto-flagged: ${flags.join(', ')}` });
toast({
title: 'Hidden',
description: 'Removed from the app via a public moderation label. Reversible.',
});
await fn();
toast(ok);
} catch (e) {
toast({
title: 'Could not hide',
title: fail,
description: e instanceof Error ? e.message : 'Try again.',
variant: 'destructive',
});
}
};
const handleUndo = async (coord: string, id: string) => {
try {
await clearVerdict({ coord, id, reason: 'verdict retracted' });
toast({
title: 'Undone',
description: 'Back to neutral — not hidden, not vouched.',
});
} catch (e) {
toast({
title: 'Could not undo',
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>
);
}
const handleConfirm = async (coord: string, id: string) => {
try {
await confirmContent({ coord, id, reason: 'looks legit after review' });
toast({
title: 'Confirmed',
description: 'Vouched for — it drops off this queue and shows as verified.',
});
} catch (e) {
toast({
title: 'Could not confirm',
description: e instanceof Error ? e.message : 'Try again.',
variant: 'destructive',
});
}
};
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>
);
}
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 that trip the cheap junk signals. Hide is a soft, reversible label; Confirm vouches for it. Nothing is deleted."
/>
{isLoading ? (
<div className="space-y-3">
<Skeleton className="h-24 w-full rounded-2xl" />
<Skeleton className="h-24 w-full rounded-2xl" />
</div>
) : rows.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 to review — no spot currently trips the junk signals.
</p>
</CardContent>
</Card>
) : (
<div className="space-y-3">
{rows.map(({ spot, coord, flags, trust, reporters, photos, hidden, verified }) => (
<Card
key={spot.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">
{spot.images?.[0] ? (
<img
src={spot.images[0]}
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">{spot.title || '(untitled)'}</span>
</p>
<p className="truncate text-xs text-gray-500">
{spot.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 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>
<span className="flex items-center gap-1">
<Users className="h-3 w-3" />
{reporters > 0 || photos > 0
? [
reporters > 0 ? `${reporters} status report${reporters === 1 ? '' : 's'}` : null,
photos > 0 ? `${photos} community photo${photos === 1 ? '' : 's'}` : null,
]
.filter(Boolean)
.join(' · ')
: 'no community signals'}
</span>
</div>
</div>
<div className="space-y-3">
{prepared.map(({ row, coord, hidden, verified, trust, reports, flags }) => (
<Card
key={row.id}
className={
hidden
? 'border-2 border-red-500/25 bg-red-500/[0.04]'
: verified
? 'border-2 border-emerald-500/25 bg-emerald-500/[0.04]'
: 'border-2 border-white/10 bg-white/[0.03]'
}
>
<CardContent className="space-y-3 p-4">
<div className="flex items-start gap-3">
{row.image ? (
<img src={row.image} alt="" className="h-14 w-14 shrink-0 rounded-lg object-cover" />
) : (
<div className="flex h-14 w-14 shrink-0 items-center justify-center rounded-lg bg-white/10 text-xl">
🛹
</div>
)}
<div className="min-w-0 flex-1">
<p className="flex items-center gap-2 truncate font-bold text-white">
{hidden && (
<Badge className="border border-red-500/50 bg-red-500/20 text-[10px] font-black uppercase tracking-wide text-red-300">
<EyeOff className="mr-1 h-3 w-3" />
Hidden
</Badge>
)}
{verified && (
<Badge className="border border-emerald-500/50 bg-emerald-500/20 text-[10px] font-black uppercase tracking-wide text-emerald-300">
<BadgeCheck className="mr-1 h-3 w-3" />
Confirmed
</Badge>
)}
<span className="truncate">{row.title || '(untitled)'}</span>
</p>
<p className="truncate text-xs text-gray-500">
{row.description?.trim() || 'no description'}
</p>
<div className="mt-1.5 flex flex-wrap gap-1">
{!hidden &&
!verified &&
flags.map((f) => (
<Badge
key={f}
className={
f.startsWith('reported')
? 'border-red-500/50 bg-red-500/20 text-[10px] font-black uppercase tracking-wide text-red-300'
: 'border border-amber-500/40 bg-amber-500/15 text-[10px] font-bold uppercase tracking-wide text-amber-300'
}
>
{f}
</Badge>
))}
</div>
<div className="mt-2 flex flex-wrap items-center gap-x-3 gap-y-1 text-[10px] text-gray-500">
<span className="flex items-center gap-1">
{typeof trust === 'number' && trust > 0 ? (
<BadgeCheck className="h-3 w-3 text-emerald-500" />
) : (
<ShieldQuestion className="h-3 w-3 text-gray-600" />
)}
mapper trust {typeof trust === 'number' ? trust : '—'}
</span>
{row.communityStatusReports !== undefined && (
<span className="flex items-center gap-1">
<Users className="h-3 w-3" />
{row.communityStatusReports + (row.communityPhotos ?? 0) > 0
? [
row.communityStatusReports > 0
? `${row.communityStatusReports} status report${row.communityStatusReports === 1 ? '' : 's'}`
: null,
(row.communityPhotos ?? 0) > 0
? `${row.communityPhotos} community photo${row.communityPhotos === 1 ? '' : 's'}`
: null,
]
.filter(Boolean)
.join(' · ')
: 'no community signals'}
</span>
)}
{reports.total > 0 && (
<span className="flex items-center gap-1 text-red-400">
<Flag className="h-3 w-3" />
{reports.total} report{reports.total === 1 ? '' : 's'}
</span>
)}
</div>
</div>
</div>
<div className="flex flex-wrap gap-2">
{(hidden || verified) ? (
<Button
size="sm"
variant="outline"
className="gap-1.5 border-white/20 text-gray-200 hover:bg-white/10"
disabled={clearing}
onClick={() => void handleUndo(coord, spot.id)}
>
{clearing ? (
<Loader2 className="h-3.5 w-3.5 animate-spin" />
) : (
<Undo2 className="h-3.5 w-3.5" />
)}
Undo
</Button>
) : (
<>
<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 handleConfirm(coord, spot.id)}
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" />
)}
{confirming ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : <BadgeCheck className="h-3.5 w-3.5" />}
Confirm
</Button>
<Button
@ -313,51 +321,195 @@ export default function AdminReviewPage() {
variant="outline"
className="gap-1.5 border-red-500/40 text-red-400 hover:bg-red-500/10"
disabled={hiding}
onClick={() => void handleHide(coord, spot.id, flags)}
>
{hiding ? (
<Loader2 className="h-3.5 w-3.5 animate-spin" />
) : (
<EyeOff className="h-3.5 w-3.5" />
)}
Hide
</Button>
<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: spot.id,
pubkey: spot.pubkey,
title: spot.title,
})
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',
)
}
>
<Flag className="h-3.5 w-3.5" />
Not a skate spot
{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={`/spot/${spot.pubkey}/${spot.identifier}`}>
<ExternalLink className="h-3.5 w-3.5" />
Look at it
</Link>
</Button>
</div>
</CardContent>
</Card>
))}
</div>
)}
</>
)}
<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} />;
}
export default function AdminReviewPage() {
const { user } = useCurrentUser();
const [kind, setKind] = useState<QueueKind>('spots');
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 === 'spots' && <SpotsQueue />}
{kind === 'shops' && <ShopsQueue />}
{kind === 'events' && <EventsQueue />}
{kind === 'tours' && <ToursQueue />}
{kind === 'gear' && <GearQueue />}
<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>, which clears the spot from this queue and badges it as
staff-vouched. Both are public, reversible labels — the spot itself stays on relays.
<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>
);

View file

@ -260,19 +260,10 @@ export default function CreateSpotPage() {
return;
}
// A photo is REQUIRED. It is the single cheapest defence against the map
// filling up with test entries and off-topic pins ("test of secret island
// airport"): a real spot has a photo, a typo doesn't. It also gives every
// other skater something to verify against.
if (images.length === 0) {
toast({
title: 'Add a photo first',
description:
'Every spot needs at least one photo — of the spot, not the map. It keeps the map real.',
variant: 'destructive',
});
return;
}
// A photo is strongly recommended but NOT required: blocking an honest
// mapper who has no picture on hand is worse than accepting a photo-less
// pin. Spots without one simply stay "Unverified" (see the spot page badge)
// and the review queue flags them, so a human can look later.
// Don't publish while the Quick Snap photo is still uploading — otherwise the
// spot would go out without the photo the user just took.
@ -408,7 +399,9 @@ export default function CreateSpotPage() {
toast({
title: 'Spot added!',
description: 'Your skate spot has been published to the network.',
description: images.length === 0
? 'Published — and marked Unverified until someone adds a photo. A picture of the spot goes a long way.'
: 'Your skate spot has been published to the network.',
});
// Navigate to spot detail using pubkey and identifier
@ -732,9 +725,8 @@ export default function CreateSpotPage() {
<div className="space-y-2">
<Label>
{t('spots.photos')}
<span className="ml-1 text-red-400">*</span>
<span className="ml-2 text-[11px] font-normal text-muted-foreground">
required — one photo of the spot keeps the map honest
strongly recommended — spots without a photo stay marked Unverified
</span>
</Label>
<div className="grid grid-cols-3 gap-4">
@ -848,7 +840,7 @@ export default function CreateSpotPage() {
<Button
type="submit"
className="w-full"
disabled={isPublishing || isUploading || isUploadingQuickPhoto || images.length === 0}
disabled={isPublishing || isUploading || isUploadingQuickPhoto}
>
{isPublishing ? (
<>

View file

@ -1649,7 +1649,7 @@ export default function MapPage() {
{/* Empty state — the active filters hid everything. Without this
the map just looks blank/unloaded. Only shown when the
spot/shop layers are on (other layers have their own markers). */}
{!isLoading && (showSpots || showShops) && filteredSpots.length === 0 && (
{!isLoading && (showSpots || showShops) && filteredSpots.length === 0 && allLocations.length > 0 && (
<div className="pointer-events-none absolute inset-0 z-[1001] flex items-center justify-center px-4">
<div className="pointer-events-auto max-w-xs rounded-2xl border border-white/10 bg-black/85 p-5 text-center shadow-2xl backdrop-blur-md">
<p className="font-black uppercase tracking-wide text-white">No spots match</p>
@ -1661,6 +1661,39 @@ export default function MapPage() {
</div>
)}
{/* Nothing mapped here AT ALL — the honest empty map. This is the
density problem, not a filter problem, so the fix on offer is
"add the first one" rather than "clear your filters". */}
{!isLoading && (showSpots || showShops) && allLocations.length === 0 && (
<div className="pointer-events-none absolute inset-0 z-[1001] flex items-center justify-center px-4">
<div className="pointer-events-auto max-w-sm rounded-2xl border-2 border-[#ccff00]/25 bg-black/85 p-6 text-center shadow-2xl backdrop-blur-md">
<p className="text-2xl">🛹</p>
<p className="mt-2 font-black uppercase tracking-wide text-white">
No spots mapped yet
</p>
<p className="mt-1 text-xs leading-relaxed text-gray-400">
Be the first to map your park — it takes a minute, it's yours, and the next
skater who rolls through will thank you.
</p>
<div className="mt-4 flex flex-col gap-2 sm:flex-row sm:justify-center">
<Link to="/create-spot">
<Button
size="sm"
className="w-full gap-2 bg-[#ccff00] font-black uppercase text-black hover:bg-[#ccff00]/90"
>
<Plus className="h-4 w-4" />
Add the first spot
</Button>
</Link>
<Button size="sm" variant="outline" className="gap-2 border-white/20" onClick={handleNearMe} disabled={nearMeLoading}>
<LocateFixed className="h-4 w-4" />
{nearMeLoading ? 'Locating…' : 'Find spots near me'}
</Button>
</div>
</div>
</div>
)}
{/* Map attribution — bottom-left, sits ABOVE the mobile bottom
nav so it never covers the Shops & Spots buttons. Tiny + subtle. */}
<div