diff --git a/SECURITY.md b/SECURITY.md
index 5422f83..4b128cd 100644
--- a/SECURITY.md
+++ b/SECURITY.md
@@ -213,6 +213,19 @@ intentionally *not* author-filtered.
worst-case loss from a compromised connection.
- Prefer wallets/flows that let you set a spending budget.
+### Admin moderation / takedowns
+
+Two hardcoded admin pubkeys (`src/lib/admins.ts`, `ADMIN_PUBKEYS`) can take
+down any listing (spot, crew, tour, event, shop, …). A takedown publishes both
+a NIP-09 kind:5 deletion request **and** a NIP-32 kind:1985 `hidden` moderation
+label authored by the admin. The client-side layer (`useAdminModeration`) then
+hides that content across the app, so takedowns are reliable even when a relay
+won't honor a cross-author NIP-09 request. **Trust boundary:** only labels whose
+`pubkey` is in the hardcoded admin list are honored — this is re-verified
+client-side in `useAdminModeration` and is never derived from a `d` tag or any
+attacker-controllable value. Auditors should confirm the admin list is the only
+source of moderation authority.
+
### Tooling
- A custom ESLint rule (`eslint-rules/no-inline-script.js`) forbids inline
@@ -251,6 +264,7 @@ oversights:
| Login / key handling | `src/components/auth/`, Nostrify login (`nostr:login`) |
| Lightning / NWC | `src/hooks/useNWC.ts`, `src/components/WalletModal.tsx` |
| Cashu ecash (native only): proofs in IndexedDB `cashu-wallet`, seed in `cashu-mnemonic` | `src/hooks/wallet/useCashu.ts`, `src/lib/platform.ts` |
+| Admin authority / moderation / takedowns | `src/lib/admins.ts`, `src/hooks/useAdminModeration.ts`, `src/hooks/useDeleteEntity.ts` |
| CSP & security headers | `index.html` (`` — covers mobile), `public/_headers`, `netlify.toml`, `public/boot-guard.js` |
| Native wrapper (same bundle) | `capacitor.config.ts` (`webDir: 'dist'`) |
| NIP-19 routing | `src/pages/NIP19Page.tsx`, `src/AppRouter.tsx` |
diff --git a/src/components/DeleteEntityDialog.tsx b/src/components/DeleteEntityDialog.tsx
index 1e7a581..da5559a 100644
--- a/src/components/DeleteEntityDialog.tsx
+++ b/src/components/DeleteEntityDialog.tsx
@@ -13,6 +13,7 @@ import {
import { useDeleteEntity } from '@/hooks/useDeleteEntity';
import { useCurrentUser } from '@/hooks/useCurrentUser';
import { useToast } from '@/hooks/useToast';
+import { isAdminPubkey } from '@/lib/admins';
import { cn } from '@/lib/utils';
import type { NostrEvent } from '@nostrify/nostrify';
@@ -53,15 +54,29 @@ export function DeleteEntityDialog({
const navigate = useNavigate();
const [open, setOpen] = useState(false);
- // Only the owner can delete their own event.
- if (!user || user.pubkey !== event.pubkey) return null;
+ if (!user) return null;
+
+ const isOwner = user.pubkey === event.pubkey;
+ const isAdmin = isAdminPubkey(user.pubkey);
+ // The owner can delete their own event; a SkateSpots admin can take down any.
+ if (!isOwner && !isAdmin) return null;
+
+ // An admin acting on someone else's content is a moderation takedown.
+ const adminTakedown = isAdmin && !isOwner;
const handleDelete = async () => {
try {
- await deleteEntity({ event, reason: `Deleted ${entityLabel} "${entityName}"` });
+ await deleteEntity({
+ event,
+ reason: adminTakedown
+ ? `SkateSpots admin removed ${entityLabel} "${entityName}"`
+ : `Deleted ${entityLabel} "${entityName}"`,
+ });
toast({
- title: 'Deletion requested',
- description: `Your ${entityLabel} has been removed. Relays that honor deletions will stop serving it.`,
+ title: adminTakedown ? 'Content removed' : 'Deletion requested',
+ description: adminTakedown
+ ? `This ${entityLabel} has been taken down and will be hidden across SkateSpots.`
+ : `Your ${entityLabel} has been removed. Relays that honor deletions will stop serving it.`,
});
setOpen(false);
if (redirectTo) navigate(redirectTo);
@@ -89,7 +104,7 @@ export function DeleteEntityDialog({
className,
)}
>
- {triggerText}
+ {adminTakedown ? `Remove (admin)` : triggerText}
)}
@@ -102,15 +117,24 @@ export function DeleteEntityDialog({
-
- This asks relays to remove your {entityLabel}. This can’t be undone from here — you’d
- have to create a new one.
-
+ {adminTakedown ? (
+
+ As a SkateSpots admin you’re taking down this {entityLabel}. It will be hidden
+ across the app for everyone and a deletion request will be sent to relays. The
+ original author can’t restore it from here.
+
+ ) : (
+
+ This asks relays to remove your {entityLabel}. This can’t be undone from here — you’d
+ have to create a new one.
+
+ )}
Nostr is decentralized, so deletion is best-effort: relays that honor NIP-09 will
- drop it, but a relay that doesn’t may keep a copy.
+ drop it, but a relay that doesn’t may keep a copy. SkateSpots hides admin-removed
+ content in the app regardless.
diff --git a/src/hooks/useAdminModeration.ts b/src/hooks/useAdminModeration.ts
new file mode 100644
index 0000000..be2d3c2
--- /dev/null
+++ b/src/hooks/useAdminModeration.ts
@@ -0,0 +1,107 @@
+import { useQuery } from '@tanstack/react-query';
+import { useSkateRelays } from '@/hooks/useSkateRelays';
+import { ADMIN_PUBKEYS } from '@/lib/admins';
+import {
+ MODERATION_LABEL_NAMESPACE,
+ MODERATION_LABEL_HIDDEN,
+} from '@/lib/types';
+
+/**
+ * App-wide admin moderation layer.
+ *
+ * SkateSpots admins (see src/lib/admins.ts) can take down ANY listing (spot,
+ * crew, tour, event, shop, Lab profile, …). Because a NIP-09 deletion request
+ * signed by a non-author is not honored by most relays, admin takedowns also
+ * publish a NIP-32 kind:1985 `hidden` label referencing the target. This hook
+ * fetches those admin labels so the client can hide the content everywhere,
+ * reliably, regardless of relay NIP-09 behavior.
+ *
+ * Security: only labels authored by a hardcoded ADMIN pubkey are honored, so a
+ * random user cannot hide someone else's listing. The label authorship is the
+ * trust boundary (see .agents/skills/nostr-security §3).
+ */
+export interface AdminHiddenSet {
+ /** Hidden addressable coordinates `kind:pubkey:d`. */
+ coords: Set;
+ /** Hidden concrete event ids. */
+ ids: Set;
+ /** True if either set contains anything (cheap guard for callers). */
+ has: (opts: { coord?: string; id?: string }) => boolean;
+}
+
+const EMPTY: AdminHiddenSet = {
+ coords: new Set(),
+ ids: new Set(),
+ has: () => false,
+};
+
+/**
+ * Drop admin-hidden entities from a parsed list. Works for any entity that
+ * carries `{ id, pubkey, identifier }` (spots, crews, tours, shops, events, …).
+ * `kind` is the entity's Nostr kind, used to build the `a` coordinate.
+ */
+export function filterAdminHidden<
+ T extends { id: string; pubkey: string; identifier: string },
+>(items: T[], kind: number, hidden: AdminHiddenSet): T[] {
+ if (hidden.coords.size === 0 && hidden.ids.size === 0) return items;
+ return items.filter(
+ (it) =>
+ !hidden.has({ coord: `${kind}:${it.pubkey}:${it.identifier}`, id: it.id }),
+ );
+}
+
+export function useAdminModeration() {
+ const { querySkateContent, getQueryKeySuffix } = useSkateRelays();
+
+ const query = useQuery({
+ queryKey: ['admin-moderation', getQueryKeySuffix()],
+ queryFn: async (): Promise => {
+ const signal = AbortSignal.timeout(8000);
+
+ const events = await querySkateContent(
+ [
+ {
+ kinds: [1985],
+ authors: ADMIN_PUBKEYS,
+ '#l': [MODERATION_LABEL_HIDDEN],
+ limit: 1000,
+ },
+ ],
+ { signal },
+ );
+
+ const adminSet = new Set(ADMIN_PUBKEYS.map((p) => p.toLowerCase()));
+ const coords = new Set();
+ const ids = new Set();
+
+ for (const ev of events) {
+ // Defense in depth: re-verify author + namespace + label client-side.
+ if (!adminSet.has(ev.pubkey.toLowerCase())) continue;
+ const hasNamespace = ev.tags.some(
+ ([n, v]) => n === 'L' && v === MODERATION_LABEL_NAMESPACE,
+ );
+ const hasLabel = ev.tags.some(
+ ([n, v]) => n === 'l' && v === MODERATION_LABEL_HIDDEN,
+ );
+ if (!hasNamespace || !hasLabel) continue;
+
+ for (const [n, v] of ev.tags) {
+ if (n === 'a' && v) coords.add(v);
+ if (n === 'e' && v) ids.add(v);
+ }
+ }
+
+ return {
+ coords,
+ ids,
+ has: ({ coord, id }) =>
+ (!!coord && coords.has(coord)) || (!!id && ids.has(id)),
+ };
+ },
+ staleTime: 60 * 1000,
+ gcTime: 10 * 60 * 1000,
+ refetchOnMount: true,
+ });
+
+ return query.data ?? EMPTY;
+}
diff --git a/src/hooks/useCrews.ts b/src/hooks/useCrews.ts
index 295e478..ad64df8 100644
--- a/src/hooks/useCrews.ts
+++ b/src/hooks/useCrews.ts
@@ -8,12 +8,15 @@ import {
type CrewFocus,
} from '@/lib/types';
import { useSkateRelays } from '@/hooks/useSkateRelays';
+import { useAdminModeration, filterAdminHidden } from '@/hooks/useAdminModeration';
export function useCrews(limit = 200) {
const { querySkateContent, getQueryKeySuffix } = useSkateRelays();
+ const hidden = useAdminModeration();
return useQuery({
queryKey: ['skate-crews', limit, getQueryKeySuffix()],
+ select: (crews: SkateCrew[]) => filterAdminHidden(crews, SKATE_CREW_KIND, hidden),
queryFn: async () => {
const signal = AbortSignal.timeout(10000);
@@ -50,9 +53,11 @@ export function useCrews(limit = 200) {
export function useCrewsByFocus(focus: Exclude, limit = 30) {
const { querySkateContent, getQueryKeySuffix } = useSkateRelays();
const focusTag = CREW_FOCUS_TAG[focus];
+ const hidden = useAdminModeration();
return useQuery({
queryKey: ['skate-crews-focus', focus, limit, getQueryKeySuffix()],
+ select: (crews: SkateCrew[]) => filterAdminHidden(crews, SKATE_CREW_KIND, hidden),
queryFn: async () => {
const signal = AbortSignal.timeout(10000);
@@ -84,9 +89,14 @@ export function useCrewsByFocus(focus: Exclude, limit = 30)
export function useCrew(identifier: string, pubkey?: string) {
const { querySkateContent, getQueryKeySuffix } = useSkateRelays();
+ const hidden = useAdminModeration();
return useQuery({
queryKey: ['skate-crew', identifier, pubkey, getQueryKeySuffix()],
+ select: (crew: SkateCrew | null) =>
+ crew && hidden.has({ coord: `${SKATE_CREW_KIND}:${crew.pubkey}:${crew.identifier}`, id: crew.id })
+ ? null
+ : crew,
queryFn: async () => {
const signal = AbortSignal.timeout(10000);
diff --git a/src/hooks/useDeleteEntity.ts b/src/hooks/useDeleteEntity.ts
index 2a41cec..a2c8068 100644
--- a/src/hooks/useDeleteEntity.ts
+++ b/src/hooks/useDeleteEntity.ts
@@ -1,23 +1,34 @@
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { useNostrPublish } from '@/hooks/useNostrPublish';
import { useCurrentUser } from '@/hooks/useCurrentUser';
+import { isAdminPubkey } from '@/lib/admins';
+import {
+ MODERATION_LABEL_NAMESPACE,
+ MODERATION_LABEL_HIDDEN,
+} from '@/lib/types';
import type { NostrEvent } from '@nostrify/nostrify';
/**
- * NIP-09 deletion request for an ADDRESSABLE event (kinds 30000–39999) such as
- * a skate crew, a Lab profile, a spot, etc.
+ * Deletion / takedown for an ADDRESSABLE event (kinds 30000–39999) such as a
+ * skate crew, a Lab profile, a spot, a tour, an event, etc.
*
- * How deletion works on Nostr (important to set expectations):
- * • We publish a kind:5 "deletion request" that references the target event by
- * its `a` coordinate (kind:pubkey:d-tag) and its `e` id + `k` kind.
- * • Relays that honor NIP-09 stop serving the referenced event and won't
- * return it in future queries. This is how "delete" works in a
- * decentralized network — there's no central database row to remove.
- * • A relay that ignores NIP-09 may still serve a cached copy, and other
- * users' relays may retain it. So deletion is best-effort, not a guarantee.
+ * Two callers, two mechanisms:
*
- * Only the ORIGINAL AUTHOR can delete their own event — a deletion request is
- * only valid when signed by the same pubkey that created the target.
+ * 1. The ORIGINAL AUTHOR deleting their own listing → NIP-09 kind:5 deletion
+ * request. Relays that honor NIP-09 stop serving the event. A deletion
+ * request is only valid when signed by the pubkey that created the target,
+ * so an author's request is respected network-wide.
+ *
+ * 2. A SkateSpots ADMIN (see src/lib/admins.ts) taking down someone else's
+ * content → we ALSO publish a kind:5 (honored by the community relay for
+ * its own stored events) AND a NIP-32 kind:1985 `hidden` label referencing
+ * the target coordinate. The label is authored by the admin, so the app's
+ * client-side moderation layer (useAdminModeration) can hide the content
+ * everywhere regardless of whether a given relay honors a cross-author
+ * NIP-09 request. This makes admin takedowns reliable inside the app.
+ *
+ * In all cases deletion on a decentralized network is best-effort: a relay that
+ * ignores NIP-09 may keep a cached copy, and other users' relays may retain it.
*/
export function useDeleteEntity() {
const { mutateAsync: publishEvent } = useNostrPublish();
@@ -35,7 +46,10 @@ export function useDeleteEntity() {
reason?: string;
}) => {
if (!user) throw new Error('You must be logged in to delete.');
- if (user.pubkey !== event.pubkey) {
+
+ const isOwner = user.pubkey === event.pubkey;
+ const isAdmin = isAdminPubkey(user.pubkey);
+ if (!isOwner && !isAdmin) {
throw new Error('You can only delete your own listing.');
}
@@ -44,17 +58,37 @@ export function useDeleteEntity() {
// NIP-09: reference by both the addressable coordinate (`a`) and the
// concrete event id (`e`), plus the kind (`k`), for maximum relay support.
- const tags: string[][] = [
+ const deletionTags: string[][] = [
['a', coord],
['e', event.id],
['k', String(event.kind)],
];
- return publishEvent({
+ await publishEvent({
kind: 5,
content: reason?.trim() || '',
- tags,
+ tags: deletionTags,
});
+
+ // Admin takedown of someone else's content: also publish a client-side
+ // moderation label so the app hides it even if relays won't honor a
+ // cross-author NIP-09 deletion.
+ if (isAdmin && !isOwner) {
+ const labelTags: string[][] = [
+ ['L', MODERATION_LABEL_NAMESPACE],
+ ['l', MODERATION_LABEL_HIDDEN, MODERATION_LABEL_NAMESPACE],
+ ['a', coord],
+ ['e', event.id],
+ ['k', String(event.kind)],
+ ['p', event.pubkey],
+ ['alt', `SkateSpots admin takedown${reason?.trim() ? `: ${reason.trim()}` : ''}`],
+ ];
+ await publishEvent({
+ kind: 1985,
+ content: reason?.trim() || 'Removed by SkateSpots admin',
+ tags: labelTags,
+ });
+ }
},
onSuccess: () => {
// Nudge every list/detail query to refetch so the deleted entity drops out.
diff --git a/src/hooks/useSkateEvents.ts b/src/hooks/useSkateEvents.ts
index df39abd..7df88a4 100644
--- a/src/hooks/useSkateEvents.ts
+++ b/src/hooks/useSkateEvents.ts
@@ -1,12 +1,15 @@
import { useQuery } from '@tanstack/react-query';
import { CALENDAR_EVENT_KIND, parseSkateEvent, isFeaturedActive, type SkateEvent } from '@/lib/types';
import { useSkateRelays } from '@/hooks/useSkateRelays';
+import { useAdminModeration, filterAdminHidden } from '@/hooks/useAdminModeration';
export function useSkateEvents(limit = 50) {
const { querySkateContent, getQueryKeySuffix } = useSkateRelays();
+ const hidden = useAdminModeration();
return useQuery({
queryKey: ['skate-events', limit, getQueryKeySuffix()],
+ select: (events: SkateEvent[]) => filterAdminHidden(events, CALENDAR_EVENT_KIND, hidden),
queryFn: async () => {
const signal = AbortSignal.timeout(15000);
@@ -41,10 +44,12 @@ export function useSkateEvents(limit = 50) {
export function useUpcomingSkateEvents(limit = 20) {
const { querySkateContent, getQueryKeySuffix } = useSkateRelays();
+ const hidden = useAdminModeration();
const now = Math.floor(Date.now() / 1000);
return useQuery({
queryKey: ['upcoming-skate-events', limit, getQueryKeySuffix()],
+ select: (events: SkateEvent[]) => filterAdminHidden(events, CALENDAR_EVENT_KIND, hidden),
queryFn: async () => {
const signal = AbortSignal.timeout(15000);
@@ -84,9 +89,14 @@ export function useUpcomingSkateEvents(limit = 20) {
export function useSkateEvent(identifier: string, pubkey?: string) {
const { querySkateContent, getQueryKeySuffix } = useSkateRelays();
+ const hidden = useAdminModeration();
return useQuery({
queryKey: ['skate-event', identifier, pubkey, getQueryKeySuffix()],
+ select: (evt: SkateEvent | null) =>
+ evt && hidden.has({ coord: `${CALENDAR_EVENT_KIND}:${evt.pubkey}:${evt.identifier}`, id: evt.id })
+ ? null
+ : evt,
queryFn: async () => {
const signal = AbortSignal.timeout(10000);
diff --git a/src/hooks/useSkateShops.ts b/src/hooks/useSkateShops.ts
index 09b2d66..bb6e849 100644
--- a/src/hooks/useSkateShops.ts
+++ b/src/hooks/useSkateShops.ts
@@ -1,15 +1,18 @@
import { useQuery } from '@tanstack/react-query';
import { SKATE_SHOP_KIND, parseSkateShop, type SkateShop } from '@/lib/types';
import { useSkateRelays } from '@/hooks/useSkateRelays';
+import { useAdminModeration, filterAdminHidden } from '@/hooks/useAdminModeration';
/**
* Hook to fetch skate shops using the skate relay logic
*/
export function useSkateShops(limit: number = 100) {
const { querySkateContent, getQueryKeySuffix } = useSkateRelays();
+ const hidden = useAdminModeration();
- return useQuery({
+ return useQuery({
queryKey: ['skate-shops', limit, getQueryKeySuffix()],
+ select: (shops: SkateShop[]) => filterAdminHidden(shops, SKATE_SHOP_KIND, hidden),
queryFn: async () => {
const signal = AbortSignal.timeout(10000);
@@ -41,9 +44,14 @@ export function useSkateShops(limit: number = 100) {
*/
export function useSkateShop(pubkey: string | undefined, identifier: string | undefined) {
const { querySkateContent, getQueryKeySuffix } = useSkateRelays();
+ const hidden = useAdminModeration();
- return useQuery({
+ return useQuery({
queryKey: ['skate-shop', pubkey, identifier, getQueryKeySuffix()],
+ select: (shop: SkateShop | null) =>
+ shop && hidden.has({ coord: `${SKATE_SHOP_KIND}:${shop.pubkey}:${shop.identifier}`, id: shop.id })
+ ? null
+ : shop,
queryFn: async () => {
if (!pubkey || !identifier) return null;
diff --git a/src/hooks/useSkateSpots.ts b/src/hooks/useSkateSpots.ts
index 2654fed..0454841 100644
--- a/src/hooks/useSkateSpots.ts
+++ b/src/hooks/useSkateSpots.ts
@@ -1,6 +1,7 @@
import { useQuery } from '@tanstack/react-query';
import { SKATE_SPOT_KIND, parseSkateSpot, type SkateSpot } from '@/lib/types';
import { useSkateRelays } from '@/hooks/useSkateRelays';
+import { useAdminModeration, filterAdminHidden } from '@/hooks/useAdminModeration';
import { cacheSpots, getAllCachedSpots, isOffline } from '@/lib/offlineCache';
// Spots change infrequently — cache for 5 minutes, keep for 30 minutes
@@ -10,9 +11,11 @@ const QUERY_TIMEOUT = 15000; // 15s — mobile cold-start needs mo
export function useSkateSpots(limit = 100) {
const { querySkateContent, getQueryKeySuffix } = useSkateRelays();
+ const hidden = useAdminModeration();
return useQuery({
queryKey: ['skate-spots', limit, getQueryKeySuffix()],
+ select: (spots: SkateSpot[]) => filterAdminHidden(spots, SKATE_SPOT_KIND, hidden),
queryFn: async () => {
// If offline, return cached spots
if (isOffline()) {
@@ -60,9 +63,14 @@ export function useSkateSpots(limit = 100) {
export function useSkateSpot(identifier: string, pubkey?: string) {
const { querySkateContent, getQueryKeySuffix } = useSkateRelays();
+ const hidden = useAdminModeration();
return useQuery({
queryKey: ['skate-spot', identifier, pubkey, getQueryKeySuffix()],
+ select: (spot: SkateSpot | null) =>
+ spot && hidden.has({ coord: `${SKATE_SPOT_KIND}:${spot.pubkey}:${spot.identifier}`, id: spot.id })
+ ? null
+ : spot,
queryFn: async () => {
// If offline, try to get from cache first
if (isOffline() && pubkey) {
diff --git a/src/hooks/useTours.ts b/src/hooks/useTours.ts
index c6c7ab9..78dfbb6 100644
--- a/src/hooks/useTours.ts
+++ b/src/hooks/useTours.ts
@@ -1,6 +1,7 @@
import { useQuery } from '@tanstack/react-query';
import { SKATE_TOUR_KIND, parseSkateTour, isFeaturedActive, type SkateTour } from '@/lib/types';
import { useSkateRelays } from '@/hooks/useSkateRelays';
+import { useAdminModeration, filterAdminHidden } from '@/hooks/useAdminModeration';
/** Sort featured (Pro) tours first, then newest. */
function sortTours(a: SkateTour, b: SkateTour): number {
@@ -16,9 +17,11 @@ const TIMEOUT = 8000;
export function useTours(limit = 100) {
const { querySkateContent, getQueryKeySuffix } = useSkateRelays();
+ const hidden = useAdminModeration();
return useQuery({
queryKey: ['skate-tours', limit, getQueryKeySuffix()],
+ select: (tours: SkateTour[]) => filterAdminHidden(tours, SKATE_TOUR_KIND, hidden),
queryFn: async () => {
const signal = AbortSignal.timeout(TIMEOUT);
@@ -49,9 +52,14 @@ export function useTours(limit = 100) {
export function useTour(identifier: string, pubkey?: string) {
const { querySkateContent, getQueryKeySuffix } = useSkateRelays();
+ const hidden = useAdminModeration();
return useQuery({
queryKey: ['skate-tour', identifier, pubkey, getQueryKeySuffix()],
+ select: (tour: SkateTour | null) =>
+ tour && hidden.has({ coord: `${SKATE_TOUR_KIND}:${tour.pubkey}:${tour.identifier}`, id: tour.id })
+ ? null
+ : tour,
queryFn: async () => {
const signal = AbortSignal.timeout(TIMEOUT);
diff --git a/src/pages/EventDetailPage.tsx b/src/pages/EventDetailPage.tsx
index f66c7cc..ab906e7 100644
--- a/src/pages/EventDetailPage.tsx
+++ b/src/pages/EventDetailPage.tsx
@@ -11,6 +11,7 @@ import { Skeleton } from '@/components/ui/skeleton';
import { Button } from '@/components/ui/button';
import { ZapButton } from '@/components/ZapButton';
import { CommentsSection } from '@/components/comments/CommentsSection';
+import { DeleteEntityDialog } from '@/components/DeleteEntityDialog';
import { useSkateEvent } from '@/hooks/useSkateEvents';
import { useAuthor } from '@/hooks/useAuthor';
import { genUserName } from '@/lib/genUserName';
@@ -194,14 +195,22 @@ export default function EventDetailPage() {
return (
- {/* Back link */}
-
-
- Back to Events
-
+ {/* Back link + owner/admin controls */}
+
+
+
+ Back to Events
+
+
+
{/* Main Content */}
diff --git a/src/pages/ShopDetailPage.tsx b/src/pages/ShopDetailPage.tsx
index 17d522d..52cdd02 100644
--- a/src/pages/ShopDetailPage.tsx
+++ b/src/pages/ShopDetailPage.tsx
@@ -17,7 +17,9 @@ import { sanitizeUrl, sanitizeTel, sanitizeEmail, urlDomain } from '@/lib/saniti
import { OfficialBadge } from '@/components/OfficialBadge';
import { useShop } from '@/hooks/useShops';
import { useAuthor } from '@/hooks/useAuthor';
-import { useNostrPublish } from '@/hooks/useNostrPublish';
+import { useDeleteEntity } from '@/hooks/useDeleteEntity';
+import { isAdminPubkey } from '@/lib/admins';
+import { cn } from '@/lib/utils';
import { useToast } from '@/hooks/useToast';
import { genUserName } from '@/lib/genUserName';
import NotFound from './NotFound';
@@ -78,13 +80,17 @@ export default function ShopDetailPage() {
const { data: shop, isLoading, error } = useShop(identifier || '', pubkey);
const author = useAuthor(shop?.pubkey);
const { user } = useCurrentUser();
- const { mutateAsync: publishEvent } = useNostrPublish();
+ const { mutateAsync: deleteEntity } = useDeleteEntity();
const { toast } = useToast();
const navigate = useNavigate();
const queryClient = useQueryClient();
const [showDeleteDialog, setShowDeleteDialog] = useState(false);
const isOwner = user && shop && user.pubkey === shop.pubkey;
+ const isAdmin = isAdminPubkey(user?.pubkey);
+ // Owner can delete their own shop; a SkateSpots admin can take down any shop.
+ const canDelete = !!user && !!shop && (isOwner || isAdmin);
+ const adminTakedown = isAdmin && !isOwner;
const hasNip05 = user?.metadata?.nip05;
useSeoMeta({
@@ -144,19 +150,19 @@ export default function ShopDetailPage() {
if (!shop || !user) return;
try {
- await publishEvent({
- kind: 5,
- content: 'Deleted shop',
- tags: [
- ['e', shop.id],
- ['a', `${shop.event.kind}:${shop.pubkey}:${shop.identifier}`],
- ],
+ // Routes through useDeleteEntity: owner → NIP-09 delete; admin → NIP-09
+ // delete + a moderation label so the shop is hidden across the app.
+ await deleteEntity({
+ event: shop.event,
+ reason: adminTakedown ? `SkateSpots admin removed shop "${shop.name}"` : 'Deleted shop',
});
setShowDeleteDialog(false);
toast({
- title: 'Shop deleted',
- description: 'Deletion request sent to relays. Most relays will remove this content.',
+ title: adminTakedown ? 'Shop removed' : 'Shop deleted',
+ description: adminTakedown
+ ? 'This shop has been taken down and will be hidden across SkateSpots.'
+ : 'Deletion request sent to relays. Most relays will remove this content.',
});
queryClient.invalidateQueries({ queryKey: ['skate-shops'] });
@@ -257,21 +263,25 @@ export default function ShopDetailPage() {
{isOwner && (
- <>
-
-
-
-
{isOwner && (
- <>
-
-
-
-
-
- setShowDeleteDialog(true)}
- className="hover:bg-destructive hover:text-destructive-foreground"
- >
-
+
+
+
- >
+
+ )}
+ {canDelete && (
+ setShowDeleteDialog(true)}
+ title={adminTakedown ? 'Remove (admin)' : 'Delete'}
+ className={cn(
+ 'hover:bg-destructive hover:text-destructive-foreground',
+ adminTakedown && 'border-red-500/50 text-red-400',
+ )}
+ >
+
+
)}
{user && !isShop && (
<>
@@ -971,10 +981,12 @@ export default function SpotDetailPage() {
- Delete Spot?
+ {adminTakedown ? 'Remove this spot? (admin)' : 'Delete Spot?'}
- This will permanently delete "{spot.title}" from all relays. This action cannot be undone.
+ {adminTakedown
+ ? `As a SkateSpots admin you're taking down "${spot.title}". It will be hidden across the app for everyone and a deletion request will be sent to relays.`
+ : `This will permanently delete "${spot.title}" from all relays. This action cannot be undone.`}
@@ -983,7 +995,7 @@ export default function SpotDetailPage() {
onClick={handleDelete}
className="bg-red-500 hover:bg-red-600 text-white"
>
- Delete Spot
+ {adminTakedown ? 'Remove Spot' : 'Delete Spot'}
diff --git a/src/pages/TourDetailPage.tsx b/src/pages/TourDetailPage.tsx
index 0f48eee..ab5558f 100644
--- a/src/pages/TourDetailPage.tsx
+++ b/src/pages/TourDetailPage.tsx
@@ -13,6 +13,7 @@ import { genUserName } from '@/lib/genUserName';
import { TOUR_SKILL_LEVELS, TOUR_SPOT_TYPES, SKATE_TOUR_KIND } from '@/lib/types';
import { ZapButton } from '@/components/ZapButton';
import { ProBadge } from '@/components/ProBadge';
+import { DeleteEntityDialog } from '@/components/DeleteEntityDialog';
import {
ArrowLeft,
Route,
@@ -124,11 +125,19 @@ export default function TourDetailPage() {
return (
- {/* Back link */}
-
-
- Back to Map
-
+ {/* Back link + owner/admin controls */}
+