Effect Chain System: - Create comprehensive effect chain types and state management - Implement EffectRack component with drag-and-drop reordering - Add enable/disable toggle for individual effects - Build PresetManager for save/load/import/export functionality - Create useEffectChain hook with localStorage persistence UI Integration: - Add Chain tab to SidePanel with effect rack - Integrate preset manager dialog - Add chain management controls (clear, presets) - Update SidePanel with chain props and handlers Features: - Drag-and-drop effect reordering with visual feedback - Effect bypass/enable toggle with power icons - Save effect chains as presets with descriptions - Import/export presets as JSON files - localStorage persistence for chains and presets - Visual status indicators for enabled/disabled effects - Preset timestamp and effect count display Components Created: - /lib/audio/effects/chain.ts - Effect chain types and utilities - /components/effects/EffectRack.tsx - Visual effect chain component - /components/effects/PresetManager.tsx - Preset management dialog - /lib/hooks/useEffectChain.ts - Effect chain state hook Updated PLAN.md: - Mark Phase 6.6 as complete - Update current status to Phase 6.6 Complete - Add effect chain features to working features list - Update Next Steps to show Phase 6 complete 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
251 lines
8.2 KiB
TypeScript
251 lines
8.2 KiB
TypeScript
'use client';
|
|
|
|
import * as React from 'react';
|
|
import { Save, FolderOpen, Trash2, Download, Upload } from 'lucide-react';
|
|
import { Button } from '@/components/ui/Button';
|
|
import { Modal } from '@/components/ui/Modal';
|
|
import type { EffectChain, EffectPreset } from '@/lib/audio/effects/chain';
|
|
import { createPreset, loadPreset } from '@/lib/audio/effects/chain';
|
|
|
|
export interface PresetManagerProps {
|
|
open: boolean;
|
|
onClose: () => void;
|
|
currentChain: EffectChain;
|
|
presets: EffectPreset[];
|
|
onSavePreset: (preset: EffectPreset) => void;
|
|
onLoadPreset: (preset: EffectPreset) => void;
|
|
onDeletePreset: (presetId: string) => void;
|
|
onExportPreset?: (preset: EffectPreset) => void;
|
|
onImportPreset?: (preset: EffectPreset) => void;
|
|
}
|
|
|
|
export function PresetManager({
|
|
open,
|
|
onClose,
|
|
currentChain,
|
|
presets,
|
|
onSavePreset,
|
|
onLoadPreset,
|
|
onDeletePreset,
|
|
onExportPreset,
|
|
onImportPreset,
|
|
}: PresetManagerProps) {
|
|
const [presetName, setPresetName] = React.useState('');
|
|
const [presetDescription, setPresetDescription] = React.useState('');
|
|
const [mode, setMode] = React.useState<'list' | 'create'>('list');
|
|
const fileInputRef = React.useRef<HTMLInputElement>(null);
|
|
|
|
const handleSave = () => {
|
|
if (!presetName.trim()) return;
|
|
|
|
const preset = createPreset(currentChain, presetName.trim(), presetDescription.trim());
|
|
onSavePreset(preset);
|
|
setPresetName('');
|
|
setPresetDescription('');
|
|
setMode('list');
|
|
};
|
|
|
|
const handleLoad = (preset: EffectPreset) => {
|
|
onLoadPreset(preset);
|
|
onClose();
|
|
};
|
|
|
|
const handleExport = (preset: EffectPreset) => {
|
|
if (!onExportPreset) return;
|
|
|
|
const data = JSON.stringify(preset, null, 2);
|
|
const blob = new Blob([data], { type: 'application/json' });
|
|
const url = URL.createObjectURL(blob);
|
|
const a = document.createElement('a');
|
|
a.href = url;
|
|
a.download = `${preset.name.replace(/[^a-z0-9]/gi, '_').toLowerCase()}.json`;
|
|
document.body.appendChild(a);
|
|
a.click();
|
|
document.body.removeChild(a);
|
|
URL.revokeObjectURL(url);
|
|
|
|
onExportPreset(preset);
|
|
};
|
|
|
|
const handleImportClick = () => {
|
|
fileInputRef.current?.click();
|
|
};
|
|
|
|
const handleFileChange = async (e: React.ChangeEvent<HTMLInputElement>) => {
|
|
const file = e.target.files?.[0];
|
|
if (!file || !onImportPreset) return;
|
|
|
|
try {
|
|
const text = await file.text();
|
|
const preset: EffectPreset = JSON.parse(text);
|
|
|
|
// Validate preset structure
|
|
if (!preset.id || !preset.name || !preset.chain) {
|
|
throw new Error('Invalid preset format');
|
|
}
|
|
|
|
onImportPreset(preset);
|
|
} catch (error) {
|
|
console.error('Failed to import preset:', error);
|
|
alert('Failed to import preset. Please check the file format.');
|
|
}
|
|
|
|
// Reset input
|
|
e.target.value = '';
|
|
};
|
|
|
|
return (
|
|
<Modal
|
|
open={open}
|
|
onClose={onClose}
|
|
title="Effect Presets"
|
|
className="max-w-2xl"
|
|
>
|
|
<div className="space-y-4">
|
|
{/* Mode Toggle */}
|
|
<div className="flex items-center gap-2 border-b border-border pb-3">
|
|
<Button
|
|
variant={mode === 'list' ? 'secondary' : 'ghost'}
|
|
size="sm"
|
|
onClick={() => setMode('list')}
|
|
>
|
|
<FolderOpen className="h-4 w-4 mr-2" />
|
|
Load Preset
|
|
</Button>
|
|
<Button
|
|
variant={mode === 'create' ? 'secondary' : 'ghost'}
|
|
size="sm"
|
|
onClick={() => setMode('create')}
|
|
>
|
|
<Save className="h-4 w-4 mr-2" />
|
|
Save Current
|
|
</Button>
|
|
{onImportPreset && (
|
|
<>
|
|
<Button variant="ghost" size="sm" onClick={handleImportClick}>
|
|
<Upload className="h-4 w-4 mr-2" />
|
|
Import
|
|
</Button>
|
|
<input
|
|
ref={fileInputRef}
|
|
type="file"
|
|
accept=".json"
|
|
onChange={handleFileChange}
|
|
className="hidden"
|
|
/>
|
|
</>
|
|
)}
|
|
</div>
|
|
|
|
{/* Create Mode */}
|
|
{mode === 'create' && (
|
|
<div className="space-y-3">
|
|
<div className="space-y-2">
|
|
<label className="text-sm font-medium text-foreground">
|
|
Preset Name
|
|
</label>
|
|
<input
|
|
type="text"
|
|
value={presetName}
|
|
onChange={(e) => setPresetName(e.target.value)}
|
|
placeholder="My Awesome Preset"
|
|
className="w-full px-3 py-2 bg-background border border-border rounded-md text-sm"
|
|
/>
|
|
</div>
|
|
<div className="space-y-2">
|
|
<label className="text-sm font-medium text-foreground">
|
|
Description (optional)
|
|
</label>
|
|
<textarea
|
|
value={presetDescription}
|
|
onChange={(e) => setPresetDescription(e.target.value)}
|
|
placeholder="What does this preset do?"
|
|
rows={3}
|
|
className="w-full px-3 py-2 bg-background border border-border rounded-md text-sm resize-none"
|
|
/>
|
|
</div>
|
|
<div className="text-xs text-muted-foreground">
|
|
Current chain has {currentChain.effects.length} effect
|
|
{currentChain.effects.length !== 1 ? 's' : ''}
|
|
</div>
|
|
<div className="flex justify-end gap-2">
|
|
<Button variant="outline" onClick={() => setMode('list')}>
|
|
Cancel
|
|
</Button>
|
|
<Button
|
|
onClick={handleSave}
|
|
disabled={!presetName.trim()}
|
|
>
|
|
<Save className="h-4 w-4 mr-2" />
|
|
Save Preset
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{/* List Mode */}
|
|
{mode === 'list' && (
|
|
<div className="space-y-2 max-h-96 overflow-y-auto custom-scrollbar">
|
|
{presets.length === 0 ? (
|
|
<div className="text-center py-8 text-sm text-muted-foreground">
|
|
No presets saved yet. Create one to get started!
|
|
</div>
|
|
) : (
|
|
presets.map((preset) => (
|
|
<div
|
|
key={preset.id}
|
|
className="flex items-start gap-3 p-3 rounded-lg border border-border bg-card hover:bg-accent/50 transition-colors"
|
|
>
|
|
<div className="flex-1 min-w-0">
|
|
<div className="text-sm font-medium text-foreground">
|
|
{preset.name}
|
|
</div>
|
|
{preset.description && (
|
|
<div className="text-xs text-muted-foreground mt-1">
|
|
{preset.description}
|
|
</div>
|
|
)}
|
|
<div className="text-xs text-muted-foreground mt-1">
|
|
{preset.chain.effects.length} effect
|
|
{preset.chain.effects.length !== 1 ? 's' : ''} •{' '}
|
|
{new Date(preset.createdAt).toLocaleDateString()}
|
|
</div>
|
|
</div>
|
|
<div className="flex items-center gap-1">
|
|
<Button
|
|
variant="ghost"
|
|
size="icon-sm"
|
|
onClick={() => handleLoad(preset)}
|
|
title="Load preset"
|
|
>
|
|
<FolderOpen className="h-4 w-4" />
|
|
</Button>
|
|
{onExportPreset && (
|
|
<Button
|
|
variant="ghost"
|
|
size="icon-sm"
|
|
onClick={() => handleExport(preset)}
|
|
title="Export preset"
|
|
>
|
|
<Download className="h-4 w-4" />
|
|
</Button>
|
|
)}
|
|
<Button
|
|
variant="ghost"
|
|
size="icon-sm"
|
|
onClick={() => onDeletePreset(preset.id)}
|
|
title="Delete preset"
|
|
>
|
|
<Trash2 className="h-4 w-4 text-destructive" />
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
))
|
|
)}
|
|
</div>
|
|
)}
|
|
</div>
|
|
</Modal>
|
|
);
|
|
}
|