feat: full studio build -- light theme, canvas thumbnails, i18n (fa/en)
This commit is contained in:
@@ -0,0 +1,242 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
|
||||
import { useDebouncedValue } from "@/hooks/useDebouncedValue";
|
||||
import {
|
||||
isDevelopmentEnv,
|
||||
loadLocalProject,
|
||||
saveLocalProject,
|
||||
} from "@/lib/dev-project-storage";
|
||||
import {
|
||||
fetchProject,
|
||||
isProjectNotFoundError,
|
||||
patchProjectSceneData,
|
||||
} from "@/lib/project-api";
|
||||
import { isDevProjectId } from "@/lib/project-ids";
|
||||
import {
|
||||
PROJECT_SAVE_DEBOUNCE_MS,
|
||||
PROJECT_SAVED_DISPLAY_MS,
|
||||
type ProjectSaveStatus,
|
||||
} from "@/lib/project-save-status";
|
||||
import { isVideoSceneDataEmpty } from "@/lib/studio-scene-data";
|
||||
import { useStudioStore } from "@/lib/studio-store";
|
||||
|
||||
export interface UseStudioProjectPersistenceResult {
|
||||
projectName: string;
|
||||
setProjectName: (name: string) => void;
|
||||
saveStatus: ProjectSaveStatus;
|
||||
usingLocalStorage: boolean;
|
||||
retrySave: () => void;
|
||||
}
|
||||
|
||||
function applyLocalSnapshot(
|
||||
projectId: string,
|
||||
hydrateFromSceneData: (sceneData: Record<string, unknown>) => boolean,
|
||||
setProjectName: (name: string) => void
|
||||
): string | null {
|
||||
const local = loadLocalProject(projectId);
|
||||
if (!local) return null;
|
||||
|
||||
if (local.name) {
|
||||
setProjectName(local.name);
|
||||
}
|
||||
|
||||
if (!isVideoSceneDataEmpty(local.scene_data)) {
|
||||
hydrateFromSceneData(local.scene_data);
|
||||
}
|
||||
|
||||
return JSON.stringify(local.scene_data);
|
||||
}
|
||||
|
||||
export function useStudioProjectPersistence(
|
||||
projectId: string
|
||||
): UseStudioProjectPersistenceResult {
|
||||
const scenes = useStudioStore((state) => state.scenes);
|
||||
const activeSceneId = useStudioStore((state) => state.activeSceneId);
|
||||
const currentTime = useStudioStore((state) => state.currentTime);
|
||||
const pxPerSecond = useStudioStore((state) => state.pxPerSecond);
|
||||
const audioFileName = useStudioStore((state) => state.audioFileName);
|
||||
const audioSrc = useStudioStore((state) => state.audioSrc);
|
||||
const audioVolume = useStudioStore((state) => state.audioVolume);
|
||||
const sceneBackgroundColor = useStudioStore(
|
||||
(state) => state.sceneBackgroundColor
|
||||
);
|
||||
const sceneAccentColor = useStudioStore((state) => state.sceneAccentColor);
|
||||
const hydrateFromSceneData = useStudioStore(
|
||||
(state) => state.hydrateFromSceneData
|
||||
);
|
||||
const getSceneDataForSave = useStudioStore((state) => state.getSceneDataForSave);
|
||||
|
||||
const [projectName, setProjectName] = useState(
|
||||
() => `Project ${projectId.slice(0, 8)}`
|
||||
);
|
||||
const [saveStatus, setSaveStatus] = useState<ProjectSaveStatus>("idle");
|
||||
const [usingLocalStorage, setUsingLocalStorage] = useState(false);
|
||||
|
||||
const skipSaveRef = useRef(true);
|
||||
const lastSavedPayloadRef = useRef<string | null>(null);
|
||||
const savedTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
const projectNameRef = useRef(projectName);
|
||||
|
||||
useEffect(() => {
|
||||
projectNameRef.current = projectName;
|
||||
}, [projectName]);
|
||||
|
||||
const persistPayload = useMemo(
|
||||
() => JSON.stringify(getSceneDataForSave()),
|
||||
[
|
||||
scenes,
|
||||
activeSceneId,
|
||||
currentTime,
|
||||
pxPerSecond,
|
||||
audioFileName,
|
||||
audioSrc,
|
||||
audioVolume,
|
||||
sceneBackgroundColor,
|
||||
sceneAccentColor,
|
||||
]
|
||||
);
|
||||
|
||||
const debouncedPayload = useDebouncedValue(
|
||||
persistPayload,
|
||||
PROJECT_SAVE_DEBOUNCE_MS
|
||||
);
|
||||
|
||||
const clearSavedTimer = useCallback(() => {
|
||||
if (savedTimerRef.current) {
|
||||
clearTimeout(savedTimerRef.current);
|
||||
savedTimerRef.current = null;
|
||||
}
|
||||
}, []);
|
||||
|
||||
const markSaved = useCallback(
|
||||
(payloadJson: string, local: boolean) => {
|
||||
lastSavedPayloadRef.current = payloadJson;
|
||||
setSaveStatus(local ? "local" : "saved");
|
||||
clearSavedTimer();
|
||||
savedTimerRef.current = setTimeout(() => {
|
||||
setSaveStatus((current) =>
|
||||
current === "saved" || current === "local" ? "idle" : current
|
||||
);
|
||||
}, PROJECT_SAVED_DISPLAY_MS);
|
||||
},
|
||||
[clearSavedTimer]
|
||||
);
|
||||
|
||||
const saveToLocalStorage = useCallback(
|
||||
(payloadJson: string) => {
|
||||
const scene_data = JSON.parse(payloadJson) as Record<string, unknown>;
|
||||
saveLocalProject(projectId, {
|
||||
scene_data,
|
||||
name: projectNameRef.current,
|
||||
});
|
||||
setUsingLocalStorage(true);
|
||||
lastSavedPayloadRef.current = payloadJson;
|
||||
setSaveStatus("local");
|
||||
clearSavedTimer();
|
||||
savedTimerRef.current = setTimeout(() => {
|
||||
setSaveStatus((current) => (current === "local" ? "idle" : current));
|
||||
}, PROJECT_SAVED_DISPLAY_MS);
|
||||
},
|
||||
[projectId, clearSavedTimer]
|
||||
);
|
||||
|
||||
const performSave = useCallback(
|
||||
async (payloadJson: string) => {
|
||||
if (isDevProjectId(projectId)) return;
|
||||
|
||||
setSaveStatus("saving");
|
||||
try {
|
||||
const scene_data = JSON.parse(payloadJson) as Record<string, unknown>;
|
||||
await patchProjectSceneData(projectId, scene_data);
|
||||
setUsingLocalStorage(false);
|
||||
markSaved(payloadJson, false);
|
||||
} catch (error) {
|
||||
if (isDevelopmentEnv() && isProjectNotFoundError(error)) {
|
||||
saveToLocalStorage(payloadJson);
|
||||
return;
|
||||
}
|
||||
setSaveStatus("error");
|
||||
}
|
||||
},
|
||||
[projectId, markSaved, saveToLocalStorage]
|
||||
);
|
||||
|
||||
const retrySave = useCallback(() => {
|
||||
void performSave(persistPayload);
|
||||
}, [performSave, persistPayload]);
|
||||
|
||||
useEffect(() => {
|
||||
if (isDevProjectId(projectId)) return;
|
||||
|
||||
let cancelled = false;
|
||||
skipSaveRef.current = true;
|
||||
|
||||
void (async () => {
|
||||
try {
|
||||
const { project } = await fetchProject(projectId);
|
||||
if (cancelled) return;
|
||||
|
||||
setUsingLocalStorage(false);
|
||||
setProjectName(project.name);
|
||||
|
||||
if (!isVideoSceneDataEmpty(project.scene_data)) {
|
||||
hydrateFromSceneData(project.scene_data);
|
||||
}
|
||||
|
||||
lastSavedPayloadRef.current = JSON.stringify(
|
||||
useStudioStore.getState().getSceneDataForSave()
|
||||
);
|
||||
} catch (error) {
|
||||
if (cancelled) return;
|
||||
|
||||
if (isDevelopmentEnv() && isProjectNotFoundError(error)) {
|
||||
const savedPayload = applyLocalSnapshot(
|
||||
projectId,
|
||||
hydrateFromSceneData,
|
||||
setProjectName
|
||||
);
|
||||
if (savedPayload) {
|
||||
setUsingLocalStorage(true);
|
||||
lastSavedPayloadRef.current = JSON.stringify(
|
||||
useStudioStore.getState().getSceneDataForSave()
|
||||
);
|
||||
}
|
||||
} else {
|
||||
setSaveStatus("error");
|
||||
}
|
||||
} finally {
|
||||
if (!cancelled) {
|
||||
skipSaveRef.current = false;
|
||||
}
|
||||
}
|
||||
})();
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [projectId, hydrateFromSceneData]);
|
||||
|
||||
useEffect(() => {
|
||||
if (isDevProjectId(projectId) || skipSaveRef.current) return;
|
||||
if (persistPayload === lastSavedPayloadRef.current) return;
|
||||
setSaveStatus("pending");
|
||||
}, [projectId, persistPayload]);
|
||||
|
||||
useEffect(() => {
|
||||
if (isDevProjectId(projectId) || skipSaveRef.current) return;
|
||||
if (debouncedPayload === lastSavedPayloadRef.current) return;
|
||||
void performSave(debouncedPayload);
|
||||
}, [projectId, debouncedPayload, performSave]);
|
||||
|
||||
useEffect(() => clearSavedTimer, [clearSavedTimer]);
|
||||
|
||||
return {
|
||||
projectName,
|
||||
setProjectName,
|
||||
saveStatus,
|
||||
usingLocalStorage,
|
||||
retrySave,
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user