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:
parent
e549aeee3a
commit
f1ddf5fdf1
10 changed files with 547 additions and 303 deletions
23
AGENTS.md
23
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
|
404s for the person who made it. `SpotDetailPage` shows an author-only banner
|
||||||
("Hidden by a moderator" / "Verified by SkateSpots"). The verdicts are public
|
("Hidden by a moderator" / "Verified by SkateSpots"). The verdicts are public
|
||||||
labels, so there is nothing secret being revealed.
|
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
|
- **Mints directory**: `/mints` page lists Cashu mints from the community
|
||||||
**MintRadar** project. MintRadar has **no CORS**, so it's fetched same-origin
|
**MintRadar** project. MintRadar has **no CORS**, so it's fetched same-origin
|
||||||
at `/api/mints/*` — prod proxies via `public/_redirects` (`/api/mints/* →
|
at `/api/mints/*` — prod proxies via `public/_redirects` (`/api/mints/* →
|
||||||
|
|
|
||||||
|
|
@ -21,6 +21,12 @@ v1.5.4
|
||||||
straight to it instead of letting you create a duplicate.
|
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
|
- **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).
|
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)
|
🛍 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
|
- Free to print, no sats needed: a 55 × 86 mm "DO YOU EVEN SKATE BRO?" sticker
|
||||||
|
|
|
||||||
|
|
@ -7,13 +7,16 @@ const STALE_TIME = 5 * 60 * 1000; // 5 min
|
||||||
const GC_TIME = 30 * 60 * 1000; // 30 min
|
const GC_TIME = 30 * 60 * 1000; // 30 min
|
||||||
const TIMEOUT = 8000;
|
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 { querySkateContent, getQueryKeySuffix } = useSkateRelays();
|
||||||
const hidden = useAdminModeration();
|
const hidden = useAdminModeration();
|
||||||
|
|
||||||
return useQuery({
|
return useQuery({
|
||||||
queryKey: ['marketplace-items', limit, getQueryKeySuffix()],
|
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 () => {
|
queryFn: async () => {
|
||||||
const signal = AbortSignal.timeout(TIMEOUT);
|
const signal = AbortSignal.timeout(TIMEOUT);
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -3,13 +3,16 @@ import { CALENDAR_EVENT_KIND, parseSkateEvent, isFeaturedActive, type SkateEvent
|
||||||
import { useSkateRelays } from '@/hooks/useSkateRelays';
|
import { useSkateRelays } from '@/hooks/useSkateRelays';
|
||||||
import { useAdminModeration, filterAdminHidden } from '@/hooks/useAdminModeration';
|
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 { querySkateContent, getQueryKeySuffix } = useSkateRelays();
|
||||||
const hidden = useAdminModeration();
|
const hidden = useAdminModeration();
|
||||||
|
|
||||||
return useQuery({
|
return useQuery({
|
||||||
queryKey: ['skate-events', limit, getQueryKeySuffix()],
|
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 () => {
|
queryFn: async () => {
|
||||||
const signal = AbortSignal.timeout(15000);
|
const signal = AbortSignal.timeout(15000);
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -6,13 +6,17 @@ import { useAdminModeration, filterAdminHidden } from '@/hooks/useAdminModeratio
|
||||||
/**
|
/**
|
||||||
* Hook to fetch skate shops using the skate relay logic
|
* 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 { querySkateContent, getQueryKeySuffix } = useSkateRelays();
|
||||||
const hidden = useAdminModeration();
|
const hidden = useAdminModeration();
|
||||||
|
|
||||||
return useQuery<SkateShop[], Error, SkateShop[]>({
|
return useQuery<SkateShop[], Error, SkateShop[]>({
|
||||||
queryKey: ['skate-shops', limit, getQueryKeySuffix()],
|
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 () => {
|
queryFn: async () => {
|
||||||
const signal = AbortSignal.timeout(10000);
|
const signal = AbortSignal.timeout(10000);
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -15,13 +15,16 @@ const STALE_TIME = 5 * 60 * 1000; // 5 min
|
||||||
const GC_TIME = 30 * 60 * 1000; // 30 min
|
const GC_TIME = 30 * 60 * 1000; // 30 min
|
||||||
const TIMEOUT = 8000;
|
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 { querySkateContent, getQueryKeySuffix } = useSkateRelays();
|
||||||
const hidden = useAdminModeration();
|
const hidden = useAdminModeration();
|
||||||
|
|
||||||
return useQuery({
|
return useQuery({
|
||||||
queryKey: ['skate-tours', limit, getQueryKeySuffix()],
|
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 () => {
|
queryFn: async () => {
|
||||||
const signal = AbortSignal.timeout(TIMEOUT);
|
const signal = AbortSignal.timeout(TIMEOUT);
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -12,6 +12,34 @@ import type { SkateSpot } from '@/lib/types';
|
||||||
const SUSPICIOUS_TITLE =
|
const SUSPICIOUS_TITLE =
|
||||||
/(^|\b)(test|testing|asdf|asd|qwerty|foo|bar|untitled|demo|sample|example|secret|airport|my (house|home|flat|apartment))(\b|$)/i;
|
/(^|\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 {
|
export interface SpotQualitySignals {
|
||||||
/** Mapper's web-of-trust score (0–100), when known. */
|
/** Mapper's web-of-trust score (0–100), when known. */
|
||||||
mapperTrust?: number;
|
mapperTrust?: number;
|
||||||
|
|
@ -23,15 +51,12 @@ export interface SpotQualitySignals {
|
||||||
|
|
||||||
/** Every flag that applies, as short human-readable labels. */
|
/** Every flag that applies, as short human-readable labels. */
|
||||||
export function spotFlags(spot: SkateSpot, extra: SpotQualitySignals = {}): string[] {
|
export function spotFlags(spot: SkateSpot, extra: SpotQualitySignals = {}): string[] {
|
||||||
const flags: string[] = [];
|
const flags = contentFlags({
|
||||||
const title = (spot.title ?? '').trim();
|
title: spot.title,
|
||||||
const description = (spot.description ?? '').trim();
|
description: spot.description,
|
||||||
|
imageCount: spot.images?.length ?? 0,
|
||||||
if (SUSPICIOUS_TITLE.test(title)) flags.push('suspicious name');
|
hasLocation: !!spot.geohash,
|
||||||
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).
|
// Signals that need extra data (passed in by the review queue).
|
||||||
if (typeof extra.mapperTrust === 'number' && extra.mapperTrust <= 0) {
|
if (typeof extra.mapperTrust === 'number' && extra.mapperTrust <= 0) {
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
import { useMemo } from 'react';
|
import { useMemo, useState } from 'react';
|
||||||
import { Link } from 'react-router-dom';
|
import { Link } from 'react-router-dom';
|
||||||
import { useSeoMeta } from '@unhead/react';
|
import { useSeoMeta } from '@unhead/react';
|
||||||
import {
|
import {
|
||||||
|
|
@ -9,9 +9,9 @@ import {
|
||||||
Loader2,
|
Loader2,
|
||||||
CheckCircle2,
|
CheckCircle2,
|
||||||
BadgeCheck,
|
BadgeCheck,
|
||||||
Undo2,
|
|
||||||
Users,
|
Users,
|
||||||
ShieldQuestion,
|
ShieldQuestion,
|
||||||
|
Undo2,
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import { PageHeader } from '@/components/PageHeader';
|
import { PageHeader } from '@/components/PageHeader';
|
||||||
import { Card, CardContent } from '@/components/ui/card';
|
import { Card, CardContent } from '@/components/ui/card';
|
||||||
|
|
@ -19,6 +19,10 @@ import { Badge } from '@/components/ui/badge';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { Skeleton } from '@/components/ui/skeleton';
|
import { Skeleton } from '@/components/ui/skeleton';
|
||||||
import { useSkateSpots } from '@/hooks/useSkateSpots';
|
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 { useBatchTrustScores } from '@/hooks/useBatchTrustScores';
|
||||||
import { useBatchSpotStatus } from '@/hooks/useSpotStatus';
|
import { useBatchSpotStatus } from '@/hooks/useSpotStatus';
|
||||||
import { useBatchSpotPhotos } from '@/hooks/useSpotContributions';
|
import { useBatchSpotPhotos } from '@/hooks/useSpotContributions';
|
||||||
|
|
@ -29,27 +33,66 @@ import { useNotASpotReport } from '@/hooks/useNotASpotReport';
|
||||||
import { useCurrentUser } from '@/hooks/useCurrentUser';
|
import { useCurrentUser } from '@/hooks/useCurrentUser';
|
||||||
import { useToast } from '@/hooks/useToast';
|
import { useToast } from '@/hooks/useToast';
|
||||||
import { isAdminPubkey } from '@/lib/admins';
|
import { isAdminPubkey } from '@/lib/admins';
|
||||||
import { spotFlags } from '@/lib/spotQuality';
|
import { contentFlags } from '@/lib/spotQuality';
|
||||||
import { SKATE_SPOT_KIND } from '@/lib/types';
|
import {
|
||||||
|
SKATE_SPOT_KIND,
|
||||||
|
SKATE_SHOP_KIND,
|
||||||
|
CALENDAR_EVENT_KIND,
|
||||||
|
SKATE_TOUR_KIND,
|
||||||
|
MARKETPLACE_ITEM_KIND,
|
||||||
|
} from '@/lib/types';
|
||||||
import NotFound from '@/pages/NotFound';
|
import NotFound from '@/pages/NotFound';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Admin review queue.
|
* Admin review queue.
|
||||||
*
|
*
|
||||||
* Surfaces spots that LOOK like junk using cheap, explainable signals — a test
|
* Covers EVERY content kind we can hide (spots, shops, events, tours, gear) —
|
||||||
* entry or an off-topic pin trips several at once ("test of secret island
|
* the hide/verify labels were always kind-agnostic (`a`/`e` tags), so the queue
|
||||||
* airport": suspicious name + no photo + no description + no community signal).
|
* just needed to stop being spot-only.
|
||||||
*
|
*
|
||||||
* Nothing here deletes: **Hide** publishes a public, reversible NIP-32 label,
|
* Nothing here deletes: **Hide** publishes a public, reversible NIP-32 label,
|
||||||
* **Confirm** publishes the opposite (`verified`) so a human review can IMPROVE
|
* **Confirm** publishes the opposite (`verified`) so a review can IMPROVE an
|
||||||
* a spot's standing and clear it from this queue, and the report sends NIP-56.
|
* item's standing, and **Undo** retracts the verdict back to neutral. Human
|
||||||
* Flags are heuristics — a human decides.
|
* reports (NIP-56) always rank above the automated heuristics. Flags are
|
||||||
|
* hypotheses — a human decides.
|
||||||
*/
|
*/
|
||||||
export default function AdminReviewPage() {
|
|
||||||
const { user } = useCurrentUser();
|
type QueueKind = 'spots' | 'shops' | 'events' | 'tours' | 'gear';
|
||||||
// includeHidden: a spot you just hid must stay listed here, or there is no
|
|
||||||
// way to undo the verdict.
|
const TABS: Array<{ id: QueueKind; label: string }> = [
|
||||||
const { data: spots, isLoading } = useSkateSpots(300, { includeHidden: true });
|
{ 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 { hideContent, isPending: hiding } = useAdminHide();
|
||||||
const { confirmContent, isPending: confirming } = useAdminVerify();
|
const { confirmContent, isPending: confirming } = useAdminVerify();
|
||||||
const { clearVerdict, isPending: clearing } = useAdminClear();
|
const { clearVerdict, isPending: clearing } = useAdminClear();
|
||||||
|
|
@ -57,148 +100,99 @@ export default function AdminReviewPage() {
|
||||||
const { reportNotASpot } = useNotASpotReport();
|
const { reportNotASpot } = useNotASpotReport();
|
||||||
const { toast } = useToast();
|
const { toast } = useToast();
|
||||||
|
|
||||||
// Signals that need batch data.
|
const mappers = useMemo(() => [...new Set(rows.map((r) => r.pubkey))], [rows]);
|
||||||
const mappers = useMemo(
|
|
||||||
() => [...new Set((spots ?? []).map((s) => s.pubkey))],
|
|
||||||
[spots],
|
|
||||||
);
|
|
||||||
const { data: trustScores } = useBatchTrustScores(mappers);
|
const { data: trustScores } = useBatchTrustScores(mappers);
|
||||||
|
const { getCounts } = useSpotReports(rows);
|
||||||
|
|
||||||
const statusInput = useMemo(
|
const prepared = 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(
|
|
||||||
() =>
|
() =>
|
||||||
(spots ?? [])
|
rows
|
||||||
.map((spot) => {
|
.map((row) => {
|
||||||
const coord = `${SKATE_SPOT_KIND}:${spot.pubkey}:${spot.identifier}`;
|
const coord = `${row.kind}:${row.pubkey}:${row.identifier}`;
|
||||||
const status = getStatus(spot.pubkey, spot.identifier);
|
const hidden = moderation.has({ coord, id: row.id });
|
||||||
const hidden = moderation.has({ coord, id: spot.id });
|
const verified = moderation.isVerified({ coord, id: row.id });
|
||||||
const verified = moderation.isVerified({ coord, id: spot.id });
|
|
||||||
const photos = getCommunityPhotoCount(spot.pubkey, spot.identifier);
|
const flags = contentFlags({
|
||||||
const reporters = status?.reporterCount ?? 0;
|
title: row.title,
|
||||||
const reports = getReportCounts(spot.id);
|
description: row.description,
|
||||||
const flags = spotFlags(spot, {
|
imageCount: row.imageCount,
|
||||||
mapperTrust: trustScores?.get(spot.pubkey),
|
hasLocation: row.hasLocation,
|
||||||
communityStatusReports: reporters,
|
|
||||||
communityPhotos: photos,
|
|
||||||
});
|
});
|
||||||
// Human reports outrank heuristics: someone looked and objected.
|
|
||||||
if (reports.notASpot > 0) flags.unshift('reported: not a skate spot');
|
const trust = trustScores?.get(row.pubkey);
|
||||||
else if (reports.total > 0) flags.unshift(`reported ×${reports.total}`);
|
if (typeof trust === 'number' && trust <= 0) {
|
||||||
return {
|
flags.push('mapper not in your web of trust');
|
||||||
spot,
|
}
|
||||||
coord,
|
if (row.communityStatusReports !== undefined || row.communityPhotos !== undefined) {
|
||||||
hidden,
|
const signals = (row.communityStatusReports ?? 0) + (row.communityPhotos ?? 0);
|
||||||
verified,
|
if (signals === 0 && flags.length > 0) flags.push('no community signals');
|
||||||
// An already-actioned row is kept so the admin can UNDO it.
|
}
|
||||||
actioned: hidden || verified,
|
|
||||||
trust: trustScores?.get(spot.pubkey),
|
const reports = getCounts(row.id);
|
||||||
reporters,
|
if (reports.total > 0) {
|
||||||
photos,
|
flags.unshift(
|
||||||
reports,
|
reports.notASpot > 0 && row.kind === SKATE_SPOT_KIND
|
||||||
flags,
|
? '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)
|
.filter((r) => r.actioned || r.flags.length > 0)
|
||||||
// Un-actioned first; within that, human reports before heuristics;
|
|
||||||
// actioned rows sink to the bottom (they are done, but undoable).
|
|
||||||
.sort((a, b) => {
|
.sort((a, b) => {
|
||||||
if (a.actioned !== b.actioned) return a.actioned ? 1 : -1;
|
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.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;
|
if (a.reports.total !== b.reports.total) return b.reports.total - a.reports.total;
|
||||||
return b.flags.length - a.flags.length;
|
return b.flags.length - a.flags.length;
|
||||||
}),
|
}),
|
||||||
[spots, moderation, trustScores, getStatus, getCommunityPhotoCount, getReportCounts],
|
[rows, moderation, trustScores, getCounts],
|
||||||
);
|
);
|
||||||
|
|
||||||
useSeoMeta({ title: 'Review queue — SkateSpots' });
|
const act = async (
|
||||||
|
fn: () => Promise<void>,
|
||||||
// Admins only. Everyone else sees a 404 rather than a hint that this exists.
|
ok: { title: string; description: string },
|
||||||
if (!isAdminPubkey(user?.pubkey)) return <NotFound />;
|
fail: string,
|
||||||
|
) => {
|
||||||
const handleHide = async (coord: string, id: string, flags: string[]) => {
|
|
||||||
try {
|
try {
|
||||||
await hideContent({ coord, id, reason: `auto-flagged: ${flags.join(', ')}` });
|
await fn();
|
||||||
toast({
|
toast(ok);
|
||||||
title: 'Hidden',
|
|
||||||
description: 'Removed from the app via a public moderation label. Reversible.',
|
|
||||||
});
|
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
toast({
|
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',
|
|
||||||
});
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
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.',
|
description: e instanceof Error ? e.message : 'Try again.',
|
||||||
variant: 'destructive',
|
variant: 'destructive',
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
if (isLoading) {
|
||||||
return (
|
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">
|
<div className="space-y-3">
|
||||||
<Skeleton className="h-24 w-full rounded-2xl" />
|
<Skeleton className="h-24 w-full rounded-2xl" />
|
||||||
<Skeleton className="h-24 w-full rounded-2xl" />
|
<Skeleton className="h-24 w-full rounded-2xl" />
|
||||||
</div>
|
</div>
|
||||||
) : rows.length === 0 ? (
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (prepared.length === 0) {
|
||||||
|
return (
|
||||||
<Card className="border-dashed border-white/10">
|
<Card className="border-dashed border-white/10">
|
||||||
<CardContent className="space-y-3 py-12 text-center">
|
<CardContent className="space-y-3 py-12 text-center">
|
||||||
<CheckCircle2 className="mx-auto h-8 w-8 text-emerald-500" />
|
<CheckCircle2 className="mx-auto h-8 w-8 text-emerald-500" />
|
||||||
<p className="text-sm text-gray-400">
|
<p className="text-sm text-gray-400">
|
||||||
Nothing to review — no spot currently trips the junk signals.
|
Nothing to review — nothing of this kind trips the junk signals.
|
||||||
</p>
|
</p>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
) : (
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
{rows.map(({ spot, coord, flags, trust, reporters, photos, hidden, verified }) => (
|
{prepared.map(({ row, coord, hidden, verified, trust, reports, flags }) => (
|
||||||
<Card
|
<Card
|
||||||
key={spot.id}
|
key={row.id}
|
||||||
className={
|
className={
|
||||||
hidden
|
hidden
|
||||||
? 'border-2 border-red-500/25 bg-red-500/[0.04]'
|
? 'border-2 border-red-500/25 bg-red-500/[0.04]'
|
||||||
|
|
@ -209,12 +203,8 @@ export default function AdminReviewPage() {
|
||||||
>
|
>
|
||||||
<CardContent className="space-y-3 p-4">
|
<CardContent className="space-y-3 p-4">
|
||||||
<div className="flex items-start gap-3">
|
<div className="flex items-start gap-3">
|
||||||
{spot.images?.[0] ? (
|
{row.image ? (
|
||||||
<img
|
<img src={row.image} alt="" className="h-14 w-14 shrink-0 rounded-lg object-cover" />
|
||||||
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 className="flex h-14 w-14 shrink-0 items-center justify-center rounded-lg bg-white/10 text-xl">
|
||||||
🛹
|
🛹
|
||||||
|
|
@ -234,18 +224,20 @@ export default function AdminReviewPage() {
|
||||||
Confirmed
|
Confirmed
|
||||||
</Badge>
|
</Badge>
|
||||||
)}
|
)}
|
||||||
<span className="truncate">{spot.title || '(untitled)'}</span>
|
<span className="truncate">{row.title || '(untitled)'}</span>
|
||||||
</p>
|
</p>
|
||||||
<p className="truncate text-xs text-gray-500">
|
<p className="truncate text-xs text-gray-500">
|
||||||
{spot.description?.trim() || 'no description'}
|
{row.description?.trim() || 'no description'}
|
||||||
</p>
|
</p>
|
||||||
<div className="mt-1.5 flex flex-wrap gap-1">
|
<div className="mt-1.5 flex flex-wrap gap-1">
|
||||||
{!hidden && !verified && flags.map((f) => (
|
{!hidden &&
|
||||||
|
!verified &&
|
||||||
|
flags.map((f) => (
|
||||||
<Badge
|
<Badge
|
||||||
key={f}
|
key={f}
|
||||||
className={
|
className={
|
||||||
f.startsWith('reported')
|
f.startsWith('reported')
|
||||||
? 'border border-red-500/50 bg-red-500/20 text-[10px] font-black uppercase tracking-wide text-red-300'
|
? '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'
|
: 'border border-amber-500/40 bg-amber-500/15 text-[10px] font-bold uppercase tracking-wide text-amber-300'
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
|
|
@ -262,35 +254,49 @@ export default function AdminReviewPage() {
|
||||||
)}
|
)}
|
||||||
mapper trust {typeof trust === 'number' ? trust : '—'}
|
mapper trust {typeof trust === 'number' ? trust : '—'}
|
||||||
</span>
|
</span>
|
||||||
|
{row.communityStatusReports !== undefined && (
|
||||||
<span className="flex items-center gap-1">
|
<span className="flex items-center gap-1">
|
||||||
<Users className="h-3 w-3" />
|
<Users className="h-3 w-3" />
|
||||||
{reporters > 0 || photos > 0
|
{row.communityStatusReports + (row.communityPhotos ?? 0) > 0
|
||||||
? [
|
? [
|
||||||
reporters > 0 ? `${reporters} status report${reporters === 1 ? '' : 's'}` : null,
|
row.communityStatusReports > 0
|
||||||
photos > 0 ? `${photos} community photo${photos === 1 ? '' : 's'}` : null,
|
? `${row.communityStatusReports} status report${row.communityStatusReports === 1 ? '' : 's'}`
|
||||||
|
: null,
|
||||||
|
(row.communityPhotos ?? 0) > 0
|
||||||
|
? `${row.communityPhotos} community photo${row.communityPhotos === 1 ? '' : 's'}`
|
||||||
|
: null,
|
||||||
]
|
]
|
||||||
.filter(Boolean)
|
.filter(Boolean)
|
||||||
.join(' · ')
|
.join(' · ')
|
||||||
: 'no community signals'}
|
: 'no community signals'}
|
||||||
</span>
|
</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>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex flex-wrap gap-2">
|
<div className="flex flex-wrap gap-2">
|
||||||
{(hidden || verified) ? (
|
{hidden || verified ? (
|
||||||
<Button
|
<Button
|
||||||
size="sm"
|
size="sm"
|
||||||
variant="outline"
|
variant="outline"
|
||||||
className="gap-1.5 border-white/20 text-gray-200 hover:bg-white/10"
|
className="gap-1.5 border-white/20 text-gray-200 hover:bg-white/10"
|
||||||
disabled={clearing}
|
disabled={clearing}
|
||||||
onClick={() => void handleUndo(coord, spot.id)}
|
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 ? (
|
{clearing ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : <Undo2 className="h-3.5 w-3.5" />}
|
||||||
<Loader2 className="h-3.5 w-3.5 animate-spin" />
|
|
||||||
) : (
|
|
||||||
<Undo2 className="h-3.5 w-3.5" />
|
|
||||||
)}
|
|
||||||
Undo
|
Undo
|
||||||
</Button>
|
</Button>
|
||||||
) : (
|
) : (
|
||||||
|
|
@ -299,13 +305,15 @@ export default function AdminReviewPage() {
|
||||||
size="sm"
|
size="sm"
|
||||||
className="gap-1.5 bg-emerald-500 font-bold text-black hover:bg-emerald-400"
|
className="gap-1.5 bg-emerald-500 font-bold text-black hover:bg-emerald-400"
|
||||||
disabled={confirming}
|
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 ? (
|
{confirming ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : <BadgeCheck className="h-3.5 w-3.5" />}
|
||||||
<Loader2 className="h-3.5 w-3.5 animate-spin" />
|
|
||||||
) : (
|
|
||||||
<BadgeCheck className="h-3.5 w-3.5" />
|
|
||||||
)}
|
|
||||||
Confirm
|
Confirm
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
|
|
@ -313,34 +321,34 @@ export default function AdminReviewPage() {
|
||||||
variant="outline"
|
variant="outline"
|
||||||
className="gap-1.5 border-red-500/40 text-red-400 hover:bg-red-500/10"
|
className="gap-1.5 border-red-500/40 text-red-400 hover:bg-red-500/10"
|
||||||
disabled={hiding}
|
disabled={hiding}
|
||||||
onClick={() => void handleHide(coord, spot.id, flags)}
|
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 ? (
|
{hiding ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : <EyeOff className="h-3.5 w-3.5" />}
|
||||||
<Loader2 className="h-3.5 w-3.5 animate-spin" />
|
|
||||||
) : (
|
|
||||||
<EyeOff className="h-3.5 w-3.5" />
|
|
||||||
)}
|
|
||||||
Hide
|
Hide
|
||||||
</Button>
|
</Button>
|
||||||
|
{row.kind === SKATE_SPOT_KIND && (
|
||||||
<Button
|
<Button
|
||||||
size="sm"
|
size="sm"
|
||||||
variant="outline"
|
variant="outline"
|
||||||
className="gap-1.5 border-amber-500/40 text-amber-400 hover:bg-amber-500/10"
|
className="gap-1.5 border-amber-500/40 text-amber-400 hover:bg-amber-500/10"
|
||||||
onClick={() =>
|
onClick={() =>
|
||||||
void reportNotASpot({
|
void reportNotASpot({ eventId: row.id, pubkey: row.pubkey, title: row.title })
|
||||||
eventId: spot.id,
|
|
||||||
pubkey: spot.pubkey,
|
|
||||||
title: spot.title,
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<Flag className="h-3.5 w-3.5" />
|
<Flag className="h-3.5 w-3.5" />
|
||||||
Not a skate spot
|
Not a skate spot
|
||||||
</Button>
|
</Button>
|
||||||
|
)}
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
<Button asChild size="sm" variant="ghost" className="gap-1.5 text-gray-400">
|
<Button asChild size="sm" variant="ghost" className="gap-1.5 text-gray-400">
|
||||||
<Link to={`/spot/${spot.pubkey}/${spot.identifier}`}>
|
<Link to={row.href}>
|
||||||
<ExternalLink className="h-3.5 w-3.5" />
|
<ExternalLink className="h-3.5 w-3.5" />
|
||||||
Look at it
|
Look at it
|
||||||
</Link>
|
</Link>
|
||||||
|
|
@ -350,14 +358,158 @@ export default function AdminReviewPage() {
|
||||||
</Card>
|
</Card>
|
||||||
))}
|
))}
|
||||||
</div>
|
</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">
|
<p className="mt-6 text-[11px] leading-relaxed text-gray-600">
|
||||||
<strong className="text-gray-500">Hide</strong> publishes{' '}
|
<strong className="text-gray-500">Hide</strong> publishes{' '}
|
||||||
<code>org.skatespots.moderation / hidden</code>;{' '}
|
<code>org.skatespots.moderation / hidden</code>;{' '}
|
||||||
<strong className="text-gray-500">Confirm</strong> publishes{' '}
|
<strong className="text-gray-500">Confirm</strong> publishes <code>… / verified</code>;{' '}
|
||||||
<code>… / verified</code>, which clears the spot from this queue and badges it as
|
<strong className="text-gray-500">Undo</strong> publishes <code>… / cleared</code> (back to
|
||||||
staff-vouched. Both are public, reversible labels — the spot itself stays on relays.
|
neutral). All three are public, reversible labels — nothing is ever deleted from relays.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -260,19 +260,10 @@ export default function CreateSpotPage() {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// A photo is REQUIRED. It is the single cheapest defence against the map
|
// A photo is strongly recommended but NOT required: blocking an honest
|
||||||
// filling up with test entries and off-topic pins ("test of secret island
|
// mapper who has no picture on hand is worse than accepting a photo-less
|
||||||
// airport"): a real spot has a photo, a typo doesn't. It also gives every
|
// pin. Spots without one simply stay "Unverified" (see the spot page badge)
|
||||||
// other skater something to verify against.
|
// and the review queue flags them, so a human can look later.
|
||||||
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;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Don't publish while the Quick Snap photo is still uploading — otherwise the
|
// Don't publish while the Quick Snap photo is still uploading — otherwise the
|
||||||
// spot would go out without the photo the user just took.
|
// spot would go out without the photo the user just took.
|
||||||
|
|
@ -408,7 +399,9 @@ export default function CreateSpotPage() {
|
||||||
|
|
||||||
toast({
|
toast({
|
||||||
title: 'Spot added!',
|
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
|
// Navigate to spot detail using pubkey and identifier
|
||||||
|
|
@ -732,9 +725,8 @@ export default function CreateSpotPage() {
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label>
|
<Label>
|
||||||
{t('spots.photos')}
|
{t('spots.photos')}
|
||||||
<span className="ml-1 text-red-400">*</span>
|
|
||||||
<span className="ml-2 text-[11px] font-normal text-muted-foreground">
|
<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>
|
</span>
|
||||||
</Label>
|
</Label>
|
||||||
<div className="grid grid-cols-3 gap-4">
|
<div className="grid grid-cols-3 gap-4">
|
||||||
|
|
@ -848,7 +840,7 @@ export default function CreateSpotPage() {
|
||||||
<Button
|
<Button
|
||||||
type="submit"
|
type="submit"
|
||||||
className="w-full"
|
className="w-full"
|
||||||
disabled={isPublishing || isUploading || isUploadingQuickPhoto || images.length === 0}
|
disabled={isPublishing || isUploading || isUploadingQuickPhoto}
|
||||||
>
|
>
|
||||||
{isPublishing ? (
|
{isPublishing ? (
|
||||||
<>
|
<>
|
||||||
|
|
|
||||||
|
|
@ -1649,7 +1649,7 @@ export default function MapPage() {
|
||||||
{/* Empty state — the active filters hid everything. Without this
|
{/* Empty state — the active filters hid everything. Without this
|
||||||
the map just looks blank/unloaded. Only shown when the
|
the map just looks blank/unloaded. Only shown when the
|
||||||
spot/shop layers are on (other layers have their own markers). */}
|
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-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">
|
<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>
|
<p className="font-black uppercase tracking-wide text-white">No spots match</p>
|
||||||
|
|
@ -1661,6 +1661,39 @@ export default function MapPage() {
|
||||||
</div>
|
</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
|
{/* Map attribution — bottom-left, sits ABOVE the mobile bottom
|
||||||
nav so it never covers the Shops & Spots buttons. Tiny + subtle. */}
|
nav so it never covers the Shops & Spots buttons. Tiny + subtle. */}
|
||||||
<div
|
<div
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue