Various improvements & Epub support
This commit is contained in:
@@ -21,6 +21,7 @@ export default function StoryDetailPage() {
|
||||
const [collections, setCollections] = useState<Collection[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [updating, setUpdating] = useState(false);
|
||||
const [isExporting, setIsExporting] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
const loadStoryData = async () => {
|
||||
@@ -65,6 +66,53 @@ export default function StoryDetailPage() {
|
||||
}
|
||||
};
|
||||
|
||||
const handleEPUBExport = async () => {
|
||||
if (!story) return;
|
||||
|
||||
setIsExporting(true);
|
||||
try {
|
||||
const token = localStorage.getItem('auth-token');
|
||||
const response = await fetch(`/api/stories/${story.id}/epub`, {
|
||||
method: 'GET',
|
||||
headers: {
|
||||
'Authorization': token ? `Bearer ${token}` : '',
|
||||
},
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
const blob = await response.blob();
|
||||
const url = window.URL.createObjectURL(blob);
|
||||
const link = document.createElement('a');
|
||||
link.href = url;
|
||||
|
||||
// Get filename from Content-Disposition header or create default
|
||||
const contentDisposition = response.headers.get('Content-Disposition');
|
||||
let filename = `${story.title}.epub`;
|
||||
if (contentDisposition) {
|
||||
const match = contentDisposition.match(/filename[^;=\n]*=((['"]).*?\2|[^;\n]*)/);
|
||||
if (match && match[1]) {
|
||||
filename = match[1].replace(/['"]/g, '');
|
||||
}
|
||||
}
|
||||
|
||||
link.download = filename;
|
||||
document.body.appendChild(link);
|
||||
link.click();
|
||||
window.URL.revokeObjectURL(url);
|
||||
document.body.removeChild(link);
|
||||
} else if (response.status === 401 || response.status === 403) {
|
||||
alert('Authentication required. Please log in.');
|
||||
} else {
|
||||
throw new Error('Failed to export EPUB');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error exporting EPUB:', error);
|
||||
alert('Failed to export EPUB. Please try again.');
|
||||
} finally {
|
||||
setIsExporting(false);
|
||||
}
|
||||
};
|
||||
|
||||
const formatDate = (dateString: string) => {
|
||||
return new Date(dateString).toLocaleDateString('en-US', {
|
||||
year: 'numeric',
|
||||
@@ -358,6 +406,14 @@ export default function StoryDetailPage() {
|
||||
>
|
||||
📚 Start Reading
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleEPUBExport}
|
||||
variant="ghost"
|
||||
size="lg"
|
||||
disabled={isExporting}
|
||||
>
|
||||
{isExporting ? 'Exporting...' : '📖 Export EPUB'}
|
||||
</Button>
|
||||
<Button
|
||||
href={`/stories/${story.id}/edit`}
|
||||
variant="ghost"
|
||||
|
||||
@@ -252,7 +252,7 @@ export default function EditStoryPage() {
|
||||
</label>
|
||||
<ImageUpload
|
||||
onImageSelect={setCoverImage}
|
||||
accept="image/jpeg,image/png,image/webp"
|
||||
accept="image/jpeg,image/png"
|
||||
maxSizeMB={5}
|
||||
aspectRatio="3:4"
|
||||
placeholder="Drop a new cover image here or click to select"
|
||||
|
||||
@@ -201,6 +201,7 @@ export default function StoryReadingPage() {
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
const findNextStory = (): Story | null => {
|
||||
if (!story?.seriesId || seriesStories.length <= 1) return null;
|
||||
|
||||
|
||||
@@ -4,6 +4,7 @@ import { useState } from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import Link from 'next/link';
|
||||
import { ArrowLeftIcon } from '@heroicons/react/24/outline';
|
||||
import BulkImportProgress from '@/components/BulkImportProgress';
|
||||
|
||||
interface ImportResult {
|
||||
url: string;
|
||||
@@ -23,14 +24,25 @@ interface BulkImportResponse {
|
||||
skipped: number;
|
||||
errors: number;
|
||||
};
|
||||
combinedStory?: {
|
||||
title: string;
|
||||
author: string;
|
||||
content: string;
|
||||
summary?: string;
|
||||
sourceUrl: string;
|
||||
tags?: string[];
|
||||
};
|
||||
}
|
||||
|
||||
export default function BulkImportPage() {
|
||||
const router = useRouter();
|
||||
const [urls, setUrls] = useState('');
|
||||
const [combineIntoOne, setCombineIntoOne] = useState(false);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [results, setResults] = useState<BulkImportResponse | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [sessionId, setSessionId] = useState<string | null>(null);
|
||||
const [showProgress, setShowProgress] = useState(false);
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
@@ -57,12 +69,17 @@ export default function BulkImportPage() {
|
||||
return;
|
||||
}
|
||||
|
||||
if (urlList.length > 50) {
|
||||
setError('Maximum 50 URLs allowed per bulk import');
|
||||
if (urlList.length > 200) {
|
||||
setError('Maximum 200 URLs allowed per bulk import');
|
||||
setIsLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
// Generate session ID for progress tracking
|
||||
const newSessionId = `bulk-import-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`;
|
||||
setSessionId(newSessionId);
|
||||
setShowProgress(true);
|
||||
|
||||
// Get auth token for server-side API calls
|
||||
const token = localStorage.getItem('auth-token');
|
||||
|
||||
@@ -72,16 +89,18 @@ export default function BulkImportPage() {
|
||||
'Content-Type': 'application/json',
|
||||
'Authorization': token ? `Bearer ${token}` : '',
|
||||
},
|
||||
body: JSON.stringify({ urls: urlList }),
|
||||
body: JSON.stringify({ urls: urlList, combineIntoOne, sessionId: newSessionId }),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const errorData = await response.json();
|
||||
throw new Error(errorData.error || 'Bulk import failed');
|
||||
throw new Error(errorData.error || 'Failed to start bulk import');
|
||||
}
|
||||
|
||||
const data: BulkImportResponse = await response.json();
|
||||
setResults(data);
|
||||
const startData = await response.json();
|
||||
console.log('Bulk import started:', startData);
|
||||
|
||||
// The progress component will handle the rest via SSE
|
||||
|
||||
} catch (err) {
|
||||
console.error('Bulk import error:', err);
|
||||
@@ -93,8 +112,43 @@ export default function BulkImportPage() {
|
||||
|
||||
const handleReset = () => {
|
||||
setUrls('');
|
||||
setCombineIntoOne(false);
|
||||
setResults(null);
|
||||
setError(null);
|
||||
setSessionId(null);
|
||||
setShowProgress(false);
|
||||
};
|
||||
|
||||
const handleProgressComplete = (data?: any) => {
|
||||
// Progress component will handle this when the operation completes
|
||||
setShowProgress(false);
|
||||
setIsLoading(false);
|
||||
|
||||
// Handle completion data
|
||||
if (data) {
|
||||
if (data.combinedStory && combineIntoOne) {
|
||||
// For combine mode, redirect to add story page with the combined content
|
||||
localStorage.setItem('pendingStory', JSON.stringify(data.combinedStory));
|
||||
router.push('/add-story?from=bulk-combine');
|
||||
return;
|
||||
} else if (data.results && data.summary) {
|
||||
// For individual mode, show the results
|
||||
setResults({
|
||||
results: data.results,
|
||||
summary: data.summary
|
||||
});
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback: just hide progress and let user know it completed
|
||||
console.log('Import completed successfully');
|
||||
};
|
||||
|
||||
const handleProgressError = (errorMessage: string) => {
|
||||
setError(errorMessage);
|
||||
setIsLoading(false);
|
||||
setShowProgress(false);
|
||||
};
|
||||
|
||||
const getStatusColor = (status: string) => {
|
||||
@@ -145,7 +199,7 @@ export default function BulkImportPage() {
|
||||
Story URLs
|
||||
</label>
|
||||
<p className="text-sm text-gray-500 mb-3">
|
||||
Enter one URL per line. Maximum 50 URLs per import.
|
||||
Enter one URL per line. Maximum 200 URLs per import.
|
||||
</p>
|
||||
<textarea
|
||||
id="urls"
|
||||
@@ -160,6 +214,37 @@ export default function BulkImportPage() {
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center">
|
||||
<input
|
||||
id="combine-into-one"
|
||||
type="checkbox"
|
||||
checked={combineIntoOne}
|
||||
onChange={(e) => setCombineIntoOne(e.target.checked)}
|
||||
className="h-4 w-4 text-blue-600 focus:ring-blue-500 border-gray-300 rounded"
|
||||
disabled={isLoading}
|
||||
/>
|
||||
<label htmlFor="combine-into-one" className="ml-2 block text-sm text-gray-700">
|
||||
Combine all URL content into a single story
|
||||
</label>
|
||||
</div>
|
||||
|
||||
{combineIntoOne && (
|
||||
<div className="bg-blue-50 border border-blue-200 rounded-md p-4">
|
||||
<div className="text-sm text-blue-800">
|
||||
<p className="font-medium mb-2">Combined Story Mode:</p>
|
||||
<ul className="list-disc list-inside space-y-1 text-blue-700">
|
||||
<li>All URLs will be scraped and their content combined into one story</li>
|
||||
<li>Story title and author will be taken from the first URL</li>
|
||||
<li>Import will fail if any URL has no content (title/author can be empty)</li>
|
||||
<li>You'll be redirected to the story creation page to review and edit</li>
|
||||
{urls.split('\n').filter(url => url.trim().length > 0).length > 50 && (
|
||||
<li className="text-yellow-700 font-medium">⚠️ Large imports (50+ URLs) may take several minutes and could be truncated if too large</li>
|
||||
)}
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{error && (
|
||||
<div className="bg-red-50 border border-red-200 rounded-md p-4">
|
||||
<div className="flex">
|
||||
@@ -192,14 +277,25 @@ export default function BulkImportPage() {
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{isLoading && (
|
||||
{/* Progress Component */}
|
||||
{showProgress && sessionId && (
|
||||
<BulkImportProgress
|
||||
sessionId={sessionId}
|
||||
onComplete={handleProgressComplete}
|
||||
onError={handleProgressError}
|
||||
combineMode={combineIntoOne}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Fallback loading indicator if progress isn't shown yet */}
|
||||
{isLoading && !showProgress && (
|
||||
<div className="bg-blue-50 border border-blue-200 rounded-md p-4">
|
||||
<div className="flex items-center">
|
||||
<div className="animate-spin rounded-full h-5 w-5 border-b-2 border-blue-600 mr-3"></div>
|
||||
<div>
|
||||
<p className="text-sm font-medium text-blue-800">Processing URLs...</p>
|
||||
<p className="text-sm font-medium text-blue-800">Starting import...</p>
|
||||
<p className="text-sm text-blue-600">
|
||||
This may take a few minutes depending on the number of URLs and response times of the source websites.
|
||||
Preparing to process {urls.split('\n').filter(url => url.trim().length > 0).length} URLs.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
432
frontend/src/app/stories/import/epub/page.tsx
Normal file
432
frontend/src/app/stories/import/epub/page.tsx
Normal file
@@ -0,0 +1,432 @@
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import Link from 'next/link';
|
||||
import { ArrowLeftIcon, DocumentArrowUpIcon } from '@heroicons/react/24/outline';
|
||||
import Button from '@/components/ui/Button';
|
||||
import { Input } from '@/components/ui/Input';
|
||||
|
||||
interface EPUBImportResponse {
|
||||
success: boolean;
|
||||
message: string;
|
||||
storyId?: string;
|
||||
storyTitle?: string;
|
||||
totalChapters?: number;
|
||||
wordCount?: number;
|
||||
readingPosition?: any;
|
||||
warnings?: string[];
|
||||
errors?: string[];
|
||||
}
|
||||
|
||||
export default function EPUBImportPage() {
|
||||
const router = useRouter();
|
||||
const [selectedFile, setSelectedFile] = useState<File | null>(null);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [isValidating, setIsValidating] = useState(false);
|
||||
const [validationResult, setValidationResult] = useState<any>(null);
|
||||
const [importResult, setImportResult] = useState<EPUBImportResponse | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
// Import options
|
||||
const [authorName, setAuthorName] = useState<string>('');
|
||||
const [seriesName, setSeriesName] = useState<string>('');
|
||||
const [seriesVolume, setSeriesVolume] = useState<string>('');
|
||||
const [tags, setTags] = useState<string>('');
|
||||
const [preserveReadingPosition, setPreserveReadingPosition] = useState(true);
|
||||
const [overwriteExisting, setOverwriteExisting] = useState(false);
|
||||
const [createMissingAuthor, setCreateMissingAuthor] = useState(true);
|
||||
const [createMissingSeries, setCreateMissingSeries] = useState(true);
|
||||
|
||||
const handleFileChange = async (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = e.target.files?.[0];
|
||||
if (file) {
|
||||
setSelectedFile(file);
|
||||
setValidationResult(null);
|
||||
setImportResult(null);
|
||||
setError(null);
|
||||
|
||||
if (file.name.toLowerCase().endsWith('.epub')) {
|
||||
await validateFile(file);
|
||||
} else {
|
||||
setError('Please select a valid EPUB file (.epub extension)');
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const validateFile = async (file: File) => {
|
||||
setIsValidating(true);
|
||||
try {
|
||||
const token = localStorage.getItem('auth-token');
|
||||
const formData = new FormData();
|
||||
formData.append('file', file);
|
||||
|
||||
const response = await fetch('/api/stories/epub/validate', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Authorization': token ? `Bearer ${token}` : '',
|
||||
},
|
||||
body: formData,
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
const result = await response.json();
|
||||
setValidationResult(result);
|
||||
if (!result.valid) {
|
||||
setError('EPUB 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 EPUB file');
|
||||
}
|
||||
} catch (err) {
|
||||
setError('Error validating EPUB file: ' + (err as Error).message);
|
||||
} finally {
|
||||
setIsValidating(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
|
||||
if (!selectedFile) {
|
||||
setError('Please select an EPUB file');
|
||||
return;
|
||||
}
|
||||
|
||||
if (validationResult && !validationResult.valid) {
|
||||
setError('Cannot import invalid EPUB file');
|
||||
return;
|
||||
}
|
||||
|
||||
setIsLoading(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
const token = localStorage.getItem('auth-token');
|
||||
const formData = new FormData();
|
||||
formData.append('file', selectedFile);
|
||||
|
||||
if (authorName) formData.append('authorName', authorName);
|
||||
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('preserveReadingPosition', preserveReadingPosition.toString());
|
||||
formData.append('overwriteExisting', overwriteExisting.toString());
|
||||
formData.append('createMissingAuthor', createMissingAuthor.toString());
|
||||
formData.append('createMissingSeries', createMissingSeries.toString());
|
||||
|
||||
const response = await fetch('/api/stories/epub/import', {
|
||||
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 EPUB');
|
||||
}
|
||||
} catch (err) {
|
||||
setError('Error importing EPUB: ' + (err as Error).message);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const resetForm = () => {
|
||||
setSelectedFile(null);
|
||||
setValidationResult(null);
|
||||
setImportResult(null);
|
||||
setError(null);
|
||||
setAuthorName('');
|
||||
setSeriesName('');
|
||||
setSeriesVolume('');
|
||||
setTags('');
|
||||
};
|
||||
|
||||
if (importResult?.success) {
|
||||
return (
|
||||
<div className="container mx-auto px-4 py-8">
|
||||
<div className="mb-6">
|
||||
<Link
|
||||
href="/stories"
|
||||
className="inline-flex items-center text-blue-600 hover:text-blue-800 mb-4"
|
||||
>
|
||||
<ArrowLeftIcon className="h-4 w-4 mr-2" />
|
||||
Back to Stories
|
||||
</Link>
|
||||
<h1 className="text-3xl font-bold text-gray-900 dark:text-white">
|
||||
EPUB Import Successful
|
||||
</h1>
|
||||
</div>
|
||||
|
||||
<div className="bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-700 rounded-lg p-6">
|
||||
<div className="mb-6">
|
||||
<h2 className="text-xl font-semibold text-green-600 mb-2">Import Completed</h2>
|
||||
<p className="text-gray-600 dark:text-gray-300">
|
||||
Your EPUB has been successfully imported into StoryCove.
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<span className="font-semibold text-gray-700 dark:text-gray-300">Story Title:</span>
|
||||
<p className="text-gray-900 dark:text-white">{importResult.storyTitle}</p>
|
||||
</div>
|
||||
|
||||
{importResult.wordCount && (
|
||||
<div>
|
||||
<span className="font-semibold text-gray-700 dark:text-gray-300">Word Count:</span>
|
||||
<p className="text-gray-900 dark:text-white">{importResult.wordCount.toLocaleString()} words</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{importResult.totalChapters && (
|
||||
<div>
|
||||
<span className="font-semibold text-gray-700 dark:text-gray-300">Chapters:</span>
|
||||
<p className="text-gray-900 dark:text-white">{importResult.totalChapters}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{importResult.warnings && importResult.warnings.length > 0 && (
|
||||
<div className="bg-yellow-50 border border-yellow-200 rounded-md p-4">
|
||||
<strong className="text-yellow-800">Warnings:</strong>
|
||||
<ul className="list-disc list-inside mt-2 text-yellow-700">
|
||||
{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}`)}
|
||||
className="bg-blue-600 hover:bg-blue-700 text-white"
|
||||
>
|
||||
View Story
|
||||
</Button>
|
||||
<Button
|
||||
onClick={resetForm}
|
||||
variant="secondary"
|
||||
>
|
||||
Import Another EPUB
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="container mx-auto px-4 py-8">
|
||||
<div className="mb-6">
|
||||
<Link
|
||||
href="/stories"
|
||||
className="inline-flex items-center text-blue-600 hover:text-blue-800 mb-4"
|
||||
>
|
||||
<ArrowLeftIcon className="h-4 w-4 mr-2" />
|
||||
Back to Stories
|
||||
</Link>
|
||||
<h1 className="text-3xl font-bold text-gray-900 dark:text-white">
|
||||
Import EPUB
|
||||
</h1>
|
||||
<p className="text-gray-600 dark:text-gray-300 mt-2">
|
||||
Upload an EPUB file to import it as a story into your library.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="bg-red-50 border border-red-200 rounded-md p-4 mb-6">
|
||||
<p className="text-red-700">{error}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<form onSubmit={handleSubmit} className="space-y-6">
|
||||
{/* File Upload */}
|
||||
<div className="bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-700 rounded-lg p-6">
|
||||
<div className="mb-4">
|
||||
<h3 className="text-lg font-semibold mb-2">Select EPUB File</h3>
|
||||
<p className="text-gray-600 dark:text-gray-300">
|
||||
Choose an EPUB file from your device to import.
|
||||
</p>
|
||||
</div>
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<label htmlFor="epub-file" className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">EPUB File</label>
|
||||
<Input
|
||||
id="epub-file"
|
||||
type="file"
|
||||
accept=".epub,application/epub+zip"
|
||||
onChange={handleFileChange}
|
||||
disabled={isLoading || isValidating}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{selectedFile && (
|
||||
<div className="flex items-center gap-2">
|
||||
<DocumentArrowUpIcon className="h-5 w-5" />
|
||||
<span className="text-sm text-gray-600">
|
||||
{selectedFile.name} ({(selectedFile.size / 1024 / 1024).toFixed(2)} MB)
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{isValidating && (
|
||||
<div className="text-sm text-blue-600">
|
||||
Validating EPUB file...
|
||||
</div>
|
||||
)}
|
||||
|
||||
{validationResult && (
|
||||
<div className="text-sm">
|
||||
{validationResult.valid ? (
|
||||
<span className="inline-flex items-center px-2 py-1 rounded text-xs font-medium bg-green-100 text-green-800">
|
||||
Valid EPUB
|
||||
</span>
|
||||
) : (
|
||||
<span className="inline-flex items-center px-2 py-1 rounded text-xs font-medium bg-red-100 text-red-800">
|
||||
Invalid EPUB
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Import Options */}
|
||||
<div className="bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-700 rounded-lg p-6">
|
||||
<div className="mb-4">
|
||||
<h3 className="text-lg font-semibold mb-2">Import Options</h3>
|
||||
<p className="text-gray-600 dark:text-gray-300">
|
||||
Configure how the EPUB should be imported.
|
||||
</p>
|
||||
</div>
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<label htmlFor="author-name" className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Author Name (Override)</label>
|
||||
<Input
|
||||
id="author-name"
|
||||
value={authorName}
|
||||
onChange={(e) => setAuthorName(e.target.value)}
|
||||
placeholder="Leave empty to use EPUB metadata"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label htmlFor="series-name" className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Series Name</label>
|
||||
<Input
|
||||
id="series-name"
|
||||
value={seriesName}
|
||||
onChange={(e) => setSeriesName(e.target.value)}
|
||||
placeholder="Optional: Add to a series"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{seriesName && (
|
||||
<div>
|
||||
<label htmlFor="series-volume" className="block text-sm font-medium text-gray-700 dark:text-gray-300 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 text-gray-700 dark:text-gray-300 mb-1">Tags</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">
|
||||
<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 text-gray-700 dark:text-gray-300">
|
||||
Preserve reading position from EPUB metadata
|
||||
</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 text-gray-700 dark:text-gray-300">
|
||||
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 text-gray-700 dark:text-gray-300">
|
||||
Create series if not found
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center">
|
||||
<input
|
||||
type="checkbox"
|
||||
id="overwrite-existing"
|
||||
checked={overwriteExisting}
|
||||
onChange={(e) => setOverwriteExisting(e.target.checked)}
|
||||
className="mr-2"
|
||||
/>
|
||||
<label htmlFor="overwrite-existing" className="text-sm text-gray-700 dark:text-gray-300">
|
||||
Overwrite existing story with same title and author
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Submit Button */}
|
||||
<div className="flex justify-end">
|
||||
<Button
|
||||
type="submit"
|
||||
disabled={!selectedFile || isLoading || isValidating || (validationResult && !validationResult.valid)}
|
||||
className="bg-blue-600 hover:bg-blue-700 text-white"
|
||||
>
|
||||
{isLoading ? 'Importing...' : 'Import EPUB'}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user