Admins can delete/take down any listing (spot, crew, tour, event, shop)
The two SkateSpots admin pubkeys (already in src/lib/admins.ts) can now remove any listing, not just their own. Because a NIP-09 deletion signed by a non-author isn't honored by most relays, admin takedowns are made reliable with a client-side moderation layer: - useDeleteEntity: owners still publish a plain NIP-09 kind:5. Admins acting on someone else's content publish the kind:5 AND a NIP-32 kind:1985 `hidden` label referencing the target coordinate/id, authored by the admin. - useAdminModeration (new): fetches admin-authored `hidden` labels app-wide and exposes a fast lookup + filterAdminHidden() helper. Only labels whose author is in the hardcoded ADMIN_PUBKEYS are honored (author = trust boundary, re-verified client-side; never derived from a d-tag). - Wired the hide filter into the list + detail hooks for spots, crews (incl. by-focus), tours, events, and shops via React Query `select`, so removed content disappears from lists and 404s on its detail page. UI: - DeleteEntityDialog now renders for owner OR admin, with distinct "Remove (admin)" framing and copy (used on Crew, Coach, Tour, Event pages). - SpotDetailPage & ShopDetailPage: routed their inline delete through useDeleteEntity, show the delete button for admins, and relabel the confirm dialog for admin takedowns. - Added the delete control to Tour and Event detail pages (previously none). SECURITY.md: documented the admin moderation trust boundary and extended the auditor file map. Verified build + no console errors. Co-authored-by: shakespeare.diy <assistant@shakespeare.diy>
This commit is contained in:
parent
538ef67b0b
commit
13eece3a1e
13 changed files with 363 additions and 98 deletions
14
SECURITY.md
14
SECURITY.md
|
|
@ -213,6 +213,19 @@ intentionally *not* author-filtered.
|
||||||
worst-case loss from a compromised connection.
|
worst-case loss from a compromised connection.
|
||||||
- Prefer wallets/flows that let you set a spending budget.
|
- 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
|
### Tooling
|
||||||
|
|
||||||
- A custom ESLint rule (`eslint-rules/no-inline-script.js`) forbids inline
|
- 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`) |
|
| Login / key handling | `src/components/auth/`, Nostrify login (`nostr:login`) |
|
||||||
| Lightning / NWC | `src/hooks/useNWC.ts`, `src/components/WalletModal.tsx` |
|
| 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` |
|
| 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` (`<meta>` — covers mobile), `public/_headers`, `netlify.toml`, `public/boot-guard.js` |
|
| CSP & security headers | `index.html` (`<meta>` — covers mobile), `public/_headers`, `netlify.toml`, `public/boot-guard.js` |
|
||||||
| Native wrapper (same bundle) | `capacitor.config.ts` (`webDir: 'dist'`) |
|
| Native wrapper (same bundle) | `capacitor.config.ts` (`webDir: 'dist'`) |
|
||||||
| NIP-19 routing | `src/pages/NIP19Page.tsx`, `src/AppRouter.tsx` |
|
| NIP-19 routing | `src/pages/NIP19Page.tsx`, `src/AppRouter.tsx` |
|
||||||
|
|
|
||||||
|
|
@ -13,6 +13,7 @@ import {
|
||||||
import { useDeleteEntity } from '@/hooks/useDeleteEntity';
|
import { useDeleteEntity } from '@/hooks/useDeleteEntity';
|
||||||
import { useCurrentUser } from '@/hooks/useCurrentUser';
|
import { useCurrentUser } from '@/hooks/useCurrentUser';
|
||||||
import { useToast } from '@/hooks/useToast';
|
import { useToast } from '@/hooks/useToast';
|
||||||
|
import { isAdminPubkey } from '@/lib/admins';
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
import type { NostrEvent } from '@nostrify/nostrify';
|
import type { NostrEvent } from '@nostrify/nostrify';
|
||||||
|
|
||||||
|
|
@ -53,15 +54,29 @@ export function DeleteEntityDialog({
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const [open, setOpen] = useState(false);
|
const [open, setOpen] = useState(false);
|
||||||
|
|
||||||
// Only the owner can delete their own event.
|
if (!user) return null;
|
||||||
if (!user || user.pubkey !== event.pubkey) 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 () => {
|
const handleDelete = async () => {
|
||||||
try {
|
try {
|
||||||
await deleteEntity({ event, reason: `Deleted ${entityLabel} "${entityName}"` });
|
await deleteEntity({
|
||||||
|
event,
|
||||||
|
reason: adminTakedown
|
||||||
|
? `SkateSpots admin removed ${entityLabel} "${entityName}"`
|
||||||
|
: `Deleted ${entityLabel} "${entityName}"`,
|
||||||
|
});
|
||||||
toast({
|
toast({
|
||||||
title: 'Deletion requested',
|
title: adminTakedown ? 'Content removed' : 'Deletion requested',
|
||||||
description: `Your ${entityLabel} has been removed. Relays that honor deletions will stop serving it.`,
|
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);
|
setOpen(false);
|
||||||
if (redirectTo) navigate(redirectTo);
|
if (redirectTo) navigate(redirectTo);
|
||||||
|
|
@ -89,7 +104,7 @@ export function DeleteEntityDialog({
|
||||||
className,
|
className,
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<Trash2 className="h-3.5 w-3.5" /> {triggerText}
|
<Trash2 className="h-3.5 w-3.5" /> {adminTakedown ? `Remove (admin)` : triggerText}
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|
@ -102,15 +117,24 @@ export function DeleteEntityDialog({
|
||||||
</AlertDialogTitle>
|
</AlertDialogTitle>
|
||||||
<AlertDialogDescription asChild>
|
<AlertDialogDescription asChild>
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
<p>
|
{adminTakedown ? (
|
||||||
This asks relays to remove your {entityLabel}. This can’t be undone from here — you’d
|
<p>
|
||||||
have to create a new one.
|
As a SkateSpots admin you’re taking down this {entityLabel}. It will be hidden
|
||||||
</p>
|
across the app for everyone and a deletion request will be sent to relays. The
|
||||||
|
original author can’t restore it from here.
|
||||||
|
</p>
|
||||||
|
) : (
|
||||||
|
<p>
|
||||||
|
This asks relays to remove your {entityLabel}. This can’t be undone from here — you’d
|
||||||
|
have to create a new one.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
<div className="flex gap-2 rounded-lg border border-yellow-500/30 bg-yellow-500/10 p-3 text-sm">
|
<div className="flex gap-2 rounded-lg border border-yellow-500/30 bg-yellow-500/10 p-3 text-sm">
|
||||||
<AlertTriangle className="mt-0.5 h-4 w-4 flex-shrink-0 text-yellow-500" />
|
<AlertTriangle className="mt-0.5 h-4 w-4 flex-shrink-0 text-yellow-500" />
|
||||||
<p className="text-yellow-400/90">
|
<p className="text-yellow-400/90">
|
||||||
Nostr is decentralized, so deletion is best-effort: relays that honor NIP-09 will
|
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.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
107
src/hooks/useAdminModeration.ts
Normal file
107
src/hooks/useAdminModeration.ts
Normal file
|
|
@ -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<string>;
|
||||||
|
/** Hidden concrete event ids. */
|
||||||
|
ids: Set<string>;
|
||||||
|
/** 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<AdminHiddenSet> => {
|
||||||
|
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<string>();
|
||||||
|
const ids = new Set<string>();
|
||||||
|
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
@ -8,12 +8,15 @@ import {
|
||||||
type CrewFocus,
|
type CrewFocus,
|
||||||
} from '@/lib/types';
|
} from '@/lib/types';
|
||||||
import { useSkateRelays } from '@/hooks/useSkateRelays';
|
import { useSkateRelays } from '@/hooks/useSkateRelays';
|
||||||
|
import { useAdminModeration, filterAdminHidden } from '@/hooks/useAdminModeration';
|
||||||
|
|
||||||
export function useCrews(limit = 200) {
|
export function useCrews(limit = 200) {
|
||||||
const { querySkateContent, getQueryKeySuffix } = useSkateRelays();
|
const { querySkateContent, getQueryKeySuffix } = useSkateRelays();
|
||||||
|
const hidden = useAdminModeration();
|
||||||
|
|
||||||
return useQuery({
|
return useQuery({
|
||||||
queryKey: ['skate-crews', limit, getQueryKeySuffix()],
|
queryKey: ['skate-crews', limit, getQueryKeySuffix()],
|
||||||
|
select: (crews: SkateCrew[]) => filterAdminHidden(crews, SKATE_CREW_KIND, hidden),
|
||||||
queryFn: async () => {
|
queryFn: async () => {
|
||||||
const signal = AbortSignal.timeout(10000);
|
const signal = AbortSignal.timeout(10000);
|
||||||
|
|
||||||
|
|
@ -50,9 +53,11 @@ export function useCrews(limit = 200) {
|
||||||
export function useCrewsByFocus(focus: Exclude<CrewFocus, 'mixed'>, limit = 30) {
|
export function useCrewsByFocus(focus: Exclude<CrewFocus, 'mixed'>, limit = 30) {
|
||||||
const { querySkateContent, getQueryKeySuffix } = useSkateRelays();
|
const { querySkateContent, getQueryKeySuffix } = useSkateRelays();
|
||||||
const focusTag = CREW_FOCUS_TAG[focus];
|
const focusTag = CREW_FOCUS_TAG[focus];
|
||||||
|
const hidden = useAdminModeration();
|
||||||
|
|
||||||
return useQuery({
|
return useQuery({
|
||||||
queryKey: ['skate-crews-focus', focus, limit, getQueryKeySuffix()],
|
queryKey: ['skate-crews-focus', focus, limit, getQueryKeySuffix()],
|
||||||
|
select: (crews: SkateCrew[]) => filterAdminHidden(crews, SKATE_CREW_KIND, hidden),
|
||||||
queryFn: async () => {
|
queryFn: async () => {
|
||||||
const signal = AbortSignal.timeout(10000);
|
const signal = AbortSignal.timeout(10000);
|
||||||
|
|
||||||
|
|
@ -84,9 +89,14 @@ export function useCrewsByFocus(focus: Exclude<CrewFocus, 'mixed'>, limit = 30)
|
||||||
|
|
||||||
export function useCrew(identifier: string, pubkey?: string) {
|
export function useCrew(identifier: string, pubkey?: string) {
|
||||||
const { querySkateContent, getQueryKeySuffix } = useSkateRelays();
|
const { querySkateContent, getQueryKeySuffix } = useSkateRelays();
|
||||||
|
const hidden = useAdminModeration();
|
||||||
|
|
||||||
return useQuery({
|
return useQuery({
|
||||||
queryKey: ['skate-crew', identifier, pubkey, getQueryKeySuffix()],
|
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 () => {
|
queryFn: async () => {
|
||||||
const signal = AbortSignal.timeout(10000);
|
const signal = AbortSignal.timeout(10000);
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,23 +1,34 @@
|
||||||
import { useMutation, useQueryClient } from '@tanstack/react-query';
|
import { useMutation, useQueryClient } from '@tanstack/react-query';
|
||||||
import { useNostrPublish } from '@/hooks/useNostrPublish';
|
import { useNostrPublish } from '@/hooks/useNostrPublish';
|
||||||
import { useCurrentUser } from '@/hooks/useCurrentUser';
|
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';
|
import type { NostrEvent } from '@nostrify/nostrify';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* NIP-09 deletion request for an ADDRESSABLE event (kinds 30000–39999) such as
|
* Deletion / takedown for an ADDRESSABLE event (kinds 30000–39999) such as a
|
||||||
* a skate crew, a Lab profile, a spot, etc.
|
* skate crew, a Lab profile, a spot, a tour, an event, etc.
|
||||||
*
|
*
|
||||||
* How deletion works on Nostr (important to set expectations):
|
* Two callers, two mechanisms:
|
||||||
* • 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.
|
|
||||||
*
|
*
|
||||||
* Only the ORIGINAL AUTHOR can delete their own event — a deletion request is
|
* 1. The ORIGINAL AUTHOR deleting their own listing → NIP-09 kind:5 deletion
|
||||||
* only valid when signed by the same pubkey that created the target.
|
* 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() {
|
export function useDeleteEntity() {
|
||||||
const { mutateAsync: publishEvent } = useNostrPublish();
|
const { mutateAsync: publishEvent } = useNostrPublish();
|
||||||
|
|
@ -35,7 +46,10 @@ export function useDeleteEntity() {
|
||||||
reason?: string;
|
reason?: string;
|
||||||
}) => {
|
}) => {
|
||||||
if (!user) throw new Error('You must be logged in to delete.');
|
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.');
|
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
|
// NIP-09: reference by both the addressable coordinate (`a`) and the
|
||||||
// concrete event id (`e`), plus the kind (`k`), for maximum relay support.
|
// concrete event id (`e`), plus the kind (`k`), for maximum relay support.
|
||||||
const tags: string[][] = [
|
const deletionTags: string[][] = [
|
||||||
['a', coord],
|
['a', coord],
|
||||||
['e', event.id],
|
['e', event.id],
|
||||||
['k', String(event.kind)],
|
['k', String(event.kind)],
|
||||||
];
|
];
|
||||||
|
|
||||||
return publishEvent({
|
await publishEvent({
|
||||||
kind: 5,
|
kind: 5,
|
||||||
content: reason?.trim() || '',
|
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: () => {
|
onSuccess: () => {
|
||||||
// Nudge every list/detail query to refetch so the deleted entity drops out.
|
// Nudge every list/detail query to refetch so the deleted entity drops out.
|
||||||
|
|
|
||||||
|
|
@ -1,12 +1,15 @@
|
||||||
import { useQuery } from '@tanstack/react-query';
|
import { useQuery } from '@tanstack/react-query';
|
||||||
import { CALENDAR_EVENT_KIND, parseSkateEvent, isFeaturedActive, type SkateEvent } from '@/lib/types';
|
import { CALENDAR_EVENT_KIND, parseSkateEvent, isFeaturedActive, type SkateEvent } from '@/lib/types';
|
||||||
import { useSkateRelays } from '@/hooks/useSkateRelays';
|
import { useSkateRelays } from '@/hooks/useSkateRelays';
|
||||||
|
import { useAdminModeration, filterAdminHidden } from '@/hooks/useAdminModeration';
|
||||||
|
|
||||||
export function useSkateEvents(limit = 50) {
|
export function useSkateEvents(limit = 50) {
|
||||||
const { querySkateContent, getQueryKeySuffix } = useSkateRelays();
|
const { querySkateContent, getQueryKeySuffix } = useSkateRelays();
|
||||||
|
const hidden = useAdminModeration();
|
||||||
|
|
||||||
return useQuery({
|
return useQuery({
|
||||||
queryKey: ['skate-events', limit, getQueryKeySuffix()],
|
queryKey: ['skate-events', limit, getQueryKeySuffix()],
|
||||||
|
select: (events: SkateEvent[]) => filterAdminHidden(events, CALENDAR_EVENT_KIND, hidden),
|
||||||
queryFn: async () => {
|
queryFn: async () => {
|
||||||
const signal = AbortSignal.timeout(15000);
|
const signal = AbortSignal.timeout(15000);
|
||||||
|
|
||||||
|
|
@ -41,10 +44,12 @@ export function useSkateEvents(limit = 50) {
|
||||||
|
|
||||||
export function useUpcomingSkateEvents(limit = 20) {
|
export function useUpcomingSkateEvents(limit = 20) {
|
||||||
const { querySkateContent, getQueryKeySuffix } = useSkateRelays();
|
const { querySkateContent, getQueryKeySuffix } = useSkateRelays();
|
||||||
|
const hidden = useAdminModeration();
|
||||||
const now = Math.floor(Date.now() / 1000);
|
const now = Math.floor(Date.now() / 1000);
|
||||||
|
|
||||||
return useQuery({
|
return useQuery({
|
||||||
queryKey: ['upcoming-skate-events', limit, getQueryKeySuffix()],
|
queryKey: ['upcoming-skate-events', limit, getQueryKeySuffix()],
|
||||||
|
select: (events: SkateEvent[]) => filterAdminHidden(events, CALENDAR_EVENT_KIND, hidden),
|
||||||
queryFn: async () => {
|
queryFn: async () => {
|
||||||
const signal = AbortSignal.timeout(15000);
|
const signal = AbortSignal.timeout(15000);
|
||||||
|
|
||||||
|
|
@ -84,9 +89,14 @@ export function useUpcomingSkateEvents(limit = 20) {
|
||||||
|
|
||||||
export function useSkateEvent(identifier: string, pubkey?: string) {
|
export function useSkateEvent(identifier: string, pubkey?: string) {
|
||||||
const { querySkateContent, getQueryKeySuffix } = useSkateRelays();
|
const { querySkateContent, getQueryKeySuffix } = useSkateRelays();
|
||||||
|
const hidden = useAdminModeration();
|
||||||
|
|
||||||
return useQuery({
|
return useQuery({
|
||||||
queryKey: ['skate-event', identifier, pubkey, getQueryKeySuffix()],
|
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 () => {
|
queryFn: async () => {
|
||||||
const signal = AbortSignal.timeout(10000);
|
const signal = AbortSignal.timeout(10000);
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,15 +1,18 @@
|
||||||
import { useQuery } from '@tanstack/react-query';
|
import { useQuery } from '@tanstack/react-query';
|
||||||
import { SKATE_SHOP_KIND, parseSkateShop, type SkateShop } from '@/lib/types';
|
import { SKATE_SHOP_KIND, parseSkateShop, type SkateShop } from '@/lib/types';
|
||||||
import { useSkateRelays } from '@/hooks/useSkateRelays';
|
import { useSkateRelays } from '@/hooks/useSkateRelays';
|
||||||
|
import { useAdminModeration, filterAdminHidden } from '@/hooks/useAdminModeration';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Hook to fetch skate shops using the skate relay logic
|
* Hook to fetch skate shops using the skate relay logic
|
||||||
*/
|
*/
|
||||||
export function useSkateShops(limit: number = 100) {
|
export function useSkateShops(limit: number = 100) {
|
||||||
const { querySkateContent, getQueryKeySuffix } = useSkateRelays();
|
const { querySkateContent, getQueryKeySuffix } = useSkateRelays();
|
||||||
|
const hidden = useAdminModeration();
|
||||||
|
|
||||||
return useQuery<SkateShop[]>({
|
return useQuery<SkateShop[], Error, SkateShop[]>({
|
||||||
queryKey: ['skate-shops', limit, getQueryKeySuffix()],
|
queryKey: ['skate-shops', limit, getQueryKeySuffix()],
|
||||||
|
select: (shops: SkateShop[]) => filterAdminHidden(shops, SKATE_SHOP_KIND, hidden),
|
||||||
queryFn: async () => {
|
queryFn: async () => {
|
||||||
const signal = AbortSignal.timeout(10000);
|
const signal = AbortSignal.timeout(10000);
|
||||||
|
|
||||||
|
|
@ -41,9 +44,14 @@ export function useSkateShops(limit: number = 100) {
|
||||||
*/
|
*/
|
||||||
export function useSkateShop(pubkey: string | undefined, identifier: string | undefined) {
|
export function useSkateShop(pubkey: string | undefined, identifier: string | undefined) {
|
||||||
const { querySkateContent, getQueryKeySuffix } = useSkateRelays();
|
const { querySkateContent, getQueryKeySuffix } = useSkateRelays();
|
||||||
|
const hidden = useAdminModeration();
|
||||||
|
|
||||||
return useQuery<SkateShop | null>({
|
return useQuery<SkateShop | null, Error, SkateShop | null>({
|
||||||
queryKey: ['skate-shop', pubkey, identifier, getQueryKeySuffix()],
|
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 () => {
|
queryFn: async () => {
|
||||||
if (!pubkey || !identifier) return null;
|
if (!pubkey || !identifier) return null;
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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 { useAdminModeration, filterAdminHidden } from '@/hooks/useAdminModeration';
|
||||||
import { cacheSpots, getAllCachedSpots, isOffline } from '@/lib/offlineCache';
|
import { cacheSpots, getAllCachedSpots, isOffline } from '@/lib/offlineCache';
|
||||||
|
|
||||||
// Spots change infrequently — cache for 5 minutes, keep for 30 minutes
|
// 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) {
|
export function useSkateSpots(limit = 100) {
|
||||||
const { querySkateContent, getQueryKeySuffix } = useSkateRelays();
|
const { querySkateContent, getQueryKeySuffix } = useSkateRelays();
|
||||||
|
const hidden = useAdminModeration();
|
||||||
|
|
||||||
return useQuery({
|
return useQuery({
|
||||||
queryKey: ['skate-spots', limit, getQueryKeySuffix()],
|
queryKey: ['skate-spots', limit, getQueryKeySuffix()],
|
||||||
|
select: (spots: SkateSpot[]) => filterAdminHidden(spots, SKATE_SPOT_KIND, hidden),
|
||||||
queryFn: async () => {
|
queryFn: async () => {
|
||||||
// If offline, return cached spots
|
// If offline, return cached spots
|
||||||
if (isOffline()) {
|
if (isOffline()) {
|
||||||
|
|
@ -60,9 +63,14 @@ export function useSkateSpots(limit = 100) {
|
||||||
|
|
||||||
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();
|
||||||
|
|
||||||
return useQuery({
|
return useQuery({
|
||||||
queryKey: ['skate-spot', identifier, pubkey, getQueryKeySuffix()],
|
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 () => {
|
queryFn: async () => {
|
||||||
// If offline, try to get from cache first
|
// If offline, try to get from cache first
|
||||||
if (isOffline() && pubkey) {
|
if (isOffline() && pubkey) {
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
import { useQuery } from '@tanstack/react-query';
|
import { useQuery } from '@tanstack/react-query';
|
||||||
import { SKATE_TOUR_KIND, parseSkateTour, isFeaturedActive, type SkateTour } from '@/lib/types';
|
import { SKATE_TOUR_KIND, parseSkateTour, isFeaturedActive, type SkateTour } from '@/lib/types';
|
||||||
import { useSkateRelays } from '@/hooks/useSkateRelays';
|
import { useSkateRelays } from '@/hooks/useSkateRelays';
|
||||||
|
import { useAdminModeration, filterAdminHidden } from '@/hooks/useAdminModeration';
|
||||||
|
|
||||||
/** Sort featured (Pro) tours first, then newest. */
|
/** Sort featured (Pro) tours first, then newest. */
|
||||||
function sortTours(a: SkateTour, b: SkateTour): number {
|
function sortTours(a: SkateTour, b: SkateTour): number {
|
||||||
|
|
@ -16,9 +17,11 @@ const TIMEOUT = 8000;
|
||||||
|
|
||||||
export function useTours(limit = 100) {
|
export function useTours(limit = 100) {
|
||||||
const { querySkateContent, getQueryKeySuffix } = useSkateRelays();
|
const { querySkateContent, getQueryKeySuffix } = useSkateRelays();
|
||||||
|
const hidden = useAdminModeration();
|
||||||
|
|
||||||
return useQuery({
|
return useQuery({
|
||||||
queryKey: ['skate-tours', limit, getQueryKeySuffix()],
|
queryKey: ['skate-tours', limit, getQueryKeySuffix()],
|
||||||
|
select: (tours: SkateTour[]) => filterAdminHidden(tours, SKATE_TOUR_KIND, hidden),
|
||||||
queryFn: async () => {
|
queryFn: async () => {
|
||||||
const signal = AbortSignal.timeout(TIMEOUT);
|
const signal = AbortSignal.timeout(TIMEOUT);
|
||||||
|
|
||||||
|
|
@ -49,9 +52,14 @@ export function useTours(limit = 100) {
|
||||||
|
|
||||||
export function useTour(identifier: string, pubkey?: string) {
|
export function useTour(identifier: string, pubkey?: string) {
|
||||||
const { querySkateContent, getQueryKeySuffix } = useSkateRelays();
|
const { querySkateContent, getQueryKeySuffix } = useSkateRelays();
|
||||||
|
const hidden = useAdminModeration();
|
||||||
|
|
||||||
return useQuery({
|
return useQuery({
|
||||||
queryKey: ['skate-tour', identifier, pubkey, getQueryKeySuffix()],
|
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 () => {
|
queryFn: async () => {
|
||||||
const signal = AbortSignal.timeout(TIMEOUT);
|
const signal = AbortSignal.timeout(TIMEOUT);
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -11,6 +11,7 @@ import { Skeleton } from '@/components/ui/skeleton';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { ZapButton } from '@/components/ZapButton';
|
import { ZapButton } from '@/components/ZapButton';
|
||||||
import { CommentsSection } from '@/components/comments/CommentsSection';
|
import { CommentsSection } from '@/components/comments/CommentsSection';
|
||||||
|
import { DeleteEntityDialog } from '@/components/DeleteEntityDialog';
|
||||||
import { useSkateEvent } from '@/hooks/useSkateEvents';
|
import { useSkateEvent } from '@/hooks/useSkateEvents';
|
||||||
import { useAuthor } from '@/hooks/useAuthor';
|
import { useAuthor } from '@/hooks/useAuthor';
|
||||||
import { genUserName } from '@/lib/genUserName';
|
import { genUserName } from '@/lib/genUserName';
|
||||||
|
|
@ -194,14 +195,22 @@ export default function EventDetailPage() {
|
||||||
return (
|
return (
|
||||||
<Layout>
|
<Layout>
|
||||||
<div className="container px-4 py-8">
|
<div className="container px-4 py-8">
|
||||||
{/* Back link */}
|
{/* Back link + owner/admin controls */}
|
||||||
<Link
|
<div className="mb-6 flex items-center justify-between gap-2">
|
||||||
to="/events"
|
<Link
|
||||||
className="inline-flex items-center text-muted-foreground hover:text-foreground transition-colors mb-6"
|
to="/events"
|
||||||
>
|
className="inline-flex items-center text-muted-foreground hover:text-foreground transition-colors"
|
||||||
<ArrowLeft className="h-4 w-4 mr-2" />
|
>
|
||||||
Back to Events
|
<ArrowLeft className="h-4 w-4 mr-2" />
|
||||||
</Link>
|
Back to Events
|
||||||
|
</Link>
|
||||||
|
<DeleteEntityDialog
|
||||||
|
event={event.event}
|
||||||
|
entityLabel="event"
|
||||||
|
entityName={event.title}
|
||||||
|
redirectTo="/events"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div className="grid lg:grid-cols-3 gap-8">
|
<div className="grid lg:grid-cols-3 gap-8">
|
||||||
{/* Main Content */}
|
{/* Main Content */}
|
||||||
|
|
|
||||||
|
|
@ -17,7 +17,9 @@ import { sanitizeUrl, sanitizeTel, sanitizeEmail, urlDomain } from '@/lib/saniti
|
||||||
import { OfficialBadge } from '@/components/OfficialBadge';
|
import { OfficialBadge } from '@/components/OfficialBadge';
|
||||||
import { useShop } from '@/hooks/useShops';
|
import { useShop } from '@/hooks/useShops';
|
||||||
import { useAuthor } from '@/hooks/useAuthor';
|
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 { useToast } from '@/hooks/useToast';
|
||||||
import { genUserName } from '@/lib/genUserName';
|
import { genUserName } from '@/lib/genUserName';
|
||||||
import NotFound from './NotFound';
|
import NotFound from './NotFound';
|
||||||
|
|
@ -78,13 +80,17 @@ export default function ShopDetailPage() {
|
||||||
const { data: shop, isLoading, error } = useShop(identifier || '', pubkey);
|
const { data: shop, isLoading, error } = useShop(identifier || '', pubkey);
|
||||||
const author = useAuthor(shop?.pubkey);
|
const author = useAuthor(shop?.pubkey);
|
||||||
const { user } = useCurrentUser();
|
const { user } = useCurrentUser();
|
||||||
const { mutateAsync: publishEvent } = useNostrPublish();
|
const { mutateAsync: deleteEntity } = useDeleteEntity();
|
||||||
const { toast } = useToast();
|
const { toast } = useToast();
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
const [showDeleteDialog, setShowDeleteDialog] = useState(false);
|
const [showDeleteDialog, setShowDeleteDialog] = useState(false);
|
||||||
|
|
||||||
const isOwner = user && shop && user.pubkey === shop.pubkey;
|
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;
|
const hasNip05 = user?.metadata?.nip05;
|
||||||
|
|
||||||
useSeoMeta({
|
useSeoMeta({
|
||||||
|
|
@ -144,19 +150,19 @@ export default function ShopDetailPage() {
|
||||||
if (!shop || !user) return;
|
if (!shop || !user) return;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await publishEvent({
|
// Routes through useDeleteEntity: owner → NIP-09 delete; admin → NIP-09
|
||||||
kind: 5,
|
// delete + a moderation label so the shop is hidden across the app.
|
||||||
content: 'Deleted shop',
|
await deleteEntity({
|
||||||
tags: [
|
event: shop.event,
|
||||||
['e', shop.id],
|
reason: adminTakedown ? `SkateSpots admin removed shop "${shop.name}"` : 'Deleted shop',
|
||||||
['a', `${shop.event.kind}:${shop.pubkey}:${shop.identifier}`],
|
|
||||||
],
|
|
||||||
});
|
});
|
||||||
|
|
||||||
setShowDeleteDialog(false);
|
setShowDeleteDialog(false);
|
||||||
toast({
|
toast({
|
||||||
title: 'Shop deleted',
|
title: adminTakedown ? 'Shop removed' : 'Shop deleted',
|
||||||
description: 'Deletion request sent to relays. Most relays will remove this content.',
|
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'] });
|
queryClient.invalidateQueries({ queryKey: ['skate-shops'] });
|
||||||
|
|
@ -257,21 +263,25 @@ export default function ShopDetailPage() {
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
{isOwner && (
|
{isOwner && (
|
||||||
<>
|
<Link to={`/edit-shop/${shop.pubkey}/${shop.identifier}`}>
|
||||||
<Link to={`/edit-shop/${shop.pubkey}/${shop.identifier}`}>
|
<Button variant="outline" size="icon">
|
||||||
<Button variant="outline" size="icon">
|
<Pencil className="h-4 w-4" />
|
||||||
<Pencil className="h-4 w-4" />
|
|
||||||
</Button>
|
|
||||||
</Link>
|
|
||||||
<Button
|
|
||||||
variant="outline"
|
|
||||||
size="icon"
|
|
||||||
onClick={() => setShowDeleteDialog(true)}
|
|
||||||
className="hover:bg-destructive hover:text-destructive-foreground"
|
|
||||||
>
|
|
||||||
<Trash2 className="h-4 w-4" />
|
|
||||||
</Button>
|
</Button>
|
||||||
</>
|
</Link>
|
||||||
|
)}
|
||||||
|
{canDelete && (
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="icon"
|
||||||
|
onClick={() => setShowDeleteDialog(true)}
|
||||||
|
title={adminTakedown ? 'Remove (admin)' : 'Delete'}
|
||||||
|
className={cn(
|
||||||
|
'hover:bg-destructive hover:text-destructive-foreground',
|
||||||
|
adminTakedown && 'border-red-500/50 text-red-400',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<Trash2 className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
)}
|
)}
|
||||||
<Button variant="outline" size="icon" onClick={handleShare}>
|
<Button variant="outline" size="icon" onClick={handleShare}>
|
||||||
<Share2 className="h-4 w-4" />
|
<Share2 className="h-4 w-4" />
|
||||||
|
|
@ -478,10 +488,12 @@ export default function ShopDetailPage() {
|
||||||
<AlertDialogHeader>
|
<AlertDialogHeader>
|
||||||
<AlertDialogTitle className="text-white flex items-center gap-2">
|
<AlertDialogTitle className="text-white flex items-center gap-2">
|
||||||
<AlertTriangle className="h-5 w-5 text-red-500" />
|
<AlertTriangle className="h-5 w-5 text-red-500" />
|
||||||
Delete Shop?
|
{adminTakedown ? 'Remove this shop? (admin)' : 'Delete Shop?'}
|
||||||
</AlertDialogTitle>
|
</AlertDialogTitle>
|
||||||
<AlertDialogDescription className="text-gray-300">
|
<AlertDialogDescription className="text-gray-300">
|
||||||
This will permanently delete "{shop.name}" from all relays. This action cannot be undone.
|
{adminTakedown
|
||||||
|
? `As a SkateSpots admin you're taking down "${shop.name}". It will be hidden across the app for everyone and a deletion request will be sent to relays.`
|
||||||
|
: `This will permanently delete "${shop.name}" from all relays. This action cannot be undone.`}
|
||||||
</AlertDialogDescription>
|
</AlertDialogDescription>
|
||||||
</AlertDialogHeader>
|
</AlertDialogHeader>
|
||||||
<AlertDialogFooter>
|
<AlertDialogFooter>
|
||||||
|
|
@ -490,7 +502,7 @@ export default function ShopDetailPage() {
|
||||||
onClick={handleDelete}
|
onClick={handleDelete}
|
||||||
className="bg-red-500 hover:bg-red-600 text-white"
|
className="bg-red-500 hover:bg-red-600 text-white"
|
||||||
>
|
>
|
||||||
Delete Shop
|
{adminTakedown ? 'Remove Shop' : 'Delete Shop'}
|
||||||
</AlertDialogAction>
|
</AlertDialogAction>
|
||||||
</AlertDialogFooter>
|
</AlertDialogFooter>
|
||||||
</AlertDialogContent>
|
</AlertDialogContent>
|
||||||
|
|
|
||||||
|
|
@ -16,7 +16,9 @@ import { SpotChat } from '@/components/SpotChat';
|
||||||
import { CrewsForSpotCard } from '@/components/CrewsForSpotCard';
|
import { CrewsForSpotCard } from '@/components/CrewsForSpotCard';
|
||||||
import { useSkateSpot } from '@/hooks/useSkateSpots';
|
import { useSkateSpot } from '@/hooks/useSkateSpots';
|
||||||
import { useAuthor } from '@/hooks/useAuthor';
|
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 { useToast } from '@/hooks/useToast';
|
||||||
import { useSpotWeather } from '@/hooks/useSpotWeather';
|
import { useSpotWeather } from '@/hooks/useSpotWeather';
|
||||||
import { useMarketplaceAtSpot } from '@/hooks/useMarketplace';
|
import { useMarketplaceAtSpot } from '@/hooks/useMarketplace';
|
||||||
|
|
@ -121,7 +123,7 @@ export default function SpotDetailPage() {
|
||||||
const { data: spotServices, isLoading: isLoadingServices } = useSpotServices(pubkey, identifier);
|
const { data: spotServices, isLoading: isLoadingServices } = useSpotServices(pubkey, identifier);
|
||||||
const author = useAuthor(spot?.pubkey);
|
const author = useAuthor(spot?.pubkey);
|
||||||
const { user } = useCurrentUser();
|
const { user } = useCurrentUser();
|
||||||
const { mutateAsync: publishEvent } = useNostrPublish();
|
const { mutateAsync: deleteEntity } = useDeleteEntity();
|
||||||
const { toast } = useToast();
|
const { toast } = useToast();
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
|
|
@ -131,6 +133,10 @@ export default function SpotDetailPage() {
|
||||||
const [showStatusDialog, setShowStatusDialog] = useState(false);
|
const [showStatusDialog, setShowStatusDialog] = useState(false);
|
||||||
|
|
||||||
const isOwner = user && spot && user.pubkey === spot.pubkey;
|
const isOwner = user && spot && user.pubkey === spot.pubkey;
|
||||||
|
const isAdmin = isAdminPubkey(user?.pubkey);
|
||||||
|
// Owner can delete their own spot; a SkateSpots admin can take down any spot.
|
||||||
|
const canDelete = !!user && !!spot && (isOwner || isAdmin);
|
||||||
|
const adminTakedown = isAdmin && !isOwner;
|
||||||
const isShop = spot?.types.includes('shop');
|
const isShop = spot?.types.includes('shop');
|
||||||
const hasNip05 = user?.metadata?.nip05;
|
const hasNip05 = user?.metadata?.nip05;
|
||||||
|
|
||||||
|
|
@ -192,19 +198,19 @@ export default function SpotDetailPage() {
|
||||||
if (!spot || !user) return;
|
if (!spot || !user) return;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await publishEvent({
|
// Routes through useDeleteEntity: owner → NIP-09 delete; admin → NIP-09
|
||||||
kind: 5,
|
// delete + a moderation label so the spot is hidden across the app.
|
||||||
content: 'Deleted spot',
|
await deleteEntity({
|
||||||
tags: [
|
event: spot.event,
|
||||||
['e', spot.id],
|
reason: adminTakedown ? `SkateSpots admin removed spot "${spot.title}"` : 'Deleted spot',
|
||||||
['a', `${spot.event.kind}:${spot.pubkey}:${spot.identifier}`],
|
|
||||||
],
|
|
||||||
});
|
});
|
||||||
|
|
||||||
setShowDeleteDialog(false);
|
setShowDeleteDialog(false);
|
||||||
toast({
|
toast({
|
||||||
title: 'Spot deleted',
|
title: adminTakedown ? 'Spot removed' : 'Spot deleted',
|
||||||
description: 'Deletion request sent to relays. Most relays will remove this content.',
|
description: adminTakedown
|
||||||
|
? 'This spot 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-spots'] });
|
queryClient.invalidateQueries({ queryKey: ['skate-spots'] });
|
||||||
|
|
@ -310,21 +316,25 @@ export default function SpotDetailPage() {
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-2 flex-wrap">
|
<div className="flex items-center gap-2 flex-wrap">
|
||||||
{isOwner && (
|
{isOwner && (
|
||||||
<>
|
<Link to={`/edit-spot/${spot.pubkey}/${spot.identifier}`}>
|
||||||
<Link to={`/edit-spot/${spot.pubkey}/${spot.identifier}`}>
|
<Button variant="outline" size="icon">
|
||||||
<Button variant="outline" size="icon">
|
<Pencil className="h-4 w-4" />
|
||||||
<Pencil className="h-4 w-4" />
|
|
||||||
</Button>
|
|
||||||
</Link>
|
|
||||||
<Button
|
|
||||||
variant="outline"
|
|
||||||
size="icon"
|
|
||||||
onClick={() => setShowDeleteDialog(true)}
|
|
||||||
className="hover:bg-destructive hover:text-destructive-foreground"
|
|
||||||
>
|
|
||||||
<Trash2 className="h-4 w-4" />
|
|
||||||
</Button>
|
</Button>
|
||||||
</>
|
</Link>
|
||||||
|
)}
|
||||||
|
{canDelete && (
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="icon"
|
||||||
|
onClick={() => setShowDeleteDialog(true)}
|
||||||
|
title={adminTakedown ? 'Remove (admin)' : 'Delete'}
|
||||||
|
className={cn(
|
||||||
|
'hover:bg-destructive hover:text-destructive-foreground',
|
||||||
|
adminTakedown && 'border-red-500/50 text-red-400',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<Trash2 className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
)}
|
)}
|
||||||
{user && !isShop && (
|
{user && !isShop && (
|
||||||
<>
|
<>
|
||||||
|
|
@ -971,10 +981,12 @@ export default function SpotDetailPage() {
|
||||||
<AlertDialogHeader>
|
<AlertDialogHeader>
|
||||||
<AlertDialogTitle className="text-white flex items-center gap-2">
|
<AlertDialogTitle className="text-white flex items-center gap-2">
|
||||||
<AlertTriangle className="h-5 w-5 text-red-500" />
|
<AlertTriangle className="h-5 w-5 text-red-500" />
|
||||||
Delete Spot?
|
{adminTakedown ? 'Remove this spot? (admin)' : 'Delete Spot?'}
|
||||||
</AlertDialogTitle>
|
</AlertDialogTitle>
|
||||||
<AlertDialogDescription className="text-gray-300">
|
<AlertDialogDescription className="text-gray-300">
|
||||||
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.`}
|
||||||
</AlertDialogDescription>
|
</AlertDialogDescription>
|
||||||
</AlertDialogHeader>
|
</AlertDialogHeader>
|
||||||
<AlertDialogFooter>
|
<AlertDialogFooter>
|
||||||
|
|
@ -983,7 +995,7 @@ export default function SpotDetailPage() {
|
||||||
onClick={handleDelete}
|
onClick={handleDelete}
|
||||||
className="bg-red-500 hover:bg-red-600 text-white"
|
className="bg-red-500 hover:bg-red-600 text-white"
|
||||||
>
|
>
|
||||||
Delete Spot
|
{adminTakedown ? 'Remove Spot' : 'Delete Spot'}
|
||||||
</AlertDialogAction>
|
</AlertDialogAction>
|
||||||
</AlertDialogFooter>
|
</AlertDialogFooter>
|
||||||
</AlertDialogContent>
|
</AlertDialogContent>
|
||||||
|
|
|
||||||
|
|
@ -13,6 +13,7 @@ import { genUserName } from '@/lib/genUserName';
|
||||||
import { TOUR_SKILL_LEVELS, TOUR_SPOT_TYPES, SKATE_TOUR_KIND } from '@/lib/types';
|
import { TOUR_SKILL_LEVELS, TOUR_SPOT_TYPES, SKATE_TOUR_KIND } from '@/lib/types';
|
||||||
import { ZapButton } from '@/components/ZapButton';
|
import { ZapButton } from '@/components/ZapButton';
|
||||||
import { ProBadge } from '@/components/ProBadge';
|
import { ProBadge } from '@/components/ProBadge';
|
||||||
|
import { DeleteEntityDialog } from '@/components/DeleteEntityDialog';
|
||||||
import {
|
import {
|
||||||
ArrowLeft,
|
ArrowLeft,
|
||||||
Route,
|
Route,
|
||||||
|
|
@ -124,11 +125,19 @@ export default function TourDetailPage() {
|
||||||
return (
|
return (
|
||||||
<Layout>
|
<Layout>
|
||||||
<div className="container px-4 py-6 max-w-3xl">
|
<div className="container px-4 py-6 max-w-3xl">
|
||||||
{/* Back link */}
|
{/* Back link + owner/admin controls */}
|
||||||
<Link to="/map" className="inline-flex items-center text-gray-400 hover:text-white transition-colors mb-6">
|
<div className="mb-6 flex items-center justify-between gap-2">
|
||||||
<ArrowLeft className="h-4 w-4 mr-2" />
|
<Link to="/map" className="inline-flex items-center text-gray-400 hover:text-white transition-colors">
|
||||||
Back to Map
|
<ArrowLeft className="h-4 w-4 mr-2" />
|
||||||
</Link>
|
Back to Map
|
||||||
|
</Link>
|
||||||
|
<DeleteEntityDialog
|
||||||
|
event={tour.event}
|
||||||
|
entityLabel="tour"
|
||||||
|
entityName={tour.title}
|
||||||
|
redirectTo="/map"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* Main card */}
|
{/* Main card */}
|
||||||
<Card className="bg-black/90 border-2 border-cyan-500/30 overflow-hidden">
|
<Card className="bg-black/90 border-2 border-cyan-500/30 overflow-hidden">
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue