mirror of
https://github.com/purrgrammer/grimoire.git
synced 2026-04-10 23:47:12 +02:00
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:
@@ -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;
|
||||
|
||||
@@ -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}
|
||||
|
||||
170
src/components/nostr/kinds/SpellbookRenderer.tsx
Normal file
170
src/components/nostr/kinds/SpellbookRenderer.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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
|
||||
};
|
||||
|
||||
|
||||
Reference in New Issue
Block a user