From 09373eb4a3f792d96d97d81f4fb86539d76e5702 Mon Sep 17 00:00:00 2001 From: Maze Winther Date: Mon, 30 Jun 2025 19:58:36 +0200 Subject: [PATCH] feat: implement project management features with storage integration, including project creation, deletion, and renaming dialogs --- .../app/editor/{ => [project_id]}/page.tsx | 14 +- apps/web/src/app/layout.tsx | 3 +- apps/web/src/app/projects/page.tsx | 488 ++++++++++-------- .../src/components/delete-project-dialog.tsx | 53 ++ .../web/src/components/editor/media-panel.tsx | 15 +- .../src/components/editor/timeline-clip.tsx | 3 +- .../src/components/rename-project-dialog.tsx | 73 +++ apps/web/src/components/storage-provider.tsx | 81 +++ apps/web/src/lib/storage/indexeddb-adapter.ts | 89 ++++ apps/web/src/lib/storage/opfs-adapter.ts | 73 +++ apps/web/src/lib/storage/storage-service.ts | 192 +++++++ apps/web/src/lib/storage/types.ts | 41 ++ apps/web/src/middleware.ts | 10 +- apps/web/src/stores/media-store.ts | 62 ++- apps/web/src/stores/project-store.ts | 186 ++++++- 15 files changed, 1114 insertions(+), 269 deletions(-) rename apps/web/src/app/editor/{ => [project_id]}/page.tsx (89%) create mode 100644 apps/web/src/components/delete-project-dialog.tsx create mode 100644 apps/web/src/components/rename-project-dialog.tsx create mode 100644 apps/web/src/components/storage-provider.tsx create mode 100644 apps/web/src/lib/storage/indexeddb-adapter.ts create mode 100644 apps/web/src/lib/storage/opfs-adapter.ts create mode 100644 apps/web/src/lib/storage/storage-service.ts create mode 100644 apps/web/src/lib/storage/types.ts diff --git a/apps/web/src/app/editor/page.tsx b/apps/web/src/app/editor/[project_id]/page.tsx similarity index 89% rename from apps/web/src/app/editor/page.tsx rename to apps/web/src/app/editor/[project_id]/page.tsx index e001393..3970d72 100644 --- a/apps/web/src/app/editor/page.tsx +++ b/apps/web/src/app/editor/[project_id]/page.tsx @@ -1,16 +1,15 @@ "use client"; import { useEffect } from "react"; -import "./editor.css"; import { ResizablePanelGroup, ResizablePanel, ResizableHandle, -} from "../../components/ui/resizable"; -import { MediaPanel } from "../../components/editor/media-panel"; +} from "../../../components/ui/resizable"; +import { MediaPanel } from "../../../components/editor/media-panel"; // import { PropertiesPanel } from "../../components/editor/properties-panel"; -import { Timeline } from "../../components/editor/timeline"; -import { PreviewPanel } from "../../components/editor/preview-panel"; +import { Timeline } from "../../../components/editor/timeline"; +import { PreviewPanel } from "../../../components/editor/preview-panel"; import { EditorHeader } from "@/components/editor-header"; import { usePanelStore } from "@/stores/panel-store"; import { useProjectStore } from "@/stores/project-store"; @@ -55,7 +54,10 @@ export default function Editor() { className="min-h-0" > {/* Main content area */} - + {/* Tools Panel */} - {children} + {children} diff --git a/apps/web/src/app/projects/page.tsx b/apps/web/src/app/projects/page.tsx index 82bae5d..c983894 100644 --- a/apps/web/src/app/projects/page.tsx +++ b/apps/web/src/app/projects/page.tsx @@ -1,228 +1,260 @@ -"use client"; - -import Link from "next/link"; -import { useState } from "react"; -import { Button } from "@/components/ui/button"; -import { Card, CardContent } from "@/components/ui/card"; -import { - ChevronLeft, - Plus, - Calendar, - MoreHorizontal, - Video, -} from "lucide-react"; -import { TProject } from "@/types/project"; -import Image from "next/image"; -import { - DropdownMenu, - DropdownMenuItem, - DropdownMenuContent, - DropdownMenuTrigger, - DropdownMenuSeparator, -} from "@/components/ui/dropdown-menu"; - -// Hard-coded project data -const mockProjects: TProject[] = [ - { - id: "1", - name: "Summer Vacation Highlights", - createdAt: new Date("2024-12-15"), - updatedAt: new Date("2024-12-20"), - thumbnail: - "https://plus.unsplash.com/premium_photo-1750854354243-81f40af63a73?q=80&w=687&auto=format&fit=crop&ixlib=rb-4.1.0&ixid=M3wxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8fA%3D%3D", - }, - { - id: "2", - name: "Product Demo Video", - createdAt: new Date("2024-12-10"), - updatedAt: new Date("2024-12-18"), - thumbnail: - "https://images.unsplash.com/photo-1750875936215-0c35c1742cd6?q=80&w=688&auto=format&fit=crop&ixlib=rb-4.1.0&ixid=M3wxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8fA%3D%3D", - }, - { - id: "3", - name: "Wedding Ceremony Edit", - createdAt: new Date("2024-12-05"), - updatedAt: new Date("2024-12-16"), - thumbnail: - "https://images.unsplash.com/photo-1750967991618-7b64a3025381?q=80&w=687&auto=format&fit=crop&ixlib=rb-4.1.0&ixid=M3wxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8fA%3D%3D", - }, - { - id: "4", - name: "Travel Vlog - Japan", - createdAt: new Date("2024-11-28"), - updatedAt: new Date("2024-12-14"), - thumbnail: - "https://images.unsplash.com/photo-1750639258774-9a714379a093?q=80&w=1470&auto=format&fit=crop&ixlib=rb-4.1.0&ixid=M3wxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8fA%3D%3D", - }, -]; - -// Mock duration data (in seconds) -const mockDurations: Record = { - "1": 245, // 4:05 - "2": 120, // 2:00 - "3": 1800, // 30:00 - "4": 780, // 13:00 - "5": 360, // 6:00 - "6": 180, // 3:00 -}; - -export default function ProjectsPage() { - return ( -
-
- - - Back - -
- -
-
-
-
-
-

- Your Projects -

-

- {mockProjects.length}{" "} - {mockProjects.length === 1 ? "project" : "projects"} -

-
-
- -
-
- - {mockProjects.length === 0 ? ( -
-
-
-

No projects yet

-

- Start creating your first video project. Import media, edit, and - export professional videos. -

- - - -
- ) : ( -
- {mockProjects.map((project, index) => ( - - ))} -
- )} -
-
- ); -} - -function ProjectCard({ project }: { project: TProject }) { - const [isDropdownOpen, setIsDropdownOpen] = useState(false); - - const formatDuration = (seconds: number): string => { - const minutes = Math.floor(seconds / 60); - const remainingSeconds = Math.floor(seconds % 60); - return `${minutes}:${remainingSeconds.toString().padStart(2, "0")}`; - }; - - const formatDate = (date: Date): string => { - return date.toLocaleDateString("en-US", { - month: "short", - day: "numeric", - year: "numeric", - }); - }; - - return ( - - -
- {/* Thumbnail preview */} -
- Project thumbnail -
- - {/* Duration badge */} -
- {formatDuration(mockDurations[project.id] || 0)} -
-
- - -
-

- {project.name} -

- - - - - { - e.preventDefault(); - e.stopPropagation(); - console.log("close"); - }} - > - Rename - Duplicate - - - Delete - - - -
- -
-
- - Created {formatDate(project.createdAt)} -
-
-
-
- - ); -} - -function CreateButton() { - return ( - - ); -} +"use client"; + +import Link from "next/link"; +import { useState } from "react"; +import { Button } from "@/components/ui/button"; +import { Card, CardContent } from "@/components/ui/card"; +import { + ChevronLeft, + Plus, + Calendar, + MoreHorizontal, + Video, + Loader2, +} from "lucide-react"; +import { TProject } from "@/types/project"; +import Image from "next/image"; +import { + DropdownMenu, + DropdownMenuItem, + DropdownMenuContent, + DropdownMenuTrigger, + DropdownMenuSeparator, +} from "@/components/ui/dropdown-menu"; +import { useProjectStore } from "@/stores/project-store"; +import { useRouter } from "next/navigation"; +import { DeleteProjectDialog } from "@/components/delete-project-dialog"; +import { RenameProjectDialog } from "@/components/rename-project-dialog"; + +export default function ProjectsPage() { + const { createNewProject, savedProjects, isLoading, isInitialized } = + useProjectStore(); + const router = useRouter(); + + const handleCreateProject = async () => { + const projectId = await createNewProject("New Project"); + console.log("projectId", projectId); + router.push(`/editor/${projectId}`); + }; + + return ( +
+
+ + + Back + +
+ +
+
+
+
+
+

+ Your Projects +

+

+ {savedProjects.length}{" "} + {savedProjects.length === 1 ? "project" : "projects"} +

+
+
+ +
+
+ + {isLoading || !isInitialized ? ( +
+ +
+ ) : savedProjects.length === 0 ? ( + + ) : ( +
+ {savedProjects.map((project) => ( + + ))} +
+ )} +
+
+ ); +} + +function ProjectCard({ project }: { project: TProject }) { + const [isDropdownOpen, setIsDropdownOpen] = useState(false); + const [isDeleteDialogOpen, setIsDeleteDialogOpen] = useState(false); + const [isRenameDialogOpen, setIsRenameDialogOpen] = useState(false); + const { deleteProject, renameProject, duplicateProject } = useProjectStore(); + + const formatDate = (date: Date): string => { + return date.toLocaleDateString("en-US", { + month: "short", + day: "numeric", + year: "numeric", + }); + }; + + const handleDeleteProject = async () => { + await deleteProject(project.id); + setIsDropdownOpen(false); + }; + + const handleRenameProject = async (newName: string) => { + await renameProject(project.id, newName); + setIsRenameDialogOpen(false); + }; + + const handleDuplicateProject = async () => { + setIsDropdownOpen(false); + await duplicateProject(project.id); + }; + + return ( + <> + + +
+ {/* Thumbnail preview or placeholder */} +
+ {project.thumbnail ? ( + Project thumbnail + ) : ( +
+
+ )} +
+
+ + +
+

+ {project.name} +

+ + + + + { + e.preventDefault(); + e.stopPropagation(); + }} + > + { + e.preventDefault(); + e.stopPropagation(); + setIsDropdownOpen(false); + setIsRenameDialogOpen(true); + }} + > + Rename + + { + e.preventDefault(); + e.stopPropagation(); + handleDuplicateProject(); + }} + > + Duplicate + + + { + e.preventDefault(); + e.stopPropagation(); + setIsDropdownOpen(false); + setIsDeleteDialogOpen(true); + }} + > + Delete + + + +
+ +
+
+ + Created {formatDate(project.createdAt)} +
+
+
+
+ + + + + ); +} + +function CreateButton({ onClick }: { onClick?: () => void }) { + return ( + + ); +} + +function NoProjects({ onCreateProject }: { onCreateProject: () => void }) { + return ( +
+
+
+

No projects yet

+

+ Start creating your first video project. Import media, edit, and export + professional videos. +

+ +
+ ); +} diff --git a/apps/web/src/components/delete-project-dialog.tsx b/apps/web/src/components/delete-project-dialog.tsx new file mode 100644 index 0000000..db70964 --- /dev/null +++ b/apps/web/src/components/delete-project-dialog.tsx @@ -0,0 +1,53 @@ +import { Button } from "@/components/ui/button"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog"; + +export function DeleteProjectDialog({ + isOpen, + onOpenChange, + onConfirm, +}: { + isOpen: boolean; + onOpenChange: (open: boolean) => void; + onConfirm: () => void; +}) { + return ( + + { + e.preventDefault(); + e.stopPropagation(); + }} + > + + Delete Project + + Are you sure you want to delete this project? This action cannot be + undone. + + + + + + + + + ); +} diff --git a/apps/web/src/components/editor/media-panel.tsx b/apps/web/src/components/editor/media-panel.tsx index f717234..dd12faf 100644 --- a/apps/web/src/components/editor/media-panel.tsx +++ b/apps/web/src/components/editor/media-panel.tsx @@ -32,7 +32,9 @@ export function MediaPanel() { setProgress(p) ); // Add each processed media item to the store - processedItems.forEach((item) => addMediaItem(item)); + for (const item of processedItems) { + await addMediaItem(item); + } } catch (error) { // Show error toast if processing fails console.error("Error processing files:", error); @@ -56,12 +58,11 @@ export function MediaPanel() { e.target.value = ""; // Reset input }; - const handleRemove = (e: React.MouseEvent, id: string) => { + const handleRemove = async (e: React.MouseEvent, id: string) => { // Remove a media item from the store e.stopPropagation(); - - // Remove tracks automatically when delete media + // Remove tracks automatically when delete media const { tracks, removeTrack } = useTimelineStore.getState(); tracks.forEach((track) => { const clipsToRemove = track.clips.filter((clip) => clip.mediaId === id); @@ -69,12 +70,14 @@ export function MediaPanel() { useTimelineStore.getState().removeClipFromTrack(track.id, clip.id); }); // Only remove track if it becomes empty and has no other clips - const updatedTrack = useTimelineStore.getState().tracks.find(t => t.id === track.id); + const updatedTrack = useTimelineStore + .getState() + .tracks.find((t) => t.id === track.id); if (updatedTrack && updatedTrack.clips.length === 0) { removeTrack(track.id); } }); - removeMediaItem(id); + await removeMediaItem(id); }; const formatDuration = (duration: number) => { diff --git a/apps/web/src/components/editor/timeline-clip.tsx b/apps/web/src/components/editor/timeline-clip.tsx index b9d3b9a..9b5f20f 100644 --- a/apps/web/src/components/editor/timeline-clip.tsx +++ b/apps/web/src/components/editor/timeline-clip.tsx @@ -34,7 +34,6 @@ export function TimelineClip({ track, zoomLevel, isSelected, - onContextMenu, onClipMouseDown, onClipClick, }: TimelineClipProps) { @@ -299,7 +298,7 @@ export function TimelineClip({ )} ${isSelected ? "ring-2 ring-primary ring-offset-1" : ""}`} onClick={(e) => onClipClick && onClipClick(e, clip)} onMouseDown={handleClipMouseDown} - onContextMenu={(e) => onContextMenu && onContextMenu(e, clip.id)} + onContextMenu={(e) => onClipMouseDown && onClipMouseDown(e, clip)} >
{renderClipContent()} diff --git a/apps/web/src/components/rename-project-dialog.tsx b/apps/web/src/components/rename-project-dialog.tsx new file mode 100644 index 0000000..d84bad7 --- /dev/null +++ b/apps/web/src/components/rename-project-dialog.tsx @@ -0,0 +1,73 @@ +import { Button } from "@/components/ui/button"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog"; +import { Input } from "@/components/ui/input"; +import { useState } from "react"; + +export function RenameProjectDialog({ + isOpen, + onOpenChange, + onConfirm, + projectName, +}: { + isOpen: boolean; + onOpenChange: (open: boolean) => void; + onConfirm: (name: string) => void; + projectName: string; +}) { + const [name, setName] = useState(projectName); + + // Reset the name when dialog opens - this is better UX than syncing with every prop change + const handleOpenChange = (open: boolean) => { + if (open) { + setName(projectName); + } + onOpenChange(open); + }; + + return ( + + + + Rename Project + + Enter a new name for your project. + + + + setName(e.target.value)} + onKeyDown={(e) => { + if (e.key === "Enter") { + e.preventDefault(); + onConfirm(name); + } + }} + placeholder="Enter a new name" + className="mt-4" + /> + + + + + + + + ); +} diff --git a/apps/web/src/components/storage-provider.tsx b/apps/web/src/components/storage-provider.tsx new file mode 100644 index 0000000..e007ca9 --- /dev/null +++ b/apps/web/src/components/storage-provider.tsx @@ -0,0 +1,81 @@ +"use client"; + +import { createContext, useContext, useEffect, useState } from "react"; +import { useProjectStore } from "@/stores/project-store"; +import { useMediaStore } from "@/stores/media-store"; +import { storageService } from "@/lib/storage/storage-service"; +import { toast } from "sonner"; + +interface StorageContextType { + isInitialized: boolean; + isLoading: boolean; + hasSupport: boolean; + error: string | null; +} + +const StorageContext = createContext(null); + +export function useStorage() { + const context = useContext(StorageContext); + if (!context) { + throw new Error("useStorage must be used within StorageProvider"); + } + return context; +} + +interface StorageProviderProps { + children: React.ReactNode; +} + +export function StorageProvider({ children }: StorageProviderProps) { + const [status, setStatus] = useState({ + isInitialized: false, + isLoading: true, + hasSupport: false, + error: null, + }); + + const loadAllProjects = useProjectStore((state) => state.loadAllProjects); + const loadAllMedia = useMediaStore((state) => state.loadAllMedia); + + useEffect(() => { + const initializeStorage = async () => { + setStatus((prev) => ({ ...prev, isLoading: true })); + + try { + // Check browser support + const hasSupport = storageService.isFullySupported(); + + if (!hasSupport) { + toast.warning( + "Storage not fully supported. Some features may not work." + ); + } + + // Load saved data in parallel + await Promise.all([loadAllProjects(), loadAllMedia()]); + + setStatus({ + isInitialized: true, + isLoading: false, + hasSupport, + error: null, + }); + } catch (error) { + console.error("Failed to initialize storage:", error); + setStatus({ + isInitialized: false, + isLoading: false, + hasSupport: storageService.isFullySupported(), + error: error instanceof Error ? error.message : "Unknown error", + }); + } + }; + + initializeStorage(); + }, [loadAllProjects, loadAllMedia]); + + return ( + {children} + ); +} diff --git a/apps/web/src/lib/storage/indexeddb-adapter.ts b/apps/web/src/lib/storage/indexeddb-adapter.ts new file mode 100644 index 0000000..f43a879 --- /dev/null +++ b/apps/web/src/lib/storage/indexeddb-adapter.ts @@ -0,0 +1,89 @@ +import { StorageAdapter } from "./types"; + +export class IndexedDBAdapter implements StorageAdapter { + private dbName: string; + private storeName: string; + private version: number; + + constructor(dbName: string, storeName: string, version: number = 1) { + this.dbName = dbName; + this.storeName = storeName; + this.version = version; + } + + private async getDB(): Promise { + return new Promise((resolve, reject) => { + const request = indexedDB.open(this.dbName, this.version); + + request.onerror = () => reject(request.error); + request.onsuccess = () => resolve(request.result); + + request.onupgradeneeded = (event) => { + const db = (event.target as IDBOpenDBRequest).result; + if (!db.objectStoreNames.contains(this.storeName)) { + db.createObjectStore(this.storeName, { keyPath: "id" }); + } + }; + }); + } + + async get(key: string): Promise { + const db = await this.getDB(); + const transaction = db.transaction([this.storeName], "readonly"); + const store = transaction.objectStore(this.storeName); + + return new Promise((resolve, reject) => { + const request = store.get(key); + request.onerror = () => reject(request.error); + request.onsuccess = () => resolve(request.result || null); + }); + } + + async set(key: string, value: T): Promise { + const db = await this.getDB(); + const transaction = db.transaction([this.storeName], "readwrite"); + const store = transaction.objectStore(this.storeName); + + return new Promise((resolve, reject) => { + const request = store.put({ id: key, ...value }); + request.onerror = () => reject(request.error); + request.onsuccess = () => resolve(); + }); + } + + async remove(key: string): Promise { + const db = await this.getDB(); + const transaction = db.transaction([this.storeName], "readwrite"); + const store = transaction.objectStore(this.storeName); + + return new Promise((resolve, reject) => { + const request = store.delete(key); + request.onerror = () => reject(request.error); + request.onsuccess = () => resolve(); + }); + } + + async list(): Promise { + const db = await this.getDB(); + const transaction = db.transaction([this.storeName], "readonly"); + const store = transaction.objectStore(this.storeName); + + return new Promise((resolve, reject) => { + const request = store.getAllKeys(); + request.onerror = () => reject(request.error); + request.onsuccess = () => resolve(request.result as string[]); + }); + } + + async clear(): Promise { + const db = await this.getDB(); + const transaction = db.transaction([this.storeName], "readwrite"); + const store = transaction.objectStore(this.storeName); + + return new Promise((resolve, reject) => { + const request = store.clear(); + request.onerror = () => reject(request.error); + request.onsuccess = () => resolve(); + }); + } +} diff --git a/apps/web/src/lib/storage/opfs-adapter.ts b/apps/web/src/lib/storage/opfs-adapter.ts new file mode 100644 index 0000000..c01954e --- /dev/null +++ b/apps/web/src/lib/storage/opfs-adapter.ts @@ -0,0 +1,73 @@ +import { StorageAdapter } from "./types"; + +export class OPFSAdapter implements StorageAdapter { + private directoryName: string; + + constructor(directoryName: string = "media") { + this.directoryName = directoryName; + } + + private async getDirectory(): Promise { + const opfsRoot = await navigator.storage.getDirectory(); + return await opfsRoot.getDirectoryHandle(this.directoryName, { + create: true, + }); + } + + async get(key: string): Promise { + try { + const directory = await this.getDirectory(); + const fileHandle = await directory.getFileHandle(key); + return await fileHandle.getFile(); + } catch (error) { + if ((error as Error).name === "NotFoundError") { + return null; + } + throw error; + } + } + + async set(key: string, file: File): Promise { + const directory = await this.getDirectory(); + const fileHandle = await directory.getFileHandle(key, { create: true }); + const writable = await fileHandle.createWritable(); + + await writable.write(file); + await writable.close(); + } + + async remove(key: string): Promise { + try { + const directory = await this.getDirectory(); + await directory.removeEntry(key); + } catch (error) { + if ((error as Error).name !== "NotFoundError") { + throw error; + } + } + } + + async list(): Promise { + const directory = await this.getDirectory(); + const keys: string[] = []; + + for await (const name of directory.keys()) { + keys.push(name); + } + + return keys; + } + + async clear(): Promise { + const directory = await this.getDirectory(); + + for await (const name of directory.keys()) { + await directory.removeEntry(name); + } + } + + // Helper method to check OPFS support + static isSupported(): boolean { + return "storage" in navigator && "getDirectory" in navigator.storage; + } +} diff --git a/apps/web/src/lib/storage/storage-service.ts b/apps/web/src/lib/storage/storage-service.ts new file mode 100644 index 0000000..5582ae2 --- /dev/null +++ b/apps/web/src/lib/storage/storage-service.ts @@ -0,0 +1,192 @@ +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, + aspectRatio: mediaItem.aspectRatio, + 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, + aspectRatio: metadata.aspectRatio, + 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 }; diff --git a/apps/web/src/lib/storage/types.ts b/apps/web/src/lib/storage/types.ts new file mode 100644 index 0000000..d56deeb --- /dev/null +++ b/apps/web/src/lib/storage/types.ts @@ -0,0 +1,41 @@ +import { TProject } from "@/types/project"; + +export interface StorageAdapter { + get(key: string): Promise; + set(key: string, value: T): Promise; + remove(key: string): Promise; + list(): Promise; + clear(): Promise; +} + +export interface MediaFileData { + id: string; + name: string; + type: "image" | "video" | "audio"; + size: number; + lastModified: number; + aspectRatio: number; + duration?: number; + // File will be stored separately in OPFS +} + +export interface StorageConfig { + projectsDb: string; + mediaDb: string; + version: number; +} + +// Helper type for serialization - converts Date objects to strings +export type SerializedProject = Omit & { + createdAt: string; + updatedAt: string; +}; + +// Extend FileSystemDirectoryHandle with missing async iterator methods +declare global { + interface FileSystemDirectoryHandle { + keys(): AsyncIterableIterator; + values(): AsyncIterableIterator; + entries(): AsyncIterableIterator<[string, FileSystemHandle]>; + } +} \ No newline at end of file diff --git a/apps/web/src/middleware.ts b/apps/web/src/middleware.ts index f2d3f59..d20c4b1 100644 --- a/apps/web/src/middleware.ts +++ b/apps/web/src/middleware.ts @@ -1,15 +1,13 @@ import { NextResponse } from "next/server"; import type { NextRequest } from "next/server"; -import { getSessionCookie } from "better-auth/cookies"; export async function middleware(request: NextRequest) { const path = request.nextUrl.pathname; - const session = getSessionCookie(request); - if (path === "/editor" && !session && process.env.NODE_ENV === "production") { - const loginUrl = new URL("/login", request.url); - loginUrl.searchParams.set("redirect", request.url); - return NextResponse.redirect(loginUrl); + if (path === "/editor" && process.env.NODE_ENV === "production") { + const homeUrl = new URL("/", request.url); + homeUrl.searchParams.set("redirect", request.url); + return NextResponse.redirect(homeUrl); } return NextResponse.next(); diff --git a/apps/web/src/stores/media-store.ts b/apps/web/src/stores/media-store.ts index bdb4d52..bf46581 100644 --- a/apps/web/src/stores/media-store.ts +++ b/apps/web/src/stores/media-store.ts @@ -1,4 +1,5 @@ import { create } from "zustand"; +import { storageService } from "@/lib/storage/storage-service"; export interface MediaItem { id: string; @@ -13,11 +14,13 @@ export interface MediaItem { interface MediaStore { mediaItems: MediaItem[]; + isLoading: boolean; // Actions - addMediaItem: (item: Omit) => void; - removeMediaItem: (id: string) => void; - clearAllMedia: () => void; + addMediaItem: (item: Omit) => Promise; + removeMediaItem: (id: string) => Promise; + loadAllMedia: () => Promise; + clearAllMedia: () => Promise; } // Helper function to determine file type @@ -126,18 +129,32 @@ export const getMediaDuration = (file: File): Promise => { export const useMediaStore = create((set, get) => ({ mediaItems: [], + isLoading: false, - addMediaItem: (item) => { + addMediaItem: async (item) => { const newItem: MediaItem = { ...item, id: crypto.randomUUID(), }; + + // Add to local state immediately for UI responsiveness set((state) => ({ mediaItems: [...state.mediaItems, newItem], })); + + // Save to persistent storage in background + try { + await storageService.saveMediaItem(newItem); + } catch (error) { + console.error("Failed to save media item:", error); + // Remove from local state if save failed + set((state) => ({ + mediaItems: state.mediaItems.filter((item) => item.id !== newItem.id), + })); + } }, - removeMediaItem: (id) => { + removeMediaItem: async (id) => { const state = get(); const item = state.mediaItems.find((item) => item.id === id); @@ -149,12 +166,34 @@ export const useMediaStore = create((set, get) => ({ } } + // Remove from local state immediately set((state) => ({ mediaItems: state.mediaItems.filter((item) => item.id !== id), })); + + // Remove from persistent storage + try { + await storageService.deleteMediaItem(id); + } catch (error) { + console.error("Failed to delete media item:", error); + // Could re-add to local state here if needed + } }, - clearAllMedia: () => { + loadAllMedia: async () => { + set({ isLoading: true }); + + try { + const mediaItems = await storageService.loadAllMediaItems(); + set({ mediaItems }); + } catch (error) { + console.error("Failed to load media items:", error); + } finally { + set({ isLoading: false }); + } + }, + + clearAllMedia: async () => { const state = get(); // Cleanup all object URLs @@ -165,6 +204,17 @@ export const useMediaStore = create((set, get) => ({ } }); + // Clear local state set({ mediaItems: [] }); + + // Clear persistent storage + try { + const mediaIds = state.mediaItems.map((item) => item.id); + await Promise.all( + mediaIds.map((id) => storageService.deleteMediaItem(id)) + ); + } catch (error) { + console.error("Failed to clear media items from storage:", error); + } }, })); diff --git a/apps/web/src/stores/project-store.ts b/apps/web/src/stores/project-store.ts index 43113fc..4fff83c 100644 --- a/apps/web/src/stores/project-store.ts +++ b/apps/web/src/stores/project-store.ts @@ -1,19 +1,32 @@ import { TProject } from "@/types/project"; import { create } from "zustand"; +import { storageService } from "@/lib/storage/storage-service"; +import { toast } from "sonner"; interface ProjectStore { activeProject: TProject | null; + savedProjects: TProject[]; + isLoading: boolean; + isInitialized: boolean; // Actions - createNewProject: (name: string) => void; + createNewProject: (name: string) => Promise; + loadProject: (id: string) => Promise; + saveCurrentProject: () => Promise; + loadAllProjects: () => Promise; + deleteProject: (id: string) => Promise; closeProject: () => void; - updateProjectName: (name: string) => void; + renameProject: (projectId: string, name: string) => Promise; + duplicateProject: (projectId: string) => Promise; } -export const useProjectStore = create((set) => ({ +export const useProjectStore = create((set, get) => ({ activeProject: null, + savedProjects: [], + isLoading: true, + isInitialized: false, - createNewProject: (name: string) => { + createNewProject: async (name: string) => { const newProject: TProject = { id: crypto.randomUUID(), name, @@ -21,22 +34,167 @@ export const useProjectStore = create((set) => ({ createdAt: new Date(), updatedAt: new Date(), }; + set({ activeProject: newProject }); + + try { + await storageService.saveProject(newProject); + // Reload all projects to update the list + await get().loadAllProjects(); + return newProject.id; + } catch (error) { + toast.error("Failed to save new project"); + throw error; + } + }, + + loadProject: async (id: string) => { + if (!get().isInitialized) { + set({ isLoading: true }); + } + + try { + const project = await storageService.loadProject(id); + if (project) { + set({ activeProject: project }); + } + } catch (error) { + console.error("Failed to load project:", error); + } finally { + set({ isLoading: false }); + } + }, + + saveCurrentProject: async () => { + const { activeProject } = get(); + if (!activeProject) return; + + try { + await storageService.saveProject(activeProject); + await get().loadAllProjects(); // Refresh the list + } catch (error) { + console.error("Failed to save project:", error); + } + }, + + loadAllProjects: async () => { + if (!get().isInitialized) { + set({ isLoading: true }); + } + + try { + const projects = await storageService.loadAllProjects(); + set({ savedProjects: projects }); + } catch (error) { + console.error("Failed to load projects:", error); + } finally { + set({ isLoading: false, isInitialized: true }); + } + }, + + deleteProject: async (id: string) => { + try { + await storageService.deleteProject(id); + await get().loadAllProjects(); // Refresh the list + + // If we deleted the active project, close it + const { activeProject } = get(); + if (activeProject?.id === id) { + set({ activeProject: null }); + } + } catch (error) { + console.error("Failed to delete project:", error); + } }, closeProject: () => { set({ activeProject: null }); }, - updateProjectName: (name: string) => { - set((state) => ({ - activeProject: state.activeProject - ? { - ...state.activeProject, - name, - updatedAt: new Date(), - } - : null, - })); + renameProject: async (id: string, name: string) => { + const { savedProjects } = get(); + + // Find the project to rename + const projectToRename = savedProjects.find((p) => p.id === id); + if (!projectToRename) { + toast.error("Project not found", { + description: "Please try again", + }); + return; + } + + const updatedProject = { + ...projectToRename, + name, + updatedAt: new Date(), + }; + + try { + // Save to storage + await storageService.saveProject(updatedProject); + + await get().loadAllProjects(); + + // Update activeProject if it's the same project + const { activeProject } = get(); + if (activeProject?.id === id) { + set({ activeProject: updatedProject }); + } + } catch (error) { + console.error("Failed to rename project:", error); + toast.error("Failed to rename project", { + description: + error instanceof Error ? error.message : "Please try again", + }); + } + }, + + duplicateProject: async (projectId: string) => { + try { + const project = await storageService.loadProject(projectId); + if (!project) { + toast.error("Project not found", { + description: "Please try again", + }); + throw new Error("Project not found"); + } + + const { savedProjects } = get(); + + // Extract the base name (remove any existing numbering) + const numberMatch = project.name.match(/^\((\d+)\)\s+(.+)$/); + const baseName = numberMatch ? numberMatch[2] : project.name; + const existingNumbers: number[] = []; + + // Check for pattern "(number) baseName" in existing projects + savedProjects.forEach((p) => { + const match = p.name.match(/^\((\d+)\)\s+(.+)$/); + if (match && match[2] === baseName) { + existingNumbers.push(parseInt(match[1], 10)); + } + }); + + const nextNumber = + existingNumbers.length > 0 ? Math.max(...existingNumbers) + 1 : 1; + + const newProject: TProject = { + id: crypto.randomUUID(), + name: `(${nextNumber}) ${baseName}`, + thumbnail: project.thumbnail, + createdAt: new Date(), + updatedAt: new Date(), + }; + + await storageService.saveProject(newProject); + await get().loadAllProjects(); + return newProject.id; + } catch (error) { + console.error("Failed to duplicate project:", error); + toast.error("Failed to duplicate project", { + description: + error instanceof Error ? error.message : "Please try again", + }); + throw error; + } }, }));