mirror of
https://github.com/purrgrammer/grimoire.git
synced 2026-06-05 10:11:12 +02:00
ui: share spellbook dialog improvements
This commit is contained in:
@@ -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>
|
||||
);
|
||||
}
|
||||
}
|
||||
0
src/components/Spellbook.tsx
Normal file
0
src/components/Spellbook.tsx
Normal file
67
src/components/WorkspaceView.tsx
Normal file
67
src/components/WorkspaceView.tsx
Normal 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"
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
78
src/components/layouts/AppShell.tsx
Normal file
78
src/components/layouts/AppShell.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
11
src/components/layouts/AppShellContext.ts
Normal file
11
src/components/layouts/AppShellContext.ts
Normal 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);
|
||||
15
src/components/pages/DashboardPage.tsx
Normal file
15
src/components/pages/DashboardPage.tsx
Normal 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 />;
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
24
src/root.tsx
24
src/root.tsx
@@ -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} />;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user