feat(spots): honest quality signals, photo required, off-topic reports, admin review queue
The 'test of secret island airport' problem: a junk pin wearing an app-endorsed 'Skateability 95/100'. - Rename the weather score to 'Conditions · weather only' (SpotDetailPage + the SpotMap popup). It was never a verdict on the place. - New Community verified / Unverified badge from signals only OTHER skaters can produce: community photos + useSpotStatus reporterCount (kind 2259). - CreateSpotPage now REQUIRES at least one photo (guard + disabled submit + a required hint on the label). - useNotASpotReport: one-tap NIP-56 (1984) report tagged 'not-a-skate-spot' (NIP-56 'other' + our L/l/t taxonomy), wired into the spot's More menu and added to the shared ReportDialog reasons. - useAdminHide: the MISSING WRITE SIDE for admin moderation. useAdminModeration always read NIP-32 kind-1985 'hidden' labels but nothing published one, so the only working hide was device-local. Now publishes the label (+ optimistic local hide). Soft and reversible — the event stays on relays. - /review (AdminReviewPage, admin-only → 404 otherwise): review queue over useSkateSpots with cheap flags (suspicious title, name <=3 chars, no photo, no description, no geohash), sorted by flag count, one-tap Hide or Not a skate spot. Entry via the admin-only AdminReviewCard in Settings → Advanced. - /review added to the smoke-test route list. Verified: the airport spot shows UNVERIFIED with 'Conditions · weather only' and no 'Skateability'; the review queue lists it with flags + Hide/report actions; create-spot blocks without a photo.
This commit is contained in:
parent
0adfbcbd29
commit
a43ff47b28
13 changed files with 529 additions and 7 deletions
23
AGENTS.md
23
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/* →
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -141,6 +141,7 @@ const ROUTES = [
|
|||
'/stats',
|
||||
'/mints',
|
||||
'/app-shop',
|
||||
'/review',
|
||||
'/messages/group/abc123',
|
||||
];
|
||||
const problems = [];
|
||||
|
|
|
|||
|
|
@ -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() {
|
|||
<Route path="/wallet" element={<WalletPage />} />
|
||||
<Route path="/mints" element={<MintsPage />} />
|
||||
<Route path="/app-shop" element={<AppShopPage />} />
|
||||
<Route path="/review" element={<AdminReviewPage />} />
|
||||
<Route path="/settings" element={<SettingsPage />} />
|
||||
<Route path="/create-spot" element={<CreateSpotPage />} />
|
||||
<Route path="/create-shop" element={<CreateShopPage />} />
|
||||
|
|
|
|||
59
src/components/AdminReviewCard.tsx
Normal file
59
src/components/AdminReviewCard.tsx
Normal file
|
|
@ -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 (
|
||||
<Link
|
||||
to="/review"
|
||||
className="block rounded-2xl border border-amber-500/20 bg-white/[0.02] transition-colors hover:border-amber-500/40"
|
||||
>
|
||||
<div className="flex items-center gap-3 px-4 py-3.5">
|
||||
<ShieldAlert className="h-5 w-5 shrink-0 text-amber-400" />
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="text-sm font-bold text-amber-400">Review queue</p>
|
||||
<p className="mt-0.5 text-xs text-muted-foreground">
|
||||
{flaggedCount > 0
|
||||
? `${flaggedCount} spot${flaggedCount === 1 ? '' : 's'} trip the junk signals — hide or dismiss.`
|
||||
: 'Spots that look like test entries or off-topic pins.'}
|
||||
</p>
|
||||
</div>
|
||||
<ArrowRight className="h-4 w-4 shrink-0 text-gray-500" />
|
||||
</div>
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -580,9 +580,9 @@ function createPopupContent(
|
|||
</div>
|
||||
<div class="text-right">
|
||||
<div class="text-xs font-semibold" style="color: ${escapeHtml(weatherData.skateability.color)}">
|
||||
${escapeHtml(weatherData.skateability.label)}
|
||||
Conditions · ${escapeHtml(weatherData.skateability.label)}
|
||||
</div>
|
||||
<div class="text-xs text-gray-500">${escapeHtml(weatherData.skateability.score)}/100</div>
|
||||
<div class="text-xs text-gray-500">${escapeHtml(weatherData.skateability.score)}/100 weather</div>
|
||||
</div>
|
||||
`;
|
||||
weatherSection.appendChild(currentWeather);
|
||||
|
|
|
|||
61
src/hooks/useAdminHide.ts
Normal file
61
src/hooks/useAdminHide.ts
Normal file
|
|
@ -0,0 +1,61 @@
|
|||
import { useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { useNostrPublish } from '@/hooks/useNostrPublish';
|
||||
import { useCurrentUser } from '@/hooks/useCurrentUser';
|
||||
import { isAdminPubkey } from '@/lib/admins';
|
||||
import { addLocalHidden } from '@/lib/localModeration';
|
||||
import { MODERATION_LABEL_NAMESPACE, MODERATION_LABEL_HIDDEN } from '@/lib/types';
|
||||
|
||||
/**
|
||||
* Publish an admin "hidden" moderation label (NIP-32 kind 1985).
|
||||
*
|
||||
* THE MISSING HALF: `useAdminModeration` has always READ these labels and
|
||||
* filtered hidden content out of every list — but nothing ever PUBLISHED one, so
|
||||
* the only working hide was the local, this-device-only one in
|
||||
* `useDeleteEntity`. This hook is the write side: the label is public, authored
|
||||
* by an admin pubkey, and any SkateSpots client (and any other client that
|
||||
* honours the namespace) will filter the target. It is a **soft, reversible
|
||||
* hide** — the underlying event stays on relays, which is the honest behaviour
|
||||
* on a censorship-resistant network.
|
||||
*
|
||||
* Also writes the OPTIMISTIC local hide so the content disappears instantly on
|
||||
* this device instead of waiting for the relay round-trip.
|
||||
*/
|
||||
export function useAdminHide() {
|
||||
const { user } = useCurrentUser();
|
||||
const { mutateAsync: publish, isPending: publishing } = useNostrPublish();
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const mutation = useMutation({
|
||||
mutationFn: async ({
|
||||
coord,
|
||||
id,
|
||||
reason,
|
||||
}: {
|
||||
/** Addressable coordinate, e.g. `30818:<pubkey>:<d>`. */
|
||||
coord?: string;
|
||||
/** Event id (for regular/replaceable events). */
|
||||
id?: string;
|
||||
/** Why it was hidden — stored in the label's content for transparency. */
|
||||
reason?: string;
|
||||
}) => {
|
||||
if (!isAdminPubkey(user?.pubkey)) throw new Error('Admins only.');
|
||||
if (!coord && !id) throw new Error('Nothing to hide — need a coordinate or an event id.');
|
||||
|
||||
const tags: string[][] = [
|
||||
['L', MODERATION_LABEL_NAMESPACE],
|
||||
['l', MODERATION_LABEL_HIDDEN, MODERATION_LABEL_NAMESPACE],
|
||||
['alt', 'SkateSpots: moderation label — hidden'],
|
||||
];
|
||||
if (coord) tags.push(['a', coord]);
|
||||
if (id) tags.push(['e', id]);
|
||||
|
||||
await publish({ kind: 1985, content: reason ?? '', tags });
|
||||
|
||||
// Optimistic: hide on this device immediately.
|
||||
addLocalHidden({ coord, id });
|
||||
queryClient.invalidateQueries({ queryKey: ['admin-moderation'] });
|
||||
},
|
||||
});
|
||||
|
||||
return { hideContent: mutation.mutateAsync, isPending: mutation.isPending || publishing };
|
||||
}
|
||||
72
src/hooks/useNotASpotReport.ts
Normal file
72
src/hooks/useNotASpotReport.ts
Normal file
|
|
@ -0,0 +1,72 @@
|
|||
import { useMutation } from '@tanstack/react-query';
|
||||
import { useNostrPublish } from '@/hooks/useNostrPublish';
|
||||
import { useCurrentUser } from '@/hooks/useCurrentUser';
|
||||
import { useToast } from '@/hooks/useToast';
|
||||
import { MODERATION_LABEL_NAMESPACE } from '@/lib/types';
|
||||
|
||||
/**
|
||||
* One-tap "this is not a skate spot" report.
|
||||
*
|
||||
* WHY NIP-56 AND NOT A NEW KIND: kind 1984 is the ecosystem's report event and
|
||||
* `other` is its catch-all reason, so the report is useful to *any* Nostr client
|
||||
* (and visible on relays) instead of being a SkateSpots-only artifact. The extra
|
||||
* `L`/`l`/`t` tags give us a stable taxonomy (`not-a-skate-spot`) that our own
|
||||
* admin tooling can filter on without breaking anyone else's interpretation.
|
||||
*
|
||||
* This is for OFF-TOPIC places (test entries, an airport, a mall, private land) —
|
||||
* not for disagreeing about whether a spot is good.
|
||||
*/
|
||||
export function useNotASpotReport() {
|
||||
const { user } = useCurrentUser();
|
||||
const { mutateAsync: publish, isPending: publishing } = useNostrPublish();
|
||||
const { toast } = useToast();
|
||||
|
||||
const mutation = useMutation({
|
||||
mutationFn: async ({
|
||||
eventId,
|
||||
pubkey,
|
||||
title,
|
||||
}: {
|
||||
/** The spot's kind-30818 event id. */
|
||||
eventId: string;
|
||||
/** The spot's author. */
|
||||
pubkey: string;
|
||||
title?: string;
|
||||
}) => {
|
||||
if (!user) throw new Error('Log in to report a spot.');
|
||||
|
||||
await publish({
|
||||
kind: 1984, // NIP-56 report
|
||||
content: title ? `Not a skate spot: ${title}` : 'Not a skate spot',
|
||||
tags: [
|
||||
['e', eventId, 'other'],
|
||||
['p', pubkey],
|
||||
['L', MODERATION_LABEL_NAMESPACE],
|
||||
['l', 'not-a-skate-spot', MODERATION_LABEL_NAMESPACE],
|
||||
['t', 'not-a-skate-spot'],
|
||||
['alt', 'SkateSpots: report — not a skate spot'],
|
||||
],
|
||||
});
|
||||
},
|
||||
onSuccess: () => {
|
||||
toast({
|
||||
title: 'Thanks — sent for review',
|
||||
description:
|
||||
'No hard feelings: this flags the pin as off-topic so people can decide for themselves.',
|
||||
});
|
||||
},
|
||||
onError: (error) => {
|
||||
toast({
|
||||
title: 'Could not send the report',
|
||||
description: error instanceof Error ? error.message : 'Please try again.',
|
||||
variant: 'destructive',
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
return {
|
||||
reportNotASpot: mutation.mutateAsync,
|
||||
isPending: mutation.isPending || publishing,
|
||||
alreadyReported: mutation.isSuccess,
|
||||
};
|
||||
}
|
||||
194
src/pages/AdminReviewPage.tsx
Normal file
194
src/pages/AdminReviewPage.tsx
Normal file
|
|
@ -0,0 +1,194 @@
|
|||
import { useMemo } from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { useSeoMeta } from '@unhead/react';
|
||||
import { ShieldAlert, Flag, EyeOff, ExternalLink, Loader2, CheckCircle2 } from 'lucide-react';
|
||||
import { PageHeader } from '@/components/PageHeader';
|
||||
import { Card, CardContent } from '@/components/ui/card';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Skeleton } from '@/components/ui/skeleton';
|
||||
import { useSkateSpots } from '@/hooks/useSkateSpots';
|
||||
import { useAdminHide } from '@/hooks/useAdminHide';
|
||||
import { useNotASpotReport } from '@/hooks/useNotASpotReport';
|
||||
import { useCurrentUser } from '@/hooks/useCurrentUser';
|
||||
import { useToast } from '@/hooks/useToast';
|
||||
import { isAdminPubkey } from '@/lib/admins';
|
||||
import { SKATE_SPOT_KIND, type SkateSpot } from '@/lib/types';
|
||||
import NotFound from '@/pages/NotFound';
|
||||
|
||||
/**
|
||||
* Admin review queue.
|
||||
*
|
||||
* Surfaces spots that LOOK like junk using cheap, explainable signals — the kind
|
||||
* of thing a test entry or an off-topic pin trips instantly ("test of secret
|
||||
* island airport": suspicious name + no photo). It does not delete anything:
|
||||
* every action is a soft, reversible NIP-32 `hidden` label (see `useAdminHide`)
|
||||
* or a NIP-56 report. Flags are suggestions, not verdicts — a human decides.
|
||||
*/
|
||||
|
||||
const SUSPICIOUS_TITLE =
|
||||
/(^|\b)(test|testing|asdf|asd|qwerty|foo|bar|untitled|demo|sample|example|secret|airport|my (house|home|flat|apartment))(\b|$)/i;
|
||||
|
||||
/** Cheap signals. Each one is something a human can check in two seconds. */
|
||||
function flagsFor(spot: SkateSpot): string[] {
|
||||
const flags: string[] = [];
|
||||
const title = (spot.title ?? '').trim();
|
||||
const description = (spot.description ?? '').trim();
|
||||
|
||||
if (SUSPICIOUS_TITLE.test(title)) flags.push('suspicious name');
|
||||
if (title.length <= 3) flags.push('name too short');
|
||||
if (!spot.images || spot.images.length === 0) flags.push('no photo');
|
||||
if (description.length < 10) flags.push('no description');
|
||||
if (!spot.geohash) flags.push('no location precision');
|
||||
|
||||
return flags;
|
||||
}
|
||||
|
||||
export default function AdminReviewPage() {
|
||||
const { user } = useCurrentUser();
|
||||
const { data: spots, isLoading } = useSkateSpots(300);
|
||||
const { hideContent, isPending: hiding } = useAdminHide();
|
||||
const { reportNotASpot } = useNotASpotReport();
|
||||
const { toast } = useToast();
|
||||
|
||||
const flagged = useMemo(
|
||||
() =>
|
||||
(spots ?? [])
|
||||
.map((spot) => ({ spot, flags: flagsFor(spot) }))
|
||||
.filter((row) => row.flags.length > 0)
|
||||
.sort((a, b) => b.flags.length - a.flags.length),
|
||||
[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 />;
|
||||
|
||||
const handleHide = async (spot: SkateSpot, flags: string[]) => {
|
||||
try {
|
||||
await hideContent({
|
||||
coord: `${SKATE_SPOT_KIND}:${spot.pubkey}:${spot.identifier}`,
|
||||
id: spot.id,
|
||||
reason: `auto-flagged: ${flags.join(', ')}`,
|
||||
});
|
||||
toast({
|
||||
title: 'Hidden',
|
||||
description: 'Removed from the app via a public moderation label. Reversible.',
|
||||
});
|
||||
} catch (e) {
|
||||
toast({
|
||||
title: 'Could not hide',
|
||||
description: e instanceof Error ? e.message : 'Try again.',
|
||||
variant: 'destructive',
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const handleNotASpot = (spot: SkateSpot) => {
|
||||
void reportNotASpot({ eventId: spot.id, pubkey: spot.pubkey, title: spot.title });
|
||||
};
|
||||
|
||||
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 — 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>
|
||||
) : flagged.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">
|
||||
{flagged.map(({ spot, flags }) => (
|
||||
<Card key={spot.id} className="border-2 border-white/10 bg-white/[0.03]">
|
||||
<CardContent className="space-y-3 p-4">
|
||||
<div className="flex items-start gap-3">
|
||||
{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="truncate font-bold text-white">{spot.title || '(untitled)'}</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">
|
||||
{flags.map((f) => (
|
||||
<Badge
|
||||
key={f}
|
||||
className="border border-amber-500/40 bg-amber-500/15 text-[10px] font-bold uppercase tracking-wide text-amber-300"
|
||||
>
|
||||
{f}
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
className="gap-1.5 border-red-500/40 text-red-400 hover:bg-red-500/10"
|
||||
disabled={hiding}
|
||||
onClick={() => void handleHide(spot, flags)}
|
||||
>
|
||||
{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={() => handleNotASpot(spot)}
|
||||
>
|
||||
<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>
|
||||
)}
|
||||
|
||||
<p className="mt-6 text-[11px] leading-relaxed text-gray-600">
|
||||
Hiding publishes a public NIP-32 label (<code>org.skatespots.moderation</code> /
|
||||
<code> hidden</code>) that every SkateSpots client filters out; the spot itself stays on
|
||||
relays, so it can be un-hidden later. Flags are heuristics — check before you hide.
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -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() {
|
|||
/>
|
||||
</div>
|
||||
|
||||
{/* Images */}
|
||||
{/* Images — required (see the guard in handleSubmit). */}
|
||||
<div className="space-y-2">
|
||||
<Label>{t('spots.photos')}</Label>
|
||||
<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
|
||||
</span>
|
||||
</Label>
|
||||
<div className="grid grid-cols-3 gap-4">
|
||||
{images.map((url) => (
|
||||
<div key={url} className="relative aspect-video rounded-lg overflow-hidden bg-muted">
|
||||
|
|
@ -749,7 +769,7 @@ export default function CreateSpotPage() {
|
|||
<Button
|
||||
type="submit"
|
||||
className="w-full"
|
||||
disabled={isPublishing || isUploading || isUploadingQuickPhoto}
|
||||
disabled={isPublishing || isUploading || isUploadingQuickPhoto || images.length === 0}
|
||||
>
|
||||
{isPublishing ? (
|
||||
<>
|
||||
|
|
|
|||
|
|
@ -9,6 +9,7 @@ import { SecuritySettings } from '@/components/SecuritySettings';
|
|||
import { RelatrSettings } from '@/components/RelatrSettings';
|
||||
import { CVMHealthCheck } from '@/components/CVMHealthCheck';
|
||||
import { SasquatchAiStatusCard } from '@/components/SasquatchAiStatusCard';
|
||||
import { AdminReviewCard } from '@/components/AdminReviewCard';
|
||||
import { LanguageSelector } from '@/components/LanguageToggle';
|
||||
import { DesktopNavManager } from '@/components/DesktopNavManager';
|
||||
import { MyDataTab } from '@/components/MyDataTab';
|
||||
|
|
@ -590,6 +591,7 @@ function AdvancedScreen({ onBack }: { onBack: () => void }) {
|
|||
{/* Owner-only — render null for everyone except platform owners. */}
|
||||
<CVMHealthCheck />
|
||||
<SasquatchAiStatusCard />
|
||||
<AdminReviewCard />
|
||||
<div className="rounded-2xl border border-amber-500/20 overflow-hidden bg-white/2">
|
||||
<div className="px-4 py-3.5 border-b border-white/5">
|
||||
<p className="text-sm font-bold text-amber-400">🦧 Sasquatch Guide</p>
|
||||
|
|
|
|||
|
|
@ -20,6 +20,7 @@ import { RouteDialog } from '@/components/RouteDialog';
|
|||
import { useSkateSpot } from '@/hooks/useSkateSpots';
|
||||
import { useAuthor } from '@/hooks/useAuthor';
|
||||
import { useDeleteEntity } from '@/hooks/useDeleteEntity';
|
||||
import { useNotASpotReport } from '@/hooks/useNotASpotReport';
|
||||
import { isAdminPubkey } from '@/lib/admins';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { useToast } from '@/hooks/useToast';
|
||||
|
|
@ -53,6 +54,8 @@ import {
|
|||
Flag,
|
||||
MoreHorizontal,
|
||||
MessageCircle,
|
||||
ShieldCheck,
|
||||
ShieldQuestion,
|
||||
} from 'lucide-react';
|
||||
import { nip19 } from 'nostr-tools';
|
||||
import { ReportDialog } from '@/components/ReportDialog';
|
||||
|
|
@ -125,6 +128,17 @@ export default function SpotDetailPage() {
|
|||
const [showDeleteDialog, setShowDeleteDialog] = useState(false);
|
||||
const [showCollectionDialog, setShowCollectionDialog] = useState(false);
|
||||
const [showReportDialog, setShowReportDialog] = useState(false);
|
||||
|
||||
// One-tap off-topic report (test entries, airports, malls, private land…).
|
||||
const {
|
||||
reportNotASpot,
|
||||
isPending: reportNotASpotPending,
|
||||
alreadyReported,
|
||||
} = useNotASpotReport();
|
||||
const handleNotASpot = () => {
|
||||
if (!spot) return;
|
||||
void reportNotASpot({ eventId: spot.id, pubkey: spot.pubkey, title: spot.title });
|
||||
};
|
||||
const [showStatusDialog, setShowStatusDialog] = useState(false);
|
||||
const [showRouteDialog, setShowRouteDialog] = useState(false);
|
||||
const [lightboxIndex, setLightboxIndex] = useState<number | null>(null);
|
||||
|
|
@ -144,6 +158,16 @@ export default function SpotDetailPage() {
|
|||
// Community spot status (active / bust / skate-stopped / closed / gone)
|
||||
const { aggregated: spotStatus } = useSpotStatus(pubkey || '', identifier || '');
|
||||
|
||||
/**
|
||||
* Community confidence — a signal INDEPENDENT of the weather score, so a
|
||||
* test/off-topic entry can't look endorsed just because the sun is out.
|
||||
* Counts things only other skaters can produce: a community photo, or a
|
||||
* status report ("I was here / bust / skate-stopped").
|
||||
*/
|
||||
const communitySignalCount =
|
||||
(communityPhotos?.length ?? 0) + (spotStatus?.reporterCount ?? 0);
|
||||
const communityVerified = communitySignalCount > 0;
|
||||
|
||||
useSeoMeta({
|
||||
title: spot ? `${spot.title} - SkateSpots` : 'Skate Spot - SkateSpots',
|
||||
description: spot?.description || 'View this skateboarding spot on SkateSpots.',
|
||||
|
|
@ -406,6 +430,19 @@ export default function SpotDetailPage() {
|
|||
Report {isShop ? 'Shop' : 'Spot'}
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
{/* One tap: this pin is off-topic (test entry, airport,
|
||||
mall, private land…). Publishes a NIP-56 report with a
|
||||
`not-a-skate-spot` tag for the admin queue. */}
|
||||
{!isOwner && !isShop && (
|
||||
<DropdownMenuItem
|
||||
onClick={handleNotASpot}
|
||||
disabled={reportNotASpotPending || alreadyReported}
|
||||
className="text-amber-500 focus:text-amber-500"
|
||||
>
|
||||
<Flag className="h-4 w-4 mr-2" />
|
||||
{alreadyReported ? 'Reported as not a skate spot' : 'Not a skate spot'}
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
|
|
@ -437,6 +474,33 @@ export default function SpotDetailPage() {
|
|||
{SHELTER_INFO[spot.shelter].emoji} {SHELTER_INFO[spot.shelter].label}
|
||||
</Badge>
|
||||
)}
|
||||
{/* Community confidence — NOT a quality/weather score. */}
|
||||
<Badge
|
||||
className={cn(
|
||||
'border font-bold uppercase',
|
||||
communityVerified
|
||||
? 'border-emerald-500/50 bg-emerald-500/20 text-emerald-400'
|
||||
: 'border-white/15 bg-white/5 text-gray-500',
|
||||
)}
|
||||
title={
|
||||
communityVerified
|
||||
? 'Another skater has contributed here (photo or status report).'
|
||||
: 'No community photo or status report yet — nobody has confirmed this place.'
|
||||
}
|
||||
>
|
||||
{communityVerified ? (
|
||||
<>
|
||||
<ShieldCheck className="mr-1 h-3 w-3" />
|
||||
Community verified
|
||||
{communitySignalCount > 1 && ` · ${communitySignalCount}`}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<ShieldQuestion className="mr-1 h-3 w-3" />
|
||||
Unverified
|
||||
</>
|
||||
)}
|
||||
</Badge>
|
||||
{spot.types.map((type) => (
|
||||
<Badge key={type} className="bg-[#ccff00]/20 text-[#ccff00] border border-[#ccff00]/50 font-bold uppercase">
|
||||
{SPOT_TYPE_LABELS[type] || type}
|
||||
|
|
@ -814,11 +878,17 @@ export default function SpotDetailPage() {
|
|||
</div>
|
||||
</div>
|
||||
|
||||
{/* 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. */}
|
||||
<div className="p-3 rounded-lg" style={{ backgroundColor: `${weatherData.skateability.color}20`, border: `2px solid ${weatherData.skateability.color}` }}>
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<span className="font-semibold" style={{ color: weatherData.skateability.color }}>
|
||||
Skateability
|
||||
Conditions
|
||||
<span className="ml-1.5 text-[10px] font-bold uppercase tracking-widest text-muted-foreground">
|
||||
weather only
|
||||
</span>
|
||||
</span>
|
||||
<span className="text-2xl font-bold" style={{ color: weatherData.skateability.color }}>
|
||||
{weatherData.skateability.score}/100
|
||||
|
|
|
|||
Loading…
Reference in a new issue