From 3c62a0e236db71571465384b3413e3037f440c1d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alejandro=20G=C3=B3mez?= Date: Sun, 21 Dec 2025 22:18:37 +0100 Subject: [PATCH] ui: share spellbook dialog improvements --- src/components/ShareSpellbookDialog.tsx | 286 ++++++------------ src/components/Spellbook.tsx | 0 src/components/WorkspaceView.tsx | 67 ++++ src/components/layouts/AppShell.tsx | 78 +++++ src/components/layouts/AppShellContext.ts | 11 + src/components/pages/DashboardPage.tsx | 15 + .../{Home.tsx => pages/SpellbookPage.tsx} | 259 +++++----------- src/root.tsx | 24 +- 8 files changed, 362 insertions(+), 378 deletions(-) create mode 100644 src/components/Spellbook.tsx create mode 100644 src/components/WorkspaceView.tsx create mode 100644 src/components/layouts/AppShell.tsx create mode 100644 src/components/layouts/AppShellContext.ts create mode 100644 src/components/pages/DashboardPage.tsx rename src/components/{Home.tsx => pages/SpellbookPage.tsx} (51%) diff --git a/src/components/ShareSpellbookDialog.tsx b/src/components/ShareSpellbookDialog.tsx index 55544f9..bce47bb 100644 --- a/src/components/ShareSpellbookDialog.tsx +++ b/src/components/ShareSpellbookDialog.tsx @@ -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(null); - const [qrCodeUrl, setQrCodeUrl] = useState(""); - const [selectedFormat, setSelectedFormat] = useState("web"); - const canvasRef = useRef(null); + const [copiedLink, setCopiedLink] = useState(null); + const [naddr, setNaddr] = useState(""); 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 ( - + Share Spellbook - Share "{spellbook.title}" using any of the formats below + Share "{spellbook.title}" with others
- {/* Format Tabs */} -
- {formats.map((format) => ( - - ))} + {copiedLink === "Link" ? ( + + ) : ( + + )} + +
+

+ Direct link to view this spellbook in Grimoire +

- {/* Selected Format Content */} - {selectedFormatData && ( -
-

- {selectedFormatData.description} -

- - {/* Value Display with Copy Button */} -
-
- {currentValue} -
- -
- - {/* QR Code */} -
-
- - QR Code -
- - - - -
-
- )} - - - {/* Quick Copy All Formats */} -
-

- Quick Copy -

-
- {formats.map((format) => ( + {/* Nostr ID (naddr) */} +
+ +
+ - ))} +
+

+ Universal identifier (naddr) for use in other Nostr clients +

); -} +} \ No newline at end of file diff --git a/src/components/Spellbook.tsx b/src/components/Spellbook.tsx new file mode 100644 index 0000000..e69de29 diff --git a/src/components/WorkspaceView.tsx b/src/components/WorkspaceView.tsx new file mode 100644 index 0000000..b3692bd --- /dev/null +++ b/src/components/WorkspaceView.tsx @@ -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 ( + } + > +
+ Window not found: {id} +
+
+ ); + } + + return ( + + ); + }; + + const activeWorkspace = state.workspaces[state.activeWorkspaceId]; + + if (!activeWorkspace) return null; + + return ( + <> + {activeWorkspace.layout === null ? ( + + ) : ( + { + if (typeof node === "string") { + handleRemoveWindow(node); + } + }} + className="mosaic-blueprint-theme" + /> + )} + + ); +} diff --git a/src/components/layouts/AppShell.tsx b/src/components/layouts/AppShell.tsx new file mode 100644 index 0000000..7a7a3a4 --- /dev/null +++ b/src/components/layouts/AppShell.tsx @@ -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 ( + + + +
+
+ + +
+ +
+ + +
+
{children}
+ +
+
+ ); +} \ No newline at end of file diff --git a/src/components/layouts/AppShellContext.ts b/src/components/layouts/AppShellContext.ts new file mode 100644 index 0000000..983f72a --- /dev/null +++ b/src/components/layouts/AppShellContext.ts @@ -0,0 +1,11 @@ +import { createContext, useContext } from "react"; + +interface AppShellContextType { + openCommandLauncher: () => void; +} + +export const AppShellContext = createContext({ + openCommandLauncher: () => {}, +}); + +export const useAppShell = () => useContext(AppShellContext); diff --git a/src/components/pages/DashboardPage.tsx b/src/components/pages/DashboardPage.tsx new file mode 100644 index 0000000..7b6f40a --- /dev/null +++ b/src/components/pages/DashboardPage.tsx @@ -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 ; +} diff --git a/src/components/Home.tsx b/src/components/pages/SpellbookPage.tsx similarity index 51% rename from src/components/Home.tsx rename to src/components/pages/SpellbookPage.tsx index 3049ecb..40da38b 100644 --- a/src/components/Home.tsx +++ b/src/components/pages/SpellbookPage.tsx @@ -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(null); const [resolutionError, setResolutionError] = useState(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((_, 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 ( - } - > -
- Window not found: {id} -
-
- ); - } - - return ( - setCommandLauncherOpen(true)} - /> - ); - }; - return ( - <> - - -
- {showBanner && ( -
+
+ {/* Banner Layer */} + {showBanner && ( +
@@ -310,57 +248,24 @@ export default function Home() {
)} - {isResolving && ( -
+ + {/* Loading States */} + {isResolving && ( +
Resolving {actor}...
- )} - {resolutionError && ( -
+ )} + {resolutionError && ( +
Failed to resolve actor: {resolutionError}
- )} -
- + )} -
- -
- - -
-
- {state.workspaces[state.activeWorkspaceId] && ( - <> - {state.workspaces[state.activeWorkspaceId].layout === null ? ( - setCommandLauncherOpen(true)} - /> - ) : ( - { - // When Mosaic removes a node from the layout, clean up the window - if (typeof node === "string") { - handleRemoveWindow(node); - } - }} - className="mosaic-blueprint-theme" - /> - )} - - )} -
- -
- + {/* Main Content */} +
+ +
+ ); } diff --git a/src/root.tsx b/src/root.tsx index 8b2e53b..0fb5a3a 100644 --- a/src/root.tsx +++ b/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: , + element: ( + + + + ), }, { path: "/preview/:actor/:identifier", - element: , + element: ( + + + + ), }, { path: "/:actor/:identifier", - element: , + element: ( + + + + ), }, ]); export default function Root() { return ; -} +} \ No newline at end of file