mirror of
https://github.com/kikootwo/ReadMeABook.git
synced 2026-06-03 12:50:09 +00:00
Add admin Bulk Import feature
Introduce a Bulk Import feature for admins to scan server folders, match discovered audiobook folders against Audible, review matches, and queue batch imports. What changed: - Added documentation: documentation/features/bulk-import.md and TABLEOFCONTENTS update. - Backend: SSE scan endpoint (POST /api/admin/bulk-import/scan) streams discovery and matching events; execute endpoint (POST /api/admin/bulk-import/execute) validates paths, creates/resolves audiobook & request records, and queues organize_files jobs. Both endpoints enforce admin-only access and validate allowed root directories (download_dir, media_dir, /bookdrop). - Frontend: Modal wizard and steps for folder selection, scan progress, and match review (BulkImportWizard + ScanFolderStep, ScanProgressStep, MatchReviewStep + shared types). - Utilities: bulk-import-scanner for folder discovery and ffprobe metadata extraction; shared types for scanned books/events. - UI: Added Bulk Import quick action to admin dashboard (src/app/admin/page.tsx). Key details: - Audible searches are rate-limited (≈1.5s) and matching results include library/request status checks. - Reuses existing organize_files job queue and manual-import pipeline; no new database tables introduced (state is ephemeral during the wizard). - Includes error handling, path normalization, and security checks for allowed directories. This commit wires frontend, backend, and docs together to provide an admin-only multi-step bulk import workflow.
This commit is contained in:
@@ -0,0 +1,348 @@
|
||||
/**
|
||||
* 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<WizardStep, string> = {
|
||||
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<WizardStep>('select_folder');
|
||||
const [selectedRootPath, setSelectedRootPath] = useState<string | null>(null);
|
||||
|
||||
// Scanning state
|
||||
const [scanProgress, setScanProgress] = useState<ScanProgressEvent | null>(null);
|
||||
const [matchingProgress, setMatchingProgress] = useState<MatchingProgressEvent | null>(null);
|
||||
const [scanPhase, setScanPhase] = useState<'discovering' | 'matching' | 'idle'>('idle');
|
||||
const abortRef = useRef<AbortController | null>(null);
|
||||
|
||||
// Results state
|
||||
const [scannedBooks, setScannedBooks] = useState<ScannedBook[]>([]);
|
||||
const [scanError, setScanError] = useState<string | null>(null);
|
||||
|
||||
// Import state
|
||||
const [isImporting, setIsImporting] = useState(false);
|
||||
const [importResults, setImportResults] = useState<any>(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,
|
||||
})),
|
||||
}),
|
||||
});
|
||||
|
||||
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 = (
|
||||
<div
|
||||
className="fixed inset-0 z-[60] flex items-center justify-center bg-black/60 backdrop-blur-sm"
|
||||
style={{ height: '100dvh' }}
|
||||
onClick={handleClose}
|
||||
>
|
||||
<div
|
||||
className="relative w-full max-w-4xl bg-white dark:bg-gray-900 rounded-2xl shadow-2xl overflow-hidden flex flex-col"
|
||||
style={{ height: 'min(720px, 90vh)' }}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between px-5 py-4 border-b border-gray-200 dark:border-gray-700/50">
|
||||
<div className="flex items-center gap-2.5">
|
||||
<FolderArrowDownIcon className="w-5 h-5 text-blue-600 dark:text-blue-400" />
|
||||
<h2 className="text-lg font-semibold text-gray-900 dark:text-gray-100">
|
||||
Bulk Import
|
||||
</h2>
|
||||
</div>
|
||||
<button
|
||||
onClick={handleClose}
|
||||
className="p-1.5 rounded-lg hover:bg-gray-100 dark:hover:bg-gray-800 transition-colors"
|
||||
>
|
||||
<XMarkIcon className="w-5 h-5 text-gray-500 dark:text-gray-400" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Step Indicator */}
|
||||
<div className="flex items-center justify-center gap-2 px-5 py-3 bg-gray-50 dark:bg-gray-800/50 border-b border-gray-200 dark:border-gray-700/50">
|
||||
{STEP_ORDER.map((s, i) => (
|
||||
<React.Fragment key={s}>
|
||||
{i > 0 && (
|
||||
<div
|
||||
className={`w-8 h-px ${
|
||||
i <= currentStepIndex
|
||||
? 'bg-blue-400 dark:bg-blue-500'
|
||||
: 'bg-gray-300 dark:bg-gray-600'
|
||||
}`}
|
||||
/>
|
||||
)}
|
||||
<div className="flex items-center gap-1.5">
|
||||
<div
|
||||
className={`w-6 h-6 rounded-full flex items-center justify-center text-xs font-medium ${
|
||||
i < currentStepIndex
|
||||
? 'bg-blue-600 text-white'
|
||||
: i === currentStepIndex
|
||||
? 'bg-blue-600 text-white ring-2 ring-blue-200 dark:ring-blue-800'
|
||||
: 'bg-gray-200 dark:bg-gray-700 text-gray-500 dark:text-gray-400'
|
||||
}`}
|
||||
>
|
||||
{i < currentStepIndex ? (
|
||||
<svg className="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2.5} d="M5 13l4 4L19 7" />
|
||||
</svg>
|
||||
) : (
|
||||
i + 1
|
||||
)}
|
||||
</div>
|
||||
<span
|
||||
className={`text-xs font-medium hidden sm:inline ${
|
||||
i <= currentStepIndex
|
||||
? 'text-gray-900 dark:text-gray-100'
|
||||
: 'text-gray-400 dark:text-gray-500'
|
||||
}`}
|
||||
>
|
||||
{STEP_LABELS[s]}
|
||||
</span>
|
||||
</div>
|
||||
</React.Fragment>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div className="flex-1 min-h-0 overflow-hidden">
|
||||
{step === 'select_folder' && (
|
||||
<ScanFolderStep onFolderSelected={handleFolderSelected} />
|
||||
)}
|
||||
|
||||
{step === 'scanning' && (
|
||||
<ScanProgressStep
|
||||
scanProgress={scanProgress}
|
||||
matchingProgress={matchingProgress}
|
||||
scanPhase={scanPhase}
|
||||
error={scanError}
|
||||
booksFound={scannedBooks.length}
|
||||
onCancel={handleCancelScan}
|
||||
onRetry={() => selectedRootPath && handleFolderSelected(selectedRootPath)}
|
||||
onBack={handleBackToFolderSelect}
|
||||
/>
|
||||
)}
|
||||
|
||||
{step === 'review' && (
|
||||
<MatchReviewStep
|
||||
books={scannedBooks}
|
||||
onToggleSkip={handleToggleSkip}
|
||||
onStartImport={handleStartImport}
|
||||
isImporting={isImporting}
|
||||
importResults={importResults}
|
||||
onClose={handleClose}
|
||||
onBack={handleBackToFolderSelect}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
return createPortal(modalContent, document.body);
|
||||
}
|
||||
@@ -0,0 +1,344 @@
|
||||
/**
|
||||
* Component: Bulk Import - Match Review Step
|
||||
* Documentation: documentation/features/bulk-import.md
|
||||
*
|
||||
* Scrollable list of discovered audiobooks with Audible matches,
|
||||
* skip toggles, library status badges, and import controls.
|
||||
*/
|
||||
|
||||
'use client';
|
||||
|
||||
import React from 'react';
|
||||
import {
|
||||
ArrowLeftIcon,
|
||||
CheckCircleIcon,
|
||||
ExclamationTriangleIcon,
|
||||
MusicalNoteIcon,
|
||||
XCircleIcon,
|
||||
} from '@heroicons/react/24/outline';
|
||||
import { CheckCircleIcon as CheckCircleSolid } from '@heroicons/react/24/solid';
|
||||
import { ScannedBook, formatBytes } from './types';
|
||||
|
||||
interface MatchReviewStepProps {
|
||||
books: ScannedBook[];
|
||||
onToggleSkip: (index: number) => void;
|
||||
onStartImport: () => void;
|
||||
isImporting: boolean;
|
||||
importResults: any;
|
||||
onClose: () => void;
|
||||
onBack: () => void;
|
||||
}
|
||||
|
||||
function BookRow({
|
||||
book,
|
||||
onToggleSkip,
|
||||
}: {
|
||||
book: ScannedBook;
|
||||
onToggleSkip: () => void;
|
||||
}) {
|
||||
const isDisabled = book.inLibrary || book.hasActiveRequest;
|
||||
const isSkipped = book.skipped;
|
||||
const hasMatch = book.match !== null;
|
||||
const isLowConfidence = book.metadataSource === 'file_name';
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`flex items-center gap-3 px-4 py-3 transition-opacity ${
|
||||
isSkipped ? 'opacity-40' : ''
|
||||
}`}
|
||||
>
|
||||
{/* Cover Art */}
|
||||
<div className="flex-shrink-0 w-12 h-12 rounded-lg overflow-hidden bg-gray-100 dark:bg-gray-800">
|
||||
{hasMatch && book.match!.coverArtUrl ? (
|
||||
/* eslint-disable-next-line @next/next/no-img-element */
|
||||
<img
|
||||
src={book.match!.coverArtUrl}
|
||||
alt={book.match!.title}
|
||||
className="w-12 h-12 object-cover"
|
||||
onError={(e) => {
|
||||
(e.target as HTMLImageElement).src = '/placeholder_cover.svg';
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<div className="w-12 h-12 flex items-center justify-center">
|
||||
<MusicalNoteIcon className="w-6 h-6 text-gray-400 dark:text-gray-600" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Book Info */}
|
||||
<div className="flex-1 min-w-0">
|
||||
{hasMatch ? (
|
||||
<>
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<p className="text-sm font-semibold text-gray-900 dark:text-gray-100 truncate">
|
||||
{book.match!.title}
|
||||
</p>
|
||||
{isLowConfidence && (
|
||||
<span className="inline-flex items-center px-1.5 py-0.5 rounded text-[10px] font-medium bg-amber-100 dark:bg-amber-900/30 text-amber-700 dark:text-amber-300 flex-shrink-0">
|
||||
Low Confidence
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<p className="text-xs text-gray-600 dark:text-gray-400 truncate">
|
||||
{book.match!.author}
|
||||
{book.match!.narrator && (
|
||||
<span className="text-gray-400 dark:text-gray-500">
|
||||
{' '}· {book.match!.narrator}
|
||||
</span>
|
||||
)}
|
||||
</p>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<div className="flex items-center gap-2">
|
||||
<p className="text-sm font-semibold text-gray-900 dark:text-gray-100 truncate">
|
||||
{book.folderName}
|
||||
</p>
|
||||
<span className="inline-flex items-center px-1.5 py-0.5 rounded text-[10px] font-medium bg-red-100 dark:bg-red-900/30 text-red-700 dark:text-red-300 flex-shrink-0">
|
||||
No Match
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-xs text-gray-500 dark:text-gray-400 italic">
|
||||
Could not find this title on Audible
|
||||
</p>
|
||||
</>
|
||||
)}
|
||||
<p className="text-[11px] text-gray-400 dark:text-gray-500 font-mono truncate mt-0.5">
|
||||
{book.relativePath}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Badges */}
|
||||
<div className="flex items-center gap-2 flex-shrink-0">
|
||||
{/* Audio file count */}
|
||||
<span className="inline-flex items-center gap-1 px-2 py-0.5 rounded-full bg-blue-50 dark:bg-blue-900/20 text-blue-700 dark:text-blue-300 text-xs font-medium">
|
||||
<MusicalNoteIcon className="w-3 h-3" />
|
||||
{book.audioFileCount}
|
||||
</span>
|
||||
|
||||
{/* Status badges */}
|
||||
{book.inLibrary && (
|
||||
<span className="inline-flex items-center gap-1 px-2 py-0.5 rounded-full bg-green-100 dark:bg-green-900/30 text-green-700 dark:text-green-300 text-xs font-medium">
|
||||
<CheckCircleSolid className="w-3 h-3" />
|
||||
In Library
|
||||
</span>
|
||||
)}
|
||||
{book.hasActiveRequest && !book.inLibrary && (
|
||||
<span className="inline-flex items-center gap-1 px-2 py-0.5 rounded-full bg-purple-100 dark:bg-purple-900/30 text-purple-700 dark:text-purple-300 text-xs font-medium">
|
||||
Requested
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Skip Toggle */}
|
||||
<button
|
||||
onClick={onToggleSkip}
|
||||
disabled={isDisabled}
|
||||
className={`flex-shrink-0 relative inline-flex h-6 w-11 items-center rounded-full transition-colors focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2 dark:focus:ring-offset-gray-900 ${
|
||||
isDisabled
|
||||
? 'cursor-not-allowed opacity-50'
|
||||
: 'cursor-pointer'
|
||||
} ${
|
||||
isSkipped
|
||||
? 'bg-gray-200 dark:bg-gray-700'
|
||||
: 'bg-blue-600'
|
||||
}`}
|
||||
title={
|
||||
isDisabled
|
||||
? book.inLibrary
|
||||
? 'Already in your library'
|
||||
: 'Already requested'
|
||||
: isSkipped
|
||||
? 'Click to include in import'
|
||||
: 'Click to skip this book'
|
||||
}
|
||||
>
|
||||
<span
|
||||
className={`inline-block h-4 w-4 transform rounded-full bg-white shadow-sm transition-transform ${
|
||||
isSkipped ? 'translate-x-1' : 'translate-x-6'
|
||||
}`}
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function MatchReviewStep({
|
||||
books,
|
||||
onToggleSkip,
|
||||
onStartImport,
|
||||
isImporting,
|
||||
importResults,
|
||||
onClose,
|
||||
onBack,
|
||||
}: MatchReviewStepProps) {
|
||||
const toImport = books.filter((b) => !b.skipped && b.match !== null);
|
||||
const skippedCount = books.filter((b) => b.skipped).length;
|
||||
const inLibraryCount = books.filter((b) => b.inLibrary).length;
|
||||
const noMatchCount = books.filter((b) => b.match === null).length;
|
||||
const matchedCount = books.filter((b) => b.match !== null).length;
|
||||
|
||||
// Import completed state
|
||||
if (importResults) {
|
||||
const succeeded = importResults.summary?.succeeded || 0;
|
||||
const failed = importResults.summary?.failed || 0;
|
||||
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center h-full px-6 py-16">
|
||||
{importResults.success !== false ? (
|
||||
<>
|
||||
<CheckCircleSolid className="w-14 h-14 text-green-500 mb-4" />
|
||||
<h3 className="text-lg font-semibold text-gray-900 dark:text-gray-100 mb-2">
|
||||
Import Started
|
||||
</h3>
|
||||
<p className="text-sm text-gray-600 dark:text-gray-400 text-center mb-2">
|
||||
{succeeded} audiobook{succeeded !== 1 ? 's' : ''} queued for import.
|
||||
</p>
|
||||
{failed > 0 && (
|
||||
<p className="text-sm text-amber-600 dark:text-amber-400 text-center mb-2">
|
||||
{failed} book{failed !== 1 ? 's' : ''} could not be queued.
|
||||
</p>
|
||||
)}
|
||||
<p className="text-xs text-gray-500 dark:text-gray-400 text-center max-w-sm">
|
||||
Files will be organized, tagged, and imported into your library. Check the admin
|
||||
dashboard for progress.
|
||||
</p>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="mt-6 px-6 py-2.5 bg-blue-600 hover:bg-blue-700 text-white text-sm font-medium rounded-xl transition-colors"
|
||||
>
|
||||
Done
|
||||
</button>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<XCircleIcon className="w-14 h-14 text-red-500 mb-4" />
|
||||
<h3 className="text-lg font-semibold text-gray-900 dark:text-gray-100 mb-2">
|
||||
Import Failed
|
||||
</h3>
|
||||
<p className="text-sm text-gray-600 dark:text-gray-400 text-center mb-6">
|
||||
{importResults.error || 'An unexpected error occurred'}
|
||||
</p>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="px-6 py-2.5 bg-gray-100 dark:bg-gray-800 hover:bg-gray-200 dark:hover:bg-gray-700 text-gray-900 dark:text-gray-100 text-sm font-medium rounded-xl transition-colors"
|
||||
>
|
||||
Close
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Empty state (no audiobooks found)
|
||||
if (books.length === 0) {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center h-full px-6 py-16">
|
||||
<ExclamationTriangleIcon className="w-12 h-12 text-gray-300 dark:text-gray-600 mb-4" />
|
||||
<h3 className="text-lg font-semibold text-gray-900 dark:text-gray-100 mb-2">
|
||||
No Audiobooks Found
|
||||
</h3>
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400 text-center max-w-sm mb-6">
|
||||
The selected folder does not contain any folders with audio files. Try selecting a
|
||||
different folder.
|
||||
</p>
|
||||
<button
|
||||
onClick={onBack}
|
||||
className="flex items-center gap-2 px-4 py-2 text-sm font-medium text-gray-700 dark:text-gray-300 bg-gray-100 dark:bg-gray-800 hover:bg-gray-200 dark:hover:bg-gray-700 rounded-xl transition-colors"
|
||||
>
|
||||
<ArrowLeftIcon className="w-4 h-4" />
|
||||
Select Different Folder
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-full">
|
||||
{/* Summary header */}
|
||||
<div className="px-5 py-3 bg-gray-50 dark:bg-gray-800/50 border-b border-gray-200 dark:border-gray-700/50">
|
||||
<div className="flex items-center gap-4 text-xs">
|
||||
<span className="text-gray-500 dark:text-gray-400">
|
||||
<span className="font-semibold text-gray-900 dark:text-gray-100">{books.length}</span> discovered
|
||||
</span>
|
||||
<span className="text-gray-300 dark:text-gray-600">·</span>
|
||||
<span className="text-gray-500 dark:text-gray-400">
|
||||
<span className="font-semibold text-blue-600 dark:text-blue-400">{matchedCount}</span> matched
|
||||
</span>
|
||||
{noMatchCount > 0 && (
|
||||
<>
|
||||
<span className="text-gray-300 dark:text-gray-600">·</span>
|
||||
<span className="text-gray-500 dark:text-gray-400">
|
||||
<span className="font-semibold text-red-600 dark:text-red-400">{noMatchCount}</span> unmatched
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
{inLibraryCount > 0 && (
|
||||
<>
|
||||
<span className="text-gray-300 dark:text-gray-600">·</span>
|
||||
<span className="text-gray-500 dark:text-gray-400">
|
||||
<span className="font-semibold text-green-600 dark:text-green-400">{inLibraryCount}</span> in library
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Scrollable book list */}
|
||||
<div className="flex-1 overflow-y-auto divide-y divide-gray-100 dark:divide-gray-800">
|
||||
{books.map((book) => (
|
||||
<BookRow
|
||||
key={book.index}
|
||||
book={book}
|
||||
onToggleSkip={() => onToggleSkip(book.index)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Import footer */}
|
||||
<div className="px-5 py-3.5 border-t border-gray-200 dark:border-gray-700/50 bg-gray-50/50 dark:bg-gray-800/30 flex items-center justify-between gap-4">
|
||||
<button
|
||||
onClick={onBack}
|
||||
className="flex items-center gap-2 px-4 py-2 text-sm font-medium text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-gray-100 transition-colors"
|
||||
>
|
||||
<ArrowLeftIcon className="w-4 h-4" />
|
||||
Back
|
||||
</button>
|
||||
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="text-sm text-gray-600 dark:text-gray-400">
|
||||
<span className="font-semibold text-gray-900 dark:text-gray-100">
|
||||
{toImport.length}
|
||||
</span>{' '}
|
||||
book{toImport.length !== 1 ? 's' : ''} to import
|
||||
{skippedCount > 0 && (
|
||||
<span className="text-gray-400 dark:text-gray-500">
|
||||
{' '}({skippedCount} skipped)
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
|
||||
<button
|
||||
onClick={onStartImport}
|
||||
disabled={toImport.length === 0 || isImporting}
|
||||
className="flex items-center gap-2 px-5 py-2.5 bg-blue-600 hover:bg-blue-700 disabled:bg-blue-400 disabled:cursor-not-allowed text-white text-sm font-medium rounded-xl transition-colors"
|
||||
>
|
||||
{isImporting ? (
|
||||
<>
|
||||
<svg className="animate-spin h-4 w-4" fill="none" viewBox="0 0 24 24">
|
||||
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
|
||||
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z" />
|
||||
</svg>
|
||||
Importing...
|
||||
</>
|
||||
) : (
|
||||
<>Start Import</>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,346 @@
|
||||
/**
|
||||
* Component: Bulk Import - Folder Selection Step
|
||||
* Documentation: documentation/features/bulk-import.md
|
||||
*
|
||||
* Filesystem browser for selecting a root folder to scan for audiobooks.
|
||||
* Adapted from the manual import BrowsePhase patterns.
|
||||
* Any folder is selectable (not just audio-containing folders).
|
||||
*/
|
||||
|
||||
'use client';
|
||||
|
||||
import React, { useState, useEffect, useCallback } from 'react';
|
||||
import {
|
||||
FolderIcon,
|
||||
FolderOpenIcon,
|
||||
FolderArrowDownIcon,
|
||||
InboxArrowDownIcon,
|
||||
HomeIcon,
|
||||
ChevronRightIcon,
|
||||
ArrowLeftIcon,
|
||||
MusicalNoteIcon,
|
||||
ExclamationTriangleIcon,
|
||||
ArrowPathIcon,
|
||||
MagnifyingGlassIcon,
|
||||
} from '@heroicons/react/24/outline';
|
||||
import { fetchWithAuth } from '@/lib/utils/api';
|
||||
import { RootEntry, DirectoryEntry, formatBytes } from './types';
|
||||
|
||||
function SkeletonRow() {
|
||||
return (
|
||||
<div className="flex items-center gap-3 px-4 py-3 animate-pulse">
|
||||
<div className="w-5 h-5 bg-gray-200 dark:bg-gray-700 rounded" />
|
||||
<div className="flex-1 space-y-1.5">
|
||||
<div className="h-4 bg-gray-200 dark:bg-gray-700 rounded w-48" />
|
||||
<div className="h-3 bg-gray-100 dark:bg-gray-800 rounded w-32" />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface ScanFolderStepProps {
|
||||
onFolderSelected: (rootPath: string) => void;
|
||||
}
|
||||
|
||||
export function ScanFolderStep({ onFolderSelected }: ScanFolderStepProps) {
|
||||
const [roots, setRoots] = useState<RootEntry[]>([]);
|
||||
const [currentPath, setCurrentPath] = useState<string | null>(null);
|
||||
const [entries, setEntries] = useState<DirectoryEntry[]>([]);
|
||||
const [pathHistory, setPathHistory] = useState<string[]>([]);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [hoveredFolder, setHoveredFolder] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
fetchRoots();
|
||||
}, []);
|
||||
|
||||
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);
|
||||
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();
|
||||
setEntries(data.entries || []);
|
||||
setCurrentPath(data.path || dirPath);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Failed to browse directory');
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const navigateInto = (dirPath: string) => {
|
||||
if (currentPath) {
|
||||
setPathHistory((prev) => [...prev, currentPath]);
|
||||
}
|
||||
fetchDirectory(dirPath);
|
||||
};
|
||||
|
||||
const navigateBack = () => {
|
||||
if (pathHistory.length > 0) {
|
||||
const prevPath = pathHistory[pathHistory.length - 1];
|
||||
setPathHistory((prev) => prev.slice(0, -1));
|
||||
fetchDirectory(prevPath);
|
||||
} else {
|
||||
setCurrentPath(null);
|
||||
setEntries([]);
|
||||
}
|
||||
};
|
||||
|
||||
const navigateToRoot = () => {
|
||||
setCurrentPath(null);
|
||||
setEntries([]);
|
||||
setPathHistory([]);
|
||||
};
|
||||
|
||||
const navigateToBreadcrumb = (index: number) => {
|
||||
if (!currentPath) return;
|
||||
const allPaths = [...pathHistory, currentPath];
|
||||
const targetPath = allPaths[index];
|
||||
if (targetPath) {
|
||||
setPathHistory(allPaths.slice(0, index));
|
||||
fetchDirectory(targetPath);
|
||||
} else {
|
||||
navigateToRoot();
|
||||
}
|
||||
};
|
||||
|
||||
// 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 },
|
||||
];
|
||||
})();
|
||||
|
||||
// Count total audio files and subfolders in current listing
|
||||
const totalSubfolders = entries.reduce((sum, e) => sum + e.subfolderCount, 0);
|
||||
const totalAudioInChildren = entries.reduce((sum, e) => sum + e.audioFileCount, 0);
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-full">
|
||||
{/* Breadcrumb bar */}
|
||||
{currentPath && (
|
||||
<div className="flex items-center gap-1 px-5 py-2.5 bg-gray-50 dark:bg-gray-800/50 border-b border-gray-100 dark:border-gray-800 text-sm overflow-x-auto">
|
||||
<button
|
||||
onClick={navigateToRoot}
|
||||
className="flex-shrink-0 p-1 rounded hover:bg-gray-200 dark:hover:bg-gray-700 transition-colors"
|
||||
>
|
||||
<HomeIcon className="w-4 h-4 text-gray-500 dark:text-gray-400" />
|
||||
</button>
|
||||
{visibleBreadcrumbs.map((crumb, i) => (
|
||||
<React.Fragment key={i}>
|
||||
<ChevronRightIcon className="w-3.5 h-3.5 text-gray-400 flex-shrink-0" />
|
||||
{crumb.index === -1 ? (
|
||||
<span className="text-gray-400 px-1">...</span>
|
||||
) : i === visibleBreadcrumbs.length - 1 ? (
|
||||
<span className="font-medium text-gray-900 dark:text-gray-100 truncate">
|
||||
{crumb.label}
|
||||
</span>
|
||||
) : (
|
||||
<button
|
||||
onClick={() => navigateToBreadcrumb(crumb.index)}
|
||||
className="text-gray-600 dark:text-gray-300 hover:text-blue-600 dark:hover:text-blue-400 truncate transition-colors"
|
||||
>
|
||||
{crumb.label}
|
||||
</button>
|
||||
)}
|
||||
</React.Fragment>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Listing */}
|
||||
<div className="flex-1 overflow-y-auto">
|
||||
{/* Loading */}
|
||||
{isLoading && (
|
||||
<div className="py-2">
|
||||
{[...Array(5)].map((_, i) => (
|
||||
<SkeletonRow key={i} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Error */}
|
||||
{error && !isLoading && (
|
||||
<div className="flex flex-col items-center justify-center py-16 px-6">
|
||||
<ExclamationTriangleIcon className="w-10 h-10 text-red-400 mb-3" />
|
||||
<p className="text-gray-900 dark:text-gray-100 font-medium text-center">{error}</p>
|
||||
<button
|
||||
onClick={currentPath ? () => fetchDirectory(currentPath) : fetchRoots}
|
||||
className="mt-4 flex items-center gap-2 px-4 py-2 text-sm font-medium text-blue-600 dark:text-blue-400 hover:bg-blue-50 dark:hover:bg-blue-900/20 rounded-lg transition-colors"
|
||||
>
|
||||
<ArrowPathIcon className="w-4 h-4" />
|
||||
Try Again
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Root view */}
|
||||
{!currentPath && !isLoading && !error && (
|
||||
<div className="p-5">
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400 mb-4">
|
||||
Select a folder to scan for audiobooks. All subfolders will be searched recursively.
|
||||
</p>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
{roots.map((root) => (
|
||||
<button
|
||||
key={root.path}
|
||||
onClick={() => navigateInto(root.path)}
|
||||
className="flex flex-col items-center gap-3 p-6 rounded-xl border border-gray-200 dark:border-gray-700 hover:border-blue-300 dark:hover:border-blue-700 hover:bg-blue-50/50 dark:hover:bg-blue-900/10 transition-all group"
|
||||
>
|
||||
{root.icon === 'download' ? (
|
||||
<FolderArrowDownIcon className="w-10 h-10 text-blue-500 group-hover:text-blue-600 transition-colors" />
|
||||
) : root.icon === 'bookdrop' ? (
|
||||
<InboxArrowDownIcon className="w-10 h-10 text-amber-500 group-hover:text-amber-600 transition-colors" />
|
||||
) : (
|
||||
<FolderIcon className="w-10 h-10 text-emerald-500 group-hover:text-emerald-600 transition-colors" />
|
||||
)}
|
||||
<span className="text-sm font-medium text-gray-900 dark:text-gray-100">
|
||||
{root.name}
|
||||
</span>
|
||||
<span className="text-xs text-gray-500 dark:text-gray-400 font-mono truncate max-w-full">
|
||||
{root.path}
|
||||
</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Directory listing */}
|
||||
{currentPath && !isLoading && !error && entries.length > 0 && (
|
||||
<div className="divide-y divide-gray-100 dark:divide-gray-800">
|
||||
{entries.map((entry) => {
|
||||
const hasAudio = entry.audioFileCount > 0;
|
||||
const isHovered = hoveredFolder === entry.name;
|
||||
|
||||
return (
|
||||
<button
|
||||
key={`dir-${entry.name}`}
|
||||
onClick={() => navigateInto(currentPath + '/' + entry.name)}
|
||||
onMouseEnter={() => setHoveredFolder(entry.name)}
|
||||
onMouseLeave={() => setHoveredFolder(null)}
|
||||
className="w-full flex items-center gap-3 px-4 py-3 text-left transition-all duration-150 hover:bg-gray-50 dark:hover:bg-gray-800/50"
|
||||
>
|
||||
<div className="flex-shrink-0 w-5 h-5 text-gray-400 dark:text-gray-500 transition-all duration-150">
|
||||
{isHovered ? (
|
||||
<FolderOpenIcon className="w-5 h-5 text-blue-500" />
|
||||
) : (
|
||||
<FolderIcon className="w-5 h-5" />
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-sm font-medium text-gray-900 dark:text-gray-100 truncate">
|
||||
{entry.name}
|
||||
</p>
|
||||
<p className="text-xs text-gray-500 dark:text-gray-400">
|
||||
{entry.subfolderCount > 0 && (
|
||||
<span>{entry.subfolderCount} folder{entry.subfolderCount !== 1 ? 's' : ''}</span>
|
||||
)}
|
||||
{entry.subfolderCount > 0 && entry.audioFileCount > 0 && <span> · </span>}
|
||||
{entry.audioFileCount > 0 && (
|
||||
<span>{entry.audioFileCount} audio file{entry.audioFileCount !== 1 ? 's' : ''}</span>
|
||||
)}
|
||||
{entry.totalSize > 0 && (
|
||||
<span> · {formatBytes(entry.totalSize)}</span>
|
||||
)}
|
||||
{entry.subfolderCount === 0 && entry.audioFileCount === 0 && (
|
||||
<span className="italic">Empty</span>
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{hasAudio && (
|
||||
<span className="flex-shrink-0 inline-flex items-center gap-1 px-2 py-0.5 rounded-full bg-blue-100 dark:bg-blue-900/30 text-blue-700 dark:text-blue-300 text-xs font-medium">
|
||||
<MusicalNoteIcon className="w-3 h-3" />
|
||||
{entry.audioFileCount}
|
||||
</span>
|
||||
)}
|
||||
|
||||
<ChevronRightIcon className="w-4 h-4 text-gray-300 dark:text-gray-600 flex-shrink-0" />
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Empty state */}
|
||||
{currentPath && !isLoading && !error && entries.length === 0 && (
|
||||
<div className="flex flex-col items-center justify-center py-16 px-6 text-center">
|
||||
<FolderOpenIcon className="w-10 h-10 text-gray-300 dark:text-gray-600 mb-3" />
|
||||
<p className="text-gray-500 dark:text-gray-400 font-medium">This folder is empty</p>
|
||||
<button
|
||||
onClick={navigateBack}
|
||||
className="mt-4 flex items-center gap-2 text-sm text-blue-600 dark:text-blue-400 hover:underline"
|
||||
>
|
||||
<ArrowLeftIcon className="w-4 h-4" />
|
||||
Go back
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Footer: Scan this folder */}
|
||||
{currentPath && !isLoading && (
|
||||
<div className="px-5 py-3.5 border-t border-gray-200 dark:border-gray-700/50 bg-gray-50/50 dark:bg-gray-800/30 flex items-center justify-between gap-4">
|
||||
<div className="text-sm text-gray-600 dark:text-gray-400 min-w-0">
|
||||
<p className="font-mono text-xs text-gray-500 dark:text-gray-500 truncate">{currentPath}</p>
|
||||
{entries.length > 0 && (
|
||||
<p className="mt-0.5">
|
||||
{entries.length} subfolder{entries.length !== 1 ? 's' : ''}
|
||||
{totalAudioInChildren > 0 && (
|
||||
<span> · {totalAudioInChildren} audio files visible</span>
|
||||
)}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<button
|
||||
onClick={() => onFolderSelected(currentPath)}
|
||||
className="flex-shrink-0 flex items-center gap-2 px-4 py-2 bg-blue-600 hover:bg-blue-700 text-white text-sm font-medium rounded-xl transition-colors"
|
||||
>
|
||||
<MagnifyingGlassIcon className="w-4 h-4" />
|
||||
Scan for Audiobooks
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,179 @@
|
||||
/**
|
||||
* Component: Bulk Import - Scan Progress Step
|
||||
* Documentation: documentation/features/bulk-import.md
|
||||
*
|
||||
* Displays progress during folder discovery and Audible matching phases.
|
||||
* Shows animated indicators, counts, and cancel/retry controls.
|
||||
*/
|
||||
|
||||
'use client';
|
||||
|
||||
import React from 'react';
|
||||
import {
|
||||
FolderIcon,
|
||||
ExclamationTriangleIcon,
|
||||
ArrowPathIcon,
|
||||
ArrowLeftIcon,
|
||||
XMarkIcon,
|
||||
} from '@heroicons/react/24/outline';
|
||||
import { ScanProgressEvent, MatchingProgressEvent } from './types';
|
||||
|
||||
interface ScanProgressStepProps {
|
||||
scanProgress: ScanProgressEvent | null;
|
||||
matchingProgress: MatchingProgressEvent | null;
|
||||
scanPhase: 'discovering' | 'matching' | 'idle';
|
||||
error: string | null;
|
||||
booksFound: number;
|
||||
onCancel: () => void;
|
||||
onRetry: () => void;
|
||||
onBack: () => void;
|
||||
}
|
||||
|
||||
export function ScanProgressStep({
|
||||
scanProgress,
|
||||
matchingProgress,
|
||||
scanPhase,
|
||||
error,
|
||||
booksFound,
|
||||
onCancel,
|
||||
onRetry,
|
||||
onBack,
|
||||
}: ScanProgressStepProps) {
|
||||
// Error state
|
||||
if (error) {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center h-full px-6 py-16">
|
||||
<ExclamationTriangleIcon className="w-12 h-12 text-red-400 mb-4" />
|
||||
<h3 className="text-lg font-semibold text-gray-900 dark:text-gray-100 mb-2">
|
||||
Scan Failed
|
||||
</h3>
|
||||
<p className="text-sm text-gray-600 dark:text-gray-400 text-center max-w-md mb-6">
|
||||
{error}
|
||||
</p>
|
||||
<div className="flex items-center gap-3">
|
||||
<button
|
||||
onClick={onBack}
|
||||
className="flex items-center gap-2 px-4 py-2 text-sm font-medium text-gray-700 dark:text-gray-300 bg-gray-100 dark:bg-gray-800 hover:bg-gray-200 dark:hover:bg-gray-700 rounded-xl transition-colors"
|
||||
>
|
||||
<ArrowLeftIcon className="w-4 h-4" />
|
||||
Go Back
|
||||
</button>
|
||||
<button
|
||||
onClick={onRetry}
|
||||
className="flex items-center gap-2 px-4 py-2 text-sm font-medium text-white bg-blue-600 hover:bg-blue-700 rounded-xl transition-colors"
|
||||
>
|
||||
<ArrowPathIcon className="w-4 h-4" />
|
||||
Retry Scan
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const matchPercent = matchingProgress
|
||||
? Math.round((matchingProgress.current / matchingProgress.total) * 100)
|
||||
: 0;
|
||||
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center h-full px-6 py-16">
|
||||
{/* Animated icon */}
|
||||
<div className="relative mb-6">
|
||||
<div className="w-16 h-16 rounded-full border-4 border-blue-200 dark:border-blue-800 flex items-center justify-center">
|
||||
<FolderIcon className="w-8 h-8 text-blue-600 dark:text-blue-400" />
|
||||
</div>
|
||||
<div className="absolute inset-0 w-16 h-16 rounded-full border-4 border-transparent border-t-blue-600 dark:border-t-blue-400 animate-spin" />
|
||||
</div>
|
||||
|
||||
{/* Phase-specific content */}
|
||||
{scanPhase === 'discovering' && (
|
||||
<>
|
||||
<h3 className="text-lg font-semibold text-gray-900 dark:text-gray-100 mb-2">
|
||||
Scanning Folders
|
||||
</h3>
|
||||
<p className="text-sm text-gray-600 dark:text-gray-400 text-center mb-4">
|
||||
Searching for folders containing audiobook files...
|
||||
</p>
|
||||
|
||||
{scanProgress && (
|
||||
<div className="flex items-center gap-6 text-sm">
|
||||
<div className="text-center">
|
||||
<div className="text-2xl font-bold text-gray-900 dark:text-gray-100">
|
||||
{scanProgress.foldersScanned}
|
||||
</div>
|
||||
<div className="text-xs text-gray-500 dark:text-gray-400">
|
||||
Folders Scanned
|
||||
</div>
|
||||
</div>
|
||||
<div className="w-px h-8 bg-gray-200 dark:bg-gray-700" />
|
||||
<div className="text-center">
|
||||
<div className="text-2xl font-bold text-blue-600 dark:text-blue-400">
|
||||
{scanProgress.audiobooksFound}
|
||||
</div>
|
||||
<div className="text-xs text-gray-500 dark:text-gray-400">
|
||||
Audiobooks Found
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{scanProgress?.currentFolder && (
|
||||
<p className="mt-4 text-xs text-gray-400 dark:text-gray-500 font-mono truncate max-w-md">
|
||||
{scanProgress.currentFolder}
|
||||
</p>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
{scanPhase === 'matching' && (
|
||||
<>
|
||||
<h3 className="text-lg font-semibold text-gray-900 dark:text-gray-100 mb-2">
|
||||
Matching Against Audible
|
||||
</h3>
|
||||
<p className="text-sm text-gray-600 dark:text-gray-400 text-center mb-6">
|
||||
Searching Audible for each discovered audiobook...
|
||||
</p>
|
||||
|
||||
{matchingProgress && (
|
||||
<>
|
||||
{/* Progress bar */}
|
||||
<div className="w-full max-w-sm mb-3">
|
||||
<div className="h-2 bg-gray-200 dark:bg-gray-700 rounded-full overflow-hidden">
|
||||
<div
|
||||
className="h-full bg-blue-600 dark:bg-blue-500 rounded-full transition-all duration-500"
|
||||
style={{ width: `${matchPercent}%` }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="text-sm text-gray-700 dark:text-gray-300 font-medium">
|
||||
{matchingProgress.current} / {matchingProgress.total}
|
||||
</div>
|
||||
|
||||
{matchingProgress.folderName && (
|
||||
<p className="mt-2 text-xs text-gray-400 dark:text-gray-500 truncate max-w-md">
|
||||
{matchingProgress.folderName}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{/* Books matched so far count */}
|
||||
{booksFound > 0 && (
|
||||
<p className="mt-4 text-xs text-gray-500 dark:text-gray-400">
|
||||
{booksFound} book{booksFound !== 1 ? 's' : ''} matched so far
|
||||
</p>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Cancel button */}
|
||||
<button
|
||||
onClick={onCancel}
|
||||
className="mt-8 flex items-center gap-2 px-4 py-2 text-sm font-medium text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-gray-100 bg-gray-100 dark:bg-gray-800 hover:bg-gray-200 dark:hover:bg-gray-700 rounded-xl transition-colors"
|
||||
>
|
||||
<XMarkIcon className="w-4 h-4" />
|
||||
Cancel Scan
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
/**
|
||||
* Component: Bulk Import Shared Types
|
||||
* Documentation: documentation/features/bulk-import.md
|
||||
*/
|
||||
|
||||
/** Root directory entry from the filesystem browse API. */
|
||||
export interface RootEntry {
|
||||
name: string;
|
||||
path: string;
|
||||
icon: string;
|
||||
}
|
||||
|
||||
/** Directory entry from the filesystem browse API. */
|
||||
export interface DirectoryEntry {
|
||||
name: string;
|
||||
type: 'directory';
|
||||
audioFileCount: number;
|
||||
subfolderCount: number;
|
||||
totalSize: number;
|
||||
}
|
||||
|
||||
/** Audible match data for a discovered audiobook. */
|
||||
export interface AudibleMatch {
|
||||
asin: string;
|
||||
title: string;
|
||||
author: string;
|
||||
narrator?: string;
|
||||
coverArtUrl?: string;
|
||||
durationMinutes?: number;
|
||||
}
|
||||
|
||||
/** A scanned audiobook result with its Audible match status. */
|
||||
export interface ScannedBook {
|
||||
index: number;
|
||||
folderPath: string;
|
||||
folderName: string;
|
||||
relativePath: string;
|
||||
audioFileCount: number;
|
||||
totalSizeBytes: number;
|
||||
metadataSource: 'tags' | 'file_name';
|
||||
searchTerm: string;
|
||||
match: AudibleMatch | null;
|
||||
inLibrary: boolean;
|
||||
hasActiveRequest: boolean;
|
||||
/** User toggle: true = skip this book during import. */
|
||||
skipped: boolean;
|
||||
}
|
||||
|
||||
/** Progress event from the SSE scan stream. */
|
||||
export interface ScanProgressEvent {
|
||||
phase: 'discovering' | 'reading_metadata';
|
||||
foldersScanned: number;
|
||||
audiobooksFound: number;
|
||||
currentFolder?: string;
|
||||
}
|
||||
|
||||
/** Matching progress event from the SSE scan stream. */
|
||||
export interface MatchingProgressEvent {
|
||||
current: number;
|
||||
total: number;
|
||||
folderName: string;
|
||||
searchTerm: string;
|
||||
}
|
||||
|
||||
/** Discovery complete event from the SSE scan stream. */
|
||||
export interface DiscoveryCompleteEvent {
|
||||
totalFound: number;
|
||||
message: string;
|
||||
}
|
||||
|
||||
/** Wizard step identifiers. */
|
||||
export type WizardStep = 'select_folder' | 'scanning' | 'review';
|
||||
|
||||
/** Format bytes into a human-readable string. */
|
||||
export function formatBytes(bytes: number): string {
|
||||
if (bytes === 0) return '0 B';
|
||||
const k = 1024;
|
||||
const sizes = ['B', 'KB', 'MB', 'GB', 'TB'];
|
||||
const i = Math.floor(Math.log(bytes) / Math.log(k));
|
||||
return `${parseFloat((bytes / Math.pow(k, i)).toFixed(1))} ${sizes[i]}`;
|
||||
}
|
||||
Reference in New Issue
Block a user