Total refactoring performed

This commit is contained in:
AG
2025-12-20 18:53:49 +02:00
parent e41bb6b588
commit 5877ca3544
29 changed files with 3888 additions and 344 deletions

View File

@@ -0,0 +1,308 @@
import React, { useRef, useState, useEffect } from 'react';
import { INSTRUMENTS, BASS_NOTES, MIN_TEMPO, MAX_TEMPO, MIN_STEPS, MAX_STEPS } from '../constants';
import { PlayIcon, StopIcon, ClearIcon, UploadIcon, DownloadIcon, PlusIcon, MinusIcon } from './icons';
import { Grid, BassLineGrid } from '../types';
import { Modal } from './Modal';
interface SequencerProps {
steps: number;
grid: Grid;
bassLine: BassLineGrid;
isPlaying: boolean;
tempo: number;
currentStep: number | null;
mutes: boolean[];
drumVolume: number;
bassVolume: number;
setStep: (instrumentIndex: number, stepIndex: number, isActive: boolean) => void;
setBassNote: (stepIndex: number, note: string) => void;
clearPattern: () => void;
setTempo: React.Dispatch<React.SetStateAction<number>>;
handleStepsChange: (newSteps: number) => void;
startPlayback: () => Promise<void>;
stopPlayback: () => void;
exportBeat: () => void;
importBeat: (event: React.ChangeEvent<HTMLInputElement>) => void;
toggleMute: (instrumentIndex: number) => void;
handleDrumVolumeChange: (newVolume: number) => void;
handleBassVolumeChange: (newVolume: number) => void;
triggerSound: (instrumentName: string) => void;
triggerBassNote: (note: string) => void;
}
const ControlButton: React.FC<{ onClick?: () => void; children: React.ReactNode; className?: string; disabled?: boolean; title?: string }> = ({ onClick, children, className, disabled, title }) => (
<button
onClick={onClick}
disabled={disabled}
title={title}
className={`flex items-center justify-center gap-2 px-3 py-1.5 text-[11px] font-black uppercase tracking-widest text-slate-600 bg-white border border-slate-200 rounded shadow-sm hover:bg-slate-50 hover:border-slate-300 transition-all active:scale-95 disabled:opacity-40 disabled:cursor-not-allowed ${className}`}
>
{children}
</button>
);
const IconButton: React.FC<{ onClick?: () => void; children: React.ReactNode; className?: string; disabled?: boolean }> = ({ onClick, children, className, disabled }) => (
<button
onClick={onClick}
disabled={disabled}
className={`p-1.5 text-slate-400 hover:text-slate-600 hover:bg-slate-100 rounded transition-colors disabled:opacity-30 ${className}`}
>
{children}
</button>
);
export const Sequencer: React.FC<SequencerProps> = ({
steps, grid, isPlaying, tempo, currentStep, mutes, bassLine,
drumVolume, bassVolume,
clearPattern, setTempo, handleStepsChange, setStep, setBassNote,
startPlayback, stopPlayback, exportBeat, importBeat,
handleDrumVolumeChange, handleBassVolumeChange, triggerSound, triggerBassNote
}) => {
const fileInputRef = useRef<HTMLInputElement>(null);
const [isDragging, setIsDragging] = useState(false);
const [dragActivationState, setDragActivationState] = useState(false);
const [dragStartRow, setDragStartRow] = useState<number | null>(null);
const [isBassDragging, setIsBassDragging] = useState(false);
const [bassDragActivationState, setBassDragActivationState] = useState(false);
const [isClearModalOpen, setIsClearModalOpen] = useState(false);
useEffect(() => {
const handleMouseUp = () => {
setIsDragging(false);
setDragStartRow(null);
setIsBassDragging(false);
};
window.addEventListener('mouseup', handleMouseUp);
return () => window.removeEventListener('mouseup', handleMouseUp);
}, []);
const handleCellMouseDown = (instIndex: number, stepIndex: number) => {
setIsDragging(true);
setDragStartRow(instIndex);
const newState = !grid[instIndex]?.[stepIndex];
setDragActivationState(newState);
setStep(instIndex, stepIndex, newState);
if (newState && !isPlaying) {
triggerSound(INSTRUMENTS[instIndex].name);
}
};
const handleCellMouseEnter = (instIndex: number, stepIndex: number) => {
if (isDragging && instIndex === dragStartRow) {
setStep(instIndex, stepIndex, dragActivationState);
}
};
const handleBassCellMouseDown = (noteName: string, stepIndex: number) => {
const isCurrentlyActive = bassLine[stepIndex]?.includes(noteName);
const newState = !isCurrentlyActive;
setBassNote(stepIndex, noteName);
setIsBassDragging(true);
setBassDragActivationState(newState);
if (newState && !isPlaying) {
triggerBassNote(noteName);
}
};
const handleBassCellMouseEnter = (noteName: string, stepIndex: number) => {
if (isBassDragging) {
const isCurrentlyActive = bassLine[stepIndex]?.includes(noteName);
if (bassDragActivationState !== isCurrentlyActive) {
setBassNote(stepIndex, noteName);
}
}
};
const incrementTempo = () => setTempo(t => Math.min(MAX_TEMPO, t + 1));
const decrementTempo = () => setTempo(t => Math.max(MIN_TEMPO, t - 1));
const incrementSteps = () => {
const newSteps = steps + 4;
if (newSteps <= MAX_STEPS) handleStepsChange(newSteps);
};
const decrementSteps = () => {
const newSteps = steps - 4;
if (newSteps >= MIN_STEPS) handleStepsChange(newSteps);
};
return (
<div className={`p-8 bg-white ${isDragging || isBassDragging ? 'select-none' : ''}`}>
<Modal
isOpen={isClearModalOpen}
onClose={() => setIsClearModalOpen(false)}
onConfirm={() => { clearPattern(); setIsClearModalOpen(false); }}
title="Clear Session"
>
<p className="text-sm text-slate-500">Are you sure you want to clear the session? This will erase all current progress.</p>
</Modal>
{/* Toolbar */}
<div className="flex items-center justify-between mb-10 pb-6 border-b border-slate-50">
<div className="flex items-center gap-3">
<ControlButton onClick={isPlaying ? stopPlayback : startPlayback} className="min-w-[80px]">
{isPlaying ? <><StopIcon className="w-3.5 h-3.5" /> Stop</> : <><PlayIcon className="w-3.5 h-3.5" /> Play</>}
</ControlButton>
<ControlButton onClick={() => setIsClearModalOpen(true)}>
<ClearIcon className="w-3.5 h-3.5" /> Clear
</ControlButton>
</div>
<div className="flex items-center gap-8">
<div className="flex items-center gap-3">
<span className="text-[10px] font-bold text-slate-400 uppercase tracking-widest">Tempo</span>
<div className="flex items-center bg-slate-50 rounded-lg p-0.5 border border-slate-100">
<IconButton onClick={decrementTempo} disabled={tempo <= MIN_TEMPO}><MinusIcon className="w-3.5 h-3.5" /></IconButton>
<span className="px-3 text-xs font-black text-slate-700 min-w-[70px] text-center">{tempo} BPM</span>
<IconButton onClick={incrementTempo} disabled={tempo >= MAX_TEMPO}><PlusIcon className="w-3.5 h-3.5" /></IconButton>
</div>
</div>
<div className="flex items-center gap-3">
<span className="text-[10px] font-bold text-slate-400 uppercase tracking-widest">Steps</span>
<div className="flex items-center bg-slate-50 rounded-lg p-0.5 border border-slate-100">
<IconButton onClick={decrementSteps} disabled={steps <= MIN_STEPS}><MinusIcon className="w-3.5 h-3.5" /></IconButton>
<span className="px-3 text-xs font-black text-slate-700 min-w-[40px] text-center">{steps}</span>
<IconButton onClick={incrementSteps} disabled={steps >= MAX_STEPS}><PlusIcon className="w-3.5 h-3.5" /></IconButton>
</div>
</div>
</div>
<div className="flex items-center gap-3">
<ControlButton onClick={() => fileInputRef.current?.click()}>
<UploadIcon className="w-3.5 h-3.5" /> Import
</ControlButton>
<input type="file" ref={fileInputRef} onChange={importBeat} accept=".json" className="hidden" />
<ControlButton onClick={exportBeat}>
<DownloadIcon className="w-3.5 h-3.5" /> Export
</ControlButton>
</div>
</div>
{/* Drum Machine Section */}
<div className="flex gap-10 mb-16">
<div className="flex flex-col items-center gap-3 shrink-0">
<div className="relative h-48 w-6 bg-slate-100 rounded-full border border-slate-200 p-1 flex items-end overflow-hidden">
<div
className="bg-orange-500 rounded-full w-full transition-all duration-300 shadow-lg shadow-orange-500/30"
style={{ height: `${(drumVolume / 1.5) * 100}%` }}
></div>
<input
type="range" min="0" max="1.5" step="0.01" value={drumVolume}
onChange={e => handleDrumVolumeChange(Number(e.target.value))}
className="absolute inset-0 opacity-0 cursor-pointer [writing-mode:vertical-lr] [direction:rtl]"
/>
<div className="absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 pointer-events-none">
<div className="w-2.5 h-2.5 bg-white border-2 border-orange-500 rounded-full shadow-md transition-all duration-300" style={{ transform: `translateY(${50 - (drumVolume / 1.5) * 100}%)` }}></div>
</div>
</div>
<span className="text-[9px] font-black text-slate-400 uppercase tracking-[0.2em] [writing-mode:vertical-lr] rotate-180">Drums</span>
</div>
<div className="flex-1">
<div className="grid gap-x-1.5 gap-y-2" style={{ gridTemplateColumns: `80px repeat(${steps}, 1fr)` }}>
{/* Header helper */}
<div className="h-6"></div>
{Array.from({ length: steps }, (_, i) => (
<div key={`d-head-${i}`} className="text-[9px] font-bold text-slate-300 text-center flex items-center justify-center">
{i % 4 === 0 ? (i / 4 + 1) : ''}
</div>
))}
{INSTRUMENTS.map((instrument, instIndex) => (
<React.Fragment key={instrument.name}>
<div className="flex items-center justify-end pr-4 h-9">
<span className={`text-[11px] font-bold uppercase tracking-widest ${mutes[instIndex] ? 'text-slate-300 line-through' : 'text-slate-500'}`}>
{instrument.name}
</span>
</div>
{Array.from({ length: steps }).map((_, stepIndex) => {
const isActive = grid[instIndex]?.[stepIndex];
const isCurrent = currentStep === stepIndex;
const isFourth = stepIndex % 4 === 0;
return (
<div
key={`${instrument.name}-${stepIndex}`}
onMouseDown={() => handleCellMouseDown(instIndex, stepIndex)}
onMouseEnter={() => handleCellMouseEnter(instIndex, stepIndex)}
className={`h-9 rounded-sm cursor-pointer transition-all duration-75 relative group overflow-hidden
${isActive ? 'bg-orange-500 shadow-md shadow-orange-200' : 'bg-slate-100 hover:bg-slate-200'}
${isCurrent ? 'ring-2 ring-blue-400 ring-offset-1 z-10' : ''}
${isFourth && stepIndex !== 0 ? 'border-l-2 border-slate-200/50' : ''}
`}
>
{isCurrent && <div className="absolute inset-0 bg-white/20 animate-pulse"></div>}
</div>
)
})}
</React.Fragment>
))}
</div>
</div>
</div>
{/* Bass Section */}
<div className="flex gap-10">
<div className="flex flex-col items-center gap-3 shrink-0">
<div className="relative h-[400px] w-6 bg-slate-100 rounded-full border border-slate-200 p-1 flex items-end overflow-hidden">
<div
className="bg-purple-500 rounded-full w-full transition-all duration-300 shadow-lg shadow-purple-500/30"
style={{ height: `${(bassVolume / 1) * 100}%` }}
></div>
<input
type="range" min="0" max="1" step="0.01" value={bassVolume}
onChange={e => handleBassVolumeChange(Number(e.target.value))}
className="absolute inset-0 opacity-0 cursor-pointer [writing-mode:vertical-lr] [direction:rtl]"
/>
<div className="absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 pointer-events-none">
<div className="w-2.5 h-2.5 bg-white border-2 border-purple-500 rounded-full shadow-md transition-all duration-300" style={{ transform: `translateY(${50 - (bassVolume / 1) * 100}%)` }}></div>
</div>
</div>
<span className="text-[9px] font-black text-slate-400 uppercase tracking-[0.2em] [writing-mode:vertical-lr] rotate-180">Bass</span>
</div>
<div className="flex-1">
<div className="grid gap-px" style={{ gridTemplateColumns: `80px repeat(${steps}, 1fr)` }}>
{/* Header helper */}
<div className="h-6"></div>
{Array.from({ length: steps }, (_, i) => (
<div key={`b-head-${i}`} className="text-[9px] font-bold text-slate-300 text-center flex items-center justify-center">
{i % 4 === 0 ? (i / 4 + 1) : ''}
</div>
))}
{[...BASS_NOTES].reverse().map((note) => (
<React.Fragment key={note.name}>
<div className={`flex items-center justify-end pr-4 h-5 border-b border-slate-50 ${note.isSharp ? 'bg-slate-50/30' : ''}`}>
<span className={`text-[10px] font-mono ${note.isSharp ? 'text-slate-300 pt-0.5' : 'text-slate-500 font-bold'}`}>
{note.name}
</span>
</div>
{Array.from({ length: steps }).map((_, stepIndex) => {
const isSelected = bassLine[stepIndex]?.includes(note.name);
const isCurrent = currentStep === stepIndex;
const isFourth = stepIndex % 4 === 0;
return (
<div
key={`${note.name}-${stepIndex}`}
onMouseDown={() => handleBassCellMouseDown(note.name, stepIndex)}
onMouseEnter={() => handleBassCellMouseEnter(note.name, stepIndex)}
className={`h-5 border-b border-r border-slate-50 cursor-pointer transition-all duration-75 relative
${isSelected ? 'bg-purple-500 shadow-inner' : note.isSharp ? 'bg-slate-50/50 hover:bg-slate-100' : 'bg-white hover:bg-slate-50'}
${isCurrent ? 'z-10 bg-blue-400/20' : ''}
${isFourth && stepIndex !== 0 ? 'border-l-2 border-slate-100' : ''}
`}
>
{isCurrent && <div className="absolute inset-0 border-x-2 border-blue-400/50 pointer-events-none"></div>}
</div>
)
})}
</React.Fragment>
))}
</div>
</div>
</div>
</div>
);
};