Various improvements & Epub support
This commit is contained in:
207
frontend/src/components/BulkImportProgress.tsx
Normal file
207
frontend/src/components/BulkImportProgress.tsx
Normal file
@@ -0,0 +1,207 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useState } from 'react';
|
||||
|
||||
interface ProgressUpdate {
|
||||
type: 'progress' | 'completed' | 'error' | 'connected';
|
||||
current: number;
|
||||
total: number;
|
||||
message: string;
|
||||
url?: string;
|
||||
title?: string;
|
||||
author?: string;
|
||||
wordCount?: number;
|
||||
totalWordCount?: number;
|
||||
error?: string;
|
||||
sessionId?: string;
|
||||
}
|
||||
|
||||
interface BulkImportProgressProps {
|
||||
sessionId: string;
|
||||
onComplete?: (data?: any) => void;
|
||||
onError?: (error: string) => void;
|
||||
combineMode?: boolean;
|
||||
}
|
||||
|
||||
export default function BulkImportProgress({
|
||||
sessionId,
|
||||
onComplete,
|
||||
onError,
|
||||
combineMode = false
|
||||
}: BulkImportProgressProps) {
|
||||
const [progress, setProgress] = useState<ProgressUpdate>({
|
||||
type: 'progress',
|
||||
current: 0,
|
||||
total: 1,
|
||||
message: 'Connecting...'
|
||||
});
|
||||
const [isConnected, setIsConnected] = useState(false);
|
||||
const [recentActivities, setRecentActivities] = useState<string[]>([]);
|
||||
|
||||
useEffect(() => {
|
||||
const eventSource = new EventSource(`/scrape/bulk/progress?sessionId=${sessionId}`);
|
||||
|
||||
eventSource.onmessage = (event) => {
|
||||
try {
|
||||
const data: ProgressUpdate = JSON.parse(event.data);
|
||||
|
||||
if (data.type === 'connected') {
|
||||
setIsConnected(true);
|
||||
return;
|
||||
}
|
||||
|
||||
setProgress(data);
|
||||
|
||||
// Add to recent activities (keep last 5)
|
||||
if (data.message) {
|
||||
setRecentActivities(prev => [
|
||||
data.message,
|
||||
...prev.slice(0, 4)
|
||||
]);
|
||||
}
|
||||
|
||||
if (data.type === 'completed') {
|
||||
setTimeout(() => {
|
||||
onComplete?.(data);
|
||||
eventSource.close();
|
||||
}, 2000); // Show completion message for 2 seconds
|
||||
} else if (data.type === 'error') {
|
||||
onError?.(data.error || 'Unknown error occurred');
|
||||
eventSource.close();
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to parse progress update:', error);
|
||||
}
|
||||
};
|
||||
|
||||
eventSource.onerror = (error) => {
|
||||
console.error('EventSource error:', error);
|
||||
setIsConnected(false);
|
||||
onError?.('Connection to progress stream failed');
|
||||
eventSource.close();
|
||||
};
|
||||
|
||||
return () => {
|
||||
eventSource.close();
|
||||
};
|
||||
}, [sessionId, onComplete, onError]);
|
||||
|
||||
const progressPercentage = progress.total > 0
|
||||
? Math.round((progress.current / progress.total) * 100)
|
||||
: 0;
|
||||
|
||||
const getStatusColor = () => {
|
||||
switch (progress.type) {
|
||||
case 'completed': return 'bg-green-600';
|
||||
case 'error': return 'bg-red-600';
|
||||
default: return 'bg-blue-600';
|
||||
}
|
||||
};
|
||||
|
||||
const getStatusIcon = () => {
|
||||
switch (progress.type) {
|
||||
case 'completed': return '✓';
|
||||
case 'error': return '✗';
|
||||
default: return null;
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="bg-white border border-gray-200 rounded-lg p-6">
|
||||
<div className="mb-4">
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<h3 className="text-lg font-medium text-gray-900">
|
||||
{combineMode ? 'Combining Stories' : 'Bulk Import Progress'}
|
||||
</h3>
|
||||
<div className="flex items-center gap-2">
|
||||
{!isConnected && (
|
||||
<div className="h-2 w-2 bg-yellow-400 rounded-full animate-pulse"></div>
|
||||
)}
|
||||
<span className="text-sm text-gray-600">
|
||||
{progress.current} of {progress.total}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Progress Bar */}
|
||||
<div className="w-full bg-gray-200 rounded-full h-3 mb-3">
|
||||
<div
|
||||
className={`h-3 rounded-full transition-all duration-500 ${getStatusColor()}`}
|
||||
style={{ width: `${progressPercentage}%` }}
|
||||
></div>
|
||||
</div>
|
||||
|
||||
{/* Progress Percentage */}
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-sm font-medium text-gray-900">
|
||||
{progressPercentage}%
|
||||
</span>
|
||||
{progress.type === 'completed' && (
|
||||
<span className="text-green-600 font-medium">
|
||||
{getStatusIcon()} Complete
|
||||
</span>
|
||||
)}
|
||||
{progress.type === 'error' && (
|
||||
<span className="text-red-600 font-medium">
|
||||
{getStatusIcon()} Error
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Current Status Message */}
|
||||
<div className="mb-4">
|
||||
<div className="flex items-center gap-2">
|
||||
{progress.type === 'progress' && (
|
||||
<div className="animate-spin rounded-full h-4 w-4 border-b-2 border-blue-600"></div>
|
||||
)}
|
||||
<p className="text-sm text-gray-700">{progress.message}</p>
|
||||
</div>
|
||||
|
||||
{/* Word Count for Combine Mode */}
|
||||
{combineMode && progress.totalWordCount !== undefined && (
|
||||
<p className="text-sm text-gray-500 mt-1">
|
||||
Total words collected: {progress.totalWordCount.toLocaleString()}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Current URL being processed */}
|
||||
{progress.url && (
|
||||
<div className="mb-4 p-3 bg-gray-50 rounded-md">
|
||||
<p className="text-sm text-gray-600 mb-1">Currently processing:</p>
|
||||
<p className="text-sm font-mono text-gray-800 truncate">{progress.url}</p>
|
||||
{progress.title && progress.author && (
|
||||
<p className="text-sm text-gray-600 mt-1">
|
||||
"{progress.title}" by {progress.author}
|
||||
{progress.wordCount && (
|
||||
<span className="ml-2 text-gray-500">
|
||||
({progress.wordCount.toLocaleString()} words)
|
||||
</span>
|
||||
)}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Recent Activities */}
|
||||
{recentActivities.length > 0 && (
|
||||
<div>
|
||||
<h4 className="text-sm font-medium text-gray-900 mb-2">Recent Activity</h4>
|
||||
<div className="space-y-1 max-h-32 overflow-y-auto">
|
||||
{recentActivities.map((activity, index) => (
|
||||
<p
|
||||
key={index}
|
||||
className={`text-xs text-gray-600 ${
|
||||
index === 0 ? 'font-medium text-gray-800' : ''
|
||||
}`}
|
||||
>
|
||||
{activity}
|
||||
</p>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -227,7 +227,7 @@ export default function CollectionForm({
|
||||
<input
|
||||
id="coverImage"
|
||||
type="file"
|
||||
accept="image/jpeg,image/png,image/webp"
|
||||
accept="image/jpeg,image/png"
|
||||
onChange={handleCoverImageChange}
|
||||
className="w-full px-3 py-2 border theme-border rounded-lg theme-card theme-text focus:outline-none focus:ring-2 focus:ring-theme-accent"
|
||||
/>
|
||||
|
||||
@@ -26,6 +26,11 @@ export default function Header() {
|
||||
label: 'Import from URL',
|
||||
description: 'Import a single story from a website'
|
||||
},
|
||||
{
|
||||
href: '/stories/import/epub',
|
||||
label: 'Import EPUB',
|
||||
description: 'Import a story from an EPUB file'
|
||||
},
|
||||
{
|
||||
href: '/stories/import/bulk',
|
||||
label: 'Bulk Import',
|
||||
@@ -165,6 +170,13 @@ export default function Header() {
|
||||
>
|
||||
Import from URL
|
||||
</Link>
|
||||
<Link
|
||||
href="/stories/import/epub"
|
||||
className="block theme-text hover:theme-accent transition-colors text-sm py-1"
|
||||
onClick={() => setIsMenuOpen(false)}
|
||||
>
|
||||
Import EPUB
|
||||
</Link>
|
||||
<Link
|
||||
href="/stories/import/bulk"
|
||||
className="block theme-text hover:theme-accent transition-colors text-sm py-1"
|
||||
|
||||
@@ -20,9 +20,12 @@ export default function RichTextEditor({
|
||||
}: RichTextEditorProps) {
|
||||
const [viewMode, setViewMode] = useState<'visual' | 'html'>('visual');
|
||||
const [htmlValue, setHtmlValue] = useState(value);
|
||||
const [isMaximized, setIsMaximized] = useState(false);
|
||||
const [containerHeight, setContainerHeight] = useState(300); // Default height in pixels
|
||||
const previewRef = useRef<HTMLDivElement>(null);
|
||||
const visualTextareaRef = useRef<HTMLTextAreaElement>(null);
|
||||
const visualDivRef = useRef<HTMLDivElement>(null);
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const [isUserTyping, setIsUserTyping] = useState(false);
|
||||
|
||||
// Utility functions for cursor position preservation
|
||||
@@ -60,6 +63,62 @@ export default function RichTextEditor({
|
||||
}
|
||||
};
|
||||
|
||||
// Maximize/minimize functionality
|
||||
const toggleMaximize = () => {
|
||||
if (!isMaximized) {
|
||||
// Store current height before maximizing
|
||||
if (containerRef.current) {
|
||||
setContainerHeight(containerRef.current.scrollHeight || containerHeight);
|
||||
}
|
||||
}
|
||||
setIsMaximized(!isMaximized);
|
||||
};
|
||||
|
||||
// Handle manual resize when dragging resize handle
|
||||
const handleMouseDown = (e: React.MouseEvent) => {
|
||||
if (isMaximized) return; // Don't allow resize when maximized
|
||||
|
||||
e.preventDefault();
|
||||
const startY = e.clientY;
|
||||
const startHeight = containerHeight;
|
||||
|
||||
const handleMouseMove = (e: MouseEvent) => {
|
||||
const deltaY = e.clientY - startY;
|
||||
const newHeight = Math.max(200, Math.min(800, startHeight + deltaY)); // Min 200px, Max 800px
|
||||
setContainerHeight(newHeight);
|
||||
};
|
||||
|
||||
const handleMouseUp = () => {
|
||||
document.removeEventListener('mousemove', handleMouseMove);
|
||||
document.removeEventListener('mouseup', handleMouseUp);
|
||||
};
|
||||
|
||||
document.addEventListener('mousemove', handleMouseMove);
|
||||
document.addEventListener('mouseup', handleMouseUp);
|
||||
};
|
||||
|
||||
// Escape key handler for maximized mode
|
||||
useEffect(() => {
|
||||
const handleEscapeKey = (e: KeyboardEvent) => {
|
||||
if (e.key === 'Escape' && isMaximized) {
|
||||
setIsMaximized(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (isMaximized) {
|
||||
document.addEventListener('keydown', handleEscapeKey);
|
||||
// Prevent body from scrolling when maximized
|
||||
document.body.style.overflow = 'hidden';
|
||||
} else {
|
||||
document.body.style.overflow = '';
|
||||
}
|
||||
|
||||
return () => {
|
||||
document.removeEventListener('keydown', handleEscapeKey);
|
||||
document.body.style.overflow = '';
|
||||
};
|
||||
}, [isMaximized]);
|
||||
|
||||
// Set initial content when component mounts
|
||||
useEffect(() => {
|
||||
const div = visualDivRef.current;
|
||||
@@ -439,6 +498,17 @@ export default function RichTextEditor({
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-1">
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
onClick={toggleMaximize}
|
||||
title={isMaximized ? "Minimize editor" : "Maximize editor"}
|
||||
className="font-mono"
|
||||
>
|
||||
{isMaximized ? "⊡" : "⊞"}
|
||||
</Button>
|
||||
<div className="w-px h-4 bg-gray-300 mx-1" />
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
@@ -504,40 +574,160 @@ export default function RichTextEditor({
|
||||
</div>
|
||||
|
||||
{/* Editor */}
|
||||
<div className="border theme-border rounded-b-lg overflow-hidden">
|
||||
{viewMode === 'visual' ? (
|
||||
<div className="relative">
|
||||
<div
|
||||
ref={visualDivRef}
|
||||
contentEditable
|
||||
onInput={handleVisualContentChange}
|
||||
onPaste={handlePaste}
|
||||
className="p-3 min-h-[300px] focus:outline-none focus:ring-0 whitespace-pre-wrap"
|
||||
style={{ minHeight: '300px' }}
|
||||
suppressContentEditableWarning={true}
|
||||
/>
|
||||
{!value && (
|
||||
<div
|
||||
className="absolute top-3 left-3 text-gray-500 dark:text-gray-400 pointer-events-none select-none"
|
||||
style={{ minHeight: '300px' }}
|
||||
>
|
||||
{placeholder}
|
||||
<div
|
||||
className={`relative border theme-border rounded-b-lg ${
|
||||
isMaximized ? 'fixed inset-4 z-50 bg-white dark:bg-gray-900 shadow-2xl' : ''
|
||||
}`}
|
||||
style={isMaximized ? {} : { height: containerHeight }}
|
||||
>
|
||||
<div
|
||||
ref={containerRef}
|
||||
className="h-full flex flex-col overflow-hidden"
|
||||
>
|
||||
{/* Maximized toolbar (shown when maximized) */}
|
||||
{isMaximized && (
|
||||
<div className="flex items-center justify-between p-2 theme-card border-b theme-border">
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
onClick={() => setViewMode('visual')}
|
||||
className={viewMode === 'visual' ? 'theme-accent-bg text-white' : ''}
|
||||
>
|
||||
Visual
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
onClick={() => setViewMode('html')}
|
||||
className={viewMode === 'html' ? 'theme-accent-bg text-white' : ''}
|
||||
>
|
||||
HTML
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-1">
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
onClick={toggleMaximize}
|
||||
title="Minimize editor"
|
||||
className="font-mono"
|
||||
>
|
||||
⊡
|
||||
</Button>
|
||||
<div className="w-px h-4 bg-gray-300 mx-1" />
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
onClick={() => formatText('strong')}
|
||||
title="Bold"
|
||||
className="font-bold"
|
||||
>
|
||||
B
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
onClick={() => formatText('em')}
|
||||
title="Italic"
|
||||
className="italic"
|
||||
>
|
||||
I
|
||||
</Button>
|
||||
<div className="w-px h-4 bg-gray-300 mx-1" />
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
onClick={() => formatText('h1')}
|
||||
title="Heading 1"
|
||||
className="text-lg font-bold"
|
||||
>
|
||||
H1
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
onClick={() => formatText('h2')}
|
||||
title="Heading 2"
|
||||
className="text-base font-bold"
|
||||
>
|
||||
H2
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
onClick={() => formatText('h3')}
|
||||
title="Heading 3"
|
||||
className="text-sm font-bold"
|
||||
>
|
||||
H3
|
||||
</Button>
|
||||
<div className="w-px h-4 bg-gray-300 mx-1" />
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
onClick={() => formatText('p')}
|
||||
title="Paragraph"
|
||||
>
|
||||
P
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Editor content */}
|
||||
<div className="flex-1 overflow-hidden">
|
||||
{viewMode === 'visual' ? (
|
||||
<div className="relative h-full">
|
||||
<div
|
||||
ref={visualDivRef}
|
||||
contentEditable
|
||||
onInput={handleVisualContentChange}
|
||||
onPaste={handlePaste}
|
||||
className="p-3 h-full overflow-y-auto focus:outline-none focus:ring-0 whitespace-pre-wrap resize-none"
|
||||
suppressContentEditableWarning={true}
|
||||
/>
|
||||
{!value && (
|
||||
<div className="absolute top-3 left-3 text-gray-500 dark:text-gray-400 pointer-events-none select-none">
|
||||
{placeholder}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<Textarea
|
||||
value={htmlValue}
|
||||
onChange={handleHtmlChange}
|
||||
placeholder="<p>Write your HTML content here...</p>"
|
||||
className="border-0 rounded-none focus:ring-0 font-mono text-sm h-full resize-none"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<Textarea
|
||||
value={htmlValue}
|
||||
onChange={handleHtmlChange}
|
||||
placeholder="<p>Write your HTML content here...</p>"
|
||||
rows={12}
|
||||
className="border-0 rounded-none focus:ring-0 font-mono text-sm"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Resize handle (only show when not maximized) */}
|
||||
{!isMaximized && (
|
||||
<div
|
||||
onMouseDown={handleMouseDown}
|
||||
className="absolute bottom-0 left-0 right-0 h-2 cursor-ns-resize bg-gray-200 dark:bg-gray-700 hover:bg-gray-300 dark:hover:bg-gray-600 transition-colors flex items-center justify-center"
|
||||
title="Drag to resize"
|
||||
>
|
||||
<div className="w-8 h-0.5 bg-gray-400 dark:bg-gray-500 rounded-full"></div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Preview for HTML mode */}
|
||||
{viewMode === 'html' && value && (
|
||||
{/* Preview for HTML mode (only show when not maximized) */}
|
||||
{viewMode === 'html' && value && !isMaximized && (
|
||||
<div className="space-y-2">
|
||||
<h4 className="text-sm font-medium theme-header">Preview:</h4>
|
||||
<div
|
||||
@@ -561,6 +751,10 @@ export default function RichTextEditor({
|
||||
<strong>HTML mode:</strong> Edit HTML source directly for advanced formatting.
|
||||
Allowed tags: p, br, div, span, strong, em, b, i, u, s, h1-h6, ul, ol, li, blockquote, and more.
|
||||
</p>
|
||||
<p>
|
||||
<strong>Tips:</strong> Use the ⊞ button to maximize the editor for larger stories.
|
||||
Drag the resize handle at the bottom to adjust height. Press Escape to exit maximized mode.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -32,7 +32,8 @@ export default function ImageUpload({
|
||||
if (rejection.errors?.[0]?.code === 'file-too-large') {
|
||||
setError(`File is too large. Maximum size is ${maxSizeMB}MB.`);
|
||||
} else if (rejection.errors?.[0]?.code === 'file-invalid-type') {
|
||||
setError('Invalid file type. Please select an image file.');
|
||||
const allowedTypes = accept.split(',').map(type => type.trim()).join(', ');
|
||||
setError(`Invalid file type. Supported formats: ${allowedTypes.replace(/image\//g, '').toUpperCase()}.`);
|
||||
} else {
|
||||
setError('File rejected. Please try another file.');
|
||||
}
|
||||
@@ -41,18 +42,31 @@ export default function ImageUpload({
|
||||
|
||||
const file = acceptedFiles[0];
|
||||
if (file) {
|
||||
// Additional client-side validation for file type
|
||||
const allowedTypes = accept.split(',').map(type => type.trim());
|
||||
if (!allowedTypes.includes(file.type)) {
|
||||
const supportedFormats = allowedTypes.map(type => type.replace('image/', '').toUpperCase()).join(', ');
|
||||
setError(`Invalid file type. Your file is ${file.type}. Supported formats: ${supportedFormats}.`);
|
||||
return;
|
||||
}
|
||||
|
||||
// Create preview
|
||||
const previewUrl = URL.createObjectURL(file);
|
||||
setPreview(previewUrl);
|
||||
onImageSelect(file);
|
||||
}
|
||||
}, [onImageSelect, maxSizeMB]);
|
||||
}, [onImageSelect, maxSizeMB, accept]);
|
||||
|
||||
// Build proper accept object for dropzone based on specific MIME types
|
||||
const acceptTypes = accept.split(',').map(type => type.trim());
|
||||
const dropzoneAccept = acceptTypes.reduce((acc, type) => {
|
||||
acc[type] = []; // Empty array means accept files with this MIME type
|
||||
return acc;
|
||||
}, {} as Record<string, string[]>);
|
||||
|
||||
const { getRootProps, getInputProps, isDragActive } = useDropzone({
|
||||
onDrop,
|
||||
accept: {
|
||||
'image/*': accept.split(',').map(type => type.trim()),
|
||||
},
|
||||
accept: dropzoneAccept,
|
||||
maxFiles: 1,
|
||||
maxSize: maxSizeMB * 1024 * 1024, // Convert MB to bytes
|
||||
});
|
||||
@@ -123,7 +137,7 @@ export default function ImageUpload({
|
||||
)}
|
||||
</div>
|
||||
<p className="text-sm text-gray-500">
|
||||
Supports JPEG, PNG, WebP up to {maxSizeMB}MB
|
||||
Supports {acceptTypes.map(type => type.replace('image/', '').toUpperCase()).join(', ')} up to {maxSizeMB}MB
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
Reference in New Issue
Block a user