diff --git a/src/components/Home.tsx b/src/components/Home.tsx index ce622fc..35bcaa2 100644 --- a/src/components/Home.tsx +++ b/src/components/Home.tsx @@ -9,11 +9,12 @@ import { Mosaic, MosaicWindow, MosaicBranch } from "react-mosaic-component"; import CommandLauncher from "./CommandLauncher"; import { WindowToolbar } from "./WindowToolbar"; import { WindowTile } from "./WindowTitle"; -import { Terminal, Book, BookHeart, X, Check } from "lucide-react"; +import { Terminal, BookHeart, X, Check } from "lucide-react"; import UserMenu from "./nostr/user-menu"; import { GrimoireWelcome } from "./GrimoireWelcome"; import { GlobalAuthPrompt } from "./GlobalAuthPrompt"; -import { useParams, useNavigate } from "react-router"; +import { SpellbookDropdown } from "./SpellbookDropdown"; +import { useParams, useNavigate, useLocation } from "react-router"; import { useNostrEvent } from "@/hooks/useNostrEvent"; import { resolveNip05, isNip05 } from "@/lib/nip05"; import { nip19 } from "nostr-tools"; @@ -22,25 +23,25 @@ import { SpellbookEvent } from "@/types/spell"; import { toast } from "sonner"; import { Button } from "./ui/button"; -export default function Home({ - spellbookName, -}: { - spellbookName?: string | null; -}) { +const PREVIEW_BACKUP_KEY = "grimoire-preview-backup"; + +export default function Home() { const { state, updateLayout, removeWindow, loadSpellbook } = useGrimoire(); const [commandLauncherOpen, setCommandLauncherOpen] = useState(false); const { actor, identifier } = useParams(); const navigate = useNavigate(); + const location = useLocation(); // Preview state const [resolvedPubkey, setResolvedPubkey] = useState(null); - const [originalState, setOriginalState] = useState(null); - const [isPreviewing, setIsPreviewing] = useState(false); + const isPreviewPath = location.pathname.startsWith("/preview/"); + const [hasLoadedSpellbook, setHasLoadedSpellbook] = useState(false); // 1. Resolve actor to pubkey useEffect(() => { if (!actor) { setResolvedPubkey(null); + setHasLoadedSpellbook(false); return; } @@ -75,48 +76,64 @@ export default function Home({ const spellbookEvent = useNostrEvent(pointer); - // 3. Apply preview when event is loaded + // 3. Apply preview/layout when event is loaded useEffect(() => { - if (spellbookEvent && !isPreviewing) { + if (spellbookEvent && !hasLoadedSpellbook) { try { const parsed = parseSpellbook(spellbookEvent as SpellbookEvent); - // Save current state before replacing - setOriginalState({ ...state }); - loadSpellbook(parsed); - setIsPreviewing(true); - toast.info(`Previewing layout: ${parsed.title}`, { - description: "This is a temporary preview. You can apply or discard it.", - }); + + if (isPreviewPath) { + // In preview mode, save current state to sessionStorage for recovery + if (!sessionStorage.getItem(PREVIEW_BACKUP_KEY)) { + sessionStorage.setItem(PREVIEW_BACKUP_KEY, JSON.stringify(state)); + } + + loadSpellbook(parsed); + setHasLoadedSpellbook(true); + toast.info(`Previewing layout: ${parsed.title}`, { + description: "You are in preview mode. Apply to keep this layout or discard to return.", + }); + } else { + // Direct mode: Just load it immediately + loadSpellbook(parsed); + setHasLoadedSpellbook(true); + // Update URL to home after loading to avoid re-loading on refresh if they start modifying + navigate("/", { replace: true }); + toast.success(`Loaded layout: ${parsed.title}`); + } } catch (e) { - console.error("Failed to parse preview spellbook:", e); - toast.error("Failed to load spellbook preview"); + console.error("Failed to parse spellbook:", e); + toast.error("Failed to load spellbook"); } } - }, [spellbookEvent, isPreviewing]); + }, [spellbookEvent, hasLoadedSpellbook, isPreviewPath]); const handleApplyLayout = () => { - setIsPreviewing(false); - setOriginalState(null); - navigate("/"); + sessionStorage.removeItem(PREVIEW_BACKUP_KEY); + navigate("/", { replace: true }); toast.success("Layout applied permanently"); }; const handleDiscardPreview = () => { - if (originalState) { - // Restore original workspaces and windows - // We need a way to restore the whole state. - // For now, let's just navigate back, which might reload if we are not careful - // Actually, useGrimoire doesn't have a 'restoreState' yet. - // Let's just navigate home and hope the user re-applies if they want. - // But Grimoire state is persisted to localStorage. - // THIS IS TRICKY: loadSpellbook already mutated the persisted state! - - // To properly discard, we would need to revert the state. - // For now, let's just go home. - window.location.href = "/"; - } else { - navigate("/"); + const backup = sessionStorage.getItem(PREVIEW_BACKUP_KEY); + if (backup) { + try { + JSON.parse(backup); + // We need a way to restore the whole state. + // For now, the easiest way to "restore" a persisted state from sessionStorage + // is to clear our local storage and reload, or manually call setters. + // But loadSpellbook already overwrote it in localStorage via Jotai. + + // Let's try to overwrite localStorage directly and reload for a clean restore + localStorage.setItem("grimoire-state", backup); + sessionStorage.removeItem(PREVIEW_BACKUP_KEY); + window.location.href = "/"; + return; + } catch (e) { + console.error("Failed to restore backup:", e); + } } + navigate("/"); }; // Sync active account and fetch relay lists @@ -189,7 +206,7 @@ export default function Home({ />
- {isPreviewing && ( + {isPreviewPath && (
@@ -227,14 +244,7 @@ export default function Home({ - {spellbookName && ( -
- - - {spellbookName} - -
- )} + diff --git a/src/components/SpellbookDropdown.tsx b/src/components/SpellbookDropdown.tsx new file mode 100644 index 0000000..0980f62 --- /dev/null +++ b/src/components/SpellbookDropdown.tsx @@ -0,0 +1,146 @@ +import { useMemo } from "react"; +import { BookHeart, ChevronDown, Layout, Loader2 } 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 { Button } from "./ui/button"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuLabel, + DropdownMenuSeparator, + DropdownMenuTrigger, +} from "./ui/dropdown-menu"; +import { toast } from "sonner"; +import { cn } from "@/lib/utils"; + +export function SpellbookDropdown() { + const { state, loadSpellbook, addWindow } = useGrimoire(); + const activeAccount = state.activeAccount; + + // Load local spellbooks from Dexie + const localSpellbooks = useLiveQuery(() => + db.spellbooks.toArray().then(books => books.filter(b => !b.deletedAt)), + ); + + // Fetch from Nostr + const { events: networkEvents, loading: networkLoading } = useReqTimeline( + activeAccount ? `header-spellbooks-${activeAccount.pubkey}` : "none", + activeAccount + ? { kinds: [SPELLBOOK_KIND], authors: [activeAccount.pubkey] } + : [], + activeAccount?.relays?.map((r) => r.url) || [], + { stream: true }, + ); + + // Merge and deduplicate logic similar to SpellbooksViewer + const spellbooks = useMemo(() => { + if (!activeAccount) return []; + + const allMap = new Map(); + + // Process local ones first + for (const s of localSpellbooks || []) { + const parsed: ParsedSpellbook = { + 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) { + 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; + } + + try { + const parsed = parseSpellbook(event as SpellbookEvent); + allMap.set(slug, parsed); + } catch (e) { + // ignore + } + } + + return Array.from(allMap.values()).sort((a, b) => + a.title.localeCompare(b.title) + ); + }, [localSpellbooks, networkEvents, activeAccount]); + + if (!activeAccount || (spellbooks.length === 0 && !networkLoading)) { + return null; + } + + const handleApply = (spellbook: ParsedSpellbook) => { + loadSpellbook(spellbook); + toast.success(`Layout "${spellbook.title}" applied`); + }; + + return ( + + + + + + + My Spellbooks + {networkLoading && } + + + + {spellbooks.length === 0 && networkLoading && ( +
+ +

Loading...

+
+ )} + + {spellbooks.map((sb) => ( + handleApply(sb)} + className="cursor-pointer" + > + +
+ {sb.title} + + {Object.keys(sb.content.workspaces).length} tabs, {Object.keys(sb.content.windows).length} windows + +
+
+ ))} + + + addWindow("spellbooks", {})} + className="cursor-crosshair text-accent" + > + + Manage Spellbooks + +
+
+ ); +} diff --git a/src/components/SpellbookLoader.tsx b/src/components/SpellbookLoader.tsx deleted file mode 100644 index ec2da12..0000000 --- a/src/components/SpellbookLoader.tsx +++ /dev/null @@ -1,104 +0,0 @@ -import { useEffect, useState } from "react"; -import { useParams } from "react-router"; -import { parseProfileCommand } from "@/lib/profile-parser"; -import { addressLoader } from "@/services/loaders"; -import { SPELLBOOK_KIND } from "@/constants/kinds"; -import { parseSpellbook } from "@/lib/spellbook-manager"; -import { useGrimoire } from "@/core/state"; -import Home from "./Home"; -import { Loader2, AlertCircle } from "lucide-react"; -import { Button } from "./ui/button"; - -export default function SpellbookLoader() { - const { user, identifier } = useParams(); - const { loadSpellbook, state } = useGrimoire(); - const [loading, setLoading] = useState(true); - const [error, setError] = useState(null); - const [loadedSlug, setLoadedSlug] = useState(null); - - useEffect(() => { - async function resolveAndLoad() { - if (!user || !identifier) return; - - try { - setLoading(true); - setError(null); - - // 1. Resolve user to pubkey - const { pubkey } = await parseProfileCommand([user], state.activeAccount?.pubkey); - - // 2. Load spellbook event - const event$ = addressLoader({ - kind: SPELLBOOK_KIND, - pubkey, - identifier, - }); - - // addressLoader returns an observable, we need to wait for the first value - const event = await new Promise((resolve, reject) => { - const sub = event$.subscribe({ - next: (ev) => { - if (ev) { - sub.unsubscribe(); - resolve(ev); - } - }, - error: reject, - }); - - // Timeout after 10 seconds - setTimeout(() => { - sub.unsubscribe(); - reject(new Error("Timeout loading spellbook")); - }, 10000); - }); - - if (!event) { - throw new Error("Spellbook not found"); - } - - // 3. Parse and load - const parsed = parseSpellbook(event); - loadSpellbook(parsed); - setLoadedSlug(`${parsed.title} by ${user}`); - setLoading(false); - } catch (err) { - console.error("Failed to load spellbook:", err); - setError(err instanceof Error ? err.message : "Failed to load spellbook"); - setLoading(false); - } - } - - resolveAndLoad(); - }, [user, identifier, loadSpellbook, state.activeAccount?.pubkey]); - - if (loading) { - return ( -
- -

Resolving spellbook...

-

@{user}/{identifier}

-
- ); - } - - if (error) { - return ( -
- -

Failed to load spellbook

-

{error}

-
- - -
-
- ); - } - - // Once loaded, we just render Home, but maybe we should use a redirect to clear the URL? - // Actually, the user wants the route to exist. - return ; -} diff --git a/src/components/nostr/kinds/SpellbookRenderer.tsx b/src/components/nostr/kinds/SpellbookRenderer.tsx index 7825550..0c7aa68 100644 --- a/src/components/nostr/kinds/SpellbookRenderer.tsx +++ b/src/components/nostr/kinds/SpellbookRenderer.tsx @@ -31,7 +31,7 @@ function PreviewButton({ event, identifier, size = "default", className = "" }: const handlePreview = (e: React.MouseEvent) => { e.stopPropagation(); const actor = profile?.nip05 || nip19.npubEncode(event.pubkey); - navigate(`/${actor}/${identifier}`); + navigate(`/preview/${actor}/${identifier}`); }; return ( diff --git a/src/root.tsx b/src/root.tsx index 47c4a3e..8b2e53b 100644 --- a/src/root.tsx +++ b/src/root.tsx @@ -6,6 +6,10 @@ const router = createBrowserRouter([ path: "/", element: , }, + { + path: "/preview/:actor/:identifier", + element: , + }, { path: "/:actor/:identifier", element: ,