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",
|
"name": "skate-spots",
|
||||||
"private": true,
|
"private": true,
|
||||||
"version": "1.0.71",
|
"version": "1.0.72",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"homepage": "https://skate-spots.com",
|
"homepage": "https://skate-spots.com",
|
||||||
"scripts": {
|
"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 { TrustBadge } from '@/components/TrustBadge';
|
||||||
import { useLabProfile } from '@/hooks/useLabProfiles';
|
import { useLabProfile } from '@/hooks/useLabProfiles';
|
||||||
import { useAuthor } from '@/hooks/useAuthor';
|
import { useAuthor } from '@/hooks/useAuthor';
|
||||||
|
import { useCurrentUser } from '@/hooks/useCurrentUser';
|
||||||
|
import { DeleteEntityDialog } from '@/components/DeleteEntityDialog';
|
||||||
import { genUserName } from '@/lib/genUserName';
|
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 { 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';
|
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 { pubkey, identifier } = useParams<{ pubkey: string; identifier: string }>();
|
||||||
const { data: profile, isLoading, error } = useLabProfile(identifier || '', pubkey);
|
const { data: profile, isLoading, error } = useLabProfile(identifier || '', pubkey);
|
||||||
const author = useAuthor(profile?.pubkey);
|
const author = useAuthor(profile?.pubkey);
|
||||||
|
const { user } = useCurrentUser();
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
|
|
||||||
const isCoach = profile?.profileType === 'coach';
|
const isCoach = profile?.profileType === 'coach';
|
||||||
|
|
@ -383,6 +386,30 @@ export default function CoachDetailPage() {
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</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) */}
|
{/* Members Card (collectives only) */}
|
||||||
{isCollective && (
|
{isCollective && (
|
||||||
<CollectiveMembers
|
<CollectiveMembers
|
||||||
|
|
|
||||||
|
|
@ -32,6 +32,7 @@ import { useCollectiveMembers } from '@/hooks/useCollectiveMembers';
|
||||||
import { useCrewRole } from '@/hooks/useCrewRole';
|
import { useCrewRole } from '@/hooks/useCrewRole';
|
||||||
import { useCrewModeration, useHideCrewPost } from '@/hooks/useCrewModeration';
|
import { useCrewModeration, useHideCrewPost } from '@/hooks/useCrewModeration';
|
||||||
import { CrewInviteDialog } from '@/components/CrewInviteDialog';
|
import { CrewInviteDialog } from '@/components/CrewInviteDialog';
|
||||||
|
import { DeleteEntityDialog } from '@/components/DeleteEntityDialog';
|
||||||
import { useToast } from '@/hooks/useToast';
|
import { useToast } from '@/hooks/useToast';
|
||||||
import { NoteContent } from '@/components/NoteContent';
|
import { NoteContent } from '@/components/NoteContent';
|
||||||
import { useCrew } from '@/hooks/useCrews';
|
import { useCrew } from '@/hooks/useCrews';
|
||||||
|
|
@ -265,11 +266,20 @@ export default function CrewDetailPage() {
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
{isFounder && (
|
{isFounder && (
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
<Link to={`/edit-crew/${crew.pubkey}/${crew.identifier}`}>
|
<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">
|
<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
|
<Edit className="h-3.5 w-3.5" /> Edit
|
||||||
</Button>
|
</Button>
|
||||||
</Link>
|
</Link>
|
||||||
|
<DeleteEntityDialog
|
||||||
|
event={crew.event}
|
||||||
|
entityLabel="crew"
|
||||||
|
entityName={crew.name}
|
||||||
|
redirectTo="/crews"
|
||||||
|
triggerText="Disband"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
{isAdmin && !isFounder && (
|
{isAdmin && !isFounder && (
|
||||||
<Badge className="gap-1 bg-teal-500/20 text-teal-300 border-0">
|
<Badge className="gap-1 bg-teal-500/20 text-teal-300 border-0">
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue