import * as React from "react"; import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle, } from "@/components/ui/dialog"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; import { Collapsible, CollapsibleContent, CollapsibleTrigger, } from "@/components/ui/collapsible"; import { ChevronDown, WandSparkles, Plus, X, Clock, User, FileText, Search, Wifi, Tag, AtSign, } from "lucide-react"; import { KindSelector } from "./KindSelector"; import { ProfileSelector } from "./ProfileSelector"; import { KindBadge } from "./KindBadge"; import { UserName } from "./nostr/UserName"; import { reconstructCommand } from "@/lib/spell-conversion"; import { SpellDialog } from "./nostr/SpellDialog"; interface CreateSpellDialogProps { open: boolean; onOpenChange: (open: boolean) => void; } /** * CreateSpellDialog - A newbie-friendly UI for building Nostr REQ commands * Allows users to visually construct filters without knowing the CLI syntax */ export function CreateSpellDialog({ open, onOpenChange, }: CreateSpellDialogProps) { // Filter state const [kinds, setKinds] = React.useState([]); const [authors, setAuthors] = React.useState([]); const [mentions, setMentions] = React.useState([]); const [search, setSearch] = React.useState(""); const [hashtags, setHashtags] = React.useState([]); const [since, setSince] = React.useState(""); const [until, setUntil] = React.useState(""); const [relays, setRelays] = React.useState([]); const [closeOnEose, setCloseOnEose] = React.useState(false); const [limit, setLimit] = React.useState(""); const [genericTags, setGenericTags] = React.useState< Record >({}); const [activeTagLetter, setActiveTagLetter] = React.useState("e"); // Sub-dialog state const [showSaveDialog, setShowSaveDialog] = React.useState(false); // Reconstruct command string for preview and saving const generatedCommand = React.useMemo(() => { const filter: any = { kinds: kinds.length > 0 ? kinds : undefined, authors: authors.length > 0 ? authors : undefined, "#p": mentions.length > 0 ? mentions : undefined, "#t": hashtags.length > 0 ? hashtags : undefined, search: search || undefined, limit: limit !== "" ? limit : undefined, }; // Add generic tags for (const [letter, values] of Object.entries(genericTags)) { if (values.length > 0) { filter[`#${letter}`] = values; } } return reconstructCommand( filter, relays.length > 0 ? relays : undefined, since || undefined, until || undefined, closeOnEose, ); }, [ kinds, authors, mentions, search, hashtags, since, until, relays, closeOnEose, limit, genericTags, ]); const handleAddKind = (kind: number) => { if (!kinds.includes(kind)) setKinds([...kinds, kind]); }; const handleRemoveKind = (kind: number) => { setKinds(kinds.filter((k) => k !== kind)); }; const handleAddAuthor = (pubkey: string) => { if (!authors.includes(pubkey)) setAuthors([...authors, pubkey]); }; const handleRemoveAuthor = (pubkey: string) => { setAuthors(authors.filter((p) => p !== pubkey)); }; const handleAddMention = (pubkey: string) => { if (!mentions.includes(pubkey)) setMentions([...mentions, pubkey]); }; const handleRemoveMention = (pubkey: string) => { setMentions(mentions.filter((p) => p !== pubkey)); }; const handleAddHashtag = (tag: string) => { const clean = tag.replace(/^#/, "").trim(); if (clean && !hashtags.includes(clean)) setHashtags([...hashtags, clean]); }; const handleRemoveHashtag = (tag: string) => { setHashtags(hashtags.filter((t) => t !== tag)); }; const handleAddRelay = (url: string) => { if (url && !relays.includes(url)) setRelays([...relays, url]); }; const handleRemoveRelay = (url: string) => { setRelays(relays.filter((r) => r !== url)); }; const handleAddGenericTag = (letter: string, value: string) => { if (!letter || !value) return; const cleanLetter = letter.trim().slice(0, 1); if (!/[a-zA-Z]/.test(cleanLetter)) return; setGenericTags((prev) => { const existing = prev[cleanLetter] || []; if (existing.includes(value)) return prev; return { ...prev, [cleanLetter]: [...existing, value], }; }); }; const handleRemoveGenericTag = (letter: string, value: string) => { setGenericTags((prev) => { const existing = prev[letter] || []; const filtered = existing.filter((v) => v !== value); if (filtered.length === 0) { const { [letter]: _, ...rest } = prev; return rest; } return { ...prev, [letter]: filtered, }; }); }; const resetForm = () => { setKinds([]); setAuthors([]); setMentions([]); setSearch(""); setHashtags([]); setSince(""); setUntil(""); setRelays([]); setCloseOnEose(false); setLimit(""); setGenericTags({}); setActiveTagLetter("e"); }; return ( <> Create New Spell Build a custom view of Nostr events by selecting filters below.
{/* Kinds Section */} } defaultOpen={true} >
{kinds.map((k) => ( ))} {kinds.length === 0 && ( All event types (notes, profiles, etc.) )}
{/* Authors Section */} } >
{authors.map((p) => ( {p === "$me" || p === "$contacts" ? ( {p} ) : ( )} ))} {authors.length === 0 && ( Anyone on the network )}
{/* Mentions Section */} } >
{mentions.map((p) => ( {p === "$me" || p === "$contacts" ? ( {p} ) : ( )} ))}
{/* Content Section */} } >
setSearch(e.target.value)} />
{ if (e.key === "Enter") { handleAddHashtag(e.currentTarget.value); e.currentTarget.value = ""; } }} />
{hashtags.map((t) => ( # {t} ))}
{/* Generic Tags Section */} } >
setActiveTagLetter(e.target.value.trim().slice(0, 1)) } className="w-12 text-center font-mono font-bold" />
{activeTagLetter === "k" ? ( handleAddGenericTag("k", k.toString())} /> ) : activeTagLetter === "p" || activeTagLetter === "P" ? ( handleAddGenericTag(activeTagLetter, pk) } placeholder={`Add ${activeTagLetter} pubkey...`} /> ) : ( { if (e.key === "Enter") { handleAddGenericTag( activeTagLetter, e.currentTarget.value, ); e.currentTarget.value = ""; } }} /> )}
{Object.entries(genericTags).map(([letter, values]) => (
#{letter}
{values.map((val) => ( {letter === "k" ? ( ) : letter === "p" || letter === "P" ? ( val.startsWith("$") ? ( {val} ) : ( ) ) : ( {val} )} ))}
))}
{/* Time Section */} } >
setSince(e.target.value)} />
{["now", "1h", "24h", "7d", "30d"].map((t) => ( ))}
setUntil(e.target.value)} />
{["now", "1h", "24h", "7d", "30d"].map((t) => ( ))}
{/* Options Section */} } >
{ const val = e.target.value; setLimit(val === "" ? "" : parseInt(val)); }} placeholder="No limit" className="w-24" />

Don't listen for new events in real-time

setCloseOnEose(e.target.checked)} className="size-4 rounded border-gray-300 text-accent focus:ring-accent" />
{ if (e.key === "Enter") { handleAddRelay(e.currentTarget.value); e.currentTarget.value = ""; } }} />
{relays.map((r) => ( {r} ))}
{generatedCommand}
{/* Actual saving/publishing dialog */} {showSaveDialog && ( { setShowSaveDialog(false); onOpenChange(false); resetForm(); }} /> )} ); } function CollapsibleSection({ title, icon, children, defaultOpen = false, }: { title: string; icon: React.ReactNode; children: React.ReactNode; defaultOpen?: boolean; }) { const [isOpen, setIsOpen] = React.useState(defaultOpen); return (
{icon} {title}
{children}
); } function Badge({ children, variant = "default", className = "", }: { children: React.ReactNode; variant?: "default" | "secondary" | "outline"; className?: string; }) { const variants = { default: "bg-primary text-primary-foreground", secondary: "bg-secondary text-secondary-foreground", outline: "border border-input bg-background", }; return (
{children}
); }