Skip to content

Commit 37e60e3

Browse files
committed
feat: implement Song Mode with tile-based grid, pattern cycling, and advanced reordering
1 parent f63550d commit 37e60e3

13 files changed

Lines changed: 1351 additions & 188 deletions

File tree

App.tsx

Lines changed: 210 additions & 59 deletions
Large diffs are not rendered by default.

components/ChannelModal.tsx

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
import React from 'react';
2+
import { X } from 'lucide-react';
3+
import { ChannelId } from '../types';
4+
5+
interface ChannelModalProps {
6+
currentChannel: ChannelId;
7+
onSelect: (id: ChannelId) => void;
8+
onClose: () => void;
9+
}
10+
11+
export const ChannelModal: React.FC<ChannelModalProps> = ({ currentChannel, onSelect, onClose }) => {
12+
const channels: ChannelId[] = ['A', 'B', 'C', 'D'];
13+
14+
return (
15+
<div className="fixed inset-0 z-[100] bg-black/60 backdrop-blur-md flex items-center justify-center p-4 animate-in fade-in duration-200" onClick={onClose}>
16+
<div className="w-full max-w-[280px] bg-[#1a1a1e] border border-white/10 rounded-3xl shadow-2xl overflow-hidden animate-in zoom-in-95 duration-200" onClick={e => e.stopPropagation()}>
17+
<div className="px-6 py-4 border-b border-white/5 flex justify-between items-center bg-zinc-900/50">
18+
<span className="text-[10px] font-extrabold uppercase text-zinc-400 tracking-widest">Select Channel</span>
19+
<button onClick={onClose} className="text-zinc-500 hover:text-white transition-colors">
20+
<X size={16} />
21+
</button>
22+
</div>
23+
<div className="p-4 grid grid-cols-2 gap-3">
24+
{channels.map(ch => (
25+
<button
26+
key={ch}
27+
onClick={() => { onSelect(ch); onClose(); }}
28+
className={`h-16 flex flex-col items-center justify-center rounded-2xl border transition-all ${currentChannel === ch
29+
? 'bg-retro-accent border-retro-accent text-white shadow-[0_0_15px_rgba(255,30,86,0.3)]'
30+
: 'bg-white/5 border-white/5 text-zinc-400 hover:bg-white/10 hover:border-white/10'
31+
}`}
32+
>
33+
<span className="text-[8px] font-extrabold uppercase opacity-50 mb-1">Channel</span>
34+
<span className="text-xl font-black">{ch}</span>
35+
</button>
36+
))}
37+
</div>
38+
</div>
39+
</div>
40+
);
41+
};

components/PadMenu.tsx

Lines changed: 51 additions & 50 deletions
Original file line numberDiff line numberDiff line change
@@ -6,10 +6,9 @@ interface PadMenuProps {
66
padIndex: number;
77
isOpen: boolean;
88
onClose: () => void;
9-
anchorRect?: DOMRect;
109
}
1110

12-
export const PadMenu: React.FC<PadMenuProps> = ({ padIndex, isOpen, onClose, anchorRect }) => {
11+
export const PadMenu: React.FC<PadMenuProps> = ({ padIndex, isOpen, onClose }) => {
1312
const { pads, currentChannel, toggleMute, toggleSolo, clearPad, setCloneMode } = usePadStore();
1413
const pad = pads[`${currentChannel}-${padIndex}`];
1514
const menuRef = useRef<HTMLDivElement>(null);
@@ -28,60 +27,62 @@ export const PadMenu: React.FC<PadMenuProps> = ({ padIndex, isOpen, onClose, anc
2827

2928
if (!isOpen || !pad) return null;
3029

31-
const menuStyle: React.CSSProperties = {
32-
position: 'fixed',
33-
top: anchorRect ? anchorRect.bottom + 8 : '50%',
34-
left: anchorRect ? anchorRect.left : '50%',
35-
transform: anchorRect ? 'none' : 'translate(-50%, -50%)',
36-
zIndex: 1000,
37-
};
38-
3930
return (
40-
<div
41-
ref={menuRef}
42-
style={menuStyle}
43-
className="bg-zinc-900 border border-zinc-800 rounded-lg shadow-2xl p-1 min-w-[160px] animate-in fade-in zoom-in duration-200"
44-
>
45-
<div className="flex items-center justify-between px-3 py-2 border-b border-zinc-800 mb-1">
46-
<span className="text-[10px] font-bold text-zinc-500 uppercase tracking-widest">Pad {padIndex + 1} Options</span>
47-
<button onClick={onClose} className="text-zinc-500 hover:text-white transition-colors">
48-
<X size={14} />
49-
</button>
50-
</div>
51-
52-
<button
53-
onClick={() => { toggleSolo(padIndex); onClose(); }}
54-
className={`w-full flex items-center gap-3 px-3 py-2 text-xs font-bold transition-all rounded ${pad.solo ? 'bg-amber-500/20 text-amber-500' : 'text-zinc-300 hover:bg-white/5 hover:text-white'}`}
31+
<div className="fixed inset-0 z-[100] bg-black/80 backdrop-blur-sm flex items-center justify-center p-4 animate-in fade-in duration-200" onClick={(e) => { if (e.target === e.currentTarget) onClose(); }}>
32+
<div
33+
ref={menuRef}
34+
className="w-full max-w-xs bg-zinc-900 border border-white/10 rounded-2xl shadow-2xl overflow-hidden animate-in zoom-in-95 duration-200"
5535
>
56-
<Music size={16} className={pad.solo ? 'animate-pulse' : ''} />
57-
<span>{pad.solo ? 'Unsolo Pad' : 'Solo Pad'}</span>
58-
</button>
36+
<div className="flex items-center justify-between px-4 py-3 border-b border-white/5 bg-zinc-800/50">
37+
<span className="text-[10px] font-extrabold text-zinc-400 uppercase tracking-widest">Pad {padIndex + 1} Options</span>
38+
<button onClick={onClose} className="text-zinc-500 hover:text-white transition-colors p-1">
39+
<X size={18} />
40+
</button>
41+
</div>
5942

60-
<button
61-
onClick={() => { toggleMute(padIndex); onClose(); }}
62-
className={`w-full flex items-center gap-3 px-3 py-2 text-xs font-bold transition-all rounded ${pad.mute ? 'bg-retro-accent/20 text-retro-accent' : 'text-zinc-300 hover:bg-white/5 hover:text-white'}`}
63-
>
64-
{pad.mute ? <Volume2 size={16} /> : <VolumeX size={16} />}
65-
<span>{pad.mute ? 'Unmute Pad' : 'Mute Pad'}</span>
66-
</button>
43+
<div className="p-2 space-y-1">
44+
<button
45+
onClick={() => { toggleSolo(padIndex); onClose(); }}
46+
className={`w-full flex items-center gap-3 px-4 py-3 text-xs font-bold uppercase transition-all rounded-xl ${pad.solo ? 'bg-amber-500/20 text-amber-500' : 'text-zinc-300 hover:bg-white/5 hover:text-white'}`}
47+
>
48+
<Music size={18} className={pad.solo ? 'animate-pulse' : ''} />
49+
<span>{pad.solo ? 'Unsolo Pad' : 'Solo Pad'}</span>
50+
</button>
6751

68-
<button
69-
onClick={() => { setCloneMode(`${currentChannel}-${padIndex}`); onClose(); }}
70-
className="w-full flex items-center gap-3 px-3 py-2 text-xs font-bold text-zinc-300 hover:bg-white/5 hover:text-white transition-all rounded"
71-
>
72-
<Copy size={16} />
73-
<span>Clone Pad</span>
74-
</button>
52+
<button
53+
onClick={() => { toggleMute(padIndex); onClose(); }}
54+
className={`w-full flex items-center gap-3 px-4 py-3 text-xs font-bold uppercase transition-all rounded-xl ${pad.mute ? 'bg-retro-accent/20 text-retro-accent' : 'text-zinc-300 hover:bg-white/5 hover:text-white'}`}
55+
>
56+
{pad.mute ? <Volume2 size={18} /> : <VolumeX size={18} />}
57+
<span>{pad.mute ? 'Unmute Pad' : 'Mute Pad'}</span>
58+
</button>
7559

76-
<div className="h-px bg-zinc-800 my-1 mx-2" />
60+
<button
61+
onClick={() => { setCloneMode(`${currentChannel}-${padIndex}`); onClose(); }}
62+
className="w-full flex items-center gap-3 px-4 py-3 text-xs font-bold uppercase text-zinc-300 hover:bg-white/5 hover:text-white transition-all rounded-xl"
63+
>
64+
<Copy size={18} />
65+
<span>Clone Pad</span>
66+
</button>
7767

78-
<button
79-
onClick={() => { if (confirm(`Clear Pad ${padIndex + 1}?`)) { clearPad(padIndex); onClose(); } }}
80-
className="w-full flex items-center gap-3 px-3 py-2 text-xs font-bold text-red-400 hover:bg-red-400/10 transition-all rounded"
81-
>
82-
<Trash2 size={16} />
83-
<span>Clear Pad</span>
84-
</button>
68+
<div className="h-px bg-white/5 my-1 mx-2" />
69+
70+
<button
71+
onClick={() => { if (confirm(`Clear Pad ${padIndex + 1}?`)) { clearPad(padIndex); onClose(); } }}
72+
className="w-full flex items-center gap-3 px-4 py-3 text-xs font-bold uppercase text-red-500 hover:bg-red-500/10 transition-all rounded-xl"
73+
>
74+
<Trash2 size={18} />
75+
<span>Clear Pad</span>
76+
</button>
77+
</div>
78+
79+
<button
80+
onClick={onClose}
81+
className="w-full py-4 bg-zinc-800/80 text-zinc-400 font-extrabold uppercase tracking-widest text-[10px] hover:text-white transition-colors"
82+
>
83+
Close
84+
</button>
85+
</div>
8586
</div>
8687
);
8788
};

components/PatternModal.tsx

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
import React from 'react';
2+
import { X } from 'lucide-react';
3+
4+
interface PatternModalProps {
5+
activePatternId: string;
6+
onSelect: (id: string) => void;
7+
onClose: () => void;
8+
}
9+
10+
export const PatternModal: React.FC<PatternModalProps> = ({ activePatternId, onSelect, onClose }) => {
11+
const patterns = Array.from({ length: 16 }, (_, i) => ({
12+
id: `ptn-${i}`,
13+
label: String.fromCharCode(65 + i)
14+
}));
15+
16+
return (
17+
<div className="fixed inset-0 z-[100] bg-black/60 backdrop-blur-md flex items-center justify-center p-4 animate-in fade-in duration-200" onClick={onClose}>
18+
<div className="w-full max-w-[320px] bg-[#1a1a1e] border border-white/10 rounded-3xl shadow-2xl overflow-hidden animate-in zoom-in-95 duration-200" onClick={e => e.stopPropagation()}>
19+
<div className="px-6 py-4 border-b border-white/5 flex justify-between items-center bg-zinc-900/50">
20+
<span className="text-[10px] font-extrabold uppercase text-zinc-400 tracking-widest">Select Pattern</span>
21+
<button onClick={onClose} className="text-zinc-500 hover:text-white transition-colors">
22+
<X size={16} />
23+
</button>
24+
</div>
25+
<div className="p-4 grid grid-cols-4 gap-2">
26+
{patterns.map(p => (
27+
<button
28+
key={p.id}
29+
onClick={() => { onSelect(p.id); onClose(); }}
30+
className={`h-12 flex items-center justify-center rounded-xl border text-sm font-black transition-all ${activePatternId === p.id
31+
? 'bg-retro-accent border-retro-accent text-white shadow-[0_0_15px_rgba(255,30,86,0.3)]'
32+
: 'bg-white/5 border-white/5 text-zinc-400 hover:bg-white/10 hover:border-white/10'
33+
}`}
34+
>
35+
{p.label}
36+
</button>
37+
))}
38+
</div>
39+
</div>
40+
</div>
41+
);
42+
};

components/SequencePanel.tsx

Lines changed: 48 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,3 @@
1-
21
import React from 'react';
32
import { Music } from 'lucide-react';
43
import { usePadStore } from '../stores/padStore';
@@ -7,7 +6,14 @@ import { Knob } from './Knob';
76

87
export const SequencePanel: React.FC = () => {
98
const { currentChannel, selectedPadId, pads, samples } = usePadStore();
10-
const { patterns, selectedStepIndex, updateStepData } = useSequencerStore();
9+
const {
10+
patterns,
11+
selectedStepIndex,
12+
updateStepData,
13+
activePatternId,
14+
setActivePatternId,
15+
createPattern
16+
} = useSequencerStore();
1117

1218
const selectedPadIndex = parseInt(selectedPadId.split('-')[1]);
1319
const activePad = pads[`${currentChannel}-${selectedPadIndex}`];
@@ -16,21 +22,55 @@ export const SequencePanel: React.FC = () => {
1622

1723
const sampleName = activePad?.name || (activePad?.sampleId ? samples[activePad.sampleId]?.name : null);
1824

25+
const patternLetters = Array.from({ length: 16 }, (_, i) => String.fromCharCode(65 + i));
26+
1927
return (
20-
<div id="sequence-panel" className="flex flex-col h-full overflow-hidden">
21-
<div id="sequence-header" className="h-6 border-b border-zinc-800/50 flex items-center px-4 justify-between bg-zinc-800/30 flex-none">
22-
<div className="flex items-center gap-2 text-[11px] font-extrabold text-retro-accent uppercase tracking-tighter">
23-
<Music size={14} /> Sequence Editor <span className="text-white ml-1">#Step {selectedStepIndex + 1}</span>
28+
<div id="sequence-panel" className="flex flex-col h-full overflow-hidden bg-zinc-900">
29+
30+
{/* 1. Header (h-6, refined gaps) */}
31+
<div id="sequence-header" className="h-6 border-b border-zinc-800/50 flex items-center px-4 justify-between bg-zinc-800/30 flex-none gap-1">
32+
<div className="flex items-center gap-1 text-[10px] font-extrabold text-retro-accent uppercase tracking-tighter shrink-0">
33+
<Music size={12} /> Sequence <span className="text-white ml-1">#Step {selectedStepIndex + 1}</span>
2434
</div>
25-
<div id="sequence-active-pad-name" className="text-[10px] text-zinc-500 font-bold uppercase">
35+
36+
<div id="sequence-active-pad-name" className="text-[9px] text-zinc-500 font-bold uppercase shrink-0 truncate max-w-[120px]">
2637
{sampleName || 'Empty'}
2738
</div>
2839
</div>
29-
<div id="sequence-controls" className="flex-1 grid grid-cols-3 gap-4 p-2 place-items-center bg-black/40">
40+
41+
{/* 2. Pattern Selector (Moved here, 16 patterns A-P) */}
42+
<div id="pattern-selector" className="flex-none p-1 border-b border-zinc-800/50 bg-black/20 overflow-x-auto no-scrollbar">
43+
<div className="flex bg-black/40 rounded p-0.5 gap-0.5 w-max">
44+
{patternLetters.map((letter, i) => {
45+
const id = `ptn-${i}`;
46+
const isActive = activePatternId === id;
47+
return (
48+
<button
49+
key={id}
50+
onClick={() => {
51+
createPattern(id); // Ensures it exists
52+
setActivePatternId(id);
53+
}}
54+
className={`
55+
w-5 h-5 flex items-center justify-center text-[9px] font-extrabold rounded-sm transition-all shrink-0
56+
${isActive ? 'bg-retro-accent text-white shadow-[0_0_10px_rgba(255,30,86,0.3)]' : 'text-zinc-500 hover:text-white hover:bg-white/10'}
57+
`}
58+
title={`Pattern ${letter}`}
59+
>
60+
{letter}
61+
</button>
62+
);
63+
})}
64+
</div>
65+
</div>
66+
67+
{/* 3. Controls (Removed gap-4) */}
68+
<div id="sequence-controls" className="flex-1 grid grid-cols-3 p-2 place-items-center bg-black/40">
3069
<Knob label="Velocity" min={0} max={127} value={activeStepData?.velocity || 0} defaultValue={100} onChange={(v) => updateStepData(currentChannel, selectedPadIndex, selectedStepIndex, { velocity: Math.round(v) })} precision={0} />
3170
<Knob label="Pitch" min={-24} max={24} value={activeStepData?.pitch || 0} defaultValue={0} onChange={(v) => updateStepData(currentChannel, selectedPadIndex, selectedStepIndex, { pitch: Math.round(v) })} />
3271
<Knob label="Length" min={0.1} max={16.0} value={activeStepData?.length || 1.0} defaultValue={1.0} onChange={(v) => updateStepData(currentChannel, selectedPadIndex, selectedStepIndex, { length: v })} />
3372
</div>
73+
3474
</div>
3575
);
3676
};

0 commit comments

Comments
 (0)