feat(moderation): positive 'verified' verdict + trust/community signals in the review queue

- MODERATION_LABEL_VERIFIED: the positive counterpart to 'hidden'. Both labels
  are fetched in ONE query; useAdminModeration now exposes verifiedCoords/
  verifiedIds/isVerified() with a local mirror (addLocalConfirmed/
  getLocalConfirmed). useAdminVerify() publishes it (shares the publish path
  with useAdminHide).
- Review queue: a human review can now CONFIRM a spot, not just hide it. A
  confirmed spot clears from the queue and gets a 'Verified by SkateSpots' badge
  on the spot page (staff verdict, shown separately from the community badge).
- Two more signals: mapper trust (useBatchTrustScores) and whether any skater has
  filed a status report (useBatchSpotStatus reporterCount) -> 'mapper not in your
  web of trust' / 'no community signals'. The latter only fires alongside another
  flag so a brand-new real spot isn't punished for being new.
- Flag logic moved to src/lib/spotQuality.ts so the page and AdminReviewCard share it.

Verified: the queue lists the airport entry with suspicious name + no photo + no
community signals, shows mapper trust, and offers Confirm/Hide/Not a skate
spot/Look at it.
This commit is contained in:
SkateSpots 2026-09-28 00:25:12 +07:00
parent a43ff47b28
commit 0419c18810
10 changed files with 338 additions and 100 deletions

View file

@ -390,6 +390,20 @@ session with **no other memory of this project**. So:
`AdminReviewCard` in Settings → Advanced. Hiding is **soft and reversible** —
the event stays on relays (honest for a censorship-resistant network); nothing
is deleted. `/review` is in the smoke-test route list.
- **Symmetric verdicts + richer signals (2026-09-27):** `MODERATION_LABEL_VERIFIED`
is the positive counterpart to `hidden`. `useAdminModeration` fetches both
labels in ONE query and exposes `verifiedCoords`/`verifiedIds`/`isVerified()`
(plus a local mirror via `addLocalConfirmed`/`getLocalConfirmed`), and
`useAdminVerify()` (same file as `useAdminHide`) publishes it. Using it, the
review queue can **Confirm** a spot — which clears it from the queue and shows
a "Verified by SkateSpots" badge on the spot page — instead of only ever
hiding things. Queue flags now also include **mapper trust** (`useBatchTrustScores`)
and **whether any skater has filed a status report** (`useBatchSpotStatus`
`reporterCount`) → 'mapper not in your web of trust' / 'no community signals'.
The flag logic is shared in **`src/lib/spotQuality.ts`** (used by both the page
and `AdminReviewCard`, so they can't drift) and is deliberately heuristic —
'no community signals' only fires alongside another flag, so a brand-new real
spot isn't punished for being new.
- **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

@ -10,6 +10,9 @@ v1.5.4
verdict on whether a place is a real spot.
- One-tap "Not a skate spot" for off-topic pins (test entries, airports, malls,
private land), plus an admin review queue that surfaces them for a human.
- The queue also weighs mapper trust and whether any skater has actually
reported on the spot, and a review can now **Confirm** a spot as well as hide
it — a staff-vouched spot gets a badge and drops off the queue.
🛍 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

@ -4,22 +4,7 @@ import { ShieldAlert, ArrowRight } from 'lucide-react';
import { useCurrentUser } from '@/hooks/useCurrentUser';
import { useSkateSpots } from '@/hooks/useSkateSpots';
import { isAdminPubkey } from '@/lib/admins';
import type { SkateSpot } from '@/lib/types';
/** Same cheap signals the review page uses (kept in sync deliberately). */
const SUSPICIOUS_TITLE =
/(^|\b)(test|testing|asdf|asd|qwerty|foo|bar|untitled|demo|sample|example|secret|airport|my (house|home|flat|apartment))(\b|$)/i;
function isFlagged(spot: SkateSpot): boolean {
const title = (spot.title ?? '').trim();
const description = (spot.description ?? '').trim();
return (
SUSPICIOUS_TITLE.test(title) ||
title.length <= 3 ||
!spot.images?.length ||
description.length < 10
);
}
import { isFlagged } from '@/lib/spotQuality';
/**
* Admin-only entry point for the review queue. Renders nothing for everyone
@ -30,10 +15,9 @@ export function AdminReviewCard() {
const isAdmin = isAdminPubkey(user?.pubkey);
const { data: spots } = useSkateSpots(300);
const flaggedCount = useMemo(
() => (spots ?? []).filter(isFlagged).length,
[spots],
);
// Cheap signals only here (no per-spot trust/status queries just to draw a
// badge) — the review page does the deeper pass.
const flaggedCount = useMemo(() => (spots ?? []).filter(isFlagged).length, [spots]);
if (!isAdmin) return null;

View file

@ -2,25 +2,32 @@ import { useMutation, useQueryClient } from '@tanstack/react-query';
import { useNostrPublish } from '@/hooks/useNostrPublish';
import { useCurrentUser } from '@/hooks/useCurrentUser';
import { isAdminPubkey } from '@/lib/admins';
import { addLocalHidden } from '@/lib/localModeration';
import { MODERATION_LABEL_NAMESPACE, MODERATION_LABEL_HIDDEN } from '@/lib/types';
import { addLocalHidden, addLocalConfirmed } from '@/lib/localModeration';
import {
MODERATION_LABEL_NAMESPACE,
MODERATION_LABEL_HIDDEN,
MODERATION_LABEL_VERIFIED,
} from '@/lib/types';
/**
* Publish an admin "hidden" moderation label (NIP-32 kind 1985).
* Publish an admin moderation label (NIP-32 kind 1985) for a target.
*
* THE MISSING HALF: `useAdminModeration` has always READ these labels and
* filtered hidden content out of every list — but nothing ever PUBLISHED one, so
* the only working hide was the local, this-device-only one in
* `useDeleteEntity`. This hook is the write side: the label is public, authored
* by an admin pubkey, and any SkateSpots client (and any other client that
* honours the namespace) will filter the target. It is a **soft, reversible
* hide** — the underlying event stays on relays, which is the honest behaviour
* on a censorship-resistant network.
* filtered on them, but nothing ever PUBLISHED one — the only working hide was
* the local, this-device-only one in `useDeleteEntity`. This is the write side.
*
* Also writes the OPTIMISTIC local hide so the content disappears instantly on
* this device instead of waiting for the relay round-trip.
* Two symmetric verdicts share one code path:
* • `hidden` — take it out of the app (soft, reversible);
* • `verified` — vouch for it, which also clears it from the review queue.
*
* The label is public and authored by an admin key, so every SkateSpots client
* filters/boosts accordingly. Nothing is ever deleted from relays — that is the
* honest behaviour on a censorship-resistant network.
*
* A local optimistic mirror is written too, so the effect is instant on this
* device instead of waiting for the relay round-trip.
*/
export function useAdminHide() {
function usePublishModerationLabel(label: string) {
const { user } = useCurrentUser();
const { mutateAsync: publish, isPending: publishing } = useNostrPublish();
const queryClient = useQueryClient();
@ -35,27 +42,40 @@ export function useAdminHide() {
coord?: string;
/** Event id (for regular/replaceable events). */
id?: string;
/** Why it was hidden — stored in the label's content for transparency. */
/** Why — stored in the label's content for transparency. */
reason?: string;
}) => {
if (!isAdminPubkey(user?.pubkey)) throw new Error('Admins only.');
if (!coord && !id) throw new Error('Nothing to hide — need a coordinate or an event id.');
if (!coord && !id) throw new Error('Need a coordinate or an event id.');
const tags: string[][] = [
['L', MODERATION_LABEL_NAMESPACE],
['l', MODERATION_LABEL_HIDDEN, MODERATION_LABEL_NAMESPACE],
['alt', 'SkateSpots: moderation label — hidden'],
['l', label, MODERATION_LABEL_NAMESPACE],
['alt', `SkateSpots: moderation label — ${label}`],
];
if (coord) tags.push(['a', coord]);
if (id) tags.push(['e', id]);
await publish({ kind: 1985, content: reason ?? '', tags });
// Optimistic: hide on this device immediately.
addLocalHidden({ coord, id });
// Optimistic, this device.
if (label === MODERATION_LABEL_HIDDEN) addLocalHidden({ coord, id });
else addLocalConfirmed({ coord, id });
queryClient.invalidateQueries({ queryKey: ['admin-moderation'] });
},
});
return { hideContent: mutation.mutateAsync, isPending: mutation.isPending || publishing };
return { run: mutation.mutateAsync, isPending: mutation.isPending || publishing };
}
/** Hide a target across the app (soft + reversible). */
export function useAdminHide() {
const { run, isPending } = usePublishModerationLabel(MODERATION_LABEL_HIDDEN);
return { hideContent: run, isPending };
}
/** Vouch for a target — the positive counterpart, which clears the review queue. */
export function useAdminVerify() {
const { run, isPending } = usePublishModerationLabel(MODERATION_LABEL_VERIFIED);
return { confirmContent: run, isPending };
}

View file

@ -4,8 +4,9 @@ import { ADMIN_PUBKEYS } from '@/lib/admins';
import {
MODERATION_LABEL_NAMESPACE,
MODERATION_LABEL_HIDDEN,
MODERATION_LABEL_VERIFIED,
} from '@/lib/types';
import { getLocalHidden } from '@/lib/localModeration';
import { getLocalHidden, getLocalConfirmed } from '@/lib/localModeration';
/**
* App-wide admin moderation layer.
@ -28,12 +29,22 @@ export interface AdminHiddenSet {
ids: Set<string>;
/** True if either set contains anything (cheap guard for callers). */
has: (opts: { coord?: string; id?: string }) => boolean;
/**
* Admin-VOUCHED targets (the positive label). Used to promote a spot out of
* the review queue and to show a "Verified" badge.
*/
verifiedCoords: Set<string>;
verifiedIds: Set<string>;
isVerified: (opts: { coord?: string; id?: string }) => boolean;
}
const EMPTY: AdminHiddenSet = {
coords: new Set(),
ids: new Set(),
has: () => false,
verifiedCoords: new Set(),
verifiedIds: new Set(),
isVerified: () => false,
};
/**
@ -87,7 +98,8 @@ export function useAdminModeration() {
{
kinds: [1985],
authors: ADMIN_PUBKEYS,
'#l': [MODERATION_LABEL_HIDDEN],
// One request for BOTH admin verdicts: hidden and verified.
'#l': [MODERATION_LABEL_HIDDEN, MODERATION_LABEL_VERIFIED],
limit: 1000,
},
],
@ -97,6 +109,8 @@ export function useAdminModeration() {
const adminSet = new Set(ADMIN_PUBKEYS.map((p) => p.toLowerCase()));
const coords = new Set<string>();
const ids = new Set<string>();
const verifiedCoords = new Set<string>();
const verifiedIds = new Set<string>();
for (const ev of events) {
// Defense in depth: re-verify author + namespace + label client-side.
@ -104,14 +118,18 @@ export function useAdminModeration() {
const hasNamespace = ev.tags.some(
([n, v]) => n === 'L' && v === MODERATION_LABEL_NAMESPACE,
);
const hasLabel = ev.tags.some(
if (!hasNamespace) continue;
const isHidden = ev.tags.some(
([n, v]) => n === 'l' && v === MODERATION_LABEL_HIDDEN,
);
if (!hasNamespace || !hasLabel) continue;
const isVerified = ev.tags.some(
([n, v]) => n === 'l' && v === MODERATION_LABEL_VERIFIED,
);
if (!isHidden && !isVerified) continue;
for (const [n, v] of ev.tags) {
if (n === 'a' && v) coords.add(v);
if (n === 'e' && v) ids.add(v);
if (n === 'a' && v) (isHidden ? coords : verifiedCoords).add(v);
if (n === 'e' && v) (isHidden ? ids : verifiedIds).add(v);
}
}
@ -120,12 +138,19 @@ export function useAdminModeration() {
const local = getLocalHidden();
for (const c of local.coords) coords.add(c);
for (const i of local.ids) ids.add(i);
const localConfirmed = getLocalConfirmed();
for (const c of localConfirmed.coords) verifiedCoords.add(c);
for (const i of localConfirmed.ids) verifiedIds.add(i);
return {
coords,
ids,
has: ({ coord, id }) =>
(!!coord && coords.has(coord)) || (!!id && ids.has(id)),
verifiedCoords,
verifiedIds,
isVerified: ({ coord, id }) =>
(!!coord && verifiedCoords.has(coord)) || (!!id && verifiedIds.has(id)),
};
},
staleTime: 60 * 1000,
@ -137,12 +162,25 @@ export function useAdminModeration() {
// hides so the takedown is reflected immediately on this device.
if (!query.data) {
const local = getLocalHidden();
if (local.coords.size === 0 && local.ids.size === 0) return EMPTY;
const localConfirmed = getLocalConfirmed();
if (
local.coords.size === 0 &&
local.ids.size === 0 &&
localConfirmed.coords.size === 0 &&
localConfirmed.ids.size === 0
) {
return EMPTY;
}
return {
coords: local.coords,
ids: local.ids,
has: ({ coord, id }) =>
(!!coord && local.coords.has(coord)) || (!!id && local.ids.has(id)),
verifiedCoords: localConfirmed.coords,
verifiedIds: localConfirmed.ids,
isVerified: ({ coord, id }) =>
(!!coord && localConfirmed.coords.has(coord)) ||
(!!id && localConfirmed.ids.has(id)),
};
}

View file

@ -18,15 +18,17 @@
*/
const STORAGE_KEY = 'skatespots_local_admin_hidden_v1';
/** Same idea, for the POSITIVE action: an admin vouching for a spot. */
const CONFIRMED_KEY = 'skatespots_local_admin_confirmed_v1';
interface LocalHidden {
coords: string[];
ids: string[];
}
function read(): LocalHidden {
function readAt(key: string): LocalHidden {
try {
const raw = localStorage.getItem(STORAGE_KEY);
const raw = localStorage.getItem(key);
if (!raw) return { coords: [], ids: [] };
const parsed = JSON.parse(raw) as Partial<LocalHidden>;
return {
@ -38,14 +40,35 @@ function read(): LocalHidden {
}
}
function write(data: LocalHidden): void {
function writeAt(key: string, data: LocalHidden): void {
try {
localStorage.setItem(STORAGE_KEY, JSON.stringify(data));
localStorage.setItem(key, JSON.stringify(data));
} catch {
/* storage full / unavailable — optimistic hide is best-effort */
/* storage full / unavailable — the optimistic mirror is best-effort */
}
}
const read = () => readAt(STORAGE_KEY);
const write = (data: LocalHidden) => writeAt(STORAGE_KEY, data);
/**
* The admin's POSITIVE action: they looked at a spot and vouched for it. Stored
* locally so the review queue stops nagging about it instantly, and mirrored by
* a public `verified` label (see useAdminVerify).
*/
export function addLocalConfirmed(opts: { coord?: string; id?: string }): void {
const data = readAt(CONFIRMED_KEY);
if (opts.coord && !data.coords.includes(opts.coord)) data.coords.push(opts.coord);
if (opts.id && !data.ids.includes(opts.id)) data.ids.push(opts.id);
writeAt(CONFIRMED_KEY, data);
}
/** Read the current local confirmed sets. */
export function getLocalConfirmed(): { coords: Set<string>; ids: Set<string> } {
const data = readAt(CONFIRMED_KEY);
return { coords: new Set(data.coords), ids: new Set(data.ids) };
}
/** Record a coordinate + event id as locally hidden by the admin. */
export function addLocalHidden(opts: { coord?: string; id?: string }): void {
const data = read();

53
src/lib/spotQuality.ts Normal file
View file

@ -0,0 +1,53 @@
/**
* Cheap, explainable "is this pin junk?" signals.
*
* Shared by the admin review queue and its Settings card so the two never drift.
* These are HEURISTICS, not verdicts: every one of them is something a human can
* check in about two seconds, and the action they drive (a reversible NIP-32
* label) is a suggestion-taking, never a deletion.
*/
import type { SkateSpot } from '@/lib/types';
/** Names that almost always mean a test entry or an off-topic pin. */
const SUSPICIOUS_TITLE =
/(^|\b)(test|testing|asdf|asd|qwerty|foo|bar|untitled|demo|sample|example|secret|airport|my (house|home|flat|apartment))(\b|$)/i;
export interface SpotQualitySignals {
/** Mapper's web-of-trust score (0–100), when known. */
mapperTrust?: number;
/** Status reports (kind 2259) from skaters — a real "someone was here" signal. */
communityStatusReports?: number;
/** Community photos contributed by people other than the mapper. */
communityPhotos?: number;
}
/** 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');
// Signals that need extra data (passed in by the review queue).
if (typeof extra.mapperTrust === 'number' && extra.mapperTrust <= 0) {
flags.push('mapper not in your web of trust');
}
const communitySignals =
(extra.communityStatusReports ?? 0) + (extra.communityPhotos ?? 0);
if (communitySignals === 0 && flags.length > 0) {
// Only meaningful alongside another flag: an unconfirmed spot with a real
// name and a photo is just new, not junk.
flags.push('no community signals');
}
return flags;
}
export function isFlagged(spot: SkateSpot): boolean {
return spotFlags(spot).length > 0;
}

View file

@ -228,6 +228,8 @@ export const VOUCH_LABEL_VALUE = 'vouch';
*/
export const MODERATION_LABEL_NAMESPACE = 'org.skatespots.moderation';
export const MODERATION_LABEL_HIDDEN = 'hidden';
/** Admin VOUCHES for the target (the positive counterpart to `hidden`). */
export const MODERATION_LABEL_VERIFIED = 'verified';
/** Extract member pubkeys (hex) claimed by a Lab profile via `p` tags. */
export function parseMemberPubkeys(event: NostrEvent): string[] {

View file

@ -1,63 +1,92 @@
import { useMemo } from 'react';
import { Link } from 'react-router-dom';
import { useSeoMeta } from '@unhead/react';
import { ShieldAlert, Flag, EyeOff, ExternalLink, Loader2, CheckCircle2 } from 'lucide-react';
import {
ShieldAlert,
Flag,
EyeOff,
ExternalLink,
Loader2,
CheckCircle2,
BadgeCheck,
Users,
ShieldQuestion,
} 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 { Skeleton } from '@/components/ui/skeleton';
import { useSkateSpots } from '@/hooks/useSkateSpots';
import { useAdminHide } from '@/hooks/useAdminHide';
import { useBatchTrustScores } from '@/hooks/useBatchTrustScores';
import { useBatchSpotStatus } from '@/hooks/useSpotStatus';
import { useAdminHide, useAdminVerify } from '@/hooks/useAdminHide';
import { useAdminModeration } from '@/hooks/useAdminModeration';
import { useNotASpotReport } from '@/hooks/useNotASpotReport';
import { useCurrentUser } from '@/hooks/useCurrentUser';
import { useToast } from '@/hooks/useToast';
import { isAdminPubkey } from '@/lib/admins';
import { SKATE_SPOT_KIND, type SkateSpot } from '@/lib/types';
import { spotFlags } from '@/lib/spotQuality';
import { SKATE_SPOT_KIND } from '@/lib/types';
import NotFound from '@/pages/NotFound';
/**
* Admin review queue.
*
* Surfaces spots that LOOK like junk using cheap, explainable signals — the kind
* of thing a test entry or an off-topic pin trips instantly ("test of secret
* island airport": suspicious name + no photo). It does not delete anything:
* every action is a soft, reversible NIP-32 `hidden` label (see `useAdminHide`)
* or a NIP-56 report. Flags are suggestions, not verdicts — a human decides.
* 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).
*
* 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.
*/
const SUSPICIOUS_TITLE =
/(^|\b)(test|testing|asdf|asd|qwerty|foo|bar|untitled|demo|sample|example|secret|airport|my (house|home|flat|apartment))(\b|$)/i;
/** Cheap signals. Each one is something a human can check in two seconds. */
function flagsFor(spot: SkateSpot): 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');
return flags;
}
export default function AdminReviewPage() {
const { user } = useCurrentUser();
const { data: spots, isLoading } = useSkateSpots(300);
const { hideContent, isPending: hiding } = useAdminHide();
const { confirmContent, isPending: confirming } = useAdminVerify();
const moderation = useAdminModeration();
const { reportNotASpot } = useNotASpotReport();
const { toast } = useToast();
const flagged = useMemo(
// Signals that need batch data.
const mappers = useMemo(
() => [...new Set((spots ?? []).map((s) => s.pubkey))],
[spots],
);
const { data: trustScores } = useBatchTrustScores(mappers);
const statusInput = useMemo(
() => (spots ?? []).map((s) => ({ pubkey: s.pubkey, identifier: s.identifier })),
[spots],
);
const { getStatus } = useBatchSpotStatus(statusInput);
const rows = useMemo(
() =>
(spots ?? [])
.map((spot) => ({ spot, flags: flagsFor(spot) }))
.filter((row) => row.flags.length > 0)
.map((spot) => {
const coord = `${SKATE_SPOT_KIND}:${spot.pubkey}:${spot.identifier}`;
const status = getStatus(spot.pubkey, spot.identifier);
return {
spot,
coord,
verified: moderation.isVerified({ coord, id: spot.id }),
trust: trustScores?.get(spot.pubkey),
reporters: status?.reporterCount ?? 0,
flags: spotFlags(spot, {
mapperTrust: trustScores?.get(spot.pubkey),
communityStatusReports: status?.reporterCount ?? 0,
}),
};
})
// Admin-vouched spots are done — and confirming one should visibly
// remove it from the pile.
.filter((row) => !row.verified && row.flags.length > 0)
.sort((a, b) => b.flags.length - a.flags.length),
[spots],
[spots, moderation, trustScores, getStatus],
);
useSeoMeta({ title: 'Review queue — SkateSpots' });
@ -65,13 +94,9 @@ export default function AdminReviewPage() {
// Admins only. Everyone else sees a 404 rather than a hint that this exists.
if (!isAdminPubkey(user?.pubkey)) return <NotFound />;
const handleHide = async (spot: SkateSpot, flags: string[]) => {
const handleHide = async (coord: string, id: string, flags: string[]) => {
try {
await hideContent({
coord: `${SKATE_SPOT_KIND}:${spot.pubkey}:${spot.identifier}`,
id: spot.id,
reason: `auto-flagged: ${flags.join(', ')}`,
});
await hideContent({ coord, id, reason: `auto-flagged: ${flags.join(', ')}` });
toast({
title: 'Hidden',
description: 'Removed from the app via a public moderation label. Reversible.',
@ -85,8 +110,20 @@ export default function AdminReviewPage() {
}
};
const handleNotASpot = (spot: SkateSpot) => {
void reportNotASpot({ eventId: spot.id, pubkey: spot.pubkey, title: spot.title });
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',
});
}
};
return (
@ -95,7 +132,7 @@ export default function AdminReviewPage() {
icon={ShieldAlert}
title="Review queue"
accent="#ff6600"
subtitle="Spots that trip the cheap junk signals. Hide is a soft, reversible label — nothing is deleted."
subtitle="Spots that trip the cheap junk signals. Hide is a soft, reversible label; Confirm vouches for it. Nothing is deleted."
/>
{isLoading ? (
@ -103,7 +140,7 @@ export default function AdminReviewPage() {
<Skeleton className="h-24 w-full rounded-2xl" />
<Skeleton className="h-24 w-full rounded-2xl" />
</div>
) : flagged.length === 0 ? (
) : 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" />
@ -114,7 +151,7 @@ export default function AdminReviewPage() {
</Card>
) : (
<div className="space-y-3">
{flagged.map(({ spot, flags }) => (
{rows.map(({ spot, coord, flags, trust, reporters }) => (
<Card key={spot.id} className="border-2 border-white/10 bg-white/[0.03]">
<CardContent className="space-y-3 p-4">
<div className="flex items-start gap-3">
@ -144,16 +181,45 @@ export default function AdminReviewPage() {
</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
? `${reporters} status report${reporters === 1 ? '' : 's'}`
: 'no community signals'}
</span>
</div>
</div>
</div>
<div className="flex flex-wrap gap-2">
<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)}
>
{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 handleHide(spot, flags)}
onClick={() => void handleHide(coord, spot.id, flags)}
>
{hiding ? (
<Loader2 className="h-3.5 w-3.5 animate-spin" />
@ -166,7 +232,13 @@ export default function AdminReviewPage() {
size="sm"
variant="outline"
className="gap-1.5 border-amber-500/40 text-amber-400 hover:bg-amber-500/10"
onClick={() => handleNotASpot(spot)}
onClick={() =>
void reportNotASpot({
eventId: spot.id,
pubkey: spot.pubkey,
title: spot.title,
})
}
>
<Flag className="h-3.5 w-3.5" />
Not a skate spot
@ -185,9 +257,11 @@ export default function AdminReviewPage() {
)}
<p className="mt-6 text-[11px] leading-relaxed text-gray-600">
Hiding publishes a public NIP-32 label (<code>org.skatespots.moderation</code> /
<code> hidden</code>) that every SkateSpots client filters out; the spot itself stays on
relays, so it can be un-hidden later. Flags are heuristics — check before you hide.
<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.
</p>
</div>
);

View file

@ -21,6 +21,7 @@ import { useSkateSpot } from '@/hooks/useSkateSpots';
import { useAuthor } from '@/hooks/useAuthor';
import { useDeleteEntity } from '@/hooks/useDeleteEntity';
import { useNotASpotReport } from '@/hooks/useNotASpotReport';
import { useAdminModeration } from '@/hooks/useAdminModeration';
import { isAdminPubkey } from '@/lib/admins';
import { cn } from '@/lib/utils';
import { useToast } from '@/hooks/useToast';
@ -29,7 +30,12 @@ import { useMarketplaceAtSpot } from '@/hooks/useMarketplace';
import { useSpotServices } from '@/hooks/useSpotServices';
import { MarketplaceItemCard } from '@/components/MarketplaceItemCard';
import { genUserName } from '@/lib/genUserName';
import { SPOT_TYPE_LABELS, ACCESS_TYPE_INFO, SHELTER_INFO } from '@/lib/types';
import {
SPOT_TYPE_LABELS,
ACCESS_TYPE_INFO,
SHELTER_INFO,
SKATE_SPOT_KIND,
} from '@/lib/types';
import NotFound from './NotFound';
import {
MapPin,
@ -56,6 +62,7 @@ import {
MessageCircle,
ShieldCheck,
ShieldQuestion,
BadgeCheck,
} from 'lucide-react';
import { nip19 } from 'nostr-tools';
import { ReportDialog } from '@/components/ReportDialog';
@ -168,6 +175,14 @@ export default function SpotDetailPage() {
(communityPhotos?.length ?? 0) + (spotStatus?.reporterCount ?? 0);
const communityVerified = communitySignalCount > 0;
// Staff verdict (NIP-32 `verified` label) — separate from the community
// signal: a human actually looked at this one.
const moderation = useAdminModeration();
const staffVerified = !!spot && moderation.isVerified({
coord: `${SKATE_SPOT_KIND}:${spot.pubkey}:${spot.identifier}`,
id: spot.id,
});
useSeoMeta({
title: spot ? `${spot.title} - SkateSpots` : 'Skate Spot - SkateSpots',
description: spot?.description || 'View this skateboarding spot on SkateSpots.',
@ -474,7 +489,18 @@ export default function SpotDetailPage() {
{SHELTER_INFO[spot.shelter].emoji} {SHELTER_INFO[spot.shelter].label}
</Badge>
)}
{/* Staff verdict wins if present: a human reviewed it. */}
{staffVerified && (
<Badge
className="border border-[#ccff00]/60 bg-[#ccff00]/20 font-bold uppercase text-[#ccff00]"
title="A SkateSpots admin reviewed and vouched for this spot."
>
<BadgeCheck className="mr-1 h-3 w-3" />
Verified by SkateSpots
</Badge>
)}
{/* Community confidence — NOT a quality/weather score. */}
{!staffVerified && (
<Badge
className={cn(
'border font-bold uppercase',
@ -501,6 +527,7 @@ export default function SpotDetailPage() {
</>
)}
</Badge>
)}
{spot.types.map((type) => (
<Badge key={type} className="bg-[#ccff00]/20 text-[#ccff00] border border-[#ccff00]/50 font-bold uppercase">
{SPOT_TYPE_LABELS[type] || type}