From 09373eb4a3f792d96d97d81f4fb86539d76e5702 Mon Sep 17 00:00:00 2001 From: Maze Winther Date: Mon, 30 Jun 2025 19:58:36 +0200 Subject: feat: implement project management features with storage integration, including project creation, deletion, and renaming dialogs --- 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 ++++++ 4 files changed, 395 insertions(+) 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 (limited to 'apps/web/src/lib') 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 00000000..f43a8798 --- /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 00000000..c01954e5 --- /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 00000000..5582ae29 --- /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 00000000..d56deebd --- /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 -- cgit v1.3.1