feat: complete Phase 2 network features for spellbooks

Sharing Enhancements:
- Install qrcode library for QR code generation
- Create ShareSpellbookDialog with tabbed interface
- Support multiple share formats: Web URL, naddr, nevent
- QR code generation and download for each format
- Quick copy buttons with visual feedback
- Integrated into SpellbookDetailRenderer

Network Discovery:
- Add "Discover" filter to browse spellbooks from other users
- Query AGGREGATOR_RELAYS for network spellbook discovery
- Show author names using UserName component
- Conditional UI: hide owner actions for discovered spellbooks
- Support viewing and applying layouts from the community

Preview Route Polish:
- Loading states with spinner during NIP-05 resolution
- 10-second timeout for NIP-05 resolution
- Error banners for resolution failures
- Author name and creation date in preview banner
- Copy link button in preview mode

Conflict Resolution:
- compareSpellbookVersions() function in spellbook-manager
- ConflictResolutionDialog component for version conflicts
- Side-by-side comparison of local vs network versions
- Show workspace/window counts and timestamps

🤖 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 14:09:52 +01:00
parent 6d89a9d342
commit 0f7f154b80
8 changed files with 725 additions and 106 deletions

View File

@@ -0,0 +1,253 @@
import { useState, useEffect, useRef } from "react";
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
} from "./ui/dialog";
import { Button } from "./ui/button";
import { Copy, Check, QrCode } 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;
}
interface ShareSpellbookDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
event: NostrEvent;
spellbook: ParsedSpellbook;
}
export function ShareSpellbookDialog({
open,
onOpenChange,
event,
spellbook,
}: ShareSpellbookDialogProps) {
const profile = useProfile(event.pubkey);
const [copiedFormat, setCopiedFormat] = useState<string | null>(null);
const [qrCodeUrl, setQrCodeUrl] = useState<string>("");
const [selectedFormat, setSelectedFormat] = useState<string>("web");
const canvasRef = useRef<HTMLCanvasElement>(null);
const actor = profile?.nip05 || nip19.npubEncode(event.pubkey);
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;
QRCodeLib.toCanvas(canvasRef.current, currentValue, {
width: 256,
margin: 2,
color: {
dark: "#000000",
light: "#FFFFFF",
},
}).catch((err) => {
console.error("QR code generation failed:", err);
});
// 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]);
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;
}
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");
};
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-[600px]">
<DialogHeader>
<DialogTitle>Share Spellbook</DialogTitle>
<DialogDescription>
Share "{spellbook.title}" using any of the formats below
</DialogDescription>
</DialogHeader>
<div className="space-y-6 py-4">
{/* Format Tabs */}
<div className="flex gap-2 border-b border-border">
{formats.map((format) => (
<button
key={format.id}
onClick={() => setSelectedFormat(format.id)}
className={`px-4 py-2 text-sm font-medium transition-colors border-b-2 -mb-px ${
selectedFormat === format.id
? "border-primary text-foreground"
: "border-transparent text-muted-foreground hover:text-foreground"
}`}
>
{format.label}
</button>
))}
</div>
{/* Selected Format Content */}
{selectedFormatData && (
<div className="space-y-4">
<p className="text-sm text-muted-foreground">
{selectedFormatData.description}
</p>
{/* Value Display with Copy Button */}
<div className="flex gap-2">
<div className="flex-1 rounded-lg border border-border bg-muted/50 p-3 font-mono text-sm break-all">
{currentValue}
</div>
<Button
variant="outline"
size="icon"
onClick={() => handleCopy(selectedFormat)}
className="flex-shrink-0"
>
{copiedFormat === selectedFormat ? (
<Check className="size-4 text-green-500" />
) : (
<Copy className="size-4" />
)}
</Button>
</div>
{/* QR Code */}
<div className="flex flex-col items-center gap-4 p-6 rounded-lg border border-border bg-card">
<div className="flex items-center gap-2 text-sm font-medium text-muted-foreground">
<QrCode className="size-4" />
QR Code
</div>
<canvas
ref={canvasRef}
className="rounded-lg border border-border bg-white p-2"
/>
<Button
variant="outline"
size="sm"
onClick={handleDownloadQR}
className="w-full"
>
Download QR Code
</Button>
</div>
</div>
)}
</div>
{/* Quick Copy All Formats */}
<div className="border-t border-border pt-4 space-y-2">
<p className="text-xs font-medium text-muted-foreground uppercase tracking-wider">
Quick Copy
</p>
<div className="flex flex-wrap gap-2">
{formats.map((format) => (
<Button
key={format.id}
variant="secondary"
size="sm"
onClick={() => handleCopy(format.id)}
className="flex items-center gap-2"
>
{copiedFormat === format.id ? (
<Check className="size-3 text-green-500" />
) : (
<Copy className="size-3" />
)}
{format.label}
</Button>
))}
</div>
</div>
</DialogContent>
</Dialog>
);
}

View File

@@ -11,6 +11,8 @@ import {
Archive,
Layout,
ExternalLink,
Globe,
User,
} from "lucide-react";
import { useLiveQuery } from "dexie-react-hooks";
import db from "@/services/db";
@@ -37,12 +39,16 @@ import { useReqTimeline } from "@/hooks/useReqTimeline";
import { parseSpellbook } from "@/lib/spellbook-manager";
import type { SpellbookEvent, ParsedSpellbook } from "@/types/spell";
import { SPELLBOOK_KIND } from "@/constants/kinds";
import { UserName } from "./nostr/UserName";
import { AGGREGATOR_RELAYS } from "@/services/loaders";
interface SpellbookCardProps {
spellbook: LocalSpellbook;
onDelete: (spellbook: LocalSpellbook) => Promise<void>;
onPublish: (spellbook: LocalSpellbook) => Promise<void>;
onApply: (spellbook: ParsedSpellbook) => void;
showAuthor?: boolean;
isOwner?: boolean;
}
function SpellbookCard({
@@ -50,6 +56,8 @@ function SpellbookCard({
onDelete,
onPublish,
onApply,
showAuthor = false,
isOwner = true,
}: SpellbookCardProps) {
const [isPublishing, setIsPublishing] = useState(false);
const [isDeleting, setIsDeleting] = useState(false);
@@ -58,6 +66,9 @@ function SpellbookCard({
const workspaceCount = Object.keys(spellbook.content.workspaces).length;
const windowCount = Object.keys(spellbook.content.windows).length;
// Get author pubkey from event if available
const authorPubkey = spellbook.event?.pubkey;
const handlePublish = async () => {
setIsPublishing(true);
try {
@@ -130,6 +141,12 @@ function SpellbookCard({
<CardContent className="p-4 pt-0 flex-1">
<div className="flex flex-col gap-2">
{showAuthor && authorPubkey && (
<div className="flex items-center gap-1 text-sm text-muted-foreground">
<User className="size-3" />
<UserName pubkey={authorPubkey} className="text-xs" />
</div>
)}
<div className="flex gap-4 mt-1 text-sm text-muted-foreground">
<div className="flex items-center gap-1">
<Layout className="size-3" />
@@ -145,51 +162,53 @@ function SpellbookCard({
</CardContent>
<CardFooter className="p-4 pt-0 flex-wrap gap-2 justify-between">
<div className="flex flex-wrap gap-2">
<Button
size="sm"
variant="destructive"
className="h-8 px-2"
onClick={handleDelete}
disabled={isPublishing || isDeleting || !!spellbook.deletedAt}
>
{isDeleting ? (
<Loader2 className="size-3.5 mr-1 animate-spin" />
) : (
<Trash2 className="size-3.5 mr-1" />
)}
{spellbook.deletedAt ? "Deleted" : "Delete"}
</Button>
{!spellbook.deletedAt && (
{isOwner && (
<div className="flex flex-wrap gap-2">
<Button
size="sm"
variant={spellbook.isPublished ? "outline" : "default"}
className="h-8"
onClick={handlePublish}
disabled={isPublishing || isDeleting}
variant="destructive"
className="h-8 px-2"
onClick={handleDelete}
disabled={isPublishing || isDeleting || !!spellbook.deletedAt}
>
{isPublishing ? (
{isDeleting ? (
<Loader2 className="size-3.5 mr-1 animate-spin" />
) : spellbook.isPublished ? (
<RefreshCw className="size-3.5 mr-1" />
) : (
<Send className="size-3.5 mr-1" />
<Trash2 className="size-3.5 mr-1" />
)}
{isPublishing
? "Publishing..."
: spellbook.isPublished
? "Rebroadcast"
: "Publish"}
{spellbook.deletedAt ? "Deleted" : "Delete"}
</Button>
)}
</div>
{!spellbook.deletedAt && (
<Button
size="sm"
variant={spellbook.isPublished ? "outline" : "default"}
className="h-8"
onClick={handlePublish}
disabled={isPublishing || isDeleting}
>
{isPublishing ? (
<Loader2 className="size-3.5 mr-1 animate-spin" />
) : spellbook.isPublished ? (
<RefreshCw className="size-3.5 mr-1" />
) : (
<Send className="size-3.5 mr-1" />
)}
{isPublishing
? "Publishing..."
: spellbook.isPublished
? "Rebroadcast"
: "Publish"}
</Button>
)}
</div>
)}
{!spellbook.deletedAt && (
<Button
size="sm"
variant="default"
className="h-8"
className={cn("h-8", !isOwner && "w-full")}
onClick={handleApply}
>
Apply Layout
@@ -203,7 +222,7 @@ function SpellbookCard({
export function SpellbooksViewer() {
const { state, loadSpellbook } = useGrimoire();
const [searchQuery, setSearchQuery] = useState("");
const [filterType, setFilterType] = useState<"all" | "local" | "published">(
const [filterType, setFilterType] = useState<"all" | "local" | "published" | "discover">(
"all",
);
@@ -212,8 +231,8 @@ export function SpellbooksViewer() {
db.spellbooks.orderBy("createdAt").reverse().toArray(),
);
// Fetch from Nostr
const { events: networkEvents, loading: networkLoading } = useReqTimeline(
// Fetch user's spellbooks from Nostr
const { events: userNetworkEvents, loading: userNetworkLoading } = useReqTimeline(
state.activeAccount
? `user-spellbooks-${state.activeAccount.pubkey}`
: "none",
@@ -224,23 +243,45 @@ export function SpellbooksViewer() {
{ stream: true },
);
// Fetch discovered spellbooks from network (all authors)
const { events: discoveredEvents, loading: discoveredLoading } = useReqTimeline(
filterType === "discover" ? "discover-spellbooks" : "none",
filterType === "discover" ? { kinds: [SPELLBOOK_KIND], limit: 50 } : [],
AGGREGATOR_RELAYS,
{ stream: true },
);
const networkLoading = userNetworkLoading || discoveredLoading;
const loading = localSpellbooks === undefined;
// Filter and sort
const { filteredSpellbooks, totalCount } = useMemo(() => {
const allSpellbooksMap = new Map<string, LocalSpellbook>();
const currentUserPubkey = state.activeAccount?.pubkey;
// Add local spellbooks first
for (const s of localSpellbooks || []) {
allSpellbooksMap.set(s.id, s);
}
for (const event of networkEvents) {
// Process network events based on filter type
const eventsToProcess = filterType === "discover"
? discoveredEvents
: userNetworkEvents;
for (const event of eventsToProcess) {
// Find d tag for matching with local slug
const slug = event.tags.find((t) => t[0] === "d")?.[1] || "";
// Look for existing by slug + pubkey? For now just ID matching if we have it
// Replaceable events are tricky. Let's match by slug if localId not found.
// For discovered mode, skip user's own spellbooks (they're in userNetworkEvents)
if (filterType === "discover" && event.pubkey === currentUserPubkey) {
continue;
}
// Look for existing by slug and author
const existing = Array.from(allSpellbooksMap.values()).find(
(s) => s.slug === slug,
(s) => s.slug === slug && s.event?.pubkey === event.pubkey,
);
if (existing) {
@@ -280,6 +321,9 @@ export function SpellbooksViewer() {
filtered = filtered.filter((s) => !s.isPublished || !!s.deletedAt);
} else if (filterType === "published") {
filtered = filtered.filter((s) => s.isPublished && !s.deletedAt);
} else if (filterType === "discover") {
// Only show network spellbooks from others
filtered = filtered.filter((s) => s.isPublished && s.event?.pubkey !== currentUserPubkey);
}
if (searchQuery.trim()) {
@@ -297,7 +341,7 @@ export function SpellbooksViewer() {
});
return { filteredSpellbooks: filtered, totalCount: total };
}, [localSpellbooks, networkEvents, searchQuery, filterType]);
}, [localSpellbooks, userNetworkEvents, discoveredEvents, searchQuery, filterType, state.activeAccount?.pubkey]);
const handleDelete = async (spellbook: LocalSpellbook) => {
if (!confirm(`Delete spellbook "${spellbook.title}"?`)) return;
@@ -368,7 +412,7 @@ export function SpellbooksViewer() {
className="pl-9"
/>
</div>
<div className="flex gap-1">
<div className="flex gap-1 flex-wrap">
<Button
size="sm"
variant={filterType === "all" ? "default" : "outline"}
@@ -381,6 +425,7 @@ export function SpellbooksViewer() {
variant={filterType === "local" ? "default" : "outline"}
onClick={() => setFilterType("local")}
>
<Lock className="size-3 mr-1" />
Local
</Button>
<Button
@@ -388,8 +433,17 @@ export function SpellbooksViewer() {
variant={filterType === "published" ? "default" : "outline"}
onClick={() => setFilterType("published")}
>
<Cloud className="size-3 mr-1" />
Published
</Button>
<Button
size="sm"
variant={filterType === "discover" ? "default" : "outline"}
onClick={() => setFilterType("discover")}
>
<Globe className="size-3 mr-1" />
Discover
</Button>
</div>
</div>
</div>
@@ -405,15 +459,22 @@ export function SpellbooksViewer() {
</div>
) : (
<div className="grid gap-3 grid-cols-1">
{filteredSpellbooks.map((s) => (
<SpellbookCard
key={s.id}
spellbook={s}
onDelete={handleDelete}
onPublish={handlePublish}
onApply={handleApply}
/>
))}
{filteredSpellbooks.map((s) => {
const isOwner = s.event?.pubkey === state.activeAccount?.pubkey || !s.event;
const showAuthor = filterType === "discover" || !isOwner;
return (
<SpellbookCard
key={s.id}
spellbook={s}
onDelete={handleDelete}
onPublish={handlePublish}
onApply={handleApply}
showAuthor={showAuthor}
isOwner={isOwner}
/>
);
})}
</div>
)}
</div>

View File

@@ -1,4 +1,4 @@
import { useMemo } from "react";
import { useMemo, useState } from "react";
import {
BaseEventContainer,
BaseEventProps,
@@ -15,6 +15,7 @@ import { nip19 } from "nostr-tools";
import { useNavigate } from "react-router";
import { KindBadge } from "@/components/KindBadge";
import { WindowInstance } from "@/types/app";
import { ShareSpellbookDialog } from "@/components/ShareSpellbookDialog";
/**
* Helper to extract all unique event kinds from a spellbook's windows
@@ -283,6 +284,7 @@ export function SpellbookRenderer({ event }: BaseEventProps) {
*/
export function SpellbookDetailRenderer({ event }: { event: NostrEvent }) {
const profile = useProfile(event.pubkey);
const [shareDialogOpen, setShareDialogOpen] = useState(false);
const spellbook = useMemo(() => {
try {
@@ -300,13 +302,6 @@ export function SpellbookDetailRenderer({ event }: { event: NostrEvent }) {
);
}
const handleCopyLink = () => {
const actor = profile?.nip05 || nip19.npubEncode(event.pubkey);
const url = `${window.location.origin}/${actor}/${spellbook.slug}`;
navigator.clipboard.writeText(url);
toast.success("Preview link copied to clipboard");
};
const sortedWorkspaces = Object.values(spellbook.content.workspaces).sort(
(a, b) => a.number - b.number,
);
@@ -327,11 +322,11 @@ export function SpellbookDetailRenderer({ event }: { event: NostrEvent }) {
<Button
variant="outline"
size="sm"
onClick={handleCopyLink}
onClick={() => setShareDialogOpen(true)}
className="flex items-center gap-2"
>
<Share2 className="size-4" />
Share Link
Share
</Button>
<PreviewButton
@@ -388,6 +383,14 @@ export function SpellbookDetailRenderer({ event }: { event: NostrEvent }) {
})}
</div>
</div>
{/* Share Dialog */}
<ShareSpellbookDialog
open={shareDialogOpen}
onOpenChange={setShareDialogOpen}
event={event}
spellbook={spellbook}
/>
</div>
);
}