summaryrefslogtreecommitdiff
path: root/apps/web/src
diff options
context:
space:
mode:
Diffstat (limited to 'apps/web/src')
-rw-r--r--apps/web/src/app/editor/[project_id]/page.tsx23
-rw-r--r--apps/web/src/components/editor/media-panel/views/media.tsx16
-rw-r--r--apps/web/src/components/editor/timeline-track.tsx61
-rw-r--r--apps/web/src/components/editor/timeline.tsx9
-rw-r--r--apps/web/src/components/storage-provider.tsx7
-rw-r--r--apps/web/src/hooks/use-drag-clip.ts237
-rw-r--r--apps/web/src/lib/storage/storage-service.ts467
-rw-r--r--apps/web/src/lib/storage/types.ts7
-rw-r--r--apps/web/src/stores/media-store.ts48
-rw-r--r--apps/web/src/stores/project-store.ts43
-rw-r--r--apps/web/src/stores/timeline-store.ts123
-rw-r--r--apps/web/src/types/project.ts15
-rw-r--r--apps/web/src/types/timeline.ts31
13 files changed, 573 insertions, 514 deletions
diff --git a/apps/web/src/app/editor/[project_id]/page.tsx b/apps/web/src/app/editor/[project_id]/page.tsx
index 8eee5e0a..011c0c60 100644
--- a/apps/web/src/app/editor/[project_id]/page.tsx
+++ b/apps/web/src/app/editor/[project_id]/page.tsx
@@ -1,6 +1,7 @@
"use client";
import { useEffect } from "react";
+import { useParams } from "next/navigation";
import {
ResizablePanelGroup,
ResizablePanel,
@@ -28,15 +29,27 @@ export default function Editor() {
setTimeline,
} = usePanelStore();
- const { activeProject, createNewProject } = useProjectStore();
+ const { activeProject, loadProject, createNewProject } = useProjectStore();
+ const params = useParams();
+ const projectId = params.project_id as string;
usePlaybackControls();
useEffect(() => {
- if (!activeProject) {
- createNewProject("Untitled Project");
- }
- }, [activeProject, createNewProject]);
+ const initializeProject = async () => {
+ if (projectId && (!activeProject || activeProject.id !== projectId)) {
+ try {
+ await loadProject(projectId);
+ } catch (error) {
+ console.error("Failed to load project:", error);
+ // If project doesn't exist, create a new one
+ await createNewProject("Untitled Project");
+ }
+ }
+ };
+
+ initializeProject();
+ }, [projectId, activeProject, loadProject, createNewProject]);
return (
<EditorProvider>
diff --git a/apps/web/src/components/editor/media-panel/views/media.tsx b/apps/web/src/components/editor/media-panel/views/media.tsx
index f975496e..f7da684d 100644
--- a/apps/web/src/components/editor/media-panel/views/media.tsx
+++ b/apps/web/src/components/editor/media-panel/views/media.tsx
@@ -24,9 +24,11 @@ import {
SelectValue,
} from "@/components/ui/select";
import { DraggableMediaItem } from "@/components/ui/draggable-item";
+import { useProjectStore } from "@/stores/project-store";
export function MediaView() {
const { mediaItems, addMediaItem, removeMediaItem } = useMediaStore();
+ const { activeProject } = useProjectStore();
const fileInputRef = useRef<HTMLInputElement>(null);
const [isProcessing, setIsProcessing] = useState(false);
const [progress, setProgress] = useState(0);
@@ -35,6 +37,11 @@ export function MediaView() {
const processFiles = async (files: FileList | File[]) => {
if (!files || files.length === 0) return;
+ if (!activeProject) {
+ toast.error("No active project");
+ return;
+ }
+
setIsProcessing(true);
setProgress(0);
try {
@@ -44,7 +51,7 @@ export function MediaView() {
);
// Add each processed media item to the store
for (const item of processedItems) {
- await addMediaItem(item);
+ await addMediaItem(activeProject.id, item);
}
} catch (error) {
// Show error toast if processing fails
@@ -73,6 +80,11 @@ export function MediaView() {
// Remove a media item from the store
e.stopPropagation();
+ if (!activeProject) {
+ toast.error("No active project");
+ return;
+ }
+
// Remove elements automatically when delete media
const { tracks, removeTrack } = useTimelineStore.getState();
tracks.forEach((track) => {
@@ -92,7 +104,7 @@ export function MediaView() {
removeTrack(track.id);
}
});
- await removeMediaItem(id);
+ await removeMediaItem(activeProject.id, id);
};
const formatDuration = (duration: number) => {
diff --git a/apps/web/src/components/editor/timeline-track.tsx b/apps/web/src/components/editor/timeline-track.tsx
index eb58e1ee..2751fa5a 100644
--- a/apps/web/src/components/editor/timeline-track.tsx
+++ b/apps/web/src/components/editor/timeline-track.tsx
@@ -13,11 +13,12 @@ import {
ContextMenuSeparator,
ContextMenuTrigger,
} from "../ui/context-menu";
-import {
+import {
TimelineTrack,
sortTracksByOrder,
ensureMainTrack,
- getMainTrack
+ getMainTrack,
+ canElementGoOnTrack,
} from "@/types/timeline";
import { usePlaybackStore } from "@/stores/playback-store";
import type {
@@ -572,7 +573,9 @@ export function TimelineTrackContent({
// dropPosition === "on" but track is not text type
// Insert above main track if main track exists, otherwise at top
if (mainTrack) {
- const mainTrackIndex = tracks.findIndex(t => t.id === mainTrack.id);
+ const mainTrackIndex = tracks.findIndex(
+ (t) => t.id === mainTrack.id
+ );
insertIndex = mainTrackIndex;
} else {
insertIndex = 0; // Top of timeline
@@ -648,9 +651,11 @@ export function TimelineTrackContent({
const isVideoOrImage =
dragData.type === "video" || dragData.type === "image";
const isAudio = dragData.type === "audio";
- const isCompatible =
- (track.type === "media" && isVideoOrImage) ||
- (track.type === "audio" && isAudio);
+ const isCompatible = isVideoOrImage
+ ? canElementGoOnTrack("media", track.type)
+ : isAudio
+ ? canElementGoOnTrack("media", track.type)
+ : false;
let targetTrack = tracks.find((t) => t.id === targetTrackId);
@@ -662,7 +667,7 @@ export function TimelineTrackContent({
if (isVideoOrImage) {
// For video/image, check if we need a main track or additional media track
const mainTrack = getMainTrack(tracks);
-
+
if (!mainTrack) {
// No main track exists, create it
const updatedTracks = ensureMainTrack(tracks);
@@ -672,22 +677,32 @@ export function TimelineTrackContent({
targetTrack = newMainTrack;
} else {
// Main track was created but somehow has elements, create new media track
- const mainTrackIndex = updatedTracks.findIndex(t => t.id === newMainTrack?.id);
+ const mainTrackIndex = updatedTracks.findIndex(
+ (t) => t.id === newMainTrack?.id
+ );
targetTrackId = insertTrackAt("media", mainTrackIndex);
- const updatedTracksAfterInsert = useTimelineStore.getState().tracks;
- const newTargetTrack = updatedTracksAfterInsert.find(t => t.id === targetTrackId);
+ const updatedTracksAfterInsert =
+ useTimelineStore.getState().tracks;
+ const newTargetTrack = updatedTracksAfterInsert.find(
+ (t) => t.id === targetTrackId
+ );
if (!newTargetTrack) return;
targetTrack = newTargetTrack;
}
- } else if (mainTrack.elements.length === 0 && dropPosition === "on") {
+ } else if (
+ mainTrack.elements.length === 0 &&
+ dropPosition === "on"
+ ) {
// Main track exists and is empty, use it
targetTrackId = mainTrack.id;
targetTrack = mainTrack;
} else {
// Create new media track above main track
- const mainTrackIndex = tracks.findIndex(t => t.id === mainTrack.id);
+ const mainTrackIndex = tracks.findIndex(
+ (t) => t.id === mainTrack.id
+ );
let insertIndex: number;
-
+
if (dropPosition === "above") {
insertIndex = currentTrackIndex;
} else if (dropPosition === "below") {
@@ -696,10 +711,12 @@ export function TimelineTrackContent({
// Insert above main track
insertIndex = mainTrackIndex;
}
-
+
targetTrackId = insertTrackAt("media", insertIndex);
const updatedTracks = useTimelineStore.getState().tracks;
- const newTargetTrack = updatedTracks.find(t => t.id === targetTrackId);
+ const newTargetTrack = updatedTracks.find(
+ (t) => t.id === targetTrackId
+ );
if (!newTargetTrack) return;
targetTrack = newTargetTrack;
}
@@ -707,7 +724,7 @@ export function TimelineTrackContent({
// Audio tracks go at the bottom
const mainTrack = getMainTrack(tracks);
let insertIndex: number;
-
+
if (dropPosition === "above") {
insertIndex = currentTrackIndex;
} else if (dropPosition === "below") {
@@ -715,16 +732,20 @@ export function TimelineTrackContent({
} else {
// Insert after main track (bottom area)
if (mainTrack) {
- const mainTrackIndex = tracks.findIndex(t => t.id === mainTrack.id);
+ const mainTrackIndex = tracks.findIndex(
+ (t) => t.id === mainTrack.id
+ );
insertIndex = mainTrackIndex + 1;
} else {
insertIndex = tracks.length; // Bottom of timeline
}
}
-
+
targetTrackId = insertTrackAt("audio", insertIndex);
const updatedTracks = useTimelineStore.getState().tracks;
- const newTargetTrack = updatedTracks.find(t => t.id === targetTrackId);
+ const newTargetTrack = updatedTracks.find(
+ (t) => t.id === targetTrackId
+ );
if (!newTargetTrack) return;
targetTrack = newTargetTrack;
}
@@ -805,7 +826,7 @@ export function TimelineTrackContent({
? wouldOverlap
? "Cannot drop - would overlap"
: "Drop element here"
- : "Drop media here"}
+ : ""}
</div>
) : (
<>
diff --git a/apps/web/src/components/editor/timeline.tsx b/apps/web/src/components/editor/timeline.tsx
index 283f6b04..f98ab5ec 100644
--- a/apps/web/src/components/editor/timeline.tsx
+++ b/apps/web/src/components/editor/timeline.tsx
@@ -31,6 +31,7 @@ import {
import { useTimelineStore } from "@/stores/timeline-store";
import { useMediaStore } from "@/stores/media-store";
import { usePlaybackStore } from "@/stores/playback-store";
+import { useProjectStore } from "@/stores/project-store";
import { processMediaFiles } from "@/lib/media-processing";
import { toast } from "sonner";
import { useState, useRef, useEffect, useCallback } from "react";
@@ -65,6 +66,7 @@ export function Timeline() {
redo,
} = useTimelineStore();
const { mediaItems, addMediaItem } = useMediaStore();
+ const { activeProject } = useProjectStore();
const {
currentTime,
duration,
@@ -377,6 +379,11 @@ export function Timeline() {
}
} else if (e.dataTransfer.files?.length > 0) {
// Handle file drops by creating new tracks
+ if (!activeProject) {
+ toast.error("No active project");
+ return;
+ }
+
setIsProcessing(true);
setProgress(0);
try {
@@ -385,7 +392,7 @@ export function Timeline() {
(p) => setProgress(p)
);
for (const processedItem of processedItems) {
- await addMediaItem(processedItem);
+ await addMediaItem(activeProject.id, processedItem);
const currentMediaItems = useMediaStore.getState().mediaItems;
const addedItem = currentMediaItems.find(
(item) =>
diff --git a/apps/web/src/components/storage-provider.tsx b/apps/web/src/components/storage-provider.tsx
index e007ca96..48423b5c 100644
--- a/apps/web/src/components/storage-provider.tsx
+++ b/apps/web/src/components/storage-provider.tsx
@@ -36,7 +36,6 @@ export function StorageProvider({ children }: StorageProviderProps) {
});
const loadAllProjects = useProjectStore((state) => state.loadAllProjects);
- const loadAllMedia = useMediaStore((state) => state.loadAllMedia);
useEffect(() => {
const initializeStorage = async () => {
@@ -52,8 +51,8 @@ export function StorageProvider({ children }: StorageProviderProps) {
);
}
- // Load saved data in parallel
- await Promise.all([loadAllProjects(), loadAllMedia()]);
+ // Load saved projects (media will be loaded when a project is loaded)
+ await loadAllProjects();
setStatus({
isInitialized: true,
@@ -73,7 +72,7 @@ export function StorageProvider({ children }: StorageProviderProps) {
};
initializeStorage();
- }, [loadAllProjects, loadAllMedia]);
+ }, [loadAllProjects]);
return (
<StorageContext.Provider value={status}>{children}</StorageContext.Provider>
diff --git a/apps/web/src/hooks/use-drag-clip.ts b/apps/web/src/hooks/use-drag-clip.ts
deleted file mode 100644
index 1d38343b..00000000
--- a/apps/web/src/hooks/use-drag-clip.ts
+++ /dev/null
@@ -1,237 +0,0 @@
-import { useState, useEffect, useCallback, useRef } from "react";
-import { useTimelineStore } from "@/stores/timeline-store";
-
-interface DragState {
- isDragging: boolean;
- elementId: string | null;
- trackId: string | null;
- startMouseX: number;
- startElementTime: number;
- clickOffsetTime: number;
- currentTime: number;
-}
-
-export function useDragClip(zoomLevel: number) {
- const { tracks, updateElementStartTime, moveElementToTrack } =
- useTimelineStore();
-
- const [dragState, setDragState] = useState<DragState>({
- isDragging: false,
- elementId: null,
- trackId: null,
- startMouseX: 0,
- startElementTime: 0,
- clickOffsetTime: 0,
- currentTime: 0,
- });
-
- const timelineRef = useRef<HTMLDivElement>(null);
- const dragStateRef = useRef(dragState);
-
- // Keep ref in sync with state
- dragStateRef.current = dragState;
-
- const startDrag = useCallback(
- (
- e: React.MouseEvent,
- elementId: string,
- trackId: string,
- elementStartTime: number,
- clickOffsetTime: number
- ) => {
- e.preventDefault();
- e.stopPropagation();
-
- setDragState({
- isDragging: true,
- elementId,
- trackId,
- startMouseX: e.clientX,
- startElementTime: elementStartTime,
- clickOffsetTime,
- currentTime: elementStartTime,
- });
- },
- []
- );
-
- const updateDrag = useCallback(
- (e: MouseEvent) => {
- if (!dragState.isDragging || !timelineRef.current) {
- return;
- }
-
- const timelineRect = timelineRef.current.getBoundingClientRect();
- const mouseX = e.clientX - timelineRect.left;
- const mouseTime = Math.max(0, mouseX / (50 * zoomLevel));
- const adjustedTime = Math.max(0, mouseTime - dragState.clickOffsetTime);
- const snappedTime = Math.round(adjustedTime * 10) / 10;
-
- setDragState((prev) => ({
- ...prev,
- currentTime: snappedTime,
- }));
- },
- [dragState.isDragging, dragState.clickOffsetTime, zoomLevel]
- );
-
- const endDrag = useCallback(
- (targetTrackId?: string) => {
- if (!dragState.isDragging || !dragState.elementId || !dragState.trackId)
- return;
-
- const finalTrackId = targetTrackId || dragState.trackId;
- const finalTime = dragState.currentTime;
-
- // Check for overlaps
- const sourceTrack = tracks.find((t) => t.id === dragState.trackId);
- const targetTrack = tracks.find((t) => t.id === finalTrackId);
- const movingElement = sourceTrack?.elements.find(
- (e) => e.id === dragState.elementId
- );
-
- if (!movingElement || !targetTrack) {
- setDragState((prev) => ({ ...prev, isDragging: false }));
- return;
- }
-
- const movingElementDuration =
- movingElement.duration -
- movingElement.trimStart -
- movingElement.trimEnd;
- const movingElementEnd = finalTime + movingElementDuration;
-
- const hasOverlap = targetTrack.elements.some((existingElement) => {
- // Skip the element being moved if it's on the same track
- if (
- dragState.trackId === finalTrackId &&
- existingElement.id === dragState.elementId
- ) {
- return false;
- }
-
- const existingStart = existingElement.startTime;
- const existingEnd =
- existingElement.startTime +
- (existingElement.duration -
- existingElement.trimStart -
- existingElement.trimEnd);
-
- return finalTime < existingEnd && movingElementEnd > existingStart;
- });
-
- if (!hasOverlap) {
- if (dragState.trackId === finalTrackId) {
- // Moving within same track
- updateElementStartTime(finalTrackId, dragState.elementId!, finalTime);
- } else {
- // Moving to different track
- moveElementToTrack(
- dragState.trackId!,
- finalTrackId,
- dragState.elementId!
- );
- requestAnimationFrame(() => {
- updateElementStartTime(
- finalTrackId,
- dragState.elementId!,
- finalTime
- );
- });
- }
- }
-
- setDragState({
- isDragging: false,
- elementId: null,
- trackId: null,
- startMouseX: 0,
- startElementTime: 0,
- clickOffsetTime: 0,
- currentTime: 0,
- });
- },
- [dragState, tracks, updateElementStartTime, moveElementToTrack]
- );
-
- const cancelDrag = useCallback(() => {
- setDragState({
- isDragging: false,
- elementId: null,
- trackId: null,
- startMouseX: 0,
- startElementTime: 0,
- clickOffsetTime: 0,
- currentTime: 0,
- });
- }, []);
-
- // Global mouse events
- useEffect(() => {
- if (!dragState.isDragging) return;
-
- const handleMouseMove = (e: MouseEvent) => updateDrag(e);
- const handleMouseUp = () => endDrag();
- const handleEscape = (e: KeyboardEvent) => {
- if (e.key === "Escape") cancelDrag();
- };
-
- document.addEventListener("mousemove", handleMouseMove);
- document.addEventListener("mouseup", handleMouseUp);
- document.addEventListener("keydown", handleEscape);
-
- return () => {
- document.removeEventListener("mousemove", handleMouseMove);
- document.removeEventListener("mouseup", handleMouseUp);
- document.removeEventListener("keydown", handleEscape);
- };
- }, [dragState.isDragging, updateDrag, endDrag, cancelDrag]);
-
- const getDraggedElementPosition = useCallback(
- (elementId: string) => {
- // Use ref to get current state, not stale closure
- const currentDragState = dragStateRef.current;
- const isMatch =
- currentDragState.isDragging && currentDragState.elementId === elementId;
-
- if (isMatch) {
- return currentDragState.currentTime;
- }
- return null;
- },
- [] // No dependencies needed since we use ref
- );
-
- const isValidDropTarget = useCallback(
- (trackId: string) => {
- if (!dragState.isDragging) return false;
-
- const sourceTrack = tracks.find((t) => t.id === dragState.trackId);
- const targetTrack = tracks.find((t) => t.id === trackId);
-
- if (!sourceTrack || !targetTrack) return false;
-
- // For now, allow drops on same track type
- return sourceTrack.type === targetTrack.type;
- },
- [dragState.isDragging, dragState.trackId, tracks]
- );
-
- return {
- // State
- isDragging: dragState.isDragging,
- draggedElementId: dragState.elementId,
- currentDragTime: dragState.currentTime,
- clickOffsetTime: dragState.clickOffsetTime,
-
- // Methods
- startDrag,
- endDrag,
- cancelDrag,
- getDraggedElementPosition,
- isValidDropTarget,
-
- // Refs
- timelineRef,
- };
-}
diff --git a/apps/web/src/lib/storage/storage-service.ts b/apps/web/src/lib/storage/storage-service.ts
index 65b3c134..79e932bd 100644
--- a/apps/web/src/lib/storage/storage-service.ts
+++ b/apps/web/src/lib/storage/storage-service.ts
@@ -1,194 +1,273 @@
-import { TProject } from "@/types/project";
-import { MediaItem } from "@/stores/media-store";
-import { IndexedDBAdapter } from "./indexeddb-adapter";
-import { OPFSAdapter } from "./opfs-adapter";
-import { MediaFileData, StorageConfig, SerializedProject } from "./types";
-
-class StorageService {
- private projectsAdapter: IndexedDBAdapter<SerializedProject>;
- private mediaMetadataAdapter: IndexedDBAdapter<MediaFileData>;
- private mediaFilesAdapter: OPFSAdapter;
- private config: StorageConfig;
-
- constructor() {
- this.config = {
- projectsDb: "video-editor-projects",
- mediaDb: "video-editor-media",
- version: 1,
- };
-
- this.projectsAdapter = new IndexedDBAdapter<SerializedProject>(
- this.config.projectsDb,
- "projects",
- this.config.version
- );
-
- this.mediaMetadataAdapter = new IndexedDBAdapter<MediaFileData>(
- this.config.mediaDb,
- "media-metadata",
- this.config.version
- );
-
- this.mediaFilesAdapter = new OPFSAdapter("media-files");
- }
-
- // Project operations
- async saveProject(project: TProject): Promise<void> {
- // Convert TProject to serializable format
- const serializedProject: SerializedProject = {
- id: project.id,
- name: project.name,
- thumbnail: project.thumbnail,
- createdAt: project.createdAt.toISOString(),
- updatedAt: project.updatedAt.toISOString(),
- };
-
- await this.projectsAdapter.set(project.id, serializedProject);
- }
-
- async loadProject(id: string): Promise<TProject | null> {
- const serializedProject = await this.projectsAdapter.get(id);
-
- if (!serializedProject) return null;
-
- // Convert back to TProject format
- return {
- id: serializedProject.id,
- name: serializedProject.name,
- thumbnail: serializedProject.thumbnail,
- createdAt: new Date(serializedProject.createdAt),
- updatedAt: new Date(serializedProject.updatedAt),
- };
- }
-
- async loadAllProjects(): Promise<TProject[]> {
- const projectIds = await this.projectsAdapter.list();
- const projects: TProject[] = [];
-
- for (const id of projectIds) {
- const project = await this.loadProject(id);
- if (project) {
- projects.push(project);
- }
- }
-
- // Sort by last updated (most recent first)
- return projects.sort(
- (a, b) => b.updatedAt.getTime() - a.updatedAt.getTime()
- );
- }
-
- async deleteProject(id: string): Promise<void> {
- await this.projectsAdapter.remove(id);
- }
-
- // Media operations
- async saveMediaItem(mediaItem: MediaItem): Promise<void> {
- // Save file to OPFS
- await this.mediaFilesAdapter.set(mediaItem.id, mediaItem.file);
-
- // Save metadata to IndexedDB
- const metadata: MediaFileData = {
- id: mediaItem.id,
- name: mediaItem.name,
- type: mediaItem.type,
- size: mediaItem.file.size,
- lastModified: mediaItem.file.lastModified,
- width: mediaItem.width,
- height: mediaItem.height,
- duration: mediaItem.duration,
- };
-
- await this.mediaMetadataAdapter.set(mediaItem.id, metadata);
- }
-
- async loadMediaItem(id: string): Promise<MediaItem | null> {
- const [file, metadata] = await Promise.all([
- this.mediaFilesAdapter.get(id),
- this.mediaMetadataAdapter.get(id),
- ]);
-
- if (!file || !metadata) return null;
-
- // Create new object URL for the file
- const url = URL.createObjectURL(file);
-
- return {
- id: metadata.id,
- name: metadata.name,
- type: metadata.type,
- file,
- url,
- width: metadata.width,
- height: metadata.height,
- duration: metadata.duration,
- // thumbnailUrl would need to be regenerated or cached separately
- };
- }
-
- async loadAllMediaItems(): Promise<MediaItem[]> {
- const mediaIds = await this.mediaMetadataAdapter.list();
- const mediaItems: MediaItem[] = [];
-
- for (const id of mediaIds) {
- const item = await this.loadMediaItem(id);
- if (item) {
- mediaItems.push(item);
- }
- }
-
- return mediaItems;
- }
-
- async deleteMediaItem(id: string): Promise<void> {
- await Promise.all([
- this.mediaFilesAdapter.remove(id),
- this.mediaMetadataAdapter.remove(id),
- ]);
- }
-
- // Utility methods
- async clearAllData(): Promise<void> {
- await Promise.all([
- this.projectsAdapter.clear(),
- this.mediaMetadataAdapter.clear(),
- this.mediaFilesAdapter.clear(),
- ]);
- }
-
- async getStorageInfo(): Promise<{
- projects: number;
- mediaItems: number;
- isOPFSSupported: boolean;
- isIndexedDBSupported: boolean;
- }> {
- const [projectIds, mediaIds] = await Promise.all([
- this.projectsAdapter.list(),
- this.mediaMetadataAdapter.list(),
- ]);
-
- return {
- projects: projectIds.length,
- mediaItems: mediaIds.length,
- isOPFSSupported: this.isOPFSSupported(),
- isIndexedDBSupported: this.isIndexedDBSupported(),
- };
- }
-
- // Check browser support
- isOPFSSupported(): boolean {
- return OPFSAdapter.isSupported();
- }
-
- isIndexedDBSupported(): boolean {
- return "indexedDB" in window;
- }
-
- isFullySupported(): boolean {
- return this.isIndexedDBSupported() && this.isOPFSSupported();
- }
-}
-
-// Export singleton instance
-export const storageService = new StorageService();
-export { StorageService };
+import { TProject } from "@/types/project";
+import { MediaItem } from "@/stores/media-store";
+import { IndexedDBAdapter } from "./indexeddb-adapter";
+import { OPFSAdapter } from "./opfs-adapter";
+import {
+ MediaFileData,
+ StorageConfig,
+ SerializedProject,
+ TimelineData,
+} from "./types";
+import { TimelineTrack } from "@/types/timeline";
+
+class StorageService {
+ private projectsAdapter: IndexedDBAdapter<SerializedProject>;
+ private config: StorageConfig;
+
+ constructor() {
+ this.config = {
+ projectsDb: "video-editor-projects",
+ mediaDb: "video-editor-media",
+ timelineDb: "video-editor-timelines",
+ version: 1,
+ };
+
+ this.projectsAdapter = new IndexedDBAdapter<SerializedProject>(
+ this.config.projectsDb,
+ "projects",
+ this.config.version
+ );
+ }
+
+ // Helper to get project-specific media adapters
+ private getProjectMediaAdapters(projectId: string) {
+ const mediaMetadataAdapter = new IndexedDBAdapter<MediaFileData>(
+ `${this.config.mediaDb}-${projectId}`,
+ "media-metadata",
+ this.config.version
+ );
+
+ const mediaFilesAdapter = new OPFSAdapter(`media-files-${projectId}`);
+
+ return { mediaMetadataAdapter, mediaFilesAdapter };
+ }
+
+ // Helper to get project-specific timeline adapter
+ private getProjectTimelineAdapter(projectId: string) {
+ return new IndexedDBAdapter<TimelineData>(
+ `${this.config.timelineDb}-${projectId}`,
+ "timeline",
+ this.config.version
+ );
+ }
+
+ // Project operations
+ async saveProject(project: TProject): Promise<void> {
+ // Convert TProject to serializable format
+ const serializedProject: SerializedProject = {
+ id: project.id,
+ name: project.name,
+ thumbnail: project.thumbnail,
+ createdAt: project.createdAt.toISOString(),
+ updatedAt: project.updatedAt.toISOString(),
+ };
+
+ await this.projectsAdapter.set(project.id, serializedProject);
+ }
+
+ async loadProject(id: string): Promise<TProject | null> {
+ const serializedProject = await this.projectsAdapter.get(id);
+
+ if (!serializedProject) return null;
+
+ // Convert back to TProject format
+ return {
+ id: serializedProject.id,
+ name: serializedProject.name,
+ thumbnail: serializedProject.thumbnail,
+ createdAt: new Date(serializedProject.createdAt),
+ updatedAt: new Date(serializedProject.updatedAt),
+ };
+ }
+
+ async loadAllProjects(): Promise<TProject[]> {
+ const projectIds = await this.projectsAdapter.list();
+ const projects: TProject[] = [];
+
+ for (const id of projectIds) {
+ const project = await this.loadProject(id);
+ if (project) {
+ projects.push(project);
+ }
+ }
+
+ // Sort by last updated (most recent first)
+ return projects.sort(
+ (a, b) => b.updatedAt.getTime() - a.updatedAt.getTime()
+ );
+ }
+
+ async deleteProject(id: string): Promise<void> {
+ await this.projectsAdapter.remove(id);
+ }
+
+ // Media operations - now project-specific
+ async saveMediaItem(projectId: string, mediaItem: MediaItem): Promise<void> {
+ const { mediaMetadataAdapter, mediaFilesAdapter } =
+ this.getProjectMediaAdapters(projectId);
+
+ // Save file to project-specific OPFS
+ await mediaFilesAdapter.set(mediaItem.id, mediaItem.file);
+
+ // Save metadata to project-specific IndexedDB
+ const metadata: MediaFileData = {
+ id: mediaItem.id,
+ name: mediaItem.name,
+ type: mediaItem.type,
+ size: mediaItem.file.size,
+ lastModified: mediaItem.file.lastModified,
+ width: mediaItem.width,
+ height: mediaItem.height,
+ duration: mediaItem.duration,
+ };
+
+ await mediaMetadataAdapter.set(mediaItem.id, metadata);
+ }
+
+ async loadMediaItem(
+ projectId: string,
+ id: string
+ ): Promise<MediaItem | null> {
+ const { mediaMetadataAdapter, mediaFilesAdapter } =
+ this.getProjectMediaAdapters(projectId);
+
+ const [file, metadata] = await Promise.all([
+ mediaFilesAdapter.get(id),
+ mediaMetadataAdapter.get(id),
+ ]);
+
+ if (!file || !metadata) return null;
+
+ // Create new object URL for the file
+ const url = URL.createObjectURL(file);
+
+ return {
+ id: metadata.id,
+ name: metadata.name,
+ type: metadata.type,
+ file,
+ url,
+ width: metadata.width,
+ height: metadata.height,
+ duration: metadata.duration,
+ // thumbnailUrl would need to be regenerated or cached separately
+ };
+ }
+
+ async loadAllMediaItems(projectId: string): Promise<MediaItem[]> {
+ const { mediaMetadataAdapter } = this.getProjectMediaAdapters(projectId);
+
+ const mediaIds = await mediaMetadataAdapter.list();
+ const mediaItems: MediaItem[] = [];
+
+ for (const id of mediaIds) {
+ const item = await this.loadMediaItem(projectId, id);
+ if (item) {
+ mediaItems.push(item);
+ }
+ }
+
+ return mediaItems;
+ }
+
+ async deleteMediaItem(projectId: string, id: string): Promise<void> {
+ const { mediaMetadataAdapter, mediaFilesAdapter } =
+ this.getProjectMediaAdapters(projectId);
+
+ await Promise.all([
+ mediaFilesAdapter.remove(id),
+ mediaMetadataAdapter.remove(id),
+ ]);
+ }
+
+ async deleteProjectMedia(projectId: string): Promise<void> {
+ const { mediaMetadataAdapter, mediaFilesAdapter } =
+ this.getProjectMediaAdapters(projectId);
+
+ await Promise.all([
+ mediaMetadataAdapter.clear(),
+ mediaFilesAdapter.clear(),
+ ]);
+ }
+
+ // Timeline operations - now project-specific
+ async saveTimeline(
+ projectId: string,
+ tracks: TimelineTrack[]
+ ): Promise<void> {
+ const timelineAdapter = this.getProjectTimelineAdapter(projectId);
+ const timelineData: TimelineData = {
+ tracks,
+ lastModified: new Date().toISOString(),
+ };
+ await timelineAdapter.set("timeline", timelineData);
+ }
+
+ async loadTimeline(projectId: string): Promise<TimelineTrack[] | null> {
+ const timelineAdapter = this.getProjectTimelineAdapter(projectId);
+ const timelineData = await timelineAdapter.get("timeline");
+ return timelineData ? timelineData.tracks : null;
+ }
+
+ async deleteProjectTimeline(projectId: string): Promise<void> {
+ const timelineAdapter = this.getProjectTimelineAdapter(projectId);
+ await timelineAdapter.remove("timeline");
+ }
+
+ // Utility methods
+ async clearAllData(): Promise<void> {
+ // Clear all projects
+ await this.projectsAdapter.clear();
+
+ // Note: Project-specific media and timelines will be cleaned up when projects are deleted
+ }
+
+ async getStorageInfo(): Promise<{
+ projects: number;
+ isOPFSSupported: boolean;
+ isIndexedDBSupported: boolean;
+ }> {
+ const projectIds = await this.projectsAdapter.list();
+
+ return {
+ projects: projectIds.length,
+ isOPFSSupported: this.isOPFSSupported(),
+ isIndexedDBSupported: this.isIndexedDBSupported(),
+ };
+ }
+
+ async getProjectStorageInfo(projectId: string): Promise<{
+ mediaItems: number;
+ hasTimeline: boolean;
+ }> {
+ const { mediaMetadataAdapter } = this.getProjectMediaAdapters(projectId);
+ const timelineAdapter = this.getProjectTimelineAdapter(projectId);
+
+ const [mediaIds, timelineData] = await Promise.all([
+ mediaMetadataAdapter.list(),
+ timelineAdapter.get("timeline"),
+ ]);
+
+ return {
+ mediaItems: mediaIds.length,
+ hasTimeline: !!timelineData,
+ };
+ }
+
+ // Check browser support
+ isOPFSSupported(): boolean {
+ return OPFSAdapter.isSupported();
+ }
+
+ isIndexedDBSupported(): boolean {
+ return "indexedDB" in window;
+ }
+
+ isFullySupported(): boolean {
+ return this.isIndexedDBSupported() && this.isOPFSSupported();
+ }
+}
+
+// Export singleton instance
+export const storageService = new StorageService();
+export { StorageService };
diff --git a/apps/web/src/lib/storage/types.ts b/apps/web/src/lib/storage/types.ts
index 7ad18e19..b662c164 100644
--- a/apps/web/src/lib/storage/types.ts
+++ b/apps/web/src/lib/storage/types.ts
@@ -1,4 +1,5 @@
import { TProject } from "@/types/project";
+import { TimelineTrack } from "@/types/timeline";
export interface StorageAdapter<T> {
get(key: string): Promise<T | null>;
@@ -20,9 +21,15 @@ export interface MediaFileData {
// File will be stored separately in OPFS
}
+export interface TimelineData {
+ tracks: TimelineTrack[];
+ lastModified: string;
+}
+
export interface StorageConfig {
projectsDb: string;
mediaDb: string;
+ timelineDb: string;
version: number;
}
diff --git a/apps/web/src/stores/media-store.ts b/apps/web/src/stores/media-store.ts
index ab413b14..cb69a7e9 100644
--- a/apps/web/src/stores/media-store.ts
+++ b/apps/web/src/stores/media-store.ts
@@ -1,5 +1,6 @@
import { create } from "zustand";
import { storageService } from "@/lib/storage/storage-service";
+import { useProjectStore } from "./project-store";
export type MediaType = "image" | "video" | "audio";
@@ -26,11 +27,15 @@ interface MediaStore {
mediaItems: MediaItem[];
isLoading: boolean;
- // Actions
- addMediaItem: (item: Omit<MediaItem, "id">) => Promise<void>;
- removeMediaItem: (id: string) => Promise<void>;
- loadAllMedia: () => Promise<void>;
- clearAllMedia: () => Promise<void>;
+ // Actions - now require projectId
+ addMediaItem: (
+ projectId: string,
+ item: Omit<MediaItem, "id">
+ ) => Promise<void>;
+ removeMediaItem: (projectId: string, id: string) => Promise<void>;
+ loadProjectMedia: (projectId: string) => Promise<void>;
+ clearProjectMedia: (projectId: string) => Promise<void>;
+ clearAllMedia: () => void; // Clear local state only
}
// Helper function to determine file type
@@ -153,7 +158,7 @@ export const useMediaStore = create<MediaStore>((set, get) => ({
mediaItems: [],
isLoading: false,
- addMediaItem: async (item) => {
+ addMediaItem: async (projectId, item) => {
const newItem: MediaItem = {
...item,
id: crypto.randomUUID(),
@@ -166,7 +171,7 @@ export const useMediaStore = create<MediaStore>((set, get) => ({
// Save to persistent storage in background
try {
- await storageService.saveMediaItem(newItem);
+ await storageService.saveMediaItem(projectId, newItem);
} catch (error) {
console.error("Failed to save media item:", error);
// Remove from local state if save failed
@@ -176,7 +181,7 @@ export const useMediaStore = create<MediaStore>((set, get) => ({
}
},
- removeMediaItem: async (id: string) => {
+ removeMediaItem: async (projectId, id: string) => {
const state = get();
const item = state.mediaItems.find((media) => media.id === id);
@@ -195,17 +200,17 @@ export const useMediaStore = create<MediaStore>((set, get) => ({
// Remove from persistent storage
try {
- await storageService.deleteMediaItem(id);
+ await storageService.deleteMediaItem(projectId, id);
} catch (error) {
console.error("Failed to delete media item:", error);
}
},
- loadAllMedia: async () => {
+ loadProjectMedia: async (projectId) => {
set({ isLoading: true });
try {
- const mediaItems = await storageService.loadAllMediaItems();
+ const mediaItems = await storageService.loadAllMediaItems(projectId);
set({ mediaItems });
} catch (error) {
console.error("Failed to load media items:", error);
@@ -214,7 +219,7 @@ export const useMediaStore = create<MediaStore>((set, get) => ({
}
},
- clearAllMedia: async () => {
+ clearProjectMedia: async (projectId) => {
const state = get();
// Cleanup all object URLs
@@ -234,10 +239,27 @@ export const useMediaStore = create<MediaStore>((set, get) => ({
try {
const mediaIds = state.mediaItems.map((item) => item.id);
await Promise.all(
- mediaIds.map((id) => storageService.deleteMediaItem(id))
+ mediaIds.map((id) => storageService.deleteMediaItem(projectId, id))
);
} catch (error) {
console.error("Failed to clear media items from storage:", error);
}
},
+
+ clearAllMedia: () => {
+ const state = get();
+
+ // Cleanup all object URLs
+ state.mediaItems.forEach((item) => {
+ if (item.url) {
+ URL.revokeObjectURL(item.url);
+ }
+ if (item.thumbnailUrl) {
+ URL.revokeObjectURL(item.thumbnailUrl);
+ }
+ });
+
+ // Clear local state
+ set({ mediaItems: [] });
+ },
}));
diff --git a/apps/web/src/stores/project-store.ts b/apps/web/src/stores/project-store.ts
index 4fff83c2..9570f4a0 100644
--- a/apps/web/src/stores/project-store.ts
+++ b/apps/web/src/stores/project-store.ts
@@ -2,6 +2,8 @@ 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";
interface ProjectStore {
activeProject: TProject | null;
@@ -53,13 +55,28 @@ export const useProjectStore = create<ProjectStore>((set, get) => ({
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 });
}
@@ -70,7 +87,12 @@ export const useProjectStore = create<ProjectStore>((set, get) => ({
if (!activeProject) return;
try {
- await storageService.saveProject(activeProject);
+ // 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);
@@ -94,13 +116,22 @@ export const useProjectStore = create<ProjectStore>((set, get) => ({
deleteProject: async (id: string) => {
try {
- await storageService.deleteProject(id);
+ // 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
+ // 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);
@@ -109,6 +140,12 @@ export const useProjectStore = create<ProjectStore>((set, get) => ({
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) => {
diff --git a/apps/web/src/stores/timeline-store.ts b/apps/web/src/stores/timeline-store.ts
index c4e4e0e3..6d1a0bd1 100644
--- a/apps/web/src/stores/timeline-store.ts
+++ b/apps/web/src/stores/timeline-store.ts
@@ -6,9 +6,12 @@ import {
TimelineTrack,
sortTracksByOrder,
ensureMainTrack,
+ validateElementTrackCompatibility,
} from "@/types/timeline";
import { useEditorStore } from "./editor-store";
import { useMediaStore, getMediaAspectRatio } from "./media-store";
+import { storageService } from "@/lib/storage/storage-service";
+import { useProjectStore } from "./project-store";
// Helper function to manage element naming with suffixes
const getElementNameWithSuffix = (
@@ -116,6 +119,11 @@ interface TimelineStore {
undo: () => void;
redo: () => void;
pushHistory: () => void;
+
+ // Persistence actions
+ loadProjectTimeline: (projectId: string) => Promise<void>;
+ saveProjectTimeline: (projectId: string) => Promise<void>;
+ clearTimeline: () => void;
}
export const useTimelineStore = create<TimelineStore>((set, get) => {
@@ -129,6 +137,25 @@ export const useTimelineStore = create<TimelineStore>((set, get) => {
});
};
+ // Helper to auto-save timeline changes
+ const autoSaveTimeline = async () => {
+ const activeProject = useProjectStore.getState().activeProject;
+ if (activeProject) {
+ try {
+ await storageService.saveTimeline(activeProject.id, get()._tracks);
+ } catch (error) {
+ console.error("Failed to auto-save timeline:", error);
+ }
+ }
+ };
+
+ // Helper to update tracks and auto-save
+ const updateTracksAndSave = (newTracks: TimelineTrack[]) => {
+ updateTracks(newTracks);
+ // Auto-save in background
+ setTimeout(autoSaveTimeline, 100);
+ };
+
// Initialize with proper track ordering
const initialTracks = ensureMainTrack([]);
const sortedInitialTracks = sortTracksByOrder(initialTracks);
@@ -158,7 +185,7 @@ export const useTimelineStore = create<TimelineStore>((set, get) => {
const { history, redoStack, _tracks } = get();
if (history.length === 0) return;
const prev = history[history.length - 1];
- updateTracks(prev);
+ updateTracksAndSave(prev);
set({
history: history.slice(0, -1),
redoStack: [...redoStack, JSON.parse(JSON.stringify(_tracks))],
@@ -224,7 +251,7 @@ export const useTimelineStore = create<TimelineStore>((set, get) => {
muted: false,
};
- updateTracks([...get()._tracks, newTrack]);
+ updateTracksAndSave([...get()._tracks, newTrack]);
return newTrack.id;
},
@@ -251,13 +278,13 @@ export const useTimelineStore = create<TimelineStore>((set, get) => {
const newTracks = [...get()._tracks];
newTracks.splice(index, 0, newTrack);
- updateTracks(newTracks);
+ updateTracksAndSave(newTracks);
return newTrack.id;
},
removeTrack: (trackId) => {
get().pushHistory();
- updateTracks(get()._tracks.filter((track) => track.id !== trackId));
+ updateTracksAndSave(get()._tracks.filter((track) => track.id !== trackId));
},
addElementToTrack: (trackId, elementData) => {
@@ -270,17 +297,10 @@ export const useTimelineStore = create<TimelineStore>((set, get) => {
return;
}
- // Validate element can be added to this track type
- if (track.type === "media" && elementData.type !== "media") {
- console.error("Media track only accepts media elements");
- return;
- }
- if (track.type === "text" && elementData.type !== "text") {
- console.error("Text track only accepts text elements");
- return;
- }
- if (track.type === "audio" && elementData.type !== "media") {
- console.error("Audio track only accepts media elements");
+ // Use utility function for validation
+ const validation = validateElementTrackCompatibility(elementData, track);
+ if (!validation.isValid) {
+ console.error(validation.errorMessage);
return;
}
@@ -331,7 +351,7 @@ export const useTimelineStore = create<TimelineStore>((set, get) => {
}
}
- updateTracks(
+ updateTracksAndSave(
get()._tracks.map((track) =>
track.id === trackId
? { ...track, elements: [...track.elements, newElement] }
@@ -342,7 +362,7 @@ export const useTimelineStore = create<TimelineStore>((set, get) => {
removeElementFromTrack: (trackId, elementId) => {
get().pushHistory();
- updateTracks(
+ updateTracksAndSave(
get()
._tracks.map((track) =>
track.id === trackId
@@ -362,11 +382,22 @@ export const useTimelineStore = create<TimelineStore>((set, get) => {
get().pushHistory();
const fromTrack = get()._tracks.find((track) => track.id === fromTrackId);
+ const toTrack = get()._tracks.find((track) => track.id === toTrackId);
const elementToMove = fromTrack?.elements.find(
(element) => element.id === elementId
);
- if (!elementToMove) return;
+ if (!elementToMove || !toTrack) return;
+
+ // Validate element type compatibility with target track
+ const validation = validateElementTrackCompatibility(
+ elementToMove,
+ toTrack
+ );
+ if (!validation.isValid) {
+ console.error(validation.errorMessage);
+ return;
+ }
const newTracks = get()
._tracks.map((track) => {
@@ -387,12 +418,12 @@ export const useTimelineStore = create<TimelineStore>((set, get) => {
})
.filter((track) => track.elements.length > 0);
- updateTracks(newTracks);
+ updateTracksAndSave(newTracks);
},
updateElementTrim: (trackId, elementId, trimStart, trimEnd) => {
get().pushHistory();
- updateTracks(
+ updateTracksAndSave(
get()._tracks.map((track) =>
track.id === trackId
? {
@@ -410,7 +441,7 @@ export const useTimelineStore = create<TimelineStore>((set, get) => {
updateElementStartTime: (trackId, elementId, startTime) => {
get().pushHistory();
- updateTracks(
+ updateTracksAndSave(
get()._tracks.map((track) =>
track.id === trackId
? {
@@ -426,7 +457,7 @@ export const useTimelineStore = create<TimelineStore>((set, get) => {
toggleTrackMute: (trackId) => {
get().pushHistory();
- updateTracks(
+ updateTracksAndSave(
get()._tracks.map((track) =>
track.id === trackId ? { ...track, muted: !track.muted } : track
)
@@ -456,7 +487,7 @@ export const useTimelineStore = create<TimelineStore>((set, get) => {
const secondElementId = crypto.randomUUID();
- updateTracks(
+ updateTracksAndSave(
get()._tracks.map((track) =>
track.id === trackId
? {
@@ -508,7 +539,7 @@ export const useTimelineStore = create<TimelineStore>((set, get) => {
const durationToRemove =
element.duration - element.trimStart - element.trimEnd - relativeTime;
- updateTracks(
+ updateTracksAndSave(
get()._tracks.map((track) =>
track.id === trackId
? {
@@ -547,7 +578,7 @@ export const useTimelineStore = create<TimelineStore>((set, get) => {
const relativeTime = splitTime - element.startTime;
- updateTracks(
+ updateTracksAndSave(
get()._tracks.map((track) =>
track.id === trackId
? {
@@ -584,7 +615,7 @@ export const useTimelineStore = create<TimelineStore>((set, get) => {
if (existingAudioTrack) {
// Add audio element to existing audio track
- updateTracks(
+ updateTracksAndSave(
get()._tracks.map((track) =>
track.id === existingAudioTrack.id
? {
@@ -617,7 +648,7 @@ export const useTimelineStore = create<TimelineStore>((set, get) => {
muted: false,
};
- updateTracks([...get()._tracks, newAudioTrack]);
+ updateTracksAndSave([...get()._tracks, newAudioTrack]);
}
return audioElementId;
@@ -645,7 +676,7 @@ export const useTimelineStore = create<TimelineStore>((set, get) => {
const { redoStack } = get();
if (redoStack.length === 0) return;
const next = redoStack[redoStack.length - 1];
- updateTracks(next);
+ updateTracksAndSave(next);
set({ redoStack: redoStack.slice(0, -1) });
},
@@ -706,5 +737,41 @@ export const useTimelineStore = create<TimelineStore>((set, get) => {
},
});
},
+
+ // Persistence methods
+ loadProjectTimeline: async (projectId) => {
+ try {
+ const tracks = await storageService.loadTimeline(projectId);
+ if (tracks) {
+ updateTracks(tracks);
+ } else {
+ // No timeline saved yet, initialize with default
+ const defaultTracks = ensureMainTrack([]);
+ updateTracks(defaultTracks);
+ }
+ // Clear history when loading a project
+ set({ history: [], redoStack: [] });
+ } catch (error) {
+ console.error("Failed to load timeline:", error);
+ // Initialize with default on error
+ const defaultTracks = ensureMainTrack([]);
+ updateTracks(defaultTracks);
+ set({ history: [], redoStack: [] });
+ }
+ },
+
+ saveProjectTimeline: async (projectId) => {
+ try {
+ await storageService.saveTimeline(projectId, get()._tracks);
+ } catch (error) {
+ console.error("Failed to save timeline:", error);
+ }
+ },
+
+ clearTimeline: () => {
+ const defaultTracks = ensureMainTrack([]);
+ updateTracks(defaultTracks);
+ set({ history: [], redoStack: [], selectedElements: [] });
+ },
};
});
diff --git a/apps/web/src/types/project.ts b/apps/web/src/types/project.ts
index 8b1eff3f..e208be3a 100644
--- a/apps/web/src/types/project.ts
+++ b/apps/web/src/types/project.ts
@@ -1,7 +1,8 @@
-export interface TProject {
- id: string;
- name: string;
- thumbnail: string;
- createdAt: Date;
- updatedAt: Date;
-}
+export interface TProject {
+ id: string;
+ name: string;
+ thumbnail: string;
+ createdAt: Date;
+ updatedAt: Date;
+ mediaItems?: string[];
+}
diff --git a/apps/web/src/types/timeline.ts b/apps/web/src/types/timeline.ts
index 7079f091..a759de21 100644
--- a/apps/web/src/types/timeline.ts
+++ b/apps/web/src/types/timeline.ts
@@ -123,3 +123,34 @@ export function ensureMainTrack(tracks: TimelineTrack[]): TimelineTrack[] {
return tracks;
}
+
+// Timeline validation utilities
+export function canElementGoOnTrack(
+ elementType: "text" | "media",
+ trackType: TrackType
+): boolean {
+ if (elementType === "text") {
+ return trackType === "text";
+ } else if (elementType === "media") {
+ return trackType === "media" || trackType === "audio";
+ }
+ return false;
+}
+
+export function validateElementTrackCompatibility(
+ element: { type: "text" | "media" },
+ track: { type: TrackType }
+): { isValid: boolean; errorMessage?: string } {
+ const isValid = canElementGoOnTrack(element.type, track.type);
+
+ if (!isValid) {
+ const errorMessage =
+ element.type === "text"
+ ? "Text elements can only be placed on text tracks"
+ : "Media elements can only be placed on media or audio tracks";
+
+ return { isValid: false, errorMessage };
+ }
+
+ return { isValid: true };
+}