From f255cded757852a56b1eff2e8e5bd4d3c296891e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alejandro=20G=C3=B3mez?= Date: Sat, 20 Dec 2025 22:15:30 +0100 Subject: [PATCH] feat: refine spellbook preview and session management logic - Implement smart banner visibility (only from client-side transitions) - Add 'Apply to Dashboard' and 'Add to Library' to SpellbookDropdown - Support updating layouts via standardized dialog - Fix build errors and type mismatches --- src/components/Home.tsx | 6 +- src/components/SaveSpellbookDialog.tsx | 85 ++++-- src/components/SpellbookDropdown.tsx | 232 +++++++-------- .../nostr/kinds/SpellbookRenderer.tsx | 274 +++++++++++------- 4 files changed, 356 insertions(+), 241 deletions(-) diff --git a/src/components/Home.tsx b/src/components/Home.tsx index e80d114..e8db4db 100644 --- a/src/components/Home.tsx +++ b/src/components/Home.tsx @@ -42,8 +42,12 @@ export default function Home() { const [resolvedPubkey, setResolvedPubkey] = useState(null); const isPreviewPath = location.pathname.startsWith("/preview/"); const isDirectPath = actor && identifier && !isPreviewPath; + const isFromApp = location.state?.fromApp === true; const [hasLoadedSpellbook, setHasLoadedSpellbook] = useState(false); + // Show banner only if temporary AND we navigated from within the app + const showBanner = isTemporary && isFromApp; + // 1. Resolve actor to pubkey useEffect(() => { if (!actor) { @@ -192,7 +196,7 @@ export default function Home() { />
- {isTemporary && ( + {showBanner && (
diff --git a/src/components/SaveSpellbookDialog.tsx b/src/components/SaveSpellbookDialog.tsx index 1bb722b..db9b950 100644 --- a/src/components/SaveSpellbookDialog.tsx +++ b/src/components/SaveSpellbookDialog.tsx @@ -1,4 +1,4 @@ -import { useState } from "react"; +import { useState, useEffect } from "react"; import { Dialog, DialogContent, @@ -22,21 +22,48 @@ import { Loader2, Save, Send } from "lucide-react"; interface SaveSpellbookDialogProps { open: boolean; onOpenChange: (open: boolean) => void; + existingSpellbook?: { + slug: string; + title: string; + description?: string; + workspaceIds?: string[]; + localId?: string; + pubkey?: string; + }; } export function SaveSpellbookDialog({ open, onOpenChange, + existingSpellbook, }: SaveSpellbookDialogProps) { const { state } = useGrimoire(); - const [title, setTitle] = useState(""); - const [description, setDescription] = useState(""); + const isUpdateMode = !!existingSpellbook; + + const [title, setTitle] = useState(existingSpellbook?.title || ""); + const [description, setDescription] = useState(existingSpellbook?.description || ""); const [selectedWorkspaces, setSelectedWorkspaces] = useState( - Object.keys(state.workspaces), + existingSpellbook?.workspaceIds || Object.keys(state.workspaces), ); const [isPublishing, setIsPublishing] = useState(false); const [isSaving, setIsSaving] = useState(false); + // Update form when dialog opens with existing spellbook data + useEffect(() => { + if (open && existingSpellbook) { + setTitle(existingSpellbook.title); + setDescription(existingSpellbook.description || ""); + setSelectedWorkspaces( + existingSpellbook.workspaceIds || Object.keys(state.workspaces), + ); + } else if (open && !existingSpellbook) { + // Reset form for new spellbook + setTitle(""); + setDescription(""); + setSelectedWorkspaces(Object.keys(state.workspaces)); + } + }, [open, existingSpellbook, state.workspaces]); + const handleSave = async (shouldPublish: boolean) => { if (!title.trim()) { toast.error("Please enter a title for your spellbook"); @@ -44,7 +71,7 @@ export function SaveSpellbookDialog({ } if (selectedWorkspaces.length === 0) { - toast.error("Please select at least one workspace to include"); + toast.error("Please select at least one tab to include"); return; } @@ -60,16 +87,21 @@ export function SaveSpellbookDialog({ workspaceIds: selectedWorkspaces, }); - // 2. Save locally + // 2. Determine slug (keep existing for updates, generate for new) + const slug = isUpdateMode + ? existingSpellbook.slug + : title.toLowerCase().trim().replace(/\s+/g, "-"); + + // 3. Save locally const localSpellbook = await saveSpellbook({ - slug: title.toLowerCase().trim().replace(/\s+/g, "-"), + slug, title, description, content: JSON.parse(encoded.eventProps.content), isPublished: false, }); - // 3. Optionally publish + // 4. Optionally publish if (shouldPublish) { const action = new PublishSpellbookAction(); await action.execute({ @@ -77,21 +109,32 @@ export function SaveSpellbookDialog({ title, description, workspaceIds: selectedWorkspaces, - localId: localSpellbook.id, + localId: existingSpellbook?.localId || localSpellbook.id, content: localSpellbook.content, // Pass explicitly to avoid re-calculating (and potentially failing) }); - toast.success("Spellbook saved and published to Nostr"); + toast.success( + isUpdateMode + ? "Spellbook updated and published to Nostr" + : "Spellbook saved and published to Nostr", + ); } else { - toast.success("Spellbook saved locally"); + toast.success( + isUpdateMode ? "Spellbook updated locally" : "Spellbook saved locally", + ); } onOpenChange(false); - // Reset form - setTitle(""); - setDescription(""); + // Reset form only if creating new + if (!isUpdateMode) { + setTitle(""); + setDescription(""); + setSelectedWorkspaces(Object.keys(state.workspaces)); + } } catch (error) { console.error("Failed to save spellbook:", error); - toast.error(error instanceof Error ? error.message : "Failed to save spellbook"); + toast.error( + error instanceof Error ? error.message : "Failed to save spellbook", + ); } finally { setIsSaving(false); setIsPublishing(false); @@ -102,9 +145,13 @@ export function SaveSpellbookDialog({ - Save Layout as Spellbook + + {isUpdateMode ? "Update Spellbook" : "Save Layout as Spellbook"} + - Save your current workspaces and window configuration. + {isUpdateMode + ? "Update the configuration of your spellbook." + : "Save your current workspaces and window configuration."} @@ -130,7 +177,7 @@ export function SaveSpellbookDialog({
- +
{Object.values(state.workspaces) .sort((a, b) => a.number - b.number) @@ -153,7 +200,7 @@ export function SaveSpellbookDialog({ htmlFor={`ws-${ws.id}`} className="text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70 cursor-pointer" > - {ws.number}. {ws.label || "Workspace"} + {ws.number}. {ws.label || "Tab"}
))} diff --git a/src/components/SpellbookDropdown.tsx b/src/components/SpellbookDropdown.tsx index 8f4d445..8e55780 100644 --- a/src/components/SpellbookDropdown.tsx +++ b/src/components/SpellbookDropdown.tsx @@ -1,10 +1,10 @@ import { useMemo, useState } from "react"; -import { BookHeart, ChevronDown, Plus, Save, X } from "lucide-react"; +import { BookHeart, ChevronDown, Plus, Save, Settings, X } 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 { createSpellbook, parseSpellbook } from "@/lib/spellbook-manager"; +import { parseSpellbook } from "@/lib/spellbook-manager"; import type { SpellbookEvent, ParsedSpellbook } from "@/types/spell"; import { SPELLBOOK_KIND } from "@/constants/kinds"; import { Button } from "./ui/button"; @@ -16,19 +16,23 @@ import { DropdownMenuSeparator, DropdownMenuTrigger, } from "./ui/dropdown-menu"; -import { toast } from "sonner"; import { cn } from "@/lib/utils"; -import { PublishSpellbookAction } from "@/actions/publish-spellbook"; -import { saveSpellbook } from "@/services/spellbook-storage"; import { SaveSpellbookDialog } from "./SaveSpellbookDialog"; export function SpellbookDropdown() { - const { state, loadSpellbook, addWindow, clearActiveSpellbook } = + const { state, loadSpellbook, addWindow, clearActiveSpellbook, applyTemporaryToPersistent, isTemporary } = useGrimoire(); const activeAccount = state.activeAccount; const activeSpellbook = state.activeSpellbook; const [saveDialogOpen, setSaveDialogOpen] = useState(false); - const [isUpdating, setIsUpdating] = useState(false); + const [dialogSpellbook, setDialogSpellbook] = useState<{ + slug: string; + title: string; + description?: string; + workspaceIds?: string[]; + localId?: string; + pubkey?: string; + } | undefined>(undefined); // 1. Load Local Data const localSpellbooks = useLiveQuery(() => @@ -82,63 +86,43 @@ export function SpellbookDropdown() { ); }, [localSpellbooks, networkEvents, activeAccount]); + // Check if active spellbook is in local library + const isActiveLocal = useMemo(() => { + if (!activeSpellbook) return false; + return (localSpellbooks || []).some(s => s.slug === activeSpellbook.slug); + }, [activeSpellbook, localSpellbooks]); + if (!activeAccount || (spellbooks.length === 0 && !activeSpellbook)) { return null; } const handleApplySpellbook = (sb: ParsedSpellbook) => { loadSpellbook(sb); - toast.success(`Layout "${sb.title}" applied`); }; const handleUpdateActive = async () => { if (!activeSpellbook) return; - setIsUpdating(true); - try { - // Generate current layout content - const encoded = createSpellbook({ - state, - title: activeSpellbook.title, - }); - const content = JSON.parse(encoded.eventProps.content); + // Get local spellbook for ID + const local = await db.spellbooks + .where("slug") + .equals(activeSpellbook.slug) + .first(); - // 1. Save locally - const local = await db.spellbooks - .where("slug") - .equals(activeSpellbook.slug) - .first(); - if (local) { - await db.spellbooks.update(local.id, { content }); - } else { - await saveSpellbook({ - slug: activeSpellbook.slug, - title: activeSpellbook.title, - content, - isPublished: false, - }); - } + // Open dialog with existing spellbook data + setDialogSpellbook({ + slug: activeSpellbook.slug, + title: activeSpellbook.title, + workspaceIds: Object.keys(state.workspaces), + localId: local?.id, + pubkey: activeSpellbook.pubkey, + }); + setSaveDialogOpen(true); + }; - // 2. If it was published or we want to publish updates - if (activeSpellbook.pubkey === activeAccount.pubkey) { - const action = new PublishSpellbookAction(); - await action.execute({ - state, - title: activeSpellbook.title, - content, - localId: local?.id, - }); - toast.success( - `Layout "${activeSpellbook.title}" updated and published`, - ); - } else { - toast.success(`Layout "${activeSpellbook.title}" updated locally`); - } - } catch (e) { - toast.error("Failed to update layout"); - } finally { - setIsUpdating(false); - } + const handleNewSpellbook = () => { + setDialogSpellbook(undefined); + setSaveDialogOpen(true); }; const itemClass = @@ -149,6 +133,7 @@ export function SpellbookDropdown() { @@ -162,7 +147,7 @@ export function SpellbookDropdown() { > - {activeSpellbook ? activeSpellbook.title : "Layouts"} + {activeSpellbook ? activeSpellbook.title : "grimoire"} @@ -175,21 +160,40 @@ export function SpellbookDropdown() { {activeSpellbook && ( <> - Current Layout + Active Layout - - -
- Update - - Save current state to this spellbook - -
-
+
+ {activeSpellbook.title || activeSpellbook.slug} +
+ + {isTemporary && ( + + + Apply to Dashboard + + )} + + {isActiveLocal && activeSpellbook.pubkey === activeAccount.pubkey ? ( + + + Update + + ) : ( + + + Add to Library + + )} + 0 && ( - <> - - My Layouts - - {spellbooks.map((sb) => { - const isActive = activeSpellbook?.slug === sb.slug; - return ( - handleApplySpellbook(sb)} - className={cn(itemClass, isActive && "bg-muted font-bold")} - > - -
- - {sb.title} - - - {Object.keys(sb.content.workspaces).length} tabs - -
-
- ); - })} - addWindow("spellbooks", {})} - className={cn(itemClass, "text-xs opacity-70")} - > - - Manage Layouts - - - + + My Layouts + + + {spellbooks.length === 0 ? ( +
+ No layouts saved yet. +
+ ) : ( + spellbooks.map((sb) => { + const isActive = activeSpellbook?.slug === sb.slug; + return ( + handleApplySpellbook(sb)} + className={cn(itemClass, isActive && "bg-muted font-bold")} + > + +
+ + {sb.title} + +
+
+ ); + }) + )} + + + + {!activeSpellbook && ( + + + Save current as Layout + )} - {/* New Section */} setSaveDialogOpen(true)} - className={itemClass} + onClick={() => addWindow("spellbooks", {})} + className={cn(itemClass, "text-xs opacity-70")} > - - - Save as new layout - + + Manage Library
); -} \ No newline at end of file +} diff --git a/src/components/nostr/kinds/SpellbookRenderer.tsx b/src/components/nostr/kinds/SpellbookRenderer.tsx index b3c29bd..d63969b 100644 --- a/src/components/nostr/kinds/SpellbookRenderer.tsx +++ b/src/components/nostr/kinds/SpellbookRenderer.tsx @@ -7,9 +7,8 @@ import { import { parseSpellbook } from "@/lib/spellbook-manager"; import { SpellbookEvent, ParsedSpellbook } from "@/types/spell"; import { NostrEvent } from "@/types/nostr"; -import { BookHeart, Layout, ExternalLink, Play, Eye, Share2 } from "lucide-react"; +import { Layout, ExternalLink, Eye, Share2 } from "lucide-react"; import { Button } from "@/components/ui/button"; -import { useGrimoire } from "@/core/state"; import { toast } from "sonner"; import { useProfile } from "@/hooks/useProfile"; import { nip19 } from "nostr-tools"; @@ -36,25 +35,30 @@ function getSpellbookKinds(spellbook: ParsedSpellbook): number[] { * Preview Button Component * Navigates to // */ -function PreviewButton({ event, identifier, size = "default", className = "" }: { - event: NostrEvent, - identifier: string, - size?: "default" | "sm" | "lg" | "icon", - className?: string +function PreviewButton({ + event, + identifier, + size = "default", + className = "", +}: { + event: NostrEvent; + identifier: string; + size?: "default" | "sm" | "lg" | "icon"; + className?: string; }) { const profile = useProfile(event.pubkey); const navigate = useNavigate(); - + const handlePreview = (e: React.MouseEvent) => { e.stopPropagation(); const actor = profile?.nip05 || nip19.npubEncode(event.pubkey); - navigate(`/preview/${actor}/${identifier}`); + navigate(`/preview/${actor}/${identifier}`, { state: { fromApp: true } }); }; return ( - - - - -
- {/* Workspaces Summary */} + {/* Event Kinds */} + {getSpellbookKinds(spellbook).length > 0 && ( +
+

+ Event Kinds +

+
+ {getSpellbookKinds(spellbook).map((kind) => ( + + ))} +
+
+ )} + + {/* Tabs Summary */}

- Workspaces Content + Tabs

-
+
{sortedWorkspaces.map((ws) => { const wsWindows = ws.windowIds.length; return (
-
- - Workspace {ws.number} - - - {ws.label || "Untitled Workspace"} - -
-
- - {wsWindows} {wsWindows === 1 ? "window" : "windows"} +
+
+ + Tab {ws.number} + + + {ws.label || "Untitled Tab"} + +
+
+ + {wsWindows} {wsWindows === 1 ? "window" : "windows"} +
+ + {ws.layout && ( + + )}
); })}
- - {/* Technical Data / Reference */} -
-
-
- D-TAG: {spellbook.slug} - VERSION: {spellbook.content.version} -
-
-
); }