feat: implement Phase 12.1 - Project Management with IndexedDB
Added complete project save/load system using IndexedDB: **New Files:** - `lib/storage/db.ts` - IndexedDB database schema and operations - ProjectMetadata interface for project metadata - SerializedAudioBuffer and SerializedTrack for storage - Database initialization with projects object store - Audio buffer serialization/deserialization functions - CRUD operations for projects - `lib/storage/projects.ts` - High-level project management service - Save/load project state with tracks and settings - List all projects sorted by last updated - Delete and duplicate project operations - Track serialization with proper type conversions - Audio buffer and effect chain handling - `components/dialogs/ProjectsDialog.tsx` - Project list UI - Grid view of all projects with metadata - Project actions: Open, Duplicate, Delete - Create new project button - Empty state with call-to-action - Confirmation dialog for deletions **Key Features:** - IndexedDB stores complete project state (tracks, audio buffers, settings) - Efficient serialization of AudioBuffer channel data - Preserves all track properties (effects, automation, volume, pan) - Sample rate and duration tracking - Created/updated timestamps - Type-safe with full TypeScript support 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
142
components/dialogs/ProjectsDialog.tsx
Normal file
142
components/dialogs/ProjectsDialog.tsx
Normal file
@@ -0,0 +1,142 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import * as React from 'react';
|
||||||
|
import { X, Plus, Trash2, Copy, FolderOpen } from 'lucide-react';
|
||||||
|
import { Button } from '@/components/ui/Button';
|
||||||
|
import type { ProjectMetadata } from '@/lib/storage/db';
|
||||||
|
import { formatDuration } from '@/lib/audio/decoder';
|
||||||
|
|
||||||
|
export interface ProjectsDialogProps {
|
||||||
|
open: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
projects: ProjectMetadata[];
|
||||||
|
onNewProject: () => void;
|
||||||
|
onLoadProject: (projectId: string) => void;
|
||||||
|
onDeleteProject: (projectId: string) => void;
|
||||||
|
onDuplicateProject: (projectId: string) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ProjectsDialog({
|
||||||
|
open,
|
||||||
|
onClose,
|
||||||
|
projects,
|
||||||
|
onNewProject,
|
||||||
|
onLoadProject,
|
||||||
|
onDeleteProject,
|
||||||
|
onDuplicateProject,
|
||||||
|
}: ProjectsDialogProps) {
|
||||||
|
if (!open) return null;
|
||||||
|
|
||||||
|
const formatDate = (timestamp: number) => {
|
||||||
|
return new Date(timestamp).toLocaleString(undefined, {
|
||||||
|
year: 'numeric',
|
||||||
|
month: 'short',
|
||||||
|
day: 'numeric',
|
||||||
|
hour: '2-digit',
|
||||||
|
minute: '2-digit',
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50">
|
||||||
|
<div className="bg-card border border-border rounded-lg shadow-xl w-full max-w-3xl max-h-[80vh] flex flex-col">
|
||||||
|
{/* Header */}
|
||||||
|
<div className="flex items-center justify-between p-6 border-b border-border">
|
||||||
|
<h2 className="text-lg font-semibold text-foreground">Projects</h2>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Button
|
||||||
|
onClick={onNewProject}
|
||||||
|
variant="default"
|
||||||
|
size="sm"
|
||||||
|
className="gap-2"
|
||||||
|
>
|
||||||
|
<Plus className="h-4 w-4" />
|
||||||
|
New Project
|
||||||
|
</Button>
|
||||||
|
<button
|
||||||
|
onClick={onClose}
|
||||||
|
className="text-muted-foreground hover:text-foreground transition-colors"
|
||||||
|
>
|
||||||
|
<X className="h-5 w-5" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Projects List */}
|
||||||
|
<div className="flex-1 overflow-y-auto p-6">
|
||||||
|
{projects.length === 0 ? (
|
||||||
|
<div className="flex flex-col items-center justify-center py-12 text-center">
|
||||||
|
<FolderOpen className="h-16 w-16 text-muted-foreground mb-4" />
|
||||||
|
<h3 className="text-lg font-medium text-foreground mb-2">
|
||||||
|
No projects yet
|
||||||
|
</h3>
|
||||||
|
<p className="text-sm text-muted-foreground mb-4">
|
||||||
|
Create your first project to get started
|
||||||
|
</p>
|
||||||
|
<Button onClick={onNewProject} variant="default">
|
||||||
|
<Plus className="h-4 w-4 mr-2" />
|
||||||
|
Create Project
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="grid gap-4">
|
||||||
|
{projects.map((project) => (
|
||||||
|
<div
|
||||||
|
key={project.id}
|
||||||
|
className="border border-border rounded-lg p-4 hover:bg-accent/50 transition-colors"
|
||||||
|
>
|
||||||
|
<div className="flex items-start justify-between">
|
||||||
|
<div className="flex-1">
|
||||||
|
<h3 className="font-medium text-foreground mb-1">
|
||||||
|
{project.name}
|
||||||
|
</h3>
|
||||||
|
{project.description && (
|
||||||
|
<p className="text-sm text-muted-foreground mb-2">
|
||||||
|
{project.description}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
<div className="flex flex-wrap gap-4 text-xs text-muted-foreground">
|
||||||
|
<span>{project.trackCount} tracks</span>
|
||||||
|
<span>{formatDuration(project.duration)}</span>
|
||||||
|
<span>{project.sampleRate / 1000}kHz</span>
|
||||||
|
<span>Updated {formatDate(project.updatedAt)}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-2 ml-4">
|
||||||
|
<button
|
||||||
|
onClick={() => onLoadProject(project.id)}
|
||||||
|
className="px-3 py-1.5 text-sm font-medium text-primary hover:bg-primary/10 rounded transition-colors"
|
||||||
|
title="Open project"
|
||||||
|
>
|
||||||
|
Open
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => onDuplicateProject(project.id)}
|
||||||
|
className="p-1.5 text-muted-foreground hover:text-foreground hover:bg-accent rounded transition-colors"
|
||||||
|
title="Duplicate project"
|
||||||
|
>
|
||||||
|
<Copy className="h-4 w-4" />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => {
|
||||||
|
if (confirm(`Delete "${project.name}"? This cannot be undone.`)) {
|
||||||
|
onDeleteProject(project.id);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
className="p-1.5 text-muted-foreground hover:text-destructive hover:bg-destructive/10 rounded transition-colors"
|
||||||
|
title="Delete project"
|
||||||
|
>
|
||||||
|
<Trash2 className="h-4 w-4" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
193
lib/storage/db.ts
Normal file
193
lib/storage/db.ts
Normal file
@@ -0,0 +1,193 @@
|
|||||||
|
/**
|
||||||
|
* IndexedDB database for project storage
|
||||||
|
*/
|
||||||
|
|
||||||
|
export const DB_NAME = 'audio-editor-db';
|
||||||
|
export const DB_VERSION = 1;
|
||||||
|
|
||||||
|
export interface ProjectMetadata {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
description?: string;
|
||||||
|
createdAt: number;
|
||||||
|
updatedAt: number;
|
||||||
|
duration: number; // Total project duration in seconds
|
||||||
|
sampleRate: number;
|
||||||
|
trackCount: number;
|
||||||
|
thumbnail?: string; // Base64 encoded waveform thumbnail
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SerializedAudioBuffer {
|
||||||
|
sampleRate: number;
|
||||||
|
length: number;
|
||||||
|
numberOfChannels: number;
|
||||||
|
channelData: Float32Array[]; // Array of channel data
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SerializedTrack {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
color: string;
|
||||||
|
volume: number;
|
||||||
|
pan: number;
|
||||||
|
muted: boolean;
|
||||||
|
soloed: boolean;
|
||||||
|
collapsed: boolean;
|
||||||
|
height: number;
|
||||||
|
audioBuffer: SerializedAudioBuffer | null;
|
||||||
|
effects: any[]; // Effect chain
|
||||||
|
automation: any; // Automation data
|
||||||
|
recordEnabled: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ProjectData {
|
||||||
|
metadata: ProjectMetadata;
|
||||||
|
tracks: SerializedTrack[];
|
||||||
|
settings: {
|
||||||
|
zoom: number;
|
||||||
|
currentTime: number;
|
||||||
|
sampleRate: number;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Initialize IndexedDB database
|
||||||
|
*/
|
||||||
|
export function initDB(): Promise<IDBDatabase> {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const request = indexedDB.open(DB_NAME, DB_VERSION);
|
||||||
|
|
||||||
|
request.onerror = () => reject(request.error);
|
||||||
|
request.onsuccess = () => resolve(request.result);
|
||||||
|
|
||||||
|
request.onupgradeneeded = (event) => {
|
||||||
|
const db = (event.target as IDBOpenDBRequest).result;
|
||||||
|
|
||||||
|
// Create projects object store
|
||||||
|
if (!db.objectStoreNames.contains('projects')) {
|
||||||
|
const projectStore = db.createObjectStore('projects', { keyPath: 'metadata.id' });
|
||||||
|
projectStore.createIndex('updatedAt', 'metadata.updatedAt', { unique: false });
|
||||||
|
projectStore.createIndex('name', 'metadata.name', { unique: false });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create audio buffers object store (for large files)
|
||||||
|
if (!db.objectStoreNames.contains('audioBuffers')) {
|
||||||
|
db.createObjectStore('audioBuffers', { keyPath: 'id' });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get all projects (metadata only for list view)
|
||||||
|
*/
|
||||||
|
export async function getAllProjects(): Promise<ProjectMetadata[]> {
|
||||||
|
const db = await initDB();
|
||||||
|
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const transaction = db.transaction(['projects'], 'readonly');
|
||||||
|
const store = transaction.objectStore('projects');
|
||||||
|
const index = store.index('updatedAt');
|
||||||
|
const request = index.openCursor(null, 'prev'); // Most recent first
|
||||||
|
|
||||||
|
const projects: ProjectMetadata[] = [];
|
||||||
|
|
||||||
|
request.onsuccess = () => {
|
||||||
|
const cursor = request.result;
|
||||||
|
if (cursor) {
|
||||||
|
projects.push(cursor.value.metadata);
|
||||||
|
cursor.continue();
|
||||||
|
} else {
|
||||||
|
resolve(projects);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
request.onerror = () => reject(request.error);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Save project to IndexedDB
|
||||||
|
*/
|
||||||
|
export async function saveProject(project: ProjectData): Promise<void> {
|
||||||
|
const db = await initDB();
|
||||||
|
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const transaction = db.transaction(['projects'], 'readwrite');
|
||||||
|
const store = transaction.objectStore('projects');
|
||||||
|
const request = store.put(project);
|
||||||
|
|
||||||
|
request.onsuccess = () => resolve();
|
||||||
|
request.onerror = () => reject(request.error);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Load project from IndexedDB
|
||||||
|
*/
|
||||||
|
export async function loadProject(projectId: string): Promise<ProjectData | null> {
|
||||||
|
const db = await initDB();
|
||||||
|
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const transaction = db.transaction(['projects'], 'readonly');
|
||||||
|
const store = transaction.objectStore('projects');
|
||||||
|
const request = store.get(projectId);
|
||||||
|
|
||||||
|
request.onsuccess = () => resolve(request.result || null);
|
||||||
|
request.onerror = () => reject(request.error);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Delete project from IndexedDB
|
||||||
|
*/
|
||||||
|
export async function deleteProject(projectId: string): Promise<void> {
|
||||||
|
const db = await initDB();
|
||||||
|
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const transaction = db.transaction(['projects'], 'readwrite');
|
||||||
|
const store = transaction.objectStore('projects');
|
||||||
|
const request = store.delete(projectId);
|
||||||
|
|
||||||
|
request.onsuccess = () => resolve();
|
||||||
|
request.onerror = () => reject(request.error);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Serialize AudioBuffer for storage
|
||||||
|
*/
|
||||||
|
export function serializeAudioBuffer(buffer: AudioBuffer): SerializedAudioBuffer {
|
||||||
|
const channelData: Float32Array[] = [];
|
||||||
|
|
||||||
|
for (let i = 0; i < buffer.numberOfChannels; i++) {
|
||||||
|
channelData.push(new Float32Array(buffer.getChannelData(i)));
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
sampleRate: buffer.sampleRate,
|
||||||
|
length: buffer.length,
|
||||||
|
numberOfChannels: buffer.numberOfChannels,
|
||||||
|
channelData,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Deserialize AudioBuffer from storage
|
||||||
|
*/
|
||||||
|
export function deserializeAudioBuffer(
|
||||||
|
serialized: SerializedAudioBuffer,
|
||||||
|
audioContext: AudioContext
|
||||||
|
): AudioBuffer {
|
||||||
|
const buffer = audioContext.createBuffer(
|
||||||
|
serialized.numberOfChannels,
|
||||||
|
serialized.length,
|
||||||
|
serialized.sampleRate
|
||||||
|
);
|
||||||
|
|
||||||
|
for (let i = 0; i < serialized.numberOfChannels; i++) {
|
||||||
|
buffer.copyToChannel(new Float32Array(serialized.channelData[i]), i);
|
||||||
|
}
|
||||||
|
|
||||||
|
return buffer;
|
||||||
|
}
|
||||||
190
lib/storage/projects.ts
Normal file
190
lib/storage/projects.ts
Normal file
@@ -0,0 +1,190 @@
|
|||||||
|
/**
|
||||||
|
* Project management service
|
||||||
|
*/
|
||||||
|
|
||||||
|
import type { Track } from '@/types/track';
|
||||||
|
import {
|
||||||
|
saveProject,
|
||||||
|
loadProject,
|
||||||
|
getAllProjects,
|
||||||
|
deleteProject,
|
||||||
|
serializeAudioBuffer,
|
||||||
|
deserializeAudioBuffer,
|
||||||
|
type ProjectData,
|
||||||
|
type ProjectMetadata,
|
||||||
|
type SerializedTrack,
|
||||||
|
} from './db';
|
||||||
|
import { getAudioContext } from '../audio/context';
|
||||||
|
import { generateId } from '../audio/effects/chain';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generate unique project ID
|
||||||
|
*/
|
||||||
|
export function generateProjectId(): string {
|
||||||
|
return `project_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Convert tracks to serialized format
|
||||||
|
*/
|
||||||
|
function serializeTracks(tracks: Track[]): SerializedTrack[] {
|
||||||
|
return tracks.map(track => ({
|
||||||
|
id: track.id,
|
||||||
|
name: track.name,
|
||||||
|
color: track.color,
|
||||||
|
volume: track.volume,
|
||||||
|
pan: track.pan,
|
||||||
|
muted: track.mute,
|
||||||
|
soloed: track.solo,
|
||||||
|
collapsed: track.collapsed,
|
||||||
|
height: track.height,
|
||||||
|
audioBuffer: track.audioBuffer ? serializeAudioBuffer(track.audioBuffer) : null,
|
||||||
|
effects: track.effectChain?.effects || [],
|
||||||
|
automation: track.automation,
|
||||||
|
recordEnabled: track.recordEnabled,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Convert serialized tracks back to Track format
|
||||||
|
*/
|
||||||
|
function deserializeTracks(serialized: SerializedTrack[]): Track[] {
|
||||||
|
const audioContext = getAudioContext();
|
||||||
|
|
||||||
|
return serialized.map(track => ({
|
||||||
|
id: track.id,
|
||||||
|
name: track.name,
|
||||||
|
color: track.color,
|
||||||
|
volume: track.volume,
|
||||||
|
pan: track.pan,
|
||||||
|
mute: track.muted,
|
||||||
|
solo: track.soloed,
|
||||||
|
collapsed: track.collapsed,
|
||||||
|
height: track.height,
|
||||||
|
audioBuffer: track.audioBuffer ? deserializeAudioBuffer(track.audioBuffer, audioContext) : null,
|
||||||
|
effectChain: {
|
||||||
|
id: generateId(),
|
||||||
|
name: `${track.name} FX`,
|
||||||
|
effects: track.effects,
|
||||||
|
},
|
||||||
|
automation: track.automation,
|
||||||
|
recordEnabled: track.recordEnabled,
|
||||||
|
selected: false,
|
||||||
|
showEffects: false,
|
||||||
|
selection: null, // Reset selection on load
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Calculate total project duration
|
||||||
|
*/
|
||||||
|
function calculateDuration(tracks: Track[]): number {
|
||||||
|
let maxDuration = 0;
|
||||||
|
|
||||||
|
for (const track of tracks) {
|
||||||
|
if (track.audioBuffer) {
|
||||||
|
maxDuration = Math.max(maxDuration, track.audioBuffer.duration);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return maxDuration;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Save current project state
|
||||||
|
*/
|
||||||
|
export async function saveCurrentProject(
|
||||||
|
projectId: string | null,
|
||||||
|
projectName: string,
|
||||||
|
tracks: Track[],
|
||||||
|
settings: {
|
||||||
|
zoom: number;
|
||||||
|
currentTime: number;
|
||||||
|
sampleRate: number;
|
||||||
|
},
|
||||||
|
description?: string
|
||||||
|
): Promise<string> {
|
||||||
|
const id = projectId || generateProjectId();
|
||||||
|
const now = Date.now();
|
||||||
|
|
||||||
|
const metadata: ProjectMetadata = {
|
||||||
|
id,
|
||||||
|
name: projectName,
|
||||||
|
description,
|
||||||
|
createdAt: projectId ? (await loadProject(id))?.metadata.createdAt || now : now,
|
||||||
|
updatedAt: now,
|
||||||
|
duration: calculateDuration(tracks),
|
||||||
|
sampleRate: settings.sampleRate,
|
||||||
|
trackCount: tracks.length,
|
||||||
|
};
|
||||||
|
|
||||||
|
const projectData: ProjectData = {
|
||||||
|
metadata,
|
||||||
|
tracks: serializeTracks(tracks),
|
||||||
|
settings,
|
||||||
|
};
|
||||||
|
|
||||||
|
await saveProject(projectData);
|
||||||
|
return id;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Load project and restore state
|
||||||
|
*/
|
||||||
|
export async function loadProjectById(projectId: string): Promise<{
|
||||||
|
tracks: Track[];
|
||||||
|
settings: {
|
||||||
|
zoom: number;
|
||||||
|
currentTime: number;
|
||||||
|
sampleRate: number;
|
||||||
|
};
|
||||||
|
metadata: ProjectMetadata;
|
||||||
|
} | null> {
|
||||||
|
const project = await loadProject(projectId);
|
||||||
|
if (!project) return null;
|
||||||
|
|
||||||
|
return {
|
||||||
|
tracks: deserializeTracks(project.tracks),
|
||||||
|
settings: project.settings,
|
||||||
|
metadata: project.metadata,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get list of all projects
|
||||||
|
*/
|
||||||
|
export async function listProjects(): Promise<ProjectMetadata[]> {
|
||||||
|
return getAllProjects();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Delete a project
|
||||||
|
*/
|
||||||
|
export async function removeProject(projectId: string): Promise<void> {
|
||||||
|
return deleteProject(projectId);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Duplicate a project
|
||||||
|
*/
|
||||||
|
export async function duplicateProject(sourceProjectId: string, newName: string): Promise<string> {
|
||||||
|
const project = await loadProject(sourceProjectId);
|
||||||
|
if (!project) throw new Error('Project not found');
|
||||||
|
|
||||||
|
const newId = generateProjectId();
|
||||||
|
const now = Date.now();
|
||||||
|
|
||||||
|
const newProject: ProjectData = {
|
||||||
|
...project,
|
||||||
|
metadata: {
|
||||||
|
...project.metadata,
|
||||||
|
id: newId,
|
||||||
|
name: newName,
|
||||||
|
createdAt: now,
|
||||||
|
updatedAt: now,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
await saveProject(newProject);
|
||||||
|
return newId;
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user