diff --git a/AGENTS.md b/AGENTS.md index 9fb9c5d..d5fce07 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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/* → diff --git a/release-notes.txt b/release-notes.txt index fd6849b..a5bed1d 100644 --- a/release-notes.txt +++ b/release-notes.txt @@ -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 diff --git a/src/hooks/useMarketplace.ts b/src/hooks/useMarketplace.ts index 1e08dcd..d447114 100644 --- a/src/hooks/useMarketplace.ts +++ b/src/hooks/useMarketplace.ts @@ -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); diff --git a/src/hooks/useSkateEvents.ts b/src/hooks/useSkateEvents.ts index 7df88a4..d5ac7e2 100644 --- a/src/hooks/useSkateEvents.ts +++ b/src/hooks/useSkateEvents.ts @@ -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); diff --git a/src/hooks/useSkateShops.ts b/src/hooks/useSkateShops.ts index bb6e849..9def83f 100644 --- a/src/hooks/useSkateShops.ts +++ b/src/hooks/useSkateShops.ts @@ -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({ 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); diff --git a/src/hooks/useTours.ts b/src/hooks/useTours.ts index 78dfbb6..e463bbc 100644 --- a/src/hooks/useTours.ts +++ b/src/hooks/useTours.ts @@ -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); diff --git a/src/lib/spotQuality.ts b/src/lib/spotQuality.ts index 68b6226..21c700e 100644 --- a/src/lib/spotQuality.ts +++ b/src/lib/spotQuality.ts @@ -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) { diff --git a/src/pages/AdminReviewPage.tsx b/src/pages/AdminReviewPage.tsx index 093af57..fa30ede 100644 --- a/src/pages/AdminReviewPage.tsx +++ b/src/pages/AdminReviewPage.tsx @@ -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 ; - - const handleHide = async (coord: string, id: string, flags: string[]) => { + const act = async ( + fn: () => Promise, + 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 ( +
+ + +
+ ); + } - 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 ( + + + +

+ Nothing to review — nothing of this kind trips the junk signals. +

+
+
+ ); + } return ( -
- - - {isLoading ? ( -
- - -
- ) : rows.length === 0 ? ( - - - -

- Nothing to review — no spot currently trips the junk signals. -

-
-
- ) : ( -
- {rows.map(({ spot, coord, flags, trust, reporters, photos, hidden, verified }) => ( -
+ )} + {/* Map attribution — bottom-left, sits ABOVE the mobile bottom nav so it never covers the Shops & Spots buttons. Tiny + subtle. */}