mirror of
https://github.com/storytold/artcraft.git
synced 2026-10-20 17:02:53 -04:00
Frontend: Moodboard persistence (#1793)
This commit is contained in:
@@ -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);
|
||||
}
|
||||
Reference in New Issue
Block a user