diff --git a/src/components/Home.tsx b/src/components/Home.tsx index 35bcaa2..7ee71db 100644 --- a/src/components/Home.tsx +++ b/src/components/Home.tsx @@ -9,7 +9,7 @@ import { Mosaic, MosaicWindow, MosaicBranch } from "react-mosaic-component"; import CommandLauncher from "./CommandLauncher"; import { WindowToolbar } from "./WindowToolbar"; import { WindowTile } from "./WindowTitle"; -import { Terminal, BookHeart, X, Check } from "lucide-react"; +import { BookHeart, X, Check } from "lucide-react"; import UserMenu from "./nostr/user-menu"; import { GrimoireWelcome } from "./GrimoireWelcome"; import { GlobalAuthPrompt } from "./GlobalAuthPrompt"; @@ -26,12 +26,14 @@ import { Button } from "./ui/button"; const PREVIEW_BACKUP_KEY = "grimoire-preview-backup"; export default function Home() { - const { state, updateLayout, removeWindow, loadSpellbook } = useGrimoire(); + const { state, updateLayout, removeWindow, loadSpellbook, clearActiveSpellbook } = useGrimoire(); const [commandLauncherOpen, setCommandLauncherOpen] = useState(false); const { actor, identifier } = useParams(); const navigate = useNavigate(); const location = useLocation(); + const activeSpellbook = state.activeSpellbook; + // Preview state const [resolvedPubkey, setResolvedPubkey] = useState(null); const isPreviewPath = location.pathname.startsWith("/preview/"); @@ -241,10 +243,24 @@ export default function Home() { title="Launch command (Cmd+K)" aria-label="Launch command palette" > - - +
+ + {activeSpellbook && !isPreviewPath && ( +
+ Active: + {activeSpellbook.title} + +
+ )} +
diff --git a/src/components/SpellbookDropdown.tsx b/src/components/SpellbookDropdown.tsx index e004ce5..20f15c4 100644 --- a/src/components/SpellbookDropdown.tsx +++ b/src/components/SpellbookDropdown.tsx @@ -1,10 +1,10 @@ -import { useMemo } from "react"; -import { BookHeart, ChevronDown, WandSparkles } from "lucide-react"; +import { useMemo, useState } from "react"; +import { BookHeart, ChevronDown, Plus, Save, 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 { createSpellbook, parseSpellbook } from "@/lib/spellbook-manager"; import { decodeSpell } from "@/lib/spell-conversion"; import type { SpellbookEvent, @@ -25,10 +25,16 @@ import { import { toast } from "sonner"; import { manPages } from "@/types/man"; 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 } = useGrimoire(); const activeAccount = state.activeAccount; + const activeSpellbook = state.activeSpellbook; + const [saveDialogOpen, setSaveDialogOpen] = useState(false); + const [isUpdating, setIsUpdating] = useState(false); // 1. Load Local Data const localSpellbooks = useLiveQuery(() => @@ -130,6 +136,51 @@ export function SpellbookDropdown() { 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); + + // 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, + }); + } + + // 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 handleRunSpell = async (spell: ParsedSpell) => { try { const parts = spell.command.trim().split(/\s+/); @@ -153,90 +204,131 @@ export function SpellbookDropdown() { "cursor-pointer py-2 hover:bg-muted focus:bg-muted transition-colors"; return ( - - - + + - - Library - - - - - {/* Spellbooks Section */} - {spellbooks.length > 0 && ( - <> - - Spellbooks - - {spellbooks.map((sb) => ( + {/* Active Spellbook Actions */} + {activeSpellbook && ( + <> + + Current Layout + handleApplySpellbook(sb)} + onClick={handleUpdateActive} + disabled={isUpdating} className={itemClass} > - +
- - {sb.title} - - - {Object.keys(sb.content.workspaces).length} tabs - + Update "{activeSpellbook.title}" + Save current state to this spellbook
- ))} - addWindow("spellbooks", {})} - className={cn(itemClass, "text-xs opacity-70")} - > - - Manage Library - - - )} + + + )} - {/* Spells Section */} - {spells.length > 0 && ( - <> - - - Spells - - {spells.map((s, idx) => ( + {/* Spellbooks Section */} + {spellbooks.length > 0 && ( + <> + + Spellbooks + + {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 + +
+
+ ); + })} handleRunSpell(s)} - className={itemClass} + onClick={() => addWindow("spellbooks", {})} + className={cn(itemClass, "text-xs opacity-70")} > - -
- - {s.name || "Untitled Spell"} - - - {s.command} - -
+ + Manage Library
- ))} - addWindow("spells", {})} - className={cn(itemClass, "text-xs opacity-70")} - > - - Manage Spells - - - )} -
-
- ); - } - + + + )} + + {/* New/Save Section */} + setSaveDialogOpen(true)} + className={itemClass} + > + + Save as new layout + + + {/* Spells Section */} + {spells.length > 0 && ( + <> + + + Spells + + {spells.map((s, idx) => ( + handleRunSpell(s)} + className={itemClass} + > + +
+ + {s.name || "Untitled Spell"} + + + {s.command} + +
+
+ ))} + addWindow("spells", {})} + className={cn(itemClass, "text-xs opacity-70")} + > + + Manage Spells + + + )} + + + + ); +} diff --git a/src/core/logic.ts b/src/core/logic.ts index 78bfdd0..9694947 100644 --- a/src/core/logic.ts +++ b/src/core/logic.ts @@ -491,3 +491,13 @@ export const setCompactModeKinds = ( compactModeKinds: kinds, }; }; + +/** + * Clears the currently active spellbook tracking. + */ +export const clearActiveSpellbook = (state: GrimoireState): GrimoireState => { + return { + ...state, + activeSpellbook: undefined, + }; +}; diff --git a/src/core/state.ts b/src/core/state.ts index f9a7ea5..76b889a 100644 --- a/src/core/state.ts +++ b/src/core/state.ts @@ -306,6 +306,11 @@ export const useGrimoire = () => { [setState], ); + const clearActiveSpellbook = useCallback( + () => setState((prev) => Logic.clearActiveSpellbook(prev)), + [setState], + ); + return { state, locale: state.locale || browserLocale, @@ -326,5 +331,6 @@ export const useGrimoire = () => { reorderWorkspaces, setCompactModeKinds, loadSpellbook, + clearActiveSpellbook, }; }; diff --git a/src/lib/spellbook-manager.ts b/src/lib/spellbook-manager.ts index da9a8ea..cc4f78a 100644 --- a/src/lib/spellbook-manager.ts +++ b/src/lib/spellbook-manager.ts @@ -266,5 +266,11 @@ export function loadSpellbook( workspaces: newWorkspaces, windows: newWindows, activeWorkspaceId: firstNewWorkspaceId || state.activeWorkspaceId, + activeSpellbook: { + id: spellbook.event?.id || uuidv4(), // Fallback to uuid if local + slug: spellbook.slug, + title: spellbook.title, + pubkey: spellbook.event?.pubkey, + }, }; } diff --git a/src/types/app.ts b/src/types/app.ts index b1a2667..fc1dc29 100644 --- a/src/types/app.ts +++ b/src/types/app.ts @@ -95,4 +95,10 @@ export interface GrimoireState { timeFormat: "12h" | "24h"; }; relayState?: GlobalRelayState; + activeSpellbook?: { + id: string; // event id or local uuid + slug: string; // d-tag + title: string; + pubkey?: string; // owner + }; }