feat: implement project management features with storage integration, including project creation, deletion, and renaming dialogs
This commit is contained in:
89
apps/web/src/lib/storage/indexeddb-adapter.ts
Normal file
89
apps/web/src/lib/storage/indexeddb-adapter.ts
Normal file
@ -0,0 +1,89 @@
|
||||
import { StorageAdapter } from "./types";
|
||||
|
||||
export class IndexedDBAdapter<T> implements StorageAdapter<T> {
|
||||
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<IDBDatabase> {
|
||||
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<T | null> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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<string[]> {
|
||||
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<void> {
|
||||
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();
|
||||
});
|
||||
}
|
||||
}
|
73
apps/web/src/lib/storage/opfs-adapter.ts
Normal file
73
apps/web/src/lib/storage/opfs-adapter.ts
Normal file
@ -0,0 +1,73 @@
|
||||
import { StorageAdapter } from "./types";
|
||||
|
||||
export class OPFSAdapter implements StorageAdapter<File> {
|
||||
private directoryName: string;
|
||||
|
||||
constructor(directoryName: string = "media") {
|
||||
this.directoryName = directoryName;
|
||||
}
|
||||
|
||||
private async getDirectory(): Promise<FileSystemDirectoryHandle> {
|
||||
const opfsRoot = await navigator.storage.getDirectory();
|
||||
return await opfsRoot.getDirectoryHandle(this.directoryName, {
|
||||
create: true,
|
||||
});
|
||||
}
|
||||
|
||||
async get(key: string): Promise<File | null> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
try {
|
||||
const directory = await this.getDirectory();
|
||||
await directory.removeEntry(key);
|
||||
} catch (error) {
|
||||
if ((error as Error).name !== "NotFoundError") {
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async list(): Promise<string[]> {
|
||||
const directory = await this.getDirectory();
|
||||
const keys: string[] = [];
|
||||
|
||||
for await (const name of directory.keys()) {
|
||||
keys.push(name);
|
||||
}
|
||||
|
||||
return keys;
|
||||
}
|
||||
|
||||
async clear(): Promise<void> {
|
||||
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;
|
||||
}
|
||||
}
|
192
apps/web/src/lib/storage/storage-service.ts
Normal file
192
apps/web/src/lib/storage/storage-service.ts
Normal file
@ -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<SerializedProject>;
|
||||
private mediaMetadataAdapter: IndexedDBAdapter<MediaFileData>;
|
||||
private mediaFilesAdapter: OPFSAdapter;
|
||||
private config: StorageConfig;
|
||||
|
||||
constructor() {
|
||||
this.config = {
|
||||
projectsDb: "video-editor-projects",
|
||||
mediaDb: "video-editor-media",
|
||||
version: 1,
|
||||
};
|
||||
|
||||
this.projectsAdapter = new IndexedDBAdapter<SerializedProject>(
|
||||
this.config.projectsDb,
|
||||
"projects",
|
||||
this.config.version
|
||||
);
|
||||
|
||||
this.mediaMetadataAdapter = new IndexedDBAdapter<MediaFileData>(
|
||||
this.config.mediaDb,
|
||||
"media-metadata",
|
||||
this.config.version
|
||||
);
|
||||
|
||||
this.mediaFilesAdapter = new OPFSAdapter("media-files");
|
||||
}
|
||||
|
||||
// Project operations
|
||||
async saveProject(project: TProject): Promise<void> {
|
||||
// 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<TProject | null> {
|
||||
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<TProject[]> {
|
||||
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<void> {
|
||||
await this.projectsAdapter.remove(id);
|
||||
}
|
||||
|
||||
// Media operations
|
||||
async saveMediaItem(mediaItem: MediaItem): Promise<void> {
|
||||
// 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<MediaItem | null> {
|
||||
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<MediaItem[]> {
|
||||
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<void> {
|
||||
await Promise.all([
|
||||
this.mediaFilesAdapter.remove(id),
|
||||
this.mediaMetadataAdapter.remove(id),
|
||||
]);
|
||||
}
|
||||
|
||||
// Utility methods
|
||||
async clearAllData(): Promise<void> {
|
||||
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 };
|
41
apps/web/src/lib/storage/types.ts
Normal file
41
apps/web/src/lib/storage/types.ts
Normal file
@ -0,0 +1,41 @@
|
||||
import { TProject } from "@/types/project";
|
||||
|
||||
export interface StorageAdapter<T> {
|
||||
get(key: string): Promise<T | null>;
|
||||
set(key: string, value: T): Promise<void>;
|
||||
remove(key: string): Promise<void>;
|
||||
list(): Promise<string[]>;
|
||||
clear(): Promise<void>;
|
||||
}
|
||||
|
||||
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<TProject, "createdAt" | "updatedAt"> & {
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
};
|
||||
|
||||
// Extend FileSystemDirectoryHandle with missing async iterator methods
|
||||
declare global {
|
||||
interface FileSystemDirectoryHandle {
|
||||
keys(): AsyncIterableIterator<string>;
|
||||
values(): AsyncIterableIterator<FileSystemHandle>;
|
||||
entries(): AsyncIterableIterator<[string, FileSystemHandle]>;
|
||||
}
|
||||
}
|
Reference in New Issue
Block a user