feat: enhance preview route and add conflict resolution for spellbooks

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 <noreply@anthropic.com>
This commit is contained in:
Alejandro Gómez
2025-12-21 13:54:04 +01:00
parent 784add4f52
commit 005605b385
3 changed files with 379 additions and 9 deletions

View File

@@ -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 (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-[600px]">
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<AlertTriangle className="size-5 text-warning" />
Spellbook Conflict Detected
</DialogTitle>
<DialogDescription>
Your local version differs from the network version. Choose which
version to keep.
</DialogDescription>
</DialogHeader>
<div className="grid grid-cols-2 gap-4 py-4">
{/* Local Version */}
<div className="space-y-3 rounded-lg border border-border p-4">
<div className="flex items-center justify-between">
<h3 className="font-semibold text-sm">Local Version</h3>
{comparison.newerVersion === "local" && (
<Badge variant="secondary" className="text-xs">
Newer
</Badge>
)}
</div>
<div className="space-y-2 text-sm">
<div className="flex items-center gap-2 text-muted-foreground">
<Clock className="size-4" />
<span>{formatDate(comparison.differences.lastModified.local)}</span>
</div>
<div className="flex items-center gap-2 text-muted-foreground">
<Layers className="size-4" />
<span>{comparison.differences.workspaceCount.local} workspaces</span>
</div>
<div className="flex items-center gap-2 text-muted-foreground">
<Layout className="size-4" />
<span>{comparison.differences.windowCount.local} windows</span>
</div>
{localSpellbook.isPublished ? (
<Badge variant="outline" className="text-xs">
Published
</Badge>
) : (
<Badge variant="outline" className="text-xs">
Local Only
</Badge>
)}
</div>
</div>
{/* Network Version */}
<div className="space-y-3 rounded-lg border border-border p-4">
<div className="flex items-center justify-between">
<h3 className="font-semibold text-sm">Network Version</h3>
{comparison.newerVersion === "network" && (
<Badge variant="secondary" className="text-xs">
Newer
</Badge>
)}
</div>
<div className="space-y-2 text-sm">
<div className="flex items-center gap-2 text-muted-foreground">
<Clock className="size-4" />
<span>{formatDate(comparison.differences.lastModified.network)}</span>
</div>
<div className="flex items-center gap-2 text-muted-foreground">
<Layers className="size-4" />
<span>
{comparison.differences.workspaceCount.network} workspaces
</span>
</div>
<div className="flex items-center gap-2 text-muted-foreground">
<Layout className="size-4" />
<span>{comparison.differences.windowCount.network} windows</span>
</div>
{authorProfile && (
<div className="text-xs text-muted-foreground">
by {authorProfile.name || "Unknown"}
</div>
)}
</div>
</div>
</div>
<div className="rounded-lg bg-muted p-3 text-sm">
<div className="flex items-start gap-2">
<AlertTriangle className="size-4 text-warning flex-shrink-0 mt-0.5" />
<div>
<p className="font-medium">What happens when you choose?</p>
<ul className="mt-1 space-y-1 text-xs text-muted-foreground">
<li>
<strong>Local:</strong> Keep your local changes and discard
network version
</li>
<li>
<strong>Network:</strong> Replace local with network version
(local changes lost)
</li>
</ul>
</div>
</div>
</div>
<DialogFooter className="flex gap-2 sm:justify-between">
<Button
variant="outline"
onClick={() => onOpenChange(false)}
className="sm:flex-1"
>
Cancel
</Button>
<div className="flex gap-2 sm:flex-1">
<Button
variant="secondary"
onClick={() => handleResolve("local")}
className="flex-1"
>
<Check className="size-4 mr-2" />
Keep Local
</Button>
<Button
variant="default"
onClick={() => handleResolve("network")}
className="flex-1"
>
<Check className="size-4 mr-2" />
Use Network
</Button>
</div>
</DialogFooter>
</DialogContent>
</Dialog>
);
}

View File

@@ -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<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;
@@ -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<never>((_, 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() {
<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 items-center gap-2">
<BookHeart className="size-4" />
<span>
{isPreviewPath ? "Preview Mode" : "Temporary Layout"}:{" "}
{spellbookEvent?.tags.find((t) => t[0] === "title")?.[1] ||
"Spellbook"}
</span>
<div className="flex items-center gap-3">
<BookHeart className="size-4 flex-shrink-0" />
<div className="flex flex-col gap-0.5">
<span className="font-semibold">
{spellbookEvent?.tags.find((t) => t[0] === "title")?.[1] ||
"Spellbook"}
</span>
{spellbookEvent && (
<span className="text-xs text-accent-foreground/70 flex items-center gap-2">
{authorProfile?.name || resolvedPubkey?.slice(0, 8)}
<span className="text-accent-foreground/50"></span>
{formatTimestamp(spellbookEvent.created_at)}
</span>
)}
</div>
</div>
<div className="flex items-center gap-2">
<Button
variant="ghost"
size="sm"
className="h-7 hover:bg-black/10 text-accent-foreground"
onClick={handleCopyLink}
title="Copy share link"
>
<LinkIcon className="size-3.5" />
</Button>
<Button
variant="ghost"
size="sm"
@@ -231,6 +303,17 @@ 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">
<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">
<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)}

View File

@@ -197,6 +197,89 @@ function updateLayoutIds(
};
}
/**
* Compares two spellbook versions to detect conflicts
*
* @param local - Local spellbook from Dexie
* @param network - Network spellbook from Nostr event
* @returns Comparison result with conflict status and differences
*/
export function compareSpellbookVersions(
local: {
createdAt: number;
content: SpellbookContent;
eventId?: string;
},
network: {
created_at: number;
content: SpellbookContent;
id: string;
}
): {
hasConflict: boolean;
newerVersion: "local" | "network" | "same";
differences: {
workspaceCount: { local: number; network: number };
windowCount: { local: number; network: number };
lastModified: { local: number; network: number };
contentDiffers: boolean;
};
} {
const localTimestamp = local.createdAt;
const networkTimestamp = network.created_at * 1000; // Convert to ms
// Count workspaces and windows
const localWorkspaceCount = Object.keys(local.content.workspaces).length;
const networkWorkspaceCount = Object.keys(network.content.workspaces).length;
const localWindowCount = Object.keys(local.content.windows).length;
const networkWindowCount = Object.keys(network.content.windows).length;
// Check if content differs (simple stringify comparison)
const localContentStr = JSON.stringify(local.content);
const networkContentStr = JSON.stringify(network.content);
const contentDiffers = localContentStr !== networkContentStr;
// Determine newer version
let newerVersion: "local" | "network" | "same";
if (localTimestamp > networkTimestamp) {
newerVersion = "local";
} else if (networkTimestamp > localTimestamp) {
newerVersion = "network";
} else {
newerVersion = "same";
}
// Determine if there's a conflict
// Conflict exists if:
// 1. Content differs AND
// 2. Local has been published (has eventId) AND
// 3. The event IDs don't match (different versions)
const hasConflict =
contentDiffers &&
!!local.eventId &&
local.eventId !== network.id;
return {
hasConflict,
newerVersion,
differences: {
workspaceCount: {
local: localWorkspaceCount,
network: networkWorkspaceCount,
},
windowCount: {
local: localWindowCount,
network: networkWindowCount,
},
lastModified: {
local: localTimestamp,
network: networkTimestamp,
},
contentDiffers,
},
};
}
/**
* Imports a parsed spellbook into the current state.
* Regenerates IDs to avoid collisions.