mirror of
https://github.com/purrgrammer/grimoire.git
synced 2026-06-15 00:58:44 +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:
@@ -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 (
|
||||
|
||||
Reference in New Issue
Block a user