From b459b46802a760a4a81ce91fdf24390e07375fb0 Mon Sep 17 00:00:00 2001 From: Maze Winther Date: Tue, 26 Aug 2025 15:04:25 +0200 Subject: refactor: type-safety & separate concerns --- apps/web/src/stores/media-store.ts | 78 +++++--------- apps/web/src/stores/sounds-store.ts | 6 +- apps/web/src/stores/timeline-store.ts | 189 +++++++++++++++------------------- 3 files changed, 112 insertions(+), 161 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 95676d75..2b7cb62e 100644 --- a/apps/web/src/stores/media-store.ts +++ b/apps/web/src/stores/media-store.ts @@ -2,44 +2,21 @@ import { create } from "zustand"; import { storageService } from "@/lib/storage/storage-service"; import { useTimelineStore } from "./timeline-store"; import { generateUUID } from "@/lib/utils"; - -export type MediaType = "image" | "video" | "audio"; - -export interface MediaItem { - id: string; - name: string; - type: MediaType; - file: File; - url?: string; // Object URL for preview - thumbnailUrl?: string; // For video thumbnails - duration?: number; // For video/audio duration - width?: number; // For video/image width - height?: number; // For video/image height - fps?: number; // For video frame rate - // Ephemeral items are used by timeline directly and should not appear in the media library or be persisted - ephemeral?: boolean; - // Text-specific properties - content?: string; // Text content - fontSize?: number; // Font size - fontFamily?: string; // Font family - color?: string; // Text color - backgroundColor?: string; // Background color - textAlign?: "left" | "center" | "right"; // Text alignment -} +import { MediaType, MediaFile } from "@/types/media"; interface MediaStore { - mediaItems: MediaItem[]; + mediaFiles: MediaFile[]; isLoading: boolean; - // Actions - now require projectId - addMediaItem: ( + // Actions + addMediaFile: ( projectId: string, - item: Omit + file: Omit ) => Promise; - removeMediaItem: (projectId: string, id: string) => Promise; + removeMediaFile: (projectId: string, id: string) => Promise; loadProjectMedia: (projectId: string) => Promise; clearProjectMedia: (projectId: string) => Promise; - clearAllMedia: () => void; // Clear local state only + clearAllMedia: () => void; } // Helper function to determine file type @@ -150,8 +127,7 @@ export const getMediaDuration = (file: File): Promise => { }); }; -// Helper to get aspect ratio from MediaItem -export const getMediaAspectRatio = (item: MediaItem): number => { +export const getMediaAspectRatio = (item: MediaFile): number => { if (item.width && item.height) { return item.width / item.height; } @@ -159,35 +135,35 @@ export const getMediaAspectRatio = (item: MediaItem): number => { }; export const useMediaStore = create((set, get) => ({ - mediaItems: [], + mediaFiles: [], isLoading: false, - addMediaItem: async (projectId, item) => { - const newItem: MediaItem = { - ...item, + addMediaFile: async (projectId, file) => { + const newItem: MediaFile = { + ...file, id: generateUUID(), }; // Add to local state immediately for UI responsiveness set((state) => ({ - mediaItems: [...state.mediaItems, newItem], + mediaFiles: [...state.mediaFiles, newItem], })); // Save to persistent storage in background try { - await storageService.saveMediaItem(projectId, newItem); + await storageService.saveMediaFile(projectId, newItem); } catch (error) { console.error("Failed to save media item:", error); // Remove from local state if save failed set((state) => ({ - mediaItems: state.mediaItems.filter((media) => media.id !== newItem.id), + mediaFiles: state.mediaFiles.filter((media) => media.id !== newItem.id), })); } }, - removeMediaItem: async (projectId: string, id: string) => { + removeMediaFile: async (projectId: string, id: string) => { const state = get(); - const item = state.mediaItems.find((media) => media.id === id); + const item = state.mediaFiles.find((media) => media.id === id); // Cleanup object URLs to prevent memory leaks if (item?.url) { @@ -199,7 +175,7 @@ export const useMediaStore = create((set, get) => ({ // 1) Remove from local state immediately set((state) => ({ - mediaItems: state.mediaItems.filter((media) => media.id !== id), + mediaFiles: state.mediaFiles.filter((media) => media.id !== id), })); // 2) Cascade into the timeline: remove any elements using this media ID @@ -238,7 +214,7 @@ export const useMediaStore = create((set, get) => ({ // 3) Remove from persistent storage try { - await storageService.deleteMediaItem(projectId, id); + await storageService.deleteMediaFile(projectId, id); } catch (error) { console.error("Failed to delete media item:", error); } @@ -248,7 +224,7 @@ export const useMediaStore = create((set, get) => ({ set({ isLoading: true }); try { - const mediaItems = await storageService.loadAllMediaItems(projectId); + const mediaItems = await storageService.loadAllMediaFiles(projectId); // Regenerate thumbnails for video items const updatedMediaItems = await Promise.all( @@ -275,7 +251,7 @@ export const useMediaStore = create((set, get) => ({ }) ); - set({ mediaItems: updatedMediaItems }); + set({ mediaFiles: updatedMediaItems }); } catch (error) { console.error("Failed to load media items:", error); } finally { @@ -287,7 +263,7 @@ export const useMediaStore = create((set, get) => ({ const state = get(); // Cleanup all object URLs - state.mediaItems.forEach((item) => { + state.mediaFiles.forEach((item) => { if (item.url) { URL.revokeObjectURL(item.url); } @@ -297,13 +273,13 @@ export const useMediaStore = create((set, get) => ({ }); // Clear local state - set({ mediaItems: [] }); + set({ mediaFiles: [] }); // Clear persistent storage try { - const mediaIds = state.mediaItems.map((item) => item.id); + const mediaIds = state.mediaFiles.map((item) => item.id); await Promise.all( - mediaIds.map((id) => storageService.deleteMediaItem(projectId, id)) + mediaIds.map((id) => storageService.deleteMediaFile(projectId, id)) ); } catch (error) { console.error("Failed to clear media items from storage:", error); @@ -314,7 +290,7 @@ export const useMediaStore = create((set, get) => ({ const state = get(); // Cleanup all object URLs - state.mediaItems.forEach((item) => { + state.mediaFiles.forEach((item) => { if (item.url) { URL.revokeObjectURL(item.url); } @@ -324,6 +300,6 @@ export const useMediaStore = create((set, get) => ({ }); // Clear local state - set({ mediaItems: [] }); + set({ mediaFiles: [] }); }, })); diff --git a/apps/web/src/stores/sounds-store.ts b/apps/web/src/stores/sounds-store.ts index 215253fb..6dffa198 100644 --- a/apps/web/src/stores/sounds-store.ts +++ b/apps/web/src/stores/sounds-store.ts @@ -247,7 +247,7 @@ export const useSoundsStore = create((set, get) => ({ type: "audio/mpeg", }); - await useMediaStore.getState().addMediaItem(activeProject.id, { + await useMediaStore.getState().addMediaFile(activeProject.id, { name: sound.name, type: "audio", file, @@ -257,12 +257,12 @@ export const useSoundsStore = create((set, get) => ({ const mediaItem = useMediaStore .getState() - .mediaItems.find((item) => item.file === file); + .mediaFiles.find((item) => item.file === file); if (!mediaItem) throw new Error("Failed to create media item"); const success = useTimelineStore .getState() - .addMediaAtTime(mediaItem, usePlaybackStore.getState().currentTime); + .addElementAtTime(mediaItem, usePlaybackStore.getState().currentTime); if (success) { return true; diff --git a/apps/web/src/stores/timeline-store.ts b/apps/web/src/stores/timeline-store.ts index 3eada6de..884db23e 100644 --- a/apps/web/src/stores/timeline-store.ts +++ b/apps/web/src/stores/timeline-store.ts @@ -10,17 +10,15 @@ import { ensureMainTrack, validateElementTrackCompatibility, } from "@/types/timeline"; -import { - useMediaStore, - getMediaAspectRatio, - type MediaItem, -} from "./media-store"; +import { useMediaStore, getMediaAspectRatio } from "./media-store"; +import { MediaFile } from "@/types/media"; import { findBestCanvasPreset } from "@/lib/editor-utils"; import { storageService } from "@/lib/storage/storage-service"; import { useProjectStore } from "./project-store"; import { generateUUID } from "@/lib/utils"; import { TIMELINE_CONSTANTS } from "@/constants/timeline-constants"; import { checkElementOverlaps, resolveElementOverlaps } from "@/lib/timeline"; +import { DEFAULT_TEXT_ELEMENT } from "@/constants/text-constants"; // Helper function to manage element naming with suffixes const getElementNameWithSuffix = ( @@ -207,10 +205,11 @@ interface TimelineStore { excludeElementId?: string ) => boolean; findOrCreateTrack: (trackType: TrackType) => string; - addMediaAtTime: (item: MediaItem, currentTime?: number) => boolean; - addTextAtTime: (item: TextElement, currentTime?: number) => boolean; - addMediaToNewTrack: (item: MediaItem) => boolean; - addTextToNewTrack: (item: TextElement | DragData) => boolean; + addElementAtTime: ( + item: MediaFile | TextElement, + currentTime?: number + ) => boolean; + addElementToNewTrack: (item: MediaFile | TextElement | DragData) => boolean; } export const useTimelineStore = create((set, get) => { @@ -502,7 +501,7 @@ export const useTimelineStore = create((set, get) => { if (isFirstElement && newElement.type === "media") { const mediaStore = useMediaStore.getState(); - const mediaItem = mediaStore.mediaItems.find( + const mediaItem = mediaStore.mediaFiles.find( (item) => item.id === newElement.mediaId ); @@ -1144,7 +1143,7 @@ export const useTimelineStore = create((set, get) => { } try { - await mediaStore.addMediaItem( + await mediaStore.addMediaFile( projectStore.activeProject.id, mediaData ); @@ -1155,7 +1154,7 @@ export const useTimelineStore = create((set, get) => { }; } - const newMediaItem = mediaStore.mediaItems.find( + const newMediaItem = mediaStore.mediaFiles.find( (item) => item.file === newFile ); @@ -1219,7 +1218,7 @@ export const useTimelineStore = create((set, get) => { getProjectThumbnail: async (projectId) => { try { const tracks = await storageService.loadTimeline(projectId); - const mediaItems = await storageService.loadAllMediaItems(projectId); + const mediaItems = await storageService.loadAllMediaFiles(projectId); if (!tracks || !mediaItems.length) return null; @@ -1230,20 +1229,20 @@ export const useTimelineStore = create((set, get) => { if (!firstMediaElement) return null; - const mediaItem = mediaItems.find( + const mediaFile = mediaItems.find( (item) => item.id === firstMediaElement.mediaId ); - if (!mediaItem) return null; + if (!mediaFile) return null; - if (mediaItem.type === "video" && mediaItem.file) { + if (mediaFile.type === "video" && mediaFile.file) { const { generateVideoThumbnail } = await import( "@/stores/media-store" ); - const { thumbnailUrl } = await generateVideoThumbnail(mediaItem.file); + const { thumbnailUrl } = await generateVideoThumbnail(mediaFile.file); return thumbnailUrl; } - if (mediaItem.type === "image" && mediaItem.url) { - return mediaItem.url; + if (mediaFile.type === "image" && mediaFile.url) { + return mediaFile.url; } return null; @@ -1401,30 +1400,24 @@ export const useTimelineStore = create((set, get) => { return get().addTrack(trackType); }, - addMediaAtTime: (item, currentTime = 0) => { - const trackType = item.type === "audio" ? "audio" : "media"; - const duration = - item.duration || TIMELINE_CONSTANTS.DEFAULT_IMAGE_DURATION; - - const tracks = get()._tracks.filter((t) => t.type === trackType); - - let targetTrackId = null; - for (const track of tracks) { - if (!get().checkElementOverlap(track.id, currentTime, duration)) { - targetTrackId = track.id; - break; - } - } - - if (!targetTrackId) { - targetTrackId = get().addTrack(trackType); + addElementAtTime: (item: MediaFile | TextElement, currentTime = 0) => { + if (item.type === "text") { + const targetTrackId = get().insertTrackAt("text", 0); + get().addElementToTrack( + targetTrackId, + buildTextElement(item, currentTime) + ); + return true; } + const media = item as MediaFile; + const trackType = media.type === "audio" ? "audio" : "media"; + const targetTrackId = get().insertTrackAt(trackType, 0); get().addElementToTrack(targetTrackId, { type: "media", - mediaId: item.id, - name: item.name, - duration, + mediaId: media.id, + name: media.name, + duration: media.duration || TIMELINE_CONSTANTS.DEFAULT_IMAGE_DURATION, startTime: currentTime, trimStart: 0, trimEnd: 0, @@ -1433,42 +1426,24 @@ export const useTimelineStore = create((set, get) => { return true; }, - addTextAtTime: (item, currentTime = 0) => { - const targetTrackId = get().insertTrackAt("text", 0); - - get().addElementToTrack(targetTrackId, { - type: "text", - name: item.name || "Text", - content: item.content || "Default Text", - duration: item.duration || TIMELINE_CONSTANTS.DEFAULT_TEXT_DURATION, - startTime: currentTime, - trimStart: 0, - trimEnd: 0, - fontSize: item.fontSize || 48, - fontFamily: item.fontFamily || "Arial", - color: item.color || "#ffffff", - backgroundColor: item.backgroundColor || "transparent", - textAlign: item.textAlign || "center", - fontWeight: item.fontWeight || "normal", - fontStyle: item.fontStyle || "normal", - textDecoration: item.textDecoration || "none", - x: item.x || 0, - y: item.y || 0, - rotation: item.rotation || 0, - opacity: item.opacity !== undefined ? item.opacity : 1, - }); - return true; - }, - - addMediaToNewTrack: (item) => { - const trackType = item.type === "audio" ? "audio" : "media"; - const targetTrackId = get().findOrCreateTrack(trackType); + addElementToNewTrack: (item) => { + if (item.type === "text") { + const targetTrackId = get().insertTrackAt("text", 0); + get().addElementToTrack( + targetTrackId, + buildTextElement(item as TextElement | DragData, 0) + ); + return true; + } + const media = item as MediaFile; + const trackType = media.type === "audio" ? "audio" : "media"; + const targetTrackId = get().insertTrackAt(trackType, 0); get().addElementToTrack(targetTrackId, { type: "media", - mediaId: item.id, - name: item.name, - duration: item.duration || TIMELINE_CONSTANTS.DEFAULT_IMAGE_DURATION, + mediaId: media.id, + name: media.name, + duration: media.duration || TIMELINE_CONSTANTS.DEFAULT_IMAGE_DURATION, startTime: 0, trimStart: 0, trimEnd: 0, @@ -1476,41 +1451,41 @@ export const useTimelineStore = create((set, get) => { }); return true; }, + }; +}); - addTextToNewTrack: (item) => { - const targetTrackId = get().insertTrackAt("text", 0); +function buildTextElement( + raw: TextElement | DragData, + startTime: number +): CreateTimelineElement { + const t = raw as Partial; - get().addElementToTrack(targetTrackId, { - type: "text", - name: item.name || "Text", - content: - ("content" in item ? item.content : "Default Text") || "Default Text", - duration: TIMELINE_CONSTANTS.DEFAULT_TEXT_DURATION, - startTime: 0, - trimStart: 0, - trimEnd: 0, - fontSize: ("fontSize" in item ? item.fontSize : 48) || 48, - fontFamily: - ("fontFamily" in item ? item.fontFamily : "Arial") || "Arial", - color: ("color" in item ? item.color : "#ffffff") || "#ffffff", - backgroundColor: - ("backgroundColor" in item ? item.backgroundColor : "transparent") || - "transparent", - textAlign: - ("textAlign" in item ? item.textAlign : "center") || "center", - fontWeight: - ("fontWeight" in item ? item.fontWeight : "normal") || "normal", - fontStyle: - ("fontStyle" in item ? item.fontStyle : "normal") || "normal", - textDecoration: - ("textDecoration" in item ? item.textDecoration : "none") || "none", - x: ("x" in item ? item.x : 0) || 0, - y: ("y" in item ? item.y : 0) || 0, - rotation: ("rotation" in item ? item.rotation : 0) || 0, - opacity: - "opacity" in item && item.opacity !== undefined ? item.opacity : 1, - }); - return true; - }, + return { + type: "text", + name: t.name ?? DEFAULT_TEXT_ELEMENT.name, + content: t.content ?? DEFAULT_TEXT_ELEMENT.content, + duration: t.duration ?? TIMELINE_CONSTANTS.DEFAULT_TEXT_DURATION, + startTime, + trimStart: 0, + trimEnd: 0, + fontSize: + typeof t.fontSize === "number" + ? t.fontSize + : DEFAULT_TEXT_ELEMENT.fontSize, + fontFamily: t.fontFamily ?? DEFAULT_TEXT_ELEMENT.fontFamily, + color: t.color ?? DEFAULT_TEXT_ELEMENT.color, + backgroundColor: t.backgroundColor ?? DEFAULT_TEXT_ELEMENT.backgroundColor, + textAlign: t.textAlign ?? DEFAULT_TEXT_ELEMENT.textAlign, + fontWeight: t.fontWeight ?? DEFAULT_TEXT_ELEMENT.fontWeight, + fontStyle: t.fontStyle ?? DEFAULT_TEXT_ELEMENT.fontStyle, + textDecoration: t.textDecoration ?? DEFAULT_TEXT_ELEMENT.textDecoration, + x: typeof t.x === "number" ? t.x : DEFAULT_TEXT_ELEMENT.x, + y: typeof t.y === "number" ? t.y : DEFAULT_TEXT_ELEMENT.y, + rotation: + typeof t.rotation === "number" + ? t.rotation + : DEFAULT_TEXT_ELEMENT.rotation, + opacity: + typeof t.opacity === "number" ? t.opacity : DEFAULT_TEXT_ELEMENT.opacity, }; -}); +} -- cgit v1.3.1