/** * Component: Bulk Import Wizard * Documentation: documentation/features/bulk-import.md * * Multi-step modal wizard for bulk importing audiobooks from server folders. * Step 1: Select root folder to scan. * Step 2: Scanning/matching progress. * Step 3: Review matches and start import. */ 'use client'; import React, { useState, useCallback, useRef } from 'react'; import { createPortal } from 'react-dom'; import { XMarkIcon, FolderArrowDownIcon } from '@heroicons/react/24/outline'; import { ScanFolderStep } from './bulk-import/ScanFolderStep'; import { ScanProgressStep } from './bulk-import/ScanProgressStep'; import { MatchReviewStep } from './bulk-import/MatchReviewStep'; import { WizardStep, ScannedBook, ScanProgressEvent, MatchingProgressEvent } from './bulk-import/types'; import { fetchWithAuth } from '@/lib/utils/api'; interface BulkImportWizardProps { isOpen: boolean; onClose: () => void; } const STEP_LABELS: Record = { select_folder: 'Select Folder', scanning: 'Scanning', review: 'Review & Import', }; const STEP_ORDER: WizardStep[] = ['select_folder', 'scanning', 'review']; export function BulkImportWizard({ isOpen, onClose }: BulkImportWizardProps) { const [step, setStep] = useState('select_folder'); const [selectedRootPath, setSelectedRootPath] = useState(null); // Scanning state const [scanProgress, setScanProgress] = useState(null); const [matchingProgress, setMatchingProgress] = useState(null); const [scanPhase, setScanPhase] = useState<'discovering' | 'matching' | 'idle'>('idle'); const abortRef = useRef(null); // Results state const [scannedBooks, setScannedBooks] = useState([]); const [scanError, setScanError] = useState(null); // Import state const [isImporting, setIsImporting] = useState(false); const [importResults, setImportResults] = useState(null); const resetWizard = useCallback(() => { setStep('select_folder'); setSelectedRootPath(null); setScanProgress(null); setMatchingProgress(null); setScanPhase('idle'); setScannedBooks([]); setScanError(null); setIsImporting(false); setImportResults(null); if (abortRef.current) { abortRef.current.abort(); abortRef.current = null; } }, []); const handleClose = useCallback(() => { if (abortRef.current) { abortRef.current.abort(); abortRef.current = null; } resetWizard(); onClose(); }, [onClose, resetWizard]); const handleFolderSelected = useCallback(async (rootPath: string) => { setSelectedRootPath(rootPath); setStep('scanning'); setScanPhase('discovering'); setScanError(null); setScannedBooks([]); const controller = new AbortController(); abortRef.current = controller; try { const response = await fetchWithAuth('/api/admin/bulk-import/scan', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ rootPath }), signal: controller.signal, }); if (!response.ok) { const errData = await response.json().catch(() => ({ error: 'Scan failed' })); throw new Error(errData.error || 'Scan failed'); } const reader = response.body?.getReader(); if (!reader) throw new Error('No response stream'); const decoder = new TextDecoder(); let buffer = ''; let eventType = ''; while (true) { const { done, value } = await reader.read(); if (done) break; buffer += decoder.decode(value, { stream: true }); // Parse SSE events from buffer const lines = buffer.split('\n'); buffer = lines.pop() || ''; for (const line of lines) { if (line.startsWith('event: ')) { eventType = line.slice(7).trim(); } else if (line.startsWith('data: ') && eventType) { try { const data = JSON.parse(line.slice(6)); handleSSEEvent(eventType, data); } catch { /* ignore parse errors */ } eventType = ''; } } } } catch (error) { if (controller.signal.aborted) return; setScanError(error instanceof Error ? error.message : 'Scan failed'); setScanPhase('idle'); } }, []); const handleSSEEvent = useCallback((event: string, data: any) => { switch (event) { case 'progress': setScanProgress(data); break; case 'discovery_complete': setScanPhase('matching'); break; case 'matching': setMatchingProgress(data); break; case 'book_matched': { const book: ScannedBook = { ...data, skipped: data.inLibrary || data.hasActiveRequest || data.match === null, }; setScannedBooks((prev) => [...prev, book]); break; } case 'complete': setScanPhase('idle'); setStep('review'); break; case 'error': setScanError(data.message || 'Scan failed'); setScanPhase('idle'); break; } }, []); const handleCancelScan = useCallback(() => { if (abortRef.current) { abortRef.current.abort(); abortRef.current = null; } setScanPhase('idle'); setStep('select_folder'); }, []); const handleToggleSkip = useCallback((index: number) => { setScannedBooks((prev) => prev.map((book) => book.index === index ? { ...book, skipped: !book.skipped } : book ) ); }, []); const handleStartImport = useCallback(async () => { const booksToImport = scannedBooks.filter( (b) => !b.skipped && b.match !== null ); if (booksToImport.length === 0) return; setIsImporting(true); try { const response = await fetchWithAuth('/api/admin/bulk-import/execute', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ imports: booksToImport.map((b) => ({ folderPath: b.folderPath, asin: b.match!.asin, audioFiles: b.audioFiles, })), }), }); const data = await response.json(); if (!response.ok) { throw new Error(data.error || 'Import failed'); } setImportResults(data); } catch (error) { setImportResults({ success: false, error: error instanceof Error ? error.message : 'Import failed', }); } finally { setIsImporting(false); } }, [scannedBooks]); const handleBackToFolderSelect = useCallback(() => { setStep('select_folder'); setScanError(null); setScannedBooks([]); setScanPhase('idle'); }, []); if (!isOpen) return null; const currentStepIndex = STEP_ORDER.indexOf(step); const modalContent = (
e.stopPropagation()} > {/* Header */}

Bulk Import

{/* Step Indicator */}
{STEP_ORDER.map((s, i) => ( {i > 0 && (
)}
{i < currentStepIndex ? ( ) : ( i + 1 )}
))}
{/* Content */}
{step === 'select_folder' && ( )} {step === 'scanning' && ( selectedRootPath && handleFolderSelected(selectedRootPath)} onBack={handleBackToFolderSelect} /> )} {step === 'review' && ( )}
); return createPortal(modalContent, document.body); }