feat: separate preview/direct views and add spellbook dropdown

- Separate preview mode to /preview/:actor/:identifier
- Direct links (/:actor/:identifier) now load layout immediately
- Add SpellbookDropdown to header center for quick layout switching
- Restore accidentally deleted wallet-related files
- Remove obsolete SpellbookLoader.tsx
This commit is contained in:
Alejandro Gómez
2025-12-20 19:58:24 +01:00
parent 7d72aec83e
commit cbba5bed00
5 changed files with 208 additions and 152 deletions

View File

@@ -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<string | null>(null);
const [originalState, setOriginalState] = useState<typeof state | null>(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({
/>
<GlobalAuthPrompt />
<main className="h-screen w-screen flex flex-col bg-background text-foreground">
{isPreviewing && (
{isPreviewPath && (
<div className="bg-accent text-accent-foreground px-4 py-1.5 flex items-center justify-between text-sm font-medium animate-in slide-in-from-top duration-300">
<div className="flex items-center gap-2">
<BookHeart className="size-4" />
@@ -227,14 +244,7 @@ export default function Home({
<Terminal className="size-4" />
</button>
{spellbookName && (
<div className="flex items-center gap-2 px-2 py-0.5 rounded-full bg-accent/10 border border-accent/20 text-accent animate-in fade-in slide-in-from-top-1 duration-500">
<Book className="size-3" />
<span className="text-xs font-medium tracking-tight truncate max-w-[200px]">
{spellbookName}
</span>
</div>
)}
<SpellbookDropdown />
<UserMenu />
</header>

View File

@@ -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<string, ParsedSpellbook>();
// 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 (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant="ghost"
size="sm"
className="h-7 px-2 gap-1.5 text-muted-foreground hover:text-accent"
>
<BookHeart className={cn("size-4", networkLoading && "animate-pulse text-accent")} />
<span className="text-xs font-medium hidden sm:inline">Spellbooks</span>
<ChevronDown className="size-3 opacity-50" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="center" className="w-56">
<DropdownMenuLabel className="flex items-center justify-between">
<span>My Spellbooks</span>
{networkLoading && <Loader2 className="size-3 animate-spin" />}
</DropdownMenuLabel>
<DropdownMenuSeparator />
{spellbooks.length === 0 && networkLoading && (
<div className="p-4 text-center">
<Loader2 className="size-4 animate-spin mx-auto mb-2 text-muted-foreground" />
<p className="text-xs text-muted-foreground">Loading...</p>
</div>
)}
{spellbooks.map((sb) => (
<DropdownMenuItem
key={sb.slug}
onClick={() => handleApply(sb)}
className="cursor-pointer"
>
<Layout className="size-3.5 mr-2 text-muted-foreground" />
<div className="flex flex-col min-w-0">
<span className="truncate font-medium">{sb.title}</span>
<span className="text-[10px] text-muted-foreground truncate">
{Object.keys(sb.content.workspaces).length} tabs, {Object.keys(sb.content.windows).length} windows
</span>
</div>
</DropdownMenuItem>
))}
<DropdownMenuSeparator />
<DropdownMenuItem
onClick={() => addWindow("spellbooks", {})}
className="cursor-crosshair text-accent"
>
<BookHeart className="size-3.5 mr-2" />
Manage Spellbooks
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
);
}

View File

@@ -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<string | null>(null);
const [loadedSlug, setLoadedSlug] = useState<string | null>(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<any>((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 (
<div className="h-screen w-screen flex flex-col items-center justify-center bg-background text-foreground">
<Loader2 className="h-10 w-10 animate-spin text-primary mb-4" />
<p className="text-lg font-medium">Resolving spellbook...</p>
<p className="text-muted-foreground mt-2">@{user}/{identifier}</p>
</div>
);
}
if (error) {
return (
<div className="h-screen w-screen flex flex-col items-center justify-center bg-background text-foreground p-6 text-center">
<AlertCircle className="h-12 w-12 text-destructive mb-4" />
<h1 className="text-2xl font-bold mb-2">Failed to load spellbook</h1>
<p className="text-muted-foreground max-w-md mb-6">{error}</p>
<div className="flex gap-4">
<Button onClick={() => window.location.reload()}>Retry</Button>
<Button variant="outline" asChild>
<a href="/">Go to Home</a>
</Button>
</div>
</div>
);
}
// 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 <Home spellbookName={loadedSlug} />;
}

View File

@@ -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 (

View File

@@ -6,6 +6,10 @@ const router = createBrowserRouter([
path: "/",
element: <Home />,
},
{
path: "/preview/:actor/:identifier",
element: <Home />,
},
{
path: "/:actor/:identifier",
element: <Home />,