refactor: store media relative to project, add storage for timeline data, and other things
This commit is contained in:
@ -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: [] });
|
||||
},
|
||||
}));
|
||||
|
@ -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) => {
|
||||
|
@ -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: [] });
|
||||
},
|
||||
};
|
||||
});
|
||||
|
Reference in New Issue
Block a user