PDF & ZIP IMPORT
This commit is contained in:
829
frontend/src/app/import/file/page.tsx
Normal file
829
frontend/src/app/import/file/page.tsx
Normal file
@@ -0,0 +1,829 @@
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { DocumentArrowUpIcon, CheckCircleIcon, XCircleIcon } from '@heroicons/react/24/outline';
|
||||
import Button from '@/components/ui/Button';
|
||||
import { Input } from '@/components/ui/Input';
|
||||
import ImportLayout from '@/components/layout/ImportLayout';
|
||||
import AuthorSelector from '@/components/stories/AuthorSelector';
|
||||
import SeriesSelector from '@/components/stories/SeriesSelector';
|
||||
|
||||
type FileType = 'epub' | 'pdf' | 'zip' | null;
|
||||
|
||||
interface ImportResponse {
|
||||
success: boolean;
|
||||
message: string;
|
||||
storyId?: string;
|
||||
storyTitle?: string;
|
||||
fileName?: string;
|
||||
fileType?: string;
|
||||
wordCount?: number;
|
||||
extractedImages?: number;
|
||||
warnings?: string[];
|
||||
errors?: string[];
|
||||
}
|
||||
|
||||
interface ZIPAnalysisResponse {
|
||||
success: boolean;
|
||||
message: string;
|
||||
zipFileName?: string;
|
||||
totalFiles?: number;
|
||||
validFiles?: number;
|
||||
files?: FileInfo[];
|
||||
warnings?: string[];
|
||||
}
|
||||
|
||||
interface FileInfo {
|
||||
fileName: string;
|
||||
fileType: string;
|
||||
fileSize: number;
|
||||
extractedTitle?: string;
|
||||
extractedAuthor?: string;
|
||||
hasMetadata: boolean;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
interface ZIPImportResponse {
|
||||
success: boolean;
|
||||
message: string;
|
||||
totalFiles: number;
|
||||
successfulImports: number;
|
||||
failedImports: number;
|
||||
results: ImportResponse[];
|
||||
warnings?: string[];
|
||||
}
|
||||
|
||||
export default function FileImportPage() {
|
||||
const router = useRouter();
|
||||
const [selectedFile, setSelectedFile] = useState<File | null>(null);
|
||||
const [fileType, setFileType] = useState<FileType>(null);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [isValidating, setIsValidating] = useState(false);
|
||||
const [validationResult, setValidationResult] = useState<any>(null);
|
||||
const [importResult, setImportResult] = useState<ImportResponse | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
// ZIP-specific state
|
||||
const [zipAnalysis, setZipAnalysis] = useState<ZIPAnalysisResponse | null>(null);
|
||||
const [zipSessionId, setZipSessionId] = useState<string | null>(null);
|
||||
const [selectedZipFiles, setSelectedZipFiles] = useState<Set<string>>(new Set());
|
||||
const [fileMetadata, setFileMetadata] = useState<Map<string, any>>(new Map());
|
||||
const [zipImportResult, setZipImportResult] = useState<ZIPImportResponse | null>(null);
|
||||
|
||||
// Import options
|
||||
const [authorName, setAuthorName] = useState<string>('');
|
||||
const [authorId, setAuthorId] = useState<string | undefined>(undefined);
|
||||
const [seriesName, setSeriesName] = useState<string>('');
|
||||
const [seriesId, setSeriesId] = useState<string | undefined>(undefined);
|
||||
const [seriesVolume, setSeriesVolume] = useState<string>('');
|
||||
const [tags, setTags] = useState<string>('');
|
||||
const [createMissingAuthor, setCreateMissingAuthor] = useState(true);
|
||||
const [createMissingSeries, setCreateMissingSeries] = useState(true);
|
||||
const [extractImages, setExtractImages] = useState(true);
|
||||
const [preserveReadingPosition, setPreserveReadingPosition] = useState(true);
|
||||
|
||||
const detectFileType = (file: File): FileType => {
|
||||
const filename = file.name.toLowerCase();
|
||||
if (filename.endsWith('.epub')) return 'epub';
|
||||
if (filename.endsWith('.pdf')) return 'pdf';
|
||||
if (filename.endsWith('.zip')) return 'zip';
|
||||
return null;
|
||||
};
|
||||
|
||||
const handleFileChange = async (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = e.target.files?.[0];
|
||||
if (file) {
|
||||
setSelectedFile(file);
|
||||
setValidationResult(null);
|
||||
setImportResult(null);
|
||||
setZipAnalysis(null);
|
||||
setZipSessionId(null);
|
||||
setSelectedZipFiles(new Set());
|
||||
setZipImportResult(null);
|
||||
setError(null);
|
||||
|
||||
const detectedType = detectFileType(file);
|
||||
setFileType(detectedType);
|
||||
|
||||
if (!detectedType) {
|
||||
setError('Unsupported file type. Please select an EPUB, PDF, or ZIP file.');
|
||||
return;
|
||||
}
|
||||
|
||||
if (detectedType === 'zip') {
|
||||
await analyzeZipFile(file);
|
||||
} else {
|
||||
await validateFile(file, detectedType);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const validateFile = async (file: File, type: FileType) => {
|
||||
if (type === 'zip') return; // ZIP has its own analysis flow
|
||||
|
||||
setIsValidating(true);
|
||||
try {
|
||||
const token = localStorage.getItem('auth-token');
|
||||
const formData = new FormData();
|
||||
formData.append('file', file);
|
||||
|
||||
const endpoint = type === 'epub' ? '/api/stories/epub/validate' : '/api/stories/pdf/validate';
|
||||
const response = await fetch(endpoint, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Authorization': token ? `Bearer ${token}` : '',
|
||||
},
|
||||
body: formData,
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
const result = await response.json();
|
||||
setValidationResult(result);
|
||||
if (!result.valid) {
|
||||
setError(`${type?.toUpperCase() || 'File'} validation failed: ` + result.errors.join(', '));
|
||||
}
|
||||
} else if (response.status === 401 || response.status === 403) {
|
||||
setError('Authentication required. Please log in.');
|
||||
} else {
|
||||
setError(`Failed to validate ${type?.toUpperCase() || 'file'}`);
|
||||
}
|
||||
} catch (err) {
|
||||
setError(`Error validating ${type?.toUpperCase() || 'file'}: ` + (err as Error).message);
|
||||
} finally {
|
||||
setIsValidating(false);
|
||||
}
|
||||
};
|
||||
|
||||
const analyzeZipFile = async (file: File) => {
|
||||
setIsLoading(true);
|
||||
try {
|
||||
const token = localStorage.getItem('auth-token');
|
||||
const formData = new FormData();
|
||||
formData.append('file', file);
|
||||
|
||||
const response = await fetch('/api/stories/zip/analyze', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Authorization': token ? `Bearer ${token}` : '',
|
||||
},
|
||||
body: formData,
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
const result: ZIPAnalysisResponse = await response.json();
|
||||
setZipAnalysis(result);
|
||||
|
||||
if (result.success && result.warnings && result.warnings.length > 0) {
|
||||
// Extract session ID from warnings
|
||||
const sessionWarning = result.warnings.find(w => w.includes('Session ID:'));
|
||||
if (sessionWarning) {
|
||||
const match = sessionWarning.match(/Session ID: ([a-f0-9-]+)/);
|
||||
if (match) {
|
||||
setZipSessionId(match[1]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!result.success) {
|
||||
setError(result.message);
|
||||
} else if (result.files && result.files.length === 0) {
|
||||
setError('No valid EPUB or PDF files found in ZIP');
|
||||
}
|
||||
} else if (response.status === 401 || response.status === 403) {
|
||||
setError('Authentication required. Please log in.');
|
||||
} else {
|
||||
setError('Failed to analyze ZIP file');
|
||||
}
|
||||
} catch (err) {
|
||||
setError('Error analyzing ZIP file: ' + (err as Error).message);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
|
||||
if (!selectedFile) {
|
||||
setError('Please select a file');
|
||||
return;
|
||||
}
|
||||
|
||||
if (fileType === 'zip') {
|
||||
await handleZipImport();
|
||||
return;
|
||||
}
|
||||
|
||||
if (validationResult && !validationResult.valid) {
|
||||
setError(`Cannot import invalid ${fileType?.toUpperCase()} file`);
|
||||
return;
|
||||
}
|
||||
|
||||
// Check PDF requires author
|
||||
if (fileType === 'pdf' && !authorName.trim()) {
|
||||
setError('PDF import requires an author name. Please provide an author name or ensure the PDF has author metadata.');
|
||||
return;
|
||||
}
|
||||
|
||||
setIsLoading(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
const token = localStorage.getItem('auth-token');
|
||||
const formData = new FormData();
|
||||
formData.append('file', selectedFile);
|
||||
|
||||
if (authorId) {
|
||||
formData.append('authorId', authorId);
|
||||
} else if (authorName) {
|
||||
formData.append('authorName', authorName);
|
||||
}
|
||||
if (seriesId) {
|
||||
formData.append('seriesId', seriesId);
|
||||
} else if (seriesName) {
|
||||
formData.append('seriesName', seriesName);
|
||||
}
|
||||
if (seriesVolume) formData.append('seriesVolume', seriesVolume);
|
||||
if (tags) {
|
||||
const tagList = tags.split(',').map(t => t.trim()).filter(t => t.length > 0);
|
||||
tagList.forEach(tag => formData.append('tags', tag));
|
||||
}
|
||||
|
||||
formData.append('createMissingAuthor', createMissingAuthor.toString());
|
||||
formData.append('createMissingSeries', createMissingSeries.toString());
|
||||
|
||||
if (fileType === 'epub') {
|
||||
formData.append('preserveReadingPosition', preserveReadingPosition.toString());
|
||||
} else if (fileType === 'pdf') {
|
||||
formData.append('extractImages', extractImages.toString());
|
||||
}
|
||||
|
||||
const endpoint = fileType === 'epub' ? '/api/stories/epub/import' : '/api/stories/pdf/import';
|
||||
const response = await fetch(endpoint, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Authorization': token ? `Bearer ${token}` : '',
|
||||
},
|
||||
body: formData,
|
||||
});
|
||||
|
||||
const result = await response.json();
|
||||
|
||||
if (response.ok && result.success) {
|
||||
setImportResult(result);
|
||||
} else if (response.status === 401 || response.status === 403) {
|
||||
setError('Authentication required. Please log in.');
|
||||
} else {
|
||||
setError(result.message || `Failed to import ${fileType?.toUpperCase()}`);
|
||||
}
|
||||
} catch (err) {
|
||||
setError(`Error importing ${fileType?.toUpperCase()}: ` + (err as Error).message);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleZipImport = async () => {
|
||||
if (!zipSessionId) {
|
||||
setError('ZIP session expired. Please re-upload the ZIP file.');
|
||||
return;
|
||||
}
|
||||
|
||||
if (selectedZipFiles.size === 0) {
|
||||
setError('Please select at least one file to import');
|
||||
return;
|
||||
}
|
||||
|
||||
setIsLoading(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
const token = localStorage.getItem('auth-token');
|
||||
|
||||
const requestBody: any = {
|
||||
zipSessionId: zipSessionId,
|
||||
selectedFiles: Array.from(selectedZipFiles),
|
||||
defaultAuthorId: authorId || undefined,
|
||||
defaultAuthorName: authorName || undefined,
|
||||
defaultSeriesId: seriesId || undefined,
|
||||
defaultSeriesName: seriesName || undefined,
|
||||
defaultTags: tags ? tags.split(',').map(t => t.trim()).filter(t => t.length > 0) : undefined,
|
||||
createMissingAuthor,
|
||||
createMissingSeries,
|
||||
extractImages,
|
||||
};
|
||||
|
||||
// Add per-file metadata if any
|
||||
if (fileMetadata.size > 0) {
|
||||
const metadata: any = {};
|
||||
fileMetadata.forEach((value, key) => {
|
||||
metadata[key] = value;
|
||||
});
|
||||
requestBody.fileMetadata = metadata;
|
||||
}
|
||||
|
||||
const response = await fetch('/api/stories/zip/import', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Authorization': token ? `Bearer ${token}` : '',
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify(requestBody),
|
||||
});
|
||||
|
||||
const result: ZIPImportResponse = await response.json();
|
||||
|
||||
if (response.ok) {
|
||||
setZipImportResult(result);
|
||||
} else if (response.status === 401 || response.status === 403) {
|
||||
setError('Authentication required. Please log in.');
|
||||
} else {
|
||||
setError(result.message || 'Failed to import files from ZIP');
|
||||
}
|
||||
} catch (err) {
|
||||
setError('Error importing from ZIP: ' + (err as Error).message);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const toggleZipFileSelection = (fileName: string) => {
|
||||
const newSelection = new Set(selectedZipFiles);
|
||||
if (newSelection.has(fileName)) {
|
||||
newSelection.delete(fileName);
|
||||
} else {
|
||||
newSelection.add(fileName);
|
||||
}
|
||||
setSelectedZipFiles(newSelection);
|
||||
};
|
||||
|
||||
const selectAllZipFiles = () => {
|
||||
if (zipAnalysis?.files) {
|
||||
const validFiles = zipAnalysis.files.filter(f => !f.error);
|
||||
setSelectedZipFiles(new Set(validFiles.map(f => f.fileName)));
|
||||
}
|
||||
};
|
||||
|
||||
const deselectAllZipFiles = () => {
|
||||
setSelectedZipFiles(new Set());
|
||||
};
|
||||
|
||||
const resetForm = () => {
|
||||
setSelectedFile(null);
|
||||
setFileType(null);
|
||||
setValidationResult(null);
|
||||
setImportResult(null);
|
||||
setZipAnalysis(null);
|
||||
setZipSessionId(null);
|
||||
setSelectedZipFiles(new Set());
|
||||
setFileMetadata(new Map());
|
||||
setZipImportResult(null);
|
||||
setError(null);
|
||||
setAuthorName('');
|
||||
setAuthorId(undefined);
|
||||
setSeriesName('');
|
||||
setSeriesId(undefined);
|
||||
setSeriesVolume('');
|
||||
setTags('');
|
||||
};
|
||||
|
||||
const handleAuthorChange = (name: string, id?: string) => {
|
||||
setAuthorName(name);
|
||||
setAuthorId(id);
|
||||
};
|
||||
|
||||
const handleSeriesChange = (name: string, id?: string) => {
|
||||
setSeriesName(name);
|
||||
setSeriesId(id);
|
||||
};
|
||||
|
||||
// Show success screen for single file import
|
||||
if (importResult?.success) {
|
||||
return (
|
||||
<ImportLayout
|
||||
title="Import Successful"
|
||||
description="Your file has been successfully imported into StoryCove"
|
||||
>
|
||||
<div className="space-y-6">
|
||||
<div className="bg-green-50 dark:bg-green-900/20 border border-green-200 dark:border-green-800 rounded-lg p-6">
|
||||
<h2 className="text-xl font-semibold text-green-600 dark:text-green-400 mb-2">Import Completed</h2>
|
||||
<p className="theme-text">
|
||||
Your {importResult.fileType || fileType?.toUpperCase()} file has been successfully imported.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="theme-card theme-shadow rounded-lg p-6">
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<span className="font-semibold theme-header">Story Title:</span>
|
||||
<p className="theme-text">{importResult.storyTitle}</p>
|
||||
</div>
|
||||
|
||||
{importResult.wordCount && (
|
||||
<div>
|
||||
<span className="font-semibold theme-header">Word Count:</span>
|
||||
<p className="theme-text">{importResult.wordCount.toLocaleString()} words</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{importResult.extractedImages !== undefined && importResult.extractedImages > 0 && (
|
||||
<div>
|
||||
<span className="font-semibold theme-header">Extracted Images:</span>
|
||||
<p className="theme-text">{importResult.extractedImages}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{importResult.warnings && importResult.warnings.length > 0 && (
|
||||
<div className="bg-yellow-50 dark:bg-yellow-900/20 border border-yellow-200 dark:border-yellow-800 rounded-lg p-4">
|
||||
<strong className="text-yellow-800 dark:text-yellow-200">Warnings:</strong>
|
||||
<ul className="list-disc list-inside mt-2 text-yellow-700 dark:text-yellow-300">
|
||||
{importResult.warnings.map((warning, index) => (
|
||||
<li key={index}>{warning}</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex gap-4 mt-6">
|
||||
<Button
|
||||
onClick={() => router.push(`/stories/${importResult.storyId}`)}
|
||||
>
|
||||
View Story
|
||||
</Button>
|
||||
<Button
|
||||
onClick={resetForm}
|
||||
variant="secondary"
|
||||
>
|
||||
Import Another File
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</ImportLayout>
|
||||
);
|
||||
}
|
||||
|
||||
// Show success screen for ZIP import
|
||||
if (zipImportResult) {
|
||||
return (
|
||||
<ImportLayout
|
||||
title="ZIP Import Complete"
|
||||
description="Import results from your ZIP file"
|
||||
>
|
||||
<div className="space-y-6">
|
||||
<div className={`border rounded-lg p-6 ${
|
||||
zipImportResult.failedImports === 0
|
||||
? 'bg-green-50 dark:bg-green-900/20 border-green-200 dark:border-green-800'
|
||||
: 'bg-yellow-50 dark:bg-yellow-900/20 border-yellow-200 dark:border-yellow-800'
|
||||
}`}>
|
||||
<h2 className={`text-xl font-semibold mb-2 ${
|
||||
zipImportResult.failedImports === 0
|
||||
? 'text-green-600 dark:text-green-400'
|
||||
: 'text-yellow-600 dark:text-yellow-400'
|
||||
}`}>
|
||||
{zipImportResult.message}
|
||||
</h2>
|
||||
<p className="theme-text">
|
||||
{zipImportResult.successfulImports} of {zipImportResult.totalFiles} files imported successfully
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="theme-card theme-shadow rounded-lg p-6">
|
||||
<h3 className="text-lg font-semibold theme-header mb-4">Import Results</h3>
|
||||
<div className="space-y-3">
|
||||
{zipImportResult.results.map((result, index) => (
|
||||
<div key={index} className={`p-4 rounded-lg border ${
|
||||
result.success
|
||||
? 'bg-green-50 dark:bg-green-900/10 border-green-200 dark:border-green-800'
|
||||
: 'bg-red-50 dark:bg-red-900/10 border-red-200 dark:border-red-800'
|
||||
}`}>
|
||||
<div className="flex items-start gap-3">
|
||||
{result.success ? (
|
||||
<CheckCircleIcon className="h-5 w-5 text-green-600 dark:text-green-400 flex-shrink-0 mt-0.5" />
|
||||
) : (
|
||||
<XCircleIcon className="h-5 w-5 text-red-600 dark:text-red-400 flex-shrink-0 mt-0.5" />
|
||||
)}
|
||||
<div className="flex-1">
|
||||
<p className="font-medium theme-header">
|
||||
{result.fileName || result.storyTitle || 'Unknown file'}
|
||||
</p>
|
||||
{result.success && result.storyTitle && (
|
||||
<p className="text-sm theme-text">
|
||||
Imported as: {result.storyTitle}
|
||||
{result.storyId && (
|
||||
<button
|
||||
onClick={() => router.push(`/stories/${result.storyId}`)}
|
||||
className="ml-2 text-xs text-blue-600 dark:text-blue-400 hover:underline"
|
||||
>
|
||||
View
|
||||
</button>
|
||||
)}
|
||||
</p>
|
||||
)}
|
||||
{!result.success && (
|
||||
<p className="text-sm text-red-600 dark:text-red-400">{result.message}</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="flex gap-4 mt-6">
|
||||
<Button
|
||||
onClick={() => router.push('/library')}
|
||||
>
|
||||
Go to Library
|
||||
</Button>
|
||||
<Button
|
||||
onClick={resetForm}
|
||||
variant="secondary"
|
||||
>
|
||||
Import Another File
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</ImportLayout>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<ImportLayout
|
||||
title="Import from File"
|
||||
description="Upload an EPUB, PDF, or ZIP file to import stories into your library"
|
||||
>
|
||||
{error && (
|
||||
<div className="bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 rounded-lg p-4 mb-6">
|
||||
<p className="text-red-800 dark:text-red-200">{error}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<form onSubmit={handleSubmit} className="space-y-6">
|
||||
{/* File Upload */}
|
||||
<div className="theme-card theme-shadow rounded-lg p-6">
|
||||
<div className="mb-4">
|
||||
<h3 className="text-lg font-semibold theme-header mb-2">Select File</h3>
|
||||
<p className="theme-text">
|
||||
Choose an EPUB, PDF, or ZIP file from your device to import.
|
||||
</p>
|
||||
</div>
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<label htmlFor="import-file" className="block text-sm font-medium theme-header mb-1">
|
||||
File (EPUB, PDF, or ZIP)
|
||||
</label>
|
||||
<Input
|
||||
id="import-file"
|
||||
type="file"
|
||||
accept=".epub,.pdf,.zip,application/epub+zip,application/pdf,application/zip"
|
||||
onChange={handleFileChange}
|
||||
disabled={isLoading || isValidating}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{selectedFile && (
|
||||
<div className="flex items-center gap-2">
|
||||
<DocumentArrowUpIcon className="h-5 w-5 theme-text" />
|
||||
<span className="text-sm theme-text">
|
||||
{selectedFile.name} ({(selectedFile.size / 1024 / 1024).toFixed(2)} MB)
|
||||
{fileType && <span className="ml-2 inline-flex items-center px-2 py-1 rounded text-xs font-medium bg-blue-100 dark:bg-blue-900/20 text-blue-800 dark:text-blue-200">
|
||||
{fileType.toUpperCase()}
|
||||
</span>}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{isValidating && (
|
||||
<div className="text-sm theme-accent">
|
||||
Validating file...
|
||||
</div>
|
||||
)}
|
||||
|
||||
{validationResult && fileType !== 'zip' && (
|
||||
<div className="text-sm">
|
||||
{validationResult.valid ? (
|
||||
<span className="inline-flex items-center px-2 py-1 rounded text-xs font-medium bg-green-100 dark:bg-green-900/20 text-green-800 dark:text-green-200">
|
||||
Valid {fileType?.toUpperCase()}
|
||||
</span>
|
||||
) : (
|
||||
<span className="inline-flex items-center px-2 py-1 rounded text-xs font-medium bg-red-100 dark:bg-red-900/20 text-red-800 dark:text-red-200">
|
||||
Invalid {fileType?.toUpperCase()}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ZIP File Selection */}
|
||||
{fileType === 'zip' && zipAnalysis?.success && zipAnalysis.files && (
|
||||
<div className="theme-card theme-shadow rounded-lg p-6">
|
||||
<div className="mb-4 flex items-center justify-between">
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold theme-header mb-2">Select Files to Import</h3>
|
||||
<p className="theme-text">
|
||||
{zipAnalysis.validFiles} valid files found in ZIP ({zipAnalysis.totalFiles} total)
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
type="button"
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
onClick={selectAllZipFiles}
|
||||
>
|
||||
Select All
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={deselectAllZipFiles}
|
||||
>
|
||||
Deselect All
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-2 max-h-96 overflow-y-auto">
|
||||
{zipAnalysis.files.map((file, index) => (
|
||||
<div
|
||||
key={index}
|
||||
className={`p-3 rounded-lg border ${
|
||||
file.error
|
||||
? 'bg-red-50 dark:bg-red-900/10 border-red-200 dark:border-red-800 opacity-50'
|
||||
: selectedZipFiles.has(file.fileName)
|
||||
? 'bg-blue-50 dark:bg-blue-900/10 border-blue-300 dark:border-blue-700'
|
||||
: 'theme-card border-gray-200 dark:border-gray-700'
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-start gap-3">
|
||||
{!file.error && (
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={selectedZipFiles.has(file.fileName)}
|
||||
onChange={() => toggleZipFileSelection(file.fileName)}
|
||||
className="mt-1"
|
||||
/>
|
||||
)}
|
||||
<div className="flex-1">
|
||||
<p className="font-medium theme-header">{file.fileName}</p>
|
||||
<p className="text-xs theme-text mt-1">
|
||||
{file.fileType} • {(file.fileSize / 1024).toFixed(2)} KB
|
||||
{file.extractedTitle && ` • ${file.extractedTitle}`}
|
||||
</p>
|
||||
{file.error && (
|
||||
<p className="text-xs text-red-600 dark:text-red-400 mt-1">{file.error}</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Import Options - Show for all file types */}
|
||||
{fileType && (!zipAnalysis || (zipAnalysis && selectedZipFiles.size > 0)) && (
|
||||
<div className="theme-card theme-shadow rounded-lg p-6">
|
||||
<div className="mb-4">
|
||||
<h3 className="text-lg font-semibold theme-header mb-2">Import Options</h3>
|
||||
<p className="theme-text">
|
||||
Configure how the {fileType === 'zip' ? 'files' : 'file'} should be imported.
|
||||
{fileType === 'zip' && ' These settings apply to all selected files.'}
|
||||
</p>
|
||||
</div>
|
||||
<div className="space-y-4">
|
||||
<AuthorSelector
|
||||
value={authorName}
|
||||
onChange={handleAuthorChange}
|
||||
placeholder={fileType === 'epub' ? 'Leave empty to use file metadata' : 'Required for PDF import'}
|
||||
required={fileType === 'pdf'}
|
||||
label={`Author${fileType === 'pdf' ? ' *' : ''}${fileType === 'zip' ? ' (Default)' : ''}`}
|
||||
error={fileType === 'pdf' && !authorName ? 'PDF import requires an author name. Select an existing author or enter a new one.' : undefined}
|
||||
/>
|
||||
|
||||
<SeriesSelector
|
||||
value={seriesName}
|
||||
onChange={handleSeriesChange}
|
||||
placeholder="Optional: Add to a series"
|
||||
label={`Series${fileType === 'zip' ? ' (Default)' : ''}`}
|
||||
authorId={authorId}
|
||||
/>
|
||||
|
||||
{seriesName && (
|
||||
<div>
|
||||
<label htmlFor="series-volume" className="block text-sm font-medium theme-header mb-1">Series Volume</label>
|
||||
<Input
|
||||
id="series-volume"
|
||||
type="number"
|
||||
value={seriesVolume}
|
||||
onChange={(e) => setSeriesVolume(e.target.value)}
|
||||
placeholder="Volume number in series"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div>
|
||||
<label htmlFor="tags" className="block text-sm font-medium theme-header mb-1">
|
||||
Tags {fileType === 'zip' && '(Default)'}
|
||||
</label>
|
||||
<Input
|
||||
id="tags"
|
||||
value={tags}
|
||||
onChange={(e) => setTags(e.target.value)}
|
||||
placeholder="Comma-separated tags (e.g., fantasy, adventure, romance)"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-3">
|
||||
{fileType === 'epub' && (
|
||||
<div className="flex items-center">
|
||||
<input
|
||||
type="checkbox"
|
||||
id="preserve-reading-position"
|
||||
checked={preserveReadingPosition}
|
||||
onChange={(e) => setPreserveReadingPosition(e.target.checked)}
|
||||
className="mr-2"
|
||||
/>
|
||||
<label htmlFor="preserve-reading-position" className="text-sm theme-text">
|
||||
Preserve reading position from EPUB metadata
|
||||
</label>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{(fileType === 'pdf' || fileType === 'zip') && (
|
||||
<div className="flex items-center">
|
||||
<input
|
||||
type="checkbox"
|
||||
id="extract-images"
|
||||
checked={extractImages}
|
||||
onChange={(e) => setExtractImages(e.target.checked)}
|
||||
className="mr-2"
|
||||
/>
|
||||
<label htmlFor="extract-images" className="text-sm theme-text">
|
||||
Extract and store embedded images from PDFs
|
||||
</label>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex items-center">
|
||||
<input
|
||||
type="checkbox"
|
||||
id="create-missing-author"
|
||||
checked={createMissingAuthor}
|
||||
onChange={(e) => setCreateMissingAuthor(e.target.checked)}
|
||||
className="mr-2"
|
||||
/>
|
||||
<label htmlFor="create-missing-author" className="text-sm theme-text">
|
||||
Create author if not found
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center">
|
||||
<input
|
||||
type="checkbox"
|
||||
id="create-missing-series"
|
||||
checked={createMissingSeries}
|
||||
onChange={(e) => setCreateMissingSeries(e.target.checked)}
|
||||
className="mr-2"
|
||||
/>
|
||||
<label htmlFor="create-missing-series" className="text-sm theme-text">
|
||||
Create series if not found
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Submit Button */}
|
||||
{fileType && fileType !== 'zip' && (
|
||||
<div className="flex justify-end">
|
||||
<Button
|
||||
type="submit"
|
||||
disabled={!selectedFile || isLoading || isValidating || (validationResult && !validationResult.valid)}
|
||||
loading={isLoading}
|
||||
>
|
||||
{isLoading ? 'Importing...' : `Import ${fileType.toUpperCase()}`}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{fileType === 'zip' && zipAnalysis?.success && (
|
||||
<div className="flex justify-end">
|
||||
<Button
|
||||
type="submit"
|
||||
disabled={selectedZipFiles.size === 0 || isLoading}
|
||||
loading={isLoading}
|
||||
>
|
||||
{isLoading ? 'Importing...' : `Import ${selectedZipFiles.size} File${selectedZipFiles.size !== 1 ? 's' : ''}`}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</form>
|
||||
</ImportLayout>
|
||||
);
|
||||
}
|
||||
@@ -27,9 +27,9 @@ export default function Header() {
|
||||
description: 'Import a single story from a website'
|
||||
},
|
||||
{
|
||||
href: '/import/epub',
|
||||
label: 'Import EPUB',
|
||||
description: 'Import a story from an EPUB file'
|
||||
href: '/import/file',
|
||||
label: 'Import from File',
|
||||
description: 'Import from EPUB, PDF, or ZIP file'
|
||||
},
|
||||
{
|
||||
href: '/import/bulk',
|
||||
|
||||
@@ -31,10 +31,10 @@ const importTabs: ImportTab[] = [
|
||||
description: 'Import a single story from a website'
|
||||
},
|
||||
{
|
||||
id: 'epub',
|
||||
label: 'Import EPUB',
|
||||
href: '/import/epub',
|
||||
description: 'Import a story from an EPUB file'
|
||||
id: 'file',
|
||||
label: 'Import from File',
|
||||
href: '/import/file',
|
||||
description: 'Import from EPUB, PDF, or ZIP file'
|
||||
},
|
||||
{
|
||||
id: 'bulk',
|
||||
|
||||
File diff suppressed because one or more lines are too long
Reference in New Issue
Block a user