mirror of
https://github.com/OpenCut-app/OpenCut.git
synced 2026-07-13 21:52:53 +02:00
feat: add ultracite (#452)
* Init ultracite * Update scripts and biome.jsonc * Update biome.jsonc * Update biome.jsonc * Update biome.jsonc * Run format
This commit is contained in:
+104
-105
@@ -1,105 +1,104 @@
|
||||
import { create } from "zustand";
|
||||
import { CanvasSize, CanvasPreset } from "@/types/editor";
|
||||
|
||||
type CanvasMode = "preset" | "original" | "custom";
|
||||
|
||||
interface EditorState {
|
||||
// Loading states
|
||||
isInitializing: boolean;
|
||||
isPanelsReady: boolean;
|
||||
|
||||
// Canvas/Project settings
|
||||
canvasSize: CanvasSize;
|
||||
canvasMode: CanvasMode;
|
||||
canvasPresets: CanvasPreset[];
|
||||
|
||||
// Actions
|
||||
setInitializing: (loading: boolean) => void;
|
||||
setPanelsReady: (ready: boolean) => void;
|
||||
initializeApp: () => Promise<void>;
|
||||
setCanvasSize: (size: CanvasSize) => void;
|
||||
setCanvasSizeToOriginal: (aspectRatio: number) => void;
|
||||
setCanvasSizeFromAspectRatio: (aspectRatio: number) => void;
|
||||
}
|
||||
|
||||
const DEFAULT_CANVAS_PRESETS: CanvasPreset[] = [
|
||||
{ name: "16:9", width: 1920, height: 1080 },
|
||||
{ name: "9:16", width: 1080, height: 1920 },
|
||||
{ name: "1:1", width: 1080, height: 1080 },
|
||||
{ name: "4:3", width: 1440, height: 1080 },
|
||||
];
|
||||
|
||||
// Helper function to find the best matching canvas preset for an aspect ratio
|
||||
const findBestCanvasPreset = (aspectRatio: number): CanvasSize => {
|
||||
// Calculate aspect ratio for each preset and find the closest match
|
||||
let bestMatch = DEFAULT_CANVAS_PRESETS[0]; // Default to 16:9 HD
|
||||
let smallestDifference = Math.abs(
|
||||
aspectRatio - bestMatch.width / bestMatch.height
|
||||
);
|
||||
|
||||
for (const preset of DEFAULT_CANVAS_PRESETS) {
|
||||
const presetAspectRatio = preset.width / preset.height;
|
||||
const difference = Math.abs(aspectRatio - presetAspectRatio);
|
||||
|
||||
if (difference < smallestDifference) {
|
||||
smallestDifference = difference;
|
||||
bestMatch = preset;
|
||||
}
|
||||
}
|
||||
|
||||
// If the difference is still significant (> 0.1), create a custom size
|
||||
// based on the media aspect ratio with a reasonable resolution
|
||||
const bestAspectRatio = bestMatch.width / bestMatch.height;
|
||||
if (Math.abs(aspectRatio - bestAspectRatio) > 0.1) {
|
||||
// Create custom dimensions based on the aspect ratio
|
||||
if (aspectRatio > 1) {
|
||||
// Landscape - use 1920 width
|
||||
return { width: 1920, height: Math.round(1920 / aspectRatio) };
|
||||
} else {
|
||||
// Portrait or square - use 1080 height
|
||||
return { width: Math.round(1080 * aspectRatio), height: 1080 };
|
||||
}
|
||||
}
|
||||
|
||||
return { width: bestMatch.width, height: bestMatch.height };
|
||||
};
|
||||
|
||||
export const useEditorStore = create<EditorState>((set, get) => ({
|
||||
// Initial states
|
||||
isInitializing: true,
|
||||
isPanelsReady: false,
|
||||
canvasSize: { width: 1920, height: 1080 }, // Default 16:9 HD
|
||||
canvasMode: "preset" as CanvasMode,
|
||||
canvasPresets: DEFAULT_CANVAS_PRESETS,
|
||||
|
||||
// Actions
|
||||
setInitializing: (loading) => {
|
||||
set({ isInitializing: loading });
|
||||
},
|
||||
|
||||
setPanelsReady: (ready) => {
|
||||
set({ isPanelsReady: ready });
|
||||
},
|
||||
|
||||
initializeApp: async () => {
|
||||
console.log("Initializing video editor...");
|
||||
set({ isInitializing: true, isPanelsReady: false });
|
||||
|
||||
set({ isPanelsReady: true, isInitializing: false });
|
||||
console.log("Video editor ready");
|
||||
},
|
||||
|
||||
setCanvasSize: (size) => {
|
||||
set({ canvasSize: size, canvasMode: "preset" });
|
||||
},
|
||||
|
||||
setCanvasSizeToOriginal: (aspectRatio) => {
|
||||
const newCanvasSize = findBestCanvasPreset(aspectRatio);
|
||||
set({ canvasSize: newCanvasSize, canvasMode: "original" });
|
||||
},
|
||||
|
||||
setCanvasSizeFromAspectRatio: (aspectRatio) => {
|
||||
const newCanvasSize = findBestCanvasPreset(aspectRatio);
|
||||
set({ canvasSize: newCanvasSize, canvasMode: "custom" });
|
||||
},
|
||||
}));
|
||||
import { create } from "zustand";
|
||||
import { CanvasSize, CanvasPreset } from "@/types/editor";
|
||||
|
||||
type CanvasMode = "preset" | "original" | "custom";
|
||||
|
||||
interface EditorState {
|
||||
// Loading states
|
||||
isInitializing: boolean;
|
||||
isPanelsReady: boolean;
|
||||
|
||||
// Canvas/Project settings
|
||||
canvasSize: CanvasSize;
|
||||
canvasMode: CanvasMode;
|
||||
canvasPresets: CanvasPreset[];
|
||||
|
||||
// Actions
|
||||
setInitializing: (loading: boolean) => void;
|
||||
setPanelsReady: (ready: boolean) => void;
|
||||
initializeApp: () => Promise<void>;
|
||||
setCanvasSize: (size: CanvasSize) => void;
|
||||
setCanvasSizeToOriginal: (aspectRatio: number) => void;
|
||||
setCanvasSizeFromAspectRatio: (aspectRatio: number) => void;
|
||||
}
|
||||
|
||||
const DEFAULT_CANVAS_PRESETS: CanvasPreset[] = [
|
||||
{ name: "16:9", width: 1920, height: 1080 },
|
||||
{ name: "9:16", width: 1080, height: 1920 },
|
||||
{ name: "1:1", width: 1080, height: 1080 },
|
||||
{ name: "4:3", width: 1440, height: 1080 },
|
||||
];
|
||||
|
||||
// Helper function to find the best matching canvas preset for an aspect ratio
|
||||
const findBestCanvasPreset = (aspectRatio: number): CanvasSize => {
|
||||
// Calculate aspect ratio for each preset and find the closest match
|
||||
let bestMatch = DEFAULT_CANVAS_PRESETS[0]; // Default to 16:9 HD
|
||||
let smallestDifference = Math.abs(
|
||||
aspectRatio - bestMatch.width / bestMatch.height
|
||||
);
|
||||
|
||||
for (const preset of DEFAULT_CANVAS_PRESETS) {
|
||||
const presetAspectRatio = preset.width / preset.height;
|
||||
const difference = Math.abs(aspectRatio - presetAspectRatio);
|
||||
|
||||
if (difference < smallestDifference) {
|
||||
smallestDifference = difference;
|
||||
bestMatch = preset;
|
||||
}
|
||||
}
|
||||
|
||||
// If the difference is still significant (> 0.1), create a custom size
|
||||
// based on the media aspect ratio with a reasonable resolution
|
||||
const bestAspectRatio = bestMatch.width / bestMatch.height;
|
||||
if (Math.abs(aspectRatio - bestAspectRatio) > 0.1) {
|
||||
// Create custom dimensions based on the aspect ratio
|
||||
if (aspectRatio > 1) {
|
||||
// Landscape - use 1920 width
|
||||
return { width: 1920, height: Math.round(1920 / aspectRatio) };
|
||||
}
|
||||
// Portrait or square - use 1080 height
|
||||
return { width: Math.round(1080 * aspectRatio), height: 1080 };
|
||||
}
|
||||
|
||||
return { width: bestMatch.width, height: bestMatch.height };
|
||||
};
|
||||
|
||||
export const useEditorStore = create<EditorState>((set, get) => ({
|
||||
// Initial states
|
||||
isInitializing: true,
|
||||
isPanelsReady: false,
|
||||
canvasSize: { width: 1920, height: 1080 }, // Default 16:9 HD
|
||||
canvasMode: "preset" as CanvasMode,
|
||||
canvasPresets: DEFAULT_CANVAS_PRESETS,
|
||||
|
||||
// Actions
|
||||
setInitializing: (loading) => {
|
||||
set({ isInitializing: loading });
|
||||
},
|
||||
|
||||
setPanelsReady: (ready) => {
|
||||
set({ isPanelsReady: ready });
|
||||
},
|
||||
|
||||
initializeApp: async () => {
|
||||
console.log("Initializing video editor...");
|
||||
set({ isInitializing: true, isPanelsReady: false });
|
||||
|
||||
set({ isPanelsReady: true, isInitializing: false });
|
||||
console.log("Video editor ready");
|
||||
},
|
||||
|
||||
setCanvasSize: (size) => {
|
||||
set({ canvasSize: size, canvasMode: "preset" });
|
||||
},
|
||||
|
||||
setCanvasSizeToOriginal: (aspectRatio) => {
|
||||
const newCanvasSize = findBestCanvasPreset(aspectRatio);
|
||||
set({ canvasSize: newCanvasSize, canvasMode: "original" });
|
||||
},
|
||||
|
||||
setCanvasSizeFromAspectRatio: (aspectRatio) => {
|
||||
const newCanvasSize = findBestCanvasPreset(aspectRatio);
|
||||
set({ canvasSize: newCanvasSize, canvasMode: "custom" });
|
||||
},
|
||||
}));
|
||||
|
||||
@@ -219,15 +219,19 @@ export const useMediaStore = create<MediaStore>((set, get) => ({
|
||||
mediaItems.map(async (item) => {
|
||||
if (item.type === "video" && item.file) {
|
||||
try {
|
||||
const { thumbnailUrl, width, height } = await generateVideoThumbnail(item.file);
|
||||
const { thumbnailUrl, width, height } =
|
||||
await generateVideoThumbnail(item.file);
|
||||
return {
|
||||
...item,
|
||||
thumbnailUrl,
|
||||
width: width || item.width,
|
||||
height: height || item.height
|
||||
height: height || item.height,
|
||||
};
|
||||
} catch (error) {
|
||||
console.error(`Failed to regenerate thumbnail for video ${item.id}:`, error);
|
||||
console.error(
|
||||
`Failed to regenerate thumbnail for video ${item.id}:`,
|
||||
error
|
||||
);
|
||||
return item;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,45 +1,45 @@
|
||||
import { create } from "zustand";
|
||||
import { persist } from "zustand/middleware";
|
||||
|
||||
const DEFAULT_PANEL_SIZES = {
|
||||
toolsPanel: 45,
|
||||
previewPanel: 75,
|
||||
propertiesPanel: 20,
|
||||
mainContent: 70,
|
||||
timeline: 30,
|
||||
} as const;
|
||||
|
||||
interface PanelState {
|
||||
// Panel sizes as percentages
|
||||
toolsPanel: number;
|
||||
previewPanel: number;
|
||||
propertiesPanel: number;
|
||||
mainContent: number;
|
||||
timeline: number;
|
||||
|
||||
// Actions
|
||||
setToolsPanel: (size: number) => void;
|
||||
setPreviewPanel: (size: number) => void;
|
||||
setPropertiesPanel: (size: number) => void;
|
||||
setMainContent: (size: number) => void;
|
||||
setTimeline: (size: number) => void;
|
||||
}
|
||||
|
||||
export const usePanelStore = create<PanelState>()(
|
||||
persist(
|
||||
(set) => ({
|
||||
// Default sizes - optimized for responsiveness
|
||||
...DEFAULT_PANEL_SIZES,
|
||||
|
||||
// Actions
|
||||
setToolsPanel: (size) => set({ toolsPanel: size }),
|
||||
setPreviewPanel: (size) => set({ previewPanel: size }),
|
||||
setPropertiesPanel: (size) => set({ propertiesPanel: size }),
|
||||
setMainContent: (size) => set({ mainContent: size }),
|
||||
setTimeline: (size) => set({ timeline: size }),
|
||||
}),
|
||||
{
|
||||
name: "panel-sizes",
|
||||
}
|
||||
)
|
||||
);
|
||||
import { create } from "zustand";
|
||||
import { persist } from "zustand/middleware";
|
||||
|
||||
const DEFAULT_PANEL_SIZES = {
|
||||
toolsPanel: 45,
|
||||
previewPanel: 75,
|
||||
propertiesPanel: 20,
|
||||
mainContent: 70,
|
||||
timeline: 30,
|
||||
} as const;
|
||||
|
||||
interface PanelState {
|
||||
// Panel sizes as percentages
|
||||
toolsPanel: number;
|
||||
previewPanel: number;
|
||||
propertiesPanel: number;
|
||||
mainContent: number;
|
||||
timeline: number;
|
||||
|
||||
// Actions
|
||||
setToolsPanel: (size: number) => void;
|
||||
setPreviewPanel: (size: number) => void;
|
||||
setPropertiesPanel: (size: number) => void;
|
||||
setMainContent: (size: number) => void;
|
||||
setTimeline: (size: number) => void;
|
||||
}
|
||||
|
||||
export const usePanelStore = create<PanelState>()(
|
||||
persist(
|
||||
(set) => ({
|
||||
// Default sizes - optimized for responsiveness
|
||||
...DEFAULT_PANEL_SIZES,
|
||||
|
||||
// Actions
|
||||
setToolsPanel: (size) => set({ toolsPanel: size }),
|
||||
setPreviewPanel: (size) => set({ previewPanel: size }),
|
||||
setPropertiesPanel: (size) => set({ propertiesPanel: size }),
|
||||
setMainContent: (size) => set({ mainContent: size }),
|
||||
setTimeline: (size) => set({ timeline: size }),
|
||||
}),
|
||||
{
|
||||
name: "panel-sizes",
|
||||
}
|
||||
)
|
||||
);
|
||||
|
||||
@@ -1,361 +1,360 @@
|
||||
import { TProject } 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 "./timeline-store";
|
||||
import { generateUUID } from "@/lib/utils";
|
||||
|
||||
interface ProjectStore {
|
||||
activeProject: TProject | null;
|
||||
savedProjects: TProject[];
|
||||
isLoading: boolean;
|
||||
isInitialized: boolean;
|
||||
|
||||
// Actions
|
||||
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>;
|
||||
|
||||
getFilteredAndSortedProjects: (
|
||||
searchQuery: string,
|
||||
sortOption: string
|
||||
) => TProject[];
|
||||
}
|
||||
|
||||
export const useProjectStore = create<ProjectStore>((set, get) => ({
|
||||
activeProject: null,
|
||||
savedProjects: [],
|
||||
isLoading: true,
|
||||
isInitialized: false,
|
||||
|
||||
createNewProject: async (name: string) => {
|
||||
const newProject: TProject = {
|
||||
id: generateUUID(),
|
||||
name,
|
||||
thumbnail: "",
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
backgroundColor: "#000000",
|
||||
backgroundType: "color",
|
||||
blurIntensity: 8,
|
||||
};
|
||||
|
||||
set({ activeProject: newProject });
|
||||
|
||||
try {
|
||||
await storageService.saveProject(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 });
|
||||
}
|
||||
|
||||
// Clear media and timeline immediately to prevent flickering when switching projects
|
||||
const mediaStore = useMediaStore.getState();
|
||||
const timelineStore = useTimelineStore.getState();
|
||||
mediaStore.clearAllMedia();
|
||||
timelineStore.clearTimeline();
|
||||
|
||||
try {
|
||||
const project = await storageService.loadProject(id);
|
||||
if (project) {
|
||||
set({ activeProject: project });
|
||||
|
||||
// Load project-specific data in parallel
|
||||
await Promise.all([
|
||||
mediaStore.loadProjectMedia(id),
|
||||
timelineStore.loadProjectTimeline(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 {
|
||||
// Save project metadata and timeline data in parallel
|
||||
const timelineStore = useTimelineStore.getState();
|
||||
await Promise.all([
|
||||
storageService.saveProject(activeProject),
|
||||
timelineStore.saveProjectTimeline(activeProject.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 {
|
||||
// Delete project data in parallel
|
||||
await Promise.all([
|
||||
storageService.deleteProjectMedia(id),
|
||||
storageService.deleteProjectTimeline(id),
|
||||
storageService.deleteProject(id),
|
||||
]);
|
||||
await get().loadAllProjects(); // Refresh the list
|
||||
|
||||
// If we deleted the 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();
|
||||
mediaStore.clearAllMedia();
|
||||
timelineStore.clearTimeline();
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Failed to delete project:", error);
|
||||
}
|
||||
},
|
||||
|
||||
closeProject: () => {
|
||||
set({ activeProject: null });
|
||||
|
||||
// Clear data from stores when closing project
|
||||
const mediaStore = useMediaStore.getState();
|
||||
const timelineStore = useTimelineStore.getState();
|
||||
mediaStore.clearAllMedia();
|
||||
timelineStore.clearTimeline();
|
||||
},
|
||||
|
||||
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 {
|
||||
// Save to storage
|
||||
await storageService.saveProject(updatedProject);
|
||||
|
||||
await get().loadAllProjects();
|
||||
|
||||
// Update activeProject if it's the 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(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 = {
|
||||
id: generateUUID(),
|
||||
name: `(${nextNumber}) ${baseName}`,
|
||||
thumbnail: project.thumbnail,
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
};
|
||||
|
||||
await storageService.saveProject(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(updatedProject);
|
||||
set({ activeProject: updatedProject });
|
||||
await get().loadAllProjects(); // Refresh the list
|
||||
} 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 && { blurIntensity: options.blurIntensity }),
|
||||
updatedAt: new Date(),
|
||||
};
|
||||
|
||||
try {
|
||||
await storageService.saveProject(updatedProject);
|
||||
set({ activeProject: updatedProject });
|
||||
await get().loadAllProjects(); // Refresh the list
|
||||
} 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(updatedProject);
|
||||
set({ activeProject: updatedProject });
|
||||
await get().loadAllProjects(); // Refresh the list
|
||||
} catch (error) {
|
||||
console.error("Failed to update project FPS:", error);
|
||||
toast.error("Failed to update project FPS", {
|
||||
description: "Please try again",
|
||||
});
|
||||
}
|
||||
},
|
||||
|
||||
getFilteredAndSortedProjects: (searchQuery: string, sortOption: string) => {
|
||||
const { savedProjects } = get();
|
||||
|
||||
// Filter projects by search query
|
||||
const filteredProjects = savedProjects.filter((project) =>
|
||||
project.name.toLowerCase().includes(searchQuery.toLowerCase())
|
||||
);
|
||||
|
||||
// Sort filtered projects
|
||||
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;
|
||||
} else {
|
||||
if (aValue > bValue) return -1;
|
||||
if (aValue < bValue) return 1;
|
||||
return 0;
|
||||
}
|
||||
});
|
||||
|
||||
return sortedProjects;
|
||||
},
|
||||
}));
|
||||
import { TProject } 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 "./timeline-store";
|
||||
import { generateUUID } from "@/lib/utils";
|
||||
|
||||
interface ProjectStore {
|
||||
activeProject: TProject | null;
|
||||
savedProjects: TProject[];
|
||||
isLoading: boolean;
|
||||
isInitialized: boolean;
|
||||
|
||||
// Actions
|
||||
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>;
|
||||
|
||||
getFilteredAndSortedProjects: (
|
||||
searchQuery: string,
|
||||
sortOption: string
|
||||
) => TProject[];
|
||||
}
|
||||
|
||||
export const useProjectStore = create<ProjectStore>((set, get) => ({
|
||||
activeProject: null,
|
||||
savedProjects: [],
|
||||
isLoading: true,
|
||||
isInitialized: false,
|
||||
|
||||
createNewProject: async (name: string) => {
|
||||
const newProject: TProject = {
|
||||
id: generateUUID(),
|
||||
name,
|
||||
thumbnail: "",
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
backgroundColor: "#000000",
|
||||
backgroundType: "color",
|
||||
blurIntensity: 8,
|
||||
};
|
||||
|
||||
set({ activeProject: newProject });
|
||||
|
||||
try {
|
||||
await storageService.saveProject(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 });
|
||||
}
|
||||
|
||||
// Clear media and timeline immediately to prevent flickering when switching projects
|
||||
const mediaStore = useMediaStore.getState();
|
||||
const timelineStore = useTimelineStore.getState();
|
||||
mediaStore.clearAllMedia();
|
||||
timelineStore.clearTimeline();
|
||||
|
||||
try {
|
||||
const project = await storageService.loadProject(id);
|
||||
if (project) {
|
||||
set({ activeProject: project });
|
||||
|
||||
// Load project-specific data in parallel
|
||||
await Promise.all([
|
||||
mediaStore.loadProjectMedia(id),
|
||||
timelineStore.loadProjectTimeline(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 {
|
||||
// Save project metadata and timeline data in parallel
|
||||
const timelineStore = useTimelineStore.getState();
|
||||
await Promise.all([
|
||||
storageService.saveProject(activeProject),
|
||||
timelineStore.saveProjectTimeline(activeProject.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 {
|
||||
// Delete project data in parallel
|
||||
await Promise.all([
|
||||
storageService.deleteProjectMedia(id),
|
||||
storageService.deleteProjectTimeline(id),
|
||||
storageService.deleteProject(id),
|
||||
]);
|
||||
await get().loadAllProjects(); // Refresh the list
|
||||
|
||||
// If we deleted the 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();
|
||||
mediaStore.clearAllMedia();
|
||||
timelineStore.clearTimeline();
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Failed to delete project:", error);
|
||||
}
|
||||
},
|
||||
|
||||
closeProject: () => {
|
||||
set({ activeProject: null });
|
||||
|
||||
// Clear data from stores when closing project
|
||||
const mediaStore = useMediaStore.getState();
|
||||
const timelineStore = useTimelineStore.getState();
|
||||
mediaStore.clearAllMedia();
|
||||
timelineStore.clearTimeline();
|
||||
},
|
||||
|
||||
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 {
|
||||
// Save to storage
|
||||
await storageService.saveProject(updatedProject);
|
||||
|
||||
await get().loadAllProjects();
|
||||
|
||||
// Update activeProject if it's the 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(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 = {
|
||||
id: generateUUID(),
|
||||
name: `(${nextNumber}) ${baseName}`,
|
||||
thumbnail: project.thumbnail,
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
};
|
||||
|
||||
await storageService.saveProject(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(updatedProject);
|
||||
set({ activeProject: updatedProject });
|
||||
await get().loadAllProjects(); // Refresh the list
|
||||
} 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 && { blurIntensity: options.blurIntensity }),
|
||||
updatedAt: new Date(),
|
||||
};
|
||||
|
||||
try {
|
||||
await storageService.saveProject(updatedProject);
|
||||
set({ activeProject: updatedProject });
|
||||
await get().loadAllProjects(); // Refresh the list
|
||||
} 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(updatedProject);
|
||||
set({ activeProject: updatedProject });
|
||||
await get().loadAllProjects(); // Refresh the list
|
||||
} catch (error) {
|
||||
console.error("Failed to update project FPS:", error);
|
||||
toast.error("Failed to update project FPS", {
|
||||
description: "Please try again",
|
||||
});
|
||||
}
|
||||
},
|
||||
|
||||
getFilteredAndSortedProjects: (searchQuery: string, sortOption: string) => {
|
||||
const { savedProjects } = get();
|
||||
|
||||
// Filter projects by search query
|
||||
const filteredProjects = savedProjects.filter((project) =>
|
||||
project.name.toLowerCase().includes(searchQuery.toLowerCase())
|
||||
);
|
||||
|
||||
// Sort filtered projects
|
||||
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;
|
||||
},
|
||||
}));
|
||||
|
||||
@@ -295,9 +295,8 @@ export const useTimelineStore = create<TimelineStore>((set, get) => {
|
||||
{ trackId, elementId },
|
||||
],
|
||||
};
|
||||
} else {
|
||||
return { selectedElements: [{ trackId, elementId }] };
|
||||
}
|
||||
return { selectedElements: [{ trackId, elementId }] };
|
||||
});
|
||||
},
|
||||
|
||||
@@ -680,7 +679,8 @@ export const useTimelineStore = create<TimelineStore>((set, get) => {
|
||||
(element) => element.id !== elementId
|
||||
),
|
||||
};
|
||||
} else if (track.id === toTrackId) {
|
||||
}
|
||||
if (track.id === toTrackId) {
|
||||
return {
|
||||
...track,
|
||||
elements: [...track.elements, elementToMove],
|
||||
@@ -1091,7 +1091,7 @@ export const useTimelineStore = create<TimelineStore>((set, get) => {
|
||||
if (!fileType) return false;
|
||||
|
||||
// Process the new media file
|
||||
let mediaData: any = {
|
||||
const mediaData: any = {
|
||||
name: newFile.name,
|
||||
type: fileType,
|
||||
file: newFile,
|
||||
@@ -1204,7 +1204,8 @@ export const useTimelineStore = create<TimelineStore>((set, get) => {
|
||||
);
|
||||
const { thumbnailUrl } = await generateVideoThumbnail(mediaItem.file);
|
||||
return thumbnailUrl;
|
||||
} else if (mediaItem.type === "image" && mediaItem.url) {
|
||||
}
|
||||
if (mediaItem.type === "image" && mediaItem.url) {
|
||||
return mediaItem.url;
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user