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