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} +
+ )}