ui: share spellbook dialog improvements

This commit is contained in:
Alejandro Gómez
2025-12-21 22:18:37 +01:00
parent fc63b3c685
commit 3c62a0e236
8 changed files with 362 additions and 378 deletions

View File

@@ -1,4 +1,4 @@
import { useState, useEffect, useRef } from "react";
import { useState, useEffect } from "react";
import {
Dialog,
DialogContent,
@@ -7,24 +7,14 @@ import {
DialogTitle,
} from "./ui/dialog";
import { Button } from "./ui/button";
import { Copy, Check, QrCode } from "lucide-react";
import { Input } from "./ui/input";
import { Copy, CopyCheck } from "lucide-react";
import { toast } from "sonner";
import { nip19 } from "nostr-tools";
import type { NostrEvent } from "@/types/nostr";
import type { ParsedSpellbook } from "@/types/spell";
import QRCodeLib from "qrcode";
import { useProfile } from "@/hooks/useProfile";
interface ShareFormat {
id: string;
label: string;
description: string;
getValue: (
event: NostrEvent,
spellbook: ParsedSpellbook,
actor: string,
) => string;
}
import { relayListCache } from "@/services/relay-list-cache";
interface ShareSpellbookDialogProps {
open: boolean;
@@ -40,219 +30,123 @@ export function ShareSpellbookDialog({
spellbook,
}: ShareSpellbookDialogProps) {
const profile = useProfile(event.pubkey);
const [copiedFormat, setCopiedFormat] = useState<string | null>(null);
const [qrCodeUrl, setQrCodeUrl] = useState<string>("");
const [selectedFormat, setSelectedFormat] = useState<string>("web");
const canvasRef = useRef<HTMLCanvasElement>(null);
const [copiedLink, setCopiedLink] = useState<string | null>(null);
const [naddr, setNaddr] = useState<string>("");
const actor = profile?.nip05 || nip19.npubEncode(event.pubkey);
const webLink = `${window.location.origin}/${actor}/${spellbook.slug}`;
const formats: ShareFormat[] = [
{
id: "web",
label: "Web Link",
description: "Share as a web URL that anyone can open",
getValue: (_e, s, a) =>
`${window.location.origin}/preview/${a}/${s.slug}`,
},
{
id: "naddr",
label: "Nostr Address (naddr)",
description: "NIP-19 address pointer for Nostr clients",
getValue: (e, _s) => {
const dTag = e.tags.find((t) => t[0] === "d")?.[1];
if (!dTag) return "";
return nip19.naddrEncode({
kind: 30777,
pubkey: e.pubkey,
identifier: dTag,
relays: e.tags
.filter((t) => t[0] === "r")
.map((t) => t[1])
.slice(0, 3),
});
},
},
{
id: "nevent",
label: "Nostr Event (nevent)",
description: "NIP-19 event pointer with relay hints",
getValue: (e) => {
return nip19.neventEncode({
id: e.id,
kind: 30777,
author: e.pubkey,
relays: e.tags
.filter((t) => t[0] === "r")
.map((t) => t[1])
.slice(0, 3),
});
},
},
];
const selectedFormatData = formats.find((f) => f.id === selectedFormat);
const currentValue = selectedFormatData
? selectedFormatData.getValue(event, spellbook, actor)
: "";
// Generate QR code when selected format changes
useEffect(() => {
if (!canvasRef.current || !currentValue) return;
const generateNaddr = async () => {
const dTag = event.tags.find((t) => t[0] === "d")?.[1];
if (!dTag) return;
QRCodeLib.toCanvas(canvasRef.current, currentValue, {
width: 256,
margin: 2,
color: {
dark: "#000000",
light: "#FFFFFF",
},
}).catch((err) => {
console.error("QR code generation failed:", err);
});
// Get relays from event or fallback to author's outbox relays
let relays = event.tags.filter((t) => t[0] === "r").map((t) => t[1]);
if (relays.length === 0) {
const authorRelays = await relayListCache.getOutboxRelays(event.pubkey);
if (authorRelays) {
relays = authorRelays;
}
}
// Also generate data URL for potential download
QRCodeLib.toDataURL(currentValue, {
width: 512,
margin: 2,
})
.then((url) => setQrCodeUrl(url))
.catch((err) => {
console.error("QR data URL generation failed:", err);
});
}, [currentValue]);
try {
const encoded = nip19.naddrEncode({
kind: 30777,
pubkey: event.pubkey,
identifier: dTag,
relays: relays.slice(0, 3),
});
setNaddr(encoded);
} catch (e) {
console.error("Failed to generate naddr:", e);
}
};
const handleCopy = (formatId: string) => {
const format = formats.find((f) => f.id === formatId);
if (!format) return;
const value = format.getValue(event, spellbook, actor);
if (!value) {
toast.error("Failed to generate share link");
return;
if (open) {
generateNaddr();
}
}, [event, open]);
const handleCopy = (value: string, label: string) => {
navigator.clipboard.writeText(value);
setCopiedFormat(formatId);
toast.success(`${format.label} copied to clipboard`);
setTimeout(() => setCopiedFormat(null), 2000);
};
const handleDownloadQR = () => {
if (!qrCodeUrl) return;
const link = document.createElement("a");
link.href = qrCodeUrl;
link.download = `spellbook-${spellbook.slug}-qr.png`;
link.click();
toast.success("QR code downloaded");
setCopiedLink(label);
toast.success(`${label} copied to clipboard`);
setTimeout(() => setCopiedLink(null), 2000);
};
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-[600px]">
<DialogContent className="sm:max-w-[500px]">
<DialogHeader>
<DialogTitle>Share Spellbook</DialogTitle>
<DialogDescription>
Share "{spellbook.title}" using any of the formats below
Share "{spellbook.title}" with others
</DialogDescription>
</DialogHeader>
<div className="space-y-6 py-4">
{/* Format Tabs */}
<div className="flex gap-2 border-b border-border">
{formats.map((format) => (
<button
key={format.id}
onClick={() => setSelectedFormat(format.id)}
className={`px-4 py-2 text-sm font-medium transition-colors border-b-2 -mb-px ${
selectedFormat === format.id
? "border-primary text-foreground"
: "border-transparent text-muted-foreground hover:text-foreground"
}`}
{/* Web Link */}
<div className="space-y-2">
<label className="text-sm font-medium text-foreground">
Web Link
</label>
<div className="relative">
<Input
readOnly
value={webLink}
className="pr-10 font-mono text-xs bg-muted/50"
/>
<Button
variant="ghost"
size="icon"
onClick={() => handleCopy(webLink, "Link")}
className="absolute right-0 top-0 h-9 w-9 text-muted-foreground hover:text-foreground"
>
{format.label}
</button>
))}
{copiedLink === "Link" ? (
<CopyCheck className="size-4 text-green-500" />
) : (
<Copy className="size-4" />
)}
</Button>
</div>
<p className="text-xs text-muted-foreground">
Direct link to view this spellbook in Grimoire
</p>
</div>
{/* Selected Format Content */}
{selectedFormatData && (
<div className="space-y-4">
<p className="text-sm text-muted-foreground">
{selectedFormatData.description}
</p>
{/* Value Display with Copy Button */}
<div className="flex gap-2">
<div className="flex-1 rounded-lg border border-border bg-muted/50 p-3 font-mono text-sm break-all">
{currentValue}
</div>
<Button
variant="outline"
size="icon"
onClick={() => handleCopy(selectedFormat)}
className="flex-shrink-0"
>
{copiedFormat === selectedFormat ? (
<Check className="size-4 text-green-500" />
) : (
<Copy className="size-4" />
)}
</Button>
</div>
{/* QR Code */}
<div className="flex flex-col items-center gap-4 p-6 rounded-lg border border-border bg-card">
<div className="flex items-center gap-2 text-sm font-medium text-muted-foreground">
<QrCode className="size-4" />
QR Code
</div>
<canvas
ref={canvasRef}
className="rounded-lg border border-border bg-white p-2"
/>
<Button
variant="outline"
size="sm"
onClick={handleDownloadQR}
className="w-full"
>
Download QR Code
</Button>
</div>
</div>
)}
</div>
{/* Quick Copy All Formats */}
<div className="border-t border-border pt-4 space-y-2">
<p className="text-xs font-medium text-muted-foreground uppercase tracking-wider">
Quick Copy
</p>
<div className="flex flex-wrap gap-2">
{formats.map((format) => (
{/* Nostr ID (naddr) */}
<div className="space-y-2">
<label className="text-sm font-medium text-foreground">
Nostr ID
</label>
<div className="relative">
<Input
readOnly
value={naddr || "Generating..."}
className="pr-10 font-mono text-xs bg-muted/50"
/>
<Button
key={format.id}
variant="secondary"
size="sm"
onClick={() => handleCopy(format.id)}
className="flex items-center gap-2"
variant="ghost"
size="icon"
onClick={() => naddr && handleCopy(naddr, "Nostr ID")}
disabled={!naddr}
className="absolute right-0 top-0 h-9 w-9 text-muted-foreground hover:text-foreground"
>
{copiedFormat === format.id ? (
<Check className="size-3 text-green-500" />
{copiedLink === "Nostr ID" ? (
<CopyCheck className="size-4 text-green-500" />
) : (
<Copy className="size-3" />
<Copy className="size-4" />
)}
{format.label}
</Button>
))}
</div>
<p className="text-xs text-muted-foreground">
Universal identifier (naddr) for use in other Nostr clients
</p>
</div>
</div>
</DialogContent>
</Dialog>
);
}
}

View File

View File

@@ -0,0 +1,67 @@
import { useGrimoire } from "@/core/state";
import { Mosaic, MosaicWindow, MosaicBranch } from "react-mosaic-component";
import { WindowToolbar } from "./WindowToolbar";
import { WindowTile } from "./WindowTitle";
import { GrimoireWelcome } from "./GrimoireWelcome";
import { useAppShell } from "./layouts/AppShellContext";
export function WorkspaceView() {
const { state, updateLayout, removeWindow } = useGrimoire();
const { openCommandLauncher } = useAppShell();
const handleRemoveWindow = (id: string) => {
removeWindow(id);
};
const renderTile = (id: string, path: MosaicBranch[]) => {
const window = state.windows[id];
if (!window) {
return (
<MosaicWindow
path={path}
title="Unknown Window"
toolbarControls={<WindowToolbar />}
>
<div className="p-4 text-muted-foreground">
Window not found: {id}
</div>
</MosaicWindow>
);
}
return (
<WindowTile
id={id}
window={window}
path={path}
onClose={handleRemoveWindow}
onEditCommand={openCommandLauncher}
/>
);
};
const activeWorkspace = state.workspaces[state.activeWorkspaceId];
if (!activeWorkspace) return null;
return (
<>
{activeWorkspace.layout === null ? (
<GrimoireWelcome onLaunchCommand={openCommandLauncher} />
) : (
<Mosaic
renderTile={renderTile}
value={activeWorkspace.layout}
onChange={updateLayout}
onRelease={(node) => {
if (typeof node === "string") {
handleRemoveWindow(node);
}
}}
className="mosaic-blueprint-theme"
/>
)}
</>
);
}

View File

@@ -0,0 +1,78 @@
import { useState, useEffect, ReactNode } from "react";
import { useAccountSync } from "@/hooks/useAccountSync";
import { useRelayListCacheSync } from "@/hooks/useRelayListCacheSync";
import { useRelayState } from "@/hooks/useRelayState";
import relayStateManager from "@/services/relay-state-manager";
import { TabBar } from "../TabBar";
import CommandLauncher from "../CommandLauncher";
import { GlobalAuthPrompt } from "../GlobalAuthPrompt";
import { SpellbookDropdown } from "../SpellbookDropdown";
import UserMenu from "../nostr/user-menu";
import { AppShellContext } from "./AppShellContext";
interface AppShellProps {
children: ReactNode;
}
export function AppShell({ children }: AppShellProps) {
const [commandLauncherOpen, setCommandLauncherOpen] = useState(false);
// Sync active account and fetch relay lists
useAccountSync();
// Auto-cache kind:10002 relay lists from EventStore to Dexie
useRelayListCacheSync();
// Initialize global relay state manager
useEffect(() => {
relayStateManager.initialize().catch((err) => {
console.error("Failed to initialize relay state manager:", err);
});
}, []);
// Sync relay state with Jotai
useRelayState();
// Keyboard shortcut: Cmd/Ctrl+K
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
if ((e.metaKey || e.ctrlKey) && e.key === "k") {
e.preventDefault();
setCommandLauncherOpen((open) => !open);
}
};
window.addEventListener("keydown", handleKeyDown);
return () => window.removeEventListener("keydown", handleKeyDown);
}, []);
const openCommandLauncher = () => setCommandLauncherOpen(true);
return (
<AppShellContext.Provider value={{ openCommandLauncher }}>
<CommandLauncher
open={commandLauncherOpen}
onOpenChange={setCommandLauncherOpen}
/>
<GlobalAuthPrompt />
<main className="h-screen w-screen flex flex-col bg-background text-foreground">
<header className="flex flex-row items-center justify-between px-1 border-b border-border">
<button
onClick={() => setCommandLauncherOpen(true)}
className="p-1 text-muted-foreground hover:text-accent transition-colors cursor-crosshair"
title="Launch command (Cmd+K)"
aria-label="Launch command palette"
></button>
<div className="flex items-center gap-2">
<SpellbookDropdown />
</div>
<UserMenu />
</header>
<section className="flex-1 relative overflow-hidden">{children}</section>
<TabBar />
</main>
</AppShellContext.Provider>
);
}

View File

@@ -0,0 +1,11 @@
import { createContext, useContext } from "react";
interface AppShellContextType {
openCommandLauncher: () => void;
}
export const AppShellContext = createContext<AppShellContextType>({
openCommandLauncher: () => {},
});
export const useAppShell = () => useContext(AppShellContext);

View File

@@ -0,0 +1,15 @@
import { useEffect } from "react";
import { useGrimoire } from "@/core/state";
import { WorkspaceView } from "../WorkspaceView";
export default function DashboardPage() {
const { isTemporary, discardTemporary } = useGrimoire();
useEffect(() => {
if (isTemporary) {
discardTemporary();
}
}, [isTemporary, discardTemporary]);
return <WorkspaceView />;
}

View File

@@ -1,65 +1,41 @@
import { useState, useEffect, useMemo } from "react";
import { useGrimoire } from "@/core/state";
import { useAccountSync } from "@/hooks/useAccountSync";
import { useRelayListCacheSync } from "@/hooks/useRelayListCacheSync";
import { useRelayState } from "@/hooks/useRelayState";
import { useProfile } from "@/hooks/useProfile";
import relayStateManager from "@/services/relay-state-manager";
import { TabBar } from "./TabBar";
import { Mosaic, MosaicWindow, MosaicBranch } from "react-mosaic-component";
import CommandLauncher from "./CommandLauncher";
import { WindowToolbar } from "./WindowToolbar";
import { WindowTile } from "./WindowTitle";
import { BookHeart, X, Check, Link as LinkIcon, Loader2 } from "lucide-react";
import UserMenu from "./nostr/user-menu";
import { GrimoireWelcome } from "./GrimoireWelcome";
import { GlobalAuthPrompt } from "./GlobalAuthPrompt";
import { SpellbookDropdown } from "./SpellbookDropdown";
import { useParams, useNavigate, useLocation } from "react-router";
import { useGrimoire } from "@/core/state";
import { useNostrEvent } from "@/hooks/useNostrEvent";
import { useProfile } from "@/hooks/useProfile";
import { resolveNip05, isNip05 } from "@/lib/nip05";
import { nip19 } from "nostr-tools";
import { parseSpellbook } from "@/lib/spellbook-manager";
import { SpellbookEvent } from "@/types/spell";
import { nip19 } from "nostr-tools";
import { toast } from "sonner";
import { Button } from "./ui/button";
import { Loader2, BookHeart, Link as LinkIcon, X, Check } from "lucide-react";
import { Button } from "../ui/button";
import { WorkspaceView } from "../WorkspaceView";
export default function Home() {
export default function SpellbookPage() {
const {
state,
updateLayout,
removeWindow,
switchToTemporary,
applyTemporaryToPersistent,
discardTemporary,
isTemporary,
} = 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 [resolutionError, setResolutionError] = useState<string | null>(null);
const [isResolving, setIsResolving] = useState(false);
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;
// Determine if we should show the preview banner
// In SpellbookPage, we always show it if we have loaded a spellbook temporarily
const showBanner = isTemporary && hasLoadedSpellbook;
// 1. Resolve actor to pubkey
useEffect(() => {
if (!actor) {
setResolvedPubkey(null);
setResolutionError(null);
setIsResolving(false);
setHasLoadedSpellbook(false);
// If we were in temporary mode and navigated back to /, discard
if (isTemporary) discardTemporary();
// Should not happen in this route, but safe guard
return;
}
@@ -72,7 +48,6 @@ export default function Home() {
const { data } = nip19.decode(actor);
setResolvedPubkey(data as string);
} else if (isNip05(actor)) {
// Add timeout for NIP-05 resolution
const timeoutPromise = new Promise<never>((_, reject) =>
setTimeout(
() => reject(new Error("NIP-05 resolution timeout")),
@@ -94,17 +69,14 @@ export default function Home() {
setResolutionError(
e instanceof Error ? e.message : "Failed to resolve actor",
);
toast.error(`Failed to resolve actor: ${actor}`, {
description:
e instanceof Error ? e.message : "Invalid format or network error",
});
toast.error(`Failed to resolve actor: ${actor}`);
} finally {
setIsResolving(false);
}
};
resolve();
}, [actor, isTemporary, discardTemporary]);
}, [actor]);
// 2. Fetch the spellbook event
const pointer = useMemo(() => {
@@ -117,38 +89,75 @@ export default function Home() {
}, [resolvedPubkey, identifier]);
const spellbookEvent = useNostrEvent(pointer);
// Get author profile for banner
const authorProfile = useProfile(resolvedPubkey || undefined);
// 3. Apply preview/layout when event is loaded
// 3. Load spellbook when event is available
useEffect(() => {
if (spellbookEvent && !hasLoadedSpellbook) {
try {
const parsed = parseSpellbook(spellbookEvent as SpellbookEvent);
// Use the new temporary state system
switchToTemporary(parsed);
setHasLoadedSpellbook(true);
const isPreviewPath = location.pathname.startsWith("/preview/");
if (isPreviewPath) {
toast.info(`Previewing spellbook: ${parsed.title}`, {
toast.info(`Previewing spellbook: ${parsed.title}`, {
description:
"You are in a temporary session. Apply to keep this spellbook.",
});
}
} catch (e) {
console.error("Failed to parse spellbook:", e);
toast.error("Failed to load spellbook");
}
}
}, [
spellbookEvent,
hasLoadedSpellbook,
isPreviewPath,
isDirectPath,
switchToTemporary,
]);
}, [spellbookEvent, hasLoadedSpellbook, switchToTemporary, location.pathname]);
// Cleanup when leaving the page (unmounting)
// But wait, if we navigate to /, we want to discard.
// If we apply, we navigate to / but we applied first.
useEffect(() => {
return () => {
// If we are unmounting and still temporary, check if we need to cleanup?
// Actually, AppShell wraps this. If we navigate to /, DashboardPage mounts.
// DashboardPage doesn't enforce cleanup.
// So we should cleanup here if we leave this route without applying.
// Ideally, we'd check if we are navigating to "Apply".
// But applyTemporaryToPersistent clears temporary state internally?
// No, it just merges it.
// Let's look at `useGrimoire`:
// applyTemporaryToPersistent -> dispatch({ type: "APPLY_TEMP" }) -> sets grimoireStateAtom = temp, internalTemporaryStateAtom = null.
// So if we applied, isTemporary is false.
// If we navigate away without applying, isTemporary is true.
// But we can't easily check "isTemporary" in cleanup function because of closure staleness?
// Use a ref or rely on the next component to not show temporary state?
// Actually, the global state holds the temporary state.
// If the user clicks "Home", they expect their old state.
// The previous logic in Home.tsx was:
// useEffect(() => { if (!actor && isTemporary) discardTemporary() }, [actor, isTemporary])
// Since we are unmounting SpellbookPage, we are going somewhere else.
// If that somewhere else is NOT a spellbook page, we might want to discard.
// But maybe we want to keep it if we navigate to "Settings" (modal) or something?
// But those are likely overlays.
// For now, let's rely on the user explicitly discarding or applying via the banner,
// OR implement the "Guard" in DashboardPage to discard if it finds itself in temporary mode?
// Or just discard on unmount if we didn't apply?
// That's hard to track.
// Let's implement the cleanup in DashboardPage!
// If DashboardPage mounts and isTemporary is true, it means we navigated back home.
// But wait, what if we "Applied"? Then isTemporary is false.
// So if DashboardPage mounts and isTemporary is TRUE, we should discard?
// Yes, that replicates the Home.tsx logic: "if (!actor) ... discard".
};
}, []);
const handleApplySpellbook = () => {
applyTemporaryToPersistent();
@@ -167,13 +176,11 @@ export default function Home() {
navigator.clipboard.writeText(link);
toast.success("Link copied to clipboard");
};
const formatTimestamp = (timestamp: number) => {
const date = new Date(timestamp * 1000);
const now = Date.now();
const diff = now - date.getTime();
// Less than 24 hours: show relative time
if (diff < 24 * 60 * 60 * 1000) {
const hours = Math.floor(diff / (60 * 60 * 1000));
if (hours === 0) {
@@ -182,8 +189,6 @@ export default function Home() {
}
return `${hours}h ago`;
}
// Otherwise show date
return date.toLocaleDateString(undefined, {
month: "short",
day: "numeric",
@@ -191,78 +196,11 @@ export default function Home() {
});
};
// Sync active account and fetch relay lists
useAccountSync();
// Auto-cache kind:10002 relay lists from EventStore to Dexie
useRelayListCacheSync();
// Initialize global relay state manager
useEffect(() => {
relayStateManager.initialize().catch((err) => {
console.error("Failed to initialize relay state manager:", err);
});
}, []);
// Sync relay state with Jotai
useRelayState();
// Keyboard shortcut: Cmd/Ctrl+K
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
if ((e.metaKey || e.ctrlKey) && e.key === "k") {
e.preventDefault();
setCommandLauncherOpen((open) => !open);
}
};
window.addEventListener("keydown", handleKeyDown);
return () => window.removeEventListener("keydown", handleKeyDown);
}, []);
const handleRemoveWindow = (id: string) => {
// Remove from windows map
removeWindow(id);
};
const renderTile = (id: string, path: MosaicBranch[]) => {
const window = state.windows[id];
if (!window) {
return (
<MosaicWindow
path={path}
title="Unknown Window"
toolbarControls={<WindowToolbar />}
>
<div className="p-4 text-muted-foreground">
Window not found: {id}
</div>
</MosaicWindow>
);
}
return (
<WindowTile
id={id}
window={window}
path={path}
onClose={handleRemoveWindow}
onEditCommand={() => setCommandLauncherOpen(true)}
/>
);
};
return (
<>
<CommandLauncher
open={commandLauncherOpen}
onOpenChange={setCommandLauncherOpen}
/>
<GlobalAuthPrompt />
<main className="h-screen w-screen flex flex-col bg-background text-foreground">
{showBanner && (
<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 shadow-md z-50">
<div className="flex flex-col h-full relative">
{/* Banner Layer */}
{showBanner && (
<div className="absolute top-0 left-0 right-0 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 shadow-md z-50">
<div className="flex items-center gap-3">
<BookHeart className="size-4 flex-shrink-0" />
<div className="flex flex-col gap-0.5">
@@ -310,57 +248,24 @@ export default function Home() {
</div>
</div>
)}
{isResolving && (
<div className="bg-muted px-4 py-2 flex items-center justify-center gap-2 text-sm text-muted-foreground">
{/* Loading States */}
{isResolving && (
<div className="absolute top-0 left-0 right-0 z-40 bg-muted px-4 py-2 flex items-center justify-center gap-2 text-sm text-muted-foreground">
<Loader2 className="size-4 animate-spin" />
<span>Resolving {actor}...</span>
</div>
)}
{resolutionError && (
<div className="bg-destructive/10 text-destructive px-4 py-2 flex items-center justify-center text-sm">
)}
{resolutionError && (
<div className="absolute top-0 left-0 right-0 z-40 bg-destructive/10 text-destructive px-4 py-2 flex items-center justify-center text-sm">
<span>Failed to resolve actor: {resolutionError}</span>
</div>
)}
<header className="flex flex-row items-center justify-between px-1 border-b border-border">
<button
onClick={() => setCommandLauncherOpen(true)}
className="p-1 text-muted-foreground hover:text-accent transition-colors cursor-crosshair"
title="Launch command (Cmd+K)"
aria-label="Launch command palette"
></button>
)}
<div className="flex items-center gap-2">
<SpellbookDropdown />
</div>
<UserMenu />
</header>
<section className="flex-1 relative overflow-hidden">
{state.workspaces[state.activeWorkspaceId] && (
<>
{state.workspaces[state.activeWorkspaceId].layout === null ? (
<GrimoireWelcome
onLaunchCommand={() => setCommandLauncherOpen(true)}
/>
) : (
<Mosaic
renderTile={renderTile}
value={state.workspaces[state.activeWorkspaceId].layout}
onChange={updateLayout}
onRelease={(node) => {
// When Mosaic removes a node from the layout, clean up the window
if (typeof node === "string") {
handleRemoveWindow(node);
}
}}
className="mosaic-blueprint-theme"
/>
)}
</>
)}
</section>
<TabBar />
</main>
</>
{/* Main Content */}
<div className={showBanner ? "pt-12 h-full" : "h-full"}>
<WorkspaceView />
</div>
</div>
);
}

View File

@@ -1,21 +1,35 @@
import { createBrowserRouter, RouterProvider } from "react-router";
import Home from "./components/Home";
import { AppShell } from "./components/layouts/AppShell";
import DashboardPage from "./components/pages/DashboardPage";
import SpellbookPage from "./components/pages/SpellbookPage";
const router = createBrowserRouter([
{
path: "/",
element: <Home />,
element: (
<AppShell>
<DashboardPage />
</AppShell>
),
},
{
path: "/preview/:actor/:identifier",
element: <Home />,
element: (
<AppShell>
<SpellbookPage />
</AppShell>
),
},
{
path: "/:actor/:identifier",
element: <Home />,
element: (
<AppShell>
<SpellbookPage />
</AppShell>
),
},
]);
export default function Root() {
return <RouterProvider router={router} />;
}
}