diff --git a/apps/web/src/app/editor/[project_id]/page.tsx b/apps/web/src/app/editor/[project_id]/page.tsx index 8eee5e0..011c0c6 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 ( 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 f975496..f7da684 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(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 eb58e1e..2751fa5 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"} + : ""} ) : ( <> diff --git a/apps/web/src/components/editor/timeline.tsx b/apps/web/src/components/editor/timeline.tsx index 283f6b0..f98ab5e 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 e007ca9..48423b5 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 ( {children} 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 1d38343..0000000 --- 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({ - isDragging: false, - elementId: null, - trackId: null, - startMouseX: 0, - startElementTime: 0, - clickOffsetTime: 0, - currentTime: 0, - }); - - const timelineRef = useRef(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 65b3c13..79e932b 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; - private mediaMetadataAdapter: IndexedDBAdapter; - private mediaFilesAdapter: OPFSAdapter; - private config: StorageConfig; - - constructor() { - this.config = { - projectsDb: "video-editor-projects", - mediaDb: "video-editor-media", - version: 1, - }; - - this.projectsAdapter = new IndexedDBAdapter( - this.config.projectsDb, - "projects", - this.config.version - ); - - this.mediaMetadataAdapter = new IndexedDBAdapter( - this.config.mediaDb, - "media-metadata", - this.config.version - ); - - this.mediaFilesAdapter = new OPFSAdapter("media-files"); - } - - // Project operations - async saveProject(project: TProject): Promise { - // 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 { - 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 { - 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 { - await this.projectsAdapter.remove(id); - } - - // Media operations - async saveMediaItem(mediaItem: MediaItem): Promise { - // 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 { - 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 { - 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 { - await Promise.all([ - this.mediaFilesAdapter.remove(id), - this.mediaMetadataAdapter.remove(id), - ]); - } - - // Utility methods - async clearAllData(): Promise { - 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; + private config: StorageConfig; + + constructor() { + this.config = { + projectsDb: "video-editor-projects", + mediaDb: "video-editor-media", + timelineDb: "video-editor-timelines", + version: 1, + }; + + this.projectsAdapter = new IndexedDBAdapter( + this.config.projectsDb, + "projects", + this.config.version + ); + } + + // Helper to get project-specific media adapters + private getProjectMediaAdapters(projectId: string) { + const mediaMetadataAdapter = new IndexedDBAdapter( + `${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( + `${this.config.timelineDb}-${projectId}`, + "timeline", + this.config.version + ); + } + + // Project operations + async saveProject(project: TProject): Promise { + // 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 { + 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 { + 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 { + await this.projectsAdapter.remove(id); + } + + // Media operations - now project-specific + async saveMediaItem(projectId: string, mediaItem: MediaItem): Promise { + 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 { + 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 { + 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 { + const { mediaMetadataAdapter, mediaFilesAdapter } = + this.getProjectMediaAdapters(projectId); + + await Promise.all([ + mediaFilesAdapter.remove(id), + mediaMetadataAdapter.remove(id), + ]); + } + + async deleteProjectMedia(projectId: string): Promise { + 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 { + const timelineAdapter = this.getProjectTimelineAdapter(projectId); + const timelineData: TimelineData = { + tracks, + lastModified: new Date().toISOString(), + }; + await timelineAdapter.set("timeline", timelineData); + } + + async loadTimeline(projectId: string): Promise { + const timelineAdapter = this.getProjectTimelineAdapter(projectId); + const timelineData = await timelineAdapter.get("timeline"); + return timelineData ? timelineData.tracks : null; + } + + async deleteProjectTimeline(projectId: string): Promise { + const timelineAdapter = this.getProjectTimelineAdapter(projectId); + await timelineAdapter.remove("timeline"); + } + + // Utility methods + async clearAllData(): Promise { + // 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 7ad18e1..b662c16 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 { get(key: string): Promise; @@ -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 ab413b1..cb69a7e 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) => Promise; - removeMediaItem: (id: string) => Promise; - loadAllMedia: () => Promise; - clearAllMedia: () => Promise; + // Actions - now require projectId + addMediaItem: ( + projectId: string, + item: Omit + ) => Promise; + removeMediaItem: (projectId: string, id: string) => Promise; + loadProjectMedia: (projectId: string) => Promise; + clearProjectMedia: (projectId: string) => Promise; + clearAllMedia: () => void; // Clear local state only } // Helper function to determine file type @@ -153,7 +158,7 @@ export const useMediaStore = create((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((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((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((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((set, get) => ({ } }, - clearAllMedia: async () => { + clearProjectMedia: async (projectId) => { const state = get(); // Cleanup all object URLs @@ -234,10 +239,27 @@ export const useMediaStore = create((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 4fff83c..9570f4a 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((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((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((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((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 c4e4e0e..6d1a0bd 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; + saveProjectTimeline: (projectId: string) => Promise; + clearTimeline: () => void; } export const useTimelineStore = create((set, get) => { @@ -129,6 +137,25 @@ export const useTimelineStore = create((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((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((set, get) => { muted: false, }; - updateTracks([...get()._tracks, newTrack]); + updateTracksAndSave([...get()._tracks, newTrack]); return newTrack.id; }, @@ -251,13 +278,13 @@ export const useTimelineStore = create((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((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((set, get) => { } } - updateTracks( + updateTracksAndSave( get()._tracks.map((track) => track.id === trackId ? { ...track, elements: [...track.elements, newElement] } @@ -342,7 +362,7 @@ export const useTimelineStore = create((set, get) => { removeElementFromTrack: (trackId, elementId) => { get().pushHistory(); - updateTracks( + updateTracksAndSave( get() ._tracks.map((track) => track.id === trackId @@ -362,11 +382,22 @@ export const useTimelineStore = create((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((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((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((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((set, get) => { const secondElementId = crypto.randomUUID(); - updateTracks( + updateTracksAndSave( get()._tracks.map((track) => track.id === trackId ? { @@ -508,7 +539,7 @@ export const useTimelineStore = create((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((set, get) => { const relativeTime = splitTime - element.startTime; - updateTracks( + updateTracksAndSave( get()._tracks.map((track) => track.id === trackId ? { @@ -584,7 +615,7 @@ export const useTimelineStore = create((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((set, get) => { muted: false, }; - updateTracks([...get()._tracks, newAudioTrack]); + updateTracksAndSave([...get()._tracks, newAudioTrack]); } return audioElementId; @@ -645,7 +676,7 @@ export const useTimelineStore = create((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((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 8b1eff3..e208be3 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 7079f09..a759de2 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 }; +}