Frontend: Moodboard persistence (#1793)

This commit is contained in:
Tanish Manku
2026-07-20 19:08:56 -07:00
committed by GitHub
parent cb4374ea70
commit 9c83c20c46
17 changed files with 1914 additions and 64 deletions
@@ -2,14 +2,17 @@ import { useState } from "react";
import { useNavigate } from "react-router-dom";
import toast from "react-hot-toast";
import { UploaderStates } from "@storyteller/common";
import { MediaFilesApi, ProjectsApi } from "@storyteller/api";
import { GalleryModal, GalleryItem } from "@storyteller/ui-gallery-modal";
import type {
MoodboardAdapter,
MoodboardPersistenceAdapter,
MoodboardReference,
MoodboardLibraryPickerProps,
} from "@storyteller/ui-moodboard";
import { uploadImage as webappUploadImage } from "../../components/prompt-box/upload-image";
import { useCreateImageStore } from "../create-image/create-image-store";
import { useSessionStore } from "../../lib/session";
import type { RefImage } from "../../components/prompt-box/types";
// Web (artcraft-webapp) implementation of the moodboard's platform seams.
@@ -100,10 +103,115 @@ const WebappLibraryPicker = ({
);
};
// Server persistence over the project-document endpoints: each board is one
// mood_board project (multipart JSON upload; save-new returns the media file
// token that links the board to its server row).
const persistence: MoodboardPersistenceAdapter = {
getUserId: () => useSessionStore.getState().user?.user_token ?? null,
subscribeAuthState: (onChange) => useSessionStore.subscribe(onChange),
saveBoard: async ({ token, name, documentJson }) => {
const api = new ProjectsApi();
const blob = new Blob([documentJson], { type: "application/json" });
const fileName = `${name || "Untitled board"}.mood.json`;
const uuid = crypto.randomUUID();
const response = token
? await api.UpdateProject({
projectType: "mood_board",
token,
blob,
fileName,
uuid,
maybe_title: name,
})
: await api.UploadNewProject({
projectType: "mood_board",
blob,
fileName,
uuid,
maybe_title: name,
});
return {
success: response.success,
token: response.data ?? token ?? undefined,
errorMessage: response.errorMessage,
};
},
listBoards: async () => {
const response = await new ProjectsApi().ListSessionProjects({
filter_project_type: "mood_board",
page_size: 100,
});
if (!response.success || !response.data) return { success: false };
return {
success: true,
boards: response.data.map((row) => ({
token: row.token,
name: row.maybe_title ?? "Untitled board",
updatedAt: row.updated_at,
})),
};
},
// The adapter contract is non-throwing; the raw fetch()es here reject on
// network failure, so wrap them — an escaped rejection would tear down
// the caller's whole hydration pass, not just this board.
loadBoard: async (token) => {
try {
const response = await new MediaFilesApi().GetMediaFileByToken({
mediaFileToken: token,
});
const cdnUrl = response.success
? response.data?.media_links?.cdn_url
: undefined;
if (!cdnUrl) return { success: false };
const documentResponse = await fetch(cdnUrl);
if (!documentResponse.ok) return { success: false };
return { success: true, documentJson: await documentResponse.text() };
} catch (error) {
console.error("[Moodboard] board document fetch failed:", error);
return { success: false };
}
},
deleteBoard: async (token) => {
try {
const response = await new MediaFilesApi().DeleteMediaFileByToken({
mediaFileToken: token,
asMod: false,
});
return response.success;
} catch (error) {
console.error("[Moodboard] board delete failed:", error);
return false;
}
},
resolveMediaUrls: async (tokens) => {
try {
const response = await new MediaFilesApi().ListMediaFilesByTokens({
mediaTokens: tokens,
});
const urlByToken: Record<string, string> = {};
for (const media of response.data ?? []) {
if (media.media_links?.cdn_url) {
urlByToken[media.token] = media.media_links.cdn_url;
}
}
return urlByToken;
} catch (error) {
console.error("[Moodboard] media URL resolution failed:", error);
return {};
}
},
};
export const webappMoodboardAdapter: MoodboardAdapter = {
uploadImage,
sendToGeneration,
renderLibraryPicker: (props) => <WebappLibraryPicker {...props} />,
persistence,
};
// Dedupe by token but keep tokenless refs (a manually-added prompt reference
@@ -23,8 +23,24 @@ export type {
MoodboardReference,
MoodboardPickedItem,
MoodboardLibraryPickerProps,
MoodboardPersistenceAdapter,
RemoteBoardMeta,
} from "./lib/adapter";
// Server persistence — document shape + sync status, for hosts that
// implement MoodboardPersistenceAdapter or build tooling around the
// persisted mood_board documents.
export {
serializeMoodboardDocument,
deserializeMoodboardDocument,
} from "./lib/persistence/documents";
export type {
MoodboardDocument,
MoodboardCanvasDocument,
} from "./lib/persistence/documents";
export { useMoodboardSyncStore } from "./lib/persistence/moodboardSync";
export type { MoodboardSaveStatus } from "./lib/persistence/moodboardSync";
// Gallery → board drop bridge. Host apps wire their gallery's drop callback to
// this; the grid + canvas views listen for the event it dispatches.
export {
@@ -0,0 +1,130 @@
import { useEffect, useRef, useState } from "react";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import {
faChevronDown,
faPlus,
faTrashCan,
} from "@fortawesome/pro-regular-svg-icons";
import { useShallow } from "zustand/react/shallow";
import { useBoardLibraryStore } from "./boards/BoardLibraryStore";
import {
applyCanvasDocument,
createBoardAndSwitch,
getCanvasForBoard,
switchActiveBoard,
} from "./persistence/canvasBridge";
import { deleteBoardEverywhere } from "./persistence/moodboardSync";
// Board switcher for the workspace's top-left cluster. Lists every board in
// the library (local + hydrated remote), switches the active board (swapping
// the canvas via the bridge), and hosts New/Delete. Rename stays on the grid
// toolbar's inline title.
export const BoardPicker = () => {
const { boards, boardOrder, activeBoardId } = useBoardLibraryStore(
useShallow((s) => ({
boards: s.boards,
boardOrder: s.boardOrder,
activeBoardId: s.activeBoardId,
})),
);
const [open, setOpen] = useState(false);
const rootRef = useRef<HTMLDivElement>(null);
const activeBoard = activeBoardId ? boards[activeBoardId] : null;
const orderedBoards = boardOrder
.map((id) => boards[id])
.filter((b) => Boolean(b));
useEffect(() => {
if (!open) return undefined;
const onPointerDown = (e: PointerEvent) => {
if (!rootRef.current?.contains(e.target as Node)) setOpen(false);
};
window.addEventListener("pointerdown", onPointerDown);
return () => window.removeEventListener("pointerdown", onPointerDown);
}, [open]);
const handleSwitch = (boardId: string) => {
switchActiveBoard(boardId);
setOpen(false);
};
const handleNewBoard = () => {
createBoardAndSwitch();
setOpen(false);
};
const handleDelete = async (boardId: string) => {
const board = boards[boardId];
if (!board) return;
if (!window.confirm(`Delete "${board.name}"? This can't be undone.`)) {
return;
}
const wasActive = activeBoardId === boardId;
await deleteBoardEverywhere(boardId);
if (wasActive) {
const nextActiveId = useBoardLibraryStore.getState().activeBoardId;
applyCanvasDocument(
nextActiveId ? getCanvasForBoard(nextActiveId) : null,
);
}
};
return (
<div ref={rootRef} className="relative">
<button
type="button"
onClick={() => setOpen((v) => !v)}
title="Switch board"
aria-label="Switch board"
className="flex max-w-[180px] items-center gap-2 rounded-xl px-3.5 py-1.5 text-sm font-medium text-base-fg/70 transition-colors duration-150 hover:text-base-fg focus:outline-none focus-visible:ring-2 focus-visible:ring-primary"
>
<span className="truncate">{activeBoard?.name ?? "Boards"}</span>
<FontAwesomeIcon icon={faChevronDown} className="h-3 w-3 shrink-0" />
</button>
{open && (
<div className="glass absolute left-0 top-full z-50 mt-1.5 w-64 rounded-2xl border border-ui-divider p-1.5 shadow-[0_12px_32px_-12px_rgba(0,0,0,0.6)]">
<div className="max-h-72 overflow-y-auto">
{orderedBoards.map((board) => (
<div
key={board.id}
className={[
"group flex items-center gap-1 rounded-xl",
board.id === activeBoardId
? "bg-base-fg/15"
: "hover:bg-base-fg/10",
].join(" ")}
>
<button
type="button"
onClick={() => handleSwitch(board.id)}
className="min-w-0 flex-1 truncate px-3 py-2 text-left text-sm text-base-fg focus:outline-none"
>
{board.name}
</button>
<button
type="button"
title={`Delete ${board.name}`}
aria-label={`Delete ${board.name}`}
onClick={() => void handleDelete(board.id)}
className="mr-1 flex h-7 w-7 shrink-0 items-center justify-center rounded-lg text-base-fg/40 opacity-0 transition-opacity hover:bg-red-500/20 hover:text-red-400 focus:opacity-100 group-hover:opacity-100"
>
<FontAwesomeIcon icon={faTrashCan} className="h-3 w-3" />
</button>
</div>
))}
</div>
<button
type="button"
onClick={handleNewBoard}
className="mt-1 flex w-full items-center gap-2 rounded-xl px-3 py-2 text-sm font-medium text-base-fg/70 transition-colors hover:bg-base-fg/10 hover:text-base-fg focus:outline-none"
>
<FontAwesomeIcon icon={faPlus} className="h-3 w-3" />
New board
</button>
</div>
)}
</div>
);
};
@@ -5,6 +5,10 @@ import {
faTableCells,
faVectorSquare,
faPlay,
faCloudArrowUp,
faCheck,
faTriangleExclamation,
faSpinnerThird,
} from "@fortawesome/pro-regular-svg-icons";
import { BoardGridView } from "./grid/BoardGridView";
import { PresentationView } from "./grid/PresentationView";
@@ -13,6 +17,11 @@ import { useActiveBoard } from "./boards/boardSelectors";
import type { ViewMode } from "./boards/boardTypes";
import type { MoodboardAdapter } from "./adapter";
import { Moodboard } from "./canvas/Moodboard";
import { BoardPicker } from "./BoardPicker";
import {
useMoodboardSync,
type MoodboardSaveStatus,
} from "./persistence/moodboardSync";
interface Props {
adapter: MoodboardAdapter;
@@ -31,6 +40,9 @@ export const MoodboardWorkspace = ({ adapter, topBarEndSlot }: Props) => {
const viewMode = useBoardLibraryStore((s) => s.viewMode);
const setViewMode = useBoardLibraryStore((s) => s.setViewMode);
const board = useActiveBoard();
// Remote board sync (no-op unless the adapter provides persistence and
// the user is signed in).
const { enabled: syncEnabled, status, saveNow } = useMoodboardSync(adapter);
// Presentation is a transient overlay, not a persisted view mode — so a
// reload never reopens straight into a slideshow.
const [presenting, setPresenting] = useState(false);
@@ -53,9 +65,11 @@ export const MoodboardWorkspace = ({ adapter, topBarEndSlot }: Props) => {
) : (
<Moodboard adapter={adapter} />
)}
<ViewSwitch mode={viewMode} onChange={setViewMode} />
<TopLeftCluster mode={viewMode} onChange={setViewMode} />
<TopRightCluster
onPresent={() => setPresenting(true)}
saveStatus={syncEnabled ? status : null}
onSave={syncEnabled ? saveNow : undefined}
endSlot={topBarEndSlot}
/>
{presenting && (
@@ -74,7 +88,9 @@ const OPTIONS: Array<{ mode: ViewMode; label: string; icon: IconDefinition }> =
{ mode: "canvas", label: "Canvas", icon: faVectorSquare },
];
const ViewSwitch = ({
// Top-left chrome: the board picker plus the Grid/Canvas view switch, sharing
// one glass island.
const TopLeftCluster = ({
mode,
onChange,
}: {
@@ -82,6 +98,8 @@ const ViewSwitch = ({
onChange: (m: ViewMode) => void;
}) => (
<div className="glass absolute left-3 top-3 z-40 flex items-center gap-0.5 rounded-2xl border border-ui-divider p-1 shadow-[0_8px_24px_-12px_rgba(0,0,0,0.45)]">
<BoardPicker />
<div className="mx-0.5 h-5 w-px bg-ui-divider" />
{OPTIONS.map((opt) => {
const active = opt.mode === mode;
return (
@@ -105,18 +123,79 @@ const ViewSwitch = ({
</div>
);
// Top-right chrome: the Present action (always present, including Tauri) and the
// host's relocated nav actions (webapp only, via endSlot). The wrapper is
// click-through so the gap between the two never blocks the board beneath.
// Manual save trigger + live sync state. Only rendered when remote
// persistence is active (adapter.persistence present + signed in).
const SaveButton = ({
status,
onSave,
}: {
status: MoodboardSaveStatus;
onSave: () => void;
}) => {
const icon =
status === "saving"
? faSpinnerThird
: status === "saved"
? faCheck
: status === "error"
? faTriangleExclamation
: faCloudArrowUp;
const label =
status === "saving"
? "Saving..."
: status === "saved"
? "Saved"
: status === "error"
? "Retry save"
: "Save";
return (
<button
type="button"
onClick={onSave}
disabled={status === "saving"}
title="Save board"
aria-label="Save board"
className={[
"flex items-center gap-2 rounded-xl px-3.5 py-1.5 text-sm font-medium transition-colors duration-150",
"focus:outline-none focus-visible:ring-2 focus-visible:ring-primary",
status === "error"
? "text-red-400 hover:text-red-300"
: "text-base-fg/55 hover:text-base-fg",
].join(" ")}
>
<FontAwesomeIcon
icon={icon}
className={[
"h-3.5 w-3.5",
status === "saving" ? "animate-spin" : "",
].join(" ")}
/>
{label}
</button>
);
};
// Top-right chrome: Save (when remote sync is active), the Present action
// (always present, including Tauri), and the host's relocated nav actions
// (webapp only, via endSlot). The wrapper is click-through so the gap between
// the clusters never blocks the board beneath.
const TopRightCluster = ({
onPresent,
saveStatus,
onSave,
endSlot,
}: {
onPresent: () => void;
saveStatus: MoodboardSaveStatus | null;
onSave?: () => void;
endSlot?: ReactNode;
}) => (
<div className="pointer-events-none absolute right-3 top-3 z-40 flex items-center gap-2">
<div className="glass pointer-events-auto flex items-center rounded-xl border border-ui-divider p-0.5 shadow-[0_8px_24px_-12px_rgba(0,0,0,0.45)]">
{saveStatus !== null && onSave && (
<SaveButton status={saveStatus} onSave={onSave} />
)}
<button
type="button"
onClick={onPresent}
@@ -21,6 +21,44 @@ export interface MoodboardLibraryPickerProps {
onPick: (items: MoodboardPickedItem[]) => void;
}
/** Summary row for a remotely persisted board (one mood_board project). */
export interface RemoteBoardMeta {
token: string;
name: string;
/** ISO timestamp of the server row's last update. */
updatedAt: string;
}
// Server persistence seam for boards. Each board maps to one mood_board
// project document on the backend; `saveBoard` creates the row when `token`
// is null and overwrites it otherwise. All members are promise-based and
// MUST NOT throw (failures come back as { success: false } / empty results)
// — the sync layer treats a rejection as an implementation bug.
export interface MoodboardPersistenceAdapter {
/** Stable id of the signed-in account, or null when logged out. Sync only
* runs for signed-in users, hydration is keyed per account, and boards
* are stamped with this id to block cross-account pushes. */
getUserId: () => string | null;
/** Notify when the auth state may have changed (session fetch resolving,
* logout, account switch). Returns an unsubscribe. Without this, a
* session that resolves after mount would never enable sync, and an
* account switch would keep syncing as the previous user. */
subscribeAuthState?: (onChange: () => void) => () => void;
saveBoard: (args: {
token: string | null;
name: string;
documentJson: string;
}) => Promise<{ success: boolean; token?: string; errorMessage?: string }>;
listBoards: () => Promise<{ success: boolean; boards?: RemoteBoardMeta[] }>;
loadBoard: (
token: string,
) => Promise<{ success: boolean; documentJson?: string }>;
deleteBoard?: (token: string) => Promise<boolean>;
/** Batch-resolve media tokens to display URLs, for items whose blob src
* couldn't be persisted. Missing tokens are simply absent from the map. */
resolveMediaUrls?: (tokens: string[]) => Promise<Record<string, string>>;
}
export interface MoodboardAdapter {
/** Upload a file, resolving to a durable media token (or null if the platform
* can't provide one). When present, uploaded images become reference-capable
@@ -34,4 +72,9 @@ export interface MoodboardAdapter {
/** Optional library/gallery picker as a render-prop. Omit to disable the
* "From library" action (e.g. when a platform's picker isn't wired yet). */
renderLibraryPicker?: (props: MoodboardLibraryPickerProps) => ReactNode;
/** Optional server persistence. When present (and the user is signed in),
* the workspace autosaves boards as mood_board project documents and
* hydrates remote boards on mount. Omit for localStorage-only platforms. */
persistence?: MoodboardPersistenceAdapter;
}
@@ -42,6 +42,25 @@ interface BoardLibraryState extends PersistedLibrary {
deleteBoard: (id: string) => void;
setActiveBoard: (id: string) => void;
// Remote sync bookkeeping (used by the persistence layer, not views).
setBoardRemoteToken: (id: string, token: string) => void;
// Flag local edits pending a server push (no-op if already flagged, so
// repeat edits don't churn board identity).
markBoardNeedsSync: (id: string) => void;
// Record a completed push/pull: clears needsSync and stamps ownership.
markBoardSynced: (id: string, args: { ownerId: string }) => void;
// Insert a remotely loaded board, or replace the content of the local
// board already linked to the token. Does not change the active board.
// Returns the (existing or new) local board id.
upsertRemoteBoard: (args: {
token: string;
name: string;
ownerId: string;
itemOrder: string[];
items: Record<string, BoardItem>;
sections: Board["sections"];
}) => string;
// Item entry — typed creators that fill base fields + aspect.
addImageItem: (
boardId: string,
@@ -182,6 +201,91 @@ export const useBoardLibraryStore = create<BoardLibraryState>()(
set({ activeBoardId: id, selectedItemIds: new Set() });
},
setBoardRemoteToken: (id, token) => {
set((s) => {
const board = s.boards[id];
if (!board || board.remoteToken === token) return s;
return {
boards: { ...s.boards, [id]: { ...board, remoteToken: token } },
};
});
},
markBoardNeedsSync: (id) => {
set((s) => {
const board = s.boards[id];
if (!board || board.needsSync === true) return s;
return {
boards: { ...s.boards, [id]: { ...board, needsSync: true } },
};
});
},
markBoardSynced: (id, { ownerId }) => {
set((s) => {
const board = s.boards[id];
if (!board) return s;
if (board.needsSync !== true && board.ownerId === ownerId) return s;
return {
boards: {
...s.boards,
[id]: { ...board, needsSync: false, ownerId },
},
};
});
},
upsertRemoteBoard: ({
token,
name,
ownerId,
itemOrder,
items,
sections,
}) => {
const existing = Object.values(get().boards).find(
(b) => b.remoteToken === token,
);
if (existing) {
set((s) => ({
boards: {
...s.boards,
[existing.id]: {
...existing,
name,
itemOrder,
items,
sections,
ownerId,
needsSync: false,
updatedAt: nextSeq(),
},
},
}));
return existing.id;
}
const id = uuidv4();
const order = nextSeq();
const board: Board = {
id,
name,
createdAt: order,
updatedAt: order,
itemOrder,
items,
sections,
remoteToken: token,
ownerId,
needsSync: false,
};
set((s) => ({
boards: { ...s.boards, [id]: board },
boardOrder: [...s.boardOrder, id],
}));
return id;
},
addImageItem: (boardId, data) =>
addItemToBoard(set, get, boardId, (base) => ({
...base,
@@ -82,6 +82,19 @@ export interface Board {
itemOrder: string[];
items: Record<string, BoardItem>;
sections: BoardSection[];
// Server-side project token (media_file_token of the mood_board document),
// set by the first successful remote save. Absent/null for local-only
// boards and for library payloads persisted before remote sync existed.
remoteToken?: string | null;
// User id of the account this board syncs to, stamped at hydration/first
// push. localStorage is machine-shared, so the sync layer refuses to push
// a board owned by a different account (cross-account write protection).
// Absent/null = unowned local board, adoptable by whoever pushes it first.
ownerId?: string | null;
// Durable dirty flag: true when local edits haven't reached the server
// yet. Persisted so a closed tab resumes its pending push next session,
// and so hydration knows local content is newer than the server copy.
needsSync?: boolean;
}
// Nominal aspect ratios for non-media items, so masonry stays deterministic.
@@ -10,6 +10,9 @@ interface UseReturn {
triggerGallery: () => void;
/** Adds an empty note and returns its id so the grid can open it for editing. */
addNote: () => string;
/** Add a local image file: places it from a blob URL, then uploads to
* capture a durable mediaToken. Also used by the grid's OS file drop. */
addImageFile: (file: File) => Promise<void>;
modals: ReactNode;
}
@@ -42,8 +45,10 @@ export const useBoardItemEntry = (
return id;
}, []);
// Place the image immediately from a blob, then (if the platform supports it)
// upload to capture a durable mediaToken so it becomes reference-capable.
// Place the image immediately from a blob; the upload that captures a
// durable mediaToken runs in the background with a write-back, so a
// multi-file add/drop places every tile at once instead of gating each
// placement behind the previous file's upload roundtrip.
const addImageFile = useCallback(
async (file: File) => {
const store = useBoardLibraryStore.getState();
@@ -57,15 +62,19 @@ export const useBoardItemEntry = (
naturalH: dims.h,
});
if (!adapter.uploadImage) return;
try {
const token = await adapter.uploadImage(file);
if (token) {
adapter.uploadImage(file).then(
(token) => {
if (!token) return;
const current = useBoardLibraryStore.getState();
// The item may have been deleted while the upload was in flight.
if (!current.boards[boardId]?.items[itemId]) return;
const patch: Partial<BoardImageItem> = { mediaId: token };
useBoardLibraryStore.getState().updateItem(boardId, itemId, patch);
}
} catch (err) {
console.error("[Moodboard] upload failed", err);
}
current.updateItem(boardId, itemId, patch);
},
(err) => {
console.error("[Moodboard] upload failed", err);
},
);
},
[adapter],
);
@@ -164,7 +173,7 @@ export const useBoardItemEntry = (
</>
);
return { triggerUpload, triggerGallery, addNote, modals };
return { triggerUpload, triggerGallery, addNote, addImageFile, modals };
};
// ---------- helpers ----------
@@ -0,0 +1,44 @@
import { useMoodboardStore } from "./MoodboardStore";
import { measureImage } from "../boards/measureMedia";
import type { MoodboardAdapter } from "../adapter";
import type { Vec2 } from "./types";
// Place a local image file on the canvas immediately (blob URL), then upload
// in the background and write the returned media token back onto the node.
// Without the write-back the node stays token-less forever: it can't be sent
// to generation and can't survive persistence (blob URLs die with the page).
export async function addCanvasImageFromFile({
file,
position,
adapter,
}: {
file: File;
position: Vec2;
adapter: MoodboardAdapter;
}): Promise<string> {
const blobUrl = URL.createObjectURL(file);
let dims = { w: 320, h: 320 };
try {
dims = await measureImage(blobUrl);
} catch (err) {
console.error("[Moodboard] paste measure failed", err);
}
const nodeId = useMoodboardStore
.getState()
.addImage(blobUrl, position, dims.w, dims.h, null);
if (adapter.uploadImage) {
adapter.uploadImage(file).then(
(token) => {
if (!token) return;
const store = useMoodboardStore.getState();
// The node may have been deleted while the upload was in flight.
if (store.nodes[nodeId]) store.updateNode(nodeId, { mediaId: token });
},
(err) => {
console.error("[Moodboard] background upload failed", err);
},
);
}
return nodeId;
}
@@ -1,28 +1,17 @@
import { useEffect } from "react";
import Konva from "konva";
import { useMoodboardStore } from "../MoodboardStore";
import { addCanvasImageFromFile } from "../addCanvasImageFile";
import type { MoodboardAdapter } from "../../adapter";
// Loads natural dimensions for an image URL.
const measure = (url: string): Promise<{ w: number; h: number }> =>
new Promise((resolve, reject) => {
const i = new window.Image();
i.onload = () => resolve({ w: i.naturalWidth, h: i.naturalHeight });
i.onerror = reject;
i.src = url;
});
// Listens for paste events while the moodboard page is mounted. For each
// image entry on the clipboard, immediately drops a blob-URL image onto the
// canvas at the viewport center, then asynchronously uploads it to the
// library so it lives in the user's gallery.
// canvas at the viewport center, then asynchronously uploads it and writes
// the returned media token back onto the node (see addCanvasImageFromFile).
export const usePasteHandler = (
adapter: MoodboardAdapter,
active: boolean,
stageRef: React.RefObject<Konva.Stage | null>,
) => {
const addImage = useMoodboardStore((s) => s.addImage);
useEffect(() => {
if (!active) return undefined;
@@ -50,23 +39,11 @@ export const usePasteHandler = (
: { x: 400, y: 400 };
for (const file of fileItems) {
const blobUrl = URL.createObjectURL(file);
try {
const dims = await measure(blobUrl);
addImage(blobUrl, center, dims.w, dims.h, null);
} catch (err) {
console.error("[Moodboard] paste measure failed", err);
addImage(blobUrl, center, 320, 320, null);
}
// Fire-and-forget: also push to the platform's media library so the
// image persists in the user's gallery.
adapter.uploadImage?.(file).catch((err) => {
console.error("[Moodboard] background upload failed", err);
});
await addCanvasImageFromFile({ file, position: center, adapter });
}
};
window.addEventListener("paste", handler);
return () => window.removeEventListener("paste", handler);
}, [adapter, active, addImage, stageRef]);
}, [adapter, active, stageRef]);
};
@@ -4,6 +4,7 @@ import toast from "react-hot-toast";
import { useMoodboardStore } from "./MoodboardStore";
import { Vec2 } from "./types";
import { measureImage, measureVideo } from "../boards/measureMedia";
import { addCanvasImageFromFile } from "./addCanvasImageFile";
import type { MoodboardAdapter, MoodboardPickedItem } from "../adapter";
const stageCenter = (stage: Konva.Stage | null): Vec2 => {
@@ -47,16 +48,12 @@ export const useMoodboardImageEntry = (
const center = stageCenter(stageRef.current);
for (const file of Array.from(files)) {
if (!file.type.startsWith("image/")) continue;
const blobUrl = URL.createObjectURL(file);
const dims = await measureImage(blobUrl);
addImage(blobUrl, center, dims.w, dims.h, null);
// Background upload so the image also lands in the user's library.
adapter.uploadImage?.(file).catch((err) => {
console.error("[Moodboard] background upload failed", err);
});
// Places the node from a blob URL, uploads in the background, and
// writes the returned media token onto the node.
await addCanvasImageFromFile({ file, position: center, adapter });
}
},
[addImage, stageRef, adapter],
[stageRef, adapter],
);
const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
@@ -8,7 +8,6 @@ import {
} from "../boards/boardSelectors";
import toast from "react-hot-toast";
import { useBoardItemEntry } from "../boards/useBoardItemEntry";
import { measureImage } from "../boards/measureMedia";
import { useGalleryBoardDrop } from "./useGalleryBoardDrop";
import { BoardImageItem, BoardItem } from "../boards/boardTypes";
import type { MoodboardAdapter } from "../adapter";
@@ -62,7 +61,7 @@ export const BoardGridView = ({ active, adapter }: Props) => {
(s) => s.assignItemsToSection,
);
const { triggerUpload, triggerGallery, addNote, modals } =
const { triggerUpload, triggerGallery, addNote, addImageFile, modals } =
useBoardItemEntry(adapter, active);
const [dragOver, setDragOver] = useState(false);
@@ -200,17 +199,10 @@ export const BoardGridView = ({ active, adapter }: Props) => {
f.type.startsWith("image/"),
);
if (files.length === 0) return;
const store = useBoardLibraryStore.getState();
const boardId = store.ensureActiveBoard();
// Same path as the upload button: place from a blob URL, then upload in
// the background so the item gains a durable mediaToken.
for (const file of files) {
const url = URL.createObjectURL(file);
const dims = await measureImage(url);
store.addImageItem(boardId, {
src: url,
mediaId: null,
naturalW: dims.w,
naturalH: dims.h,
});
await addImageFile(file);
}
};
@@ -0,0 +1,183 @@
import { useMoodboardStore } from "../canvas/MoodboardStore";
import { useMoodboardHistoryStore } from "../canvas/MoodboardHistoryStore";
import { useBoardLibraryStore } from "../boards/BoardLibraryStore";
import { withDirtySuppressed } from "./dirtySuppression";
import {
EMPTY_CANVAS_DOCUMENT,
persistableCanvas,
type MoodboardCanvasDocument,
} from "./documents";
// The Konva canvas store is a single global surface with no board concept.
// This bridge keys canvas documents by board id and mirrors them to
// localStorage, making the canvas a durable local replica just like the
// grid model (which the board store already persists):
// - switching boards swaps the live canvas through the map;
// - a reload restores each board's canvas instead of starting empty — so
// there is never a window where an "empty because it just loaded" canvas
// can masquerade as user intent and overwrite real content anywhere;
// - the sync layer serializes map entries, never a maybe-stale live view.
// Blob-URL nodes are stripped at the storage boundary (object URLs die with
// the page); the in-memory map keeps them so within-session switches are
// lossless.
const CANVAS_STORAGE_KEY = "artcraft_moodboard_canvas_v1";
const PERSIST_DEBOUNCE_MS = 500;
const canvasByBoard = new Map<string, MoodboardCanvasDocument>();
let loadedFromStorage = false;
let persistTimer: ReturnType<typeof setTimeout> | null = null;
export function captureCanvasDocument(): MoodboardCanvasDocument {
const state = useMoodboardStore.getState();
return structuredClone({
nodes: state.nodes,
rootOrder: state.rootOrder,
viewport: state.viewport,
canvasSize: state.canvasSize,
gridSpacing: state.gridSpacing,
snapEnabled: state.snapEnabled,
});
}
// Replaces the live canvas. Restores nodes, order, viewport (pan + zoom),
// grid spacing, and snap; selection and undo history reset. canvasSize is
// deliberately not applied — the stage re-measures its container. Runs with
// dirty tracking suppressed: swapping a board's canvas in is not an edit.
export function applyCanvasDocument(
document: MoodboardCanvasDocument | null,
): void {
const doc = document ?? EMPTY_CANVAS_DOCUMENT;
withDirtySuppressed(() => {
useMoodboardHistoryStore.getState().clear();
useMoodboardStore.setState({
nodes: structuredClone(doc.nodes),
rootOrder: [...doc.rootOrder],
selectedIds: new Set(),
viewport: structuredClone(doc.viewport),
gridSpacing: doc.gridSpacing,
snapEnabled: doc.snapEnabled,
});
});
}
export function setCanvasForBoard(
boardId: string,
document: MoodboardCanvasDocument,
): void {
ensureLoaded();
canvasByBoard.set(boardId, document);
schedulePersist();
}
export function getCanvasForBoard(
boardId: string,
): MoodboardCanvasDocument | null {
ensureLoaded();
return canvasByBoard.get(boardId) ?? null;
}
export function dropCanvasForBoard(boardId: string): void {
ensureLoaded();
canvasByBoard.delete(boardId);
schedulePersist();
}
// Stash the live canvas under the given board id (the owner of what's on
// screen). Called by the sync layer whenever the canvas changes, and by the
// switch flow before swapping boards.
export function stashLiveCanvas(boardId: string): void {
setCanvasForBoard(boardId, captureCanvasDocument());
}
// Restore the live canvas from the replica on page load. Applied once by
// the workspace mount; a no-op when the board has no stored canvas.
export function restoreLiveCanvasForBoard(boardId: string | null): void {
if (!boardId) return;
const stored = getCanvasForBoard(boardId);
if (stored) applyCanvasDocument(stored);
}
// Board-switch handoff: stash the live canvas under the outgoing board, then
// apply the incoming board's canvas (or a blank one).
export function switchCanvasBetweenBoards({
fromBoardId,
toBoardId,
}: {
fromBoardId: string | null;
toBoardId: string;
}): void {
if (fromBoardId && fromBoardId !== toBoardId) {
stashLiveCanvas(fromBoardId);
}
applyCanvasDocument(getCanvasForBoard(toBoardId));
}
// Activate another board, swapping the canvas along with it.
export function switchActiveBoard(boardId: string): void {
const store = useBoardLibraryStore.getState();
if (store.activeBoardId === boardId || !store.boards[boardId]) return;
switchCanvasBetweenBoards({
fromBoardId: store.activeBoardId,
toBoardId: boardId,
});
store.setActiveBoard(boardId);
}
// Create + activate a fresh board with a blank canvas.
export function createBoardAndSwitch(): string {
const store = useBoardLibraryStore.getState();
const fromBoardId = store.activeBoardId;
const boardId = store.createBoard();
switchCanvasBetweenBoards({ fromBoardId, toBoardId: boardId });
return boardId;
}
// Synchronous write for pagehide/teardown — the debounce must not be the
// reason a closing tab loses its last edit.
export function persistCanvasReplicaNow(): void {
if (persistTimer) {
clearTimeout(persistTimer);
persistTimer = null;
}
writeToStorage();
}
// ---------- helpers ----------
function ensureLoaded(): void {
if (loadedFromStorage) return;
loadedFromStorage = true;
try {
const raw = localStorage.getItem(CANVAS_STORAGE_KEY);
if (!raw) return;
const parsed = JSON.parse(raw) as Record<string, MoodboardCanvasDocument>;
for (const [boardId, doc] of Object.entries(parsed)) {
canvasByBoard.set(boardId, { ...EMPTY_CANVAS_DOCUMENT, ...doc });
}
} catch (error) {
console.error("[Moodboard] canvas replica load failed:", error);
}
}
function schedulePersist(): void {
if (persistTimer) clearTimeout(persistTimer);
persistTimer = setTimeout(() => {
persistTimer = null;
writeToStorage();
}, PERSIST_DEBOUNCE_MS);
}
function writeToStorage(): void {
try {
const serializable: Record<string, MoodboardCanvasDocument> = {};
for (const [boardId, doc] of canvasByBoard.entries()) {
serializable[boardId] = persistableCanvas(doc);
}
localStorage.setItem(CANVAS_STORAGE_KEY, JSON.stringify(serializable));
} catch (error) {
// Quota or serialization failure: the in-memory map still covers the
// session; the server replica covers durability for synced boards.
console.error("[Moodboard] canvas replica persist failed:", error);
}
}
@@ -0,0 +1,17 @@
// Reentrant guard shared by the sync controller and the canvas bridge:
// store writes made by the persistence machinery itself (hydration inserts,
// token write-back, board-switch canvas swaps) must not re-mark boards
// dirty, or every sync-driven write would schedule another save.
let depth = 0;
export const isDirtySuppressed = (): boolean => depth > 0;
export function withDirtySuppressed<T>(run: () => T): T {
depth++;
try {
return run();
} finally {
depth--;
}
}
@@ -0,0 +1,232 @@
import { describe, expect, it } from "vitest";
import type { Board } from "../boards/boardTypes";
import type { ImageNode, GroupNode } from "../canvas/types";
import {
applyResolvedMediaUrls,
collectUnresolvedMediaTokens,
deserializeMoodboardDocument,
serializeMoodboardDocument,
EMPTY_CANVAS_DOCUMENT,
UNRESOLVED_MEDIA_SRC,
type MoodboardCanvasDocument,
} from "./documents";
describe("moodboard document serialization", () => {
it("round-trips board, canvas nodes, and viewport through JSON", () => {
const document = serializeMoodboardDocument({
board: buildBoard(),
canvas: buildCanvas(),
});
const restored = deserializeMoodboardDocument(JSON.stringify(document));
expect(restored).not.toBeNull();
expect(restored?.board.name).toBe("Concept wall");
expect(restored?.board.items["item-remote"]).toBeDefined();
expect(restored?.canvas.viewport).toEqual({
zoom: 1.75,
pan: { x: -120, y: 40 },
});
expect(restored?.canvas.gridSpacing).toBe(24);
expect(restored?.canvas.snapEnabled).toBe(false);
expect(restored?.canvas.rootOrder).toContain("node-remote");
});
it("drops tokenless blob items and keeps tokened ones with cleared src", () => {
const document = serializeMoodboardDocument({
board: buildBoard(),
canvas: buildCanvas(),
});
// Board: blob item without token dropped; with token kept as src "".
expect(document.board.items["item-blob-untokened"]).toBeUndefined();
expect(document.board.itemOrder).not.toContain("item-blob-untokened");
const keptItem = document.board.items["item-blob-tokened"];
expect(keptItem).toBeDefined();
expect((keptItem as { src: string }).src).toBe("");
// Canvas: same rules, plus group membership pruning.
expect(document.canvas.nodes["node-blob-untokened"]).toBeUndefined();
expect(document.canvas.rootOrder).not.toContain("node-blob-untokened");
const keptNode = document.canvas.nodes["node-blob-tokened"] as ImageNode;
expect(keptNode.src).toBe("");
const group = document.canvas.nodes["node-group"] as GroupNode;
expect(group.childIds).toEqual(["node-remote"]);
});
it("collects and re-applies unresolved media tokens", () => {
const document = serializeMoodboardDocument({
board: buildBoard(),
canvas: buildCanvas(),
});
const tokens = collectUnresolvedMediaTokens(document);
expect(tokens.sort()).toEqual(["m_board1", "m_canvas1"]);
const patched = applyResolvedMediaUrls(document, {
m_board1: "https://cdn/board1.png",
m_canvas1: "https://cdn/canvas1.png",
});
expect(
(patched.board.items["item-blob-tokened"] as { src: string }).src,
).toBe("https://cdn/board1.png");
expect((patched.canvas.nodes["node-blob-tokened"] as ImageNode).src).toBe(
"https://cdn/canvas1.png",
);
});
it("rejects malformed payloads", () => {
expect(deserializeMoodboardDocument("not json")).toBeNull();
expect(deserializeMoodboardDocument("42")).toBeNull();
expect(deserializeMoodboardDocument("{}")).toBeNull();
});
it("gives unresolved tokens a visible placeholder that stays self-healing", () => {
const document = serializeMoodboardDocument({
board: buildBoard(),
canvas: buildCanvas(),
});
// Backend returned nothing for these tokens (media deleted): the item
// stays visible with the placeholder instead of an invisible tile.
const patched = applyResolvedMediaUrls(document, {});
expect(
(patched.board.items["item-blob-tokened"] as { src: string }).src,
).toBe(UNRESOLVED_MEDIA_SRC);
expect((patched.canvas.nodes["node-blob-tokened"] as ImageNode).src).toBe(
UNRESOLVED_MEDIA_SRC,
);
// The placeholder is never persisted: a re-serialize clears it back to
// "" so every future load re-attempts resolution (self-healing if the
// media comes back), and the token stays collectable.
const reserialized = serializeMoodboardDocument({
board: {
...buildBoard(),
items: patched.board.items,
itemOrder: patched.board.itemOrder,
},
canvas: { ...buildCanvas(), nodes: patched.canvas.nodes },
});
expect(
(reserialized.board.items["item-blob-tokened"] as { src: string }).src,
).toBe("");
expect(collectUnresolvedMediaTokens(reserialized)).toContain("m_board1");
});
});
// ---------- fixtures ----------
function buildBoard(): Board {
return {
id: "board-1",
name: "Concept wall",
createdAt: 1,
updatedAt: 2,
itemOrder: ["item-remote", "item-blob-tokened", "item-blob-untokened"],
items: {
"item-remote": {
id: "item-remote",
kind: "image",
sectionId: null,
createdAt: 1,
tags: [],
aspect: 1,
rating: 0,
src: "https://cdn/remote.png",
mediaId: "m_remote1",
naturalW: 100,
naturalH: 100,
caption: "",
},
"item-blob-tokened": {
id: "item-blob-tokened",
kind: "image",
sectionId: null,
createdAt: 2,
tags: [],
aspect: 1,
rating: 0,
src: "blob:https://app/abc",
mediaId: "m_board1",
naturalW: 100,
naturalH: 100,
caption: "",
},
"item-blob-untokened": {
id: "item-blob-untokened",
kind: "image",
sectionId: null,
createdAt: 3,
tags: [],
aspect: 1,
rating: 0,
src: "blob:https://app/def",
mediaId: null,
naturalW: 100,
naturalH: 100,
caption: "",
},
},
sections: [],
remoteToken: null,
};
}
function buildCanvas(): MoodboardCanvasDocument {
const base = {
parentId: null,
x: 0,
y: 0,
width: 100,
height: 100,
rotation: 0,
zIndex: 0,
};
return {
...EMPTY_CANVAS_DOCUMENT,
viewport: { zoom: 1.75, pan: { x: -120, y: 40 } },
gridSpacing: 24,
snapEnabled: false,
rootOrder: [
"node-remote",
"node-blob-tokened",
"node-blob-untokened",
"node-group",
],
nodes: {
"node-remote": {
...base,
id: "node-remote",
kind: "image",
src: "https://cdn/remote.png",
mediaId: "m_remote1",
naturalW: 100,
naturalH: 100,
},
"node-blob-tokened": {
...base,
id: "node-blob-tokened",
kind: "image",
src: "blob:https://app/abc",
mediaId: "m_canvas1",
naturalW: 100,
naturalH: 100,
},
"node-blob-untokened": {
...base,
id: "node-blob-untokened",
kind: "image",
src: "blob:https://app/def",
mediaId: null,
naturalW: 100,
naturalH: 100,
},
"node-group": {
...base,
id: "node-group",
kind: "group",
childIds: ["node-remote", "node-blob-untokened"],
},
},
};
}
@@ -0,0 +1,252 @@
import type { Board, BoardItem, BoardSection } from "../boards/boardTypes";
import type { MoodboardNode } from "../canvas/types";
import type { CanvasSize, Viewport } from "../canvas/layout/geometry";
// The JSON document persisted per board (one mood_board project row on the
// backend). Bundles the durable grid model with the canvas state so a load
// restores the canvas exactly as it was left — nodes, pan, and zoom.
export interface MoodboardCanvasDocument {
nodes: Record<string, MoodboardNode>;
rootOrder: string[];
viewport: Viewport;
// Stage size at save time. Persisted for context but not re-applied on
// load (the stage re-measures its container on mount).
canvasSize: CanvasSize;
gridSpacing: number;
snapEnabled: boolean;
}
export interface MoodboardDocument {
version: 1;
board: {
name: string;
itemOrder: string[];
items: Record<string, BoardItem>;
sections: BoardSection[];
};
canvas: MoodboardCanvasDocument;
}
export const EMPTY_CANVAS_DOCUMENT: MoodboardCanvasDocument = {
nodes: {},
rootOrder: [],
viewport: { zoom: 1, pan: { x: 0, y: 0 } },
canvasSize: { width: 800, height: 600 },
gridSpacing: 32,
snapEnabled: true,
};
// Blob object URLs die with the page, so they can't be persisted:
// - items/nodes with a mediaId keep their place with src cleared to "" —
// the sync layer re-resolves the token to a CDN URL on load;
// - tokenless blob items/nodes are dropped entirely (nothing could ever
// display them again). Dropped canvas nodes are also pruned from
// rootOrder and from group/card childIds.
export function serializeMoodboardDocument({
board,
canvas,
}: {
board: Board;
canvas: MoodboardCanvasDocument;
}): MoodboardDocument {
return {
version: 1,
board: {
name: board.name,
...persistableBoardContent(board),
sections: board.sections,
},
canvas: persistableCanvas(canvas),
};
}
export function deserializeMoodboardDocument(
documentJson: string,
): MoodboardDocument | null {
let parsed: unknown;
try {
parsed = JSON.parse(documentJson);
} catch {
return null;
}
if (!parsed || typeof parsed !== "object") return null;
const document = parsed as MoodboardDocument;
if (!document.board || typeof document.board !== "object") return null;
return {
version: 1,
board: {
name: document.board.name ?? "Untitled board",
itemOrder: document.board.itemOrder ?? [],
items: document.board.items ?? {},
sections: document.board.sections ?? [],
},
canvas: {
...EMPTY_CANVAS_DOCUMENT,
...(document.canvas ?? {}),
},
};
}
// Rendered in place of media whose token couldn't be resolved (deleted from
// the library, or the resolve call failed). Keeps the item visible instead
// of an invisible/broken tile. Serialization treats it like an empty src, so
// the token is re-resolved on every load — self-healing if the media comes
// back.
export const UNRESOLVED_MEDIA_SRC =
"data:image/svg+xml," +
encodeURIComponent(
'<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 96 96">' +
'<rect width="96" height="96" fill="#2a2a2e"/>' +
'<path d="M30 62l12-16 9 11 6-7 9 12H30z" fill="#55555c"/>' +
'<circle cx="38" cy="34" r="6" fill="#55555c"/>' +
"</svg>",
);
// True when a media src needs (re-)resolution from its token: never
// persisted ("" after a blob strip) or previously unresolved (placeholder).
const needsMediaResolution = (src: string): boolean =>
src === "" || src === UNRESOLVED_MEDIA_SRC;
// Media tokens whose display URL needs re-resolution after a load (src was a
// blob URL at save time and got cleared).
export function collectUnresolvedMediaTokens(
document: MoodboardDocument,
): string[] {
const tokens = new Set<string>();
for (const item of Object.values(document.board.items)) {
if (
(item.kind === "image" || item.kind === "video") &&
needsMediaResolution(item.src) &&
item.mediaId
) {
tokens.add(item.mediaId);
}
}
for (const node of Object.values(document.canvas.nodes)) {
if (
(node.kind === "image" || node.kind === "video") &&
needsMediaResolution(node.src) &&
node.mediaId
) {
tokens.add(node.mediaId);
}
}
return Array.from(tokens);
}
// Patch resolved URLs back into a loaded document (on a copy). Tokens the
// backend didn't return get the placeholder src so the item stays visible.
export function applyResolvedMediaUrls(
document: MoodboardDocument,
urlByToken: Record<string, string>,
): MoodboardDocument {
const items = { ...document.board.items };
for (const [id, item] of Object.entries(items)) {
if (
(item.kind === "image" || item.kind === "video") &&
needsMediaResolution(item.src) &&
item.mediaId
) {
items[id] = {
...item,
src: urlByToken[item.mediaId] ?? UNRESOLVED_MEDIA_SRC,
};
}
}
const nodes = { ...document.canvas.nodes };
for (const [id, node] of Object.entries(nodes)) {
if (
(node.kind === "image" || node.kind === "video") &&
needsMediaResolution(node.src) &&
node.mediaId
) {
nodes[id] = {
...node,
src: urlByToken[node.mediaId] ?? UNRESOLVED_MEDIA_SRC,
};
}
}
return {
...document,
board: { ...document.board, items },
canvas: { ...document.canvas, nodes },
};
}
// ---------- helpers ----------
// Blob object URLs and the unresolved placeholder both die with the
// document — persist them as "" (token-only) so loads re-resolve.
const isEphemeralSrc = (src: string): boolean =>
src.startsWith("blob:") || src === UNRESOLVED_MEDIA_SRC;
function persistableBoardContent(board: Board): {
itemOrder: string[];
items: Record<string, BoardItem>;
} {
const items: Record<string, BoardItem> = {};
for (const [id, item] of Object.entries(board.items)) {
if (item.kind !== "image" && item.kind !== "video") {
items[id] = item;
continue;
}
if (!isEphemeralSrc(item.src)) {
items[id] = item;
continue;
}
if (item.mediaId) {
items[id] = { ...item, src: "" };
}
// blob src without a token: dropped.
}
return {
itemOrder: board.itemOrder.filter((id) => items[id]),
items,
};
}
export function persistableCanvas(
canvas: MoodboardCanvasDocument,
): MoodboardCanvasDocument {
const nodes: Record<string, MoodboardNode> = {};
const dropped = new Set<string>();
for (const [id, node] of Object.entries(canvas.nodes)) {
if (node.kind !== "image" && node.kind !== "video") {
nodes[id] = node;
continue;
}
if (!isEphemeralSrc(node.src)) {
nodes[id] = node;
continue;
}
if (node.mediaId) {
nodes[id] = { ...node, src: "" };
} else {
dropped.add(id);
}
}
// Prune dropped node ids from group/card membership.
if (dropped.size > 0) {
for (const [id, node] of Object.entries(nodes)) {
if (node.kind === "group" || node.kind === "card") {
const childIds = node.childIds.filter((child) => !dropped.has(child));
if (childIds.length !== node.childIds.length) {
nodes[id] = { ...node, childIds };
}
}
}
}
return {
nodes,
rootOrder: canvas.rootOrder.filter((id) => nodes[id]),
viewport: canvas.viewport,
canvasSize: canvas.canvasSize,
gridSpacing: canvas.gridSpacing,
snapEnabled: canvas.snapEnabled,
};
}
@@ -0,0 +1,654 @@
import { useEffect, useState } from "react";
import { create } from "zustand";
import { useBoardLibraryStore } from "../boards/BoardLibraryStore";
import { useMoodboardStore } from "../canvas/MoodboardStore";
import type {
MoodboardAdapter,
MoodboardPersistenceAdapter,
} from "../adapter";
import {
applyResolvedMediaUrls,
collectUnresolvedMediaTokens,
deserializeMoodboardDocument,
serializeMoodboardDocument,
EMPTY_CANVAS_DOCUMENT,
} from "./documents";
import {
applyCanvasDocument,
dropCanvasForBoard,
getCanvasForBoard,
persistCanvasReplicaNow,
restoreLiveCanvasForBoard,
setCanvasForBoard,
stashLiveCanvas,
} from "./canvasBridge";
import { isDirtySuppressed, withDirtySuppressed } from "./dirtySuppression";
// Remote sync controller. Both the grid model and the canvas are durable
// LOCAL replicas (board store → zustand persist; canvas → canvasBridge), so
// sync is a replication problem, not a fetch-and-push:
//
// - Saves are gated behind hydration ("ready" phase): nothing is ever
// pushed from a partially loaded session, so a just-loaded state can
// never overwrite real server content.
// - Dirty state is per board and durable (Board.needsSync persists), so a
// closed tab resumes its pending push next session and hydration knows
// when local content is newer than the server copy.
// - Hydration is dirty-aware last-write-wins: clean boards take the server
// copy; dirty boards keep local content and push it instead. It never
// clobbers unsaved work.
// - Sync is scoped per account: hydration re-runs on account change, and a
// board stamped with another account's ownerId is never pushed
// (localStorage is machine-shared; server rows are not).
// - Failed pushes stay dirty and retry with exponential backoff. Deletes
// are tombstoned so a board removed while offline can't resurrect from
// the server on the next hydration.
const AUTOSAVE_DEBOUNCE_MS = 2000;
const RETRY_BASE_MS = 5000;
const RETRY_MAX_MS = 60_000;
const HYDRATE_CONCURRENCY = 4;
const TOMBSTONE_STORAGE_KEY = "artcraft_moodboard_deleted_tokens_v1";
const DEFAULT_BOARD_NAME = "Untitled board";
export type MoodboardSaveStatus =
| "idle"
| "dirty"
| "saving"
| "saved"
| "error";
interface MoodboardSyncState {
status: MoodboardSaveStatus;
setStatus: (status: MoodboardSaveStatus) => void;
}
export const useMoodboardSyncStore = create<MoodboardSyncState>((set) => ({
status: "idle",
setStatus: (status) => set({ status }),
}));
// ---------- module-level controller state ----------
type SyncPhase = "idle" | "hydrating" | "ready";
let persistenceRef: MoodboardPersistenceAdapter | null = null;
let syncPhase: SyncPhase = "idle";
// Account the current hydration/ready phase belongs to. A change of user
// resets the machine and re-hydrates.
let syncUserId: string | null = null;
const dirtyBoardIds = new Set<string>();
let saveTimer: ReturnType<typeof setTimeout> | null = null;
let isSaving = false;
let hasPendingFlush = false;
let retryAttempt = 0;
let pageHideHooked = false;
let restoredLiveCanvas = false;
const setStatus = (status: MoodboardSaveStatus) =>
useMoodboardSyncStore.getState().setStatus(status);
// ---------- public surface ----------
export function useMoodboardSync(adapter: MoodboardAdapter): {
enabled: boolean;
status: MoodboardSaveStatus;
saveNow: () => void;
} {
const status = useMoodboardSyncStore((s) => s.status);
const persistence = adapter.persistence;
// The session resolves asynchronously, so track the account reactively —
// a boolean sampled once would miss both late logins and account
// switches.
const [userId, setUserId] = useState<string | null>(
() => persistence?.getUserId() ?? null,
);
useEffect(() => {
if (!persistence) return undefined;
const update = () => setUserId(persistence.getUserId());
update();
return persistence.subscribeAuthState?.(update);
}, [persistence]);
// Local-replica wiring, active regardless of login: restore the active
// board's canvas from storage on first mount, keep the replica stashed as
// the canvas changes, and flush it synchronously when the page hides.
useEffect(() => {
restoreLiveCanvasOnce();
hookPageHide();
const unsubscribeCanvas = useMoodboardStore.subscribe((state, prev) => {
if (isDirtySuppressed()) return;
if (
state.nodes === prev.nodes &&
state.rootOrder === prev.rootOrder &&
state.viewport === prev.viewport &&
state.snapEnabled === prev.snapEnabled
) {
return;
}
// Attribute the edit to the board that owns the canvas RIGHT NOW —
// resolving later (at flush time) would blame whichever board the
// user had switched to meanwhile.
const boardId = useBoardLibraryStore.getState().activeBoardId;
if (!boardId) return;
stashLiveCanvas(boardId);
markBoardDirty(boardId);
});
const unsubscribeBoards = useBoardLibraryStore.subscribe((state, prev) => {
if (isDirtySuppressed()) return;
if (state.boards === prev.boards) return;
for (const [id, board] of Object.entries(state.boards)) {
if (prev.boards[id] !== board) markBoardDirty(id);
}
});
return () => {
unsubscribeCanvas();
unsubscribeBoards();
};
}, []);
// Server sync, gated on a signed-in account.
useEffect(() => {
persistenceRef = persistence ?? null;
if (!persistence || !userId) {
// Logged out (or logged out mid-session): stop pushing. Local
// replicas and needsSync flags stay — pushes resume on login.
syncPhase = "idle";
clearSaveTimer();
return;
}
if (syncUserId !== userId) {
// First login of this page load, or an account switch: reset the
// machine and hydrate for this user. Dirty bookkeeping is rebuilt
// from the durable needsSync flags.
syncUserId = userId;
syncPhase = "hydrating";
clearSaveTimer();
dirtyBoardIds.clear();
retryAttempt = 0;
seedDirtyFromDurableFlags(userId);
void hydrateRemoteBoards(persistence, userId);
} else if (syncPhase === "ready") {
// Same user re-mounting the workspace: nothing to hydrate.
scheduleFlushIfDirty();
}
}, [persistence, userId]);
return {
enabled: Boolean(persistence) && userId !== null,
status,
saveNow: flushNow,
};
}
// Immediate save of everything dirty (manual Save button). Also treats the
// active board as dirty so the button always produces a fresh server copy.
export function flushNow(): void {
const activeId = useBoardLibraryStore.getState().activeBoardId;
if (activeId) {
stashLiveCanvas(activeId);
markBoardDirty(activeId);
}
clearSaveTimer();
void flush();
}
// Delete a board locally and (when linked) remotely. The token is
// tombstoned first so a failed remote delete can't resurrect the board from
// the server on the next hydration — the delete is retried then instead.
export async function deleteBoardEverywhere(boardId: string): Promise<void> {
const store = useBoardLibraryStore.getState();
const token = store.boards[boardId]?.remoteToken;
store.deleteBoard(boardId);
dropCanvasForBoard(boardId);
dirtyBoardIds.delete(boardId);
if (!token) return;
addTombstone(token);
if (persistenceRef?.deleteBoard) {
try {
const deleted = await persistenceRef.deleteBoard(token);
if (deleted) removeTombstone(token);
} catch (error) {
console.error("[Moodboard] remote board delete failed:", error);
}
}
}
// ---------- dirty tracking + scheduling ----------
function markBoardDirty(boardId: string): void {
dirtyBoardIds.add(boardId);
// Durable flag: survives reload, and tells hydration that local wins.
withDirtySuppressed(() => {
useBoardLibraryStore.getState().markBoardNeedsSync(boardId);
});
if (canSaveNow()) {
scheduleFlush(AUTOSAVE_DEBOUNCE_MS);
}
if (useMoodboardSyncStore.getState().status !== "saving") {
setStatus("dirty");
}
}
function canSaveNow(): boolean {
return (
syncPhase === "ready" &&
persistenceRef !== null &&
syncUserId !== null &&
persistenceRef.getUserId() === syncUserId
);
}
function scheduleFlush(delayMs: number): void {
if (saveTimer) clearTimeout(saveTimer);
saveTimer = setTimeout(() => {
saveTimer = null;
void flush();
}, delayMs);
}
function scheduleFlushIfDirty(): void {
if (dirtyBoardIds.size > 0) scheduleFlush(AUTOSAVE_DEBOUNCE_MS);
}
function clearSaveTimer(): void {
if (saveTimer) {
clearTimeout(saveTimer);
saveTimer = null;
}
}
function seedDirtyFromDurableFlags(userId: string): void {
const { boards } = useBoardLibraryStore.getState();
for (const board of Object.values(boards)) {
if (board.ownerId && board.ownerId !== userId) continue;
if (board.needsSync) dirtyBoardIds.add(board.id);
}
if (dirtyBoardIds.size > 0) setStatus("dirty");
}
// ---------- flush / save ----------
async function flush(): Promise<void> {
if (isSaving) {
hasPendingFlush = true;
return;
}
if (!canSaveNow()) {
// Not ready yet (still hydrating, or logged out). Dirty state is
// durable; hydration completion / login re-schedules.
return;
}
const persistence = persistenceRef as MoodboardPersistenceAdapter;
const userId = syncUserId as string;
if (dirtyBoardIds.size === 0) {
setStatus("saved");
return;
}
isSaving = true;
setStatus("saving");
const ids = Array.from(dirtyBoardIds);
dirtyBoardIds.clear();
const failed: string[] = [];
try {
for (const id of ids) {
const ok = await saveBoard(persistence, userId, id);
if (!ok) failed.push(id);
}
} finally {
isSaving = false;
}
// Failed boards stay dirty and retry with backoff — a transient network
// error must never silently drop edits from the push queue.
failed.forEach((id) => dirtyBoardIds.add(id));
if (failed.length > 0) {
retryAttempt += 1;
setStatus("error");
scheduleFlush(
Math.min(RETRY_BASE_MS * 2 ** (retryAttempt - 1), RETRY_MAX_MS),
);
} else {
retryAttempt = 0;
if (dirtyBoardIds.size > 0 || hasPendingFlush) {
// Edits arrived while saving.
setStatus("dirty");
scheduleFlush(AUTOSAVE_DEBOUNCE_MS);
} else {
setStatus("saved");
}
}
hasPendingFlush = false;
}
async function saveBoard(
persistence: MoodboardPersistenceAdapter,
userId: string,
boardId: string,
): Promise<boolean> {
const state = useBoardLibraryStore.getState();
const board = state.boards[boardId];
if (!board) return true;
// Cross-account write protection: a board stamped for another account is
// never pushed from this one, even though it is visible in the
// machine-shared local library.
if (board.ownerId && board.ownerId !== userId) {
console.warn(
"[Moodboard] skipping push of board owned by another account:",
boardId,
);
return true;
}
// The replica map is the canvas authority; the live canvas is stashed
// into it on every edit and board switch.
if (boardId === state.activeBoardId) stashLiveCanvas(boardId);
const canvas = getCanvasForBoard(boardId) ?? EMPTY_CANVAS_DOCUMENT;
// Never-touched scratch boards (auto-created empty "Untitled board")
// don't get server rows — a row per visit would litter the project list.
const isUntouched =
!board.remoteToken &&
board.itemOrder.length === 0 &&
board.sections.length === 0 &&
board.name === DEFAULT_BOARD_NAME &&
canvas.rootOrder.length === 0;
if (isUntouched) {
withDirtySuppressed(() => {
useBoardLibraryStore.getState().markBoardSynced(boardId, {
ownerId: userId,
});
});
return true;
}
const documentJson = JSON.stringify(
serializeMoodboardDocument({ board, canvas }),
);
try {
const result = await persistence.saveBoard({
token: board.remoteToken ?? null,
name: board.name,
documentJson,
});
if (!result.success) {
console.error("[Moodboard] board save failed:", result.errorMessage);
return false;
}
withDirtySuppressed(() => {
const store = useBoardLibraryStore.getState();
if (result.token && !board.remoteToken) {
store.setBoardRemoteToken(boardId, result.token);
}
// Only clear the durable dirty flag if nothing changed while the
// save was in flight — a mid-save edit re-marked it and must
// survive to the next push. Board content fields are compared (not
// identity, which sync's own token write-back churns); the canvas
// is compared by replica-entry reference (every stash replaces it).
const canvasUnchanged =
(getCanvasForBoard(boardId) ?? EMPTY_CANVAS_DOCUMENT) === canvas;
if (!boardEditedSince(board, boardId) && canvasUnchanged) {
store.markBoardSynced(boardId, { ownerId: userId });
}
});
return true;
} catch (error) {
console.error("[Moodboard] board save failed:", error);
return false;
}
}
// True when the board's content changed after `snapshot` was captured.
// Compares content fields, not identity — sync's own bookkeeping writes
// (token write-back) change identity without changing content.
function boardEditedSince(snapshot: Board, boardId: string): boolean {
const current = useBoardLibraryStore.getState().boards[boardId];
if (!current) return false;
return (
current.items !== snapshot.items ||
current.itemOrder !== snapshot.itemOrder ||
current.sections !== snapshot.sections ||
current.name !== snapshot.name
);
}
// ---------- hydration ----------
async function hydrateRemoteBoards(
persistence: MoodboardPersistenceAdapter,
userId: string,
): Promise<void> {
try {
const listed = await persistence.listBoards();
// Abandon silently if the account changed mid-flight.
if (syncUserId !== userId) return;
if (listed.success && listed.boards) {
await retryTombstonedDeletes(persistence);
const tombstones = readTombstones();
const rows = listed.boards.filter((row) => !tombstones.has(row.token));
// Each board hydrates independently — one bad row/fetch must not
// strand every board after it.
await mapWithConcurrency(rows, HYDRATE_CONCURRENCY, async (row) => {
try {
await hydrateBoard(persistence, userId, row.token, row.name);
} catch (error) {
console.error(
"[Moodboard] board hydration failed:",
row.token,
error,
);
}
});
} else {
console.error("[Moodboard] board list failed; pushing dirty only");
}
} catch (error) {
console.error("[Moodboard] hydration failed:", error);
} finally {
if (syncUserId === userId) {
syncPhase = "ready";
// Adopt content-bearing local boards that never reached the server
// (created logged-out, or before sync existed) so signing in
// uploads them rather than leaving them stranded on this machine.
seedUnsyncedLocalBoards(userId);
if (dirtyBoardIds.size > 0) {
setStatus("dirty");
scheduleFlush(AUTOSAVE_DEBOUNCE_MS);
} else {
setStatus("saved");
}
}
}
}
async function hydrateBoard(
persistence: MoodboardPersistenceAdapter,
userId: string,
token: string,
name: string,
): Promise<void> {
const local = Object.values(useBoardLibraryStore.getState().boards).find(
(b) => b.remoteToken === token,
);
// Dirty-aware LWW: unsaved local edits win — they are strictly newer
// than the server copy from this device's perspective, and the pending
// push reconciles the server. Applying the server copy here would
// destroy them.
if (local?.needsSync) {
dirtyBoardIds.add(local.id);
return;
}
// Owned by a different account: hands off entirely.
if (local?.ownerId && local.ownerId !== userId) return;
const loaded = await persistence.loadBoard(token);
if (syncUserId !== userId) return;
if (!loaded.success || !loaded.documentJson) return;
let doc = deserializeMoodboardDocument(loaded.documentJson);
if (!doc) return;
const unresolved = collectUnresolvedMediaTokens(doc);
if (unresolved.length > 0 && persistence.resolveMediaUrls) {
try {
const urls = await persistence.resolveMediaUrls(unresolved);
doc = applyResolvedMediaUrls(doc, urls);
} catch (error) {
console.error("[Moodboard] media URL resolution failed:", error);
doc = applyResolvedMediaUrls(doc, {});
}
}
if (syncUserId !== userId) return;
const applied = doc;
withDirtySuppressed(() => {
const store = useBoardLibraryStore.getState();
// Re-check dirtiness at apply time: the user may have edited this
// board while its document was downloading.
const current = Object.values(store.boards).find(
(b) => b.remoteToken === token,
);
if (current?.needsSync) {
dirtyBoardIds.add(current.id);
return;
}
const boardId = store.upsertRemoteBoard({
token,
name: applied.board.name || name,
ownerId: userId,
itemOrder: applied.board.itemOrder,
items: applied.board.items,
sections: applied.board.sections,
});
setCanvasForBoard(boardId, applied.canvas);
if (boardId === store.activeBoardId) {
applyCanvasDocument(applied.canvas);
}
});
}
// Non-empty boards without a server row (and not owned by someone else)
// become part of this account's push backlog after hydration.
function seedUnsyncedLocalBoards(userId: string): void {
const { boards } = useBoardLibraryStore.getState();
for (const board of Object.values(boards)) {
if (board.remoteToken) continue;
if (board.ownerId && board.ownerId !== userId) continue;
const canvas = getCanvasForBoard(board.id);
const hasContent =
board.itemOrder.length > 0 ||
board.sections.length > 0 ||
board.name !== DEFAULT_BOARD_NAME ||
(canvas?.rootOrder.length ?? 0) > 0;
if (hasContent) dirtyBoardIds.add(board.id);
}
}
// ---------- delete tombstones ----------
function readTombstones(): Set<string> {
try {
const raw = localStorage.getItem(TOMBSTONE_STORAGE_KEY);
if (!raw) return new Set();
return new Set(JSON.parse(raw) as string[]);
} catch {
return new Set();
}
}
function writeTombstones(tokens: Set<string>): void {
try {
localStorage.setItem(
TOMBSTONE_STORAGE_KEY,
JSON.stringify(Array.from(tokens)),
);
} catch (error) {
console.error("[Moodboard] tombstone persist failed:", error);
}
}
function addTombstone(token: string): void {
const tokens = readTombstones();
tokens.add(token);
writeTombstones(tokens);
}
function removeTombstone(token: string): void {
const tokens = readTombstones();
if (!tokens.delete(token)) return;
writeTombstones(tokens);
}
async function retryTombstonedDeletes(
persistence: MoodboardPersistenceAdapter,
): Promise<void> {
if (!persistence.deleteBoard) return;
for (const token of readTombstones()) {
try {
const deleted = await persistence.deleteBoard(token);
if (deleted) removeTombstone(token);
} catch (error) {
console.error("[Moodboard] tombstoned delete retry failed:", error);
}
}
}
// ---------- page lifecycle ----------
function restoreLiveCanvasOnce(): void {
if (restoredLiveCanvas) return;
restoredLiveCanvas = true;
restoreLiveCanvasForBoard(useBoardLibraryStore.getState().activeBoardId);
}
function hookPageHide(): void {
if (pageHideHooked || typeof window === "undefined") return;
pageHideHooked = true;
window.addEventListener("pagehide", () => {
// Durability on exit is local-first: flush the canvas replica and the
// needsSync flags synchronously (multipart uploads can't be relied on
// during unload); the next session pushes the backlog.
const activeId = useBoardLibraryStore.getState().activeBoardId;
if (activeId && dirtyBoardIds.has(activeId)) stashLiveCanvas(activeId);
withDirtySuppressed(() => {
const store = useBoardLibraryStore.getState();
dirtyBoardIds.forEach((id) => store.markBoardNeedsSync(id));
});
persistCanvasReplicaNow();
});
}
// ---------- small utils ----------
async function mapWithConcurrency<T>(
items: T[],
limit: number,
run: (item: T) => Promise<void>,
): Promise<void> {
let nextIndex = 0;
const workers = Array.from(
{ length: Math.min(limit, items.length) },
async () => {
while (nextIndex < items.length) {
const index = nextIndex++;
await run(items[index]);
}
},
);
await Promise.all(workers);
}