mirror of
https://github.com/purrgrammer/grimoire.git
synced 2026-04-09 23:16:50 +02:00
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:
204
src/components/ConflictResolutionDialog.tsx
Normal file
204
src/components/ConflictResolutionDialog.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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)}
|
||||
|
||||
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user