diff --git a/src/actions/publish-spellbook.ts b/src/actions/publish-spellbook.ts index d1464aa..849f60d 100644 --- a/src/actions/publish-spellbook.ts +++ b/src/actions/publish-spellbook.ts @@ -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; }; } diff --git a/src/components/Home.tsx b/src/components/Home.tsx index 6c333d5..3049ecb 100644 --- a/src/components/Home.tsx +++ b/src/components/Home.tsx @@ -74,7 +74,10 @@ export default function Home() { } else if (isNip05(actor)) { // Add timeout for NIP-05 resolution const timeoutPromise = new Promise((_, 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} > - Apply Layout + Apply Spellbook diff --git a/src/components/SaveSpellbookDialog.tsx b/src/components/SaveSpellbookDialog.tsx index a706b0f..73b5afd 100644 --- a/src/components/SaveSpellbookDialog.tsx +++ b/src/components/SaveSpellbookDialog.tsx @@ -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( 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({ - {isUpdateMode ? "Update Spellbook" : "Save Layout as Spellbook"} + {isUpdateMode ? "Update Spellbook" : "Save as Spellbook"} {isUpdateMode @@ -181,7 +197,7 @@ export function SaveSpellbookDialog({