Adaptive GUI
This commit is contained in:
BIN
session_state.db
BIN
session_state.db
Binary file not shown.
82
src/App.tsx
82
src/App.tsx
@@ -5,7 +5,7 @@ import { useWebSocket } from './hooks/useWebSocket';
|
|||||||
import { useDrumMachine } from './hooks/useDrumMachine';
|
import { useDrumMachine } from './hooks/useDrumMachine';
|
||||||
import { useSession } from './hooks/useSession';
|
import { useSession } from './hooks/useSession';
|
||||||
import { Sequencer } from './components/Sequencer';
|
import { Sequencer } from './components/Sequencer';
|
||||||
import { ShareIcon, CursorIcon } from './components/icons';
|
import { ShareIcon, CursorIcon, UploadIcon, DownloadIcon } from './components/icons';
|
||||||
|
|
||||||
const App: React.FC = () => {
|
const App: React.FC = () => {
|
||||||
const sessionId = useSession();
|
const sessionId = useSession();
|
||||||
@@ -29,32 +29,66 @@ const App: React.FC = () => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen flex flex-col items-center justify-center p-4 font-sans bg-slate-50 text-slate-900">
|
<div className="min-h-screen flex flex-col items-center justify-center p-4 sm:p-6 md:p-8 font-sans bg-slate-50 text-slate-900">
|
||||||
<div className="w-full max-w-6xl mx-auto">
|
<div className="w-full max-w-7xl mx-auto">
|
||||||
<header className="mb-8 text-center relative">
|
<header className="mb-8 md:mb-12 px-2">
|
||||||
<div className="absolute top-0 right-0 flex flex-col items-end gap-1">
|
<div className="flex flex-col md:flex-row items-center md:items-start justify-between gap-6 md:gap-4">
|
||||||
<div className={`flex items-center gap-1.5 text-[10px] font-bold uppercase tracking-wider ${isConnected ? 'text-green-500' : 'text-slate-400'}`}>
|
{/* Logo area */}
|
||||||
<span className={`w-1.5 h-1.5 rounded-full ${isConnected ? 'bg-green-500 animate-pulse' : 'bg-slate-300'}`}></span>
|
<div className="text-center md:text-left">
|
||||||
{isConnected ? 'Connected' : 'Disconnected'}
|
<h1 className="text-3xl md:text-4xl font-black text-slate-800 tracking-tightest uppercase mb-1">AG Beats</h1>
|
||||||
|
<p className="text-slate-400 text-xs md:text-sm font-medium max-w-lg mx-auto md:mx-0">
|
||||||
|
Craft your beats and bass lines with this interactive step sequencer.
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="relative mt-1">
|
|
||||||
<button
|
{/* Status and Actions area */}
|
||||||
onClick={handleShareSession}
|
<div className="flex flex-row md:flex-col items-center md:items-end gap-3 md:gap-2 w-full md:w-auto justify-center md:justify-end bg-slate-50 md:bg-transparent p-3 md:p-0 rounded-xl border border-slate-100 md:border-0 shadow-sm md:shadow-none">
|
||||||
className="flex items-center gap-2 px-3 py-1.5 text-xs font-bold 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"
|
<div className="flex items-center gap-2 md:hidden">
|
||||||
>
|
<button
|
||||||
<ShareIcon className="w-3.5 h-3.5" />
|
onClick={() => (document.getElementById('import-input') as HTMLInputElement)?.click()}
|
||||||
Share Session
|
title="Import"
|
||||||
</button>
|
className="p-2 text-slate-500 hover:text-slate-700 bg-white border border-slate-200 rounded-lg shadow-sm"
|
||||||
{localCopyMessage && (
|
>
|
||||||
<div className="absolute top-full right-0 mt-2 py-1 px-3 text-[10px] font-bold text-white bg-slate-900 rounded shadow-lg z-50 whitespace-nowrap animate-in fade-in slide-in-from-top-1">
|
<UploadIcon className="w-4 h-4" />
|
||||||
Link copied!
|
</button>
|
||||||
</div>
|
<button
|
||||||
)}
|
onClick={drumMachine.exportBeat}
|
||||||
|
title="Export"
|
||||||
|
className="p-2 text-slate-500 hover:text-slate-700 bg-white border border-slate-200 rounded-lg shadow-sm"
|
||||||
|
>
|
||||||
|
<DownloadIcon className="w-4 h-4" />
|
||||||
|
</button>
|
||||||
|
<input
|
||||||
|
type="file"
|
||||||
|
id="import-input"
|
||||||
|
onChange={drumMachine.importBeat}
|
||||||
|
accept=".json"
|
||||||
|
className="hidden"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className={`flex items-center gap-1.5 text-[10px] font-black uppercase tracking-wider ${isConnected ? 'text-green-500' : 'text-slate-400'}`}>
|
||||||
|
<span className={`w-1.5 h-1.5 rounded-full ${isConnected ? 'bg-green-500 animate-pulse' : 'bg-slate-300'}`}></span>
|
||||||
|
{isConnected ? 'Connected' : 'Disconnected'}
|
||||||
|
</div>
|
||||||
|
<div className="relative">
|
||||||
|
<button
|
||||||
|
onClick={handleShareSession}
|
||||||
|
className="flex items-center gap-2 px-3 py-1.5 text-xs font-bold text-slate-600 bg-white border border-slate-200 rounded-lg shadow-sm hover:bg-slate-50 hover:border-slate-300 transition-all active:scale-95"
|
||||||
|
>
|
||||||
|
<ShareIcon className="w-3.5 h-3.5" />
|
||||||
|
<span className="hidden sm:inline">Share Session</span>
|
||||||
|
<span className="sm:hidden">Share</span>
|
||||||
|
</button>
|
||||||
|
{/* Link copied message */}
|
||||||
|
{localCopyMessage && (
|
||||||
|
<div className="absolute top-full right-0 mt-2 py-1 px-3 text-[10px] font-bold text-white bg-slate-900 rounded shadow-lg z-50 whitespace-nowrap animate-in fade-in slide-in-from-top-1">
|
||||||
|
Link copied!
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<h1 className="text-3xl font-black text-slate-800 tracking-tightest uppercase mb-1">AG Beats</h1>
|
|
||||||
<p className="text-slate-400 text-sm font-medium">Craft your beats and bass lines with this interactive step sequencer.</p>
|
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
<main ref={mainRef} className="relative bg-white rounded-2xl shadow-xl shadow-slate-200/50 border border-slate-100 overflow-hidden">
|
<main ref={mainRef} className="relative bg-white rounded-2xl shadow-xl shadow-slate-200/50 border border-slate-100 overflow-hidden">
|
||||||
|
|||||||
@@ -37,7 +37,20 @@ const ControlButton: React.FC<{ onClick?: () => void; children: React.ReactNode;
|
|||||||
title={title}
|
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}`}
|
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}
|
{React.Children.map(children, child => {
|
||||||
|
if (typeof child === 'string') {
|
||||||
|
return <span className="hidden md:inline">{child}</span>;
|
||||||
|
}
|
||||||
|
if (React.isValidElement(child) && child.type === React.Fragment) {
|
||||||
|
return React.Children.map((child.props as any).children, subChild => {
|
||||||
|
if (typeof subChild === 'string') {
|
||||||
|
return <span className="hidden md:inline">{subChild}</span>;
|
||||||
|
}
|
||||||
|
return subChild;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return child;
|
||||||
|
})}
|
||||||
</button>
|
</button>
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -67,6 +80,15 @@ export const Sequencer: React.FC<SequencerProps> = ({
|
|||||||
const [isBassDragging, setIsBassDragging] = useState(false);
|
const [isBassDragging, setIsBassDragging] = useState(false);
|
||||||
const [bassDragActivationState, setBassDragActivationState] = useState(false);
|
const [bassDragActivationState, setBassDragActivationState] = useState(false);
|
||||||
const [isClearModalOpen, setIsClearModalOpen] = useState(false);
|
const [isClearModalOpen, setIsClearModalOpen] = useState(false);
|
||||||
|
const [isMobile, setIsMobile] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const mql = window.matchMedia('(max-width: 767px)');
|
||||||
|
setIsMobile(mql.matches);
|
||||||
|
const handler = (e: MediaQueryListEvent) => setIsMobile(e.matches);
|
||||||
|
mql.addEventListener('change', handler);
|
||||||
|
return () => mql.removeEventListener('change', handler);
|
||||||
|
}, []);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const handleMouseUp = () => {
|
const handleMouseUp = () => {
|
||||||
@@ -129,7 +151,7 @@ export const Sequencer: React.FC<SequencerProps> = ({
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={`p-8 bg-white ${isDragging || isBassDragging ? 'select-none' : ''}`}>
|
<div className={`p-4 sm:p-6 md:p-8 bg-white ${isDragging || isBassDragging ? 'select-none' : ''}`}>
|
||||||
<Modal
|
<Modal
|
||||||
isOpen={isClearModalOpen}
|
isOpen={isClearModalOpen}
|
||||||
onClose={() => setIsClearModalOpen(false)}
|
onClose={() => setIsClearModalOpen(false)}
|
||||||
@@ -140,28 +162,28 @@ export const Sequencer: React.FC<SequencerProps> = ({
|
|||||||
</Modal>
|
</Modal>
|
||||||
|
|
||||||
{/* Toolbar */}
|
{/* Toolbar */}
|
||||||
<div className="flex items-center justify-between mb-10 pb-6 border-b border-slate-50">
|
<div className="flex flex-col lg:flex-row items-center justify-between mb-8 md:mb-10 pb-6 border-b border-slate-50 gap-6 lg:gap-0">
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex flex-wrap items-center gap-3 w-full lg:w-auto justify-center lg:justify-start">
|
||||||
<ControlButton onClick={isPlaying ? stopPlayback : startPlayback} className="min-w-[80px]">
|
<ControlButton onClick={isPlaying ? stopPlayback : startPlayback} className="md:min-w-[100px] flex-1 lg:flex-none">
|
||||||
{isPlaying ? <><StopIcon className="w-3.5 h-3.5" /> Stop</> : <><PlayIcon className="w-3.5 h-3.5" /> Play</>}
|
{isPlaying ? <><StopIcon className="w-3.5 h-3.5" /> Stop</> : <><PlayIcon className="w-3.5 h-3.5" /> Play</>}
|
||||||
</ControlButton>
|
</ControlButton>
|
||||||
<ControlButton onClick={() => setIsClearModalOpen(true)}>
|
<ControlButton onClick={() => setIsClearModalOpen(true)} className="flex-1 lg:flex-none">
|
||||||
<ClearIcon className="w-3.5 h-3.5" /> Clear
|
<ClearIcon className="w-3.5 h-3.5" /> Clear
|
||||||
</ControlButton>
|
</ControlButton>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex items-center gap-8">
|
<div className="flex items-center justify-center gap-3 w-full lg:w-auto">
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3 flex-1 lg:flex-none">
|
||||||
<span className="text-[10px] font-bold text-slate-400 uppercase tracking-widest">Tempo</span>
|
<span className="text-[10px] font-bold text-slate-400 uppercase tracking-widest hidden sm:inline">Tempo</span>
|
||||||
<div className="flex items-center bg-slate-50 rounded-lg p-0.5 border border-slate-100">
|
<div className="flex items-center justify-between bg-slate-50 rounded-lg p-0.5 border border-slate-100 flex-1 lg:flex-none min-h-[34px]">
|
||||||
<IconButton onClick={decrementTempo} disabled={tempo <= MIN_TEMPO}><MinusIcon className="w-3.5 h-3.5" /></IconButton>
|
<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>
|
<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>
|
<IconButton onClick={incrementTempo} disabled={tempo >= MAX_TEMPO}><PlusIcon className="w-3.5 h-3.5" /></IconButton>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3 flex-1 lg:flex-none">
|
||||||
<span className="text-[10px] font-bold text-slate-400 uppercase tracking-widest">Steps</span>
|
<span className="text-[10px] font-bold text-slate-400 uppercase tracking-widest hidden sm:inline">Steps</span>
|
||||||
<div className="flex items-center bg-slate-50 rounded-lg p-0.5 border border-slate-100">
|
<div className="flex items-center justify-between bg-slate-50 rounded-lg p-0.5 border border-slate-100 flex-1 lg:flex-none min-h-[34px]">
|
||||||
<IconButton onClick={decrementSteps} disabled={steps <= MIN_STEPS}><MinusIcon className="w-3.5 h-3.5" /></IconButton>
|
<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>
|
<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>
|
<IconButton onClick={incrementSteps} disabled={steps >= MAX_STEPS}><PlusIcon className="w-3.5 h-3.5" /></IconButton>
|
||||||
@@ -169,39 +191,51 @@ export const Sequencer: React.FC<SequencerProps> = ({
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex items-center gap-3">
|
<div className="hidden md:flex items-center gap-3 w-full lg:w-auto justify-center lg:justify-end">
|
||||||
<ControlButton onClick={() => fileInputRef.current?.click()}>
|
<ControlButton onClick={() => fileInputRef.current?.click()} className="flex-1 lg:flex-none">
|
||||||
<UploadIcon className="w-3.5 h-3.5" /> Import
|
<UploadIcon className="w-3.5 h-3.5" /> Import
|
||||||
</ControlButton>
|
</ControlButton>
|
||||||
<input type="file" ref={fileInputRef} onChange={importBeat} accept=".json" className="hidden" />
|
<input type="file" ref={fileInputRef} onChange={importBeat} accept=".json" className="hidden" />
|
||||||
<ControlButton onClick={exportBeat}>
|
<ControlButton onClick={exportBeat} className="flex-1 lg:flex-none">
|
||||||
<DownloadIcon className="w-3.5 h-3.5" /> Export
|
<DownloadIcon className="w-3.5 h-3.5" /> Export
|
||||||
</ControlButton>
|
</ControlButton>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Drum Machine Section */}
|
{/* Drum Machine Section */}
|
||||||
<div className="flex gap-10 mb-16">
|
<div className="flex flex-col md:flex-row items-stretch md:items-center gap-4 md:gap-10 mb-12 md:mb-16 px-4 md:px-0">
|
||||||
<div className="flex flex-col items-center gap-3 shrink-0">
|
<div className="flex md: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">
|
<span className="text-[9px] font-black text-slate-400 uppercase tracking-[0.2em] md:[writing-mode:vertical-lr] md:rotate-180 mb-2 md:mb-0">Drums</span>
|
||||||
|
<div className="relative h-6 md:h-48 w-full md:w-6 bg-slate-100 rounded-full border border-slate-200 p-1 flex items-center md:items-end">
|
||||||
<div
|
<div
|
||||||
className="bg-orange-500 rounded-full w-full transition-all duration-300 shadow-lg shadow-orange-500/30"
|
className="bg-orange-500 rounded-full transition-all duration-300 shadow-lg shadow-orange-500/30"
|
||||||
style={{ height: `${(drumVolume / 1.5) * 100}%` }}
|
style={{
|
||||||
|
width: isMobile ? `${(drumVolume / 1.5) * 100}%` : '100%',
|
||||||
|
height: !isMobile ? `${(drumVolume / 1.5) * 100}%` : '100%'
|
||||||
|
}}
|
||||||
></div>
|
></div>
|
||||||
<input
|
<input
|
||||||
type="range" min="0" max="1.5" step="0.01" value={drumVolume}
|
type="range" min="0" max="1.5" step="0.01" value={drumVolume}
|
||||||
onChange={e => handleDrumVolumeChange(Number(e.target.value))}
|
onChange={e => handleDrumVolumeChange(Number(e.target.value))}
|
||||||
className="absolute inset-0 opacity-0 cursor-pointer [writing-mode:vertical-lr] [direction:rtl]"
|
className="absolute inset-0 opacity-0 cursor-pointer z-30"
|
||||||
|
style={isMobile ? { width: '100%', height: '100%' } : { width: '100%', height: '100%', writingMode: '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="absolute inset-0 pointer-events-none p-1">
|
||||||
<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
|
||||||
|
className="w-2.5 h-2.5 bg-white border-2 border-orange-500 rounded-full shadow-md transition-all duration-300"
|
||||||
|
style={{
|
||||||
|
position: 'absolute',
|
||||||
|
left: isMobile ? `${(drumVolume / 1.5) * 100}%` : '50%',
|
||||||
|
bottom: !isMobile ? `${(drumVolume / 1.5) * 100}%` : '50%',
|
||||||
|
transform: 'translate(-50%, 50%)'
|
||||||
|
}}
|
||||||
|
></div>
|
||||||
</div>
|
</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>
|
||||||
|
|
||||||
<div className="flex-1">
|
<div className="flex-1 overflow-x-auto pb-4 scrollbar-thin scrollbar-thumb-slate-200 scrollbar-track-transparent w-full">
|
||||||
<div className="grid gap-x-1.5 gap-y-2" style={{ gridTemplateColumns: `80px repeat(${steps}, 1fr)` }}>
|
<div className="grid gap-x-1 gap-y-1.5 md:gap-x-1.5 md:gap-y-2" style={{ gridTemplateColumns: `clamp(70px, 12vw, 90px) repeat(${steps}, minmax(40px, 1fr))`, minWidth: 'max-content' }}>
|
||||||
{/* Header helper */}
|
{/* Header helper */}
|
||||||
<div className="h-6"></div>
|
<div className="h-6"></div>
|
||||||
{Array.from({ length: steps }, (_, i) => (
|
{Array.from({ length: steps }, (_, i) => (
|
||||||
@@ -212,8 +246,8 @@ export const Sequencer: React.FC<SequencerProps> = ({
|
|||||||
|
|
||||||
{INSTRUMENTS.map((instrument, instIndex) => (
|
{INSTRUMENTS.map((instrument, instIndex) => (
|
||||||
<React.Fragment key={instrument.name}>
|
<React.Fragment key={instrument.name}>
|
||||||
<div className="flex items-center justify-end pr-4 h-9">
|
<div className="flex items-center justify-end pr-2 md:pr-4 h-8 md:h-9 sticky left-0 bg-white/95 backdrop-blur-sm z-20">
|
||||||
<span className={`text-[11px] font-bold uppercase tracking-widest ${mutes[instIndex] ? 'text-slate-300 line-through' : 'text-slate-500'}`}>
|
<span className={`text-[10px] md:text-[11px] font-bold uppercase tracking-widest ${mutes[instIndex] ? 'text-slate-300 line-through' : 'text-slate-500'}`}>
|
||||||
{instrument.name}
|
{instrument.name}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
@@ -226,7 +260,7 @@ export const Sequencer: React.FC<SequencerProps> = ({
|
|||||||
key={`${instrument.name}-${stepIndex}`}
|
key={`${instrument.name}-${stepIndex}`}
|
||||||
onMouseDown={() => handleCellMouseDown(instIndex, stepIndex)}
|
onMouseDown={() => handleCellMouseDown(instIndex, stepIndex)}
|
||||||
onMouseEnter={() => handleCellMouseEnter(instIndex, stepIndex)}
|
onMouseEnter={() => handleCellMouseEnter(instIndex, stepIndex)}
|
||||||
className={`h-9 rounded-sm cursor-pointer transition-all duration-75 relative group overflow-hidden
|
className={`h-8 md: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'}
|
${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' : ''}
|
${isCurrent ? 'ring-2 ring-blue-400 ring-offset-1 z-10' : ''}
|
||||||
${isFourth && stepIndex !== 0 ? 'border-l-2 border-slate-200/50' : ''}
|
${isFourth && stepIndex !== 0 ? 'border-l-2 border-slate-200/50' : ''}
|
||||||
@@ -243,27 +277,39 @@ export const Sequencer: React.FC<SequencerProps> = ({
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Bass Section */}
|
{/* Bass Section */}
|
||||||
<div className="flex gap-10">
|
<div className="flex flex-col md:flex-row items-stretch md:items-center gap-4 md:gap-10 px-4 md:px-0">
|
||||||
<div className="flex flex-col items-center gap-3 shrink-0">
|
<div className="flex md: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">
|
<span className="text-[9px] font-black text-slate-400 uppercase tracking-[0.2em] md:[writing-mode:vertical-lr] md:rotate-180 mb-2 md:mb-0">Bass</span>
|
||||||
|
<div className="relative h-6 md:h-[400px] w-full md:w-6 bg-slate-100 rounded-full border border-slate-200 p-1 flex items-center md:items-end">
|
||||||
<div
|
<div
|
||||||
className="bg-purple-500 rounded-full w-full transition-all duration-300 shadow-lg shadow-purple-500/30"
|
className="bg-purple-500 rounded-full transition-all duration-300 shadow-lg shadow-purple-500/30"
|
||||||
style={{ height: `${(bassVolume / 1) * 100}%` }}
|
style={{
|
||||||
|
width: isMobile ? `${(bassVolume / 1) * 100}%` : '100%',
|
||||||
|
height: !isMobile ? `${(bassVolume / 1) * 100}%` : '100%'
|
||||||
|
}}
|
||||||
></div>
|
></div>
|
||||||
<input
|
<input
|
||||||
type="range" min="0" max="1" step="0.01" value={bassVolume}
|
type="range" min="0" max="1" step="0.01" value={bassVolume}
|
||||||
onChange={e => handleBassVolumeChange(Number(e.target.value))}
|
onChange={e => handleBassVolumeChange(Number(e.target.value))}
|
||||||
className="absolute inset-0 opacity-0 cursor-pointer [writing-mode:vertical-lr] [direction:rtl]"
|
className="absolute inset-0 opacity-0 cursor-pointer z-30"
|
||||||
|
style={isMobile ? { width: '100%', height: '100%' } : { width: '100%', height: '100%', writingMode: '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="absolute inset-0 pointer-events-none p-1">
|
||||||
<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
|
||||||
|
className="w-2.5 h-2.5 bg-white border-2 border-purple-500 rounded-full shadow-md transition-all duration-300"
|
||||||
|
style={{
|
||||||
|
position: 'absolute',
|
||||||
|
left: isMobile ? `${(bassVolume / 1) * 100}%` : '50%',
|
||||||
|
bottom: !isMobile ? `${(bassVolume / 1) * 100}%` : '50%',
|
||||||
|
transform: 'translate(-50%, 50%)'
|
||||||
|
}}
|
||||||
|
></div>
|
||||||
</div>
|
</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>
|
||||||
|
|
||||||
<div className="flex-1">
|
<div className="flex-1 overflow-x-auto pb-4 scrollbar-thin scrollbar-thumb-slate-200 scrollbar-track-transparent w-full">
|
||||||
<div className="grid gap-px" style={{ gridTemplateColumns: `80px repeat(${steps}, 1fr)` }}>
|
<div className="grid gap-px" style={{ gridTemplateColumns: `clamp(70px, 12vw, 90px) repeat(${steps}, minmax(40px, 1fr))`, minWidth: 'max-content' }}>
|
||||||
{/* Header helper */}
|
{/* Header helper */}
|
||||||
<div className="h-6"></div>
|
<div className="h-6"></div>
|
||||||
{Array.from({ length: steps }, (_, i) => (
|
{Array.from({ length: steps }, (_, i) => (
|
||||||
@@ -274,8 +320,8 @@ export const Sequencer: React.FC<SequencerProps> = ({
|
|||||||
|
|
||||||
{[...BASS_NOTES].reverse().map((note) => (
|
{[...BASS_NOTES].reverse().map((note) => (
|
||||||
<React.Fragment key={note.name}>
|
<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' : ''}`}>
|
<div className={`flex items-center justify-end pr-2 md:pr-4 h-5 border-b border-slate-50 sticky left-0 z-20 bg-white/95 backdrop-blur-sm ${note.isSharp ? 'bg-slate-50/80' : ''}`}>
|
||||||
<span className={`text-[10px] font-mono ${note.isSharp ? 'text-slate-300 pt-0.5' : 'text-slate-500 font-bold'}`}>
|
<span className={`text-[9px] md:text-[10px] font-mono ${note.isSharp ? 'text-slate-300 pt-0.5' : 'text-slate-500 font-bold'}`}>
|
||||||
{note.name}
|
{note.name}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
Reference in New Issue
Block a user