feat: add nice preview renderers for spellbooks (kind 30777)

- Create SpellbookRenderer for feed view
- Create SpellbookDetailRenderer with Apply Layout action
- Register kind 30777 renderers in index
This commit is contained in:
Alejandro Gómez
2025-12-20 19:02:41 +01:00
parent 1836deee6c
commit 6a2c1a60fb
4 changed files with 238 additions and 23 deletions

View File

@@ -8,6 +8,7 @@ import { relayListCache } from "@/services/relay-list-cache";
import { AGGREGATOR_RELAYS } from "@/services/loaders";
import { mergeRelaySets } from "applesauce-core/helpers";
import { GrimoireState } from "@/types/app";
import { SpellbookContent } from "@/types/spell";
export interface PublishSpellbookOptions {
state: GrimoireState;
@@ -15,6 +16,7 @@ export interface PublishSpellbookOptions {
description?: string;
workspaceIds?: string[];
localId?: string; // If provided, updates this local spellbook
content?: SpellbookContent; // Optional explicit content
}
export class PublishSpellbookAction {
@@ -22,27 +24,41 @@ export class PublishSpellbookAction {
label = "Publish Spellbook";
async execute(options: PublishSpellbookOptions): Promise<void> {
const { state, title, description, workspaceIds, localId } = options;
const { state, title, description, workspaceIds, localId, content } = options;
const account = accountManager.active;
if (!account) throw new Error("No active account");
const signer = account.signer;
if (!signer) throw new Error("No signer available");
// 1. Create event props from state
const encoded = createSpellbook({
state,
title,
description,
workspaceIds,
});
// 1. Create event props from state or use provided content
let eventProps;
if (content) {
eventProps = {
kind: 30777,
content: JSON.stringify(content),
tags: [
["d", title.toLowerCase().trim().replace(/\s+/g, "-")],
["title", title],
],
};
if (description) eventProps.tags.push(["description", description]);
} else {
const encoded = createSpellbook({
state,
title,
description,
workspaceIds,
});
eventProps = encoded.eventProps;
}
// 2. Build and sign event
const factory = new EventFactory({ signer });
const draft = await factory.build({
kind: encoded.eventProps.kind,
content: encoded.eventProps.content,
tags: encoded.eventProps.tags,
kind: eventProps.kind,
content: eventProps.content,
tags: eventProps.tags as [string, string, ...string[]][],
});
const event = (await factory.sign(draft)) as SpellbookEvent;

View File

@@ -44,7 +44,12 @@ interface SpellbookCardProps {
onApply: (spellbook: ParsedSpellbook) => void;
}
function SpellbookCard({ spellbook, onDelete, onPublish, onApply }: SpellbookCardProps) {
function SpellbookCard({
spellbook,
onDelete,
onPublish,
onApply,
}: SpellbookCardProps) {
const [isPublishing, setIsPublishing] = useState(false);
const [isDeleting, setIsDeleting] = useState(false);
const displayName = spellbook.title || "Untitled Spellbook";
@@ -127,18 +132,19 @@ function SpellbookCard({ spellbook, onDelete, onPublish, onApply }: SpellbookCar
<div className="flex gap-4 mt-1 text-sm text-muted-foreground">
<div className="flex items-center gap-1">
<Layout className="size-3" />
{workspaceCount} {workspaceCount === 1 ? 'workspace' : 'workspaces'}
{workspaceCount}{" "}
{workspaceCount === 1 ? "workspace" : "workspaces"}
</div>
<div className="flex items-center gap-1">
<ExternalLink className="size-3" />
{windowCount} {windowCount === 1 ? 'window' : 'windows'}
{windowCount} {windowCount === 1 ? "window" : "windows"}
</div>
</div>
</div>
</CardContent>
<CardFooter className="p-4 pt-0 flex-wrap gap-2 justify-between">
<div className="flex gap-2">
<div className="flex flex-wrap gap-2">
<Button
size="sm"
variant="destructive"
@@ -169,13 +175,22 @@ function SpellbookCard({ spellbook, onDelete, onPublish, onApply }: SpellbookCar
) : (
<Send className="size-3.5 mr-1" />
)}
{isPublishing ? "Publishing..." : spellbook.isPublished ? "Rebroadcast" : "Publish"}
{isPublishing
? "Publishing..."
: spellbook.isPublished
? "Rebroadcast"
: "Publish"}
</Button>
)}
</div>
{!spellbook.deletedAt && (
<Button size="sm" variant="default" className="h-8" onClick={handleApply}>
<Button
size="sm"
variant="default"
className="h-8"
onClick={handleApply}
>
Apply Layout
</Button>
)}
@@ -198,7 +213,9 @@ export function SpellbooksViewer() {
// Fetch from Nostr
const { events: networkEvents, loading: networkLoading } = useReqTimeline(
state.activeAccount ? `user-spellbooks-${state.activeAccount.pubkey}` : "none",
state.activeAccount
? `user-spellbooks-${state.activeAccount.pubkey}`
: "none",
state.activeAccount
? { kinds: [SPELLBOOK_KIND], authors: [state.activeAccount.pubkey] }
: [],
@@ -217,11 +234,13 @@ export function SpellbooksViewer() {
for (const event of networkEvents) {
// Find d tag for matching with local slug
const slug = event.tags.find(t => t[0] === 'd')?.[1] || '';
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.
const existing = Array.from(allSpellbooksMap.values()).find(s => s.slug === slug);
const existing = Array.from(allSpellbooksMap.values()).find(
(s) => s.slug === slug,
);
if (existing) {
// Update existing with network event if it's newer
@@ -284,7 +303,10 @@ export function SpellbooksViewer() {
try {
if (spellbook.isPublished && spellbook.event) {
await new DeleteEventAction().execute({ event: spellbook.event }, "Deleted by user");
await new DeleteEventAction().execute(
{ event: spellbook.event },
"Deleted by user",
);
}
await deleteSpellbook(spellbook.id);
toast.success("Spellbook deleted");
@@ -302,6 +324,7 @@ export function SpellbooksViewer() {
description: spellbook.description,
workspaceIds: Object.keys(spellbook.content.workspaces),
localId: spellbook.id,
content: spellbook.content, // Pass existing content
});
toast.success("Spellbook published");
} catch (error) {
@@ -379,7 +402,7 @@ export function SpellbooksViewer() {
No spellbooks found.
</div>
) : (
<div className="grid gap-3 grid-cols-1 md:grid-cols-2 xl:grid-cols-3">
<div className="grid gap-3 grid-cols-1">
{filteredSpellbooks.map((s) => (
<SpellbookCard
key={s.id}

View File

@@ -0,0 +1,170 @@
import { useMemo } from "react";
import {
BaseEventContainer,
BaseEventProps,
ClickableEventTitle,
} from "./BaseEventRenderer";
import { parseSpellbook } from "@/lib/spellbook-manager";
import { SpellbookEvent } from "@/types/spell";
import { NostrEvent } from "@/types/nostr";
import { Grid3x3, Layout, ExternalLink, Play } from "lucide-react";
import { Button } from "@/components/ui/button";
import { useGrimoire } from "@/core/state";
import { toast } from "sonner";
/**
* Renderer for Kind 30777 - Spellbook (Layout Configuration)
* Displays spellbook title, description, and counts in feed
*/
export function SpellbookRenderer({ event }: BaseEventProps) {
const spellbook = useMemo(() => {
try {
return parseSpellbook(event as SpellbookEvent);
} catch (e) {
return null;
}
}, [event]);
if (!spellbook) {
return (
<BaseEventContainer event={event}>
<div className="text-destructive text-sm italic">Failed to parse spellbook data</div>
</BaseEventContainer>
);
}
const workspaceCount = Object.keys(spellbook.content.workspaces).length;
const windowCount = Object.keys(spellbook.content.windows).length;
return (
<BaseEventContainer event={event}>
<div className="flex flex-col gap-2">
{/* Title */}
<div className="flex items-center gap-2">
<Grid3x3 className="size-4 text-accent" />
<ClickableEventTitle
event={event}
className="text-lg font-bold text-foreground"
>
{spellbook.title}
</ClickableEventTitle>
</div>
{/* Description */}
{spellbook.description && (
<p className="text-sm text-muted-foreground line-clamp-2">
{spellbook.description}
</p>
)}
{/* Stats */}
<div className="flex gap-4 mt-1 text-xs text-muted-foreground font-mono">
<div className="flex items-center gap-1">
<Layout className="size-3" />
{workspaceCount} {workspaceCount === 1 ? 'workspace' : 'workspaces'}
</div>
<div className="flex items-center gap-1">
<ExternalLink className="size-3" />
{windowCount} {windowCount === 1 ? 'window' : 'windows'}
</div>
</div>
</div>
</BaseEventContainer>
);
}
/**
* Detail renderer for Kind 30777 - Spellbook
* Shows detailed workspace information and Apply Layout button
*/
export function SpellbookDetailRenderer({ event }: { event: NostrEvent }) {
const { loadSpellbook } = useGrimoire();
const spellbook = useMemo(() => {
try {
return parseSpellbook(event as SpellbookEvent);
} catch (e) {
return null;
}
}, [event]);
if (!spellbook) {
return <div className="p-4 text-destructive italic">Failed to parse spellbook data</div>;
}
const handleApply = () => {
loadSpellbook(spellbook);
toast.success("Layout applied", {
description: `Replaced current layout with ${Object.keys(spellbook.content.workspaces).length} workspaces.`,
});
};
const sortedWorkspaces = Object.values(spellbook.content.workspaces).sort((a, b) => a.number - b.number);
return (
<div className="flex flex-col gap-6 p-6 max-w-4xl mx-auto">
{/* Header */}
<div className="flex flex-col gap-4 md:flex-row md:items-start md:justify-between">
<div className="space-y-2">
<div className="flex items-center gap-3">
<div className="p-2 bg-accent/10 rounded-lg">
<Grid3x3 className="size-6 text-accent" />
</div>
<h2 className="text-3xl font-bold">{spellbook.title}</h2>
</div>
{spellbook.description && (
<p className="text-lg text-muted-foreground">{spellbook.description}</p>
)}
</div>
<Button
size="lg"
onClick={handleApply}
className="bg-accent hover:bg-accent/90 text-accent-foreground flex items-center gap-2 h-12 px-6 text-lg font-bold"
>
<Play className="size-5 fill-current" />
Apply Layout
</Button>
</div>
{/* Workspaces Summary */}
<div className="space-y-4">
<h3 className="text-sm font-semibold uppercase tracking-wider text-muted-foreground flex items-center gap-2">
<Layout className="size-4" />
Workspaces Content
</h3>
<div className="grid gap-3 grid-cols-1 sm:grid-cols-2">
{sortedWorkspaces.map((ws) => {
const wsWindows = ws.windowIds.length;
return (
<div
key={ws.id}
className="p-4 rounded-xl border border-border bg-card/50 flex items-center justify-between"
>
<div className="flex flex-col gap-0.5">
<span className="text-sm font-mono text-muted-foreground">Workspace {ws.number}</span>
<span className="font-bold">{ws.label || 'Untitled Workspace'}</span>
</div>
<div className="flex items-center gap-1.5 px-3 py-1 bg-muted rounded-full text-xs font-medium">
<ExternalLink className="size-3" />
{wsWindows} {wsWindows === 1 ? 'window' : 'windows'}
</div>
</div>
);
})}
</div>
</div>
{/* Technical Data / Reference */}
<div className="mt-8 pt-8 border-t border-border/50">
<div className="flex items-center justify-between text-xs text-muted-foreground font-mono">
<div className="flex gap-4">
<span>D-TAG: {spellbook.slug}</span>
<span>VERSION: {spellbook.content.version}</span>
</div>
</div>
</div>
</div>
);
}

View File

@@ -37,6 +37,10 @@ import { GenericRelayListRenderer } from "./GenericRelayListRenderer";
import { LiveActivityRenderer } from "./LiveActivityRenderer";
import { LiveActivityDetailRenderer } from "./LiveActivityDetailRenderer";
import { SpellRenderer, SpellDetailRenderer } from "./SpellRenderer";
import {
SpellbookRenderer,
SpellbookDetailRenderer,
} from "./SpellbookRenderer";
import { NostrEvent } from "@/types/nostr";
import { BaseEventContainer, type BaseEventProps } from "./BaseEventRenderer";
@@ -76,6 +80,7 @@ const kindRenderers: Record<number, React.ComponentType<BaseEventProps>> = {
30311: LiveActivityRenderer, // Live Streaming Event (NIP-53)
30617: RepositoryRenderer, // Repository (NIP-34)
30618: RepositoryStateRenderer, // Repository State (NIP-34)
30777: SpellbookRenderer, // Spellbook (Grimoire)
30817: CommunityNIPRenderer, // Community NIP
39701: Kind39701Renderer, // Web Bookmarks (NIP-B0)
};
@@ -132,6 +137,7 @@ const detailRenderers: Record<
30311: LiveActivityDetailRenderer, // Live Streaming Event Detail (NIP-53)
30617: RepositoryDetailRenderer, // Repository Detail (NIP-34)
30618: RepositoryStateDetailRenderer, // Repository State Detail (NIP-34)
30777: SpellbookDetailRenderer, // Spellbook Detail (Grimoire)
30817: CommunityNIPDetailRenderer, // Community NIP Detail
};