From bd0c7f22069d1a2e4b2a2d962becaf6b9e942414 Mon Sep 17 00:00:00 2001 From: Maze Winther Date: Mon, 7 Jul 2025 19:06:36 +0200 Subject: refactor: store media relative to project, add storage for timeline data, and other things --- apps/web/src/stores/media-store.ts | 48 +++++++++---- apps/web/src/stores/project-store.ts | 43 +++++++++++- apps/web/src/stores/timeline-store.ts | 123 ++++++++++++++++++++++++++-------- 3 files changed, 170 insertions(+), 44 deletions(-) (limited to 'apps/web/src/stores') 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) => 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 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((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 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; + 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: [] }); + }, }; }); -- cgit v1.3.1