diff --git a/AGENTS.md b/AGENTS.md
index 88c4a7e..7baaa42 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -367,6 +367,29 @@ session with **no other memory of this project**. So:
SVGs are self-contained: brand fonts + logo embedded as data URIs and a real
vector QR (verified decoding to https://skate-spots.org — if you regenerate,
re-verify the QR scans with the logo in the middle; error-correction level H).
+- **Spot data quality (2026-09-27):** three cheap defences against junk pins
+ (test entries, airports, malls), because "the map looks endorsed" is the real
+ risk. (1) `CreateSpotPage` **requires ≥1 photo** (guard in `handleSubmit` +
+ the submit button is disabled) — a typo rarely has a photo. (2) The spot page
+ shows a **Community verified / Unverified** badge computed from signals only
+ *other* skaters can produce: community photos + `useSpotStatus` `reporterCount`
+ (kind 2259). The weather score was renamed **"Conditions · weather only"**
+ (in `SpotDetailPage` and the `SpotMap` popup) so it can't read as spot quality.
+ (3) `useNotASpotReport` = one-tap **NIP-56 (1984)** report tagged
+ `not-a-skate-spot` (NIP-56 `other` reason + our `L`/`l`/`t` taxonomy), used
+ from the spot's "More" menu and the shared `ReportDialog`.
+- **Admin hide + review queue (2026-09-27):** `useAdminModeration` always READ
+ NIP-32 kind-1985 `hidden` labels, but **nothing published one** — the only
+ working hide was device-local (`useDeleteEntity` → `addLocalHidden`).
+ `useAdminHide` is the missing write side: kind 1985 + `['L',
+ 'org.skatespots.moderation']` + `['l','hidden',NS]` + `['a',coord]`/`['e',id]`,
+ plus an optimistic local hide. `/review` (`AdminReviewPage`, **admin-only,
+ 404 otherwise**) lists spots that trip cheap signals (suspicious title, name
+ ≤3 chars, no photo, no description, no geohash) sorted by flag count, with
+ one-tap **Hide** or **Not a skate spot**; entry point is the admin-only
+ `AdminReviewCard` in Settings → Advanced. Hiding is **soft and reversible** —
+ the event stays on relays (honest for a censorship-resistant network); nothing
+ is deleted. `/review` is in the smoke-test route list.
- **Mints directory**: `/mints` page lists Cashu mints from the community
**MintRadar** project. MintRadar has **no CORS**, so it's fetched same-origin
at `/api/mints/*` — prod proxies via `public/_redirects` (`/api/mints/* →
diff --git a/release-notes.txt b/release-notes.txt
index 74ae84a..0732359 100644
--- a/release-notes.txt
+++ b/release-notes.txt
@@ -1,5 +1,16 @@
v1.5.4
+🧹 Keeping the map real
+- Every new spot now needs at least one photo — the cheapest defence against
+ test entries and off-topic pins ("test of secret island airport").
+- Spots only say "Community verified" when another skater has contributed (a
+ photo or a status report). Until then they are labelled "Unverified" instead
+ of looking endorsed.
+- The weather score is now labelled "Conditions · weather only". It was never a
+ verdict on whether a place is a real spot.
+- One-tap "Not a skate spot" for off-topic pins (test entries, airports, malls,
+ private land), plus an admin review queue that surfaces them for a human.
+
🛍 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
and an A3 poster, both print-ready vector files with a working QR.
diff --git a/scripts/smoke-test.mjs b/scripts/smoke-test.mjs
index cf9ac8c..78f2c44 100755
--- a/scripts/smoke-test.mjs
+++ b/scripts/smoke-test.mjs
@@ -141,6 +141,7 @@ const ROUTES = [
'/stats',
'/mints',
'/app-shop',
+ '/review',
'/messages/group/abc123',
];
const problems = [];
diff --git a/src/AppRouter.tsx b/src/AppRouter.tsx
index 174e560..95fa1bf 100644
--- a/src/AppRouter.tsx
+++ b/src/AppRouter.tsx
@@ -18,6 +18,7 @@ const LabPage = lazyWithRetry(() => import("./pages/LabPage"));
const WalletPage = lazyWithRetry(() => import("./pages/WalletPage"));
const MintsPage = lazyWithRetry(() => import("./pages/MintsPage"));
const AppShopPage = lazyWithRetry(() => import("./pages/AppShopPage"));
+const AdminReviewPage = lazyWithRetry(() => import("./pages/AdminReviewPage"));
const SettingsPage = lazyWithRetry(() => import("./pages/SettingsPage"));
const CreateSpotPage = lazyWithRetry(() => import("./pages/CreateSpotPage"));
const CreateShopPage = lazyWithRetry(() => import("./pages/CreateShopPage"));
@@ -136,6 +137,7 @@ export function AppRouter() {
} />
} />
} />
+ } />
} />
} />
} />
diff --git a/src/components/AdminReviewCard.tsx b/src/components/AdminReviewCard.tsx
new file mode 100644
index 0000000..1609e76
--- /dev/null
+++ b/src/components/AdminReviewCard.tsx
@@ -0,0 +1,59 @@
+import { Link } from 'react-router-dom';
+import { useMemo } from 'react';
+import { ShieldAlert, ArrowRight } from 'lucide-react';
+import { useCurrentUser } from '@/hooks/useCurrentUser';
+import { useSkateSpots } from '@/hooks/useSkateSpots';
+import { isAdminPubkey } from '@/lib/admins';
+import type { SkateSpot } from '@/lib/types';
+
+/** Same cheap signals the review page uses (kept in sync deliberately). */
+const SUSPICIOUS_TITLE =
+ /(^|\b)(test|testing|asdf|asd|qwerty|foo|bar|untitled|demo|sample|example|secret|airport|my (house|home|flat|apartment))(\b|$)/i;
+
+function isFlagged(spot: SkateSpot): boolean {
+ const title = (spot.title ?? '').trim();
+ const description = (spot.description ?? '').trim();
+ return (
+ SUSPICIOUS_TITLE.test(title) ||
+ title.length <= 3 ||
+ !spot.images?.length ||
+ description.length < 10
+ );
+}
+
+/**
+ * Admin-only entry point for the review queue. Renders nothing for everyone
+ * else, so it can sit in Settings → Advanced beside the other owner cards.
+ */
+export function AdminReviewCard() {
+ const { user } = useCurrentUser();
+ const isAdmin = isAdminPubkey(user?.pubkey);
+ const { data: spots } = useSkateSpots(300);
+
+ const flaggedCount = useMemo(
+ () => (spots ?? []).filter(isFlagged).length,
+ [spots],
+ );
+
+ if (!isAdmin) return null;
+
+ return (
+
+
+
+
+
Review queue
+
+ {flaggedCount > 0
+ ? `${flaggedCount} spot${flaggedCount === 1 ? '' : 's'} trip the junk signals — hide or dismiss.`
+ : 'Spots that look like test entries or off-topic pins.'}
+
+
+
+
+
+ );
+}
diff --git a/src/components/ReportDialog.tsx b/src/components/ReportDialog.tsx
index 07b379a..1e20a3d 100644
--- a/src/components/ReportDialog.tsx
+++ b/src/components/ReportDialog.tsx
@@ -18,6 +18,13 @@ import { cn } from '@/lib/utils';
// NIP-56 Report types with skate-specific labels
const REPORT_REASONS = [
+ {
+ id: 'other',
+ label: 'Not a skate spot',
+ description: 'A test entry, or a place that is not for skating at all',
+ icon: '🛹',
+ subtype: 'not-a-skate-spot',
+ },
{
id: 'spam',
label: 'Fake/Non-existent spot',
diff --git a/src/components/SpotMap.tsx b/src/components/SpotMap.tsx
index ea549b7..244ad46 100644
--- a/src/components/SpotMap.tsx
+++ b/src/components/SpotMap.tsx
@@ -580,9 +580,9 @@ function createPopupContent(
+ Hiding publishes a public NIP-32 label (org.skatespots.moderation /
+ hidden) that every SkateSpots client filters out; the spot itself stays on
+ relays, so it can be un-hidden later. Flags are heuristics — check before you hide.
+
+
+ );
+}
diff --git a/src/pages/CreateSpotPage.tsx b/src/pages/CreateSpotPage.tsx
index 6082468..b438ba9 100644
--- a/src/pages/CreateSpotPage.tsx
+++ b/src/pages/CreateSpotPage.tsx
@@ -251,6 +251,20 @@ 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;
+ }
+
// Don't publish while the Quick Snap photo is still uploading — otherwise the
// spot would go out without the photo the user just took.
if (isUploadingQuickPhoto) {
@@ -682,9 +696,15 @@ export default function CreateSpotPage() {
/>
- {/* Images */}
+ {/* Images — required (see the guard in handleSubmit). */}
-
+
{images.map((url) => (
@@ -749,7 +769,7 @@ export default function CreateSpotPage() {
- {/* Skateability Score */}
+ {/* Conditions score — WEATHER ONLY. Deliberately not called
+ "Skateability": this is about the sky, not whether the
+ place is a real, rideable spot. The community signal lives
+ in the badge next to the spot's type tags. */}
- Skateability
+ Conditions
+
+ weather only
+
{weatherData.skateability.score}/100