import { useState, useMemo } from "react"; import { Search, WandSparkles, Trash2, Send, Cloud, Lock, Loader2, RefreshCw, Archive, WandSparkles as Wand, BookUp, } from "lucide-react"; import { useLiveQuery } from "dexie-react-hooks"; import db from "@/services/db"; import { Input } from "./ui/input"; import { Button } from "./ui/button"; import { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle, } from "./ui/card"; import { Badge } from "./ui/badge"; import { toast } from "sonner"; import { deleteSpell } from "@/services/spell-storage"; import type { LocalSpell } from "@/services/db"; import { ExecutableCommand } from "./ManPage"; import { PublishSpellAction } from "@/actions/publish-spell"; import { DeleteEventAction } from "@/actions/delete-event"; import { useGrimoire } from "@/core/state"; import { cn } from "@/lib/utils"; import { KindBadge } from "@/components/KindBadge"; import { parseReqCommand } from "@/lib/req-parser"; import { CreateSpellDialog } from "./CreateSpellDialog"; import { useReqTimeline } from "@/hooks/useReqTimeline"; import { decodeSpell } from "@/lib/spell-conversion"; import type { SpellEvent } from "@/types/spell"; interface SpellCardProps { spell: LocalSpell; onDelete: (spell: LocalSpell) => Promise; onPublish: (spell: LocalSpell) => Promise; } function SpellCard({ spell, onDelete, onPublish }: SpellCardProps) { const { addWindow } = useGrimoire(); const [isPublishing, setIsPublishing] = useState(false); const [isDeleting, setIsDeleting] = useState(false); const displayName = spell.name || spell.alias || "Untitled Spell"; const kinds = useMemo(() => { try { const commandWithoutReq = spell.command.replace(/^\s*req\s+/, ""); const tokens = commandWithoutReq.split(/\s+/); const parsed = parseReqCommand(tokens); return parsed.filter.kinds || []; } catch { return []; } }, [spell.command]); const handlePublish = async () => { setIsPublishing(true); try { await onPublish(spell); } finally { setIsPublishing(false); } }; const handleDelete = async () => { setIsDeleting(true); try { await onDelete(spell); } finally { setIsDeleting(false); } }; const handleOpenEvent = () => { const id = spell.eventId || (spell.event?.id as string); if (id && id.length === 64) { addWindow("open", { pointer: { id } }, `open ${id}`); } }; return (
{displayName}
{spell.deletedAt ? ( ) : spell.isPublished ? ( ) : ( )}
{spell.description && ( {spell.description} )}
{spell.command}
{kinds.map((kind) => ( ))} {spell.alias && (
Alias: {spell.alias}
)}
{!spell.deletedAt && ( )}
); } /** * SpellsViewer - Browse and manage saved spells * Shows both local and published spells with search/filter capabilities */ export function SpellsViewer() { const { state } = useGrimoire(); const [searchQuery, setSearchQuery] = useState(""); const [filterType, setFilterType] = useState<"all" | "local" | "published">( "all", ); const [isCreateOpen, setIsCreateOpen] = useState(false); // Load spells from storage with live query const localSpells = useLiveQuery(() => db.spells.orderBy("createdAt").reverse().toArray(), ); // Fetch spells from Nostr if logged in const { events: networkEvents, loading: networkLoading } = useReqTimeline( state.activeAccount ? `user-spells-${state.activeAccount.pubkey}` : "none", state.activeAccount ? { kinds: [777], authors: [state.activeAccount.pubkey] } : [], state.activeAccount?.relays?.map((r) => r.url) || [], { stream: true }, ); const loading = localSpells === undefined; // Filter and sort spells const { filteredSpells, totalCount } = useMemo(() => { // Start with local spells const allSpellsMap = new Map(); for (const s of localSpells || []) { allSpellsMap.set(s.eventId || s.id, s); } // Merge in network spells for (const event of networkEvents) { if (allSpellsMap.has(event.id)) continue; try { const decoded = decodeSpell(event as SpellEvent); const spell: LocalSpell = { id: event.id, name: decoded.name, command: decoded.command, description: decoded.description, createdAt: event.created_at * 1000, isPublished: true, eventId: event.id, event: event as SpellEvent, }; allSpellsMap.set(event.id, spell); } catch (e) { console.warn("Failed to decode network spell", event.id, e); } } const allMerged = Array.from(allSpellsMap.values()); const total = allMerged.length; let filtered = [...allMerged]; // Filter by type if (filterType === "local") { filtered = filtered.filter((s) => !s.isPublished || !!s.deletedAt); } else if (filterType === "published") { filtered = filtered.filter((s) => s.isPublished && !s.deletedAt); } // Filter by search query if (searchQuery.trim()) { const query = searchQuery.toLowerCase(); filtered = filtered.filter( (s) => s.name?.toLowerCase().includes(query) || s.alias?.toLowerCase().includes(query) || s.description?.toLowerCase().includes(query) || s.command.toLowerCase().includes(query), ); } // Sort: non-deleted first, then by createdAt descending filtered.sort((a, b) => { if (!!a.deletedAt !== !!b.deletedAt) { return a.deletedAt ? 1 : -1; } return b.createdAt - a.createdAt; }); return { filteredSpells: filtered, totalCount: total }; }, [localSpells, networkEvents, searchQuery, filterType]); // Handle deleting a spell const handleDeleteSpell = async (spell: LocalSpell) => { const isPublic = spell.isPublished && spell.eventId; const confirmMsg = isPublic ? `Are you sure you want to delete "${spell.name || spell.alias || "this spell"}"? This will also send a deletion request to Nostr relays.` : `Are you sure you want to delete "${spell.name || spell.alias || "this spell"}"?`; if (!confirm(confirmMsg)) { return; } try { // 1. If published, send Nostr Kind 5 if (isPublic && spell.event) { toast.promise( new DeleteEventAction().execute( { event: spell.event }, "Deleted by user in Grimoire", ), { loading: "Sending Nostr deletion request...", success: "Deletion request broadcasted", error: "Failed to broadcast deletion request", }, ); } // 2. Mark as deleted in local DB await deleteSpell(spell.id); toast.success(`"${spell.name || spell.alias || "spell"}" archived`); } catch (error) { console.error("Failed to delete spell:", error); toast.error("Failed to delete spell"); } }; const handlePublishSpell = async (spell: LocalSpell) => { try { const action = new PublishSpellAction(); const writeRelays = state.activeAccount?.relays?.filter((r) => r.write).map((r) => r.url) || []; await action.execute(spell, writeRelays); toast.success( spell.isPublished ? `Rebroadcasted "${spell.name || spell.alias || "spell"}"` : `Published "${spell.name || spell.alias || "spell"}"`, ); } catch (error) { console.error("Failed to publish spell:", error); toast.error( error instanceof Error ? error.message : "Failed to publish spell", ); throw error; // Re-throw to let the card know it failed } }; return (
{/* Header */}

Spells

{filteredSpells.length}/{totalCount} {networkLoading && ( )}
{/* Search and filters */}
setSearchQuery(e.target.value)} className="pl-9" />
{/* Type filter buttons */}
{!loading && searchQuery === "" && filterType !== "local" && (
Enhance your grimoire

Browse spells published by your contacts.

req -k 777 -a $contacts
)} {/* Spell list */}
{loading ? (

Loading spells...

) : filteredSpells.length === 0 ? (

No spells found

{searchQuery ? "Try a different search query" : "Create your first spell from any REQ window"}

Open a REQ window and click the "Save as Spell" button to create a spell

) : (
{filteredSpells.map((spell) => ( ))}
)}
); }