feat: complete spellbook UX overhaul with enhanced state tracking

- Redesign SpellbookDropdown with clear status indicators (ownership, storage)
- Add SpellbookStatus component showing you/other and local/published/network
- Enhance activeSpellbook type with source, localId, isPublished fields
- Fix PublishSpellbook action to properly yield events (caller handles side-effects)
- Add k tags extraction from REQ windows for kind-based filtering/discovery
- Update terminology from "Layout" to "Spellbook" consistently
- Add comprehensive tests for k tags and source tracking

🤖 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 19:19:24 +01:00
parent be01c7d882
commit f4d0e86f09
9 changed files with 698 additions and 229 deletions

View File

@@ -1,5 +1,4 @@
import { createSpellbook, slugify } from "@/lib/spellbook-manager";
import { markSpellbookPublished } from "@/services/spellbook-storage";
import { SpellbookEvent } from "@/types/spell";
import { GrimoireState } from "@/types/app";
import { SpellbookContent } from "@/types/spell";
@@ -12,7 +11,6 @@ export interface PublishSpellbookOptions {
title: string;
description?: string;
workspaceIds?: string[];
localId?: string; // If provided, updates this local spellbook
content?: SpellbookContent; // Optional explicit content
}
@@ -24,7 +22,10 @@ export interface PublishSpellbookOptions {
* 2. Creates spellbook event from state or explicit content
* 3. Signs the event using the action hub's factory
* 4. Yields the signed event (ActionHub handles publishing)
* 5. Marks local spellbook as published if localId provided
*
* NOTE: This action does NOT mark the local spellbook as published.
* The caller should use hub.exec() and call markSpellbookPublished()
* AFTER successful publish to ensure data consistency.
*
* @param options - Spellbook publishing options
* @returns Action generator for ActionHub
@@ -33,17 +34,15 @@ export interface PublishSpellbookOptions {
*
* @example
* ```typescript
* // Publish via ActionHub
* await hub.run(PublishSpellbook, {
* state: currentState,
* title: "My Dashboard",
* description: "Daily workflow",
* localId: "local-spellbook-id"
* });
* // Publish via ActionHub with proper side-effect handling
* for await (const event of hub.exec(PublishSpellbook, options)) {
* // Only mark as published AFTER successful relay publish
* await markSpellbookPublished(localId, event as SpellbookEvent);
* }
* ```
*/
export function PublishSpellbook(options: PublishSpellbookOptions) {
const { state, title, description, workspaceIds, localId, content } = options;
const { state, title, description, workspaceIds, content } = options;
return async function* ({
factory,
@@ -103,12 +102,9 @@ export function PublishSpellbook(options: PublishSpellbookOptions) {
// 4. Sign the event
const event = (await factory.sign(draft)) as SpellbookEvent;
// 5. Mark as published in local DB (before yielding for better UX)
if (localId) {
await markSpellbookPublished(localId, event);
}
// 6. Yield signed event - ActionHub's publishEvent will handle relay selection and publishing
// 5. Yield signed event - ActionHub handles relay selection and publishing
// NOTE: Caller is responsible for marking local spellbook as published
// after successful publish using markSpellbookPublished()
yield event;
};
}

View File

@@ -74,7 +74,10 @@ export default function Home() {
} else if (isNip05(actor)) {
// Add timeout for NIP-05 resolution
const timeoutPromise = new Promise<never>((_, reject) =>
setTimeout(() => reject(new Error("NIP-05 resolution timeout")), 10000)
setTimeout(
() => reject(new Error("NIP-05 resolution timeout")),
10000,
),
);
const pubkey = await Promise.race([
resolveNip05(actor),
@@ -89,7 +92,7 @@ export default function Home() {
} catch (e) {
console.error("Failed to resolve actor:", actor, e);
setResolutionError(
e instanceof Error ? e.message : "Failed to resolve actor"
e instanceof Error ? e.message : "Failed to resolve actor",
);
toast.error(`Failed to resolve actor: ${actor}`, {
description:
@@ -129,9 +132,9 @@ export default function Home() {
setHasLoadedSpellbook(true);
if (isPreviewPath) {
toast.info(`Previewing layout: ${parsed.title}`, {
toast.info(`Previewing spellbook: ${parsed.title}`, {
description:
"You are in a temporary session. Apply to keep this layout.",
"You are in a temporary session. Apply to keep this spellbook.",
});
}
} catch (e) {
@@ -147,10 +150,10 @@ export default function Home() {
switchToTemporary,
]);
const handleApplyLayout = () => {
const handleApplySpellbook = () => {
applyTemporaryToPersistent();
navigate("/", { replace: true });
toast.success("Layout applied to your dashboard");
toast.success("Spellbook applied to your dashboard");
};
const handleDiscardPreview = () => {
@@ -181,7 +184,11 @@ export default function Home() {
}
// Otherwise show date
return date.toLocaleDateString(undefined, { month: 'short', day: 'numeric', year: 'numeric' });
return date.toLocaleDateString(undefined, {
month: "short",
day: "numeric",
year: "numeric",
});
};
// Sync active account and fetch relay lists
@@ -295,10 +302,10 @@ export default function Home() {
variant="secondary"
size="sm"
className="h-7 bg-white text-accent hover:bg-white/90 font-bold shadow-sm"
onClick={handleApplyLayout}
onClick={handleApplySpellbook}
>
<Check className="size-3.5 mr-1" />
Apply Layout
Apply Spellbook
</Button>
</div>
</div>

View File

@@ -14,11 +14,15 @@ import { Textarea } from "./ui/textarea";
import { Checkbox } from "./ui/checkbox";
import { useGrimoire } from "@/core/state";
import { toast } from "sonner";
import { saveSpellbook } from "@/services/spellbook-storage";
import {
saveSpellbook,
markSpellbookPublished,
} from "@/services/spellbook-storage";
import { PublishSpellbook } from "@/actions/publish-spellbook";
import { hub } from "@/services/hub";
import { createSpellbook } from "@/lib/spellbook-manager";
import { Loader2, Save, Send } from "lucide-react";
import type { SpellbookEvent } from "@/types/spell";
interface SaveSpellbookDialogProps {
open: boolean;
@@ -42,7 +46,9 @@ export function SaveSpellbookDialog({
const isUpdateMode = !!existingSpellbook;
const [title, setTitle] = useState(existingSpellbook?.title || "");
const [description, setDescription] = useState(existingSpellbook?.description || "");
const [description, setDescription] = useState(
existingSpellbook?.description || "",
);
const [selectedWorkspaces, setSelectedWorkspaces] = useState<string[]>(
existingSpellbook?.workspaceIds || Object.keys(state.workspaces),
);
@@ -105,14 +111,18 @@ export function SaveSpellbookDialog({
// 4. Optionally publish
if (shouldPublish) {
await hub.run(PublishSpellbook, {
const localId = existingSpellbook?.localId || localSpellbook.id;
// Use hub.exec() to get the event and handle side effects after successful publish
for await (const event of hub.exec(PublishSpellbook, {
state,
title,
description,
workspaceIds: selectedWorkspaces,
localId: existingSpellbook?.localId || localSpellbook.id,
content: localSpellbook.content, // Pass explicitly to avoid re-calculating
});
content: localSpellbook.content,
})) {
// Only mark as published AFTER successful relay publish
await markSpellbookPublished(localId, event as SpellbookEvent);
}
toast.success(
isUpdateMode
? "Spellbook updated and published to Nostr"
@@ -120,11 +130,13 @@ export function SaveSpellbookDialog({
);
} else {
toast.success(
isUpdateMode ? "Spellbook updated locally" : "Spellbook saved locally",
isUpdateMode
? "Spellbook updated locally"
: "Spellbook saved locally",
);
}
// 5. Set as active spellbook
// 5. Set as active spellbook with full source tracking
const parsedSpellbook = {
slug,
title,
@@ -132,6 +144,10 @@ export function SaveSpellbookDialog({
content: localSpellbook.content,
referencedSpells: [],
event: localSpellbook.event as any, // Event might not exist for locally-only spellbooks
// Enhanced source tracking:
localId: localSpellbook.id,
isPublished: shouldPublish || localSpellbook.isPublished,
source: "local" as const,
};
loadSpellbook(parsedSpellbook);
@@ -158,7 +174,7 @@ export function SaveSpellbookDialog({
<DialogContent className="sm:max-w-[425px]">
<DialogHeader>
<DialogTitle>
{isUpdateMode ? "Update Spellbook" : "Save Layout as Spellbook"}
{isUpdateMode ? "Update Spellbook" : "Save as Spellbook"}
</DialogTitle>
<DialogDescription>
{isUpdateMode
@@ -181,7 +197,7 @@ export function SaveSpellbookDialog({
<Label>Description (optional)</Label>
<Textarea
id="description"
placeholder="What is this layout for?"
placeholder="What is this spellbook for?"
value={description}
onChange={(e) => setDescription(e.target.value)}
rows={3}

View File

@@ -1,7 +1,21 @@
import { useMemo, useState } from "react";
import { BookHeart, ChevronDown, Plus, Save, Settings, X } from "lucide-react";
import {
BookHeart,
Check,
ChevronDown,
Cloud,
GitFork,
Lock,
Plus,
Save,
Settings,
Share2,
User,
Users,
X,
} from "lucide-react";
import { useLiveQuery } from "dexie-react-hooks";
import { useLocation } from "react-router";
import { useLocation, useNavigate } from "react-router";
import db from "@/services/db";
import { useGrimoire } from "@/core/state";
import { useReqTimeline } from "@/hooks/useReqTimeline";
@@ -19,6 +33,68 @@ import {
} from "./ui/dropdown-menu";
import { cn } from "@/lib/utils";
import { SaveSpellbookDialog } from "./SaveSpellbookDialog";
import { toast } from "sonner";
/**
* Status indicator component for spellbook state
*/
function SpellbookStatus({
isOwner,
isPublished,
isLocal,
className,
}: {
isOwner: boolean;
isPublished?: boolean;
isLocal?: boolean;
className?: string;
}) {
return (
<div
className={cn(
"flex items-center gap-1 text-[10px] text-muted-foreground",
className,
)}
>
{/* Ownership */}
{isOwner ? (
<span className="flex items-center gap-0.5" title="Your spellbook">
<User className="size-2.5" />
<span>you</span>
</span>
) : (
<span className="flex items-center gap-0.5" title="Others' spellbook">
<Users className="size-2.5" />
<span>other</span>
</span>
)}
<span className="opacity-50"></span>
{/* Storage status */}
{isPublished ? (
<span
className="flex items-center gap-0.5 text-green-600"
title="Published to Nostr"
>
<Cloud className="size-2.5" />
<span>published</span>
</span>
) : isLocal ? (
<span className="flex items-center gap-0.5" title="Local only">
<Lock className="size-2.5" />
<span>local</span>
</span>
) : (
<span
className="flex items-center gap-0.5"
title="Network only (not in library)"
>
<Cloud className="size-2.5" />
<span>network</span>
</span>
)}
</div>
);
}
export function SpellbookDropdown() {
const {
@@ -27,21 +103,23 @@ export function SpellbookDropdown() {
addWindow,
clearActiveSpellbook,
applyTemporaryToPersistent,
discardTemporary,
isTemporary,
} = useGrimoire();
const location = useLocation();
const navigate = useNavigate();
const activeAccount = state.activeAccount;
const activeSpellbook = state.activeSpellbook;
const [saveDialogOpen, setSaveDialogOpen] = useState(false);
const [dialogSpellbook, setDialogSpellbook] = useState<
| {
slug: string;
title: string;
description?: string;
workspaceIds?: string[];
localId?: string;
pubkey?: string;
}
slug: string;
title: string;
description?: string;
workspaceIds?: string[];
localId?: string;
pubkey?: string;
}
| undefined
>(undefined);
@@ -76,6 +154,9 @@ export function SpellbookDropdown() {
content: s.content,
referencedSpells: [],
event: s.event as SpellbookEvent,
localId: s.id,
isPublished: s.isPublished,
source: "local",
});
}
@@ -89,8 +170,14 @@ export function SpellbookDropdown() {
)
continue;
try {
allMap.set(slug, parseSpellbook(event as SpellbookEvent));
} catch (e) {
const parsed = parseSpellbook(event as SpellbookEvent);
allMap.set(slug, {
...parsed,
localId: existing?.localId,
isPublished: true,
source: existing?.localId ? "local" : "network",
});
} catch (_e) {
// ignore
}
}
@@ -100,48 +187,78 @@ export function SpellbookDropdown() {
);
}, [localSpellbooks, networkEvents, activeAccount]);
// Check if active spellbook is in local library
const isActiveLocal = useMemo(() => {
// Derived states for clearer UX
const isOwner = useMemo(() => {
if (!activeSpellbook) return false;
return (localSpellbooks || []).some((s) => s.slug === activeSpellbook.slug);
}, [activeSpellbook, localSpellbooks]);
// Owner if: no pubkey (local-only) OR pubkey matches active account
return (
!activeSpellbook.pubkey ||
activeSpellbook.pubkey === activeAccount?.pubkey
);
}, [activeSpellbook, activeAccount]);
const isInLibrary = useMemo(() => {
if (!activeSpellbook) return false;
return !!activeSpellbook.localId;
}, [activeSpellbook]);
// Show dropdown if: in preview mode, has active account, or has active spellbook
if (!isPreviewMode && !activeAccount && !activeSpellbook) {
return null;
}
const handleApplySpellbook = (sb: ParsedSpellbook) => {
const handleLoadSpellbook = (sb: ParsedSpellbook) => {
loadSpellbook(sb);
toast.success(`Loaded "${sb.title}"`);
};
const handleUpdateActive = async () => {
const handleUpdateSpellbook = async () => {
if (!activeSpellbook) return;
// Get local spellbook for ID
const local = await db.spellbooks
.where("slug")
.equals(activeSpellbook.slug)
.first();
// Open dialog with existing spellbook data
// Prefer local description if available, fall back to active spellbook
setDialogSpellbook({
slug: activeSpellbook.slug,
title: activeSpellbook.title,
description: local?.description || activeSpellbook.description,
workspaceIds: Object.keys(state.workspaces),
localId: local?.id,
localId: local?.id || activeSpellbook.localId,
pubkey: activeSpellbook.pubkey,
});
setSaveDialogOpen(true);
};
const handleForkSpellbook = () => {
if (!activeSpellbook) return;
// Open save dialog without existing spellbook to create a new one
setDialogSpellbook(undefined);
setSaveDialogOpen(true);
};
const handleNewSpellbook = () => {
setDialogSpellbook(undefined);
setSaveDialogOpen(true);
};
const handleApplyToMain = () => {
applyTemporaryToPersistent();
navigate("/", { replace: true });
toast.success("Spellbook applied to your dashboard");
};
const handleExitPreview = () => {
discardTemporary();
navigate("/", { replace: true });
};
const handleCloseSpellbook = () => {
clearActiveSpellbook();
toast.info("Spellbook closed");
};
const itemClass =
"cursor-pointer py-2 hover:bg-muted focus:bg-muted transition-colors";
@@ -150,7 +267,7 @@ export function SpellbookDropdown() {
<SaveSpellbookDialog
open={saveDialogOpen}
onOpenChange={setSaveDialogOpen}
existingSpellbook={isActiveLocal ? dialogSpellbook : undefined}
existingSpellbook={isOwner && isInLibrary ? dialogSpellbook : undefined}
/>
<DropdownMenu>
<DropdownMenuTrigger asChild>
@@ -159,11 +276,14 @@ export function SpellbookDropdown() {
size="sm"
className={cn(
"h-7 px-2 gap-1.5 text-muted-foreground hover:text-foreground",
activeSpellbook && "text-foreground font-bold",
activeSpellbook && "text-foreground font-medium",
isTemporary && "ring-1 ring-amber-500/50",
)}
>
<BookHeart className="size-4" />
<span className="text-xs font-medium hidden sm:inline">
<BookHeart
className={cn("size-4", isTemporary && "text-amber-500")}
/>
<span className="text-xs font-medium hidden sm:inline max-w-[120px] truncate">
{activeSpellbook ? activeSpellbook.title : "grimoire"}
</span>
<ChevronDown className="size-3 opacity-50" />
@@ -171,68 +291,143 @@ export function SpellbookDropdown() {
</DropdownMenuTrigger>
<DropdownMenuContent
align="center"
className="w-64 max-h-[80vh] overflow-y-auto"
className="w-72 max-h-[80vh] overflow-y-auto"
>
{/* Active Spellbook Actions */}
{activeSpellbook && (
{/* Preview Mode Banner */}
{isPreviewMode && (
<>
<DropdownMenuLabel className="py-1 px-2 text-[10px] uppercase tracking-wider text-muted-foreground font-bold">
Active Layout
</DropdownMenuLabel>
<div className="px-2 py-1 text-sm font-medium truncate opacity-80 mb-1">
{activeSpellbook.title || activeSpellbook.slug}
<div className="px-3 py-2 bg-amber-500/10 border-b border-amber-500/20">
<div className="flex items-center gap-2 text-amber-600 text-xs font-medium">
<BookHeart className="size-3.5" />
Preview Mode
</div>
<p className="text-[10px] text-muted-foreground mt-1">
You're viewing a shared spellbook. Apply to keep it.
</p>
</div>
{isTemporary && (
<DropdownMenuItem
onClick={applyTemporaryToPersistent}
className={cn(itemClass, "bg-accent/5 font-bold")}
>
<Save className="size-3.5 mr-2" />
Apply to Dashboard
</DropdownMenuItem>
{activeSpellbook && (
<div className="px-3 py-2 border-b">
<div className="font-medium text-sm truncate">
{activeSpellbook.title}
</div>
<SpellbookStatus
isOwner={isOwner}
isPublished={activeSpellbook.isPublished}
isLocal={isInLibrary}
className="mt-1"
/>
</div>
)}
{isActiveLocal && activeAccount &&
activeSpellbook.pubkey === activeAccount.pubkey ? (
<DropdownMenuItem
onClick={handleUpdateActive}
className={itemClass}
>
<Save className="size-3.5 mr-2 text-muted-foreground" />
Update
</DropdownMenuItem>
) : (
<DropdownMenuItem
onClick={handleUpdateActive}
className={itemClass}
>
<Plus className="size-3.5 mr-2 text-muted-foreground" />
Add to Library
</DropdownMenuItem>
)}
<DropdownMenuItem
onClick={clearActiveSpellbook}
className={cn(itemClass, "text-xs opacity-70")}
onClick={handleApplyToMain}
className={cn(
itemClass,
"bg-green-500/5 text-green-600 font-medium",
)}
>
<X className="size-3.5 mr-2 text-muted-foreground" />
Deselect
<Check className="size-3.5 mr-2" />
Apply to Dashboard
</DropdownMenuItem>
{activeAccount && (
<DropdownMenuItem
onClick={handleForkSpellbook}
className={itemClass}
>
<GitFork className="size-3.5 mr-2 text-muted-foreground" />
Fork to Library
</DropdownMenuItem>
)}
<DropdownMenuItem
onClick={handleExitPreview}
className={cn(itemClass, "text-muted-foreground")}
>
<X className="size-3.5 mr-2" />
Exit Preview
</DropdownMenuItem>
<DropdownMenuSeparator />
</>
)}
{/* Spellbooks Section - only show if user is logged in */}
{/* Active Spellbook Section (non-preview) */}
{activeSpellbook && !isPreviewMode && (
<>
<DropdownMenuLabel className="py-1 px-3 text-[10px] uppercase tracking-wider text-muted-foreground font-bold">
Active Spellbook
</DropdownMenuLabel>
<div className="px-3 py-2 border-b">
<div className="font-medium text-sm truncate">
{activeSpellbook.title}
</div>
<SpellbookStatus
isOwner={isOwner}
isPublished={activeSpellbook.isPublished}
isLocal={isInLibrary}
className="mt-1"
/>
</div>
{/* Temporary session actions */}
{isTemporary && (
<DropdownMenuItem
onClick={handleApplyToMain}
className={cn(itemClass, "bg-amber-500/5 font-medium")}
>
<Check className="size-3.5 mr-2 text-amber-600" />
Keep This Spellbook
</DropdownMenuItem>
)}
{/* Owner actions */}
{isOwner && isInLibrary && (
<DropdownMenuItem
onClick={handleUpdateSpellbook}
className={itemClass}
>
<Save className="size-3.5 mr-2 text-muted-foreground" />
Update & Publish
</DropdownMenuItem>
)}
{/* Non-owner or not in library actions */}
{(!isOwner || !isInLibrary) && activeAccount && (
<DropdownMenuItem
onClick={handleForkSpellbook}
className={itemClass}
>
<GitFork className="size-3.5 mr-2 text-muted-foreground" />
{isOwner ? "Save to Library" : "Fork to Library"}
</DropdownMenuItem>
)}
<DropdownMenuItem
onClick={() => addWindow("spellbooks", {})}
className={cn(itemClass, "text-muted-foreground text-xs")}
>
<Share2 className="size-3.5 mr-2" />
Share...
</DropdownMenuItem>
<DropdownMenuItem
onClick={handleCloseSpellbook}
className={cn(itemClass, "text-muted-foreground text-xs")}
>
<X className="size-3.5 mr-2" />
Close Spellbook
</DropdownMenuItem>
<DropdownMenuSeparator />
</>
)}
{/* My Spellbooks Section */}
{activeAccount && (
<>
<DropdownMenuLabel className="flex items-center justify-between py-1 px-2 text-[10px] uppercase tracking-wider text-muted-foreground font-bold">
My Layouts
<DropdownMenuLabel className="flex items-center justify-between py-1 px-3 text-[10px] uppercase tracking-wider text-muted-foreground font-bold">
My Spellbooks
</DropdownMenuLabel>
{spellbooks.length === 0 ? (
<div className="px-2 py-4 text-center text-xs text-muted-foreground italic">
No layouts saved yet.
<div className="px-3 py-4 text-center text-xs text-muted-foreground italic">
No spellbooks saved yet.
</div>
) : (
spellbooks.map((sb) => {
@@ -241,20 +436,39 @@ export function SpellbookDropdown() {
<DropdownMenuItem
key={sb.slug}
disabled={isActive}
onClick={() => handleApplySpellbook(sb)}
className={cn(itemClass, isActive && "bg-muted font-bold")}
onClick={() => handleLoadSpellbook(sb)}
className={cn(
itemClass,
"flex items-center gap-2",
isActive && "bg-muted",
)}
>
<BookHeart
className={cn(
"size-3.5 mr-2 text-muted-foreground",
"size-3.5 flex-shrink-0 text-muted-foreground",
isActive && "text-foreground",
)}
/>
<div className="flex flex-row gap-0 min-w-0">
<span className="truncate font-medium text-sm">
{sb.title}
</span>
</div>
<span
className={cn(
"truncate flex-1 text-sm",
isActive && "font-medium",
)}
>
{sb.title}
</span>
{/* Status badge */}
{sb.isPublished ? (
<Cloud
className="size-3 text-green-600 flex-shrink-0"
title="Published"
/>
) : (
<Lock
className="size-3 text-muted-foreground flex-shrink-0"
title="Local only"
/>
)}
</DropdownMenuItem>
);
})
@@ -262,30 +476,29 @@ export function SpellbookDropdown() {
<DropdownMenuSeparator />
{!activeSpellbook && (
<DropdownMenuItem
onClick={handleNewSpellbook}
className={itemClass}
>
<Save className="size-3.5 mr-2 text-muted-foreground" />
<span className="text-sm font-medium">Save Spellbook</span>
</DropdownMenuItem>
)}
{/* Actions */}
<DropdownMenuItem
onClick={handleNewSpellbook}
className={itemClass}
>
<Plus className="size-3.5 mr-2 text-muted-foreground" />
<span className="text-sm">Save Current as Spellbook</span>
</DropdownMenuItem>
<DropdownMenuItem
onClick={() => addWindow("spellbooks", {})}
className={cn(itemClass, "text-xs opacity-70")}
className={cn(itemClass, "text-muted-foreground")}
>
<Settings className="size-3.5 mr-2 text-muted-foreground" />
<span className="text-sm font-medium">Manage Library</span>
<Settings className="size-3.5 mr-2" />
<span className="text-sm">Manage Library</span>
</DropdownMenuItem>
</>
)}
{/* Show message for non-logged-in users in preview mode */}
{/* Non-logged-in user in preview mode */}
{!activeAccount && isPreviewMode && (
<div className="px-2 py-4 text-center text-xs text-muted-foreground italic">
Log in to save and manage layouts
<div className="px-3 py-4 text-center text-xs text-muted-foreground italic">
Log in to save and manage spellbooks
</div>
)}
</DropdownMenuContent>

View File

@@ -28,7 +28,10 @@ import {
} from "./ui/card";
import { Badge } from "./ui/badge";
import { toast } from "sonner";
import { deleteSpellbook } from "@/services/spellbook-storage";
import {
deleteSpellbook,
markSpellbookPublished,
} from "@/services/spellbook-storage";
import type { LocalSpellbook } from "@/services/db";
import { PublishSpellbook } from "@/actions/publish-spellbook";
import { DeleteEventAction } from "@/actions/delete-event";
@@ -96,6 +99,10 @@ function SpellbookCard({
content: spellbook.content,
referencedSpells: [], // We don't need this for applying
event: spellbook.event as SpellbookEvent,
// Enhanced source tracking:
localId: spellbook.id,
isPublished: spellbook.isPublished,
source: "local",
};
onApply(parsed);
};
@@ -211,7 +218,7 @@ function SpellbookCard({
className={cn("h-8", !isOwner && "w-full")}
onClick={handleApply}
>
Apply Layout
Load Spellbook
</Button>
)}
</CardFooter>
@@ -222,9 +229,9 @@ function SpellbookCard({
export function SpellbooksViewer() {
const { state, loadSpellbook } = useGrimoire();
const [searchQuery, setSearchQuery] = useState("");
const [filterType, setFilterType] = useState<"all" | "local" | "published" | "discover">(
"all",
);
const [filterType, setFilterType] = useState<
"all" | "local" | "published" | "discover"
>("all");
// Load local spellbooks from Dexie
const localSpellbooks = useLiveQuery(() =>
@@ -232,24 +239,26 @@ export function SpellbooksViewer() {
);
// Fetch user's spellbooks from Nostr
const { events: userNetworkEvents, loading: userNetworkLoading } = useReqTimeline(
state.activeAccount
? `user-spellbooks-${state.activeAccount.pubkey}`
: "none",
state.activeAccount
? { kinds: [SPELLBOOK_KIND], authors: [state.activeAccount.pubkey] }
: [],
state.activeAccount?.relays?.map((r) => r.url) || [],
{ stream: true },
);
const { events: userNetworkEvents, loading: userNetworkLoading } =
useReqTimeline(
state.activeAccount
? `user-spellbooks-${state.activeAccount.pubkey}`
: "none",
state.activeAccount
? { kinds: [SPELLBOOK_KIND], authors: [state.activeAccount.pubkey] }
: [],
state.activeAccount?.relays?.map((r) => r.url) || [],
{ 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 { 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;
@@ -266,9 +275,8 @@ export function SpellbooksViewer() {
}
// Process network events based on filter type
const eventsToProcess = filterType === "discover"
? discoveredEvents
: userNetworkEvents;
const eventsToProcess =
filterType === "discover" ? discoveredEvents : userNetworkEvents;
for (const event of eventsToProcess) {
// Find d tag for matching with local slug
@@ -323,7 +331,9 @@ export function SpellbooksViewer() {
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);
filtered = filtered.filter(
(s) => s.isPublished && s.event?.pubkey !== currentUserPubkey,
);
}
if (searchQuery.trim()) {
@@ -341,7 +351,14 @@ export function SpellbooksViewer() {
});
return { filteredSpellbooks: filtered, totalCount: total };
}, [localSpellbooks, userNetworkEvents, discoveredEvents, searchQuery, filterType, state.activeAccount?.pubkey]);
}, [
localSpellbooks,
userNetworkEvents,
discoveredEvents,
searchQuery,
filterType,
state.activeAccount?.pubkey,
]);
const handleDelete = async (spellbook: LocalSpellbook) => {
if (!confirm(`Delete spellbook "${spellbook.title}"?`)) return;
@@ -355,33 +372,36 @@ export function SpellbooksViewer() {
}
await deleteSpellbook(spellbook.id);
toast.success("Spellbook deleted");
} catch (error) {
} catch (_error) {
toast.error("Failed to delete spellbook");
}
};
const handlePublish = async (spellbook: LocalSpellbook) => {
try {
await hub.run(PublishSpellbook, {
// Use hub.exec() to get the event and handle side effects after successful publish
for await (const event of hub.exec(PublishSpellbook, {
state,
title: spellbook.title,
description: spellbook.description,
workspaceIds: Object.keys(spellbook.content.workspaces),
localId: spellbook.id,
content: spellbook.content, // Pass existing content
});
content: spellbook.content,
})) {
// Only mark as published AFTER successful relay publish
await markSpellbookPublished(spellbook.id, event as SpellbookEvent);
}
toast.success("Spellbook published");
} catch (error) {
toast.error(
error instanceof Error ? error.message : "Failed to publish spellbook"
error instanceof Error ? error.message : "Failed to publish spellbook",
);
}
};
const handleApply = (spellbook: ParsedSpellbook) => {
loadSpellbook(spellbook);
toast.success("Layout applied", {
description: `Replaced current layout with ${Object.keys(spellbook.content.workspaces).length} workspaces.`,
toast.success("Spellbook loaded", {
description: `Loaded ${Object.keys(spellbook.content.workspaces).length} workspaces.`,
});
};
@@ -460,7 +480,8 @@ export function SpellbooksViewer() {
) : (
<div className="grid gap-3 grid-cols-1">
{filteredSpellbooks.map((s) => {
const isOwner = s.event?.pubkey === state.activeAccount?.pubkey || !s.event;
const isOwner =
s.event?.pubkey === state.activeAccount?.pubkey || !s.event;
const showAuthor = filterType === "discover" || !isOwner;
return (

View File

@@ -22,6 +22,28 @@ const mockWindow2: WindowInstance = {
props: { kind: 1 },
};
// REQ window with filter.kinds for k tag extraction
const mockReqWindow: WindowInstance = {
id: "win-req",
appId: "req",
props: {
filter: {
kinds: [1, 6, 7, 30023],
authors: ["abc"],
},
},
};
const mockReqWindow2: WindowInstance = {
id: "win-req-2",
appId: "req",
props: {
filter: {
kinds: [1, 4], // kind 1 duplicated to test dedup
},
},
};
const mockWorkspace1: Workspace = {
id: "ws-1",
number: 1,
@@ -40,6 +62,17 @@ const mockWorkspace2: Workspace = {
windowIds: ["win-1", "win-2"],
};
const mockWorkspaceWithReq: Workspace = {
id: "ws-req",
number: 3,
layout: {
direction: "row",
first: "win-req",
second: "win-req-2",
},
windowIds: ["win-req", "win-req-2"],
};
const mockState: GrimoireState = {
__version: 6,
windows: {
@@ -58,6 +91,23 @@ const mockState: GrimoireState = {
},
};
const mockStateWithReq: GrimoireState = {
__version: 6,
windows: {
"win-req": mockReqWindow,
"win-req-2": mockReqWindow2,
},
workspaces: {
"ws-req": mockWorkspaceWithReq,
},
activeWorkspaceId: "ws-req",
layoutConfig: {
insertionMode: "smart",
splitPercentage: 50,
insertionPosition: "second",
},
};
describe("Spellbook Manager", () => {
describe("slugify", () => {
it("converts titles to slugs", () => {
@@ -84,9 +134,12 @@ describe("Spellbook Manager", () => {
expect(eventProps.kind).toBe(SPELLBOOK_KIND);
expect(eventProps.tags).toContainEqual(["d", "my-backup"]);
expect(eventProps.tags).toContainEqual(["title", "My Backup"]);
expect(eventProps.tags).toContainEqual(["description", "Test description"]);
expect(eventProps.tags).toContainEqual([
"description",
"Test description",
]);
expect(eventProps.tags).toContainEqual(["client", "grimoire"]);
// Check referenced spells (e tags)
expect(referencedSpells).toContain("spell-1");
expect(eventProps.tags).toContainEqual(["e", "spell-1", "", "mention"]);
@@ -95,7 +148,7 @@ describe("Spellbook Manager", () => {
expect(content.version).toBe(1);
expect(Object.keys(content.workspaces)).toHaveLength(1);
expect(content.workspaces["ws-1"]).toBeDefined();
// Should only include windows referenced in the workspace
expect(Object.keys(content.windows)).toHaveLength(1);
expect(content.windows["win-1"]).toBeDefined();
@@ -112,6 +165,38 @@ describe("Spellbook Manager", () => {
expect(Object.keys(content.workspaces)).toHaveLength(2);
expect(Object.keys(content.windows)).toHaveLength(2);
});
it("extracts k tags from REQ windows and deduplicates", () => {
const result = createSpellbook({
state: mockStateWithReq,
title: "REQ Dashboard",
});
const kTags = result.eventProps.tags.filter((t) => t[0] === "k");
// Should have k tags for kinds: 1, 4, 6, 7, 30023 (deduped and sorted)
expect(kTags).toContainEqual(["k", "1"]);
expect(kTags).toContainEqual(["k", "4"]);
expect(kTags).toContainEqual(["k", "6"]);
expect(kTags).toContainEqual(["k", "7"]);
expect(kTags).toContainEqual(["k", "30023"]);
// Should be sorted by kind number
expect(kTags).toHaveLength(5);
expect(kTags[0]).toEqual(["k", "1"]);
expect(kTags[kTags.length - 1]).toEqual(["k", "30023"]);
});
it("does not include k tags for non-REQ windows", () => {
const result = createSpellbook({
state: mockState,
title: "No REQ",
workspaceIds: ["ws-1"],
});
const kTags = result.eventProps.tags.filter((t) => t[0] === "k");
expect(kTags).toHaveLength(0);
});
});
describe("parseSpellbook", () => {
@@ -153,12 +238,14 @@ describe("Spellbook Manager", () => {
tags: [],
} as any;
expect(() => parseSpellbook(event)).toThrow("Failed to parse spellbook content");
expect(() => parseSpellbook(event)).toThrow(
"Failed to parse spellbook content",
);
});
});
describe("loadSpellbook", () => {
it("imports workspaces with new IDs and numbers", () => {
it("replaces all workspaces with imported ones and regenerates IDs", () => {
const spellbookContent = {
version: 1,
workspaces: { "ws-1": mockWorkspace1 },
@@ -173,65 +260,165 @@ describe("Spellbook Manager", () => {
event: {} as any,
};
// State has workspaces 1 and 2 used. Next available should be 3.
const newState = loadSpellbook(mockState, parsed);
// Should have 3 workspaces now (2 original + 1 imported)
expect(Object.keys(newState.workspaces)).toHaveLength(3);
// Should have only 1 workspace (replaces all existing)
expect(Object.keys(newState.workspaces)).toHaveLength(1);
// Get the new workspace
const newWs = Object.values(newState.workspaces)[0];
// Find the new workspace
const newWsEntry = Object.entries(newState.workspaces).find(
([id]) => id !== "ws-1" && id !== "ws-2"
);
expect(newWsEntry).toBeDefined();
const [newId, newWs] = newWsEntry!;
// IDs should be regenerated
expect(newId).not.toBe("ws-1");
expect(newWs.id).not.toBe("ws-1");
// Number should be 3 (lowest available)
expect(newWs.number).toBe(3);
// Number should be 1 (sequential assignment)
expect(newWs.number).toBe(1);
// Window IDs should be regenerated
const newWinId = newWs.windowIds[0];
expect(newWinId).not.toBe("win-1");
expect(newState.windows[newWinId]).toBeDefined();
expect(newState.windows[newWinId].appId).toBe("profile");
// Layout should reference new window ID
expect(newWs.layout).toBe(newWinId);
});
it("updates layout tree with new window IDs", () => {
const spellbookContent = {
version: 1,
workspaces: { "ws-2": mockWorkspace2 },
windows: { "win-1": mockWindow1, "win-2": mockWindow2 },
};
const spellbookContent = {
version: 1,
workspaces: { "ws-2": mockWorkspace2 },
windows: { "win-1": mockWindow1, "win-2": mockWindow2 },
};
const parsed = {
slug: "test",
title: "Test",
content: spellbookContent,
referencedSpells: [],
event: {} as any,
};
const parsed = {
slug: "test",
title: "Test",
content: spellbookContent,
referencedSpells: [],
event: {} as any,
};
const newState = loadSpellbook(mockState, parsed);
const newWs = Object.values(newState.workspaces).find(w => w.number === 3)!;
expect(typeof newWs.layout).toBe("object");
if (typeof newWs.layout === "object" && newWs.layout !== null) {
// Check that leaf nodes are new UUIDs, not old IDs
expect(newWs.layout.first).not.toBe("win-2");
expect(newWs.layout.second).not.toBe("win-1");
// Check that they match the windowIds list
expect(newWs.windowIds).toContain(newWs.layout.first);
expect(newWs.windowIds).toContain(newWs.layout.second);
}
const newState = loadSpellbook(mockState, parsed);
// Since loadSpellbook replaces all workspaces, the imported workspace gets number 1
const newWs = Object.values(newState.workspaces).find(
(w) => w.number === 1,
)!;
expect(typeof newWs.layout).toBe("object");
if (typeof newWs.layout === "object" && newWs.layout !== null) {
// Check that leaf nodes are new UUIDs, not old IDs
expect(newWs.layout.first).not.toBe("win-2");
expect(newWs.layout.second).not.toBe("win-1");
// Check that they match the windowIds list
expect(newWs.windowIds).toContain(newWs.layout.first);
expect(newWs.windowIds).toContain(newWs.layout.second);
}
});
it("sets activeSpellbook with source tracking from network event", () => {
const spellbookContent = {
version: 1,
workspaces: { "ws-1": mockWorkspace1 },
windows: { "win-1": mockWindow1 },
};
const event: SpellbookEvent = {
id: "event-123",
pubkey: "author-pubkey",
created_at: 123456,
kind: SPELLBOOK_KIND,
tags: [["d", "test"]],
content: JSON.stringify(spellbookContent),
sig: "sig",
};
const parsed = {
slug: "test",
title: "Test Title",
description: "Test Description",
content: spellbookContent,
referencedSpells: [],
event,
// Simulating network-loaded spellbook (no localId)
source: "network" as const,
isPublished: true,
};
const newState = loadSpellbook(mockState, parsed);
// Check activeSpellbook has enhanced fields
expect(newState.activeSpellbook).toBeDefined();
expect(newState.activeSpellbook?.slug).toBe("test");
expect(newState.activeSpellbook?.title).toBe("Test Title");
expect(newState.activeSpellbook?.description).toBe("Test Description");
expect(newState.activeSpellbook?.pubkey).toBe("author-pubkey");
expect(newState.activeSpellbook?.source).toBe("network");
expect(newState.activeSpellbook?.isPublished).toBe(true);
expect(newState.activeSpellbook?.localId).toBeUndefined();
});
it("sets activeSpellbook with source tracking from local spellbook", () => {
const spellbookContent = {
version: 1,
workspaces: { "ws-1": mockWorkspace1 },
windows: { "win-1": mockWindow1 },
};
const parsed = {
slug: "local-test",
title: "Local Test",
content: spellbookContent,
referencedSpells: [],
// Simulating local-loaded spellbook
localId: "local-uuid-123",
source: "local" as const,
isPublished: false,
};
const newState = loadSpellbook(mockState, parsed);
// Check activeSpellbook has enhanced fields
expect(newState.activeSpellbook).toBeDefined();
expect(newState.activeSpellbook?.slug).toBe("local-test");
expect(newState.activeSpellbook?.source).toBe("local");
expect(newState.activeSpellbook?.isPublished).toBe(false);
expect(newState.activeSpellbook?.localId).toBe("local-uuid-123");
expect(newState.activeSpellbook?.pubkey).toBeUndefined();
});
it("infers source from event presence when not provided", () => {
const spellbookContent = {
version: 1,
workspaces: { "ws-1": mockWorkspace1 },
windows: { "win-1": mockWindow1 },
};
const event: SpellbookEvent = {
id: "event-abc",
pubkey: "some-pubkey",
created_at: 123456,
kind: SPELLBOOK_KIND,
tags: [],
content: JSON.stringify(spellbookContent),
sig: "sig",
};
const parsed = {
slug: "inferred",
title: "Inferred",
content: spellbookContent,
referencedSpells: [],
event, // Has event, so should infer network
// No source, localId, or isPublished provided
};
const newState = loadSpellbook(mockState, parsed);
// Should infer source as "network" and isPublished as true from event presence
expect(newState.activeSpellbook?.source).toBe("network");
expect(newState.activeSpellbook?.isPublished).toBe(true);
});
});
});

View File

@@ -40,8 +40,8 @@ export function slugify(text: string): string {
.toLowerCase()
.trim()
.replace(/\s+/g, "-") // Replace spaces with -
.replace(/[^\w\-]+/g, "") // Remove all non-word chars
.replace(/\-\-+/g, "-"); // Replace multiple - with single -
.replace(/[^\w-]+/g, "") // Remove all non-word chars
.replace(/--+/g, "-"); // Replace multiple - with single -
}
/**
@@ -79,6 +79,7 @@ export function createSpellbook(
const selectedWorkspaces: Record<string, Workspace> = {};
const selectedWindows: Record<string, WindowInstance> = {};
const referencedSpells = new Set<string>();
const usedKinds = new Set<number>();
// 2. Collect workspaces and their windows
for (const wsId of targetWorkspaceIds) {
@@ -95,7 +96,7 @@ export function createSpellbook(
// Also include any loose windows in the workspace definition just in case
ws.windowIds.forEach((id) => windowIds.add(id));
// 3. Extract window instances
// 3. Extract window instances and analyze kinds
for (const winId of windowIds) {
const window = state.windows[winId];
if (window) {
@@ -103,6 +104,14 @@ export function createSpellbook(
if (window.spellId) {
referencedSpells.add(window.spellId);
}
// Extract kinds from REQ windows for filtering/discovery
if (window.appId === "req" && window.props?.filter?.kinds) {
for (const kind of window.props.filter.kinds) {
if (typeof kind === "number") {
usedKinds.add(kind);
}
}
}
}
}
}
@@ -133,6 +142,12 @@ export function createSpellbook(
tags.push(["e", spellId, "", "mention"]);
}
// Add k tags for kinds used in REQ windows (enables filtering/discovery)
const sortedKinds = Array.from(usedKinds).sort((a, b) => a - b);
for (const kind of sortedKinds) {
tags.push(["k", String(kind)]);
}
return {
eventProps: {
kind: SPELLBOOK_KIND,
@@ -150,13 +165,15 @@ export function parseSpellbook(event: SpellbookEvent): ParsedSpellbook {
let content: SpellbookContent;
try {
content = JSON.parse(event.content);
} catch (e) {
} catch (_e) {
throw new Error("Failed to parse spellbook content: Invalid JSON");
}
// Validate version (basic check)
if (!content.version || content.version < 1) {
console.warn("Spellbook missing version or invalid, attempting to load anyway");
console.warn(
"Spellbook missing version or invalid, attempting to load anyway",
);
}
// Extract metadata
@@ -214,7 +231,7 @@ export function compareSpellbookVersions(
created_at: number;
content: SpellbookContent;
id: string;
}
},
): {
hasConflict: boolean;
newerVersion: "local" | "network" | "same";
@@ -255,9 +272,7 @@ export function compareSpellbookVersions(
// 2. Local has been published (has eventId) AND
// 3. The event IDs don't match (different versions)
const hasConflict =
contentDiffers &&
!!local.eventId &&
local.eventId !== network.id;
contentDiffers && !!local.eventId && local.eventId !== network.id;
return {
hasConflict,
@@ -289,7 +304,7 @@ export function loadSpellbook(
spellbook: ParsedSpellbook,
): GrimoireState {
const { workspaces, windows } = spellbook.content;
// Maps to track old -> new IDs
const workspaceIdMap = new Map<string, string>();
const windowIdMap = new Map<string, string>();
@@ -302,7 +317,7 @@ export function loadSpellbook(
Object.values(windows).forEach((window) => {
const newId = uuidv4();
windowIdMap.set(window.id, newId);
// Create new window instance with new ID
newWindows[newId] = {
...window,
@@ -313,7 +328,7 @@ export function loadSpellbook(
// 3. Process Workspaces
// Sort by original number to preserve order
const sortedWorkspaces = Object.values(workspaces).sort(
(a, b) => a.number - b.number
(a, b) => a.number - b.number,
);
let firstNewWorkspaceId: string | null = null;
@@ -321,7 +336,7 @@ export function loadSpellbook(
sortedWorkspaces.forEach((ws) => {
const newWsId = uuidv4();
if (!firstNewWorkspaceId) firstNewWorkspaceId = newWsId;
workspaceIdMap.set(ws.id, newWsId);
// Update window IDs in the windowIds array
@@ -350,11 +365,15 @@ export function loadSpellbook(
windows: newWindows,
activeWorkspaceId: firstNewWorkspaceId || state.activeWorkspaceId,
activeSpellbook: {
id: spellbook.event?.id || uuidv4(), // Fallback to uuid if local
id: spellbook.event?.id || spellbook.localId || uuidv4(),
slug: spellbook.slug,
title: spellbook.title,
description: spellbook.description,
pubkey: spellbook.event?.pubkey,
// Enhanced fields for UX clarity:
source: spellbook.source || (spellbook.event ? "network" : "local"),
localId: spellbook.localId,
isPublished: spellbook.isPublished ?? !!spellbook.event,
},
};
}

View File

@@ -100,6 +100,10 @@ export interface GrimoireState {
slug: string; // d-tag
title: string;
description?: string;
pubkey?: string; // owner
pubkey?: string; // owner's pubkey (undefined = local-only, never published)
// Enhanced fields for better UX:
source: "local" | "network"; // Where the spellbook was loaded from
localId?: string; // Local DB ID if saved to library
isPublished?: boolean; // Whether it has been published to Nostr
};
}

View File

@@ -167,6 +167,12 @@ export interface ParsedSpellbook {
content: SpellbookContent;
/** IDs of spells referenced in this book (from e tags) */
referencedSpells: string[];
/** Full event reference */
event: SpellbookEvent;
/** Full event reference (may be undefined for local-only spellbooks) */
event?: SpellbookEvent;
/** Optional: Local DB ID if this spellbook is in the user's library */
localId?: string;
/** Optional: Whether this spellbook has been published to Nostr */
isPublished?: boolean;
/** Optional: Where this spellbook was loaded from */
source?: "local" | "network";
}