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
|
||||
("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/* →
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
||||
|
|
|
|||
|
|
@ -6,13 +6,17 @@ import { useAdminModeration, filterAdminHidden } from '@/hooks/useAdminModeratio
|
|||
/**
|
||||
* Hook to fetch skate shops using the skate relay logic
|
||||
*/
|
||||
export function useSkateShops(limit: number = 100) {
|
||||
export function useSkateShops(limit: number = 100, options: { includeHidden?: boolean } = {}) {
|
||||
const { includeHidden = false } = options;
|
||||
const { querySkateContent, getQueryKeySuffix } = useSkateRelays();
|
||||
const hidden = useAdminModeration();
|
||||
|
||||
return useQuery<SkateShop[], Error, SkateShop[]>({
|
||||
queryKey: ['skate-shops', limit, getQueryKeySuffix()],
|
||||
select: (shops: SkateShop[]) => filterAdminHidden(shops, SKATE_SHOP_KIND, hidden),
|
||||
// `includeHidden` is for ADMIN TOOLING only (the review queue) so a hidden
|
||||
// item stays visible and its verdict can be undone.
|
||||
select: (shops: SkateShop[]) =>
|
||||
includeHidden ? shops : filterAdminHidden(shops, SKATE_SHOP_KIND, hidden),
|
||||
queryFn: async () => {
|
||||
const signal = AbortSignal.timeout(10000);
|
||||
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import { useMemo } from 'react';
|
||||
import { useMemo, useState } from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { useSeoMeta } from '@unhead/react';
|
||||
import {
|
||||
|
|
@ -9,9 +9,9 @@ import {
|
|||
Loader2,
|
||||
CheckCircle2,
|
||||
BadgeCheck,
|
||||
Undo2,
|
||||
Users,
|
||||
ShieldQuestion,
|
||||
Undo2,
|
||||
} from 'lucide-react';
|
||||
import { PageHeader } from '@/components/PageHeader';
|
||||
import { Card, CardContent } from '@/components/ui/card';
|
||||
|
|
@ -19,6 +19,10 @@ import { Badge } from '@/components/ui/badge';
|
|||
import { Button } from '@/components/ui/button';
|
||||
import { Skeleton } from '@/components/ui/skeleton';
|
||||
import { useSkateSpots } from '@/hooks/useSkateSpots';
|
||||
import { useSkateShops } from '@/hooks/useSkateShops';
|
||||
import { useSkateEvents } from '@/hooks/useSkateEvents';
|
||||
import { useTours } from '@/hooks/useTours';
|
||||
import { useMarketplaceItems } from '@/hooks/useMarketplace';
|
||||
import { useBatchTrustScores } from '@/hooks/useBatchTrustScores';
|
||||
import { useBatchSpotStatus } from '@/hooks/useSpotStatus';
|
||||
import { useBatchSpotPhotos } from '@/hooks/useSpotContributions';
|
||||
|
|
@ -29,27 +33,66 @@ import { useNotASpotReport } from '@/hooks/useNotASpotReport';
|
|||
import { useCurrentUser } from '@/hooks/useCurrentUser';
|
||||
import { useToast } from '@/hooks/useToast';
|
||||
import { isAdminPubkey } from '@/lib/admins';
|
||||
import { spotFlags } from '@/lib/spotQuality';
|
||||
import { SKATE_SPOT_KIND } from '@/lib/types';
|
||||
import { contentFlags } from '@/lib/spotQuality';
|
||||
import {
|
||||
SKATE_SPOT_KIND,
|
||||
SKATE_SHOP_KIND,
|
||||
CALENDAR_EVENT_KIND,
|
||||
SKATE_TOUR_KIND,
|
||||
MARKETPLACE_ITEM_KIND,
|
||||
} from '@/lib/types';
|
||||
import NotFound from '@/pages/NotFound';
|
||||
|
||||
/**
|
||||
* Admin review queue.
|
||||
*
|
||||
* Surfaces spots that LOOK like junk using cheap, explainable signals — a test
|
||||
* entry or an off-topic pin trips several at once ("test of secret island
|
||||
* airport": suspicious name + no photo + no description + no community signal).
|
||||
* Covers EVERY content kind we can hide (spots, shops, events, tours, gear) —
|
||||
* the hide/verify labels were always kind-agnostic (`a`/`e` tags), so the queue
|
||||
* just needed to stop being spot-only.
|
||||
*
|
||||
* Nothing here deletes: **Hide** publishes a public, reversible NIP-32 label,
|
||||
* **Confirm** publishes the opposite (`verified`) so a human review can IMPROVE
|
||||
* a spot's standing and clear it from this queue, and the report sends NIP-56.
|
||||
* Flags are heuristics — a human decides.
|
||||
* **Confirm** publishes the opposite (`verified`) so a review can IMPROVE an
|
||||
* item's standing, and **Undo** retracts the verdict back to neutral. Human
|
||||
* reports (NIP-56) always rank above the automated heuristics. Flags are
|
||||
* hypotheses — a human decides.
|
||||
*/
|
||||
export default function AdminReviewPage() {
|
||||
const { user } = useCurrentUser();
|
||||
// includeHidden: a spot you just hid must stay listed here, or there is no
|
||||
// way to undo the verdict.
|
||||
const { data: spots, isLoading } = useSkateSpots(300, { includeHidden: true });
|
||||
|
||||
type QueueKind = 'spots' | 'shops' | 'events' | 'tours' | 'gear';
|
||||
|
||||
const TABS: Array<{ id: QueueKind; label: string }> = [
|
||||
{ id: 'spots', label: 'Spots' },
|
||||
{ id: 'shops', label: 'Shops' },
|
||||
{ id: 'events', label: 'Events' },
|
||||
{ id: 'tours', label: 'Tours' },
|
||||
{ id: 'gear', label: 'Gear' },
|
||||
];
|
||||
|
||||
/** One reviewable item, flattened from whatever kind it came from. */
|
||||
interface QueueRow {
|
||||
id: string;
|
||||
pubkey: string;
|
||||
identifier: string;
|
||||
kind: number;
|
||||
title: string;
|
||||
description: string;
|
||||
image?: string;
|
||||
imageCount: number;
|
||||
hasLocation: boolean;
|
||||
href: string;
|
||||
/** Spot-only signals (others have no status/photo data). */
|
||||
communityStatusReports?: number;
|
||||
communityPhotos?: number;
|
||||
}
|
||||
|
||||
// ─── Shared rendering: trust + reports + verdicts + actions ──────────────────
|
||||
|
||||
function QueueList({
|
||||
rows,
|
||||
isLoading,
|
||||
}: {
|
||||
rows: QueueRow[];
|
||||
isLoading: boolean;
|
||||
}) {
|
||||
const { hideContent, isPending: hiding } = useAdminHide();
|
||||
const { confirmContent, isPending: confirming } = useAdminVerify();
|
||||
const { clearVerdict, isPending: clearing } = useAdminClear();
|
||||
|
|
@ -57,255 +100,220 @@ export default function AdminReviewPage() {
|
|||
const { reportNotASpot } = useNotASpotReport();
|
||||
const { toast } = useToast();
|
||||
|
||||
// Signals that need batch data.
|
||||
const mappers = useMemo(
|
||||
() => [...new Set((spots ?? []).map((s) => s.pubkey))],
|
||||
[spots],
|
||||
);
|
||||
const mappers = useMemo(() => [...new Set(rows.map((r) => r.pubkey))], [rows]);
|
||||
const { data: trustScores } = useBatchTrustScores(mappers);
|
||||
const { getCounts } = useSpotReports(rows);
|
||||
|
||||
const statusInput = useMemo(
|
||||
() => (spots ?? []).map((s) => ({ pubkey: s.pubkey, identifier: s.identifier })),
|
||||
[spots],
|
||||
);
|
||||
const { getStatus } = useBatchSpotStatus(statusInput);
|
||||
const { getCommunityPhotoCount } = useBatchSpotPhotos(statusInput);
|
||||
// What people actually reported — the strongest signal we have.
|
||||
const { getCounts: getReportCounts } = useSpotReports(spots ?? []);
|
||||
|
||||
const rows = useMemo(
|
||||
const prepared = useMemo(
|
||||
() =>
|
||||
(spots ?? [])
|
||||
.map((spot) => {
|
||||
const coord = `${SKATE_SPOT_KIND}:${spot.pubkey}:${spot.identifier}`;
|
||||
const status = getStatus(spot.pubkey, spot.identifier);
|
||||
const hidden = moderation.has({ coord, id: spot.id });
|
||||
const verified = moderation.isVerified({ coord, id: spot.id });
|
||||
const photos = getCommunityPhotoCount(spot.pubkey, spot.identifier);
|
||||
const reporters = status?.reporterCount ?? 0;
|
||||
const reports = getReportCounts(spot.id);
|
||||
const flags = spotFlags(spot, {
|
||||
mapperTrust: trustScores?.get(spot.pubkey),
|
||||
communityStatusReports: reporters,
|
||||
communityPhotos: photos,
|
||||
rows
|
||||
.map((row) => {
|
||||
const coord = `${row.kind}:${row.pubkey}:${row.identifier}`;
|
||||
const hidden = moderation.has({ coord, id: row.id });
|
||||
const verified = moderation.isVerified({ coord, id: row.id });
|
||||
|
||||
const flags = contentFlags({
|
||||
title: row.title,
|
||||
description: row.description,
|
||||
imageCount: row.imageCount,
|
||||
hasLocation: row.hasLocation,
|
||||
});
|
||||
// Human reports outrank heuristics: someone looked and objected.
|
||||
if (reports.notASpot > 0) flags.unshift('reported: not a skate spot');
|
||||
else if (reports.total > 0) flags.unshift(`reported ×${reports.total}`);
|
||||
return {
|
||||
spot,
|
||||
coord,
|
||||
hidden,
|
||||
verified,
|
||||
// An already-actioned row is kept so the admin can UNDO it.
|
||||
actioned: hidden || verified,
|
||||
trust: trustScores?.get(spot.pubkey),
|
||||
reporters,
|
||||
photos,
|
||||
reports,
|
||||
flags,
|
||||
};
|
||||
|
||||
const trust = trustScores?.get(row.pubkey);
|
||||
if (typeof trust === 'number' && trust <= 0) {
|
||||
flags.push('mapper not in your web of trust');
|
||||
}
|
||||
if (row.communityStatusReports !== undefined || row.communityPhotos !== undefined) {
|
||||
const signals = (row.communityStatusReports ?? 0) + (row.communityPhotos ?? 0);
|
||||
if (signals === 0 && flags.length > 0) flags.push('no community signals');
|
||||
}
|
||||
|
||||
const reports = getCounts(row.id);
|
||||
if (reports.total > 0) {
|
||||
flags.unshift(
|
||||
reports.notASpot > 0 && row.kind === SKATE_SPOT_KIND
|
||||
? 'reported: not a skate spot'
|
||||
: `reported ×${reports.total}`,
|
||||
);
|
||||
}
|
||||
|
||||
return { row, coord, hidden, verified, actioned: hidden || verified, trust, reports, flags };
|
||||
})
|
||||
.filter((row) => row.actioned || row.flags.length > 0)
|
||||
// Un-actioned first; within that, human reports before heuristics;
|
||||
// actioned rows sink to the bottom (they are done, but undoable).
|
||||
.filter((r) => r.actioned || r.flags.length > 0)
|
||||
.sort((a, b) => {
|
||||
if (a.actioned !== b.actioned) return a.actioned ? 1 : -1;
|
||||
if (a.reports.notASpot !== b.reports.notASpot) return b.reports.notASpot - a.reports.notASpot;
|
||||
if (a.reports.total !== b.reports.total) return b.reports.total - a.reports.total;
|
||||
return b.flags.length - a.flags.length;
|
||||
}),
|
||||
[spots, moderation, trustScores, getStatus, getCommunityPhotoCount, getReportCounts],
|
||||
[rows, moderation, trustScores, getCounts],
|
||||
);
|
||||
|
||||
useSeoMeta({ title: 'Review queue — SkateSpots' });
|
||||
|
||||
// Admins only. Everyone else sees a 404 rather than a hint that this exists.
|
||||
if (!isAdminPubkey(user?.pubkey)) return <NotFound />;
|
||||
|
||||
const handleHide = async (coord: string, id: string, flags: string[]) => {
|
||||
const act = async (
|
||||
fn: () => Promise<void>,
|
||||
ok: { title: string; description: string },
|
||||
fail: string,
|
||||
) => {
|
||||
try {
|
||||
await hideContent({ coord, id, reason: `auto-flagged: ${flags.join(', ')}` });
|
||||
toast({
|
||||
title: 'Hidden',
|
||||
description: 'Removed from the app via a public moderation label. Reversible.',
|
||||
});
|
||||
await fn();
|
||||
toast(ok);
|
||||
} catch (e) {
|
||||
toast({
|
||||
title: 'Could not hide',
|
||||
title: fail,
|
||||
description: e instanceof Error ? e.message : 'Try again.',
|
||||
variant: 'destructive',
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const handleUndo = async (coord: string, id: string) => {
|
||||
try {
|
||||
await clearVerdict({ coord, id, reason: 'verdict retracted' });
|
||||
toast({
|
||||
title: 'Undone',
|
||||
description: 'Back to neutral — not hidden, not vouched.',
|
||||
});
|
||||
} catch (e) {
|
||||
toast({
|
||||
title: 'Could not undo',
|
||||
description: e instanceof Error ? e.message : 'Try again.',
|
||||
variant: 'destructive',
|
||||
});
|
||||
}
|
||||
};
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
<Skeleton className="h-24 w-full rounded-2xl" />
|
||||
<Skeleton className="h-24 w-full rounded-2xl" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const handleConfirm = async (coord: string, id: string) => {
|
||||
try {
|
||||
await confirmContent({ coord, id, reason: 'looks legit after review' });
|
||||
toast({
|
||||
title: 'Confirmed',
|
||||
description: 'Vouched for — it drops off this queue and shows as verified.',
|
||||
});
|
||||
} catch (e) {
|
||||
toast({
|
||||
title: 'Could not confirm',
|
||||
description: e instanceof Error ? e.message : 'Try again.',
|
||||
variant: 'destructive',
|
||||
});
|
||||
}
|
||||
};
|
||||
if (prepared.length === 0) {
|
||||
return (
|
||||
<Card className="border-dashed border-white/10">
|
||||
<CardContent className="space-y-3 py-12 text-center">
|
||||
<CheckCircle2 className="mx-auto h-8 w-8 text-emerald-500" />
|
||||
<p className="text-sm text-gray-400">
|
||||
Nothing to review — nothing of this kind trips the junk signals.
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="container mx-auto max-w-3xl px-4 py-8 pb-24 md:pb-14">
|
||||
<PageHeader
|
||||
icon={ShieldAlert}
|
||||
title="Review queue"
|
||||
accent="#ff6600"
|
||||
subtitle="Spots that trip the cheap junk signals. Hide is a soft, reversible label; Confirm vouches for it. Nothing is deleted."
|
||||
/>
|
||||
|
||||
{isLoading ? (
|
||||
<div className="space-y-3">
|
||||
<Skeleton className="h-24 w-full rounded-2xl" />
|
||||
<Skeleton className="h-24 w-full rounded-2xl" />
|
||||
</div>
|
||||
) : rows.length === 0 ? (
|
||||
<Card className="border-dashed border-white/10">
|
||||
<CardContent className="space-y-3 py-12 text-center">
|
||||
<CheckCircle2 className="mx-auto h-8 w-8 text-emerald-500" />
|
||||
<p className="text-sm text-gray-400">
|
||||
Nothing to review — no spot currently trips the junk signals.
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
{rows.map(({ spot, coord, flags, trust, reporters, photos, hidden, verified }) => (
|
||||
<Card
|
||||
key={spot.id}
|
||||
className={
|
||||
hidden
|
||||
? 'border-2 border-red-500/25 bg-red-500/[0.04]'
|
||||
: verified
|
||||
? 'border-2 border-emerald-500/25 bg-emerald-500/[0.04]'
|
||||
: 'border-2 border-white/10 bg-white/[0.03]'
|
||||
}
|
||||
>
|
||||
<CardContent className="space-y-3 p-4">
|
||||
<div className="flex items-start gap-3">
|
||||
{spot.images?.[0] ? (
|
||||
<img
|
||||
src={spot.images[0]}
|
||||
alt=""
|
||||
className="h-14 w-14 shrink-0 rounded-lg object-cover"
|
||||
/>
|
||||
) : (
|
||||
<div className="flex h-14 w-14 shrink-0 items-center justify-center rounded-lg bg-white/10 text-xl">
|
||||
🛹
|
||||
</div>
|
||||
)}
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="flex items-center gap-2 truncate font-bold text-white">
|
||||
{hidden && (
|
||||
<Badge className="border border-red-500/50 bg-red-500/20 text-[10px] font-black uppercase tracking-wide text-red-300">
|
||||
<EyeOff className="mr-1 h-3 w-3" />
|
||||
Hidden
|
||||
</Badge>
|
||||
)}
|
||||
{verified && (
|
||||
<Badge className="border border-emerald-500/50 bg-emerald-500/20 text-[10px] font-black uppercase tracking-wide text-emerald-300">
|
||||
<BadgeCheck className="mr-1 h-3 w-3" />
|
||||
Confirmed
|
||||
</Badge>
|
||||
)}
|
||||
<span className="truncate">{spot.title || '(untitled)'}</span>
|
||||
</p>
|
||||
<p className="truncate text-xs text-gray-500">
|
||||
{spot.description?.trim() || 'no description'}
|
||||
</p>
|
||||
<div className="mt-1.5 flex flex-wrap gap-1">
|
||||
{!hidden && !verified && flags.map((f) => (
|
||||
<Badge
|
||||
key={f}
|
||||
className={
|
||||
f.startsWith('reported')
|
||||
? 'border border-red-500/50 bg-red-500/20 text-[10px] font-black uppercase tracking-wide text-red-300'
|
||||
: 'border border-amber-500/40 bg-amber-500/15 text-[10px] font-bold uppercase tracking-wide text-amber-300'
|
||||
}
|
||||
>
|
||||
{f}
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
<div className="mt-2 flex flex-wrap items-center gap-x-3 gap-y-1 text-[10px] text-gray-500">
|
||||
<span className="flex items-center gap-1">
|
||||
{typeof trust === 'number' && trust > 0 ? (
|
||||
<BadgeCheck className="h-3 w-3 text-emerald-500" />
|
||||
) : (
|
||||
<ShieldQuestion className="h-3 w-3 text-gray-600" />
|
||||
)}
|
||||
mapper trust {typeof trust === 'number' ? trust : '—'}
|
||||
</span>
|
||||
<span className="flex items-center gap-1">
|
||||
<Users className="h-3 w-3" />
|
||||
{reporters > 0 || photos > 0
|
||||
? [
|
||||
reporters > 0 ? `${reporters} status report${reporters === 1 ? '' : 's'}` : null,
|
||||
photos > 0 ? `${photos} community photo${photos === 1 ? '' : 's'}` : null,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(' · ')
|
||||
: 'no community signals'}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-3">
|
||||
{prepared.map(({ row, coord, hidden, verified, trust, reports, flags }) => (
|
||||
<Card
|
||||
key={row.id}
|
||||
className={
|
||||
hidden
|
||||
? 'border-2 border-red-500/25 bg-red-500/[0.04]'
|
||||
: verified
|
||||
? 'border-2 border-emerald-500/25 bg-emerald-500/[0.04]'
|
||||
: 'border-2 border-white/10 bg-white/[0.03]'
|
||||
}
|
||||
>
|
||||
<CardContent className="space-y-3 p-4">
|
||||
<div className="flex items-start gap-3">
|
||||
{row.image ? (
|
||||
<img src={row.image} alt="" className="h-14 w-14 shrink-0 rounded-lg object-cover" />
|
||||
) : (
|
||||
<div className="flex h-14 w-14 shrink-0 items-center justify-center rounded-lg bg-white/10 text-xl">
|
||||
🛹
|
||||
</div>
|
||||
)}
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="flex items-center gap-2 truncate font-bold text-white">
|
||||
{hidden && (
|
||||
<Badge className="border border-red-500/50 bg-red-500/20 text-[10px] font-black uppercase tracking-wide text-red-300">
|
||||
<EyeOff className="mr-1 h-3 w-3" />
|
||||
Hidden
|
||||
</Badge>
|
||||
)}
|
||||
{verified && (
|
||||
<Badge className="border border-emerald-500/50 bg-emerald-500/20 text-[10px] font-black uppercase tracking-wide text-emerald-300">
|
||||
<BadgeCheck className="mr-1 h-3 w-3" />
|
||||
Confirmed
|
||||
</Badge>
|
||||
)}
|
||||
<span className="truncate">{row.title || '(untitled)'}</span>
|
||||
</p>
|
||||
<p className="truncate text-xs text-gray-500">
|
||||
{row.description?.trim() || 'no description'}
|
||||
</p>
|
||||
<div className="mt-1.5 flex flex-wrap gap-1">
|
||||
{!hidden &&
|
||||
!verified &&
|
||||
flags.map((f) => (
|
||||
<Badge
|
||||
key={f}
|
||||
className={
|
||||
f.startsWith('reported')
|
||||
? 'border-red-500/50 bg-red-500/20 text-[10px] font-black uppercase tracking-wide text-red-300'
|
||||
: 'border border-amber-500/40 bg-amber-500/15 text-[10px] font-bold uppercase tracking-wide text-amber-300'
|
||||
}
|
||||
>
|
||||
{f}
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
<div className="mt-2 flex flex-wrap items-center gap-x-3 gap-y-1 text-[10px] text-gray-500">
|
||||
<span className="flex items-center gap-1">
|
||||
{typeof trust === 'number' && trust > 0 ? (
|
||||
<BadgeCheck className="h-3 w-3 text-emerald-500" />
|
||||
) : (
|
||||
<ShieldQuestion className="h-3 w-3 text-gray-600" />
|
||||
)}
|
||||
mapper trust {typeof trust === 'number' ? trust : '—'}
|
||||
</span>
|
||||
{row.communityStatusReports !== undefined && (
|
||||
<span className="flex items-center gap-1">
|
||||
<Users className="h-3 w-3" />
|
||||
{row.communityStatusReports + (row.communityPhotos ?? 0) > 0
|
||||
? [
|
||||
row.communityStatusReports > 0
|
||||
? `${row.communityStatusReports} status report${row.communityStatusReports === 1 ? '' : 's'}`
|
||||
: null,
|
||||
(row.communityPhotos ?? 0) > 0
|
||||
? `${row.communityPhotos} community photo${row.communityPhotos === 1 ? '' : 's'}`
|
||||
: null,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(' · ')
|
||||
: 'no community signals'}
|
||||
</span>
|
||||
)}
|
||||
{reports.total > 0 && (
|
||||
<span className="flex items-center gap-1 text-red-400">
|
||||
<Flag className="h-3 w-3" />
|
||||
{reports.total} report{reports.total === 1 ? '' : 's'}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{(hidden || verified) ? (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
className="gap-1.5 border-white/20 text-gray-200 hover:bg-white/10"
|
||||
disabled={clearing}
|
||||
onClick={() => void handleUndo(coord, spot.id)}
|
||||
>
|
||||
{clearing ? (
|
||||
<Loader2 className="h-3.5 w-3.5 animate-spin" />
|
||||
) : (
|
||||
<Undo2 className="h-3.5 w-3.5" />
|
||||
)}
|
||||
Undo
|
||||
</Button>
|
||||
) : (
|
||||
<>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{hidden || verified ? (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
className="gap-1.5 border-white/20 text-gray-200 hover:bg-white/10"
|
||||
disabled={clearing}
|
||||
onClick={() =>
|
||||
void act(
|
||||
() => clearVerdict({ coord, id: row.id, reason: 'verdict retracted' }),
|
||||
{ title: 'Undone', description: 'Back to neutral — not hidden, not vouched.' },
|
||||
'Could not undo',
|
||||
)
|
||||
}
|
||||
>
|
||||
{clearing ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : <Undo2 className="h-3.5 w-3.5" />}
|
||||
Undo
|
||||
</Button>
|
||||
) : (
|
||||
<>
|
||||
<Button
|
||||
size="sm"
|
||||
className="gap-1.5 bg-emerald-500 font-bold text-black hover:bg-emerald-400"
|
||||
disabled={confirming}
|
||||
onClick={() => void handleConfirm(coord, spot.id)}
|
||||
onClick={() =>
|
||||
void act(
|
||||
() => confirmContent({ coord, id: row.id, reason: 'looks legit after review' }),
|
||||
{ title: 'Confirmed', description: 'Vouched for — it drops off this queue.' },
|
||||
'Could not confirm',
|
||||
)
|
||||
}
|
||||
>
|
||||
{confirming ? (
|
||||
<Loader2 className="h-3.5 w-3.5 animate-spin" />
|
||||
) : (
|
||||
<BadgeCheck className="h-3.5 w-3.5" />
|
||||
)}
|
||||
{confirming ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : <BadgeCheck className="h-3.5 w-3.5" />}
|
||||
Confirm
|
||||
</Button>
|
||||
<Button
|
||||
|
|
@ -313,51 +321,195 @@ export default function AdminReviewPage() {
|
|||
variant="outline"
|
||||
className="gap-1.5 border-red-500/40 text-red-400 hover:bg-red-500/10"
|
||||
disabled={hiding}
|
||||
onClick={() => void handleHide(coord, spot.id, flags)}
|
||||
>
|
||||
{hiding ? (
|
||||
<Loader2 className="h-3.5 w-3.5 animate-spin" />
|
||||
) : (
|
||||
<EyeOff className="h-3.5 w-3.5" />
|
||||
)}
|
||||
Hide
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
className="gap-1.5 border-amber-500/40 text-amber-400 hover:bg-amber-500/10"
|
||||
onClick={() =>
|
||||
void reportNotASpot({
|
||||
eventId: spot.id,
|
||||
pubkey: spot.pubkey,
|
||||
title: spot.title,
|
||||
})
|
||||
void act(
|
||||
() => hideContent({ coord, id: row.id, reason: `auto-flagged: ${flags.join(', ')}` }),
|
||||
{ title: 'Hidden', description: 'Removed from the app via a public label. Reversible.' },
|
||||
'Could not hide',
|
||||
)
|
||||
}
|
||||
>
|
||||
<Flag className="h-3.5 w-3.5" />
|
||||
Not a skate spot
|
||||
{hiding ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : <EyeOff className="h-3.5 w-3.5" />}
|
||||
Hide
|
||||
</Button>
|
||||
</>
|
||||
{row.kind === SKATE_SPOT_KIND && (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
className="gap-1.5 border-amber-500/40 text-amber-400 hover:bg-amber-500/10"
|
||||
onClick={() =>
|
||||
void reportNotASpot({ eventId: row.id, pubkey: row.pubkey, title: row.title })
|
||||
}
|
||||
>
|
||||
<Flag className="h-3.5 w-3.5" />
|
||||
Not a skate spot
|
||||
</Button>
|
||||
)}
|
||||
<Button asChild size="sm" variant="ghost" className="gap-1.5 text-gray-400">
|
||||
<Link to={`/spot/${spot.pubkey}/${spot.identifier}`}>
|
||||
<ExternalLink className="h-3.5 w-3.5" />
|
||||
Look at it
|
||||
</Link>
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
<Button asChild size="sm" variant="ghost" className="gap-1.5 text-gray-400">
|
||||
<Link to={row.href}>
|
||||
<ExternalLink className="h-3.5 w-3.5" />
|
||||
Look at it
|
||||
</Link>
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Per-kind loaders (one hook each, so only the open tab queries) ──────────
|
||||
|
||||
function SpotsQueue() {
|
||||
// includeHidden: a hidden item must stay listed, or its verdict can't be undone.
|
||||
const { data, isLoading } = useSkateSpots(300, { includeHidden: true });
|
||||
const spots = useMemo(() => data ?? [], [data]);
|
||||
|
||||
// Spot-only signals.
|
||||
const statusInput = useMemo(
|
||||
() => spots.map((s) => ({ pubkey: s.pubkey, identifier: s.identifier })),
|
||||
[spots],
|
||||
);
|
||||
const { getStatus } = useBatchSpotStatus(statusInput);
|
||||
const { getCommunityPhotoCount } = useBatchSpotPhotos(statusInput);
|
||||
|
||||
const rows: QueueRow[] = spots.map((s) => ({
|
||||
id: s.id,
|
||||
pubkey: s.pubkey,
|
||||
identifier: s.identifier,
|
||||
kind: SKATE_SPOT_KIND,
|
||||
title: s.title,
|
||||
description: s.description,
|
||||
image: s.images?.[0],
|
||||
imageCount: s.images?.length ?? 0,
|
||||
hasLocation: !!s.geohash,
|
||||
href: `/spot/${s.pubkey}/${s.identifier}`,
|
||||
communityStatusReports: getStatus(s.pubkey, s.identifier)?.reporterCount ?? 0,
|
||||
communityPhotos: getCommunityPhotoCount(s.pubkey, s.identifier),
|
||||
}));
|
||||
|
||||
return <QueueList rows={rows} isLoading={isLoading} />;
|
||||
}
|
||||
|
||||
function ShopsQueue() {
|
||||
const { data, isLoading } = useSkateShops(300, { includeHidden: true });
|
||||
const rows: QueueRow[] = (data ?? []).map((s) => ({
|
||||
id: s.id,
|
||||
pubkey: s.pubkey,
|
||||
identifier: s.identifier,
|
||||
kind: SKATE_SHOP_KIND,
|
||||
title: s.name,
|
||||
description: s.description,
|
||||
image: s.images?.[0],
|
||||
imageCount: s.images?.length ?? 0,
|
||||
hasLocation: !!s.geohash,
|
||||
href: `/shop/${s.pubkey}/${s.identifier}`,
|
||||
}));
|
||||
return <QueueList rows={rows} isLoading={isLoading} />;
|
||||
}
|
||||
|
||||
function EventsQueue() {
|
||||
const { data, isLoading } = useSkateEvents(200, { includeHidden: true });
|
||||
const rows: QueueRow[] = (data ?? []).map((e) => ({
|
||||
id: e.id,
|
||||
pubkey: e.pubkey,
|
||||
identifier: e.identifier,
|
||||
kind: CALENDAR_EVENT_KIND,
|
||||
title: e.title,
|
||||
description: e.description,
|
||||
image: e.image,
|
||||
imageCount: e.image ? 1 : 0,
|
||||
hasLocation: !!e.geohash,
|
||||
href: `/event/${e.pubkey}/${e.identifier}`,
|
||||
}));
|
||||
return <QueueList rows={rows} isLoading={isLoading} />;
|
||||
}
|
||||
|
||||
function ToursQueue() {
|
||||
const { data, isLoading } = useTours(200, { includeHidden: true });
|
||||
const rows: QueueRow[] = (data ?? []).map((t) => ({
|
||||
id: t.id,
|
||||
pubkey: t.pubkey,
|
||||
identifier: t.identifier,
|
||||
kind: SKATE_TOUR_KIND,
|
||||
title: t.title,
|
||||
description: t.description,
|
||||
image: t.image,
|
||||
imageCount: t.image ? 1 : 0,
|
||||
hasLocation: !!t.geohash,
|
||||
href: `/tour/${t.pubkey}/${t.identifier}`,
|
||||
}));
|
||||
return <QueueList rows={rows} isLoading={isLoading} />;
|
||||
}
|
||||
|
||||
function GearQueue() {
|
||||
const { data, isLoading } = useMarketplaceItems(200, { includeHidden: true });
|
||||
const rows: QueueRow[] = (data ?? []).map((i) => ({
|
||||
id: i.id,
|
||||
pubkey: i.pubkey,
|
||||
identifier: i.identifier,
|
||||
kind: MARKETPLACE_ITEM_KIND,
|
||||
title: i.title,
|
||||
description: i.description,
|
||||
image: i.images?.[0],
|
||||
imageCount: i.images?.length ?? 0,
|
||||
hasLocation: !!i.geohash,
|
||||
href: `/listing/${i.pubkey}/${i.identifier}`,
|
||||
}));
|
||||
return <QueueList rows={rows} isLoading={isLoading} />;
|
||||
}
|
||||
|
||||
export default function AdminReviewPage() {
|
||||
const { user } = useCurrentUser();
|
||||
const [kind, setKind] = useState<QueueKind>('spots');
|
||||
|
||||
useSeoMeta({ title: 'Review queue — SkateSpots' });
|
||||
|
||||
// Admins only. Everyone else sees a 404 rather than a hint that this exists.
|
||||
if (!isAdminPubkey(user?.pubkey)) return <NotFound />;
|
||||
|
||||
return (
|
||||
<div className="container mx-auto max-w-3xl px-4 py-8 pb-24 md:pb-14">
|
||||
<PageHeader
|
||||
icon={ShieldAlert}
|
||||
title="Review queue"
|
||||
accent="#ff6600"
|
||||
subtitle="Spots, shops, events, tours and gear that trip the junk signals. Hide and Confirm are soft, reversible labels; reports rank first. Nothing is deleted."
|
||||
/>
|
||||
|
||||
<div className="mb-4 flex flex-wrap gap-1.5">
|
||||
{TABS.map((t) => (
|
||||
<button
|
||||
key={t.id}
|
||||
type="button"
|
||||
onClick={() => setKind(t.id)}
|
||||
className={
|
||||
kind === t.id
|
||||
? 'rounded-full border border-[#ff6600]/60 bg-[#ff6600]/15 px-3 py-1.5 text-[11px] font-black uppercase tracking-wide text-[#ff6600]'
|
||||
: 'rounded-full border border-white/12 px-3 py-1.5 text-[11px] font-bold uppercase tracking-wide text-gray-400 hover:text-white'
|
||||
}
|
||||
aria-pressed={kind === t.id}
|
||||
>
|
||||
{t.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{kind === 'spots' && <SpotsQueue />}
|
||||
{kind === 'shops' && <ShopsQueue />}
|
||||
{kind === 'events' && <EventsQueue />}
|
||||
{kind === 'tours' && <ToursQueue />}
|
||||
{kind === 'gear' && <GearQueue />}
|
||||
|
||||
<p className="mt-6 text-[11px] leading-relaxed text-gray-600">
|
||||
<strong className="text-gray-500">Hide</strong> publishes{' '}
|
||||
<code>org.skatespots.moderation / hidden</code>;{' '}
|
||||
<strong className="text-gray-500">Confirm</strong> publishes{' '}
|
||||
<code>… / verified</code>, which clears the spot from this queue and badges it as
|
||||
staff-vouched. Both are public, reversible labels — the spot itself stays on relays.
|
||||
<strong className="text-gray-500">Confirm</strong> publishes <code>… / verified</code>;{' '}
|
||||
<strong className="text-gray-500">Undo</strong> publishes <code>… / cleared</code> (back to
|
||||
neutral). All three are public, reversible labels — nothing is ever deleted from relays.
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -260,19 +260,10 @@ export default function CreateSpotPage() {
|
|||
return;
|
||||
}
|
||||
|
||||
// A photo is REQUIRED. It is the single cheapest defence against the map
|
||||
// filling up with test entries and off-topic pins ("test of secret island
|
||||
// airport"): a real spot has a photo, a typo doesn't. It also gives every
|
||||
// other skater something to verify against.
|
||||
if (images.length === 0) {
|
||||
toast({
|
||||
title: 'Add a photo first',
|
||||
description:
|
||||
'Every spot needs at least one photo — of the spot, not the map. It keeps the map real.',
|
||||
variant: 'destructive',
|
||||
});
|
||||
return;
|
||||
}
|
||||
// A photo is strongly recommended but NOT required: blocking an honest
|
||||
// mapper who has no picture on hand is worse than accepting a photo-less
|
||||
// pin. Spots without one simply stay "Unverified" (see the spot page badge)
|
||||
// and the review queue flags them, so a human can look later.
|
||||
|
||||
// Don't publish while the Quick Snap photo is still uploading — otherwise the
|
||||
// spot would go out without the photo the user just took.
|
||||
|
|
@ -408,7 +399,9 @@ export default function CreateSpotPage() {
|
|||
|
||||
toast({
|
||||
title: 'Spot added!',
|
||||
description: 'Your skate spot has been published to the network.',
|
||||
description: images.length === 0
|
||||
? 'Published — and marked Unverified until someone adds a photo. A picture of the spot goes a long way.'
|
||||
: 'Your skate spot has been published to the network.',
|
||||
});
|
||||
|
||||
// Navigate to spot detail using pubkey and identifier
|
||||
|
|
@ -732,9 +725,8 @@ export default function CreateSpotPage() {
|
|||
<div className="space-y-2">
|
||||
<Label>
|
||||
{t('spots.photos')}
|
||||
<span className="ml-1 text-red-400">*</span>
|
||||
<span className="ml-2 text-[11px] font-normal text-muted-foreground">
|
||||
required — one photo of the spot keeps the map honest
|
||||
strongly recommended — spots without a photo stay marked Unverified
|
||||
</span>
|
||||
</Label>
|
||||
<div className="grid grid-cols-3 gap-4">
|
||||
|
|
@ -848,7 +840,7 @@ export default function CreateSpotPage() {
|
|||
<Button
|
||||
type="submit"
|
||||
className="w-full"
|
||||
disabled={isPublishing || isUploading || isUploadingQuickPhoto || images.length === 0}
|
||||
disabled={isPublishing || isUploading || isUploadingQuickPhoto}
|
||||
>
|
||||
{isPublishing ? (
|
||||
<>
|
||||
|
|
|
|||
|
|
@ -1649,7 +1649,7 @@ export default function MapPage() {
|
|||
{/* Empty state — the active filters hid everything. Without this
|
||||
the map just looks blank/unloaded. Only shown when the
|
||||
spot/shop layers are on (other layers have their own markers). */}
|
||||
{!isLoading && (showSpots || showShops) && filteredSpots.length === 0 && (
|
||||
{!isLoading && (showSpots || showShops) && filteredSpots.length === 0 && allLocations.length > 0 && (
|
||||
<div className="pointer-events-none absolute inset-0 z-[1001] flex items-center justify-center px-4">
|
||||
<div className="pointer-events-auto max-w-xs rounded-2xl border border-white/10 bg-black/85 p-5 text-center shadow-2xl backdrop-blur-md">
|
||||
<p className="font-black uppercase tracking-wide text-white">No spots match</p>
|
||||
|
|
@ -1661,6 +1661,39 @@ export default function MapPage() {
|
|||
</div>
|
||||
)}
|
||||
|
||||
{/* Nothing mapped here AT ALL — the honest empty map. This is the
|
||||
density problem, not a filter problem, so the fix on offer is
|
||||
"add the first one" rather than "clear your filters". */}
|
||||
{!isLoading && (showSpots || showShops) && allLocations.length === 0 && (
|
||||
<div className="pointer-events-none absolute inset-0 z-[1001] flex items-center justify-center px-4">
|
||||
<div className="pointer-events-auto max-w-sm rounded-2xl border-2 border-[#ccff00]/25 bg-black/85 p-6 text-center shadow-2xl backdrop-blur-md">
|
||||
<p className="text-2xl">🛹</p>
|
||||
<p className="mt-2 font-black uppercase tracking-wide text-white">
|
||||
No spots mapped yet
|
||||
</p>
|
||||
<p className="mt-1 text-xs leading-relaxed text-gray-400">
|
||||
Be the first to map your park — it takes a minute, it's yours, and the next
|
||||
skater who rolls through will thank you.
|
||||
</p>
|
||||
<div className="mt-4 flex flex-col gap-2 sm:flex-row sm:justify-center">
|
||||
<Link to="/create-spot">
|
||||
<Button
|
||||
size="sm"
|
||||
className="w-full gap-2 bg-[#ccff00] font-black uppercase text-black hover:bg-[#ccff00]/90"
|
||||
>
|
||||
<Plus className="h-4 w-4" />
|
||||
Add the first spot
|
||||
</Button>
|
||||
</Link>
|
||||
<Button size="sm" variant="outline" className="gap-2 border-white/20" onClick={handleNearMe} disabled={nearMeLoading}>
|
||||
<LocateFixed className="h-4 w-4" />
|
||||
{nearMeLoading ? 'Locating…' : 'Find spots near me'}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Map attribution — bottom-left, sits ABOVE the mobile bottom
|
||||
nav so it never covers the Shops & Spots buttons. Tiny + subtle. */}
|
||||
<div
|
||||
|
|
|
|||
Loading…
Reference in a new issue