mirror of
https://github.com/purrgrammer/grimoire.git
synced 2026-04-21 04:08:17 +02:00
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:
253
src/components/ShareSpellbookDialog.tsx
Normal file
253
src/components/ShareSpellbookDialog.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user