Adaptive GUI

This commit is contained in:
AG
2025-12-20 20:17:41 +02:00
parent 5877ca3544
commit 26cedfca61
3 changed files with 147 additions and 67 deletions

Binary file not shown.

View File

@@ -5,7 +5,7 @@ import { useWebSocket } from './hooks/useWebSocket';
import { useDrumMachine } from './hooks/useDrumMachine';
import { useSession } from './hooks/useSession';
import { Sequencer } from './components/Sequencer';
import { ShareIcon, CursorIcon } from './components/icons';
import { ShareIcon, CursorIcon, UploadIcon, DownloadIcon } from './components/icons';
const App: React.FC = () => {
const sessionId = useSession();
@@ -29,22 +29,58 @@ const App: React.FC = () => {
};
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="w-full max-w-6xl mx-auto">
<header className="mb-8 text-center relative">
<div className="absolute top-0 right-0 flex flex-col items-end gap-1">
<div className={`flex items-center gap-1.5 text-[10px] font-bold uppercase tracking-wider ${isConnected ? 'text-green-500' : 'text-slate-400'}`}>
<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-7xl mx-auto">
<header className="mb-8 md:mb-12 px-2">
<div className="flex flex-col md:flex-row items-center md:items-start justify-between gap-6 md:gap-4">
{/* Logo area */}
<div className="text-center md:text-left">
<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>
{/* Status and Actions area */}
<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">
<div className="flex items-center gap-2 md:hidden">
<button
onClick={() => (document.getElementById('import-input') as HTMLInputElement)?.click()}
title="Import"
className="p-2 text-slate-500 hover:text-slate-700 bg-white border border-slate-200 rounded-lg shadow-sm"
>
<UploadIcon className="w-4 h-4" />
</button>
<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 mt-1">
<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 shadow-sm hover:bg-slate-50 hover:border-slate-300 transition-all active:scale-95"
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" />
Share Session
<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!
@@ -52,9 +88,7 @@ const App: React.FC = () => {
)}
</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>
</div>
</header>
<main ref={mainRef} className="relative bg-white rounded-2xl shadow-xl shadow-slate-200/50 border border-slate-100 overflow-hidden">

View File

@@ -37,7 +37,20 @@ const ControlButton: React.FC<{ onClick?: () => void; children: React.ReactNode;
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}
{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>
);
@@ -67,6 +80,15 @@ export const Sequencer: React.FC<SequencerProps> = ({
const [isBassDragging, setIsBassDragging] = useState(false);
const [bassDragActivationState, setBassDragActivationState] = 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(() => {
const handleMouseUp = () => {
@@ -129,7 +151,7 @@ export const Sequencer: React.FC<SequencerProps> = ({
};
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
isOpen={isClearModalOpen}
onClose={() => setIsClearModalOpen(false)}
@@ -140,28 +162,28 @@ export const Sequencer: React.FC<SequencerProps> = ({
</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]">
<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 flex-wrap items-center gap-3 w-full lg:w-auto justify-center lg:justify-start">
<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</>}
</ControlButton>
<ControlButton onClick={() => setIsClearModalOpen(true)}>
<ControlButton onClick={() => setIsClearModalOpen(true)} className="flex-1 lg:flex-none">
<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">
<div className="flex items-center justify-center gap-3 w-full lg:w-auto">
<div className="flex items-center gap-3 flex-1 lg:flex-none">
<span className="text-[10px] font-bold text-slate-400 uppercase tracking-widest hidden sm:inline">Tempo</span>
<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>
<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">
<div className="flex items-center gap-3 flex-1 lg:flex-none">
<span className="text-[10px] font-bold text-slate-400 uppercase tracking-widest hidden sm:inline">Steps</span>
<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>
<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>
@@ -169,39 +191,51 @@ export const Sequencer: React.FC<SequencerProps> = ({
</div>
</div>
<div className="flex items-center gap-3">
<ControlButton onClick={() => fileInputRef.current?.click()}>
<div className="hidden md:flex items-center gap-3 w-full lg:w-auto justify-center lg:justify-end">
<ControlButton onClick={() => fileInputRef.current?.click()} className="flex-1 lg:flex-none">
<UploadIcon className="w-3.5 h-3.5" /> Import
</ControlButton>
<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
</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="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 md:flex-col items-center gap-3 shrink-0">
<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
className="bg-orange-500 rounded-full w-full transition-all duration-300 shadow-lg shadow-orange-500/30"
style={{ height: `${(drumVolume / 1.5) * 100}%` }}
className="bg-orange-500 rounded-full transition-all duration-300 shadow-lg shadow-orange-500/30"
style={{
width: isMobile ? `${(drumVolume / 1.5) * 100}%` : '100%',
height: !isMobile ? `${(drumVolume / 1.5) * 100}%` : '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]"
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="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="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={{
position: 'absolute',
left: isMobile ? `${(drumVolume / 1.5) * 100}%` : '50%',
bottom: !isMobile ? `${(drumVolume / 1.5) * 100}%` : '50%',
transform: 'translate(-50%, 50%)'
}}
></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)` }}>
<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 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 */}
<div className="h-6"></div>
{Array.from({ length: steps }, (_, i) => (
@@ -212,8 +246,8 @@ export const Sequencer: React.FC<SequencerProps> = ({
{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'}`}>
<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-[10px] md:text-[11px] font-bold uppercase tracking-widest ${mutes[instIndex] ? 'text-slate-300 line-through' : 'text-slate-500'}`}>
{instrument.name}
</span>
</div>
@@ -226,7 +260,7 @@ export const Sequencer: React.FC<SequencerProps> = ({
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
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'}
${isCurrent ? 'ring-2 ring-blue-400 ring-offset-1 z-10' : ''}
${isFourth && stepIndex !== 0 ? 'border-l-2 border-slate-200/50' : ''}
@@ -243,27 +277,39 @@ export const Sequencer: React.FC<SequencerProps> = ({
</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="flex flex-col md:flex-row items-stretch md:items-center gap-4 md:gap-10 px-4 md:px-0">
<div className="flex md:flex-col items-center gap-3 shrink-0">
<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
className="bg-purple-500 rounded-full w-full transition-all duration-300 shadow-lg shadow-purple-500/30"
style={{ height: `${(bassVolume / 1) * 100}%` }}
className="bg-purple-500 rounded-full transition-all duration-300 shadow-lg shadow-purple-500/30"
style={{
width: isMobile ? `${(bassVolume / 1) * 100}%` : '100%',
height: !isMobile ? `${(bassVolume / 1) * 100}%` : '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]"
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="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="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={{
position: 'absolute',
left: isMobile ? `${(bassVolume / 1) * 100}%` : '50%',
bottom: !isMobile ? `${(bassVolume / 1) * 100}%` : '50%',
transform: 'translate(-50%, 50%)'
}}
></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)` }}>
<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: `clamp(70px, 12vw, 90px) repeat(${steps}, minmax(40px, 1fr))`, minWidth: 'max-content' }}>
{/* Header helper */}
<div className="h-6"></div>
{Array.from({ length: steps }, (_, i) => (
@@ -274,8 +320,8 @@ export const Sequencer: React.FC<SequencerProps> = ({
{[...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'}`}>
<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-[9px] md:text-[10px] font-mono ${note.isSharp ? 'text-slate-300 pt-0.5' : 'text-slate-500 font-bold'}`}>
{note.name}
</span>
</div>