From 6a2c1a60fb2d06412c5ea2a0ae0597f534974fb0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alejandro=20G=C3=B3mez?= Date: Sat, 20 Dec 2025 19:02:41 +0100 Subject: [PATCH] feat: add nice preview renderers for spellbooks (kind 30777) - Create SpellbookRenderer for feed view - Create SpellbookDetailRenderer with Apply Layout action - Register kind 30777 renderers in index --- src/actions/publish-spellbook.ts | 38 ++-- src/components/SpellbooksViewer.tsx | 47 +++-- .../nostr/kinds/SpellbookRenderer.tsx | 170 ++++++++++++++++++ src/components/nostr/kinds/index.tsx | 6 + 4 files changed, 238 insertions(+), 23 deletions(-) create mode 100644 src/components/nostr/kinds/SpellbookRenderer.tsx diff --git a/src/actions/publish-spellbook.ts b/src/actions/publish-spellbook.ts index f9bac61..f0ed009 100644 --- a/src/actions/publish-spellbook.ts +++ b/src/actions/publish-spellbook.ts @@ -8,6 +8,7 @@ import { relayListCache } from "@/services/relay-list-cache"; import { AGGREGATOR_RELAYS } from "@/services/loaders"; import { mergeRelaySets } from "applesauce-core/helpers"; import { GrimoireState } from "@/types/app"; +import { SpellbookContent } from "@/types/spell"; export interface PublishSpellbookOptions { state: GrimoireState; @@ -15,6 +16,7 @@ export interface PublishSpellbookOptions { description?: string; workspaceIds?: string[]; localId?: string; // If provided, updates this local spellbook + content?: SpellbookContent; // Optional explicit content } export class PublishSpellbookAction { @@ -22,27 +24,41 @@ export class PublishSpellbookAction { label = "Publish Spellbook"; async execute(options: PublishSpellbookOptions): Promise { - const { state, title, description, workspaceIds, localId } = options; + const { state, title, description, workspaceIds, localId, content } = options; const account = accountManager.active; if (!account) throw new Error("No active account"); const signer = account.signer; if (!signer) throw new Error("No signer available"); - // 1. Create event props from state - const encoded = createSpellbook({ - state, - title, - description, - workspaceIds, - }); + // 1. Create event props from state or use provided content + let eventProps; + if (content) { + eventProps = { + kind: 30777, + content: JSON.stringify(content), + tags: [ + ["d", title.toLowerCase().trim().replace(/\s+/g, "-")], + ["title", title], + ], + }; + if (description) eventProps.tags.push(["description", description]); + } else { + const encoded = createSpellbook({ + state, + title, + description, + workspaceIds, + }); + eventProps = encoded.eventProps; + } // 2. Build and sign event const factory = new EventFactory({ signer }); const draft = await factory.build({ - kind: encoded.eventProps.kind, - content: encoded.eventProps.content, - tags: encoded.eventProps.tags, + kind: eventProps.kind, + content: eventProps.content, + tags: eventProps.tags as [string, string, ...string[]][], }); const event = (await factory.sign(draft)) as SpellbookEvent; diff --git a/src/components/SpellbooksViewer.tsx b/src/components/SpellbooksViewer.tsx index aa2ce71..4347d85 100644 --- a/src/components/SpellbooksViewer.tsx +++ b/src/components/SpellbooksViewer.tsx @@ -44,7 +44,12 @@ interface SpellbookCardProps { onApply: (spellbook: ParsedSpellbook) => void; } -function SpellbookCard({ spellbook, onDelete, onPublish, onApply }: SpellbookCardProps) { +function SpellbookCard({ + spellbook, + onDelete, + onPublish, + onApply, +}: SpellbookCardProps) { const [isPublishing, setIsPublishing] = useState(false); const [isDeleting, setIsDeleting] = useState(false); const displayName = spellbook.title || "Untitled Spellbook"; @@ -127,18 +132,19 @@ function SpellbookCard({ spellbook, onDelete, onPublish, onApply }: SpellbookCar
- {workspaceCount} {workspaceCount === 1 ? 'workspace' : 'workspaces'} + {workspaceCount}{" "} + {workspaceCount === 1 ? "workspace" : "workspaces"}
- {windowCount} {windowCount === 1 ? 'window' : 'windows'} + {windowCount} {windowCount === 1 ? "window" : "windows"}
-
+
)}
{!spellbook.deletedAt && ( - )} @@ -198,7 +213,9 @@ export function SpellbooksViewer() { // Fetch from Nostr const { events: networkEvents, loading: networkLoading } = useReqTimeline( - state.activeAccount ? `user-spellbooks-${state.activeAccount.pubkey}` : "none", + state.activeAccount + ? `user-spellbooks-${state.activeAccount.pubkey}` + : "none", state.activeAccount ? { kinds: [SPELLBOOK_KIND], authors: [state.activeAccount.pubkey] } : [], @@ -217,11 +234,13 @@ export function SpellbooksViewer() { for (const event of networkEvents) { // Find d tag for matching with local slug - const slug = event.tags.find(t => t[0] === 'd')?.[1] || ''; - + const slug = event.tags.find((t) => t[0] === "d")?.[1] || ""; + // Look for existing by slug + pubkey? For now just ID matching if we have it // Replaceable events are tricky. Let's match by slug if localId not found. - const existing = Array.from(allSpellbooksMap.values()).find(s => s.slug === slug); + const existing = Array.from(allSpellbooksMap.values()).find( + (s) => s.slug === slug, + ); if (existing) { // Update existing with network event if it's newer @@ -284,7 +303,10 @@ export function SpellbooksViewer() { try { if (spellbook.isPublished && spellbook.event) { - await new DeleteEventAction().execute({ event: spellbook.event }, "Deleted by user"); + await new DeleteEventAction().execute( + { event: spellbook.event }, + "Deleted by user", + ); } await deleteSpellbook(spellbook.id); toast.success("Spellbook deleted"); @@ -302,6 +324,7 @@ export function SpellbooksViewer() { description: spellbook.description, workspaceIds: Object.keys(spellbook.content.workspaces), localId: spellbook.id, + content: spellbook.content, // Pass existing content }); toast.success("Spellbook published"); } catch (error) { @@ -379,7 +402,7 @@ export function SpellbooksViewer() { No spellbooks found.
) : ( -
+
{filteredSpellbooks.map((s) => ( { + try { + return parseSpellbook(event as SpellbookEvent); + } catch (e) { + return null; + } + }, [event]); + + if (!spellbook) { + return ( + +
Failed to parse spellbook data
+
+ ); + } + + const workspaceCount = Object.keys(spellbook.content.workspaces).length; + const windowCount = Object.keys(spellbook.content.windows).length; + + return ( + +
+ {/* Title */} +
+ + + {spellbook.title} + +
+ + {/* Description */} + {spellbook.description && ( +

+ {spellbook.description} +

+ )} + + {/* Stats */} +
+
+ + {workspaceCount} {workspaceCount === 1 ? 'workspace' : 'workspaces'} +
+
+ + {windowCount} {windowCount === 1 ? 'window' : 'windows'} +
+
+
+
+ ); +} + +/** + * Detail renderer for Kind 30777 - Spellbook + * Shows detailed workspace information and Apply Layout button + */ +export function SpellbookDetailRenderer({ event }: { event: NostrEvent }) { + const { loadSpellbook } = useGrimoire(); + + const spellbook = useMemo(() => { + try { + return parseSpellbook(event as SpellbookEvent); + } catch (e) { + return null; + } + }, [event]); + + if (!spellbook) { + return
Failed to parse spellbook data
; + } + + const handleApply = () => { + loadSpellbook(spellbook); + toast.success("Layout applied", { + description: `Replaced current layout with ${Object.keys(spellbook.content.workspaces).length} workspaces.`, + }); + }; + + const sortedWorkspaces = Object.values(spellbook.content.workspaces).sort((a, b) => a.number - b.number); + + return ( +
+ {/* Header */} +
+
+
+
+ +
+

{spellbook.title}

+
+ {spellbook.description && ( +

{spellbook.description}

+ )} +
+ + +
+ + {/* Workspaces Summary */} +
+

+ + Workspaces Content +

+ +
+ {sortedWorkspaces.map((ws) => { + const wsWindows = ws.windowIds.length; + return ( +
+
+ Workspace {ws.number} + {ws.label || 'Untitled Workspace'} +
+
+ + {wsWindows} {wsWindows === 1 ? 'window' : 'windows'} +
+
+ ); + })} +
+
+ + {/* Technical Data / Reference */} +
+
+
+ D-TAG: {spellbook.slug} + VERSION: {spellbook.content.version} +
+
+
+
+ ); +} diff --git a/src/components/nostr/kinds/index.tsx b/src/components/nostr/kinds/index.tsx index fce03f9..e4e4581 100644 --- a/src/components/nostr/kinds/index.tsx +++ b/src/components/nostr/kinds/index.tsx @@ -37,6 +37,10 @@ import { GenericRelayListRenderer } from "./GenericRelayListRenderer"; import { LiveActivityRenderer } from "./LiveActivityRenderer"; import { LiveActivityDetailRenderer } from "./LiveActivityDetailRenderer"; import { SpellRenderer, SpellDetailRenderer } from "./SpellRenderer"; +import { + SpellbookRenderer, + SpellbookDetailRenderer, +} from "./SpellbookRenderer"; import { NostrEvent } from "@/types/nostr"; import { BaseEventContainer, type BaseEventProps } from "./BaseEventRenderer"; @@ -76,6 +80,7 @@ const kindRenderers: Record> = { 30311: LiveActivityRenderer, // Live Streaming Event (NIP-53) 30617: RepositoryRenderer, // Repository (NIP-34) 30618: RepositoryStateRenderer, // Repository State (NIP-34) + 30777: SpellbookRenderer, // Spellbook (Grimoire) 30817: CommunityNIPRenderer, // Community NIP 39701: Kind39701Renderer, // Web Bookmarks (NIP-B0) }; @@ -132,6 +137,7 @@ const detailRenderers: Record< 30311: LiveActivityDetailRenderer, // Live Streaming Event Detail (NIP-53) 30617: RepositoryDetailRenderer, // Repository Detail (NIP-34) 30618: RepositoryStateDetailRenderer, // Repository State Detail (NIP-34) + 30777: SpellbookDetailRenderer, // Spellbook Detail (Grimoire) 30817: CommunityNIPDetailRenderer, // Community NIP Detail };