Various improvements & Epub support

This commit is contained in:
Stefan Hardegger
2025-08-08 14:09:14 +02:00
parent 090b858a54
commit 379c8c170f
37 changed files with 4069 additions and 298 deletions

View 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>
);
}

View File

@@ -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"
/>

View File

@@ -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"

View File

@@ -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>
);

View File

@@ -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>
)}