feat(spots): reports reach the admin, duplicates are caught, contributors are told
1) Reports were a VOID: ReportDialog publishes NIP-56 (1984) but the only reader in the app was a count on marketplace cards, so an admin never saw one. Now useSpotReports batch-reads kind 1984 by '#e' (one query for every spot) and the review queue ranks reported spots ABOVE the heuristics with a red 'REPORTED: ...' chip. useNotASpotReport publishes to getWriteRelays() (community relay + the reporter's), not the default pool — the admin reads from the community relay, so reports written only to personal relays would never arrive. Verified end-to-end: published a report from Node, saw it surface in the queue. 2) Duplicate guard in CreateSpotPage: on submit, existing spots within 60 m are listed with distances + links, with 'It's a different spot — publish anyway'. Targets the clone-spam failure mode (five near-identical pins). 3) Contributor feedback: useSkateSpot no longer nulls a hidden spot for its AUTHOR (it used to silently 404 for the person who made it), and SpotDetailPage shows an author-only banner for hidden/verified. tsc/eslint clean, 101 tests, smoke PASS.
This commit is contained in:
parent
e44f8b81a7
commit
e549aeee3a
8 changed files with 273 additions and 22 deletions
21
AGENTS.md
21
AGENTS.md
|
|
@ -417,6 +417,27 @@ session with **no other memory of this project**. So:
|
||||||
is **admin-tooling only** — without it a hidden spot leaves the list and can
|
is **admin-tooling only** — without it a hidden spot leaves the list and can
|
||||||
never be un-hidden. The review queue keeps actioned rows (badge + **Undo**)
|
never be un-hidden. The review queue keeps actioned rows (badge + **Undo**)
|
||||||
so both verbiage and verdicts are reversible in the UI.
|
so both verbiage and verdicts are reversible in the UI.
|
||||||
|
- **Reports reach the admin, duplicates are caught, contributors are told (2026-09-28):**
|
||||||
|
three gaps closed in one pass.
|
||||||
|
1. **Reports were a void.** `ReportDialog` publishes NIP-56 (1984) for spots/
|
||||||
|
events/tours/users/listings/comments, but the ONLY reader in the app was a
|
||||||
|
count on marketplace cards — an admin never saw a report. Now
|
||||||
|
**`useSpotReports`** batch-reads `kinds:[1984], '#e': [all spot ids]` in ONE
|
||||||
|
query (via `querySkateContent`), and the review queue ranks reported spots
|
||||||
|
ABOVE the heuristics (red `REPORTED: …` chip). `useNotASpotReport` now
|
||||||
|
publishes to **`getWriteRelays()`** (community relay + the reporter's) rather
|
||||||
|
than the default pool, because the admin reads from the community relay — the
|
||||||
|
same relay-parity rule as the newsletter and DMs. Verified end-to-end by
|
||||||
|
publishing a report from Node and seeing it surface in the queue.
|
||||||
|
2. **Duplicate guard** in `CreateSpotPage`: on submit, any existing spot within
|
||||||
|
**60 m** (geohash-decoded, ≤3 candidates) shows an inline warning listing them
|
||||||
|
with distances and links, plus "It's a different spot — publish anyway"
|
||||||
|
(`dupConfirmedRef` bypass). Designed for the clone-spam failure mode.
|
||||||
|
3. **Contributor feedback:** `useSkateSpot` no longer returns null for a hidden
|
||||||
|
spot when the viewer is its **author** — otherwise a moderated spot silently
|
||||||
|
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.
|
||||||
- **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/* →
|
||||||
|
|
|
||||||
|
|
@ -15,6 +15,12 @@ v1.5.4
|
||||||
**Confirm** a spot as well as hide it (a staff-vouched spot gets a badge).
|
**Confirm** a spot as well as hide it (a staff-vouched spot gets a badge).
|
||||||
- Moderation is **reversible**: Hide and Confirm can both be undone, returning the
|
- Moderation is **reversible**: Hide and Confirm can both be undone, returning the
|
||||||
spot to neutral without hiding or vouching for it.
|
spot to neutral without hiding or vouching for it.
|
||||||
|
- **Reports now reach a human**: the review queue surfaces reported spots at the
|
||||||
|
very top, above the automated signals — so "not a skate spot" actually lands.
|
||||||
|
- **Adding a spot warns you if one already exists** within 60 m, and links you
|
||||||
|
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).
|
||||||
|
|
||||||
🛍 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
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
import { useMutation } from '@tanstack/react-query';
|
import { useMutation } from '@tanstack/react-query';
|
||||||
import { useNostrPublish } from '@/hooks/useNostrPublish';
|
import { useNostr } from '@nostrify/react';
|
||||||
|
import { useSkateRelays } from '@/hooks/useSkateRelays';
|
||||||
import { useCurrentUser } from '@/hooks/useCurrentUser';
|
import { useCurrentUser } from '@/hooks/useCurrentUser';
|
||||||
import { useToast } from '@/hooks/useToast';
|
import { useToast } from '@/hooks/useToast';
|
||||||
import { MODERATION_LABEL_NAMESPACE } from '@/lib/types';
|
import { MODERATION_LABEL_NAMESPACE } from '@/lib/types';
|
||||||
|
|
@ -18,7 +19,8 @@ import { MODERATION_LABEL_NAMESPACE } from '@/lib/types';
|
||||||
*/
|
*/
|
||||||
export function useNotASpotReport() {
|
export function useNotASpotReport() {
|
||||||
const { user } = useCurrentUser();
|
const { user } = useCurrentUser();
|
||||||
const { mutateAsync: publish, isPending: publishing } = useNostrPublish();
|
const { nostr } = useNostr();
|
||||||
|
const { getWriteRelays } = useSkateRelays();
|
||||||
const { toast } = useToast();
|
const { toast } = useToast();
|
||||||
|
|
||||||
const mutation = useMutation({
|
const mutation = useMutation({
|
||||||
|
|
@ -35,9 +37,10 @@ export function useNotASpotReport() {
|
||||||
}) => {
|
}) => {
|
||||||
if (!user) throw new Error('Log in to report a spot.');
|
if (!user) throw new Error('Log in to report a spot.');
|
||||||
|
|
||||||
await publish({
|
const event = await user.signer.signEvent({
|
||||||
kind: 1984, // NIP-56 report
|
kind: 1984, // NIP-56 report
|
||||||
content: title ? `Not a skate spot: ${title}` : 'Not a skate spot',
|
content: title ? `Not a skate spot: ${title}` : 'Not a skate spot',
|
||||||
|
created_at: Math.floor(Date.now() / 1000),
|
||||||
tags: [
|
tags: [
|
||||||
['e', eventId, 'other'],
|
['e', eventId, 'other'],
|
||||||
['p', pubkey],
|
['p', pubkey],
|
||||||
|
|
@ -47,6 +50,18 @@ export function useNotASpotReport() {
|
||||||
['alt', 'SkateSpots: report — not a skate spot'],
|
['alt', 'SkateSpots: report — not a skate spot'],
|
||||||
],
|
],
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Publish to the COMMUNITY relay as well as the reporter's own relays.
|
||||||
|
// Admins read reports from the community relay, so a report written only
|
||||||
|
// to the reporter's personal relays would never reach them.
|
||||||
|
const results = await Promise.allSettled(
|
||||||
|
getWriteRelays().map((url) =>
|
||||||
|
nostr.relay(url).event(event, { signal: AbortSignal.timeout(8000) }),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
if (results.every((r) => r.status === 'rejected')) {
|
||||||
|
throw new Error('Could not reach any relay — check your connection and try again.');
|
||||||
|
}
|
||||||
},
|
},
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
toast({
|
toast({
|
||||||
|
|
@ -66,7 +81,7 @@ export function useNotASpotReport() {
|
||||||
|
|
||||||
return {
|
return {
|
||||||
reportNotASpot: mutation.mutateAsync,
|
reportNotASpot: mutation.mutateAsync,
|
||||||
isPending: mutation.isPending || publishing,
|
isPending: mutation.isPending,
|
||||||
alreadyReported: mutation.isSuccess,
|
alreadyReported: mutation.isSuccess,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
import { useQuery } from '@tanstack/react-query';
|
import { useQuery } from '@tanstack/react-query';
|
||||||
import { SKATE_SPOT_KIND, parseSkateSpot, type SkateSpot } from '@/lib/types';
|
import { SKATE_SPOT_KIND, parseSkateSpot, type SkateSpot } from '@/lib/types';
|
||||||
import { useSkateRelays } from '@/hooks/useSkateRelays';
|
import { useSkateRelays } from '@/hooks/useSkateRelays';
|
||||||
|
import { useCurrentUser } from '@/hooks/useCurrentUser';
|
||||||
import { useAdminModeration, filterAdminHidden } from '@/hooks/useAdminModeration';
|
import { useAdminModeration, filterAdminHidden } from '@/hooks/useAdminModeration';
|
||||||
import { cacheSpots, getAllCachedSpots, isOffline } from '@/lib/offlineCache';
|
import { cacheSpots, getAllCachedSpots, isOffline } from '@/lib/offlineCache';
|
||||||
|
|
||||||
|
|
@ -73,13 +74,20 @@ export function useSkateSpots(limit = 100, options: { includeHidden?: boolean }
|
||||||
export function useSkateSpot(identifier: string, pubkey?: string) {
|
export function useSkateSpot(identifier: string, pubkey?: string) {
|
||||||
const { querySkateContent, getQueryKeySuffix } = useSkateRelays();
|
const { querySkateContent, getQueryKeySuffix } = useSkateRelays();
|
||||||
const hidden = useAdminModeration();
|
const hidden = useAdminModeration();
|
||||||
|
const { user } = useCurrentUser();
|
||||||
|
|
||||||
return useQuery({
|
return useQuery({
|
||||||
queryKey: ['skate-spot', identifier, pubkey, getQueryKeySuffix()],
|
queryKey: ['skate-spot', identifier, pubkey, getQueryKeySuffix()],
|
||||||
select: (spot: SkateSpot | null) =>
|
select: (spot: SkateSpot | null) => {
|
||||||
spot && hidden.has({ coord: `${SKATE_SPOT_KIND}:${spot.pubkey}:${spot.identifier}`, id: spot.id })
|
if (!spot) return null;
|
||||||
? null
|
if (!hidden.has({ coord: `${SKATE_SPOT_KIND}:${spot.pubkey}:${spot.identifier}`, id: spot.id })) {
|
||||||
: spot,
|
return spot;
|
||||||
|
}
|
||||||
|
// A hidden spot stays reachable for its OWN AUTHOR — otherwise their spot
|
||||||
|
// silently 404s and they never learn why. Everyone else gets null (the map
|
||||||
|
// and lists filter it too). The detail page shows them what happened.
|
||||||
|
return user?.pubkey === spot.pubkey ? spot : null;
|
||||||
|
},
|
||||||
queryFn: async () => {
|
queryFn: async () => {
|
||||||
// If offline, try to get from cache first
|
// If offline, try to get from cache first
|
||||||
if (isOffline() && pubkey) {
|
if (isOffline() && pubkey) {
|
||||||
|
|
|
||||||
68
src/hooks/useSpotReports.ts
Normal file
68
src/hooks/useSpotReports.ts
Normal file
|
|
@ -0,0 +1,68 @@
|
||||||
|
import { useMemo } from 'react';
|
||||||
|
import { useQuery } from '@tanstack/react-query';
|
||||||
|
import { useSkateRelays } from '@/hooks/useSkateRelays';
|
||||||
|
|
||||||
|
/** NIP-56 report kind. */
|
||||||
|
export const REPORT_KIND = 1984;
|
||||||
|
|
||||||
|
export interface SpotReportCounts {
|
||||||
|
/** Every NIP-56 report referencing the spot. */
|
||||||
|
total: number;
|
||||||
|
/** Reports specifically tagged `not-a-skate-spot` (see useNotASpotReport). */
|
||||||
|
notASpot: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Batch-read NIP-56 reports for many spots in ONE query.
|
||||||
|
*
|
||||||
|
* Reports carry the target's event id in an `e` tag, so asking for
|
||||||
|
* `#e: [all spot ids]` is a single relay request — the same trick as the status
|
||||||
|
* and photo batch hooks.
|
||||||
|
*
|
||||||
|
* WHY THIS EXISTS: people could already report a spot, but nothing ever brought
|
||||||
|
* those reports to the admin — the only reader in the whole app was a count on
|
||||||
|
* marketplace cards. A report button whose output nobody sees is decoration, so
|
||||||
|
* the review queue now ranks reported spots to the top.
|
||||||
|
*/
|
||||||
|
export function useSpotReports(spots: Array<{ id: string }>) {
|
||||||
|
const { querySkateContent, getQueryKeySuffix } = useSkateRelays();
|
||||||
|
|
||||||
|
const ids = useMemo(() => spots.map((s) => s.id).filter(Boolean), [spots]);
|
||||||
|
|
||||||
|
const query = useQuery({
|
||||||
|
queryKey: ['spot-reports', ids.slice().sort().join(','), getQueryKeySuffix()],
|
||||||
|
enabled: ids.length > 0,
|
||||||
|
staleTime: 60_000,
|
||||||
|
queryFn: async (c) => {
|
||||||
|
const signal = AbortSignal.any([c.signal, AbortSignal.timeout(8000)]);
|
||||||
|
return querySkateContent(
|
||||||
|
[{ kinds: [REPORT_KIND], '#e': ids, limit: 1000 }],
|
||||||
|
{ signal },
|
||||||
|
);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const counts = useMemo(() => {
|
||||||
|
const map = new Map<string, SpotReportCounts>();
|
||||||
|
for (const ev of query.data ?? []) {
|
||||||
|
// A report can reference several targets; count each.
|
||||||
|
for (const [n, value] of ev.tags) {
|
||||||
|
if (n !== 'e' || !value) continue;
|
||||||
|
const entry = map.get(value) ?? { total: 0, notASpot: 0 };
|
||||||
|
entry.total += 1;
|
||||||
|
// Our own taxonomy marker, or the NIP-56 'other' reason from the dialog.
|
||||||
|
const isNotASpot =
|
||||||
|
ev.tags.some(([t, v]) => t === 't' && v === 'not-a-skate-spot') ||
|
||||||
|
ev.tags.some(([t, v]) => t === 'l' && v === 'not-a-skate-spot');
|
||||||
|
if (isNotASpot) entry.notASpot += 1;
|
||||||
|
map.set(value, entry);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return map;
|
||||||
|
}, [query.data]);
|
||||||
|
|
||||||
|
return {
|
||||||
|
isLoading: query.isLoading,
|
||||||
|
getCounts: (id: string): SpotReportCounts => counts.get(id) ?? { total: 0, notASpot: 0 },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
@ -22,6 +22,7 @@ import { useSkateSpots } from '@/hooks/useSkateSpots';
|
||||||
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';
|
||||||
|
import { useSpotReports } from '@/hooks/useSpotReports';
|
||||||
import { useAdminHide, useAdminVerify, useAdminClear } from '@/hooks/useAdminHide';
|
import { useAdminHide, useAdminVerify, useAdminClear } from '@/hooks/useAdminHide';
|
||||||
import { useAdminModeration } from '@/hooks/useAdminModeration';
|
import { useAdminModeration } from '@/hooks/useAdminModeration';
|
||||||
import { useNotASpotReport } from '@/hooks/useNotASpotReport';
|
import { useNotASpotReport } from '@/hooks/useNotASpotReport';
|
||||||
|
|
@ -69,6 +70,8 @@ export default function AdminReviewPage() {
|
||||||
);
|
);
|
||||||
const { getStatus } = useBatchSpotStatus(statusInput);
|
const { getStatus } = useBatchSpotStatus(statusInput);
|
||||||
const { getCommunityPhotoCount } = useBatchSpotPhotos(statusInput);
|
const { getCommunityPhotoCount } = useBatchSpotPhotos(statusInput);
|
||||||
|
// What people actually reported — the strongest signal we have.
|
||||||
|
const { getCounts: getReportCounts } = useSpotReports(spots ?? []);
|
||||||
|
|
||||||
const rows = useMemo(
|
const rows = useMemo(
|
||||||
() =>
|
() =>
|
||||||
|
|
@ -80,6 +83,15 @@ export default function AdminReviewPage() {
|
||||||
const verified = moderation.isVerified({ coord, id: spot.id });
|
const verified = moderation.isVerified({ coord, id: spot.id });
|
||||||
const photos = getCommunityPhotoCount(spot.pubkey, spot.identifier);
|
const photos = getCommunityPhotoCount(spot.pubkey, spot.identifier);
|
||||||
const reporters = status?.reporterCount ?? 0;
|
const reporters = status?.reporterCount ?? 0;
|
||||||
|
const reports = getReportCounts(spot.id);
|
||||||
|
const flags = spotFlags(spot, {
|
||||||
|
mapperTrust: trustScores?.get(spot.pubkey),
|
||||||
|
communityStatusReports: reporters,
|
||||||
|
communityPhotos: photos,
|
||||||
|
});
|
||||||
|
// 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 {
|
return {
|
||||||
spot,
|
spot,
|
||||||
coord,
|
coord,
|
||||||
|
|
@ -90,19 +102,20 @@ export default function AdminReviewPage() {
|
||||||
trust: trustScores?.get(spot.pubkey),
|
trust: trustScores?.get(spot.pubkey),
|
||||||
reporters,
|
reporters,
|
||||||
photos,
|
photos,
|
||||||
flags: spotFlags(spot, {
|
reports,
|
||||||
mapperTrust: trustScores?.get(spot.pubkey),
|
flags,
|
||||||
communityStatusReports: reporters,
|
|
||||||
communityPhotos: photos,
|
|
||||||
}),
|
|
||||||
};
|
};
|
||||||
})
|
})
|
||||||
.filter((row) => row.actioned || row.flags.length > 0)
|
.filter((row) => row.actioned || row.flags.length > 0)
|
||||||
// Un-actioned rows first (most flags first); actioned rows sink below.
|
// Un-actioned first; within that, human reports before heuristics;
|
||||||
.sort((a, b) =>
|
// actioned rows sink to the bottom (they are done, but undoable).
|
||||||
a.actioned === b.actioned ? b.flags.length - a.flags.length : a.actioned ? 1 : -1,
|
.sort((a, b) => {
|
||||||
),
|
if (a.actioned !== b.actioned) return a.actioned ? 1 : -1;
|
||||||
[spots, moderation, trustScores, getStatus, getCommunityPhotoCount],
|
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],
|
||||||
);
|
);
|
||||||
|
|
||||||
useSeoMeta({ title: 'Review queue — SkateSpots' });
|
useSeoMeta({ title: 'Review queue — SkateSpots' });
|
||||||
|
|
@ -230,7 +243,11 @@ export default function AdminReviewPage() {
|
||||||
{!hidden && !verified && flags.map((f) => (
|
{!hidden && !verified && flags.map((f) => (
|
||||||
<Badge
|
<Badge
|
||||||
key={f}
|
key={f}
|
||||||
className="border border-amber-500/40 bg-amber-500/15 text-[10px] font-bold uppercase tracking-wide text-amber-300"
|
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}
|
{f}
|
||||||
</Badge>
|
</Badge>
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
import { useState, useEffect } from 'react';
|
import { useState, useEffect, useRef } from 'react';
|
||||||
import { useSeoMeta } from '@unhead/react';
|
import { useSeoMeta } from '@unhead/react';
|
||||||
import { useNavigate, useSearchParams } from 'react-router-dom';
|
import { useNavigate, useSearchParams } from 'react-router-dom';
|
||||||
import ngeohash from 'ngeohash';
|
import ngeohash from 'ngeohash';
|
||||||
|
|
@ -10,6 +10,9 @@ import { Label } from '@/components/ui/label';
|
||||||
import { useNostrPublish } from '@/hooks/useNostrPublish';
|
import { useNostrPublish } from '@/hooks/useNostrPublish';
|
||||||
import { useCurrentUser } from '@/hooks/useCurrentUser';
|
import { useCurrentUser } from '@/hooks/useCurrentUser';
|
||||||
import { useUploadFile } from '@/hooks/useUploadFile';
|
import { useUploadFile } from '@/hooks/useUploadFile';
|
||||||
|
import { useSkateSpots } from '@/hooks/useSkateSpots';
|
||||||
|
import { calculateDistance } from '@/lib/sortOptions';
|
||||||
|
import type { SkateSpot } from '@/lib/types';
|
||||||
import { useToast } from '@/hooks/useToast';
|
import { useToast } from '@/hooks/useToast';
|
||||||
import { useTranslation } from '@/contexts/language';
|
import { useTranslation } from '@/contexts/language';
|
||||||
import { LoginArea } from '@/components/auth/LoginArea';
|
import { LoginArea } from '@/components/auth/LoginArea';
|
||||||
|
|
@ -59,6 +62,12 @@ export default function CreateSpotPage() {
|
||||||
const [shelter, setShelter] = useState<ShelterType>(DEFAULT_SHELTER);
|
const [shelter, setShelter] = useState<ShelterType>(DEFAULT_SHELTER);
|
||||||
const [contactInfo, setContactInfo] = useState('');
|
const [contactInfo, setContactInfo] = useState('');
|
||||||
const [images, setImages] = useState<string[]>([]);
|
const [images, setImages] = useState<string[]>([]);
|
||||||
|
// Duplicate guard: warn when a spot already exists right where you're pinning
|
||||||
|
// one. This is the cheapest fix for clone spam (five near-identical pins for
|
||||||
|
// the same spot) — and it points the contributor at the existing pin instead.
|
||||||
|
const { data: existingSpots } = useSkateSpots(500);
|
||||||
|
const [dupWarning, setDupWarning] = useState<Array<{ spot: SkateSpot; km: number }> | null>(null);
|
||||||
|
const dupConfirmedRef = useRef(false);
|
||||||
const [isGettingLocation, setIsGettingLocation] = useState(false);
|
const [isGettingLocation, setIsGettingLocation] = useState(false);
|
||||||
const [showCamera, setShowCamera] = useState(false);
|
const [showCamera, setShowCamera] = useState(false);
|
||||||
// True while the Quick Snap photo is uploading in the background after landing
|
// True while the Quick Snap photo is uploading in the background after landing
|
||||||
|
|
@ -209,8 +218,8 @@ export default function CreateSpotPage() {
|
||||||
setImages((prev) => prev.filter((img) => img !== url));
|
setImages((prev) => prev.filter((img) => img !== url));
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleSubmit = async (e: React.FormEvent) => {
|
const handleSubmit = async (e?: React.FormEvent) => {
|
||||||
e.preventDefault();
|
e?.preventDefault();
|
||||||
|
|
||||||
if (!title.trim()) {
|
if (!title.trim()) {
|
||||||
toast({
|
toast({
|
||||||
|
|
@ -275,6 +284,29 @@ export default function CreateSpotPage() {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Look for an existing spot within 60 m and ask before creating a clone.
|
||||||
|
if (!dupConfirmedRef.current) {
|
||||||
|
const nearby = (existingSpots ?? [])
|
||||||
|
.map((s) => {
|
||||||
|
if (!s.geohash) return null;
|
||||||
|
const { latitude, longitude } = ngeohash.decode(s.geohash);
|
||||||
|
if (!Number.isFinite(latitude) || !Number.isFinite(longitude)) return null;
|
||||||
|
return { spot: s, km: calculateDistance(lat, lng, latitude, longitude) };
|
||||||
|
})
|
||||||
|
.filter((x): x is { spot: SkateSpot; km: number } => !!x && x.km < 0.06)
|
||||||
|
.sort((a, b) => a.km - b.km)
|
||||||
|
.slice(0, 3);
|
||||||
|
|
||||||
|
if (nearby.length > 0) {
|
||||||
|
setDupWarning(nearby);
|
||||||
|
toast({
|
||||||
|
title: 'That spot may already be on the map',
|
||||||
|
description: 'Check the pin below before adding a duplicate.',
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Generate geohash from coordinates (7 chars = ~153m precision)
|
// Generate geohash from coordinates (7 chars = ~153m precision)
|
||||||
const geohash = ngeohash.encode(lat, lng, 7);
|
const geohash = ngeohash.encode(lat, lng, 7);
|
||||||
|
|
@ -765,6 +797,53 @@ export default function CreateSpotPage() {
|
||||||
hasSpotType={!!selectedCategory}
|
hasSpotType={!!selectedCategory}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
{dupWarning && (
|
||||||
|
<div className="rounded-2xl border-2 border-amber-500/40 bg-amber-500/10 p-4">
|
||||||
|
<p className="text-sm font-black uppercase tracking-wide text-amber-300">
|
||||||
|
This spot may already exist
|
||||||
|
</p>
|
||||||
|
<p className="mt-1 text-xs text-gray-300">
|
||||||
|
{dupWarning.length === 1 ? 'There is a spot' : 'There are spots'} within 60 m of
|
||||||
|
your pin. If it's the same place, add your photo or a status report there
|
||||||
|
instead of creating a duplicate.
|
||||||
|
</p>
|
||||||
|
<ul className="mt-2 space-y-1">
|
||||||
|
{dupWarning.map(({ spot, km }) => (
|
||||||
|
<li key={spot.id} className="truncate text-xs">
|
||||||
|
<Link
|
||||||
|
to={`/spot/${spot.pubkey}/${spot.identifier}`}
|
||||||
|
className="text-[#ccff00] hover:opacity-80"
|
||||||
|
>
|
||||||
|
{spot.title || 'Untitled spot'} · {Math.round(km * 1000)} m away
|
||||||
|
</Link>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
<div className="mt-3 flex flex-wrap gap-2">
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
className="border-white/20"
|
||||||
|
onClick={() => {
|
||||||
|
dupConfirmedRef.current = true;
|
||||||
|
setDupWarning(null);
|
||||||
|
void handleSubmit();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
It's a different spot — publish anyway
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="ghost"
|
||||||
|
className="text-gray-400"
|
||||||
|
onClick={() => setDupWarning(null)}
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Submit */}
|
{/* Submit */}
|
||||||
<Button
|
<Button
|
||||||
type="submit"
|
type="submit"
|
||||||
|
|
|
||||||
|
|
@ -63,6 +63,7 @@ import {
|
||||||
ShieldCheck,
|
ShieldCheck,
|
||||||
ShieldQuestion,
|
ShieldQuestion,
|
||||||
BadgeCheck,
|
BadgeCheck,
|
||||||
|
ShieldAlert,
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import { nip19 } from 'nostr-tools';
|
import { nip19 } from 'nostr-tools';
|
||||||
import { ReportDialog } from '@/components/ReportDialog';
|
import { ReportDialog } from '@/components/ReportDialog';
|
||||||
|
|
@ -178,6 +179,10 @@ export default function SpotDetailPage() {
|
||||||
// Staff verdict (NIP-32 `verified` label) — separate from the community
|
// Staff verdict (NIP-32 `verified` label) — separate from the community
|
||||||
// signal: a human actually looked at this one.
|
// signal: a human actually looked at this one.
|
||||||
const moderation = useAdminModeration();
|
const moderation = useAdminModeration();
|
||||||
|
const staffHidden = !!spot && moderation.has({
|
||||||
|
coord: `${SKATE_SPOT_KIND}:${spot.pubkey}:${spot.identifier}`,
|
||||||
|
id: spot.id,
|
||||||
|
});
|
||||||
const staffVerified = !!spot && moderation.isVerified({
|
const staffVerified = !!spot && moderation.isVerified({
|
||||||
coord: `${SKATE_SPOT_KIND}:${spot.pubkey}:${spot.identifier}`,
|
coord: `${SKATE_SPOT_KIND}:${spot.pubkey}:${spot.identifier}`,
|
||||||
id: spot.id,
|
id: spot.id,
|
||||||
|
|
@ -547,6 +552,38 @@ export default function SpotDetailPage() {
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Author-facing moderation notice. The verdicts are public
|
||||||
|
labels, so there is nothing secret to hide — and without this
|
||||||
|
a contributor whose spot was hidden just sees it vanish. */}
|
||||||
|
{isOwner && staffHidden && (
|
||||||
|
<div className="mb-4 flex items-start gap-3 rounded-2xl border-2 border-amber-500/40 bg-amber-500/10 p-4">
|
||||||
|
<ShieldAlert className="mt-0.5 h-5 w-5 shrink-0 text-amber-400" />
|
||||||
|
<div className="min-w-0">
|
||||||
|
<p className="text-sm font-black uppercase tracking-wide text-amber-300">
|
||||||
|
Hidden by a moderator
|
||||||
|
</p>
|
||||||
|
<p className="mt-1 text-xs leading-relaxed text-gray-300">
|
||||||
|
This spot is not shown on the map or in search. You can still see it because
|
||||||
|
it's yours. Editing it will not un-hide it — it stays hidden until a moderator
|
||||||
|
retracts the verdict.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{isOwner && staffVerified && (
|
||||||
|
<div className="mb-4 flex items-start gap-3 rounded-2xl border-2 border-[#ccff00]/30 bg-[#ccff00]/5 p-4">
|
||||||
|
<BadgeCheck className="mt-0.5 h-5 w-5 shrink-0 text-[#ccff00]" />
|
||||||
|
<div className="min-w-0">
|
||||||
|
<p className="text-sm font-black uppercase tracking-wide text-[#ccff00]">
|
||||||
|
Verified by SkateSpots
|
||||||
|
</p>
|
||||||
|
<p className="mt-1 text-xs leading-relaxed text-gray-300">
|
||||||
|
A moderator reviewed this spot and vouched for it. Nice one.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Community Contributions Section */}
|
{/* Community Contributions Section */}
|
||||||
{!isShop && (
|
{!isShop && (
|
||||||
<Card className="bg-black/90 border-2 border-blue-500/30 overflow-hidden">
|
<Card className="bg-black/90 border-2 border-blue-500/30 overflow-hidden">
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue