From 005605b385832ba93983cb3574ff4d7e30cb47ef Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alejandro=20G=C3=B3mez?= Date: Sun, 21 Dec 2025 13:54:04 +0100 Subject: [PATCH] feat: enhance preview route and add conflict resolution for spellbooks MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Improve the preview route UX with better loading states, error handling, and metadata display. Add version comparison logic and conflict resolution dialog for handling local vs network spellbook conflicts. Changes: - Enhanced preview route in Home.tsx: - Add loading state with spinner while resolving actor - Add NIP-05 resolution timeout (10 seconds) - Display error banner for resolution failures - Show author name and creation date in preview banner - Add copy link button to share spellbook easily - Improve banner layout with metadata - Add compareSpellbookVersions() in spellbook-manager.ts: - Detects conflicts between local and network versions - Compares timestamps, workspace counts, window counts - Identifies newer version and content differences - Returns structured comparison data - Create ConflictResolutionDialog component: - Side-by-side comparison of local vs network versions - Shows metadata: timestamps, counts, author, publish status - Clear explanation of resolution choices - Accessible UI with proper button hierarchy TypeScript compilation successful ✅ 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- src/components/ConflictResolutionDialog.tsx | 204 ++++++++++++++++++++ src/components/Home.tsx | 101 +++++++++- src/lib/spellbook-manager.ts | 83 ++++++++ 3 files changed, 379 insertions(+), 9 deletions(-) create mode 100644 src/components/ConflictResolutionDialog.tsx diff --git a/src/components/ConflictResolutionDialog.tsx b/src/components/ConflictResolutionDialog.tsx new file mode 100644 index 0000000..48c86e4 --- /dev/null +++ b/src/components/ConflictResolutionDialog.tsx @@ -0,0 +1,204 @@ +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from "./ui/dialog"; +import { Button } from "./ui/button"; +import { Badge } from "./ui/badge"; +import { AlertTriangle, Check, Clock, Layers, Layout } from "lucide-react"; +import type { LocalSpellbook } from "@/services/db"; +import type { ParsedSpellbook } from "@/types/spell"; +import { compareSpellbookVersions } from "@/lib/spellbook-manager"; +import { useProfile } from "@/hooks/useProfile"; + +interface ConflictResolutionDialogProps { + open: boolean; + onOpenChange: (open: boolean) => void; + localSpellbook: LocalSpellbook; + networkSpellbook: ParsedSpellbook; + onResolve: (choice: "local" | "network") => void; +} + +export function ConflictResolutionDialog({ + open, + onOpenChange, + localSpellbook, + networkSpellbook, + onResolve, +}: ConflictResolutionDialogProps) { + const comparison = compareSpellbookVersions( + { + createdAt: localSpellbook.createdAt, + content: localSpellbook.content, + eventId: localSpellbook.eventId, + }, + { + created_at: networkSpellbook.event?.created_at || 0, + content: networkSpellbook.content, + id: networkSpellbook.event?.id || "", + } + ); + + const authorProfile = useProfile(networkSpellbook.event?.pubkey); + + const formatDate = (timestamp: number) => { + const date = new Date(timestamp); + return date.toLocaleString(undefined, { + month: "short", + day: "numeric", + year: "numeric", + hour: "numeric", + minute: "2-digit", + }); + }; + + const handleResolve = (choice: "local" | "network") => { + onResolve(choice); + onOpenChange(false); + }; + + return ( + + + + + + Spellbook Conflict Detected + + + Your local version differs from the network version. Choose which + version to keep. + + + +
+ {/* Local Version */} +
+
+

Local Version

+ {comparison.newerVersion === "local" && ( + + Newer + + )} +
+ +
+
+ + {formatDate(comparison.differences.lastModified.local)} +
+ +
+ + {comparison.differences.workspaceCount.local} workspaces +
+ +
+ + {comparison.differences.windowCount.local} windows +
+ + {localSpellbook.isPublished ? ( + + Published + + ) : ( + + Local Only + + )} +
+
+ + {/* Network Version */} +
+
+

Network Version

+ {comparison.newerVersion === "network" && ( + + Newer + + )} +
+ +
+
+ + {formatDate(comparison.differences.lastModified.network)} +
+ +
+ + + {comparison.differences.workspaceCount.network} workspaces + +
+ +
+ + {comparison.differences.windowCount.network} windows +
+ + {authorProfile && ( +
+ by {authorProfile.name || "Unknown"} +
+ )} +
+
+
+ +
+
+ +
+

What happens when you choose?

+
    +
  • + Local: Keep your local changes and discard + network version +
  • +
  • + Network: Replace local with network version + (local changes lost) +
  • +
+
+
+
+ + + +
+ + +
+
+
+
+ ); +} diff --git a/src/components/Home.tsx b/src/components/Home.tsx index cfc7403..6c333d5 100644 --- a/src/components/Home.tsx +++ b/src/components/Home.tsx @@ -3,13 +3,14 @@ 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 } from "lucide-react"; +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"; @@ -40,6 +41,8 @@ export default function Home() { // 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; @@ -52,6 +55,8 @@ export default function Home() { 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(); @@ -59,18 +64,39 @@ export default function Home() { } const resolve = async () => { + setIsResolving(true); + setResolutionError(null); + try { if (actor.startsWith("npub")) { const { data } = nip19.decode(actor); setResolvedPubkey(data as string); } else if (isNip05(actor)) { - const pubkey = await resolveNip05(actor); + // Add timeout for NIP-05 resolution + const timeoutPromise = new Promise((_, reject) => + setTimeout(() => reject(new Error("NIP-05 resolution timeout")), 10000) + ); + const pubkey = await Promise.race([ + resolveNip05(actor), + timeoutPromise, + ]); setResolvedPubkey(pubkey); } else if (actor.length === 64) { setResolvedPubkey(actor); + } else { + setResolutionError(`Invalid actor format: ${actor}`); } } catch (e) { console.error("Failed to resolve actor:", actor, e); + 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", + }); + } finally { + setIsResolving(false); } }; @@ -89,6 +115,9 @@ export default function Home() { const spellbookEvent = useNostrEvent(pointer); + // Get author profile for banner + const authorProfile = useProfile(resolvedPubkey || undefined); + // 3. Apply preview/layout when event is loaded useEffect(() => { if (spellbookEvent && !hasLoadedSpellbook) { @@ -129,6 +158,32 @@ export default function Home() { navigate("/", { replace: true }); }; + const handleCopyLink = () => { + if (!actor || !identifier) return; + const link = `${window.location.origin}/preview/${actor}/${identifier}`; + 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) { + const minutes = Math.floor(diff / (60 * 1000)); + return minutes === 0 ? "just now" : `${minutes}m ago`; + } + return `${hours}h ago`; + } + + // Otherwise show date + return date.toLocaleDateString(undefined, { month: 'short', day: 'numeric', year: 'numeric' }); + }; + // Sync active account and fetch relay lists useAccountSync(); @@ -201,15 +256,32 @@ export default function Home() {
{showBanner && (
-
- - - {isPreviewPath ? "Preview Mode" : "Temporary Layout"}:{" "} - {spellbookEvent?.tags.find((t) => t[0] === "title")?.[1] || - "Spellbook"} - +
+ +
+ + {spellbookEvent?.tags.find((t) => t[0] === "title")?.[1] || + "Spellbook"} + + {spellbookEvent && ( + + {authorProfile?.name || resolvedPubkey?.slice(0, 8)} + + {formatTimestamp(spellbookEvent.created_at)} + + )} +
+
)} + {isResolving && ( +
+ + Resolving {actor}... +
+ )} + {resolutionError && ( +
+ Failed to resolve actor: {resolutionError} +
+ )}