470 lines
13 KiB
TypeScript
470 lines
13 KiB
TypeScript
import { TProject, TScene } from "@/types/project";
|
|
import { create } from "zustand";
|
|
import { storageService } from "@/lib/storage/storage-service";
|
|
import { toast } from "sonner";
|
|
import { useMediaStore } from "./media-store";
|
|
import { useTimelineStore } from "../apps/web/src/stores/timeline-store";
|
|
import { useSceneStore } from "./scene-store";
|
|
import { generateUUID } from "@/lib/utils";
|
|
import { TCanvasSize } from "@/types/editor";
|
|
import {
|
|
DEFAULT_BLUR_INTENSITY,
|
|
DEFAULT_CANVAS_SIZE,
|
|
DEFAULT_FPS,
|
|
} from "@/constants/editor-constants";
|
|
|
|
export function createMainScene(): TScene {
|
|
return {
|
|
id: generateUUID(),
|
|
name: "Main Scene",
|
|
isMain: true,
|
|
createdAt: new Date(),
|
|
updatedAt: new Date(),
|
|
};
|
|
}
|
|
|
|
const createDefaultProject = (name: string): TProject => {
|
|
const mainScene: TScene = createMainScene();
|
|
|
|
return {
|
|
id: generateUUID(),
|
|
name,
|
|
thumbnail: "",
|
|
createdAt: new Date(),
|
|
updatedAt: new Date(),
|
|
scenes: [mainScene],
|
|
currentSceneId: mainScene.id,
|
|
backgroundColor: "#000000",
|
|
backgroundType: "color",
|
|
blurIntensity: DEFAULT_BLUR_INTENSITY,
|
|
fps: DEFAULT_FPS,
|
|
canvasSize: DEFAULT_CANVAS_SIZE,
|
|
};
|
|
};
|
|
|
|
interface ProjectStore {
|
|
activeProject: TProject | null;
|
|
savedProjects: TProject[];
|
|
isLoading: boolean;
|
|
isInitialized: boolean;
|
|
invalidProjectIds?: Set<string>;
|
|
createNewProject: (name: string) => Promise<string>;
|
|
loadProject: (id: string) => Promise<void>;
|
|
saveCurrentProject: () => Promise<void>;
|
|
loadAllProjects: () => Promise<void>;
|
|
deleteProject: (id: string) => Promise<void>;
|
|
closeProject: () => void;
|
|
renameProject: (projectId: string, name: string) => Promise<void>;
|
|
duplicateProject: (projectId: string) => Promise<string>;
|
|
updateProjectBackground: (backgroundColor: string) => Promise<void>;
|
|
updateBackgroundType: (
|
|
type: "color" | "blur",
|
|
options?: { backgroundColor?: string; blurIntensity?: number },
|
|
) => Promise<void>;
|
|
updateProjectFps: (fps: number) => Promise<void>;
|
|
updateCanvasSize: ({ size }: { size: TCanvasSize }) => Promise<void>;
|
|
getFilteredAndSortedProjects: (
|
|
searchQuery: string,
|
|
sortOption: string,
|
|
) => TProject[];
|
|
isInvalidProjectId: (id: string) => boolean;
|
|
markProjectIdAsInvalid: (id: string) => void;
|
|
clearInvalidProjectIds: () => void;
|
|
}
|
|
|
|
export const useProjectStore = create<ProjectStore>((set, get) => ({
|
|
activeProject: null,
|
|
savedProjects: [],
|
|
isLoading: true,
|
|
isInitialized: false,
|
|
invalidProjectIds: new Set<string>(),
|
|
|
|
createNewProject: async (name: string) => {
|
|
const newProject = createDefaultProject(name);
|
|
|
|
set({ activeProject: newProject });
|
|
|
|
const mediaStore = useMediaStore.getState();
|
|
const timelineStore = useTimelineStore.getState();
|
|
const sceneStore = useSceneStore.getState();
|
|
|
|
mediaStore.clearAllMedia();
|
|
timelineStore.clearTimeline();
|
|
|
|
sceneStore.initializeScenes({
|
|
scenes: newProject.scenes,
|
|
currentSceneId: newProject.currentSceneId,
|
|
});
|
|
|
|
try {
|
|
await storageService.saveProject({ project: newProject });
|
|
// Reload all projects to update the list
|
|
await get().loadAllProjects();
|
|
return newProject.id;
|
|
} catch (error) {
|
|
toast.error("Failed to save new project");
|
|
throw error;
|
|
}
|
|
},
|
|
|
|
loadProject: async (id: string) => {
|
|
if (!get().isInitialized) {
|
|
set({ isLoading: true });
|
|
}
|
|
|
|
// Prevent flicker when switching projects - clear all stores
|
|
const mediaStore = useMediaStore.getState();
|
|
const timelineStore = useTimelineStore.getState();
|
|
const sceneStore = useSceneStore.getState();
|
|
|
|
mediaStore.clearAllMedia();
|
|
timelineStore.clearTimeline();
|
|
sceneStore.clearScenes();
|
|
|
|
try {
|
|
const project = await storageService.loadProject({ id });
|
|
if (project) {
|
|
set({ activeProject: project });
|
|
|
|
let currentScene = null;
|
|
if (project.scenes && project.scenes.length > 0) {
|
|
sceneStore.initializeScenes({
|
|
scenes: project.scenes,
|
|
currentSceneId: project.currentSceneId,
|
|
});
|
|
// Get current scene directly from project data (don't rely on store state)
|
|
currentScene =
|
|
project.scenes.find((s) => s.id === project.currentSceneId) ||
|
|
project.scenes.find((s) => s.isMain) ||
|
|
project.scenes[0];
|
|
}
|
|
|
|
await Promise.all([
|
|
mediaStore.loadProjectMedia(id),
|
|
timelineStore.loadProjectTimeline({
|
|
projectId: id,
|
|
sceneId: currentScene?.id,
|
|
}),
|
|
]);
|
|
} else {
|
|
throw new Error(`Project with id ${id} not found`);
|
|
}
|
|
} catch (error) {
|
|
console.error("Failed to load project:", error);
|
|
throw error; // Re-throw so the editor page can handle it
|
|
} finally {
|
|
set({ isLoading: false });
|
|
}
|
|
},
|
|
|
|
saveCurrentProject: async () => {
|
|
const { activeProject } = get();
|
|
if (!activeProject) return;
|
|
|
|
try {
|
|
const timelineStore = useTimelineStore.getState();
|
|
const sceneStore = useSceneStore.getState();
|
|
const currentScene = sceneStore.currentScene;
|
|
|
|
await Promise.all([
|
|
storageService.saveProject({ project: activeProject }),
|
|
timelineStore.saveProjectTimeline({
|
|
projectId: activeProject.id,
|
|
sceneId: currentScene?.id,
|
|
}),
|
|
]);
|
|
await get().loadAllProjects(); // Refresh the list
|
|
} catch (error) {
|
|
console.error("Failed to save project:", error);
|
|
}
|
|
},
|
|
|
|
loadAllProjects: async () => {
|
|
if (!get().isInitialized) {
|
|
set({ isLoading: true });
|
|
}
|
|
|
|
try {
|
|
const projects = await storageService.loadAllProjects();
|
|
set({ savedProjects: projects });
|
|
} catch (error) {
|
|
console.error("Failed to load projects:", error);
|
|
} finally {
|
|
set({ isLoading: false, isInitialized: true });
|
|
}
|
|
},
|
|
|
|
deleteProject: async (id: string) => {
|
|
try {
|
|
await Promise.all([
|
|
storageService.deleteProjectMedia({ projectId: id }),
|
|
storageService.deleteProjectTimeline({ projectId: id }),
|
|
storageService.deleteProject({ id }),
|
|
]);
|
|
await get().loadAllProjects(); // Refresh the list
|
|
|
|
// If deleted active project, close it and clear data
|
|
const { activeProject } = get();
|
|
if (activeProject?.id === id) {
|
|
set({ activeProject: null });
|
|
const mediaStore = useMediaStore.getState();
|
|
const timelineStore = useTimelineStore.getState();
|
|
const sceneStore = useSceneStore.getState();
|
|
|
|
mediaStore.clearAllMedia();
|
|
timelineStore.clearTimeline();
|
|
sceneStore.clearScenes();
|
|
}
|
|
} catch (error) {
|
|
console.error("Failed to delete project:", error);
|
|
}
|
|
},
|
|
|
|
closeProject: () => {
|
|
set({ activeProject: null });
|
|
|
|
const mediaStore = useMediaStore.getState();
|
|
const timelineStore = useTimelineStore.getState();
|
|
const sceneStore = useSceneStore.getState();
|
|
|
|
mediaStore.clearAllMedia();
|
|
timelineStore.clearTimeline();
|
|
sceneStore.clearScenes();
|
|
},
|
|
|
|
renameProject: async (id: string, name: string) => {
|
|
const { savedProjects } = get();
|
|
|
|
// Find the project to rename
|
|
const projectToRename = savedProjects.find((p) => p.id === id);
|
|
if (!projectToRename) {
|
|
toast.error("Project not found", {
|
|
description: "Please try again",
|
|
});
|
|
return;
|
|
}
|
|
|
|
const updatedProject = {
|
|
...projectToRename,
|
|
name,
|
|
updatedAt: new Date(),
|
|
};
|
|
|
|
try {
|
|
await storageService.saveProject({ project: updatedProject });
|
|
|
|
await get().loadAllProjects();
|
|
|
|
// Update activeProject if same project
|
|
const { activeProject } = get();
|
|
if (activeProject?.id === id) {
|
|
set({ activeProject: updatedProject });
|
|
}
|
|
} catch (error) {
|
|
console.error("Failed to rename project:", error);
|
|
toast.error("Failed to rename project", {
|
|
description:
|
|
error instanceof Error ? error.message : "Please try again",
|
|
});
|
|
}
|
|
},
|
|
|
|
duplicateProject: async (projectId: string) => {
|
|
try {
|
|
const project = await storageService.loadProject({ id: projectId });
|
|
if (!project) {
|
|
toast.error("Project not found", {
|
|
description: "Please try again",
|
|
});
|
|
throw new Error("Project not found");
|
|
}
|
|
|
|
const { savedProjects } = get();
|
|
|
|
// Extract the base name (remove any existing numbering)
|
|
const numberMatch = project.name.match(/^\((\d+)\)\s+(.+)$/);
|
|
const baseName = numberMatch ? numberMatch[2] : project.name;
|
|
const existingNumbers: number[] = [];
|
|
|
|
// Check for pattern "(number) baseName" in existing projects
|
|
savedProjects.forEach((p) => {
|
|
const match = p.name.match(/^\((\d+)\)\s+(.+)$/);
|
|
if (match && match[2] === baseName) {
|
|
existingNumbers.push(parseInt(match[1], 10));
|
|
}
|
|
});
|
|
|
|
const nextNumber =
|
|
existingNumbers.length > 0 ? Math.max(...existingNumbers) + 1 : 1;
|
|
|
|
const newProject: TProject = {
|
|
...project,
|
|
id: generateUUID(),
|
|
name: `(${nextNumber}) ${baseName}`,
|
|
createdAt: new Date(),
|
|
updatedAt: new Date(),
|
|
};
|
|
|
|
await storageService.saveProject({ project: newProject });
|
|
await get().loadAllProjects();
|
|
return newProject.id;
|
|
} catch (error) {
|
|
console.error("Failed to duplicate project:", error);
|
|
toast.error("Failed to duplicate project", {
|
|
description:
|
|
error instanceof Error ? error.message : "Please try again",
|
|
});
|
|
throw error;
|
|
}
|
|
},
|
|
|
|
updateProjectBackground: async (backgroundColor: string) => {
|
|
const { activeProject } = get();
|
|
if (!activeProject) return;
|
|
|
|
const updatedProject = {
|
|
...activeProject,
|
|
backgroundColor,
|
|
updatedAt: new Date(),
|
|
};
|
|
|
|
try {
|
|
await storageService.saveProject({ project: updatedProject });
|
|
set({ activeProject: updatedProject });
|
|
await get().loadAllProjects();
|
|
} catch (error) {
|
|
console.error("Failed to update project background:", error);
|
|
toast.error("Failed to update background", {
|
|
description: "Please try again",
|
|
});
|
|
}
|
|
},
|
|
|
|
updateBackgroundType: async (
|
|
type: "color" | "blur",
|
|
options?: { backgroundColor?: string; blurIntensity?: number },
|
|
) => {
|
|
const { activeProject } = get();
|
|
if (!activeProject) return;
|
|
|
|
const updatedProject = {
|
|
...activeProject,
|
|
backgroundType: type,
|
|
...(options?.backgroundColor && {
|
|
backgroundColor: options.backgroundColor,
|
|
}),
|
|
...(options?.blurIntensity !== undefined && {
|
|
blurIntensity: options.blurIntensity,
|
|
}),
|
|
updatedAt: new Date(),
|
|
};
|
|
|
|
try {
|
|
await storageService.saveProject({ project: updatedProject });
|
|
set({ activeProject: updatedProject });
|
|
await get().loadAllProjects();
|
|
} catch (error) {
|
|
console.error("Failed to update background type:", error);
|
|
toast.error("Failed to update background", {
|
|
description: "Please try again",
|
|
});
|
|
}
|
|
},
|
|
|
|
updateProjectFps: async (fps: number) => {
|
|
const { activeProject } = get();
|
|
if (!activeProject) return;
|
|
|
|
const updatedProject = {
|
|
...activeProject,
|
|
fps,
|
|
updatedAt: new Date(),
|
|
};
|
|
|
|
try {
|
|
await storageService.saveProject({ project: updatedProject });
|
|
set({ activeProject: updatedProject });
|
|
await get().loadAllProjects();
|
|
} catch (error) {
|
|
console.error("Failed to update project FPS:", error);
|
|
toast.error("Failed to update project FPS", {
|
|
description: "Please try again",
|
|
});
|
|
}
|
|
},
|
|
|
|
updateCanvasSize: async ({ size }: { size: TCanvasSize }) => {
|
|
const { activeProject } = get();
|
|
if (!activeProject) return;
|
|
|
|
const updatedProject: TProject = {
|
|
...activeProject,
|
|
canvasSize: size,
|
|
updatedAt: new Date(),
|
|
};
|
|
|
|
try {
|
|
await storageService.saveProject({ project: updatedProject });
|
|
set({ activeProject: updatedProject });
|
|
await get().loadAllProjects();
|
|
} catch (error) {
|
|
console.error("Failed to update canvas size:", error);
|
|
toast.error("Failed to update canvas size", {
|
|
description: "Please try again",
|
|
});
|
|
}
|
|
},
|
|
|
|
getFilteredAndSortedProjects: (searchQuery: string, sortOption: string) => {
|
|
const { savedProjects } = get();
|
|
|
|
const filteredProjects = savedProjects.filter((project) =>
|
|
project.name.toLowerCase().includes(searchQuery.toLowerCase()),
|
|
);
|
|
|
|
const sortedProjects = [...filteredProjects].sort((a, b) => {
|
|
const [key, order] = sortOption.split("-");
|
|
|
|
if (key !== "createdAt" && key !== "name") {
|
|
console.warn(`Invalid sort key: ${key}`);
|
|
return 0;
|
|
}
|
|
|
|
const aValue = a[key];
|
|
const bValue = b[key];
|
|
|
|
if (aValue === undefined || bValue === undefined) return 0;
|
|
|
|
if (order === "asc") {
|
|
if (aValue < bValue) return -1;
|
|
if (aValue > bValue) return 1;
|
|
return 0;
|
|
}
|
|
if (aValue > bValue) return -1;
|
|
if (aValue < bValue) return 1;
|
|
return 0;
|
|
});
|
|
|
|
return sortedProjects;
|
|
},
|
|
|
|
// Global invalid project ID tracking
|
|
isInvalidProjectId: (id: string) => {
|
|
const invalidIds = get().invalidProjectIds || new Set();
|
|
return invalidIds.has(id);
|
|
},
|
|
|
|
markProjectIdAsInvalid: (id: string) => {
|
|
set((state) => ({
|
|
invalidProjectIds: new Set([
|
|
...(state.invalidProjectIds || new Set()),
|
|
id,
|
|
]),
|
|
}));
|
|
},
|
|
|
|
clearInvalidProjectIds: () => {
|
|
set({ invalidProjectIds: new Set() });
|
|
},
|
|
}));
|