feat: add advanced audio effects and improve UI
Phase 6.5 Advanced Effects: - Add Pitch Shifter with semitones and cents adjustment - Add Time Stretch with pitch preservation using overlap-add - Add Distortion with soft/hard/tube types and tone control - Add Bitcrusher with bit depth and sample rate reduction - Add AdvancedParameterDialog with real-time waveform visualization - Add 4 professional presets per effect type Improvements: - Fix undefined parameter errors by adding nullish coalescing operators - Add global custom scrollbar styling with color-mix transparency - Add custom-scrollbar utility class for side panel - Improve theme-aware scrollbar appearance in light/dark modes - Fix parameter initialization when switching effect types Integration: - All advanced effects support undo/redo via EffectCommand - Effects accessible via command palette and side panel - Selection-based processing support - Toast notifications for all effects 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
@@ -4,7 +4,7 @@ import { cn } from '@/lib/utils/cn';
|
||||
export interface ButtonProps
|
||||
extends React.ButtonHTMLAttributes<HTMLButtonElement> {
|
||||
variant?: 'default' | 'destructive' | 'outline' | 'secondary' | 'ghost' | 'link';
|
||||
size?: 'default' | 'sm' | 'lg' | 'icon';
|
||||
size?: 'default' | 'sm' | 'lg' | 'icon' | 'icon-sm';
|
||||
}
|
||||
|
||||
const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
|
||||
@@ -32,6 +32,7 @@ const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
|
||||
'h-9 rounded-md px-3': size === 'sm',
|
||||
'h-11 rounded-md px-8': size === 'lg',
|
||||
'h-10 w-10': size === 'icon',
|
||||
'h-8 w-8': size === 'icon-sm',
|
||||
},
|
||||
className
|
||||
)}
|
||||
|
||||
195
components/ui/CommandPalette.tsx
Normal file
195
components/ui/CommandPalette.tsx
Normal file
@@ -0,0 +1,195 @@
|
||||
'use client';
|
||||
|
||||
import * as React from 'react';
|
||||
import { Command } from 'lucide-react';
|
||||
import { cn } from '@/lib/utils/cn';
|
||||
|
||||
export interface CommandAction {
|
||||
id: string;
|
||||
label: string;
|
||||
description?: string;
|
||||
shortcut?: string;
|
||||
category: 'edit' | 'playback' | 'file' | 'view' | 'effects';
|
||||
action: () => void;
|
||||
}
|
||||
|
||||
export interface CommandPaletteProps {
|
||||
actions: CommandAction[];
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function CommandPalette({ actions, className }: CommandPaletteProps) {
|
||||
const [isOpen, setIsOpen] = React.useState(false);
|
||||
const [search, setSearch] = React.useState('');
|
||||
const [selectedIndex, setSelectedIndex] = React.useState(0);
|
||||
const inputRef = React.useRef<HTMLInputElement>(null);
|
||||
|
||||
const filteredActions = React.useMemo(() => {
|
||||
if (!search) return actions;
|
||||
const query = search.toLowerCase();
|
||||
return actions.filter(
|
||||
(action) =>
|
||||
action.label.toLowerCase().includes(query) ||
|
||||
action.description?.toLowerCase().includes(query) ||
|
||||
action.category.toLowerCase().includes(query)
|
||||
);
|
||||
}, [actions, search]);
|
||||
|
||||
const groupedActions = React.useMemo(() => {
|
||||
const groups: Record<string, CommandAction[]> = {};
|
||||
filteredActions.forEach((action) => {
|
||||
if (!groups[action.category]) {
|
||||
groups[action.category] = [];
|
||||
}
|
||||
groups[action.category].push(action);
|
||||
});
|
||||
return groups;
|
||||
}, [filteredActions]);
|
||||
|
||||
React.useEffect(() => {
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
// Ctrl+K or Cmd+K to open
|
||||
if ((e.ctrlKey || e.metaKey) && e.key === 'k') {
|
||||
e.preventDefault();
|
||||
setIsOpen(true);
|
||||
}
|
||||
// Escape to close
|
||||
if (e.key === 'Escape') {
|
||||
setIsOpen(false);
|
||||
setSearch('');
|
||||
setSelectedIndex(0);
|
||||
}
|
||||
};
|
||||
|
||||
window.addEventListener('keydown', handleKeyDown);
|
||||
return () => window.removeEventListener('keydown', handleKeyDown);
|
||||
}, []);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (isOpen && inputRef.current) {
|
||||
inputRef.current.focus();
|
||||
}
|
||||
}, [isOpen]);
|
||||
|
||||
const handleKeyDown = (e: React.KeyboardEvent) => {
|
||||
if (e.key === 'ArrowDown') {
|
||||
e.preventDefault();
|
||||
setSelectedIndex((prev) => Math.min(prev + 1, filteredActions.length - 1));
|
||||
} else if (e.key === 'ArrowUp') {
|
||||
e.preventDefault();
|
||||
setSelectedIndex((prev) => Math.max(prev - 1, 0));
|
||||
} else if (e.key === 'Enter') {
|
||||
e.preventDefault();
|
||||
if (filteredActions[selectedIndex]) {
|
||||
filteredActions[selectedIndex].action();
|
||||
setIsOpen(false);
|
||||
setSearch('');
|
||||
setSelectedIndex(0);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const executeAction = (action: CommandAction) => {
|
||||
action.action();
|
||||
setIsOpen(false);
|
||||
setSearch('');
|
||||
setSelectedIndex(0);
|
||||
};
|
||||
|
||||
if (!isOpen) {
|
||||
return (
|
||||
<button
|
||||
onClick={() => setIsOpen(true)}
|
||||
className={cn(
|
||||
'h-9 w-9 rounded-md',
|
||||
'inline-flex items-center justify-center',
|
||||
'hover:bg-accent hover:text-accent-foreground',
|
||||
'transition-colors',
|
||||
className
|
||||
)}
|
||||
title="Command Palette (Ctrl+K)"
|
||||
>
|
||||
<Command className="h-5 w-5" />
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-start justify-center p-4 sm:p-8 bg-black/50 backdrop-blur-sm">
|
||||
<div
|
||||
className={cn(
|
||||
'w-full max-w-2xl mt-20 bg-card rounded-lg border-2 border-border shadow-2xl',
|
||||
'animate-slideInFromTop',
|
||||
className
|
||||
)}
|
||||
>
|
||||
{/* Search Input */}
|
||||
<div className="flex items-center gap-3 p-4 border-b border-border">
|
||||
<Command className="h-5 w-5 text-muted-foreground" />
|
||||
<input
|
||||
ref={inputRef}
|
||||
type="text"
|
||||
value={search}
|
||||
onChange={(e) => {
|
||||
setSearch(e.target.value);
|
||||
setSelectedIndex(0);
|
||||
}}
|
||||
onKeyDown={handleKeyDown}
|
||||
placeholder="Type a command or search..."
|
||||
className="flex-1 bg-transparent border-none outline-none text-foreground placeholder:text-muted-foreground"
|
||||
/>
|
||||
<kbd className="px-2 py-1 text-xs bg-muted rounded border border-border">
|
||||
ESC
|
||||
</kbd>
|
||||
</div>
|
||||
|
||||
{/* Results */}
|
||||
<div className="max-h-96 overflow-y-auto p-2">
|
||||
{Object.keys(groupedActions).length === 0 ? (
|
||||
<div className="p-8 text-center text-muted-foreground text-sm">
|
||||
No commands found
|
||||
</div>
|
||||
) : (
|
||||
Object.entries(groupedActions).map(([category, categoryActions]) => (
|
||||
<div key={category} className="mb-4 last:mb-0">
|
||||
<div className="px-2 py-1 text-xs font-semibold text-muted-foreground uppercase">
|
||||
{category}
|
||||
</div>
|
||||
{categoryActions.map((action, index) => {
|
||||
const globalIndex = filteredActions.indexOf(action);
|
||||
return (
|
||||
<button
|
||||
key={action.id}
|
||||
onClick={() => executeAction(action)}
|
||||
className={cn(
|
||||
'w-full flex items-center justify-between gap-4 px-3 py-2.5 rounded-md',
|
||||
'hover:bg-secondary/50 transition-colors text-left',
|
||||
globalIndex === selectedIndex && 'bg-secondary'
|
||||
)}
|
||||
>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="text-sm font-medium text-foreground">
|
||||
{action.label}
|
||||
</div>
|
||||
{action.description && (
|
||||
<div className="text-xs text-muted-foreground truncate">
|
||||
{action.description}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{action.shortcut && (
|
||||
<kbd className="px-2 py-1 text-xs bg-muted rounded border border-border whitespace-nowrap">
|
||||
{action.shortcut}
|
||||
</kbd>
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
118
components/ui/Modal.tsx
Normal file
118
components/ui/Modal.tsx
Normal file
@@ -0,0 +1,118 @@
|
||||
'use client';
|
||||
|
||||
import * as React from 'react';
|
||||
import { X } from 'lucide-react';
|
||||
import { Button } from './Button';
|
||||
import { cn } from '@/lib/utils/cn';
|
||||
|
||||
export interface ModalProps {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
title: string;
|
||||
description?: string;
|
||||
children: React.ReactNode;
|
||||
footer?: React.ReactNode;
|
||||
size?: 'sm' | 'md' | 'lg' | 'xl';
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function Modal({
|
||||
open,
|
||||
onClose,
|
||||
title,
|
||||
description,
|
||||
children,
|
||||
footer,
|
||||
size = 'md',
|
||||
className,
|
||||
}: ModalProps) {
|
||||
// Close on Escape key
|
||||
React.useEffect(() => {
|
||||
const handleEscape = (e: KeyboardEvent) => {
|
||||
if (e.key === 'Escape' && open) {
|
||||
onClose();
|
||||
}
|
||||
};
|
||||
|
||||
if (open) {
|
||||
document.addEventListener('keydown', handleEscape);
|
||||
// Prevent body scroll when modal is open
|
||||
document.body.style.overflow = 'hidden';
|
||||
}
|
||||
|
||||
return () => {
|
||||
document.removeEventListener('keydown', handleEscape);
|
||||
document.body.style.overflow = 'unset';
|
||||
};
|
||||
}, [open, onClose]);
|
||||
|
||||
if (!open) return null;
|
||||
|
||||
const sizeClasses = {
|
||||
sm: 'max-w-sm',
|
||||
md: 'max-w-md',
|
||||
lg: 'max-w-lg',
|
||||
xl: 'max-w-xl',
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center">
|
||||
{/* Backdrop */}
|
||||
<div
|
||||
className="fixed inset-0 bg-black/50 backdrop-blur-sm"
|
||||
onClick={onClose}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
|
||||
{/* Modal */}
|
||||
<div
|
||||
className={cn(
|
||||
'relative w-full bg-card border border-border rounded-lg shadow-lg',
|
||||
'flex flex-col max-h-[90vh]',
|
||||
sizeClasses[size],
|
||||
className
|
||||
)}
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby="modal-title"
|
||||
>
|
||||
{/* Header */}
|
||||
<div className="flex items-start justify-between p-4 border-b border-border">
|
||||
<div className="flex-1">
|
||||
<h2
|
||||
id="modal-title"
|
||||
className="text-lg font-semibold text-foreground"
|
||||
>
|
||||
{title}
|
||||
</h2>
|
||||
{description && (
|
||||
<p className="mt-1 text-sm text-muted-foreground">
|
||||
{description}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
onClick={onClose}
|
||||
className="ml-2"
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div className="flex-1 overflow-y-auto p-4">
|
||||
{children}
|
||||
</div>
|
||||
|
||||
{/* Footer */}
|
||||
{footer && (
|
||||
<div className="flex items-center justify-end gap-2 p-4 border-t border-border">
|
||||
{footer}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -4,9 +4,10 @@ import * as React from 'react';
|
||||
import { cn } from '@/lib/utils/cn';
|
||||
|
||||
export interface SliderProps
|
||||
extends Omit<React.InputHTMLAttributes<HTMLInputElement>, 'onChange' | 'value'> {
|
||||
value?: number;
|
||||
extends Omit<React.InputHTMLAttributes<HTMLInputElement>, 'onChange' | 'value' | 'onValueChange'> {
|
||||
value?: number | number[];
|
||||
onChange?: (value: number) => void;
|
||||
onValueChange?: (value: number[]) => void;
|
||||
min?: number;
|
||||
max?: number;
|
||||
step?: number;
|
||||
@@ -20,6 +21,7 @@ const Slider = React.forwardRef<HTMLInputElement, SliderProps>(
|
||||
className,
|
||||
value = 0,
|
||||
onChange,
|
||||
onValueChange,
|
||||
min = 0,
|
||||
max = 100,
|
||||
step = 1,
|
||||
@@ -30,8 +32,13 @@ const Slider = React.forwardRef<HTMLInputElement, SliderProps>(
|
||||
},
|
||||
ref
|
||||
) => {
|
||||
// Support both value formats (number or number[])
|
||||
const currentValue = Array.isArray(value) ? value[0] : value;
|
||||
|
||||
const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
onChange?.(parseFloat(e.target.value));
|
||||
const numValue = parseFloat(e.target.value);
|
||||
onChange?.(numValue);
|
||||
onValueChange?.([numValue]);
|
||||
};
|
||||
|
||||
return (
|
||||
@@ -44,7 +51,7 @@ const Slider = React.forwardRef<HTMLInputElement, SliderProps>(
|
||||
</label>
|
||||
)}
|
||||
{showValue && (
|
||||
<span className="text-sm text-muted-foreground">{value}</span>
|
||||
<span className="text-sm text-muted-foreground">{currentValue}</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
@@ -54,7 +61,7 @@ const Slider = React.forwardRef<HTMLInputElement, SliderProps>(
|
||||
min={min}
|
||||
max={max}
|
||||
step={step}
|
||||
value={value}
|
||||
value={currentValue}
|
||||
onChange={handleChange}
|
||||
disabled={disabled}
|
||||
className={cn(
|
||||
|
||||
Reference in New Issue
Block a user