Add delete/dissolve for crews and Lab profiles via NIP-09 (v1.0.72)

- 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 <assistant@shakespeare.diy>
This commit is contained in:
shakespeare.diy 2026-07-24 20:02:33 +07:00
parent def67d4df2
commit a6a377f468
5 changed files with 252 additions and 6 deletions

View file

@ -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": {

View file

@ -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 ? (
<span onClick={() => setOpen(true)}>{trigger}</span>
) : (
<Button
type="button"
variant="outline"
size="sm"
onClick={() => setOpen(true)}
className={cn(
'gap-1.5 border-red-500/40 text-red-400 hover:bg-red-500/10 hover:text-red-300',
className,
)}
>
<Trash2 className="h-3.5 w-3.5" /> {triggerText}
</Button>
)}
<AlertDialog open={open} onOpenChange={setOpen}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle className="flex items-center gap-2">
<Trash2 className="h-5 w-5 text-red-500" />
Delete "{entityName}"?
</AlertDialogTitle>
<AlertDialogDescription asChild>
<div className="space-y-3">
<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">
<AlertTriangle className="mt-0.5 h-4 w-4 flex-shrink-0 text-yellow-500" />
<p className="text-yellow-400/90">
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.
</p>
</div>
</div>
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<Button variant="outline" onClick={() => setOpen(false)} disabled={isPending}>
Cancel
</Button>
<Button
onClick={handleDelete}
disabled={isPending}
className="bg-red-500 text-white hover:bg-red-600"
>
{isPending ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
Deleting…
</>
) : (
<>
<Trash2 className="mr-2 h-4 w-4" />
{triggerText}
</>
)}
</Button>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</>
);
}

View file

@ -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();
},
});
}

View file

@ -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() {
</CardContent>
</Card>
{/* Owner controls — delete your own Lab profile */}
{user && user.pubkey === profile.pubkey && (
<Card className="bg-black/90 border-2 border-red-500/30">
<CardHeader>
<CardTitle className="text-lg text-red-400 uppercase font-black">
Manage
</CardTitle>
</CardHeader>
<CardContent className="space-y-3">
<p className="text-sm text-gray-400">
Remove your {isCoach ? 'coach' : isFilmer ? 'filmer' : isEditor ? 'video editor' : isPhotographer ? 'photographer' : 'builder'} profile from The Lab.
</p>
<DeleteEntityDialog
event={profile.event}
entityLabel={`${isCoach ? 'coach' : isFilmer ? 'filmer' : isEditor ? 'editor' : isPhotographer ? 'photographer' : 'builder'} profile`}
entityName={profile.title}
redirectTo="/lab"
triggerText="Delete Profile"
className="w-full justify-center"
/>
</CardContent>
</Card>
)}
{/* Members Card (collectives only) */}
{isCollective && (
<CollectiveMembers

View file

@ -32,6 +32,7 @@ import { useCollectiveMembers } from '@/hooks/useCollectiveMembers';
import { useCrewRole } from '@/hooks/useCrewRole';
import { useCrewModeration, useHideCrewPost } from '@/hooks/useCrewModeration';
import { CrewInviteDialog } from '@/components/CrewInviteDialog';
import { DeleteEntityDialog } from '@/components/DeleteEntityDialog';
import { useToast } from '@/hooks/useToast';
import { NoteContent } from '@/components/NoteContent';
import { useCrew } from '@/hooks/useCrews';
@ -265,11 +266,20 @@ export default function CrewDetailPage() {
/>
)}
{isFounder && (
<Link to={`/edit-crew/${crew.pubkey}/${crew.identifier}`}>
<Button variant="outline" size="sm" className="gap-1.5 border-teal-500/40 text-teal-400 hover:bg-teal-500/10">
<Edit className="h-3.5 w-3.5" /> Edit
</Button>
</Link>
<div className="flex items-center gap-2">
<Link to={`/edit-crew/${crew.pubkey}/${crew.identifier}`}>
<Button variant="outline" size="sm" className="gap-1.5 border-teal-500/40 text-teal-400 hover:bg-teal-500/10">
<Edit className="h-3.5 w-3.5" /> Edit
</Button>
</Link>
<DeleteEntityDialog
event={crew.event}
entityLabel="crew"
entityName={crew.name}
redirectTo="/crews"
triggerText="Disband"
/>
</div>
)}
{isAdmin && !isFounder && (
<Badge className="gap-1 bg-teal-500/20 text-teal-300 border-0">