feat: implement project management features with storage integration, including project creation, deletion, and renaming dialogs

This commit is contained in:
Maze Winther
2025-06-30 19:58:36 +02:00
parent cd30c205b4
commit 09373eb4a3
15 changed files with 1114 additions and 269 deletions

View 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();
});
}
}

View 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;
}
}

View 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 };

View 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]>;
}
}