141 lines
4.3 KiB
TypeScript
141 lines
4.3 KiB
TypeScript
"use client";
|
|
|
|
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
|
|
|
import { useDebouncedValue } from "@/hooks/useDebouncedValue";
|
|
import { fetchProject, patchProjectSceneData } from "@/lib/project-api";
|
|
import { isDevProjectId } from "@/lib/project-ids";
|
|
import { isImageSceneDataEmpty } from "@/lib/image-scene-data";
|
|
import { useImageEditorStore } from "@/lib/image-editor-store";
|
|
import {
|
|
PROJECT_SAVE_DEBOUNCE_MS,
|
|
PROJECT_SAVED_DISPLAY_MS,
|
|
type ProjectSaveStatus,
|
|
} from "@/lib/project-save-status";
|
|
|
|
export interface UseImageProjectPersistenceResult {
|
|
projectName: string;
|
|
setProjectName: (name: string) => void;
|
|
saveStatus: ProjectSaveStatus;
|
|
retrySave: () => void;
|
|
}
|
|
|
|
export function useImageProjectPersistence(
|
|
projectId: string | undefined
|
|
): UseImageProjectPersistenceResult {
|
|
const layers = useImageEditorStore((state) => state.layers);
|
|
const canvasWidth = useImageEditorStore((state) => state.canvasWidth);
|
|
const canvasHeight = useImageEditorStore((state) => state.canvasHeight);
|
|
const adjustments = useImageEditorStore((state) => state.adjustments);
|
|
const activeFilterPreset = useImageEditorStore(
|
|
(state) => state.activeFilterPreset
|
|
);
|
|
const hydrateFromSceneData = useImageEditorStore(
|
|
(state) => state.hydrateFromSceneData
|
|
);
|
|
const getSceneDataForSave = useImageEditorStore(
|
|
(state) => state.getSceneDataForSave
|
|
);
|
|
|
|
const [projectName, setProjectName] = useState("Untitled image");
|
|
const [saveStatus, setSaveStatus] = useState<ProjectSaveStatus>("idle");
|
|
|
|
const skipSaveRef = useRef(true);
|
|
const lastSavedPayloadRef = useRef<string | null>(null);
|
|
const savedTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
|
|
|
const persistPayload = useMemo(
|
|
() => JSON.stringify(getSceneDataForSave()),
|
|
[layers, canvasWidth, canvasHeight, adjustments, activeFilterPreset]
|
|
);
|
|
|
|
const debouncedPayload = useDebouncedValue(
|
|
persistPayload,
|
|
PROJECT_SAVE_DEBOUNCE_MS
|
|
);
|
|
|
|
const clearSavedTimer = useCallback(() => {
|
|
if (savedTimerRef.current) {
|
|
clearTimeout(savedTimerRef.current);
|
|
savedTimerRef.current = null;
|
|
}
|
|
}, []);
|
|
|
|
const performSave = useCallback(
|
|
async (payloadJson: string) => {
|
|
if (!projectId || isDevProjectId(projectId)) return;
|
|
|
|
setSaveStatus("saving");
|
|
try {
|
|
const scene_data = JSON.parse(payloadJson) as Record<string, unknown>;
|
|
await patchProjectSceneData(projectId, scene_data);
|
|
lastSavedPayloadRef.current = payloadJson;
|
|
setSaveStatus("saved");
|
|
clearSavedTimer();
|
|
savedTimerRef.current = setTimeout(() => {
|
|
setSaveStatus((current) => (current === "saved" ? "idle" : current));
|
|
}, PROJECT_SAVED_DISPLAY_MS);
|
|
} catch {
|
|
setSaveStatus("error");
|
|
}
|
|
},
|
|
[projectId, clearSavedTimer]
|
|
);
|
|
|
|
const retrySave = useCallback(() => {
|
|
void performSave(persistPayload);
|
|
}, [performSave, persistPayload]);
|
|
|
|
useEffect(() => {
|
|
if (!projectId || isDevProjectId(projectId)) return;
|
|
|
|
let cancelled = false;
|
|
skipSaveRef.current = true;
|
|
|
|
void (async () => {
|
|
try {
|
|
const { project } = await fetchProject(projectId);
|
|
if (cancelled) return;
|
|
|
|
setProjectName(project.name);
|
|
|
|
if (!isImageSceneDataEmpty(project.scene_data)) {
|
|
hydrateFromSceneData(project.scene_data);
|
|
}
|
|
|
|
lastSavedPayloadRef.current = JSON.stringify(
|
|
useImageEditorStore.getState().getSceneDataForSave()
|
|
);
|
|
} catch {
|
|
if (!cancelled) {
|
|
setSaveStatus("error");
|
|
}
|
|
} finally {
|
|
if (!cancelled) {
|
|
skipSaveRef.current = false;
|
|
}
|
|
}
|
|
})();
|
|
|
|
return () => {
|
|
cancelled = true;
|
|
};
|
|
}, [projectId, hydrateFromSceneData]);
|
|
|
|
useEffect(() => {
|
|
if (!projectId || isDevProjectId(projectId) || skipSaveRef.current) return;
|
|
if (persistPayload === lastSavedPayloadRef.current) return;
|
|
setSaveStatus("pending");
|
|
}, [projectId, persistPayload]);
|
|
|
|
useEffect(() => {
|
|
if (!projectId || isDevProjectId(projectId) || skipSaveRef.current) return;
|
|
if (debouncedPayload === lastSavedPayloadRef.current) return;
|
|
void performSave(debouncedPayload);
|
|
}, [projectId, debouncedPayload, performSave]);
|
|
|
|
useEffect(() => clearSavedTimer, [clearSavedTimer]);
|
|
|
|
return { projectName, setProjectName, saveStatus, retrySave };
|
|
}
|