From a6a377f4681a47c6a7cbf801490960b403dcacbf Mon Sep 17 00:00:00 2001 From: "shakespeare.diy" Date: Fri, 24 Jul 2026 20:02:33 +0700 Subject: [PATCH] Add delete/dissolve for crews and Lab profiles via NIP-09 (v1.0.72) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - New useDeleteEntity hook: publishes a kind:5 deletion request referencing the target addressable event by `a` coord, `e` id, and `k` kind. Owner-only. - New reusable DeleteEntityDialog: owner-gated trigger + confirmation with a clear "deletion is best-effort on decentralized relays" warning, redirect, and query invalidation so the item drops out of lists. - Wired "Disband" into CrewDetailPage (founder only, redirects to /crews). - Wired a "Manage → Delete Profile" card into CoachDetailPage for all Lab profile types (coach/filmer/editor/photographer/builder), redirects to /lab. Co-authored-by: shakespeare.diy --- package.json | 2 +- src/components/DeleteEntityDialog.tsx | 145 ++++++++++++++++++++++++++ src/hooks/useDeleteEntity.ts | 64 ++++++++++++ src/pages/CoachDetailPage.tsx | 27 +++++ src/pages/CrewDetailPage.tsx | 20 +++- 5 files changed, 252 insertions(+), 6 deletions(-) create mode 100644 src/components/DeleteEntityDialog.tsx create mode 100644 src/hooks/useDeleteEntity.ts diff --git a/package.json b/package.json index fb23ffe..62c5d54 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "skate-spots", "private": true, - "version": "1.0.71", + "version": "1.0.72", "type": "module", "homepage": "https://skate-spots.com", "scripts": { diff --git a/src/components/DeleteEntityDialog.tsx b/src/components/DeleteEntityDialog.tsx new file mode 100644 index 0000000..1e7a581 --- /dev/null +++ b/src/components/DeleteEntityDialog.tsx @@ -0,0 +1,145 @@ +import { useState, type ReactNode } from 'react'; +import { useNavigate } from 'react-router-dom'; +import { Trash2, Loader2, AlertTriangle } from 'lucide-react'; +import { Button } from '@/components/ui/button'; +import { + AlertDialog, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, +} from '@/components/ui/alert-dialog'; +import { useDeleteEntity } from '@/hooks/useDeleteEntity'; +import { useCurrentUser } from '@/hooks/useCurrentUser'; +import { useToast } from '@/hooks/useToast'; +import { cn } from '@/lib/utils'; +import type { NostrEvent } from '@nostrify/nostrify'; + +interface DeleteEntityDialogProps { + /** The addressable event to delete (crew, Lab profile, spot, …). */ + event: NostrEvent; + /** Human label for the thing being deleted, e.g. "crew" or "coach profile". */ + entityLabel: string; + /** The name/title shown in the confirmation copy. */ + entityName: string; + /** Where to send the user after a successful delete (e.g. "/crews"). */ + redirectTo?: string; + /** Verb shown on the trigger button (default "Delete"). */ + triggerText?: string; + /** Optional custom trigger. If omitted a default outline button is rendered. */ + trigger?: ReactNode; + /** Extra classes for the default trigger button. */ + className?: string; +} + +/** + * Reusable "delete / dissolve" control for any addressable event the current + * user owns. Publishes a NIP-09 deletion request (see useDeleteEntity) after an + * explicit confirmation, and is only rendered for the owner. + */ +export function DeleteEntityDialog({ + event, + entityLabel, + entityName, + redirectTo, + triggerText = 'Delete', + trigger, + className, +}: DeleteEntityDialogProps) { + const { user } = useCurrentUser(); + const { mutateAsync: deleteEntity, isPending } = useDeleteEntity(); + const { toast } = useToast(); + const navigate = useNavigate(); + const [open, setOpen] = useState(false); + + // Only the owner can delete their own event. + if (!user || user.pubkey !== event.pubkey) return null; + + const handleDelete = async () => { + try { + await deleteEntity({ event, reason: `Deleted ${entityLabel} "${entityName}"` }); + toast({ + title: 'Deletion requested', + description: `Your ${entityLabel} has been removed. Relays that honor deletions will stop serving it.`, + }); + setOpen(false); + if (redirectTo) navigate(redirectTo); + } catch (error) { + toast({ + title: 'Delete failed', + description: error instanceof Error ? error.message : 'Something went wrong', + variant: 'destructive', + }); + } + }; + + return ( + <> + {trigger ? ( + setOpen(true)}>{trigger} + ) : ( + + )} + + + + + + + Delete "{entityName}"? + + +
+

+ 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. +

+
+
+
+
+ + + + +
+
+ + ); +} diff --git a/src/hooks/useDeleteEntity.ts b/src/hooks/useDeleteEntity.ts new file mode 100644 index 0000000..2a41cec --- /dev/null +++ b/src/hooks/useDeleteEntity.ts @@ -0,0 +1,64 @@ +import { useMutation, useQueryClient } from '@tanstack/react-query'; +import { useNostrPublish } from '@/hooks/useNostrPublish'; +import { useCurrentUser } from '@/hooks/useCurrentUser'; +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. + * + * 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. + * + * 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. + */ +export function useDeleteEntity() { + const { mutateAsync: publishEvent } = useNostrPublish(); + const { user } = useCurrentUser(); + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: async ({ + event, + reason, + }: { + /** The full addressable event to delete. */ + event: NostrEvent; + /** Optional human-readable reason, stored in the deletion request content. */ + reason?: string; + }) => { + if (!user) throw new Error('You must be logged in to delete.'); + if (user.pubkey !== event.pubkey) { + throw new Error('You can only delete your own listing.'); + } + + const dTag = event.tags.find(([name]) => name === 'd')?.[1] ?? ''; + const coord = `${event.kind}:${event.pubkey}:${dTag}`; + + // 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[][] = [ + ['a', coord], + ['e', event.id], + ['k', String(event.kind)], + ]; + + return publishEvent({ + kind: 5, + content: reason?.trim() || '', + tags, + }); + }, + onSuccess: () => { + // Nudge every list/detail query to refetch so the deleted entity drops out. + queryClient.invalidateQueries(); + }, + }); +} diff --git a/src/pages/CoachDetailPage.tsx b/src/pages/CoachDetailPage.tsx index 0e13282..ff93d2c 100644 --- a/src/pages/CoachDetailPage.tsx +++ b/src/pages/CoachDetailPage.tsx @@ -14,6 +14,8 @@ import { LinkedCrewCard } from '@/components/CrossLinkCards'; import { TrustBadge } from '@/components/TrustBadge'; import { useLabProfile } from '@/hooks/useLabProfiles'; import { useAuthor } from '@/hooks/useAuthor'; +import { useCurrentUser } from '@/hooks/useCurrentUser'; +import { DeleteEntityDialog } from '@/components/DeleteEntityDialog'; import { genUserName } from '@/lib/genUserName'; import { COACH_SKILL_LABELS, COACH_SKILL_DESCRIPTIONS, FILMING_SERVICE_LABELS, FILMING_SERVICE_DESCRIPTIONS, FILMING_SKILL_LABELS, FILMING_SKILL_DESCRIPTIONS, EDITOR_SERVICE_LABELS, EDITOR_SERVICE_DESCRIPTIONS, EDITOR_SKILL_LABELS, EDITOR_SKILL_DESCRIPTIONS, PHOTO_SERVICE_LABELS, PHOTO_SERVICE_DESCRIPTIONS, PHOTO_CAMERA_LABELS, PHOTO_LENS_LABELS, PHOTO_LIGHTING_LABELS, PHOTO_SUPPORT_LABELS, PHOTO_STYLE_LABELS, BUILDER_SERVICE_LABELS, BUILDER_SERVICE_DESCRIPTIONS, BUILDER_SKILL_LABELS, BUILDER_SCALE_LABELS } from '@/lib/types'; import { sanitizeUrl, sanitizeTel, sanitizeEmail, urlDomain } from '@/lib/sanitizeUrl'; @@ -72,6 +74,7 @@ export default function CoachDetailPage() { const { pubkey, identifier } = useParams<{ pubkey: string; identifier: string }>(); const { data: profile, isLoading, error } = useLabProfile(identifier || '', pubkey); const author = useAuthor(profile?.pubkey); + const { user } = useCurrentUser(); const navigate = useNavigate(); const isCoach = profile?.profileType === 'coach'; @@ -383,6 +386,30 @@ export default function CoachDetailPage() { + {/* Owner controls — delete your own Lab profile */} + {user && user.pubkey === profile.pubkey && ( + + + + Manage + + + +

+ Remove your {isCoach ? 'coach' : isFilmer ? 'filmer' : isEditor ? 'video editor' : isPhotographer ? 'photographer' : 'builder'} profile from The Lab. +

+ +
+
+ )} + {/* Members Card (collectives only) */} {isCollective && ( )} {isFounder && ( - - - +
+ + + + +
)} {isAdmin && !isFounder && (