Compare commits

...
Author SHA1 Message Date
Tanish Manku 1abfc3457a Moodboard: rebuild sync as gated, per-account replication
The first cut was a naive fetch-and-push that raced its own hydration:
an async server-wins pull could clobber edits made while it loaded, a
just-reloaded (empty) canvas could overwrite the server document, a
module one-shot flag let a second account in the same page load keep
writing to the first account's rows, failed saves silently fell out of
the dirty set, and canvas edits were attributed to whichever board was
active at flush time.

Sync is now replication between durable replicas:

- The canvas persists locally per board (localStorage, blob-stripped),
  ending the 'empty because it just loaded' state entirely - reloads
  restore the canvas even logged out, and there is no window where a
  hollow canvas can masquerade as user intent.
- Saves are gated behind a hydration state machine (idle/hydrating/
  ready); nothing pushes from a partially loaded session.
- Dirty state is per board and durable (Board.needsSync persists), so
  closed tabs resume their pending push next session; pagehide flushes
  flags and replica synchronously.
- Hydration is dirty-aware last-write-wins - clean boards take the
  server copy, dirty boards keep local content and push it - with
  per-board error isolation and bounded concurrency.
- Sync is account-scoped: hydration re-runs per user id, and boards
  stamped with another account's ownerId are never pushed (localStorage
  is machine-shared; server rows must not be). The adapter seam now
  exposes getUserId/subscribeAuthState instead of a boolean.
- Failed pushes stay dirty and retry with exponential backoff; deletes
  are tombstoned so offline deletes can't resurrect on next hydration;
  save status reflects real dirty state (never 'Saved' with edits
  pending).
- Media whose token no longer resolves renders a visible placeholder
  that is never persisted, so resolution self-heals on every load.
- Grid multi-image drops place all tiles immediately again (uploads
  moved to background write-back), and canvas edits are attributed to
  the board that owned the canvas at edit time.

Webapp adapter: session user token as the account id, and the raw
document/delete/resolve fetches wrapped so a network rejection degrades
to a per-board failure instead of aborting hydration.
2026-07-20 18:02:10 -07:00
Tanish Manku ab9a6dbc9b Webapp: moodboard persistence over the project/mood_board endpoints
Implements the moodboard lib's persistence seam: saves create or update
mood_board project documents (multipart JSON, maybe_title = board name),
listing filters project/list by mood_board, loads fetch the document from
the media file's CDN link, deletes remove the media file, and cleared
blob srcs re-resolve through the media files batch endpoint. Sync is
gated on the session store, with a login-state subscription so it
activates once the async session fetch resolves.
2026-07-20 18:02:09 -07:00
Tanish Manku 1be5781601 Moodboard: server persistence with per-board documents and canvas restore
Each board now persists as one mood_board project document bundling the
grid model with the canvas state - nodes, root order, and the viewport
pan/zoom (MoodboardSnapshot omits the camera, so the document captures it
separately). Blob-backed items keep their place when they carry a media
token (src cleared and re-resolved on load); tokenless blobs are dropped.

A sync controller autosaves dirty boards (2s debounce) and hydrates
remote boards on mount - server wins for boards already linked by token,
since the canvas half never persists locally. The workspace gains a
manual Save button with live status and a board picker (switch, new,
delete) that swaps the canvas per board through a new bridge; both the
picker and remote sync only activate when the host adapter provides the
new optional persistence seam, so the Tauri app is unaffected.

Also fixes the upload token write-back gaps: canvas file-adds and pastes
discarded the uploaded media token (nodes stayed token-less forever), and
the grid's OS file drop never uploaded at all. Empty untouched default
boards are never pushed, so fresh visits don't litter the project list.
2026-07-20 18:02:09 -07:00
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);
}