feat: full studio build -- light theme, canvas thumbnails, i18n (fa/en)

This commit is contained in:
Soroush.Asadi
2026-05-24 17:37:21 +03:30
parent d962483359
commit c61f587767
295 changed files with 29797 additions and 265 deletions
+74
View File
@@ -0,0 +1,74 @@
"use client";
import { useEffect } from "react";
import { useStudioStore } from "@/lib/studio-store";
function isEditableTarget(target: EventTarget | null): boolean {
if (!(target instanceof HTMLElement)) return false;
const tag = target.tagName;
return tag === "INPUT" || tag === "TEXTAREA" || target.isContentEditable;
}
export function useCanvasKeyboard() {
const selectedLayerId = useStudioStore((state) => state.selectedLayerId);
const deleteLayer = useStudioStore((state) => state.deleteLayer);
const copyLayer = useStudioStore((state) => state.copyLayer);
const pasteLayer = useStudioStore((state) => state.pasteLayer);
const undo = useStudioStore((state) => state.undo);
const redo = useStudioStore((state) => state.redo);
useEffect(() => {
const handleKeyDown = (event: KeyboardEvent) => {
if (isEditableTarget(event.target)) return;
if (event.key === "Delete" || event.key === "Backspace") {
if (!selectedLayerId) return;
event.preventDefault();
deleteLayer(selectedLayerId);
return;
}
const mod = event.ctrlKey || event.metaKey;
if (!mod) return;
if (event.key === "c" || event.key === "C") {
if (!selectedLayerId) return;
event.preventDefault();
copyLayer(selectedLayerId);
return;
}
if (event.key === "v" || event.key === "V") {
event.preventDefault();
pasteLayer();
return;
}
if (event.key === "z" || event.key === "Z") {
event.preventDefault();
if (event.shiftKey) {
redo();
} else {
undo();
}
return;
}
if (event.key === "y" || event.key === "Y") {
event.preventDefault();
redo();
}
};
window.addEventListener("keydown", handleKeyDown);
return () => window.removeEventListener("keydown", handleKeyDown);
}, [
selectedLayerId,
deleteLayer,
copyLayer,
pasteLayer,
undo,
redo,
]);
}
+93
View File
@@ -0,0 +1,93 @@
"use client";
import { type RefObject, useEffect, useRef } from "react";
import { playSceneTransition } from "@/lib/scene-transitions";
import {
getProjectDuration,
getSceneAtTime,
} from "@/lib/studio-timeline";
import { useStudioStore } from "@/lib/studio-store";
import type { SceneTransition } from "@/lib/studio-types";
const PLAYBACK_TICK_MS = 100;
const PLAYBACK_STEP_SECONDS = 0.1;
function getOutgoingTransition(
scenes: { id: string; transitionType?: SceneTransition }[],
fromSceneId: string
): SceneTransition {
const scene = scenes.find((item) => item.id === fromSceneId);
return scene?.transitionType ?? "none";
}
export function useCanvasPreviewPlayback(
canvasWrapperRef: RefObject<HTMLDivElement | null>
) {
const isPlaying = useStudioStore((state) => state.isPlaying);
const transitioningRef = useRef(false);
useEffect(() => {
if (!isPlaying) return;
const intervalId = window.setInterval(() => {
void (async () => {
if (transitioningRef.current) return;
const state = useStudioStore.getState();
const max = getProjectDuration(state.scenes);
const next = state.currentTime + PLAYBACK_STEP_SECONDS;
if (next >= max) {
const lastScene = state.scenes[state.scenes.length - 1];
useStudioStore.setState({
currentTime: max,
isPlaying: false,
activeSceneId: lastScene?.id ?? state.activeSceneId,
selectedLayerId: null,
});
return;
}
const nextScene = getSceneAtTime(state.scenes, next);
const sceneChanged =
nextScene !== undefined && nextScene.id !== state.activeSceneId;
if (sceneChanged) {
const transition = getOutgoingTransition(
state.scenes,
state.activeSceneId
);
const wrapper = canvasWrapperRef.current;
if (wrapper && transition !== "none") {
transitioningRef.current = true;
try {
await playSceneTransition(wrapper, transition, () => {
useStudioStore.setState({
currentTime: next,
activeSceneId: nextScene.id,
selectedLayerId: null,
});
});
} finally {
transitioningRef.current = false;
}
return;
}
useStudioStore.setState({
currentTime: next,
activeSceneId: nextScene.id,
selectedLayerId: null,
});
return;
}
useStudioStore.setState({ currentTime: next });
})();
}, PLAYBACK_TICK_MS);
return () => window.clearInterval(intervalId);
}, [isPlaying, canvasWrapperRef]);
}
+30
View File
@@ -0,0 +1,30 @@
"use client";
import { useEffect, useRef, useState } from "react";
export interface ContainerSize {
width: number;
height: number;
}
export function useContainerSize<T extends HTMLElement = HTMLDivElement>() {
const ref = useRef<T>(null);
const [size, setSize] = useState<ContainerSize>({ width: 0, height: 0 });
useEffect(() => {
const element = ref.current;
if (!element) return;
const updateSize = () => {
const rect = element.getBoundingClientRect();
setSize({ width: rect.width, height: rect.height });
};
updateSize();
const observer = new ResizeObserver(updateSize);
observer.observe(element);
return () => observer.disconnect();
}, []);
return { ref, width: size.width, height: size.height };
}
+14
View File
@@ -0,0 +1,14 @@
"use client";
import { useEffect, useState } from "react";
export function useDebouncedValue<T>(value: T, delay = 300): T {
const [debouncedValue, setDebouncedValue] = useState(value);
useEffect(() => {
const timer = window.setTimeout(() => setDebouncedValue(value), delay);
return () => window.clearTimeout(timer);
}, [value, delay]);
return debouncedValue;
}
+140
View File
@@ -0,0 +1,140 @@
"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 };
}
+34
View File
@@ -0,0 +1,34 @@
"use client";
import { useEffect, useState } from "react";
/** Matches Tailwind `md` breakpoint (768px); mobile is strictly below */
export const MOBILE_MEDIA_QUERY = "(max-width: 767px)";
export interface UseIsMobileResult {
/** True when viewport width is under 768px */
isMobile: boolean;
/** False during SSR and until `matchMedia` runs on the client */
isReady: boolean;
}
export function useIsMobile(): UseIsMobileResult {
const [state, setState] = useState<UseIsMobileResult>({
isMobile: false,
isReady: false,
});
useEffect(() => {
const mediaQuery = window.matchMedia(MOBILE_MEDIA_QUERY);
const sync = (): void => {
setState({ isMobile: mediaQuery.matches, isReady: true });
};
sync();
mediaQuery.addEventListener("change", sync);
return () => mediaQuery.removeEventListener("change", sync);
}, []);
return state;
}
+242
View File
@@ -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,
};
}
+82
View File
@@ -0,0 +1,82 @@
"use client";
import { useEffect, useState } from "react";
const THUMBNAIL_INTERVAL_SECONDS = 2;
export function useVideoThumbnails(
videoUrl: string | null,
duration: number
) {
const [thumbnails, setThumbnails] = useState<string[]>([]);
useEffect(() => {
if (!videoUrl || duration <= 0) {
setThumbnails([]);
return;
}
let cancelled = false;
const video = document.createElement("video");
video.src = videoUrl;
video.muted = true;
video.playsInline = true;
video.preload = "auto";
const captureFrames = async () => {
await new Promise<void>((resolve, reject) => {
video.onloadedmetadata = () => resolve();
video.onerror = () => reject(new Error("Failed to load video"));
});
const canvas = document.createElement("canvas");
const context = canvas.getContext("2d");
if (!context) return;
const thumbWidth = 80;
const thumbHeight = Math.round(
(video.videoHeight / video.videoWidth) * thumbWidth
);
canvas.width = thumbWidth;
canvas.height = thumbHeight || 45;
const times: number[] = [];
for (
let t = 0;
t < duration;
t += THUMBNAIL_INTERVAL_SECONDS
) {
times.push(t);
}
if (times[times.length - 1] !== duration - 0.01) {
times.push(Math.max(0, duration - 0.05));
}
const results: string[] = [];
for (const time of times) {
if (cancelled) return;
await new Promise<void>((resolve) => {
video.currentTime = time;
video.onseeked = () => resolve();
});
context.drawImage(video, 0, 0, canvas.width, canvas.height);
results.push(canvas.toDataURL("image/jpeg", 0.65));
}
if (!cancelled) setThumbnails(results);
};
captureFrames().catch(() => {
if (!cancelled) setThumbnails([]);
});
return () => {
cancelled = true;
video.removeAttribute("src");
video.load();
};
}, [videoUrl, duration]);
return thumbnails;
}