import { useState, useCallback, useMemo, useRef } from 'react'; import { Award, Upload, Loader2, Check, Copy, Users } from 'lucide-react'; import { nip19 } from 'nostr-tools'; import type { NostrEvent } from '@nostrify/nostrify'; import { useQueryClient } from '@tanstack/react-query'; import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription, } from '@/components/ui/dialog'; import { Button } from '@/components/ui/button'; import { Input } from '@/components/ui/input'; import { Textarea } from '@/components/ui/textarea'; import { Label } from '@/components/ui/label'; import { ScrollArea } from '@/components/ui/scroll-area'; import { AwardBadgeDialog } from '@/components/AwardBadgeDialog'; import { useCurrentUser } from '@/hooks/useCurrentUser'; import { useCreateBadge } from '@/hooks/useCreateBadge'; import { useAwardBadge } from '@/hooks/useAwardBadge'; import { useAcceptBadge } from '@/hooks/useAcceptBadge'; import { useUploadFile } from '@/hooks/useUploadFile'; import { useToast } from '@/hooks/useToast'; import { BADGE_DEFINITION_KIND } from '@/lib/badgeUtils'; /** Convert a badge name into a URL-safe slug for the d-tag identifier. */ function slugify(text: string): string { return text .toLowerCase() .trim() .replace(/[^\w\s-]/g, '') .replace(/[\s_]+/g, '-') .replace(/^-+|-+$/g, ''); } interface CreateBadgeDialogProps { open: boolean; onOpenChange: (open: boolean) => void; } export function CreateBadgeDialog({ open, onOpenChange }: CreateBadgeDialogProps) { const { user } = useCurrentUser(); const { toast } = useToast(); const queryClient = useQueryClient(); // Form state const [name, setName] = useState(''); const [identifier, setIdentifier] = useState(''); const [identifierTouched, setIdentifierTouched] = useState(false); const [description, setDescription] = useState(''); const [imageUrl, setImageUrl] = useState(''); const [imagePreview, setImagePreview] = useState(''); const fileInputRef = useRef(null); // Post-creation state const [createdBadge, setCreatedBadge] = useState(null); const [awardDialogOpen, setAwardDialogOpen] = useState(false); const [selfAwarded, setSelfAwarded] = useState(false); const [copied, setCopied] = useState(false); // Mutations const { mutateAsync: createBadge, isPending: isCreating } = useCreateBadge(); const { mutateAsync: awardBadge, isPending: isAwardingSelf } = useAwardBadge(); const { mutateAsync: acceptBadge } = useAcceptBadge(); const { mutateAsync: uploadFile, isPending: isUploading } = useUploadFile(); // Derived values const effectiveIdentifier = identifierTouched ? identifier : slugify(name); const badgeATag = useMemo(() => { if (!createdBadge) return ''; const dTag = createdBadge.tags.find(([n]) => n === 'd')?.[1] ?? ''; return `${BADGE_DEFINITION_KIND}:${createdBadge.pubkey}:${dTag}`; }, [createdBadge]); const badgeName = useMemo(() => { if (!createdBadge) return ''; return createdBadge.tags.find(([n]) => n === 'name')?.[1] ?? ''; }, [createdBadge]); const resetForm = useCallback(() => { setName(''); setIdentifier(''); setIdentifierTouched(false); setDescription(''); setImageUrl(''); setImagePreview(''); setCreatedBadge(null); setSelfAwarded(false); setCopied(false); }, []); const handleOpenChange = useCallback((nextOpen: boolean) => { if (!nextOpen) resetForm(); onOpenChange(nextOpen); }, [onOpenChange, resetForm]); // Handlers const handleNameChange = useCallback((value: string) => { setName(value); if (!identifierTouched) { setIdentifier(slugify(value)); } }, [identifierTouched]); const handleIdentifierChange = useCallback((value: string) => { setIdentifierTouched(true); setIdentifier(value); }, []); const handleFileSelect = useCallback(async (file: File) => { if (!file.type.startsWith('image/')) { toast({ title: 'Invalid file', description: 'Please select an image file.', variant: 'destructive' }); return; } const reader = new FileReader(); reader.onload = (e) => setImagePreview(e.target?.result as string); reader.readAsDataURL(file); try { const [[, url]] = await uploadFile(file); setImageUrl(url); toast({ title: 'Image uploaded' }); } catch { setImagePreview(''); toast({ title: 'Upload failed', description: 'Please try again.', variant: 'destructive' }); } }, [uploadFile, toast]); const handleDrop = useCallback((e: React.DragEvent) => { e.preventDefault(); const file = e.dataTransfer.files[0]; if (file) handleFileSelect(file); }, [handleFileSelect]); const handleCreate = useCallback(async () => { if (!name.trim() || !effectiveIdentifier.trim()) return; try { const event = await createBadge({ name: name.trim(), identifier: effectiveIdentifier.trim(), description: description.trim() || undefined, imageUrl: imageUrl || undefined, }); setCreatedBadge(event); queryClient.invalidateQueries({ queryKey: ['my-created-badges'] }); toast({ title: 'Badge created!' }); } catch { toast({ title: 'Failed to create badge', description: 'Please try again.', variant: 'destructive' }); } }, [name, effectiveIdentifier, description, imageUrl, createBadge, queryClient, toast]); const handleSelfAward = useCallback(async () => { if (!user || !createdBadge || !badgeATag) return; try { const awardEvent = await awardBadge({ aTag: badgeATag, recipientPubkeys: [user.pubkey], }); await acceptBadge({ aTag: badgeATag, awardEventId: awardEvent.id, }); setSelfAwarded(true); toast({ title: 'Badge awarded to yourself!' }); } catch { toast({ title: 'Failed to self-award', description: 'Please try again.', variant: 'destructive' }); } }, [user, createdBadge, badgeATag, awardBadge, acceptBadge, toast]); const handleCopyLink = useCallback(() => { if (!createdBadge) return; const dTag = createdBadge.tags.find(([n]) => n === 'd')?.[1] ?? ''; const naddr = nip19.naddrEncode({ kind: BADGE_DEFINITION_KIND, pubkey: createdBadge.pubkey, identifier: dTag, }); navigator.clipboard.writeText(`${window.location.origin}/${naddr}`); setCopied(true); toast({ title: 'Link copied to clipboard!' }); setTimeout(() => setCopied(false), 2000); }, [createdBadge, toast]); if (!user) return null; return ( <> {createdBadge ? ( /* ── Success state ── */

Badge Created!

"{badgeName}" is ready to be awarded.

{imageUrl && (
{badgeName}
)}
) : ( /* ── Creation form ── */ <> Create a Badge Design a NIP-58 badge to award to users.
{/* Image upload */}
fileInputRef.current?.click()} onDrop={handleDrop} onDragOver={(e) => e.preventDefault()} onKeyDown={(e) => { if (e.key === 'Enter' || e.key === ' ') fileInputRef.current?.click(); }} className="relative flex flex-col items-center justify-center w-full h-32 border-2 border-dashed border-border rounded-xl bg-secondary/5 hover:bg-secondary/10 transition-colors cursor-pointer overflow-hidden" > {imagePreview ? ( Badge preview ) : isUploading ? (
Uploading...
) : (
Drop an image or click to upload
)} { const file = e.target.files?.[0]; if (file) handleFileSelect(file); }} />
{/* Badge name */}
handleNameChange(e.target.value)} />
{/* Identifier / d-tag */}
handleIdentifierChange(e.target.value)} className="font-mono text-sm" />

URL-safe identifier. Auto-generated from the name.

{/* Description */}