From 60c67764f796b6d75ce26b1c2c83e27817ed1821 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alejandro=20G=C3=B3mez?= Date: Sat, 20 Dec 2025 19:59:40 +0100 Subject: [PATCH] feat: show spells in SpellbookDropdown and remove icon animation --- src/components/SpellbookDropdown.tsx | 196 ++++++++++++++++++--------- 1 file changed, 131 insertions(+), 65 deletions(-) diff --git a/src/components/SpellbookDropdown.tsx b/src/components/SpellbookDropdown.tsx index 0980f62..4637e40 100644 --- a/src/components/SpellbookDropdown.tsx +++ b/src/components/SpellbookDropdown.tsx @@ -1,12 +1,13 @@ import { useMemo } from "react"; -import { BookHeart, ChevronDown, Layout, Loader2 } from "lucide-react"; +import { BookHeart, ChevronDown, Layout, Loader2, WandSparkles } from "lucide-react"; import { useLiveQuery } from "dexie-react-hooks"; import db from "@/services/db"; import { useGrimoire } from "@/core/state"; import { useReqTimeline } from "@/hooks/useReqTimeline"; import { parseSpellbook } from "@/lib/spellbook-manager"; -import type { SpellbookEvent, ParsedSpellbook } from "@/types/spell"; -import { SPELLBOOK_KIND } from "@/constants/kinds"; +import { decodeSpell } from "@/lib/spell-conversion"; +import type { SpellbookEvent, ParsedSpellbook, SpellEvent, ParsedSpell } from "@/types/spell"; +import { SPELLBOOK_KIND, SPELL_KIND } from "@/constants/kinds"; import { Button } from "./ui/button"; import { DropdownMenu, @@ -17,76 +18,114 @@ import { DropdownMenuTrigger, } from "./ui/dropdown-menu"; import { toast } from "sonner"; -import { cn } from "@/lib/utils"; +import { manPages } from "@/types/man"; export function SpellbookDropdown() { const { state, loadSpellbook, addWindow } = useGrimoire(); const activeAccount = state.activeAccount; - // Load local spellbooks from Dexie + // 1. Load Local Data const localSpellbooks = useLiveQuery(() => db.spellbooks.toArray().then(books => books.filter(b => !b.deletedAt)), ); + const localSpells = useLiveQuery(() => + db.spells.toArray().then(spells => spells.filter(s => !s.deletedAt)), + ); - // Fetch from Nostr + // 2. Fetch Network Data const { events: networkEvents, loading: networkLoading } = useReqTimeline( - activeAccount ? `header-spellbooks-${activeAccount.pubkey}` : "none", + activeAccount ? `header-resources-${activeAccount.pubkey}` : "none", activeAccount - ? { kinds: [SPELLBOOK_KIND], authors: [activeAccount.pubkey] } + ? { kinds: [SPELLBOOK_KIND, SPELL_KIND], authors: [activeAccount.pubkey] } : [], activeAccount?.relays?.map((r) => r.url) || [], { stream: true }, ); - // Merge and deduplicate logic similar to SpellbooksViewer + // 3. Process Spellbooks const spellbooks = useMemo(() => { if (!activeAccount) return []; - const allMap = new Map(); - // Process local ones first for (const s of localSpellbooks || []) { - const parsed: ParsedSpellbook = { + allMap.set(s.slug, { slug: s.slug, title: s.title, description: s.description, content: s.content, referencedSpells: [], event: s.event as SpellbookEvent, - }; - allMap.set(s.slug, parsed); + }); } - // Merge network ones - for (const event of networkEvents) { + for (const event of networkEvents.filter(e => e.kind === SPELLBOOK_KIND)) { const slug = event.tags.find((t) => t[0] === "d")?.[1] || ""; if (!slug) continue; - const existing = allMap.get(slug); - if (existing && event.created_at * 1000 <= (existing.event?.created_at || 0) * 1000) { - continue; - } - + if (existing && event.created_at * 1000 <= (existing.event?.created_at || 0) * 1000) continue; try { - const parsed = parseSpellbook(event as SpellbookEvent); - allMap.set(slug, parsed); - } catch (e) { - // ignore - } + allMap.set(slug, parseSpellbook(event as SpellbookEvent)); + } catch (e) {} + } + + return Array.from(allMap.values()).sort((a, b) => a.title.localeCompare(b.title)); + }, [localSpellbooks, networkEvents, activeAccount]); + + // 4. Process Spells + const spells = useMemo(() => { + if (!activeAccount) return []; + const allMap = new Map(); + + for (const s of localSpells || []) { + allMap.set(s.id, { + name: s.name || s.alias, + command: s.command, + description: s.description, + event: s.event as SpellEvent, + filter: {}, // Not needed for dropdown + topics: [], + closeOnEose: false + }); + } + + for (const event of networkEvents.filter(e => e.kind === SPELL_KIND)) { + if (allMap.has(event.id)) continue; + try { + allMap.set(event.id, decodeSpell(event as SpellEvent)); + } catch (e) {} } return Array.from(allMap.values()).sort((a, b) => - a.title.localeCompare(b.title) + (a.name || "Untitled").localeCompare(b.name || "Untitled") ); - }, [localSpellbooks, networkEvents, activeAccount]); + }, [localSpells, networkEvents, activeAccount]); - if (!activeAccount || (spellbooks.length === 0 && !networkLoading)) { + if (!activeAccount || (spellbooks.length === 0 && spells.length === 0 && !networkLoading)) { return null; } - const handleApply = (spellbook: ParsedSpellbook) => { - loadSpellbook(spellbook); - toast.success(`Layout "${spellbook.title}" applied`); + const handleApplySpellbook = (sb: ParsedSpellbook) => { + loadSpellbook(sb); + toast.success(`Layout "${sb.title}" applied`); + }; + + const handleRunSpell = async (spell: ParsedSpell) => { + try { + const parts = spell.command.trim().split(/\s+/); + const commandName = parts[0]?.toLowerCase(); + const cmdArgs = parts.slice(1); + const command = manPages[commandName]; + + if (command) { + const cmdProps = command.argParser + ? await Promise.resolve(command.argParser(cmdArgs)) + : command.defaultProps || {}; + addWindow(command.appId, cmdProps, spell.command); + toast.success(`Ran spell: ${spell.name || 'Untitled'}`); + } + } catch (e) { + toast.error("Failed to run spell"); + } }; return ( @@ -97,50 +136,77 @@ export function SpellbookDropdown() { size="sm" className="h-7 px-2 gap-1.5 text-muted-foreground hover:text-accent" > - - Spellbooks + + Library - - - My Spellbooks - {networkLoading && } - - - - {spellbooks.length === 0 && networkLoading && ( -
- -

Loading...

-
+ + {/* Spellbooks Section */} + {spellbooks.length > 0 && ( + <> + + Spellbooks + + {spellbooks.map((sb) => ( + handleApplySpellbook(sb)} + className="cursor-pointer py-2" + > + +
+ {sb.title} + + {Object.keys(sb.content.workspaces).length} tabs + +
+
+ ))} + )} - {spellbooks.map((sb) => ( - handleApply(sb)} - className="cursor-pointer" - > - -
- {sb.title} - - {Object.keys(sb.content.workspaces).length} tabs, {Object.keys(sb.content.windows).length} windows - -
-
- ))} + {/* Spells Section */} + {spells.length > 0 && ( + <> + + + Spells + + {spells.map((s, idx) => ( + handleRunSpell(s)} + className="cursor-pointer py-2" + > + +
+ {s.name || "Untitled Spell"} + + {s.command} + +
+
+ ))} + + )} + + {networkLoading && ( +
+ + Syncing... +
+ )} addWindow("spellbooks", {})} - className="cursor-crosshair text-accent" + className="cursor-crosshair py-2" > - - Manage Spellbooks + + Manage Library
); -} +} \ No newline at end of file