/** * Component: Manual Import File Browser * Documentation: documentation/features/manual-import.md * * Two-phase modal for browsing server directories and importing audiobook files. * Phase 1 (BrowsePhase): Directory navigation with audio file detection. * Phase 2 (ConfirmPhase): Review and start import. * * Sub-components: manual-import/BrowsePhase.tsx, manual-import/ConfirmPhase.tsx */ 'use client'; import React, { useState, useEffect, useCallback } from 'react'; import { createPortal } from 'react-dom'; import { fetchWithAuth } from '@/lib/utils/api'; import { FolderArrowDownIcon, XMarkIcon } from '@heroicons/react/24/outline'; import { RootEntry, DirectoryEntry, AudioFileEntry, SlideDirection } from './manual-import/types'; import { BrowsePhase } from './manual-import/BrowsePhase'; import { ConfirmPhase } from './manual-import/ConfirmPhase'; interface ManualImportBrowserProps { isOpen: boolean; onClose: () => void; onSuccess: () => void; audiobook: { asin: string; title: string; author: string; coverArtUrl?: string; }; } type Phase = 'browse' | 'confirm'; export function ManualImportBrowser({ isOpen, onClose, onSuccess, audiobook, }: ManualImportBrowserProps) { const [phase, setPhase] = useState('browse'); const [slideDirection, setSlideDirection] = useState('right'); // Browse state const [roots, setRoots] = useState([]); const [currentPath, setCurrentPath] = useState(null); const [entries, setEntries] = useState([]); const [selectedPath, setSelectedPath] = useState(null); const [selectedAudioFiles, setSelectedAudioFiles] = useState([]); const [currentAudioFiles, setCurrentAudioFiles] = useState([]); const [pathHistory, setPathHistory] = useState([]); // Loading/error state const [isLoading, setIsLoading] = useState(false); const [error, setError] = useState(null); const [isImporting, setIsImporting] = useState(false); const [importError, setImportError] = useState(null); // Cleanup source toggle const [cleanupSource, setCleanupSource] = useState(false); // File selection state (shared between BrowsePhase and ConfirmPhase) const [checkedFiles, setCheckedFiles] = useState>(new Set()); // Hover state for folder icon swap const [hoveredFolder, setHoveredFolder] = useState(null); // Fetch roots on open useEffect(() => { if (!isOpen) return; setPhase('browse'); setCurrentPath(null); setSelectedPath(null); setPathHistory([]); fetchRoots(); }, [isOpen]); const fetchRoots = async () => { setIsLoading(true); setError(null); try { const res = await fetchWithAuth('/api/admin/filesystem/browse'); if (!res.ok) { const data = await res.json().catch(() => ({ error: 'Failed to load' })); throw new Error(data.error || 'Failed to load directories'); } const data = await res.json(); setRoots(data.roots || []); } catch (err) { setError(err instanceof Error ? err.message : 'Failed to load directories'); } finally { setIsLoading(false); } }; const fetchDirectory = useCallback(async (dirPath: string) => { setIsLoading(true); setError(null); setCheckedFiles(new Set()); try { const res = await fetchWithAuth( `/api/admin/filesystem/browse?path=${encodeURIComponent(dirPath)}` ); if (!res.ok) { const data = await res.json().catch(() => ({ error: 'Failed to load' })); throw new Error(data.error || 'Failed to browse directory'); } const data = await res.json(); const audioFiles: AudioFileEntry[] = data.audioFiles || []; setEntries(data.entries || []); setCurrentAudioFiles(audioFiles); setCurrentPath(data.path || dirPath); } catch (err) { setError(err instanceof Error ? err.message : 'Failed to browse directory'); } finally { setIsLoading(false); } }, []); const navigateInto = (dirPath: string) => { setSlideDirection('right'); if (currentPath) { setPathHistory((prev) => [...prev, currentPath]); } setSelectedPath(null); fetchDirectory(dirPath); }; const navigateBack = () => { setSlideDirection('left'); setSelectedPath(null); if (pathHistory.length > 0) { const prevPath = pathHistory[pathHistory.length - 1]; setPathHistory((prev) => prev.slice(0, -1)); fetchDirectory(prevPath); } else { setCurrentPath(null); setEntries([]); } }; const navigateToRoot = () => { setSlideDirection('left'); setSelectedPath(null); setCurrentPath(null); setEntries([]); setCurrentAudioFiles([]); setPathHistory([]); }; const navigateToBreadcrumb = (index: number) => { if (!currentPath) return; setSlideDirection('left'); setSelectedPath(null); const allPaths = [...pathHistory, currentPath]; const targetPath = allPaths[index]; if (targetPath) { setPathHistory(allPaths.slice(0, index)); fetchDirectory(targetPath); } else { navigateToRoot(); } }; const handleFolderClick = (entry: DirectoryEntry) => { const fullPath = currentPath + '/' + entry.name; navigateInto(fullPath); }; const handleToggleFile = (fileName: string) => { setCheckedFiles((prev) => { const next = new Set(prev); if (next.has(fileName)) { next.delete(fileName); } else { next.add(fileName); } return next; }); }; const handleToggleAll = () => { // In confirm phase, toggle against selectedAudioFiles; in browse phase, against currentAudioFiles const files = phase === 'confirm' ? selectedAudioFiles : currentAudioFiles; if (checkedFiles.size === files.length) { setCheckedFiles(new Set()); } else { setCheckedFiles(new Set(files.map((f) => f.name))); } }; const handleSelectFiles = () => { if (!currentPath || currentAudioFiles.length === 0) return; // No individual selection = whole folder; otherwise only checked files const selected = checkedFiles.size > 0 ? currentAudioFiles.filter((f) => checkedFiles.has(f.name)) : currentAudioFiles; setSelectedPath(currentPath); setSelectedAudioFiles(selected); // Ensure checkedFiles reflects what we're importing for ConfirmPhase setCheckedFiles(new Set(selected.map((f) => f.name))); setSlideDirection('right'); setPhase('confirm'); }; const handleBackToBrowse = () => { setSlideDirection('left'); setPhase('browse'); }; const handleStartImport = async () => { if (!selectedPath) return; setIsImporting(true); setImportError(null); try { // Send only the files that are still checked in ConfirmPhase const fileNames = selectedAudioFiles .filter((f) => checkedFiles.has(f.name)) .map((f) => f.name); if (fileNames.length === 0) return; const res = await fetchWithAuth('/api/admin/manual-import', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ asin: audiobook.asin, folderPath: selectedPath, selectedFiles: fileNames, cleanupSource, }), }); const data = await res.json(); if (!res.ok) { throw new Error(data.error || 'Import failed'); } onSuccess(); onClose(); } catch (err) { setImportError(err instanceof Error ? err.message : 'Import failed'); } finally { setIsImporting(false); } }; // Build breadcrumb segments const breadcrumbs = (() => { if (!currentPath) return []; const allPaths = [...pathHistory, currentPath]; return allPaths.map((p) => { const parts = p.replace(/\\/g, '/').split('/'); return parts[parts.length - 1] || p; }); })(); const visibleBreadcrumbs = (() => { if (breadcrumbs.length <= 3) return breadcrumbs.map((b, i) => ({ label: b, index: i })); return [ { label: breadcrumbs[0], index: 0 }, { label: '...', index: -1 }, { label: breadcrumbs[breadcrumbs.length - 1], index: breadcrumbs.length - 1 }, ]; })(); if (!isOpen) return null; const slideClass = slideDirection === 'right' ? 'animate-[slideRight_200ms_ease-out]' : 'animate-[slideLeft_200ms_ease-out]'; const modalContent = (
e.stopPropagation()} > {/* Header */}

{phase === 'browse' ? 'Manual Import' : 'Confirm Import'}

{/* Content */}
{phase === 'browse' ? ( fetchDirectory(currentPath) : fetchRoots} /> ) : ( )}
); return createPortal(modalContent, document.body); }