Skip to content

Commit d3b2a98

Browse files
committed
update: optical improvements
1 parent fb39b78 commit d3b2a98

2 files changed

Lines changed: 186 additions & 80 deletions

File tree

src/components/Layout/Sidebar.tsx

Lines changed: 118 additions & 78 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,8 @@
1-
import { useState, useCallback } from 'react';
1+
import { useState, useCallback, useRef, useEffect } from 'react';
22
import { useSongStore } from '../../stores/useSongStore';
33
import { useModeStore } from '../../stores/useModeStore';
44
import { MarkerList } from '../Markers/MarkerList';
55
import { exportSong, importSong, exportSetlist, importSetlist } from '../../services/exportService';
6-
import type { Setlist } from '../../types';
76
import { useTabStore } from '../../stores/useTabStore';
87
import { useToastStore } from '../../stores/useToastStore';
98

@@ -21,11 +20,26 @@ export function Sidebar({ onSeekTo, duration, currentTime, isViewer = false, col
2120
const [sectionsOpen, setSectionsOpen] = useState(true);
2221
const [setlistOpen, setSetlistOpen] = useState(true);
2322
const [setlistName, setSetlistName] = useState('');
24-
const [importedSetlist] = useState<Setlist | null>(null);
2523
const [editingPauseId, setEditingPauseId] = useState<string | null>(null);
2624
const [editingPauseValue, setEditingPauseValue] = useState('');
2725
const [dragIndex, setDragIndex] = useState<number | null>(null);
2826
const [dropIndex, setDropIndex] = useState<number | null>(null);
27+
const [showImportExport, setShowImportExport] = useState(false);
28+
const [setlistExportMode, setSetlistExportMode] = useState(false);
29+
const importExportRef = useRef<HTMLDivElement>(null);
30+
31+
// Close dropdown on outside click
32+
useEffect(() => {
33+
if (!showImportExport) return;
34+
const handleClick = (e: MouseEvent) => {
35+
if (importExportRef.current && !importExportRef.current.contains(e.target as Node)) {
36+
setShowImportExport(false);
37+
setSetlistExportMode(false);
38+
}
39+
};
40+
document.addEventListener('mousedown', handleClick);
41+
return () => document.removeEventListener('mousedown', handleClick);
42+
}, [showImportExport]);
2943

3044
const songs = useSongStore((state) => state.songs);
3145
const songOrder = useSongStore((state) => state.songOrder);
@@ -249,31 +263,6 @@ export function Sidebar({ onSeekTo, duration, currentTime, isViewer = false, col
249263
</div>
250264
)}
251265

252-
{/* Song import/export */}
253-
<div className='border-t border-slate-700 p-3 flex flex-col gap-2'>
254-
<p className='text-xs font-mono text-slate-500 uppercase tracking-widest'>
255-
Song Data
256-
</p>
257-
<div className='flex gap-2'>
258-
{activeSong && (
259-
<button
260-
onClick={handleExportSong}
261-
className='flex-1 px-2 py-1 text-xs font-mono bg-slate-700
262-
hover:bg-slate-600 text-slate-300 rounded transition-colors'
263-
>
264-
↓ export
265-
</button>
266-
)}
267-
<button
268-
onClick={handleImportSong}
269-
className='flex-1 px-2 py-1 text-xs font-mono bg-slate-700
270-
hover:bg-slate-600 text-slate-300 rounded transition-colors'
271-
>
272-
↑ import
273-
</button>
274-
</div>
275-
</div>
276-
277266
</div>
278267
)}
279268
</div>
@@ -546,60 +535,111 @@ export function Sidebar({ onSeekTo, duration, currentTime, isViewer = false, col
546535
</button>
547536
)}
548537
</div>
538+
</div>
539+
)}
540+
</div>
549541

550-
{!isBand && (
551-
<>
552-
{/* Export setlist */}
553-
<div className='flex flex-col gap-2 border-t border-slate-700 pt-3'>
554-
<p className='text-xs font-mono text-slate-500 uppercase tracking-widest'>
555-
Export Setlist
556-
</p>
557-
<input
558-
type='text'
559-
placeholder='Setlist name...'
560-
value={setlistName}
561-
onChange={(e) => setSetlistName(e.target.value)}
562-
className='bg-slate-800 text-slate-200 text-xs rounded px-2 py-1.5
563-
border border-slate-600 focus:border-indigo-500 outline-none font-mono'
564-
/>
565-
<button
566-
onClick={handleExportSetlist}
567-
disabled={!setlistName.trim() || songs.length === 0}
568-
className='px-2 py-1 text-xs font-mono bg-slate-700 hover:bg-slate-600
569-
text-slate-300 rounded transition-colors
570-
disabled:opacity-30 disabled:cursor-not-allowed'
571-
>
572-
↓ export setlist
573-
</button>
574-
</div>
575-
576-
{/* Import setlist */}
577-
<div className='flex flex-col gap-2 border-t border-slate-700 pt-3'>
578-
<p className='text-xs font-mono text-slate-500 uppercase tracking-widest'>
579-
Import Setlist
580-
</p>
542+
{/* ── Import / Export (pinned to bottom) ── */}
543+
<div className='mt-auto border-t border-slate-700 p-3' ref={importExportRef}>
544+
<div className='relative'>
545+
<button
546+
onClick={() => {
547+
setShowImportExport((v) => !v);
548+
setSetlistExportMode(false);
549+
}}
550+
className='w-full px-2 py-1.5 text-xs font-mono rounded transition-colors
551+
bg-slate-700 hover:bg-slate-600 text-slate-300'
552+
>
553+
Import / Export
554+
</button>
555+
{showImportExport && (
556+
<div className='absolute left-0 right-0 bottom-full mb-1 bg-slate-800 border
557+
border-slate-600 rounded-lg shadow-xl py-1 z-50'>
558+
{activeSong && (
559+
<button
560+
onClick={() => {
561+
handleExportSong();
562+
setShowImportExport(false);
563+
}}
564+
className='w-full text-left px-3 py-1.5 text-xs font-mono
565+
text-slate-300 hover:bg-slate-700 transition-colors'
566+
>
567+
↓ Export Song
568+
</button>
569+
)}
570+
<button
571+
onClick={() => {
572+
handleImportSong();
573+
setShowImportExport(false);
574+
}}
575+
className='w-full text-left px-3 py-1.5 text-xs font-mono
576+
text-slate-300 hover:bg-slate-700 transition-colors'
577+
>
578+
↑ Import Song
579+
</button>
580+
{!isBand && (
581+
<>
582+
<div className='border-t border-slate-700 my-1' />
583+
{!setlistExportMode ? (
584+
<button
585+
onClick={() => setSetlistExportMode(true)}
586+
disabled={songs.length === 0}
587+
className='w-full text-left px-3 py-1.5 text-xs font-mono
588+
text-slate-300 hover:bg-slate-700 transition-colors
589+
disabled:opacity-30 disabled:cursor-not-allowed'
590+
>
591+
↓ Export Setlist
592+
</button>
593+
) : (
594+
<div className='px-3 py-1.5 flex flex-col gap-1.5'>
595+
<input
596+
type='text'
597+
placeholder='Setlist name...'
598+
value={setlistName}
599+
onChange={(e) => setSetlistName(e.target.value)}
600+
onKeyDown={(e) => {
601+
if (e.key === 'Enter' && setlistName.trim()) {
602+
handleExportSetlist();
603+
setShowImportExport(false);
604+
setSetlistExportMode(false);
605+
}
606+
}}
607+
autoFocus
608+
className='bg-slate-900 text-slate-200 text-xs rounded px-2
609+
py-1 border border-slate-600 focus:border-indigo-500
610+
outline-none font-mono w-full'
611+
/>
612+
<button
613+
onClick={() => {
614+
handleExportSetlist();
615+
setShowImportExport(false);
616+
setSetlistExportMode(false);
617+
}}
618+
disabled={!setlistName.trim()}
619+
className='px-2 py-1 text-xs font-mono bg-indigo-600
620+
hover:bg-indigo-500 text-white rounded
621+
transition-colors disabled:opacity-30
622+
disabled:cursor-not-allowed'
623+
>
624+
↓ Export
625+
</button>
626+
</div>
627+
)}
581628
<button
582-
onClick={handleImportSetlist}
583-
className='px-2 py-1 text-xs font-mono bg-slate-700 hover:bg-slate-600
584-
text-slate-300 rounded transition-colors'
629+
onClick={() => {
630+
handleImportSetlist();
631+
setShowImportExport(false);
632+
}}
633+
className='w-full text-left px-3 py-1.5 text-xs font-mono
634+
text-slate-300 hover:bg-slate-700 transition-colors'
585635
>
586-
import setlist
636+
Import Setlist
587637
</button>
588-
{importedSetlist && (
589-
<div className='flex flex-col gap-1'>
590-
<p className='text-xs font-mono text-slate-400'>{importedSetlist.name}</p>
591-
{importedSetlist.entries.map((entry) => (
592-
<div key={entry.songId} className='text-xs font-mono text-slate-500 px-2'>
593-
{entry.title}
594-
</div>
595-
))}
596-
</div>
597-
)}
598-
</div>
599-
</>
600-
)}
601-
</div>
602-
)}
638+
</>
639+
)}
640+
</div>
641+
)}
642+
</div>
603643
</div>
604644
</aside>
605645
);

src/components/Layout/SongTabs.tsx

Lines changed: 68 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { useState, useRef, useEffect } from 'react';
1+
import { useState, useRef, useEffect, useCallback } from 'react';
22
import { useSongStore } from '../../stores/useSongStore';
33
import { useTabStore } from '../../stores/useTabStore';
44
import { useToastStore } from '../../stores/useToastStore';
@@ -27,7 +27,42 @@ export function SongTabs({ onAddSong, onCreateDummy, isViewer = false }: SongTab
2727
const [showMenu, setShowMenu] = useState(false);
2828
const menuRef = useRef<HTMLDivElement>(null);
2929
const btnRef = useRef<HTMLButtonElement>(null);
30+
const scrollRef = useRef<HTMLDivElement>(null);
3031
const [menuPos, setMenuPos] = useState({ top: 0, left: 0 });
32+
const [canScrollLeft, setCanScrollLeft] = useState(false);
33+
const [canScrollRight, setCanScrollRight] = useState(false);
34+
35+
const checkScroll = useCallback(() => {
36+
const el = scrollRef.current;
37+
if (!el) return;
38+
setCanScrollLeft(el.scrollLeft > 0);
39+
setCanScrollRight(el.scrollLeft + el.clientWidth < el.scrollWidth - 1);
40+
}, []);
41+
42+
// Re-check on song list changes
43+
useEffect(() => {
44+
checkScroll();
45+
}, [orderedSongs.length, checkScroll]);
46+
47+
// Listen to scroll and resize
48+
useEffect(() => {
49+
const el = scrollRef.current;
50+
if (!el) return;
51+
el.addEventListener('scroll', checkScroll);
52+
const observer = new ResizeObserver(checkScroll);
53+
observer.observe(el);
54+
return () => {
55+
el.removeEventListener('scroll', checkScroll);
56+
observer.disconnect();
57+
};
58+
}, [checkScroll]);
59+
60+
const scroll = (direction: 'left' | 'right') => {
61+
scrollRef.current?.scrollBy({
62+
left: direction === 'left' ? -200 : 200,
63+
behavior: 'smooth',
64+
});
65+
};
3166
const addToast = useToastStore((state) => state.addToast);
3267
const editInputRef = useRef<HTMLInputElement>(null);
3368

@@ -54,7 +89,25 @@ export function SongTabs({ onAddSong, onCreateDummy, isViewer = false }: SongTab
5489
}, [showMenu]);
5590

5691
return (
57-
<div className='flex items-center gap-1 overflow-x-auto'>
92+
<div className='flex items-center gap-0 relative'>
93+
{/* Scroll left button */}
94+
{canScrollLeft && (
95+
<button
96+
onClick={() => scroll('left')}
97+
className='shrink-0 px-2 py-1 text-lg font-bold text-slate-500
98+
hover:text-slate-200 transition-colors z-10'
99+
aria-label='Scroll tabs left'
100+
>
101+
102+
</button>
103+
)}
104+
105+
{/* Scrollable tab area */}
106+
<div
107+
ref={scrollRef}
108+
className='flex items-center gap-1 overflow-x-auto flex-1'
109+
style={{ scrollbarWidth: 'none' }}
110+
>
58111
{orderedSongs.map((song) => {
59112
const isActive = song.id === activeSongId;
60113
return (
@@ -132,6 +185,19 @@ export function SongTabs({ onAddSong, onCreateDummy, isViewer = false }: SongTab
132185
</div>
133186
);
134187
})}
188+
</div>
189+
190+
{/* Scroll right button */}
191+
{canScrollRight && (
192+
<button
193+
onClick={() => scroll('right')}
194+
className='shrink-0 px-2 py-1 text-lg font-bold text-slate-500
195+
hover:text-slate-200 transition-colors z-10'
196+
aria-label='Scroll tabs right'
197+
>
198+
199+
</button>
200+
)}
135201

136202
{/* Add song dropdown */}
137203
{!isViewer && (

0 commit comments

Comments
 (0)