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:
parent
def67d4df2
commit
a6a377f468
5 changed files with 252 additions and 6 deletions
|
|
@ -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": {
|
||||
|
|
|
|||
145
src/components/DeleteEntityDialog.tsx
Normal file
145
src/components/DeleteEntityDialog.tsx
Normal 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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
64
src/hooks/useDeleteEntity.ts
Normal file
64
src/hooks/useDeleteEntity.ts
Normal 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();
|
||||
},
|
||||
});
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
Loading…
Reference in a new issue