Skip to content

Commit 8f8971b

Browse files
committed
feat: resizable NotationPanel height + Sidebar split (C5)
1 parent 1d89054 commit 8f8971b

5 files changed

Lines changed: 1139 additions & 979 deletions

File tree

CODEMAP.md

Lines changed: 15 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -314,6 +314,10 @@ Re-generate: `./scripts/generate-codemap.sh > CODEMAP.md`
314314
- 13:export function CreateDummySongDialog({ onClose }: CreateDummySongDialogProps)
315315
- **deps**: ../../stores/useSetlistStore,../../stores/useSongStore,../../stores/useTabStore,../../stores/useToastStore
316316

317+
### ImportExportPanel.tsx (276 lines)
318+
- 19:export function ImportExportPanel()
319+
- **deps**: ../../hooks/useClickOutside,../../hooks/useOrderedSetlist,../../services/exportService,../../stores/useModeStore,../../stores/useSetlistStore,../../stores/useSongStore,../../stores/useTabStore,../../stores/useToastStore,../../utils/iconSizes
320+
317321
### MidiSettingsDialog.tsx (261 lines)
318322
- 37:export function MidiSettingsDialog({ onClose }: MidiSettingsDialogProps)
319323
- **deps**: ../../services/midiService,../../stores/useMidiStore,../../stores/useToastStore
@@ -322,9 +326,17 @@ Re-generate: `./scripts/generate-codemap.sh > CODEMAP.md`
322326
- 3:export function ModeMenu()
323327
- **deps**: ../../stores/useModeStore
324328

325-
### Sidebar.tsx (1141 lines)
326-
- 29:export function Sidebar({ onSeekTo, duration, currentTime, isViewer = false, collapsed = false, onToggleCollapse, o...
327-
- **deps**: ../../hooks/useClickOutside,../../hooks/useOrderedSetlist,../Markers/MarkerList,../../services/exportService,../../stores/useModeStore,../../stores/useSetlistStore,../../stores/useSongStore,../../stores/useTabStore,../../stores/useToastStore,../../utils/formatTime,../../utils/iconSizes
329+
### SetlistItemList.tsx (588 lines)
330+
- 28:export function SetlistItemList({ isViewer, canEdit, onAddSong, onCreateDummy }: SetlistItemListProps)
331+
- **deps**: ../../hooks/useClickOutside,../../stores/useSetlistStore,../../stores/useSongStore,../../stores/useTabStore,../../stores/useToastStore,../../utils/iconSizes
332+
333+
### SetlistSelector.tsx (241 lines)
334+
- 19:export function SetlistSelector({ canEdit }: SetlistSelectorProps)
335+
- **deps**: ../../hooks/useClickOutside,../../hooks/useOrderedSetlist,../../stores/useSetlistStore,../../utils/formatTime,../../utils/iconSizes
336+
337+
### Sidebar.tsx (181 lines)
338+
- 26:export function Sidebar({ onSeekTo, duration, currentTime, isViewer = false, collapsed = false, onToggleCollapse, o...
339+
- **deps**: ../Markers/MarkerList,../../stores/useModeStore,../../stores/useSetlistStore,../../stores/useSongStore,../../stores/useTabStore,../../utils/iconSizes
328340

329341
### SongTabs.tsx (250 lines)
330342
- 16:export function SongTabs({ onAddSong, onCreateDummy, isViewer = false }: SongTabsProps)
Lines changed: 277 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,277 @@
1+
import { useState, useRef } from 'react';
2+
import { useSongStore } from '../../stores/useSongStore';
3+
import { useSetlistStore } from '../../stores/useSetlistStore';
4+
import { useOrderedSetlist } from '../../hooks/useOrderedSetlist';
5+
import { useClickOutside } from '../../hooks/useClickOutside';
6+
import { useModeStore } from '../../stores/useModeStore';
7+
import { useTabStore } from '../../stores/useTabStore';
8+
import { useToastStore } from '../../stores/useToastStore';
9+
import { exportSong, exportSetlist, exportGig, importFile } from '../../services/exportService';
10+
import { UrlImportDialog } from './UrlImportDialog';
11+
import { ChevronRight, Download, Upload } from 'lucide-react';
12+
import { ICON_SIZE } from '../../utils/iconSizes';
13+
import { useShallow } from 'zustand/shallow';
14+
15+
/**
16+
* Import/Export menu pinned to the sidebar bottom (practice mode only),
17+
* including the URL import dialog. Extracted from Sidebar (C5 split, block 1).
18+
*/
19+
export function ImportExportPanel() {
20+
const [showImportExport, setShowImportExport] = useState(false);
21+
const [showExportOptions, setShowExportOptions] = useState(false);
22+
const [showUrlImport, setShowUrlImport] = useState(false);
23+
const importExportRef = useRef<HTMLDivElement>(null);
24+
25+
useClickOutside(
26+
importExportRef,
27+
() => {
28+
setShowImportExport(false);
29+
setShowExportOptions(false);
30+
},
31+
showImportExport,
32+
);
33+
34+
// --- Song store ---
35+
const songs = useSongStore((state) => state.songs);
36+
const activeSongId = useSongStore((state) => state.activeSongId);
37+
const setActiveSongId = useSongStore((state) => state.setActiveSongId);
38+
const addSong = useSongStore((state) => state.addSong);
39+
const activeSong = songs.find((s) => s.id === activeSongId) ?? null;
40+
41+
// --- Setlist store ---
42+
const allSetlists = useSetlistStore((state) => state.setlists);
43+
const activeSetlistId = useSetlistStore((state) => state.activeSetlistId);
44+
const activeSetlist = allSetlists.find((s) => s.id === activeSetlistId);
45+
const songOrder = useSetlistStore(useShallow((state) => {
46+
const active = state.setlists.find((s) => s.id === state.activeSetlistId);
47+
return active?.items ?? [];
48+
}));
49+
const switchSetlist = useSetlistStore((state) => state.switchSetlist);
50+
const createSetlist = useSetlistStore((state) => state.createSetlist);
51+
52+
const { orderedSongs } = useOrderedSetlist();
53+
const addToast = useToastStore((state) => state.addToast);
54+
const isSession = useModeStore((state) => state.mode) === 'session';
55+
56+
const closeMenus = () => {
57+
setShowImportExport(false);
58+
setShowExportOptions(false);
59+
};
60+
61+
const handleExportSong = async () => {
62+
if (!activeSong) return;
63+
try {
64+
await exportSong(activeSong);
65+
addToast(`Exported "${activeSong.title}"`, 'success');
66+
} catch (error) {
67+
console.error('Song export failed:', error);
68+
addToast('Export failed', 'error');
69+
}
70+
};
71+
72+
const handleExportSetlist = async () => {
73+
const name = activeSetlist?.name ?? 'Setlist';
74+
try {
75+
await exportSetlist(name, songOrder, orderedSongs);
76+
addToast(`Exported "${name}"`, 'success');
77+
} catch (error) {
78+
console.error('Setlist export failed:', error);
79+
addToast('Export failed', 'error');
80+
}
81+
};
82+
83+
const handleExportGig = async () => {
84+
const allItems = allSetlists.map((sl) => ({
85+
name: sl.name,
86+
items: sl.items,
87+
}));
88+
try {
89+
await exportGig(allItems, songs);
90+
addToast('Exported gig', 'success');
91+
} catch (error) {
92+
console.error('Gig export failed:', error);
93+
addToast('Export failed', 'error');
94+
}
95+
};
96+
97+
const handleImport = () => {
98+
const input = document.createElement('input');
99+
input.type = 'file';
100+
input.accept = '.json';
101+
input.onchange = async (e) => {
102+
const file = (e.target as HTMLInputElement).files?.[0];
103+
if (!file) return;
104+
try {
105+
const result = await importFile(file);
106+
107+
if (result.type === 'song') {
108+
// Single song import
109+
await addSong(result.song);
110+
await useSetlistStore.getState().addSongToActiveSetlist(result.song.id);
111+
await setActiveSongId(result.song.id);
112+
await useTabStore.getState().loadTabsForSong(result.song.id);
113+
await useTabStore.getState().loadSheetsForSong(result.song.id);
114+
addToast(`Imported "${result.song.title}"`, 'success');
115+
} else {
116+
// Gig / setlist import (one or more setlists)
117+
for (const song of result.songs) {
118+
await addSong(song);
119+
}
120+
121+
let firstSetlistId: string | null = null;
122+
for (const sl of result.setlists) {
123+
const id = await createSetlist(sl.name);
124+
await useSetlistStore.getState().setActiveItems(sl.items);
125+
if (!firstSetlistId) firstSetlistId = id;
126+
}
127+
128+
if (firstSetlistId) {
129+
switchSetlist(firstSetlistId);
130+
}
131+
if (result.songs.length > 0) {
132+
await setActiveSongId(result.songs[0].id);
133+
await useTabStore.getState().loadTabsForSong(result.songs[0].id);
134+
await useTabStore.getState().loadSheetsForSong(result.songs[0].id);
135+
}
136+
137+
const setlistCount = result.setlists.length;
138+
const importedSongCount = result.songs.length;
139+
const label = setlistCount > 1
140+
? `Imported ${setlistCount} setlists with ${importedSongCount} song(s)`
141+
: `Imported ${importedSongCount} song(s)`;
142+
addToast(label, 'success');
143+
}
144+
} catch (err) {
145+
console.error('Import failed:', err);
146+
addToast('Import failed', 'error');
147+
}
148+
};
149+
input.click();
150+
};
151+
152+
// Import/Export is available in practice mode only
153+
if (isSession) return null;
154+
155+
return (
156+
<>
157+
<div className='mt-auto border-t border-slate-700 p-3' ref={importExportRef}>
158+
<div className='relative'>
159+
<button
160+
onClick={() => {
161+
setShowImportExport((v) => !v);
162+
}}
163+
className='w-full px-2 py-1.5 text-xs font-mono rounded transition-colors
164+
bg-slate-700 hover:bg-slate-600 text-slate-300'
165+
>
166+
Import / Export
167+
</button>
168+
{showImportExport && (
169+
<div className='absolute left-0 right-0 bottom-full mb-1 bg-slate-800 border
170+
border-slate-600 rounded-lg shadow-xl py-1 z-50'>
171+
<button
172+
onClick={() => {
173+
handleImport();
174+
closeMenus();
175+
}}
176+
className='w-full text-left px-3 py-1.5 text-xs font-mono
177+
text-slate-300 hover:bg-slate-700 transition-colors'
178+
>
179+
<Upload size={ICON_SIZE.ACTION} className='inline-block' /> Import
180+
</button>
181+
<button
182+
onClick={() => {
183+
setShowUrlImport(true);
184+
closeMenus();
185+
}}
186+
className='w-full text-left px-3 py-1.5 text-xs font-mono
187+
text-slate-300 hover:bg-slate-700 transition-colors'
188+
>
189+
<Upload size={ICON_SIZE.ACTION} className='inline-block' /> Import from URL
190+
</button>
191+
<div className='border-t border-slate-700 my-1' />
192+
<button
193+
onClick={() => setShowExportOptions((v) => !v)}
194+
className='w-full text-left px-3 py-1.5 text-xs font-mono
195+
text-slate-300 hover:bg-slate-700 transition-colors
196+
flex items-center justify-between'
197+
>
198+
<span><Download size={ICON_SIZE.ACTION} className='inline-block' /> Export</span>
199+
<ChevronRight
200+
size={ICON_SIZE.ACTION}
201+
className={`transition-transform ${showExportOptions ? 'rotate-90' : ''}`}
202+
/>
203+
</button>
204+
{showExportOptions && (
205+
<>
206+
{activeSong && (
207+
<button
208+
onClick={() => {
209+
handleExportSong();
210+
closeMenus();
211+
}}
212+
className='w-full text-left pl-6 pr-3 py-1.5 text-xs font-mono
213+
text-slate-400 hover:text-slate-300 hover:bg-slate-700
214+
transition-colors'
215+
>
216+
Song
217+
</button>
218+
)}
219+
<button
220+
onClick={() => {
221+
handleExportSetlist();
222+
closeMenus();
223+
}}
224+
disabled={songOrder.length === 0}
225+
className='w-full text-left pl-6 pr-3 py-1.5 text-xs font-mono
226+
text-slate-400 hover:text-slate-300 hover:bg-slate-700
227+
transition-colors
228+
disabled:opacity-30 disabled:cursor-not-allowed'
229+
>
230+
Setlist
231+
</button>
232+
<button
233+
onClick={() => {
234+
handleExportGig();
235+
closeMenus();
236+
}}
237+
disabled={allSetlists.length === 0}
238+
className='w-full text-left pl-6 pr-3 py-1.5 text-xs font-mono
239+
text-slate-400 hover:text-slate-300 hover:bg-slate-700
240+
transition-colors
241+
disabled:opacity-30 disabled:cursor-not-allowed'
242+
>
243+
Gig
244+
</button>
245+
</>
246+
)}
247+
</div>
248+
)}
249+
</div>
250+
</div>
251+
252+
{/* URL Import Dialog */}
253+
{showUrlImport && (
254+
<UrlImportDialog
255+
onClose={() => setShowUrlImport(false)}
256+
onImported={async (result) => {
257+
try {
258+
for (const song of result.songs) {
259+
await addSong(song);
260+
}
261+
await createSetlist(result.name);
262+
await useSetlistStore.getState().setActiveItems(result.items);
263+
if (result.songs.length > 0) {
264+
await setActiveSongId(result.songs[0].id);
265+
await useTabStore.getState().loadTabsForSong(result.songs[0].id);
266+
await useTabStore.getState().loadSheetsForSong(result.songs[0].id);
267+
}
268+
} catch (error) {
269+
console.error('URL import failed:', error);
270+
addToast('Import failed', 'error');
271+
}
272+
}}
273+
/>
274+
)}
275+
</>
276+
);
277+
}

0 commit comments

Comments
 (0)