mirror of
https://github.com/purrgrammer/grimoire.git
synced 2026-04-13 08:57:04 +02:00
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:
@@ -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;
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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";
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user