revert richtext replacement
This commit is contained in:
2025-09-21 10:13:48 +02:00
parent b1dbd85346
commit a5628019f8
28 changed files with 10558 additions and 3337 deletions

View File

@@ -1,118 +0,0 @@
# Portable Text Editor Setup Instructions
## Current Status
⚠️ **Temporarily Reverted to Original Editor**
Due to npm cache permission issues preventing Docker builds, I've temporarily reverted the imports back to `RichTextEditor`. The Portable Text implementation is complete and ready to activate once the npm issue is resolved.
## Files Ready for Portable Text
-`PortableTextEditor.tsx` - Complete implementation
-`schema.ts` - Portable Text schema
-`conversion.ts` - HTML ↔ Portable Text conversion
-`package.json.with-portabletext` - Updated dependencies
## Docker Build Issue Resolution
The error `npm ci` requires `package-lock.json` but npm cache permissions prevent generating it.
### Solution Steps:
1. **Fix npm permissions:**
```bash
sudo chown -R $(whoami) ~/.npm
```
2. **Switch to Portable Text setup:**
```bash
cd frontend
mv package.json package.json.original
mv package.json.with-portabletext package.json
npm install # This will generate package-lock.json
```
3. **Update component imports** (change RichTextEditor → PortableTextEditor):
```typescript
// In src/app/add-story/page.tsx and src/app/stories/[id]/edit/page.tsx
import PortableTextEditor from '../../components/stories/PortableTextEditor';
// And update the JSX to use <PortableTextEditor ... />
```
4. **Build and test:**
```bash
npm run build
docker-compose build
```
## Implementation Complete
**Portable Text Schema** - Defines formatting options matching the original editor
**HTML ↔ Portable Text Conversion** - Seamless conversion between formats
**Sanitization Integration** - Uses existing sanitization strategy
**Component Replacement** - PortableTextEditor replaces RichTextEditor
**Image Processing** - Maintains existing image processing functionality
**Toolbar** - All formatting buttons from original editor
**Keyboard Shortcuts** - Ctrl+B, Ctrl+I, Ctrl+Shift+1-6
## Features Maintained
### 1. **Formatting Options**
- Bold, Italic, Underline, Strike, Code
- Headings H1-H6
- Paragraphs and Blockquotes
- All original toolbar buttons
### 2. **Visual & HTML Modes**
- Visual mode: Structured Portable Text editing
- HTML mode: Direct HTML editing (fallback)
- Live preview in HTML mode
### 3. **Image Processing**
- Existing image processing pipeline maintained
- Background image download and conversion
- Processing status indicators
- Warning system
### 4. **Paste Handling**
- Rich text paste from websites
- Image processing during paste
- HTML sanitization
- Structured content conversion
### 5. **Maximization & Resizing**
- Fullscreen editing mode
- Resizable editor height
- Keyboard shortcuts (Escape to exit)
## Benefits of Portable Text
1. **Structured Content** - Content is stored as JSON, not just HTML
2. **Future-Proof** - Easy to export/migrate content
3. **Better Search** - Structured content works better with Typesense
4. **Extensible** - Easy to add custom block types (images, etc.)
5. **Sanitization** - Inherently safer than HTML parsing
## Next Steps
1. Install the npm packages using one of the methods above
2. Test the editor functionality
3. Verify image processing works correctly
4. Optional: Add custom image block types for enhanced image handling
## File Structure
```
frontend/src/
├── components/stories/
│ ├── PortableTextEditor.tsx # New editor component
│ └── RichTextEditor.tsx # Original (can be removed after testing)
├── lib/portabletext/
│ ├── schema.ts # Portable Text schema and types
│ └── conversion.ts # HTML ↔ Portable Text conversion
└── app/
├── add-story/page.tsx # Updated to use PortableTextEditor
└── stories/[id]/edit/page.tsx # Updated to use PortableTextEditor
```
The implementation is backward compatible and maintains all existing functionality while providing the benefits of structured content editing.

View File

@@ -9,7 +9,7 @@ RUN apk add --no-cache dumb-init
COPY package*.json ./ COPY package*.json ./
# Install dependencies with optimized settings # Install dependencies with optimized settings
RUN npm install --prefer-offline --no-audit --legacy-peer-deps RUN npm ci --prefer-offline --no-audit --frozen-lockfile
# Build stage # Build stage
FROM node:18-alpine AS builder FROM node:18-alpine AS builder

View File

@@ -2,4 +2,4 @@
/// <reference types="next/image-types/global" /> /// <reference types="next/image-types/global" />
// NOTE: This file should not be edited // NOTE: This file should not be edited
// see https://nextjs.org/docs/app/building-your-application/configuring/typescript for more information. // see https://nextjs.org/docs/basic-features/typescript for more information.

6291
frontend/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -11,15 +11,11 @@
}, },
"dependencies": { "dependencies": {
"@heroicons/react": "^2.2.0", "@heroicons/react": "^2.2.0",
"@portabletext/editor": "2.12.0",
"@portabletext/keyboard-shortcuts": "^1.1.1",
"@portabletext/react": "4.0.3",
"@portabletext/types": "2.0.14",
"autoprefixer": "^10.4.16", "autoprefixer": "^10.4.16",
"axios": "^1.11.0", "axios": "^1.11.0",
"cheerio": "^1.0.0-rc.12", "cheerio": "^1.0.0-rc.12",
"dompurify": "^3.2.6", "dompurify": "^3.2.6",
"next": "^14.2.32", "next": "14.0.0",
"postcss": "^8.4.31", "postcss": "^8.4.31",
"react": "^18", "react": "^18",
"react-dom": "^18", "react-dom": "^18",

View File

@@ -1,37 +0,0 @@
{
"name": "storycove-frontend",
"version": "0.1.0",
"private": true,
"scripts": {
"dev": "next dev",
"build": "next build",
"start": "next start",
"lint": "next lint",
"type-check": "tsc --noEmit"
},
"dependencies": {
"@heroicons/react": "^2.2.0",
"@portabletext/react": "4.0.3",
"@portabletext/types": "2.0.14",
"autoprefixer": "^10.4.16",
"axios": "^1.11.0",
"cheerio": "^1.0.0-rc.12",
"dompurify": "^3.2.6",
"next": "14.0.0",
"postcss": "^8.4.31",
"react": "^18",
"react-dom": "^18",
"react-dropzone": "^14.2.3",
"server-only": "^0.0.1",
"tailwindcss": "^3.3.0"
},
"devDependencies": {
"@types/dompurify": "^3.0.5",
"@types/node": "^20",
"@types/react": "^18",
"@types/react-dom": "^18",
"eslint": "^8",
"eslint-config-next": "14.0.0",
"typescript": "^5"
}
}

View File

@@ -1,550 +0,0 @@
'use client';
import { useState, useEffect } from 'react';
import { useRouter, useSearchParams } from 'next/navigation';
import { useAuth } from '../../contexts/AuthContext';
import { Input, Textarea } from '../../components/ui/Input';
import Button from '../../components/ui/Button';
import TagInput from '../../components/stories/TagInput';
import PortableTextEditor from '../../components/stories/PortableTextEditorNew';
import ImageUpload from '../../components/ui/ImageUpload';
import AuthorSelector from '../../components/stories/AuthorSelector';
import SeriesSelector from '../../components/stories/SeriesSelector';
import { storyApi, authorApi } from '../../lib/api';
export default function AddStoryContent() {
const [formData, setFormData] = useState({
title: '',
summary: '',
authorName: '',
authorId: undefined as string | undefined,
contentHtml: '',
sourceUrl: '',
tags: [] as string[],
seriesName: '',
seriesId: undefined as string | undefined,
volume: '',
});
const [coverImage, setCoverImage] = useState<File | null>(null);
const [loading, setLoading] = useState(false);
const [processingImages, setProcessingImages] = useState(false);
const [errors, setErrors] = useState<Record<string, string>>({});
const [duplicateWarning, setDuplicateWarning] = useState<{
show: boolean;
count: number;
duplicates: Array<{
id: string;
title: string;
authorName: string;
createdAt: string;
}>;
}>({ show: false, count: 0, duplicates: [] });
const [checkingDuplicates, setCheckingDuplicates] = useState(false);
const router = useRouter();
const searchParams = useSearchParams();
const { isAuthenticated } = useAuth();
// Handle URL parameters
useEffect(() => {
const authorId = searchParams.get('authorId');
const from = searchParams.get('from');
// Pre-fill author if authorId is provided in URL
if (authorId) {
const loadAuthor = async () => {
try {
const author = await authorApi.getAuthor(authorId);
setFormData(prev => ({
...prev,
authorName: author.name,
authorId: author.id
}));
} catch (error) {
console.error('Failed to load author:', error);
}
};
loadAuthor();
}
// Handle URL import data
if (from === 'url-import') {
const title = searchParams.get('title') || '';
const summary = searchParams.get('summary') || '';
const author = searchParams.get('author') || '';
const sourceUrl = searchParams.get('sourceUrl') || '';
const tagsParam = searchParams.get('tags');
const content = searchParams.get('content') || '';
let tags: string[] = [];
try {
tags = tagsParam ? JSON.parse(tagsParam) : [];
} catch (error) {
console.error('Failed to parse tags:', error);
tags = [];
}
setFormData(prev => ({
...prev,
title,
summary,
authorName: author,
authorId: undefined, // Reset author ID when importing from URL
contentHtml: content,
sourceUrl,
tags
}));
// Show success message
setErrors({ success: 'Story data imported successfully! Review and edit as needed before saving.' });
}
}, [searchParams]);
// Load pending story data from bulk combine operation
useEffect(() => {
const fromBulkCombine = searchParams.get('from') === 'bulk-combine';
if (fromBulkCombine) {
const pendingStoryData = localStorage.getItem('pendingStory');
if (pendingStoryData) {
try {
const storyData = JSON.parse(pendingStoryData);
setFormData(prev => ({
...prev,
title: storyData.title || '',
authorName: storyData.author || '',
authorId: undefined, // Reset author ID for bulk combined stories
contentHtml: storyData.content || '',
sourceUrl: storyData.sourceUrl || '',
summary: storyData.summary || '',
tags: storyData.tags || []
}));
// Clear the pending data
localStorage.removeItem('pendingStory');
} catch (error) {
console.error('Failed to load pending story data:', error);
}
}
}
}, [searchParams]);
// Check for duplicates when title and author are both present
useEffect(() => {
const checkDuplicates = async () => {
const title = formData.title.trim();
const authorName = formData.authorName.trim();
// Don't check if user isn't authenticated or if title/author are empty
if (!isAuthenticated || !title || !authorName) {
setDuplicateWarning({ show: false, count: 0, duplicates: [] });
return;
}
// Debounce the check to avoid too many API calls
const timeoutId = setTimeout(async () => {
try {
setCheckingDuplicates(true);
const result = await storyApi.checkDuplicate(title, authorName);
if (result.hasDuplicates) {
setDuplicateWarning({
show: true,
count: result.count,
duplicates: result.duplicates
});
} else {
setDuplicateWarning({ show: false, count: 0, duplicates: [] });
}
} catch (error) {
console.error('Failed to check for duplicates:', error);
// Clear any existing duplicate warnings on error
setDuplicateWarning({ show: false, count: 0, duplicates: [] });
// Don't show error to user as this is just a helpful warning
// Authentication errors will be handled by the API interceptor
} finally {
setCheckingDuplicates(false);
}
}, 500); // 500ms debounce
return () => clearTimeout(timeoutId);
};
checkDuplicates();
}, [formData.title, formData.authorName, isAuthenticated]);
const handleInputChange = (field: string) => (
e: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement>
) => {
setFormData(prev => ({
...prev,
[field]: e.target.value
}));
// Clear error when user starts typing
if (errors[field]) {
setErrors(prev => ({ ...prev, [field]: '' }));
}
};
const handleContentChange = (html: string) => {
setFormData(prev => ({ ...prev, contentHtml: html }));
if (errors.contentHtml) {
setErrors(prev => ({ ...prev, contentHtml: '' }));
}
};
const handleTagsChange = (tags: string[]) => {
setFormData(prev => ({ ...prev, tags }));
};
const handleAuthorChange = (authorName: string, authorId?: string) => {
setFormData(prev => ({
...prev,
authorName,
authorId: authorId // This will be undefined if creating new author, which clears the existing ID
}));
// Clear error when user changes author
if (errors.authorName) {
setErrors(prev => ({ ...prev, authorName: '' }));
}
};
const handleSeriesChange = (seriesName: string, seriesId?: string) => {
setFormData(prev => ({
...prev,
seriesName,
seriesId: seriesId // This will be undefined if creating new series, which clears the existing ID
}));
// Clear error when user changes series
if (errors.seriesName) {
setErrors(prev => ({ ...prev, seriesName: '' }));
}
};
const validateForm = () => {
const newErrors: Record<string, string> = {};
if (!formData.title.trim()) {
newErrors.title = 'Title is required';
}
if (!formData.authorName.trim()) {
newErrors.authorName = 'Author name is required';
}
if (!formData.contentHtml.trim()) {
newErrors.contentHtml = 'Story content is required';
}
if (formData.seriesName && !formData.volume) {
newErrors.volume = 'Volume number is required when series is specified';
}
if (formData.volume && !formData.seriesName.trim()) {
newErrors.seriesName = 'Series name is required when volume is specified';
}
setErrors(newErrors);
return Object.keys(newErrors).length === 0;
};
// Helper function to detect external images in HTML content
const hasExternalImages = (htmlContent: string): boolean => {
if (!htmlContent) return false;
// Create a temporary DOM element to parse HTML
const tempDiv = document.createElement('div');
tempDiv.innerHTML = htmlContent;
const images = tempDiv.querySelectorAll('img');
for (let i = 0; i < images.length; i++) {
const img = images[i];
const src = img.getAttribute('src');
if (src && (src.startsWith('http://') || src.startsWith('https://'))) {
return true;
}
}
return false;
};
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
if (!validateForm()) {
return;
}
setLoading(true);
try {
// First, create the story with JSON data
const storyData = {
title: formData.title,
summary: formData.summary || undefined,
contentHtml: formData.contentHtml,
sourceUrl: formData.sourceUrl || undefined,
volume: formData.seriesName ? parseInt(formData.volume) : undefined,
// Send seriesId if we have it (existing series), otherwise send seriesName (new series)
...(formData.seriesId ? { seriesId: formData.seriesId } : { seriesName: formData.seriesName || undefined }),
// Send authorId if we have it (existing author), otherwise send authorName (new author)
...(formData.authorId ? { authorId: formData.authorId } : { authorName: formData.authorName }),
tagNames: formData.tags.length > 0 ? formData.tags : undefined,
};
const story = await storyApi.createStory(storyData);
// Process images if there are external images in the content
if (hasExternalImages(formData.contentHtml)) {
try {
setProcessingImages(true);
const imageResult = await storyApi.processContentImages(story.id, formData.contentHtml);
// If images were processed and content was updated, save the updated content
if (imageResult.processedContent !== formData.contentHtml) {
await storyApi.updateStory(story.id, {
title: formData.title,
summary: formData.summary || undefined,
contentHtml: imageResult.processedContent,
sourceUrl: formData.sourceUrl || undefined,
volume: formData.seriesName ? parseInt(formData.volume) : undefined,
...(formData.seriesId ? { seriesId: formData.seriesId } : { seriesName: formData.seriesName || undefined }),
...(formData.authorId ? { authorId: formData.authorId } : { authorName: formData.authorName }),
tagNames: formData.tags.length > 0 ? formData.tags : undefined,
});
// Show success message with image processing info
if (imageResult.downloadedImages.length > 0) {
console.log(`Successfully processed ${imageResult.downloadedImages.length} images`);
}
if (imageResult.warnings && imageResult.warnings.length > 0) {
console.warn('Image processing warnings:', imageResult.warnings);
}
}
} catch (imageError) {
console.error('Failed to process images:', imageError);
// Don't fail the entire operation if image processing fails
// The story was created successfully, just without processed images
} finally {
setProcessingImages(false);
}
}
// If there's a cover image, upload it separately
if (coverImage) {
await storyApi.uploadCover(story.id, coverImage);
}
router.push(`/stories/${story.id}/detail`);
} catch (error: any) {
console.error('Failed to create story:', error);
const errorMessage = error.response?.data?.message || 'Failed to create story';
setErrors({ submit: errorMessage });
} finally {
setLoading(false);
}
};
return (
<>
{/* Success Message */}
{errors.success && (
<div className="p-4 bg-green-50 dark:bg-green-900/20 border border-green-200 dark:border-green-800 rounded-lg mb-6">
<p className="text-green-800 dark:text-green-200">{errors.success}</p>
</div>
)}
<form onSubmit={handleSubmit} className="space-y-6">
{/* Title */}
<Input
label="Title *"
value={formData.title}
onChange={handleInputChange('title')}
placeholder="Enter the story title"
error={errors.title}
required
/>
{/* Author Selector */}
<AuthorSelector
label="Author *"
value={formData.authorName}
onChange={handleAuthorChange}
placeholder="Select or enter author name"
error={errors.authorName}
required
/>
{/* Duplicate Warning */}
{duplicateWarning.show && (
<div className="p-4 bg-yellow-50 dark:bg-yellow-900/20 border border-yellow-200 dark:border-yellow-800 rounded-lg">
<div className="flex items-start gap-3">
<div className="text-yellow-600 dark:text-yellow-400 mt-0.5">
</div>
<div>
<h4 className="font-medium text-yellow-800 dark:text-yellow-200">
Potential Duplicate Detected
</h4>
<p className="text-sm text-yellow-700 dark:text-yellow-300 mt-1">
Found {duplicateWarning.count} existing {duplicateWarning.count === 1 ? 'story' : 'stories'} with the same title and author:
</p>
<ul className="mt-2 space-y-1">
{duplicateWarning.duplicates.map((duplicate, index) => (
<li key={duplicate.id} className="text-sm text-yellow-700 dark:text-yellow-300">
<span className="font-medium">{duplicate.title}</span> by {duplicate.authorName}
<span className="text-xs ml-2">
(added {new Date(duplicate.createdAt).toLocaleDateString()})
</span>
</li>
))}
</ul>
<p className="text-xs text-yellow-600 dark:text-yellow-400 mt-2">
You can still create this story if it's different from the existing ones.
</p>
</div>
</div>
</div>
)}
{/* Checking indicator */}
{checkingDuplicates && (
<div className="flex items-center gap-2 text-sm theme-text">
<div className="animate-spin w-4 h-4 border-2 border-theme-accent border-t-transparent rounded-full"></div>
Checking for duplicates...
</div>
)}
{/* Summary */}
<div>
<label className="block text-sm font-medium theme-header mb-2">
Summary
</label>
<Textarea
value={formData.summary}
onChange={handleInputChange('summary')}
placeholder="Brief summary or description of the story..."
rows={3}
/>
<p className="text-sm theme-text mt-1">
Optional summary that will be displayed on the story detail page
</p>
</div>
{/* Cover Image Upload */}
<div>
<label className="block text-sm font-medium theme-header mb-2">
Cover Image
</label>
<ImageUpload
onImageSelect={setCoverImage}
accept="image/jpeg,image/png"
maxSizeMB={5}
aspectRatio="3:4"
placeholder="Drop a cover image here or click to select"
/>
</div>
{/* Content */}
<div>
<label className="block text-sm font-medium theme-header mb-2">
Story Content *
</label>
<PortableTextEditor
value={formData.contentHtml}
onChange={handleContentChange}
placeholder="Write or paste your story content here..."
error={errors.contentHtml}
enableImageProcessing={false}
/>
<p className="text-sm theme-text mt-2">
💡 <strong>Tip:</strong> If you paste content with images, they'll be automatically downloaded and stored locally when you save the story.
</p>
</div>
{/* Tags */}
<div>
<label className="block text-sm font-medium theme-header mb-2">
Tags
</label>
<TagInput
tags={formData.tags}
onChange={handleTagsChange}
placeholder="Add tags to categorize your story..."
/>
</div>
{/* Series and Volume */}
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<SeriesSelector
label="Series (optional)"
value={formData.seriesName}
onChange={handleSeriesChange}
placeholder="Select or enter series name if part of a series"
error={errors.seriesName}
authorId={formData.authorId}
/>
<Input
label="Volume/Part (optional)"
type="number"
min="1"
value={formData.volume}
onChange={handleInputChange('volume')}
placeholder="Enter volume/part number"
error={errors.volume}
/>
</div>
{/* Source URL */}
<Input
label="Source URL (optional)"
type="url"
value={formData.sourceUrl}
onChange={handleInputChange('sourceUrl')}
placeholder="https://example.com/original-story-url"
/>
{/* Image Processing Indicator */}
{processingImages && (
<div className="p-4 bg-blue-50 dark:bg-blue-900/20 border border-blue-200 dark:border-blue-800 rounded-lg">
<div className="flex items-center gap-3">
<div className="animate-spin w-4 h-4 border-2 border-blue-500 border-t-transparent rounded-full"></div>
<p className="text-blue-800 dark:text-blue-200">
Processing and downloading images...
</p>
</div>
</div>
)}
{/* Submit Error */}
{errors.submit && (
<div className="p-4 bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 rounded-lg">
<p className="text-red-800 dark:text-red-200">{errors.submit}</p>
</div>
)}
{/* Actions */}
<div className="flex justify-end gap-4 pt-6">
<Button
type="button"
variant="ghost"
onClick={() => router.back()}
disabled={loading}
>
Cancel
</Button>
<Button
type="submit"
loading={loading}
disabled={!formData.title || !formData.authorName || !formData.contentHtml}
>
{processingImages ? 'Processing Images...' : 'Add Story'}
</Button>
</div>
</form>
</>
);
}

View File

@@ -1,23 +1,554 @@
'use client'; 'use client';
import { Suspense } from 'react'; import { useState, useEffect } from 'react';
import { useRouter, useSearchParams } from 'next/navigation';
import { useAuth } from '../../contexts/AuthContext';
import ImportLayout from '../../components/layout/ImportLayout'; import ImportLayout from '../../components/layout/ImportLayout';
import LoadingSpinner from '../../components/ui/LoadingSpinner'; import { Input, Textarea } from '../../components/ui/Input';
import AddStoryContent from './AddStoryContent'; import Button from '../../components/ui/Button';
import TagInput from '../../components/stories/TagInput';
import RichTextEditor from '../../components/stories/RichTextEditor';
import ImageUpload from '../../components/ui/ImageUpload';
import AuthorSelector from '../../components/stories/AuthorSelector';
import SeriesSelector from '../../components/stories/SeriesSelector';
import { storyApi, authorApi } from '../../lib/api';
export default function AddStoryPage() { export default function AddStoryPage() {
const [formData, setFormData] = useState({
title: '',
summary: '',
authorName: '',
authorId: undefined as string | undefined,
contentHtml: '',
sourceUrl: '',
tags: [] as string[],
seriesName: '',
seriesId: undefined as string | undefined,
volume: '',
});
const [coverImage, setCoverImage] = useState<File | null>(null);
const [loading, setLoading] = useState(false);
const [processingImages, setProcessingImages] = useState(false);
const [errors, setErrors] = useState<Record<string, string>>({});
const [duplicateWarning, setDuplicateWarning] = useState<{
show: boolean;
count: number;
duplicates: Array<{
id: string;
title: string;
authorName: string;
createdAt: string;
}>;
}>({ show: false, count: 0, duplicates: [] });
const [checkingDuplicates, setCheckingDuplicates] = useState(false);
const router = useRouter();
const searchParams = useSearchParams();
const { isAuthenticated } = useAuth();
// Handle URL parameters
useEffect(() => {
const authorId = searchParams.get('authorId');
const from = searchParams.get('from');
// Pre-fill author if authorId is provided in URL
if (authorId) {
const loadAuthor = async () => {
try {
const author = await authorApi.getAuthor(authorId);
setFormData(prev => ({
...prev,
authorName: author.name,
authorId: author.id
}));
} catch (error) {
console.error('Failed to load author:', error);
}
};
loadAuthor();
}
// Handle URL import data
if (from === 'url-import') {
const title = searchParams.get('title') || '';
const summary = searchParams.get('summary') || '';
const author = searchParams.get('author') || '';
const sourceUrl = searchParams.get('sourceUrl') || '';
const tagsParam = searchParams.get('tags');
const content = searchParams.get('content') || '';
let tags: string[] = [];
try {
tags = tagsParam ? JSON.parse(tagsParam) : [];
} catch (error) {
console.error('Failed to parse tags:', error);
tags = [];
}
setFormData(prev => ({
...prev,
title,
summary,
authorName: author,
authorId: undefined, // Reset author ID when importing from URL
contentHtml: content,
sourceUrl,
tags
}));
// Show success message
setErrors({ success: 'Story data imported successfully! Review and edit as needed before saving.' });
}
}, [searchParams]);
// Load pending story data from bulk combine operation
useEffect(() => {
const fromBulkCombine = searchParams.get('from') === 'bulk-combine';
if (fromBulkCombine) {
const pendingStoryData = localStorage.getItem('pendingStory');
if (pendingStoryData) {
try {
const storyData = JSON.parse(pendingStoryData);
setFormData(prev => ({
...prev,
title: storyData.title || '',
authorName: storyData.author || '',
authorId: undefined, // Reset author ID for bulk combined stories
contentHtml: storyData.content || '',
sourceUrl: storyData.sourceUrl || '',
summary: storyData.summary || '',
tags: storyData.tags || []
}));
// Clear the pending data
localStorage.removeItem('pendingStory');
} catch (error) {
console.error('Failed to load pending story data:', error);
}
}
}
}, [searchParams]);
// Check for duplicates when title and author are both present
useEffect(() => {
const checkDuplicates = async () => {
const title = formData.title.trim();
const authorName = formData.authorName.trim();
// Don't check if user isn't authenticated or if title/author are empty
if (!isAuthenticated || !title || !authorName) {
setDuplicateWarning({ show: false, count: 0, duplicates: [] });
return;
}
// Debounce the check to avoid too many API calls
const timeoutId = setTimeout(async () => {
try {
setCheckingDuplicates(true);
const result = await storyApi.checkDuplicate(title, authorName);
if (result.hasDuplicates) {
setDuplicateWarning({
show: true,
count: result.count,
duplicates: result.duplicates
});
} else {
setDuplicateWarning({ show: false, count: 0, duplicates: [] });
}
} catch (error) {
console.error('Failed to check for duplicates:', error);
// Clear any existing duplicate warnings on error
setDuplicateWarning({ show: false, count: 0, duplicates: [] });
// Don't show error to user as this is just a helpful warning
// Authentication errors will be handled by the API interceptor
} finally {
setCheckingDuplicates(false);
}
}, 500); // 500ms debounce
return () => clearTimeout(timeoutId);
};
checkDuplicates();
}, [formData.title, formData.authorName, isAuthenticated]);
const handleInputChange = (field: string) => (
e: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement>
) => {
setFormData(prev => ({
...prev,
[field]: e.target.value
}));
// Clear error when user starts typing
if (errors[field]) {
setErrors(prev => ({ ...prev, [field]: '' }));
}
};
const handleContentChange = (html: string) => {
setFormData(prev => ({ ...prev, contentHtml: html }));
if (errors.contentHtml) {
setErrors(prev => ({ ...prev, contentHtml: '' }));
}
};
const handleTagsChange = (tags: string[]) => {
setFormData(prev => ({ ...prev, tags }));
};
const handleAuthorChange = (authorName: string, authorId?: string) => {
setFormData(prev => ({
...prev,
authorName,
authorId: authorId // This will be undefined if creating new author, which clears the existing ID
}));
// Clear error when user changes author
if (errors.authorName) {
setErrors(prev => ({ ...prev, authorName: '' }));
}
};
const handleSeriesChange = (seriesName: string, seriesId?: string) => {
setFormData(prev => ({
...prev,
seriesName,
seriesId: seriesId // This will be undefined if creating new series, which clears the existing ID
}));
// Clear error when user changes series
if (errors.seriesName) {
setErrors(prev => ({ ...prev, seriesName: '' }));
}
};
const validateForm = () => {
const newErrors: Record<string, string> = {};
if (!formData.title.trim()) {
newErrors.title = 'Title is required';
}
if (!formData.authorName.trim()) {
newErrors.authorName = 'Author name is required';
}
if (!formData.contentHtml.trim()) {
newErrors.contentHtml = 'Story content is required';
}
if (formData.seriesName && !formData.volume) {
newErrors.volume = 'Volume number is required when series is specified';
}
if (formData.volume && !formData.seriesName.trim()) {
newErrors.seriesName = 'Series name is required when volume is specified';
}
setErrors(newErrors);
return Object.keys(newErrors).length === 0;
};
// Helper function to detect external images in HTML content
const hasExternalImages = (htmlContent: string): boolean => {
if (!htmlContent) return false;
// Create a temporary DOM element to parse HTML
const tempDiv = document.createElement('div');
tempDiv.innerHTML = htmlContent;
const images = tempDiv.querySelectorAll('img');
for (let i = 0; i < images.length; i++) {
const img = images[i];
const src = img.getAttribute('src');
if (src && (src.startsWith('http://') || src.startsWith('https://'))) {
return true;
}
}
return false;
};
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
if (!validateForm()) {
return;
}
setLoading(true);
try {
// First, create the story with JSON data
const storyData = {
title: formData.title,
summary: formData.summary || undefined,
contentHtml: formData.contentHtml,
sourceUrl: formData.sourceUrl || undefined,
volume: formData.seriesName ? parseInt(formData.volume) : undefined,
// Send seriesId if we have it (existing series), otherwise send seriesName (new series)
...(formData.seriesId ? { seriesId: formData.seriesId } : { seriesName: formData.seriesName || undefined }),
// Send authorId if we have it (existing author), otherwise send authorName (new author)
...(formData.authorId ? { authorId: formData.authorId } : { authorName: formData.authorName }),
tagNames: formData.tags.length > 0 ? formData.tags : undefined,
};
const story = await storyApi.createStory(storyData);
// Process images if there are external images in the content
if (hasExternalImages(formData.contentHtml)) {
try {
setProcessingImages(true);
const imageResult = await storyApi.processContentImages(story.id, formData.contentHtml);
// If images were processed and content was updated, save the updated content
if (imageResult.processedContent !== formData.contentHtml) {
await storyApi.updateStory(story.id, {
title: formData.title,
summary: formData.summary || undefined,
contentHtml: imageResult.processedContent,
sourceUrl: formData.sourceUrl || undefined,
volume: formData.seriesName ? parseInt(formData.volume) : undefined,
...(formData.seriesId ? { seriesId: formData.seriesId } : { seriesName: formData.seriesName || undefined }),
...(formData.authorId ? { authorId: formData.authorId } : { authorName: formData.authorName }),
tagNames: formData.tags.length > 0 ? formData.tags : undefined,
});
// Show success message with image processing info
if (imageResult.downloadedImages.length > 0) {
console.log(`Successfully processed ${imageResult.downloadedImages.length} images`);
}
if (imageResult.warnings && imageResult.warnings.length > 0) {
console.warn('Image processing warnings:', imageResult.warnings);
}
}
} catch (imageError) {
console.error('Failed to process images:', imageError);
// Don't fail the entire operation if image processing fails
// The story was created successfully, just without processed images
} finally {
setProcessingImages(false);
}
}
// If there's a cover image, upload it separately
if (coverImage) {
await storyApi.uploadCover(story.id, coverImage);
}
router.push(`/stories/${story.id}/detail`);
} catch (error: any) {
console.error('Failed to create story:', error);
const errorMessage = error.response?.data?.message || 'Failed to create story';
setErrors({ submit: errorMessage });
} finally {
setLoading(false);
}
};
return ( return (
<ImportLayout <ImportLayout
title="Add New Story" title="Add New Story"
description="Add a story to your personal collection" description="Add a story to your personal collection"
> >
<Suspense fallback={ {/* Success Message */}
<div className="flex items-center justify-center py-20"> {errors.success && (
<LoadingSpinner size="lg" /> <div className="p-4 bg-green-50 dark:bg-green-900/20 border border-green-200 dark:border-green-800 rounded-lg mb-6">
<p className="text-green-800 dark:text-green-200">{errors.success}</p>
</div> </div>
}> )}
<AddStoryContent />
</Suspense> <form onSubmit={handleSubmit} className="space-y-6">
{/* Title */}
<Input
label="Title *"
value={formData.title}
onChange={handleInputChange('title')}
placeholder="Enter the story title"
error={errors.title}
required
/>
{/* Author Selector */}
<AuthorSelector
label="Author *"
value={formData.authorName}
onChange={handleAuthorChange}
placeholder="Select or enter author name"
error={errors.authorName}
required
/>
{/* Duplicate Warning */}
{duplicateWarning.show && (
<div className="p-4 bg-yellow-50 dark:bg-yellow-900/20 border border-yellow-200 dark:border-yellow-800 rounded-lg">
<div className="flex items-start gap-3">
<div className="text-yellow-600 dark:text-yellow-400 mt-0.5">
</div>
<div>
<h4 className="font-medium text-yellow-800 dark:text-yellow-200">
Potential Duplicate Detected
</h4>
<p className="text-sm text-yellow-700 dark:text-yellow-300 mt-1">
Found {duplicateWarning.count} existing {duplicateWarning.count === 1 ? 'story' : 'stories'} with the same title and author:
</p>
<ul className="mt-2 space-y-1">
{duplicateWarning.duplicates.map((duplicate, index) => (
<li key={duplicate.id} className="text-sm text-yellow-700 dark:text-yellow-300">
<span className="font-medium">{duplicate.title}</span> by {duplicate.authorName}
<span className="text-xs ml-2">
(added {new Date(duplicate.createdAt).toLocaleDateString()})
</span>
</li>
))}
</ul>
<p className="text-xs text-yellow-600 dark:text-yellow-400 mt-2">
You can still create this story if it's different from the existing ones.
</p>
</div>
</div>
</div>
)}
{/* Checking indicator */}
{checkingDuplicates && (
<div className="flex items-center gap-2 text-sm theme-text">
<div className="animate-spin w-4 h-4 border-2 border-theme-accent border-t-transparent rounded-full"></div>
Checking for duplicates...
</div>
)}
{/* Summary */}
<div>
<label className="block text-sm font-medium theme-header mb-2">
Summary
</label>
<Textarea
value={formData.summary}
onChange={handleInputChange('summary')}
placeholder="Brief summary or description of the story..."
rows={3}
/>
<p className="text-sm theme-text mt-1">
Optional summary that will be displayed on the story detail page
</p>
</div>
{/* Cover Image Upload */}
<div>
<label className="block text-sm font-medium theme-header mb-2">
Cover Image
</label>
<ImageUpload
onImageSelect={setCoverImage}
accept="image/jpeg,image/png"
maxSizeMB={5}
aspectRatio="3:4"
placeholder="Drop a cover image here or click to select"
/>
</div>
{/* Content */}
<div>
<label className="block text-sm font-medium theme-header mb-2">
Story Content *
</label>
<RichTextEditor
value={formData.contentHtml}
onChange={handleContentChange}
placeholder="Write or paste your story content here..."
error={errors.contentHtml}
enableImageProcessing={false}
/>
<p className="text-sm theme-text mt-2">
💡 <strong>Tip:</strong> If you paste content with images, they'll be automatically downloaded and stored locally when you save the story.
</p>
</div>
{/* Tags */}
<div>
<label className="block text-sm font-medium theme-header mb-2">
Tags
</label>
<TagInput
tags={formData.tags}
onChange={handleTagsChange}
placeholder="Add tags to categorize your story..."
/>
</div>
{/* Series and Volume */}
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<SeriesSelector
label="Series (optional)"
value={formData.seriesName}
onChange={handleSeriesChange}
placeholder="Select or enter series name if part of a series"
error={errors.seriesName}
authorId={formData.authorId}
/>
<Input
label="Volume/Part (optional)"
type="number"
min="1"
value={formData.volume}
onChange={handleInputChange('volume')}
placeholder="Enter volume/part number"
error={errors.volume}
/>
</div>
{/* Source URL */}
<Input
label="Source URL (optional)"
type="url"
value={formData.sourceUrl}
onChange={handleInputChange('sourceUrl')}
placeholder="https://example.com/original-story-url"
/>
{/* Image Processing Indicator */}
{processingImages && (
<div className="p-4 bg-blue-50 dark:bg-blue-900/20 border border-blue-200 dark:border-blue-800 rounded-lg">
<div className="flex items-center gap-3">
<div className="animate-spin w-4 h-4 border-2 border-blue-500 border-t-transparent rounded-full"></div>
<p className="text-blue-800 dark:text-blue-200">
Processing and downloading images...
</p>
</div>
</div>
)}
{/* Submit Error */}
{errors.submit && (
<div className="p-4 bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 rounded-lg">
<p className="text-red-800 dark:text-red-200">{errors.submit}</p>
</div>
)}
{/* Actions */}
<div className="flex justify-end gap-4 pt-6">
<Button
type="button"
variant="ghost"
onClick={() => router.back()}
disabled={loading}
>
Cancel
</Button>
<Button
type="submit"
loading={loading}
disabled={!formData.title || !formData.authorName || !formData.contentHtml}
>
{processingImages ? 'Processing Images...' : 'Add Story'}
</Button>
</div>
</form>
</ImportLayout> </ImportLayout>
); );
} }

View File

@@ -1,341 +0,0 @@
'use client';
import { useState, useEffect } from 'react';
import { useRouter, useSearchParams } from 'next/navigation';
import { searchApi, storyApi, tagApi } from '../../lib/api';
import { Story, Tag, FacetCount, AdvancedFilters } from '../../types/api';
import { Input } from '../../components/ui/Input';
import Button from '../../components/ui/Button';
import StoryMultiSelect from '../../components/stories/StoryMultiSelect';
import TagFilter from '../../components/stories/TagFilter';
import LoadingSpinner from '../../components/ui/LoadingSpinner';
import SidebarLayout from '../../components/library/SidebarLayout';
import ToolbarLayout from '../../components/library/ToolbarLayout';
import MinimalLayout from '../../components/library/MinimalLayout';
import { useLibraryLayout } from '../../hooks/useLibraryLayout';
type ViewMode = 'grid' | 'list';
type SortOption = 'createdAt' | 'title' | 'authorName' | 'rating' | 'wordCount' | 'lastRead';
export default function LibraryContent() {
const router = useRouter();
const searchParams = useSearchParams();
const { layout } = useLibraryLayout();
const [stories, setStories] = useState<Story[]>([]);
const [tags, setTags] = useState<Tag[]>([]);
const [loading, setLoading] = useState(false);
const [searchLoading, setSearchLoading] = useState(false);
const [randomLoading, setRandomLoading] = useState(false);
const [searchQuery, setSearchQuery] = useState('');
const [selectedTags, setSelectedTags] = useState<string[]>([]);
const [viewMode, setViewMode] = useState<ViewMode>('list');
const [sortOption, setSortOption] = useState<SortOption>('lastRead');
const [sortDirection, setSortDirection] = useState<'asc' | 'desc'>('desc');
const [page, setPage] = useState(0);
const [totalPages, setTotalPages] = useState(1);
const [totalElements, setTotalElements] = useState(0);
const [refreshTrigger, setRefreshTrigger] = useState(0);
const [urlParamsProcessed, setUrlParamsProcessed] = useState(false);
const [advancedFilters, setAdvancedFilters] = useState<AdvancedFilters>({});
// Initialize filters from URL parameters
useEffect(() => {
const tagsParam = searchParams.get('tags');
if (tagsParam) {
console.log('URL tag filter detected:', tagsParam);
// Use functional updates to ensure all state changes happen together
setSelectedTags([tagsParam]);
setPage(0); // Reset to first page when applying URL filter
}
setUrlParamsProcessed(true);
}, [searchParams]);
// Convert facet counts to Tag objects for the UI, enriched with full tag data
const [fullTags, setFullTags] = useState<Tag[]>([]);
// Fetch full tag data for enrichment
useEffect(() => {
const fetchFullTags = async () => {
try {
const result = await tagApi.getTags({ size: 1000 }); // Get all tags
setFullTags(result.content || []);
} catch (error) {
console.error('Failed to fetch full tag data:', error);
setFullTags([]);
}
};
fetchFullTags();
}, []);
const convertFacetsToTags = (facets?: Record<string, FacetCount[]>): Tag[] => {
if (!facets || !facets.tagNames) {
return [];
}
return facets.tagNames.map(facet => {
// Find the full tag data by name
const fullTag = fullTags.find(tag => tag.name.toLowerCase() === facet.value.toLowerCase());
return {
id: fullTag?.id || facet.value, // Use actual ID if available, fallback to name
name: facet.value,
storyCount: facet.count,
// Include color and other metadata from the full tag data
color: fullTag?.color,
description: fullTag?.description,
aliasCount: fullTag?.aliasCount,
createdAt: fullTag?.createdAt,
aliases: fullTag?.aliases
};
});
};
// Enrich existing tags when fullTags are loaded
useEffect(() => {
if (fullTags.length > 0) {
// Use functional update to get the current tags state
setTags(currentTags => {
if (currentTags.length > 0) {
// Check if tags already have color data to avoid infinite loops
const hasColors = currentTags.some(tag => tag.color);
if (!hasColors) {
// Re-enrich existing tags with color data
return currentTags.map(tag => {
const fullTag = fullTags.find(ft => ft.name.toLowerCase() === tag.name.toLowerCase());
return {
...tag,
color: fullTag?.color,
description: fullTag?.description,
aliasCount: fullTag?.aliasCount,
createdAt: fullTag?.createdAt,
aliases: fullTag?.aliases,
id: fullTag?.id || tag.id
};
});
}
}
return currentTags; // Return unchanged if no enrichment needed
});
}
}, [fullTags]); // Only run when fullTags change
// Debounce search to avoid too many API calls
useEffect(() => {
// Don't run search until URL parameters have been processed
if (!urlParamsProcessed) return;
const debounceTimer = setTimeout(() => {
const performSearch = async () => {
try {
// Use searchLoading for background search, loading only for initial load
const isInitialLoad = stories.length === 0 && !searchQuery;
if (isInitialLoad) {
setLoading(true);
} else {
setSearchLoading(true);
}
// Always use search API for consistency - use '*' for match-all when no query
const apiParams = {
query: searchQuery.trim() || '*',
page: page, // Use 0-based pagination consistently
size: 20,
tags: selectedTags.length > 0 ? selectedTags : undefined,
sortBy: sortOption,
sortDir: sortDirection,
facetBy: ['tagNames'], // Request tag facets for the filter UI
// Advanced filters
...advancedFilters
};
console.log('Performing search with params:', apiParams);
const result = await searchApi.search(apiParams);
const currentStories = result?.results || [];
setStories(currentStories);
setTotalPages(Math.ceil((result?.totalHits || 0) / 20));
setTotalElements(result?.totalHits || 0);
// Update tags from facets - these represent all matching stories, not just current page
const resultTags = convertFacetsToTags(result?.facets);
setTags(resultTags);
} catch (error) {
console.error('Failed to load stories:', error);
setStories([]);
setTags([]);
} finally {
setLoading(false);
setSearchLoading(false);
}
};
performSearch();
}, searchQuery ? 500 : 0); // Debounce search queries, but load immediately for filters/pagination
return () => clearTimeout(debounceTimer);
}, [searchQuery, selectedTags, sortOption, sortDirection, page, refreshTrigger, urlParamsProcessed, advancedFilters]);
const handleSearchChange = (e: React.ChangeEvent<HTMLInputElement>) => {
setSearchQuery(e.target.value);
setPage(0);
};
const handleStoryUpdate = () => {
setRefreshTrigger(prev => prev + 1);
};
const handleRandomStory = async () => {
if (totalElements === 0) return;
try {
setRandomLoading(true);
const randomStory = await storyApi.getRandomStory({
searchQuery: searchQuery || undefined,
tags: selectedTags.length > 0 ? selectedTags : undefined,
...advancedFilters
});
if (randomStory) {
router.push(`/stories/${randomStory.id}`);
} else {
alert('No stories available. Please add some stories first.');
}
} catch (error) {
console.error('Failed to get random story:', error);
alert('Failed to get a random story. Please try again.');
} finally {
setRandomLoading(false);
}
};
const clearFilters = () => {
setSearchQuery('');
setSelectedTags([]);
setAdvancedFilters({});
setPage(0);
setRefreshTrigger(prev => prev + 1);
};
const handleTagToggle = (tagName: string) => {
setSelectedTags(prev =>
prev.includes(tagName)
? prev.filter(t => t !== tagName)
: [...prev, tagName]
);
setPage(0);
setRefreshTrigger(prev => prev + 1);
};
const handleSortDirectionToggle = () => {
setSortDirection(prev => prev === 'asc' ? 'desc' : 'asc');
};
const handleAdvancedFiltersChange = (filters: AdvancedFilters) => {
setAdvancedFilters(filters);
setPage(0);
setRefreshTrigger(prev => prev + 1);
};
if (loading) {
return (
<div className="flex items-center justify-center py-20">
<LoadingSpinner size="lg" />
</div>
);
}
const handleSortChange = (option: string) => {
setSortOption(option as SortOption);
};
const layoutProps = {
stories,
tags,
totalElements,
searchQuery,
selectedTags,
viewMode,
sortOption,
sortDirection,
advancedFilters,
onSearchChange: handleSearchChange,
onTagToggle: handleTagToggle,
onViewModeChange: setViewMode,
onSortChange: handleSortChange,
onSortDirectionToggle: handleSortDirectionToggle,
onAdvancedFiltersChange: handleAdvancedFiltersChange,
onRandomStory: handleRandomStory,
onClearFilters: clearFilters,
};
const renderContent = () => {
if (stories.length === 0 && !loading) {
return (
<div className="text-center py-12 theme-card theme-shadow rounded-lg">
<p className="theme-text text-lg mb-4">
{searchQuery || selectedTags.length > 0 || Object.values(advancedFilters).some(v => v !== undefined && v !== '' && v !== 'all' && v !== false)
? 'No stories match your search criteria.'
: 'Your library is empty.'
}
</p>
{searchQuery || selectedTags.length > 0 || Object.values(advancedFilters).some(v => v !== undefined && v !== '' && v !== 'all' && v !== false) ? (
<Button variant="ghost" onClick={clearFilters}>
Clear Filters
</Button>
) : (
<Button href="/add-story">
Add Your First Story
</Button>
)}
</div>
);
}
return (
<>
<StoryMultiSelect
stories={stories}
viewMode={viewMode}
onUpdate={handleStoryUpdate}
allowMultiSelect={true}
/>
{/* Pagination */}
{totalPages > 1 && (
<div className="flex justify-center gap-2 mt-8">
<Button
variant="ghost"
onClick={() => setPage(page - 1)}
disabled={page === 0}
>
Previous
</Button>
<span className="flex items-center px-4 py-2 theme-text">
Page {page + 1} of {totalPages}
</span>
<Button
variant="ghost"
onClick={() => setPage(page + 1)}
disabled={page >= totalPages - 1}
>
Next
</Button>
</div>
)}
</>
);
};
const LayoutComponent = layout === 'sidebar' ? SidebarLayout :
layout === 'toolbar' ? ToolbarLayout :
MinimalLayout;
return (
<LayoutComponent {...layoutProps}>
{renderContent()}
</LayoutComponent>
);
}

View File

@@ -1,20 +1,346 @@
'use client'; 'use client';
import { Suspense } from 'react'; import { useState, useEffect } from 'react';
import { useRouter, useSearchParams } from 'next/navigation';
import { searchApi, storyApi, tagApi } from '../../lib/api';
import { Story, Tag, FacetCount, AdvancedFilters } from '../../types/api';
import AppLayout from '../../components/layout/AppLayout'; import AppLayout from '../../components/layout/AppLayout';
import { Input } from '../../components/ui/Input';
import Button from '../../components/ui/Button';
import StoryMultiSelect from '../../components/stories/StoryMultiSelect';
import TagFilter from '../../components/stories/TagFilter';
import LoadingSpinner from '../../components/ui/LoadingSpinner'; import LoadingSpinner from '../../components/ui/LoadingSpinner';
import LibraryContent from './LibraryContent'; import SidebarLayout from '../../components/library/SidebarLayout';
import ToolbarLayout from '../../components/library/ToolbarLayout';
import MinimalLayout from '../../components/library/MinimalLayout';
import { useLibraryLayout } from '../../hooks/useLibraryLayout';
type ViewMode = 'grid' | 'list';
type SortOption = 'createdAt' | 'title' | 'authorName' | 'rating' | 'wordCount' | 'lastRead';
export default function LibraryPage() { export default function LibraryPage() {
return ( const router = useRouter();
<AppLayout> const searchParams = useSearchParams();
<Suspense fallback={ const { layout } = useLibraryLayout();
const [stories, setStories] = useState<Story[]>([]);
const [tags, setTags] = useState<Tag[]>([]);
const [loading, setLoading] = useState(false);
const [searchLoading, setSearchLoading] = useState(false);
const [randomLoading, setRandomLoading] = useState(false);
const [searchQuery, setSearchQuery] = useState('');
const [selectedTags, setSelectedTags] = useState<string[]>([]);
const [viewMode, setViewMode] = useState<ViewMode>('list');
const [sortOption, setSortOption] = useState<SortOption>('lastRead');
const [sortDirection, setSortDirection] = useState<'asc' | 'desc'>('desc');
const [page, setPage] = useState(0);
const [totalPages, setTotalPages] = useState(1);
const [totalElements, setTotalElements] = useState(0);
const [refreshTrigger, setRefreshTrigger] = useState(0);
const [urlParamsProcessed, setUrlParamsProcessed] = useState(false);
const [advancedFilters, setAdvancedFilters] = useState<AdvancedFilters>({});
// Initialize filters from URL parameters
useEffect(() => {
const tagsParam = searchParams.get('tags');
if (tagsParam) {
console.log('URL tag filter detected:', tagsParam);
// Use functional updates to ensure all state changes happen together
setSelectedTags([tagsParam]);
setPage(0); // Reset to first page when applying URL filter
}
setUrlParamsProcessed(true);
}, [searchParams]);
// Convert facet counts to Tag objects for the UI, enriched with full tag data
const [fullTags, setFullTags] = useState<Tag[]>([]);
// Fetch full tag data for enrichment
useEffect(() => {
const fetchFullTags = async () => {
try {
const result = await tagApi.getTags({ size: 1000 }); // Get all tags
setFullTags(result.content || []);
} catch (error) {
console.error('Failed to fetch full tag data:', error);
setFullTags([]);
}
};
fetchFullTags();
}, []);
const convertFacetsToTags = (facets?: Record<string, FacetCount[]>): Tag[] => {
if (!facets || !facets.tagNames) {
return [];
}
return facets.tagNames.map(facet => {
// Find the full tag data by name
const fullTag = fullTags.find(tag => tag.name.toLowerCase() === facet.value.toLowerCase());
return {
id: fullTag?.id || facet.value, // Use actual ID if available, fallback to name
name: facet.value,
storyCount: facet.count,
// Include color and other metadata from the full tag data
color: fullTag?.color,
description: fullTag?.description,
aliasCount: fullTag?.aliasCount,
createdAt: fullTag?.createdAt,
aliases: fullTag?.aliases
};
});
};
// Enrich existing tags when fullTags are loaded
useEffect(() => {
if (fullTags.length > 0) {
// Use functional update to get the current tags state
setTags(currentTags => {
if (currentTags.length > 0) {
// Check if tags already have color data to avoid infinite loops
const hasColors = currentTags.some(tag => tag.color);
if (!hasColors) {
// Re-enrich existing tags with color data
return currentTags.map(tag => {
const fullTag = fullTags.find(ft => ft.name.toLowerCase() === tag.name.toLowerCase());
return {
...tag,
color: fullTag?.color,
description: fullTag?.description,
aliasCount: fullTag?.aliasCount,
createdAt: fullTag?.createdAt,
aliases: fullTag?.aliases,
id: fullTag?.id || tag.id
};
});
}
}
return currentTags; // Return unchanged if no enrichment needed
});
}
}, [fullTags]); // Only run when fullTags change
// Debounce search to avoid too many API calls
useEffect(() => {
// Don't run search until URL parameters have been processed
if (!urlParamsProcessed) return;
const debounceTimer = setTimeout(() => {
const performSearch = async () => {
try {
// Use searchLoading for background search, loading only for initial load
const isInitialLoad = stories.length === 0 && !searchQuery;
if (isInitialLoad) {
setLoading(true);
} else {
setSearchLoading(true);
}
// Always use search API for consistency - use '*' for match-all when no query
const apiParams = {
query: searchQuery.trim() || '*',
page: page, // Use 0-based pagination consistently
size: 20,
tags: selectedTags.length > 0 ? selectedTags : undefined,
sortBy: sortOption,
sortDir: sortDirection,
facetBy: ['tagNames'], // Request tag facets for the filter UI
// Advanced filters
...advancedFilters
};
console.log('Performing search with params:', apiParams);
const result = await searchApi.search(apiParams);
const currentStories = result?.results || [];
setStories(currentStories);
setTotalPages(Math.ceil((result?.totalHits || 0) / 20));
setTotalElements(result?.totalHits || 0);
// Update tags from facets - these represent all matching stories, not just current page
const resultTags = convertFacetsToTags(result?.facets);
setTags(resultTags);
} catch (error) {
console.error('Failed to load stories:', error);
setStories([]);
setTags([]);
} finally {
setLoading(false);
setSearchLoading(false);
}
};
performSearch();
}, searchQuery ? 500 : 0); // Debounce search queries, but load immediately for filters/pagination
return () => clearTimeout(debounceTimer);
}, [searchQuery, selectedTags, sortOption, sortDirection, page, refreshTrigger, urlParamsProcessed, advancedFilters]);
const handleSearchChange = (e: React.ChangeEvent<HTMLInputElement>) => {
setSearchQuery(e.target.value);
setPage(0);
};
const handleStoryUpdate = () => {
setRefreshTrigger(prev => prev + 1);
};
const handleRandomStory = async () => {
if (totalElements === 0) return;
try {
setRandomLoading(true);
const randomStory = await storyApi.getRandomStory({
searchQuery: searchQuery || undefined,
tags: selectedTags.length > 0 ? selectedTags : undefined,
...advancedFilters
});
if (randomStory) {
router.push(`/stories/${randomStory.id}`);
} else {
alert('No stories available. Please add some stories first.');
}
} catch (error) {
console.error('Failed to get random story:', error);
alert('Failed to get a random story. Please try again.');
} finally {
setRandomLoading(false);
}
};
const clearFilters = () => {
setSearchQuery('');
setSelectedTags([]);
setAdvancedFilters({});
setPage(0);
setRefreshTrigger(prev => prev + 1);
};
const handleTagToggle = (tagName: string) => {
setSelectedTags(prev =>
prev.includes(tagName)
? prev.filter(t => t !== tagName)
: [...prev, tagName]
);
setPage(0);
setRefreshTrigger(prev => prev + 1);
};
const handleSortDirectionToggle = () => {
setSortDirection(prev => prev === 'asc' ? 'desc' : 'asc');
};
const handleAdvancedFiltersChange = (filters: AdvancedFilters) => {
setAdvancedFilters(filters);
setPage(0);
setRefreshTrigger(prev => prev + 1);
};
if (loading) {
return (
<AppLayout>
<div className="flex items-center justify-center py-20"> <div className="flex items-center justify-center py-20">
<LoadingSpinner size="lg" /> <LoadingSpinner size="lg" />
</div> </div>
}> </AppLayout>
<LibraryContent /> );
</Suspense> }
const handleSortChange = (option: string) => {
setSortOption(option as SortOption);
};
const layoutProps = {
stories,
tags,
totalElements,
searchQuery,
selectedTags,
viewMode,
sortOption,
sortDirection,
advancedFilters,
onSearchChange: handleSearchChange,
onTagToggle: handleTagToggle,
onViewModeChange: setViewMode,
onSortChange: handleSortChange,
onSortDirectionToggle: handleSortDirectionToggle,
onAdvancedFiltersChange: handleAdvancedFiltersChange,
onRandomStory: handleRandomStory,
onClearFilters: clearFilters,
};
const renderContent = () => {
if (stories.length === 0 && !loading) {
return (
<div className="text-center py-12 theme-card theme-shadow rounded-lg">
<p className="theme-text text-lg mb-4">
{searchQuery || selectedTags.length > 0 || Object.values(advancedFilters).some(v => v !== undefined && v !== '' && v !== 'all' && v !== false)
? 'No stories match your search criteria.'
: 'Your library is empty.'
}
</p>
{searchQuery || selectedTags.length > 0 || Object.values(advancedFilters).some(v => v !== undefined && v !== '' && v !== 'all' && v !== false) ? (
<Button variant="ghost" onClick={clearFilters}>
Clear Filters
</Button>
) : (
<Button href="/add-story">
Add Your First Story
</Button>
)}
</div>
);
}
return (
<>
<StoryMultiSelect
stories={stories}
viewMode={viewMode}
onUpdate={handleStoryUpdate}
allowMultiSelect={true}
/>
{/* Pagination */}
{totalPages > 1 && (
<div className="flex justify-center gap-2 mt-8">
<Button
variant="ghost"
onClick={() => setPage(page - 1)}
disabled={page === 0}
>
Previous
</Button>
<span className="flex items-center px-4 py-2 theme-text">
Page {page + 1} of {totalPages}
</span>
<Button
variant="ghost"
onClick={() => setPage(page + 1)}
disabled={page >= totalPages - 1}
>
Next
</Button>
</div>
)}
</>
);
};
const LayoutComponent = layout === 'sidebar' ? SidebarLayout :
layout === 'toolbar' ? ToolbarLayout :
MinimalLayout;
return (
<AppLayout>
<LayoutComponent {...layoutProps}>
{renderContent()}
</LayoutComponent>
</AppLayout> </AppLayout>
); );
} }

View File

@@ -1,9 +1,27 @@
import { NextRequest } from 'next/server'; import { NextRequest } from 'next/server';
import { progressStore, type ProgressUpdate } from '../../../../lib/progress';
// Configure route timeout for long-running progress streams // Configure route timeout for long-running progress streams
export const maxDuration = 900; // 15 minutes (900 seconds) export const maxDuration = 900; // 15 minutes (900 seconds)
interface ProgressUpdate {
type: 'progress' | 'completed' | 'error';
current: number;
total: number;
message: string;
url?: string;
title?: string;
author?: string;
wordCount?: number;
totalWordCount?: number;
error?: string;
combinedStory?: any;
results?: any[];
summary?: any;
}
// Global progress storage (in production, use Redis or database)
const progressStore = new Map<string, ProgressUpdate[]>();
export async function GET(request: NextRequest) { export async function GET(request: NextRequest) {
const searchParams = request.nextUrl.searchParams; const searchParams = request.nextUrl.searchParams;
const sessionId = searchParams.get('sessionId'); const sessionId = searchParams.get('sessionId');
@@ -63,3 +81,13 @@ export async function GET(request: NextRequest) {
}); });
} }
// Helper function for other routes to send progress updates
export function sendProgressUpdate(sessionId: string, update: ProgressUpdate) {
if (!progressStore.has(sessionId)) {
progressStore.set(sessionId, []);
}
progressStore.get(sessionId)!.push(update);
}
// Export the helper for other modules to use
export { progressStore };

View File

@@ -4,7 +4,15 @@ import { NextRequest, NextResponse } from 'next/server';
export const maxDuration = 900; // 15 minutes (900 seconds) export const maxDuration = 900; // 15 minutes (900 seconds)
// Import progress tracking helper // Import progress tracking helper
import { sendProgressUpdate } from '../../../lib/progress'; async function sendProgressUpdate(sessionId: string, update: any) {
try {
// Dynamic import to avoid circular dependency
const { sendProgressUpdate: sendUpdate } = await import('./progress/route');
sendUpdate(sessionId, update);
} catch (error) {
console.warn('Failed to send progress update:', error);
}
}
interface BulkImportRequest { interface BulkImportRequest {
urls: string[]; urls: string[];

View File

@@ -1,183 +0,0 @@
'use client';
import { useState, useEffect } from 'react';
import { useRouter, useSearchParams } from 'next/navigation';
import TabNavigation from '../../components/ui/TabNavigation';
import AppearanceSettings from '../../components/settings/AppearanceSettings';
import ContentSettings from '../../components/settings/ContentSettings';
import SystemSettings from '../../components/settings/SystemSettings';
import Button from '../../components/ui/Button';
import { useTheme } from '../../lib/theme';
type FontFamily = 'serif' | 'sans' | 'mono';
type FontSize = 'small' | 'medium' | 'large' | 'extra-large';
type ReadingWidth = 'narrow' | 'medium' | 'wide';
interface Settings {
theme: 'light' | 'dark';
fontFamily: FontFamily;
fontSize: FontSize;
readingWidth: ReadingWidth;
readingSpeed: number; // words per minute
}
const defaultSettings: Settings = {
theme: 'light',
fontFamily: 'serif',
fontSize: 'medium',
readingWidth: 'medium',
readingSpeed: 200,
};
const tabs = [
{ id: 'appearance', label: 'Appearance', icon: '🎨' },
{ id: 'content', label: 'Content', icon: '🏷️' },
{ id: 'system', label: 'System', icon: '🔧' },
];
export default function SettingsContent() {
const router = useRouter();
const searchParams = useSearchParams();
const { theme, setTheme } = useTheme();
const [settings, setSettings] = useState<Settings>(defaultSettings);
const [saved, setSaved] = useState(false);
const [activeTab, setActiveTab] = useState('appearance');
// Initialize tab from URL parameter
useEffect(() => {
const tabFromUrl = searchParams.get('tab');
if (tabFromUrl && tabs.some(tab => tab.id === tabFromUrl)) {
setActiveTab(tabFromUrl);
}
}, [searchParams]);
// Load settings from localStorage on mount
useEffect(() => {
const savedSettings = localStorage.getItem('storycove-settings');
if (savedSettings) {
try {
const parsed = JSON.parse(savedSettings);
setSettings({ ...defaultSettings, ...parsed, theme });
} catch (error) {
console.error('Failed to parse saved settings:', error);
setSettings({ ...defaultSettings, theme });
}
} else {
setSettings({ ...defaultSettings, theme });
}
}, [theme]);
// Update URL when tab changes
const handleTabChange = (tabId: string) => {
setActiveTab(tabId);
const newUrl = `/settings?tab=${tabId}`;
router.replace(newUrl, { scroll: false });
};
// Save settings to localStorage
const saveSettings = () => {
localStorage.setItem('storycove-settings', JSON.stringify(settings));
// Apply theme change
setTheme(settings.theme);
// Apply font settings to CSS custom properties
const root = document.documentElement;
const fontFamilyMap = {
serif: 'Georgia, Times, serif',
sans: 'Inter, system-ui, sans-serif',
mono: 'Monaco, Consolas, monospace',
};
const fontSizeMap = {
small: '14px',
medium: '16px',
large: '18px',
'extra-large': '20px',
};
const readingWidthMap = {
narrow: '600px',
medium: '800px',
wide: '1000px',
};
root.style.setProperty('--reading-font-family', fontFamilyMap[settings.fontFamily]);
root.style.setProperty('--reading-font-size', fontSizeMap[settings.fontSize]);
root.style.setProperty('--reading-max-width', readingWidthMap[settings.readingWidth]);
setSaved(true);
setTimeout(() => setSaved(false), 2000);
};
const updateSetting = <K extends keyof Settings>(key: K, value: Settings[K]) => {
setSettings(prev => ({ ...prev, [key]: value }));
};
const resetToDefaults = () => {
setSettings({ ...defaultSettings, theme });
};
const renderTabContent = () => {
switch (activeTab) {
case 'appearance':
return (
<AppearanceSettings
settings={settings}
onSettingChange={updateSetting}
/>
);
case 'content':
return <ContentSettings />;
case 'system':
return <SystemSettings />;
default:
return <AppearanceSettings settings={settings} onSettingChange={updateSetting} />;
}
};
return (
<div className="max-w-4xl mx-auto space-y-6">
{/* Header */}
<div>
<h1 className="text-3xl font-bold theme-header">Settings</h1>
<p className="theme-text mt-2">
Customize your StoryCove experience and manage system settings
</p>
</div>
{/* Tab Navigation */}
<TabNavigation
tabs={tabs}
activeTab={activeTab}
onTabChange={handleTabChange}
className="mb-6"
/>
{/* Tab Content */}
<div className="min-h-[400px]">
{renderTabContent()}
</div>
{/* Save Actions - Only show for Appearance tab */}
{activeTab === 'appearance' && (
<div className="flex justify-end gap-4 pt-6 border-t theme-border">
<Button
variant="ghost"
onClick={resetToDefaults}
>
Reset to Defaults
</Button>
<Button
onClick={saveSettings}
className={saved ? 'bg-green-600 hover:bg-green-700' : ''}
>
{saved ? '✓ Saved!' : 'Save Settings'}
</Button>
</div>
)}
</div>
);
}

View File

@@ -1,20 +1,186 @@
'use client'; 'use client';
import { Suspense } from 'react'; import { useState, useEffect } from 'react';
import { useRouter, useSearchParams } from 'next/navigation';
import AppLayout from '../../components/layout/AppLayout'; import AppLayout from '../../components/layout/AppLayout';
import LoadingSpinner from '../../components/ui/LoadingSpinner'; import TabNavigation from '../../components/ui/TabNavigation';
import SettingsContent from './SettingsContent'; import AppearanceSettings from '../../components/settings/AppearanceSettings';
import ContentSettings from '../../components/settings/ContentSettings';
import SystemSettings from '../../components/settings/SystemSettings';
import Button from '../../components/ui/Button';
import { useTheme } from '../../lib/theme';
type FontFamily = 'serif' | 'sans' | 'mono';
type FontSize = 'small' | 'medium' | 'large' | 'extra-large';
type ReadingWidth = 'narrow' | 'medium' | 'wide';
interface Settings {
theme: 'light' | 'dark';
fontFamily: FontFamily;
fontSize: FontSize;
readingWidth: ReadingWidth;
readingSpeed: number; // words per minute
}
const defaultSettings: Settings = {
theme: 'light',
fontFamily: 'serif',
fontSize: 'medium',
readingWidth: 'medium',
readingSpeed: 200,
};
const tabs = [
{ id: 'appearance', label: 'Appearance', icon: '🎨' },
{ id: 'content', label: 'Content', icon: '🏷️' },
{ id: 'system', label: 'System', icon: '🔧' },
];
export default function SettingsPage() { export default function SettingsPage() {
const router = useRouter();
const searchParams = useSearchParams();
const { theme, setTheme } = useTheme();
const [settings, setSettings] = useState<Settings>(defaultSettings);
const [saved, setSaved] = useState(false);
const [activeTab, setActiveTab] = useState('appearance');
// Initialize tab from URL parameter
useEffect(() => {
const tabFromUrl = searchParams.get('tab');
if (tabFromUrl && tabs.some(tab => tab.id === tabFromUrl)) {
setActiveTab(tabFromUrl);
}
}, [searchParams]);
// Load settings from localStorage on mount
useEffect(() => {
const savedSettings = localStorage.getItem('storycove-settings');
if (savedSettings) {
try {
const parsed = JSON.parse(savedSettings);
setSettings({ ...defaultSettings, ...parsed, theme });
} catch (error) {
console.error('Failed to parse saved settings:', error);
setSettings({ ...defaultSettings, theme });
}
} else {
setSettings({ ...defaultSettings, theme });
}
}, [theme]);
// Update URL when tab changes
const handleTabChange = (tabId: string) => {
setActiveTab(tabId);
const newUrl = `/settings?tab=${tabId}`;
router.replace(newUrl, { scroll: false });
};
// Save settings to localStorage
const saveSettings = () => {
localStorage.setItem('storycove-settings', JSON.stringify(settings));
// Apply theme change
setTheme(settings.theme);
// Apply font settings to CSS custom properties
const root = document.documentElement;
const fontFamilyMap = {
serif: 'Georgia, Times, serif',
sans: 'Inter, system-ui, sans-serif',
mono: 'Monaco, Consolas, monospace',
};
const fontSizeMap = {
small: '14px',
medium: '16px',
large: '18px',
'extra-large': '20px',
};
const readingWidthMap = {
narrow: '600px',
medium: '800px',
wide: '1000px',
};
root.style.setProperty('--reading-font-family', fontFamilyMap[settings.fontFamily]);
root.style.setProperty('--reading-font-size', fontSizeMap[settings.fontSize]);
root.style.setProperty('--reading-max-width', readingWidthMap[settings.readingWidth]);
setSaved(true);
setTimeout(() => setSaved(false), 2000);
};
const updateSetting = <K extends keyof Settings>(key: K, value: Settings[K]) => {
setSettings(prev => ({ ...prev, [key]: value }));
};
const resetToDefaults = () => {
setSettings({ ...defaultSettings, theme });
};
const renderTabContent = () => {
switch (activeTab) {
case 'appearance':
return (
<AppearanceSettings
settings={settings}
onSettingChange={updateSetting}
/>
);
case 'content':
return <ContentSettings />;
case 'system':
return <SystemSettings />;
default:
return <AppearanceSettings settings={settings} onSettingChange={updateSetting} />;
}
};
return ( return (
<AppLayout> <AppLayout>
<Suspense fallback={ <div className="max-w-4xl mx-auto space-y-6">
<div className="flex items-center justify-center py-20"> {/* Header */}
<LoadingSpinner size="lg" /> <div>
<h1 className="text-3xl font-bold theme-header">Settings</h1>
<p className="theme-text mt-2">
Customize your StoryCove experience and manage system settings
</p>
</div> </div>
}>
<SettingsContent /> {/* Tab Navigation */}
</Suspense> <TabNavigation
tabs={tabs}
activeTab={activeTab}
onTabChange={handleTabChange}
className="mb-6"
/>
{/* Tab Content */}
<div className="min-h-[400px]">
{renderTabContent()}
</div>
{/* Save Actions - Only show for Appearance tab */}
{activeTab === 'appearance' && (
<div className="flex justify-end gap-4 pt-6 border-t theme-border">
<Button
variant="ghost"
onClick={resetToDefaults}
>
Reset to Defaults
</Button>
<Button
onClick={saveSettings}
className={saved ? 'bg-green-600 hover:bg-green-700' : ''}
>
{saved ? '✓ Saved!' : 'Save Settings'}
</Button>
</div>
)}
</div>
</AppLayout> </AppLayout>
); );
} }

View File

@@ -7,7 +7,7 @@ import { Input, Textarea } from '../../../../components/ui/Input';
import Button from '../../../../components/ui/Button'; import Button from '../../../../components/ui/Button';
import TagInput from '../../../../components/stories/TagInput'; import TagInput from '../../../../components/stories/TagInput';
import TagSuggestions from '../../../../components/tags/TagSuggestions'; import TagSuggestions from '../../../../components/tags/TagSuggestions';
import PortableTextEditor from '../../../../components/stories/PortableTextEditorNew'; import RichTextEditor from '../../../../components/stories/RichTextEditor';
import ImageUpload from '../../../../components/ui/ImageUpload'; import ImageUpload from '../../../../components/ui/ImageUpload';
import AuthorSelector from '../../../../components/stories/AuthorSelector'; import AuthorSelector from '../../../../components/stories/AuthorSelector';
import SeriesSelector from '../../../../components/stories/SeriesSelector'; import SeriesSelector from '../../../../components/stories/SeriesSelector';
@@ -337,7 +337,7 @@ export default function EditStoryPage() {
<label className="block text-sm font-medium theme-header mb-2"> <label className="block text-sm font-medium theme-header mb-2">
Story Content * Story Content *
</label> </label>
<PortableTextEditor <RichTextEditor
value={formData.contentHtml} value={formData.contentHtml}
onChange={handleContentChange} onChange={handleContentChange}
placeholder="Edit your story content here..." placeholder="Edit your story content here..."

View File

@@ -1,6 +1,6 @@
'use client'; 'use client';
import { useState, useEffect, useRef, useCallback, useMemo, memo } from 'react'; import { useState, useEffect, useRef, useCallback } from 'react';
import { useParams, useRouter } from 'next/navigation'; import { useParams, useRouter } from 'next/navigation';
import Link from 'next/link'; import Link from 'next/link';
import { storyApi, seriesApi } from '../../../lib/api'; import { storyApi, seriesApi } from '../../../lib/api';
@@ -12,27 +12,6 @@ import TagDisplay from '../../../components/tags/TagDisplay';
import TableOfContents from '../../../components/stories/TableOfContents'; import TableOfContents from '../../../components/stories/TableOfContents';
import { sanitizeHtml, preloadSanitizationConfig } from '../../../lib/sanitization'; import { sanitizeHtml, preloadSanitizationConfig } from '../../../lib/sanitization';
// Memoized content component that only re-renders when content changes
const StoryContent = memo(({
content,
contentRef
}: {
content: string;
contentRef: React.RefObject<HTMLDivElement>;
}) => {
console.log('🔄 StoryContent component rendering with content length:', content.length);
return (
<div
ref={contentRef}
className="reading-content"
dangerouslySetInnerHTML={{ __html: content }}
/>
);
});
StoryContent.displayName = 'StoryContent';
export default function StoryReadingPage() { export default function StoryReadingPage() {
const params = useParams(); const params = useParams();
const router = useRouter(); const router = useRouter();
@@ -237,66 +216,58 @@ export default function StoryReadingPage() {
// Track reading progress and save position // Track reading progress and save position
useEffect(() => { useEffect(() => {
let ticking = false;
const handleScroll = () => { const handleScroll = () => {
if (!ticking) { const article = document.querySelector('[data-reading-content]') as HTMLElement;
requestAnimationFrame(() => { if (article) {
const article = document.querySelector('[data-reading-content]') as HTMLElement; const scrolled = window.scrollY;
if (article) { const articleTop = article.offsetTop;
const scrolled = window.scrollY; const articleHeight = article.scrollHeight;
const articleTop = article.offsetTop; const windowHeight = window.innerHeight;
const articleHeight = article.scrollHeight;
const windowHeight = window.innerHeight;
const progress = Math.min(100, Math.max(0, const progress = Math.min(100, Math.max(0,
((scrolled - articleTop + windowHeight) / articleHeight) * 100 ((scrolled - articleTop + windowHeight) / articleHeight) * 100
)); ));
setReadingProgress(progress); setReadingProgress(progress);
// Multi-method end-of-story detection // Multi-method end-of-story detection
const documentHeight = document.documentElement.scrollHeight; const documentHeight = document.documentElement.scrollHeight;
const windowBottom = scrolled + windowHeight; const windowBottom = scrolled + windowHeight;
const distanceFromBottom = documentHeight - windowBottom; const distanceFromBottom = documentHeight - windowBottom;
// Method 1: Distance from bottom (most reliable) // Method 1: Distance from bottom (most reliable)
const nearBottom = distanceFromBottom <= 200; const nearBottom = distanceFromBottom <= 200;
// Method 2: High progress but only as secondary check // Method 2: High progress but only as secondary check
const highProgress = progress >= 98; const highProgress = progress >= 98;
// Method 3: Check if story content itself is fully visible // Method 3: Check if story content itself is fully visible
const storyContentElement = contentRef.current; const storyContentElement = contentRef.current;
let storyContentFullyVisible = false; let storyContentFullyVisible = false;
if (storyContentElement) { if (storyContentElement) {
const contentRect = storyContentElement.getBoundingClientRect(); const contentRect = storyContentElement.getBoundingClientRect();
const contentBottom = scrolled + contentRect.bottom; const contentBottom = scrolled + contentRect.bottom;
const documentContentHeight = Math.max(documentHeight - 300, contentBottom); // Account for footer padding const documentContentHeight = Math.max(documentHeight - 300, contentBottom); // Account for footer padding
storyContentFullyVisible = windowBottom >= documentContentHeight; storyContentFullyVisible = windowBottom >= documentContentHeight;
} }
// Trigger end detection if user is near bottom AND (has high progress OR story content is fully visible) // Trigger end detection if user is near bottom AND (has high progress OR story content is fully visible)
if (nearBottom && (highProgress || storyContentFullyVisible) && !hasReachedEnd && hasScrolledToPosition) { if (nearBottom && (highProgress || storyContentFullyVisible) && !hasReachedEnd && hasScrolledToPosition) {
console.log('End of story detected:', { nearBottom, highProgress, storyContentFullyVisible, distanceFromBottom, progress }); console.log('End of story detected:', { nearBottom, highProgress, storyContentFullyVisible, distanceFromBottom, progress });
setHasReachedEnd(true); setHasReachedEnd(true);
setShowEndOfStoryPopup(true); setShowEndOfStoryPopup(true);
} }
// Save reading position and update percentage (debounced) // Save reading position and update percentage (debounced)
if (hasScrolledToPosition) { // Only save after initial auto-scroll if (hasScrolledToPosition) { // Only save after initial auto-scroll
const characterPosition = getCharacterPositionFromScroll(); const characterPosition = getCharacterPositionFromScroll();
const percentage = calculateReadingPercentage(characterPosition); const percentage = calculateReadingPercentage(characterPosition);
console.log('Scroll detected, character position:', characterPosition, 'percentage:', percentage); console.log('Scroll detected, character position:', characterPosition, 'percentage:', percentage);
setReadingPercentage(percentage); setReadingPercentage(percentage);
debouncedSavePosition(characterPosition); debouncedSavePosition(characterPosition);
} else { } else {
console.log('Scroll detected but not ready for tracking yet'); console.log('Scroll detected but not ready for tracking yet');
} }
}
ticking = false;
});
ticking = true;
} }
}; };
@@ -358,11 +329,6 @@ export default function StoryReadingPage() {
const nextStory = findNextStory(); const nextStory = findNextStory();
const previousStory = findPreviousStory(); const previousStory = findPreviousStory();
// Memoize the sanitized content to prevent re-processing on scroll
const memoizedContent = useMemo(() => {
return sanitizedContent;
}, [sanitizedContent]);
if (loading) { if (loading) {
return ( return (
<div className="min-h-screen theme-bg flex items-center justify-center"> <div className="min-h-screen theme-bg flex items-center justify-center">
@@ -569,10 +535,10 @@ export default function StoryReadingPage() {
</header> </header>
{/* Story Content */} {/* Story Content */}
<StoryContent <div
key={`story-content-${story?.id || 'loading'}`} ref={contentRef}
content={memoizedContent} className="reading-content"
contentRef={contentRef} dangerouslySetInnerHTML={{ __html: sanitizedContent }}
/> />
</article> </article>

View File

@@ -1,9 +1,16 @@
'use client'; 'use client';
import { ReactNode, Suspense } from 'react'; import { ReactNode } from 'react';
import Link from 'next/link';
import { usePathname, useSearchParams } from 'next/navigation';
import AppLayout from './AppLayout'; import AppLayout from './AppLayout';
import LoadingSpinner from '../ui/LoadingSpinner';
import ImportLayoutContent from './ImportLayoutContent'; interface ImportTab {
id: string;
label: string;
href: string;
description: string;
}
interface ImportLayoutProps { interface ImportLayoutProps {
children: ReactNode; children: ReactNode;
@@ -11,23 +18,112 @@ interface ImportLayoutProps {
description?: string; description?: string;
} }
export default function ImportLayout({ const importTabs: ImportTab[] = [
children, {
title, id: 'manual',
description label: 'Manual Entry',
}: ImportLayoutProps) { href: '/add-story',
description: 'Add a story by manually entering details'
},
{
id: 'url',
label: 'Import from URL',
href: '/import',
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: 'bulk',
label: 'Bulk Import',
href: '/import/bulk',
description: 'Import multiple stories from a list of URLs'
}
];
export default function ImportLayout({ children, title, description }: ImportLayoutProps) {
const pathname = usePathname();
const searchParams = useSearchParams();
const mode = searchParams.get('mode');
// Determine which tab is active
const getActiveTab = () => {
if (pathname === '/add-story') {
return 'manual';
} else if (pathname === '/import') {
return 'url';
} else if (pathname === '/import/epub') {
return 'epub';
} else if (pathname === '/import/bulk') {
return 'bulk';
}
return 'manual';
};
const activeTab = getActiveTab();
return ( return (
<AppLayout> <AppLayout>
<div className="max-w-4xl mx-auto"> <div className="max-w-4xl mx-auto space-y-6">
<Suspense fallback={ {/* Header */}
<div className="flex items-center justify-center py-20"> <div className="text-center">
<LoadingSpinner size="lg" /> <h1 className="text-3xl font-bold theme-header">{title}</h1>
{description && (
<p className="theme-text mt-2 text-lg">
{description}
</p>
)}
</div>
{/* Tab Navigation */}
<div className="theme-card theme-shadow rounded-lg overflow-hidden">
{/* Tab Headers */}
<div className="flex border-b theme-border overflow-x-auto">
{importTabs.map((tab) => (
<Link
key={tab.id}
href={tab.href}
className={`flex-1 min-w-0 px-4 py-3 text-sm font-medium text-center transition-colors whitespace-nowrap ${
activeTab === tab.id
? 'theme-accent-bg text-white border-b-2 border-transparent'
: 'theme-text hover:theme-accent-light hover:theme-accent-text'
}`}
>
<div className="truncate">
{tab.label}
</div>
</Link>
))}
</div> </div>
}>
<ImportLayoutContent title={title} description={description}> {/* Tab Descriptions */}
<div className="px-6 py-4 bg-gray-50 dark:bg-gray-800/50">
<div className="flex items-center justify-center">
<p className="text-sm theme-text text-center">
{importTabs.find(tab => tab.id === activeTab)?.description}
</p>
</div>
</div>
{/* Tab Content */}
<div className="p-6">
{children} {children}
</ImportLayoutContent> </div>
</Suspense> </div>
{/* Quick Actions */}
<div className="flex justify-center">
<Link
href="/library"
className="theme-text hover:theme-accent transition-colors text-sm"
>
Back to Library
</Link>
</div>
</div> </div>
</AppLayout> </AppLayout>
); );

View File

@@ -1,116 +0,0 @@
'use client';
import { ReactNode } from 'react';
import Link from 'next/link';
import { usePathname, useSearchParams } from 'next/navigation';
interface ImportTab {
id: string;
label: string;
href: string;
description: string;
}
interface ImportLayoutContentProps {
children: ReactNode;
title: string;
description?: string;
}
const importTabs: ImportTab[] = [
{
id: 'manual',
label: 'Manual Entry',
href: '/add-story',
description: 'Add a story by manually entering details'
},
{
id: 'url',
label: 'Import from URL',
href: '/import',
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: 'bulk',
label: 'Bulk Import',
href: '/import/bulk',
description: 'Import multiple stories from URLs'
}
];
export default function ImportLayoutContent({
children,
title,
description
}: ImportLayoutContentProps) {
const pathname = usePathname();
const searchParams = useSearchParams();
// Determine active tab based on current path
const activeTab = importTabs.find(tab => {
if (tab.href === pathname) return true;
if (tab.href === '/import' && pathname === '/import') return true;
return false;
});
return (
<>
<div className="mb-8">
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4 mb-6">
<div>
<h1 className="text-3xl font-bold theme-header">{title}</h1>
{description && (
<p className="theme-text mt-2">{description}</p>
)}
</div>
<Link
href="/library"
className="inline-flex items-center px-4 py-2 text-sm font-medium theme-button theme-border border rounded-lg hover:theme-button-hover transition-colors"
>
Back to Library
</Link>
</div>
{/* Import Method Tabs */}
<div className="border-b theme-border">
<nav className="-mb-px flex space-x-8 overflow-x-auto">
{importTabs.map((tab) => {
const isActive = activeTab?.id === tab.id;
return (
<Link
key={tab.id}
href={tab.href}
className={`
group inline-flex items-center px-1 py-4 border-b-2 font-medium text-sm whitespace-nowrap
${isActive
? 'border-theme-accent text-theme-accent'
: 'border-transparent theme-text hover:text-theme-header hover:border-gray-300'
}
`}
>
<span className="flex flex-col">
<span>{tab.label}</span>
<span className="text-xs theme-text mt-1 group-hover:text-theme-header">
{tab.description}
</span>
</span>
</Link>
);
})}
</nav>
</div>
</div>
{/* Tab Content */}
<div className="flex-1">
{children}
</div>
</>
);
}

View File

@@ -1,610 +0,0 @@
'use client';
import React, { useState, useEffect, useCallback, useRef } from 'react';
import { PortableText } from '@portabletext/react';
import type { PortableTextBlock } from '@portabletext/types';
import Button from '../ui/Button';
import { Textarea } from '../ui/Input';
import { sanitizeHtmlSync } from '../../lib/sanitization';
import { storyApi } from '../../lib/api';
import {
htmlToPortableText,
portableTextToHtml,
parseHtmlToBlocks
} from '../../lib/portabletext/conversion';
import {
createTextBlock,
createImageBlock,
emptyPortableTextContent,
portableTextSchema
} from '../../lib/portabletext/schema';
import type { CustomPortableTextBlock } from '../../lib/portabletext/schema';
interface PortableTextEditorProps {
value: string; // HTML value for compatibility
onChange: (value: string) => void; // Returns HTML for compatibility
placeholder?: string;
error?: string;
storyId?: string;
enableImageProcessing?: boolean;
}
export default function PortableTextEditor({
value,
onChange,
placeholder = 'Write your story here...',
error,
storyId,
enableImageProcessing = false
}: PortableTextEditorProps) {
console.log('🎯 PortableTextEditor loaded!', { value: value?.length, enableImageProcessing });
const [viewMode, setViewMode] = useState<'visual' | 'html'>('visual');
const [portableTextValue, setPortableTextValue] = useState<CustomPortableTextBlock[]>(emptyPortableTextContent);
const [htmlValue, setHtmlValue] = useState(value);
const [isMaximized, setIsMaximized] = useState(false);
const [containerHeight, setContainerHeight] = useState(300);
const containerRef = useRef<HTMLDivElement>(null);
const editableRef = useRef<HTMLDivElement>(null);
// Image processing state
const [imageProcessingQueue, setImageProcessingQueue] = useState<string[]>([]);
const [processedImages, setProcessedImages] = useState<Set<string>>(new Set());
const [imageWarnings, setImageWarnings] = useState<string[]>([]);
const imageProcessingTimeoutRef = useRef<NodeJS.Timeout | null>(null);
// Initialize Portable Text content from HTML value
useEffect(() => {
if (value && value !== htmlValue) {
const blocks = parseHtmlToBlocks(value);
setPortableTextValue(blocks);
setHtmlValue(value);
}
}, [value]);
// Convert Portable Text to HTML when content changes
const updateHtmlFromPortableText = useCallback((blocks: CustomPortableTextBlock[]) => {
const html = portableTextToHtml(blocks);
setHtmlValue(html);
onChange(html);
}, [onChange]);
// Image processing functionality (maintained from original)
const findImageUrlsInHtml = (html: string): string[] => {
const imgRegex = /<img[^>]+src=["']([^"']+)["'][^>]*>/gi;
const urls: string[] = [];
let match;
while ((match = imgRegex.exec(html)) !== null) {
const url = match[1];
if (!url.startsWith('/') && !url.startsWith('data:')) {
urls.push(url);
}
}
return urls;
};
const processContentImagesDebounced = useCallback(async (content: string) => {
if (!enableImageProcessing || !storyId) return;
const imageUrls = findImageUrlsInHtml(content);
if (imageUrls.length === 0) return;
const newUrls = imageUrls.filter(url => !processedImages.has(url));
if (newUrls.length === 0) return;
setImageProcessingQueue(prev => [...prev, ...newUrls]);
try {
const result = await storyApi.processContentImages(storyId, content);
setProcessedImages(prev => new Set([...Array.from(prev), ...newUrls]));
setImageProcessingQueue(prev => prev.filter(url => !newUrls.includes(url)));
if (result.processedContent !== content) {
const newBlocks = parseHtmlToBlocks(result.processedContent);
setPortableTextValue(newBlocks);
onChange(result.processedContent);
setHtmlValue(result.processedContent);
}
if (result.hasWarnings && result.warnings) {
setImageWarnings(prev => [...prev, ...result.warnings!]);
}
} catch (error) {
console.error('Failed to process content images:', error);
setImageProcessingQueue(prev => prev.filter(url => !newUrls.includes(url)));
const errorMessage = error instanceof Error ? error.message : String(error);
setImageWarnings(prev => [...prev, `Failed to process some images: ${errorMessage}`]);
}
}, [enableImageProcessing, storyId, processedImages, onChange]);
const triggerImageProcessing = useCallback((content: string) => {
if (!enableImageProcessing || !storyId) return;
if (imageProcessingTimeoutRef.current) {
clearTimeout(imageProcessingTimeoutRef.current);
}
imageProcessingTimeoutRef.current = setTimeout(() => {
processContentImagesDebounced(content);
}, 2000);
}, [enableImageProcessing, storyId, processContentImagesDebounced]);
// Toolbar functionality
const insertTextWithFormat = (format: string) => {
const newBlock = createTextBlock('New ' + format, format === 'normal' ? 'normal' : format);
const newBlocks = [...portableTextValue, newBlock];
setPortableTextValue(newBlocks);
updateHtmlFromPortableText(newBlocks);
};
const formatText = useCallback((format: string) => {
if (viewMode === 'visual') {
// In visual mode, add a new formatted block
insertTextWithFormat(format);
} else {
// HTML mode - maintain original functionality
const textarea = document.querySelector('textarea') as HTMLTextAreaElement;
if (!textarea) return;
const start = textarea.selectionStart;
const end = textarea.selectionEnd;
const selectedText = htmlValue.substring(start, end);
if (selectedText) {
const beforeText = htmlValue.substring(0, start);
const afterText = htmlValue.substring(end);
const formattedText = `<${format}>${selectedText}</${format}>`;
const newValue = beforeText + formattedText + afterText;
setHtmlValue(newValue);
onChange(newValue);
setTimeout(() => {
textarea.focus();
textarea.setSelectionRange(start, start + formattedText.length);
}, 0);
} else {
const template = format === 'h1' ? '<h1>Heading 1</h1>' :
format === 'h2' ? '<h2>Heading 2</h2>' :
format === 'h3' ? '<h3>Heading 3</h3>' :
format === 'h4' ? '<h4>Heading 4</h4>' :
format === 'h5' ? '<h5>Heading 5</h5>' :
format === 'h6' ? '<h6>Heading 6</h6>' :
`<${format}>Formatted text</${format}>`;
const newValue = htmlValue.substring(0, start) + template + htmlValue.substring(start);
setHtmlValue(newValue);
onChange(newValue);
setTimeout(() => {
const tagLength = `<${format}>`.length;
const newPosition = start + tagLength;
textarea.focus();
textarea.setSelectionRange(newPosition, newPosition + (template.includes('Heading') ? template.split('>')[1].split('<')[0].length : 'Formatted text'.length));
}, 0);
}
}
}, [viewMode, htmlValue, onChange, portableTextValue, updateHtmlFromPortableText]);
// Handle HTML mode changes
const handleHtmlChange = (e: React.ChangeEvent<HTMLTextAreaElement>) => {
const html = e.target.value;
setHtmlValue(html);
onChange(html);
// Update Portable Text representation
const blocks = parseHtmlToBlocks(html);
setPortableTextValue(blocks);
triggerImageProcessing(html);
};
// Handle visual mode content changes
const handleVisualContentChange = () => {
if (editableRef.current) {
const html = editableRef.current.innerHTML;
const blocks = parseHtmlToBlocks(html);
setPortableTextValue(blocks);
updateHtmlFromPortableText(blocks);
triggerImageProcessing(html);
}
};
// Paste handling
const handlePaste = async (e: React.ClipboardEvent<HTMLDivElement>) => {
if (viewMode !== 'visual') return;
e.preventDefault();
try {
const clipboardData = e.clipboardData;
let htmlContent = '';
let plainText = '';
try {
htmlContent = clipboardData.getData('text/html');
plainText = clipboardData.getData('text/plain');
} catch (e) {
console.log('Direct getData failed:', e);
}
if (htmlContent && htmlContent.trim().length > 0) {
let processedHtml = htmlContent;
if (enableImageProcessing && storyId) {
const hasImages = /<img[^>]+src=['"'][^'"']*['"][^>]*>/i.test(htmlContent);
if (hasImages) {
try {
const result = await storyApi.processContentImages(storyId, htmlContent);
processedHtml = result.processedContent;
if (result.downloadedImages && result.downloadedImages.length > 0) {
setProcessedImages(prev => new Set([...Array.from(prev), ...result.downloadedImages]));
}
if (result.warnings && result.warnings.length > 0) {
setImageWarnings(prev => [...prev, ...result.warnings!]);
}
} catch (error) {
console.error('Image processing failed during paste:', error);
}
}
}
const sanitizedHtml = sanitizeHtmlSync(processedHtml);
const blocks = parseHtmlToBlocks(sanitizedHtml);
// Insert at current position
const newBlocks = [...portableTextValue, ...blocks];
setPortableTextValue(newBlocks);
updateHtmlFromPortableText(newBlocks);
} else if (plainText && plainText.trim().length > 0) {
const textBlocks = plainText
.split('\n\n')
.filter(p => p.trim())
.map(p => createTextBlock(p.trim()));
const newBlocks = [...portableTextValue, ...textBlocks];
setPortableTextValue(newBlocks);
updateHtmlFromPortableText(newBlocks);
}
} catch (error) {
console.error('Error handling paste:', error);
}
};
// Maximize/minimize functionality
const toggleMaximize = () => {
if (!isMaximized) {
if (containerRef.current) {
setContainerHeight(containerRef.current.scrollHeight || containerHeight);
}
}
setIsMaximized(!isMaximized);
};
// Keyboard shortcuts
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
if (e.key === 'Escape' && isMaximized) {
setIsMaximized(false);
return;
}
if (e.ctrlKey && e.shiftKey && !e.altKey && !e.metaKey) {
const num = parseInt(e.key);
if (num >= 1 && num <= 6) {
e.preventDefault();
formatText(`h${num}`);
return;
}
}
if (e.ctrlKey && !e.shiftKey && !e.altKey && !e.metaKey) {
switch (e.key.toLowerCase()) {
case 'b':
e.preventDefault();
formatText('strong');
return;
case 'i':
e.preventDefault();
formatText('em');
return;
}
}
};
document.addEventListener('keydown', handleKeyDown);
if (isMaximized) {
document.body.style.overflow = 'hidden';
} else {
document.body.style.overflow = '';
}
return () => {
document.removeEventListener('keydown', handleKeyDown);
document.body.style.overflow = '';
};
}, [isMaximized, formatText]);
// Cleanup
useEffect(() => {
return () => {
if (imageProcessingTimeoutRef.current) {
clearTimeout(imageProcessingTimeoutRef.current);
}
};
}, []);
// Custom components for Portable Text rendering
const portableTextComponents = {
types: {
image: ({ value }: { value: any }) => (
<div className="image-block my-4">
<img
src={value.src}
alt={value.alt || ''}
className="max-w-full h-auto"
loading="lazy"
/>
{value.caption && (
<p className="text-sm text-gray-600 mt-2 italic">{value.caption}</p>
)}
</div>
),
},
block: {
normal: ({ children }: any) => <p className="mb-2">{children}</p>,
h1: ({ children }: any) => <h1 className="text-3xl font-bold mb-4">{children}</h1>,
h2: ({ children }: any) => <h2 className="text-2xl font-bold mb-3">{children}</h2>,
h3: ({ children }: any) => <h3 className="text-xl font-bold mb-3">{children}</h3>,
h4: ({ children }: any) => <h4 className="text-lg font-bold mb-2">{children}</h4>,
h5: ({ children }: any) => <h5 className="text-base font-bold mb-2">{children}</h5>,
h6: ({ children }: any) => <h6 className="text-sm font-bold mb-2">{children}</h6>,
blockquote: ({ children }: any) => (
<blockquote className="border-l-4 border-gray-300 pl-4 italic my-4">{children}</blockquote>
),
},
marks: {
strong: ({ children }: any) => <strong>{children}</strong>,
em: ({ children }: any) => <em>{children}</em>,
underline: ({ children }: any) => <u>{children}</u>,
strike: ({ children }: any) => <s>{children}</s>,
code: ({ children }: any) => (
<code className="bg-gray-100 px-1 py-0.5 rounded text-sm font-mono">{children}</code>
),
},
};
return (
<div className="space-y-2">
{/* Toolbar */}
<div className="flex items-center justify-between p-2 theme-card border theme-border rounded-t-lg">
<div className="flex items-center gap-2">
<div className="text-xs bg-green-100 text-green-800 px-2 py-1 rounded">
Portable Text Editor
</div>
<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">
{/* Image processing status */}
{enableImageProcessing && (
<>
{imageProcessingQueue.length > 0 && (
<div className="flex items-center gap-1 text-xs text-blue-600 dark:text-blue-400 mr-2">
<div className="animate-spin h-3 w-3 border-2 border-blue-600 border-t-transparent rounded-full"></div>
<span>Processing {imageProcessingQueue.length} image{imageProcessingQueue.length > 1 ? 's' : ''}...</span>
</div>
)}
{imageWarnings.length > 0 && (
<div className="flex items-center gap-1 text-xs text-orange-600 dark:text-orange-400 mr-2" title={imageWarnings.join('\n')}>
<span></span>
<span>{imageWarnings.length} warning{imageWarnings.length > 1 ? 's' : ''}</span>
</div>
)}
</>
)}
<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"
variant="ghost"
onClick={() => formatText('strong')}
title="Bold (Ctrl+B)"
className="font-bold"
>
B
</Button>
<Button
type="button"
size="sm"
variant="ghost"
onClick={() => formatText('em')}
title="Italic (Ctrl+I)"
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 (Ctrl+Shift+1)"
className="text-lg font-bold"
>
H1
</Button>
<Button
type="button"
size="sm"
variant="ghost"
onClick={() => formatText('h2')}
title="Heading 2 (Ctrl+Shift+2)"
className="text-base font-bold"
>
H2
</Button>
<Button
type="button"
size="sm"
variant="ghost"
onClick={() => formatText('h3')}
title="Heading 3 (Ctrl+Shift+3)"
className="text-sm font-bold"
>
H3
</Button>
<Button
type="button"
size="sm"
variant="ghost"
onClick={() => formatText('h4')}
title="Heading 4 (Ctrl+Shift+4)"
className="text-xs font-bold"
>
H4
</Button>
<Button
type="button"
size="sm"
variant="ghost"
onClick={() => formatText('h5')}
title="Heading 5 (Ctrl+Shift+5)"
className="text-xs font-bold"
>
H5
</Button>
<Button
type="button"
size="sm"
variant="ghost"
onClick={() => formatText('h6')}
title="Heading 6 (Ctrl+Shift+6)"
className="text-xs font-bold"
>
H6
</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 */}
<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"
>
{/* Editor content */}
<div className="flex-1 overflow-hidden">
{viewMode === 'visual' ? (
<div className="relative h-full">
<div
ref={editableRef}
contentEditable
onInput={handleVisualContentChange}
onPaste={handlePaste}
className="p-3 h-full overflow-y-auto focus:outline-none focus:ring-0 resize-none"
suppressContentEditableWarning={true}
>
<PortableText
value={portableTextValue}
components={portableTextComponents}
/>
</div>
{(!portableTextValue || portableTextValue.length === 0 ||
(portableTextValue.length === 1 && !portableTextValue[0])) && (
<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>
</div>
</div>
{/* Preview for HTML mode */}
{viewMode === 'html' && htmlValue && !isMaximized && (
<div className="space-y-2">
<h4 className="text-sm font-medium theme-header">Preview:</h4>
<div className="p-4 border theme-border rounded-lg theme-card max-h-40 overflow-y-auto">
<PortableText
value={portableTextValue}
components={portableTextComponents}
/>
</div>
</div>
)}
{error && (
<p className="text-sm text-red-600 dark:text-red-400">{error}</p>
)}
<div className="text-xs theme-text">
<p>
<strong>Visual mode:</strong> Structured content editor with rich formatting.
Paste content from websites and it will be converted to structured format.
</p>
<p>
<strong>HTML mode:</strong> Edit HTML source directly for advanced formatting.
Content is automatically sanitized for security.
</p>
<p>
<strong>Keyboard shortcuts:</strong> Ctrl+B (Bold), Ctrl+I (Italic), Ctrl+Shift+1-6 (Headings 1-6).
</p>
</div>
</div>
);
}

View File

@@ -1,671 +0,0 @@
'use client';
import React, { useState, useEffect, useCallback, useRef } from 'react';
import {
EditorProvider,
PortableTextEditable,
useEditor,
type PortableTextBlock,
type RenderDecoratorFunction,
type RenderStyleFunction,
type RenderBlockFunction,
type RenderListItemFunction,
type RenderAnnotationFunction
} from '@portabletext/editor';
import { PortableText } from '@portabletext/react';
import Button from '../ui/Button';
import { sanitizeHtmlSync } from '../../lib/sanitization';
import { editorSchema } from '../../lib/portabletext/editorSchema';
interface PortableTextEditorProps {
value: string; // HTML value for compatibility - will be converted
onChange: (value: string) => void; // Returns HTML for compatibility
placeholder?: string;
error?: string;
storyId?: string;
enableImageProcessing?: boolean;
}
// Conversion utilities
function htmlToPortableTextBlocks(html: string): PortableTextBlock[] {
if (!html || html.trim() === '') {
return [{ _type: 'block', _key: generateKey(), style: 'normal', markDefs: [], children: [{ _type: 'span', _key: generateKey(), text: '', marks: [] }] }];
}
// Basic HTML to Portable Text conversion
// This is a simplified implementation - you could enhance this
const sanitizedHtml = sanitizeHtmlSync(html);
const parser = new DOMParser();
const doc = parser.parseFromString(sanitizedHtml, 'text/html');
const blocks: PortableTextBlock[] = [];
const paragraphs = doc.querySelectorAll('p, h1, h2, h3, h4, h5, h6, blockquote, div');
if (paragraphs.length === 0) {
// Fallback: treat as single paragraph
return [{
_type: 'block',
_key: generateKey(),
style: 'normal',
markDefs: [],
children: [{
_type: 'span',
_key: generateKey(),
text: doc.body.textContent || '',
marks: []
}]
}];
}
// Process all elements in document order to maintain sequence
const allElements = Array.from(doc.body.querySelectorAll('*'));
const processedElements = new Set<Element>();
for (const element of allElements) {
// Skip if already processed
if (processedElements.has(element)) continue;
// Handle images
if (element.tagName === 'IMG') {
const img = element as HTMLImageElement;
blocks.push({
_type: 'image',
_key: generateKey(),
src: img.getAttribute('src') || '',
alt: img.getAttribute('alt') || '',
caption: img.getAttribute('title') || '',
width: img.getAttribute('width') ? parseInt(img.getAttribute('width')!) : undefined,
height: img.getAttribute('height') ? parseInt(img.getAttribute('height')!) : undefined,
});
processedElements.add(element);
continue;
}
// Handle code blocks
if ((element.tagName === 'CODE' && element.parentElement?.tagName === 'PRE') ||
(element.tagName === 'PRE' && element.querySelector('code'))) {
const codeEl = element.tagName === 'CODE' ? element : element.querySelector('code');
if (codeEl) {
const code = codeEl.textContent || '';
const language = codeEl.getAttribute('class')?.replace('language-', '') || '';
if (code.trim()) {
blocks.push({
_type: 'codeBlock',
_key: generateKey(),
code,
language,
});
processedElements.add(element);
if (element.tagName === 'PRE') processedElements.add(codeEl);
}
}
continue;
}
// Handle text blocks (paragraphs, headings, etc.)
if (['P', 'H1', 'H2', 'H3', 'H4', 'H5', 'H6', 'BLOCKQUOTE', 'DIV'].includes(element.tagName)) {
// Skip if this contains already processed elements
if (element.querySelector('img') || (element.querySelector('code') && element.querySelector('pre'))) {
processedElements.add(element);
continue;
}
const style = getStyleFromElement(element);
const text = element.textContent || '';
if (text.trim()) {
blocks.push({
_type: 'block',
_key: generateKey(),
style,
markDefs: [],
children: [{
_type: 'span',
_key: generateKey(),
text,
marks: []
}]
});
processedElements.add(element);
}
}
}
return blocks.length > 0 ? blocks : [{
_type: 'block',
_key: generateKey(),
style: 'normal',
markDefs: [],
children: [{
_type: 'span',
_key: generateKey(),
text: '',
marks: []
}]
}];
}
function portableTextToHtml(blocks: PortableTextBlock[]): string {
if (!blocks || blocks.length === 0) return '';
const htmlParts: string[] = [];
blocks.forEach(block => {
if (block._type === 'block' && Array.isArray(block.children)) {
const tag = getHtmlTagFromStyle((block.style as string) || 'normal');
const children = block.children as PortableTextChild[];
const text = children
.map(child => child._type === 'span' ? child.text || '' : '')
.join('') || '';
if (text.trim() || block.style !== 'normal') {
htmlParts.push(`<${tag}>${text}</${tag}>`);
}
} else if (block._type === 'image' && isImageBlock(block)) {
// Convert image blocks back to HTML
const attrs: string[] = [];
if (block.src) attrs.push(`src="${block.src}"`);
if (block.alt) attrs.push(`alt="${block.alt}"`);
if (block.caption) attrs.push(`title="${block.caption}"`);
if (block.width) attrs.push(`width="${block.width}"`);
if (block.height) attrs.push(`height="${block.height}"`);
htmlParts.push(`<img ${attrs.join(' ')} />`);
} else if (block._type === 'codeBlock' && isCodeBlock(block)) {
// Convert code blocks back to HTML
const langClass = block.language ? ` class="language-${block.language}"` : '';
htmlParts.push(`<pre><code${langClass}>${block.code || ''}</code></pre>`);
}
});
const html = htmlParts.join('\n');
return sanitizeHtmlSync(html);
}
function getStyleFromElement(element: Element): string {
const tagName = element.tagName.toLowerCase();
const styleMap: Record<string, string> = {
'p': 'normal',
'div': 'normal',
'h1': 'h1',
'h2': 'h2',
'h3': 'h3',
'h4': 'h4',
'h5': 'h5',
'h6': 'h6',
'blockquote': 'blockquote',
};
return styleMap[tagName] || 'normal';
}
function getHtmlTagFromStyle(style: string): string {
const tagMap: Record<string, string> = {
'normal': 'p',
'h1': 'h1',
'h2': 'h2',
'h3': 'h3',
'h4': 'h4',
'h5': 'h5',
'h6': 'h6',
'blockquote': 'blockquote',
};
return tagMap[style] || 'p';
}
interface PortableTextChild {
_type: string;
_key: string;
text?: string;
marks?: string[];
}
// Type guards for custom block types
function isImageBlock(value: any): value is {
_type: 'image';
src?: string;
alt?: string;
caption?: string;
width?: number;
height?: number;
} {
return value && typeof value === 'object' && value._type === 'image';
}
function isCodeBlock(value: any): value is {
_type: 'codeBlock';
code?: string;
language?: string;
} {
return value && typeof value === 'object' && value._type === 'codeBlock';
}
function generateKey(): string {
return Math.random().toString(36).substring(2, 11);
}
// Toolbar component
function EditorToolbar({
isScrollable,
onToggleScrollable
}: {
isScrollable: boolean;
onToggleScrollable: () => void;
}) {
const editor = useEditor();
const toggleDecorator = (decorator: string) => {
editor.send({ type: 'decorator.toggle', decorator });
};
const setStyle = (style: string) => {
editor.send({ type: 'style.toggle', style });
};
return (
<div className="flex items-center justify-between p-2 theme-card border theme-border rounded-t-lg">
<div className="flex items-center gap-2">
<div className="text-xs bg-blue-100 text-blue-800 px-2 py-1 rounded">
Portable Text Editor
</div>
{/* Style buttons */}
<div className="flex items-center gap-1 border-r pr-2 mr-2">
<Button
type="button"
size="sm"
variant="ghost"
onClick={() => setStyle('normal')}
title="Normal paragraph"
>
P
</Button>
<Button
type="button"
size="sm"
variant="ghost"
onClick={() => setStyle('h1')}
title="Heading 1"
className="text-lg font-bold"
>
H1
</Button>
<Button
type="button"
size="sm"
variant="ghost"
onClick={() => setStyle('h2')}
title="Heading 2"
className="text-base font-bold"
>
H2
</Button>
<Button
type="button"
size="sm"
variant="ghost"
onClick={() => setStyle('h3')}
title="Heading 3"
className="text-sm font-bold"
>
H3
</Button>
</div>
{/* Decorator buttons */}
<div className="flex items-center gap-1">
<Button
type="button"
size="sm"
variant="ghost"
onClick={() => toggleDecorator('strong')}
title="Bold (Ctrl+B)"
className="font-bold"
>
B
</Button>
<Button
type="button"
size="sm"
variant="ghost"
onClick={() => toggleDecorator('em')}
title="Italic (Ctrl+I)"
className="italic"
>
I
</Button>
<Button
type="button"
size="sm"
variant="ghost"
onClick={() => toggleDecorator('underline')}
title="Underline"
className="underline"
>
U
</Button>
<Button
type="button"
size="sm"
variant="ghost"
onClick={() => toggleDecorator('strike')}
title="Strike-through"
className="line-through"
>
S
</Button>
</div>
</div>
{/* Scrollable toggle */}
<div className="flex items-center gap-2">
<span className="text-xs theme-text">Scrollable:</span>
<Button
type="button"
size="sm"
variant="ghost"
onClick={onToggleScrollable}
className={isScrollable ? 'theme-accent-bg text-white' : ''}
title={isScrollable ? 'Switch to auto-expand mode' : 'Switch to scrollable mode'}
>
{isScrollable ? '📜' : '📏'}
</Button>
</div>
</div>
);
}
// Simple component that uses Portable Text editor directly
function EditorContent({
value,
onChange,
placeholder,
error
}: {
value: string;
onChange: (value: string) => void;
placeholder?: string;
error?: string;
}) {
const [portableTextValue, setPortableTextValue] = useState<PortableTextBlock[]>(() =>
htmlToPortableTextBlocks(value)
);
const [isScrollable, setIsScrollable] = useState(true); // Default to scrollable
// Sync HTML value with prop changes
useEffect(() => {
console.log('🔄 Editor value changed:', { valueLength: value?.length, valuePreview: value?.substring(0, 100) });
setPortableTextValue(htmlToPortableTextBlocks(value));
}, [value]);
// Debug: log when portableTextValue changes
useEffect(() => {
console.log('📝 Portable text blocks updated:', { blockCount: portableTextValue.length, blocks: portableTextValue });
}, [portableTextValue]);
// Add a ref to the editor container for direct paste handling
const editorContainerRef = useRef<HTMLDivElement>(null);
// Global paste event listener to catch ALL paste events
useEffect(() => {
const handleGlobalPaste = (event: ClipboardEvent) => {
console.log('🌍 Global paste event captured');
// Check if the paste is happening within our editor
const target = event.target as Element;
const isInEditor = editorContainerRef.current?.contains(target);
console.log('📋 Paste details:', {
isInEditor,
targetTag: target?.tagName,
targetClasses: target?.className,
hasClipboardData: !!event.clipboardData
});
if (isInEditor && event.clipboardData) {
const htmlData = event.clipboardData.getData('text/html');
const textData = event.clipboardData.getData('text/plain');
console.log('📋 Clipboard contents:', {
htmlLength: htmlData.length,
textLength: textData.length,
hasImages: htmlData.includes('<img'),
htmlPreview: htmlData.substring(0, 300)
});
if (htmlData && htmlData.includes('<img')) {
console.log('📋 Images detected in paste! Attempting to process...');
// Prevent default paste to handle it completely ourselves
event.preventDefault();
event.stopPropagation();
// Convert the pasted HTML to our blocks maintaining order
const pastedBlocks = htmlToPortableTextBlocks(htmlData);
console.log('📋 Converted blocks:', pastedBlocks.map(block => ({
type: block._type,
key: block._key,
...(block._type === 'image' ? { src: (block as any).src, alt: (block as any).alt } : {}),
...(block._type === 'block' ? { style: (block as any).style, text: (block as any).children?.[0]?.text?.substring(0, 50) } : {})
})));
if (pastedBlocks.length > 0) {
// Insert the blocks at the end of current content (maintaining order within the paste)
setTimeout(() => {
setPortableTextValue(prev => {
const updatedBlocks = [...prev, ...pastedBlocks];
const html = portableTextToHtml(updatedBlocks);
onChange(html);
console.log('📋 Added structured blocks maintaining order:', { pastedCount: pastedBlocks.length, totalBlocks: updatedBlocks.length });
return updatedBlocks;
});
}, 10);
}
}
}
};
// Add global event listener with capture phase to catch events early
document.addEventListener('paste', handleGlobalPaste, true);
return () => {
document.removeEventListener('paste', handleGlobalPaste, true);
};
}, [onChange]);
// Handle paste events directly on the editor container (backup approach)
const handleContainerPaste = useCallback((_event: React.ClipboardEvent) => {
console.log('📦 Container paste handler triggered');
// This might not be reached if global handler prevents default
}, []);
// Render functions for the editor
const renderStyle: RenderStyleFunction = useCallback((props) => {
const { schemaType, children } = props;
switch (schemaType.value) {
case 'h1':
return <h1 className="text-3xl font-bold mb-4">{children}</h1>;
case 'h2':
return <h2 className="text-2xl font-bold mb-3">{children}</h2>;
case 'h3':
return <h3 className="text-xl font-bold mb-3">{children}</h3>;
case 'h4':
return <h4 className="text-lg font-bold mb-2">{children}</h4>;
case 'h5':
return <h5 className="text-base font-bold mb-2">{children}</h5>;
case 'h6':
return <h6 className="text-sm font-bold mb-2">{children}</h6>;
case 'blockquote':
return <blockquote className="border-l-4 border-gray-300 pl-4 italic my-4">{children}</blockquote>;
default:
return <p className="mb-2">{children}</p>;
}
}, []);
const renderDecorator: RenderDecoratorFunction = useCallback((props) => {
const { schemaType, children } = props;
switch (schemaType.value) {
case 'strong':
return <strong>{children}</strong>;
case 'em':
return <em>{children}</em>;
case 'underline':
return <u>{children}</u>;
case 'strike':
return <s>{children}</s>;
case 'code':
return <code className="bg-gray-100 px-1 py-0.5 rounded text-sm font-mono">{children}</code>;
default:
return <>{children}</>;
}
}, []);
const renderBlock: RenderBlockFunction = useCallback((props) => {
const { schemaType, value, children } = props;
console.log('🎨 Rendering block:', { schemaType: schemaType.name, valueType: value?._type, value });
// Handle image blocks
if (schemaType.name === 'image' && isImageBlock(value)) {
console.log('🖼️ Rendering image block:', value);
return (
<div className="my-4 p-3 border border-dashed border-gray-300 rounded-lg bg-gray-50">
<div className="flex items-center gap-2 mb-2">
<span className="text-lg">🖼</span>
<span className="font-medium text-gray-700">Image Block</span>
</div>
<div className="text-sm text-gray-600 space-y-1">
<p><strong>Source:</strong> {value.src || 'No source'}</p>
{value.alt && <p><strong>Alt text:</strong> {value.alt}</p>}
{value.caption && <p><strong>Caption:</strong> {value.caption}</p>}
{(value.width || value.height) && (
<p><strong>Dimensions:</strong> {value.width || '?'} × {value.height || '?'}</p>
)}
</div>
</div>
);
}
// Handle code blocks
if (schemaType.name === 'codeBlock' && isCodeBlock(value)) {
return (
<div className="my-4 p-3 border border-dashed border-blue-300 rounded-lg bg-blue-50">
<div className="flex items-center gap-2 mb-2">
<span className="text-lg">💻</span>
<span className="font-medium text-blue-700">Code Block</span>
{value.language && (
<span className="text-xs bg-blue-200 text-blue-800 px-2 py-1 rounded">
{value.language}
</span>
)}
</div>
<pre className="text-sm text-gray-800 bg-white p-2 rounded border overflow-x-auto">
<code>{value.code || '// No code'}</code>
</pre>
</div>
);
}
// Default block rendering
return <div>{children}</div>;
}, []);
const renderListItem: RenderListItemFunction = useCallback((props) => {
return <li>{props.children}</li>;
}, []);
const renderAnnotation: RenderAnnotationFunction = useCallback((props) => {
const { schemaType, children, value } = props;
if (schemaType.name === 'link' && value && typeof value === 'object') {
const linkValue = value as { href?: string; target?: string; title?: string };
return (
<a
href={linkValue.href}
target={linkValue.target || '_self'}
title={linkValue.title}
className="text-blue-600 hover:text-blue-800 underline"
>
{children}
</a>
);
}
return <>{children}</>;
}, []);
return (
<div className="space-y-2">
<EditorProvider
key={`editor-${portableTextValue.length}-${Date.now()}`}
initialConfig={{
schemaDefinition: editorSchema,
initialValue: portableTextValue,
}}
>
<EditorToolbar
isScrollable={isScrollable}
onToggleScrollable={() => setIsScrollable(!isScrollable)}
/>
<div
ref={editorContainerRef}
className="border theme-border rounded-b-lg overflow-hidden"
onPaste={handleContainerPaste}
>
<PortableTextEditable
className={`p-3 focus:outline-none focus:ring-0 resize-none ${
isScrollable
? 'h-[400px] overflow-y-auto'
: 'min-h-[300px]'
}`}
placeholder={placeholder}
renderStyle={renderStyle}
renderDecorator={renderDecorator}
renderBlock={renderBlock}
renderListItem={renderListItem}
renderAnnotation={renderAnnotation}
/>
</div>
</EditorProvider>
{error && (
<p className="text-sm text-red-600 dark:text-red-400">{error}</p>
)}
<div className="text-xs theme-text">
<p>
<strong>Portable Text Editor:</strong> Rich text editor with structured content.
{isScrollable ? ' Fixed height with scrolling.' : ' Auto-expanding height.'}
📋 Paste detection active.
</p>
</div>
</div>
);
}
export default function PortableTextEditorNew({
value,
onChange,
placeholder = 'Write your story here...',
error,
storyId,
enableImageProcessing = false
}: PortableTextEditorProps) {
console.log('🎯 Portable Text Editor loaded!', {
valueLength: value?.length,
enableImageProcessing,
hasStoryId: !!storyId
});
return (
<EditorContent
value={value}
onChange={onChange}
placeholder={placeholder}
error={error}
/>
);
}

View File

@@ -1,274 +0,0 @@
/**
* Conversion utilities between HTML and Portable Text
* Maintains compatibility with existing sanitization strategy
*/
import type { PortableTextBlock } from '@portabletext/types';
import type { CustomPortableTextBlock } from './schema';
import { createTextBlock, createImageBlock } from './schema';
import { sanitizeHtmlSync } from '../sanitization';
/**
* Convert HTML to Portable Text
* This maintains backward compatibility with existing HTML content
*/
export function htmlToPortableText(html: string): CustomPortableTextBlock[] {
if (!html || html.trim() === '') {
return [createTextBlock()];
}
// First sanitize the HTML using existing strategy
const sanitizedHtml = sanitizeHtmlSync(html);
// Parse the sanitized HTML into Portable Text blocks
const parser = new DOMParser();
const doc = parser.parseFromString(sanitizedHtml, 'text/html');
const blocks: CustomPortableTextBlock[] = [];
// Process each child element in the body
const walker = doc.createTreeWalker(
doc.body,
NodeFilter.SHOW_ELEMENT | NodeFilter.SHOW_TEXT
);
let currentBlock: PortableTextBlock | null = null;
let node = walker.nextNode();
while (node) {
if (node.nodeType === Node.ELEMENT_NODE) {
const element = node as Element;
// Handle block-level elements
if (isBlockElement(element.tagName)) {
// Finish current block if any
if (currentBlock) {
blocks.push(currentBlock);
currentBlock = null;
}
// Handle images separately
if (element.tagName === 'IMG') {
const img = element as HTMLImageElement;
blocks.push(createImageBlock(
img.src,
img.alt,
img.title || undefined
));
} else {
// Create new block for this element
const style = getBlockStyle(element.tagName);
const text = element.textContent || '';
currentBlock = createTextBlock(text, style);
}
} else {
// Handle inline elements - add to current block
if (!currentBlock) {
currentBlock = createTextBlock();
}
// Inline elements are handled by processing their text content
// Mark handling would go here for future enhancement
}
} else if (node.nodeType === Node.TEXT_NODE && node.textContent?.trim()) {
// Handle text nodes
if (!currentBlock) {
currentBlock = createTextBlock();
}
// Text content is already included in the parent element processing
}
node = walker.nextNode();
}
// Add final block if any
if (currentBlock) {
blocks.push(currentBlock);
}
// If no blocks were created, return empty content
if (blocks.length === 0) {
return [createTextBlock()];
}
return blocks;
}
/**
* Convert Portable Text to HTML
* This ensures compatibility with existing backend processing
*/
export function portableTextToHtml(blocks: CustomPortableTextBlock[]): string {
if (!blocks || blocks.length === 0) {
return '';
}
const htmlParts: string[] = [];
for (const block of blocks) {
if (block._type === 'block') {
const portableBlock = block as PortableTextBlock;
const tag = getHtmlTag(portableBlock.style || 'normal');
const text = extractTextFromBlock(portableBlock);
if (text.trim() || portableBlock.style !== 'normal') {
htmlParts.push(`<${tag}>${text}</${tag}>`);
}
} else if (block._type === 'image') {
const imgBlock = block as any; // Type assertion for custom image block
const alt = imgBlock.alt ? ` alt="${escapeHtml(imgBlock.alt)}"` : '';
const title = imgBlock.caption ? ` title="${escapeHtml(imgBlock.caption)}"` : '';
htmlParts.push(`<img src="${escapeHtml(imgBlock.src)}"${alt}${title} />`);
}
}
const html = htmlParts.join('\n');
// Apply final sanitization to ensure security
return sanitizeHtmlSync(html);
}
/**
* Extract plain text from a Portable Text block
*/
function extractTextFromBlock(block: PortableTextBlock): string {
if (!block.children) return '';
return block.children
.map(child => {
if (child._type === 'span') {
return child.text || '';
}
return '';
})
.join('');
}
/**
* Determine if an HTML tag is a block-level element
*/
function isBlockElement(tagName: string): boolean {
const blockElements = [
'P', 'DIV', 'H1', 'H2', 'H3', 'H4', 'H5', 'H6',
'BLOCKQUOTE', 'UL', 'OL', 'LI', 'IMG', 'BR'
];
return blockElements.includes(tagName.toUpperCase());
}
/**
* Get Portable Text block style from HTML tag
*/
function getBlockStyle(tagName: string): string {
const styleMap: Record<string, string> = {
'P': 'normal',
'DIV': 'normal',
'H1': 'h1',
'H2': 'h2',
'H3': 'h3',
'H4': 'h4',
'H5': 'h5',
'H6': 'h6',
'BLOCKQUOTE': 'blockquote',
};
return styleMap[tagName.toUpperCase()] || 'normal';
}
/**
* Get HTML tag from Portable Text block style
*/
function getHtmlTag(style: string): string {
const tagMap: Record<string, string> = {
'normal': 'p',
'h1': 'h1',
'h2': 'h2',
'h3': 'h3',
'h4': 'h4',
'h5': 'h5',
'h6': 'h6',
'blockquote': 'blockquote',
};
return tagMap[style] || 'p';
}
/**
* Escape HTML entities
*/
function escapeHtml(text: string): string {
const div = document.createElement('div');
div.textContent = text;
return div.innerHTML;
}
/**
* Simple HTML parsing for converting existing content
* This is a basic implementation - could be enhanced with more sophisticated parsing
*/
export function parseHtmlToBlocks(html: string): CustomPortableTextBlock[] {
if (!html || html.trim() === '') {
return [createTextBlock()];
}
// Sanitize first
const sanitizedHtml = sanitizeHtmlSync(html);
// Split by block-level elements and convert
const blocks: CustomPortableTextBlock[] = [];
// Simple regex-based parsing for common elements
const blockElements = sanitizedHtml.split(/(<\/?(?:p|div|h[1-6]|blockquote|img)[^>]*>)/i)
.filter(part => part.trim().length > 0);
let currentText = '';
let currentStyle = 'normal';
for (const part of blockElements) {
if (part.match(/^<(h[1-6]|p|div|blockquote)/i)) {
// Start of block element
const match = part.match(/^<(h[1-6]|p|div|blockquote)/i);
if (match) {
currentStyle = getBlockStyle(match[1]);
}
} else if (part.match(/^<img/i)) {
// Image element
const srcMatch = part.match(/src=['"']([^'"']+)['"']/);
const altMatch = part.match(/alt=['"']([^'"']+)['"']/);
const titleMatch = part.match(/title=['"']([^'"']+)['"']/);
if (srcMatch) {
blocks.push(createImageBlock(
srcMatch[1],
altMatch?.[1],
titleMatch?.[1]
));
}
} else if (part.match(/^<\//)) {
// End tag - finalize current block
if (currentText.trim()) {
blocks.push(createTextBlock(currentText.trim(), currentStyle));
currentText = '';
currentStyle = 'normal';
}
} else if (!part.match(/^</)) {
// Text content
currentText += part;
}
}
// Handle remaining text
if (currentText.trim()) {
blocks.push(createTextBlock(currentText.trim(), currentStyle));
}
// If no blocks created, return empty block
if (blocks.length === 0) {
return [createTextBlock()];
}
return blocks;
}
// Helper function to generate unique keys
function generateKey(): string {
return Math.random().toString(36).substr(2, 9);
}

View File

@@ -1,97 +0,0 @@
/**
* Portable Text Editor Schema Definition
* Defines the structure and capabilities of the editor
*/
import { defineSchema } from '@portabletext/editor';
import type { SchemaDefinition } from '@portabletext/editor';
export const editorSchema: SchemaDefinition = defineSchema({
// Text decorators (inline formatting)
decorators: [
{ name: 'strong' },
{ name: 'em' },
{ name: 'underline' },
{ name: 'strike' },
{ name: 'code' },
],
// Block styles (paragraph types)
styles: [
{ name: 'normal' },
{ name: 'h1' },
{ name: 'h2' },
{ name: 'h3' },
{ name: 'h4' },
{ name: 'h5' },
{ name: 'h6' },
{ name: 'blockquote' },
],
// List types
lists: [
{ name: 'bullet' },
{ name: 'number' },
],
// Annotations (links, etc.)
annotations: [
{
name: 'link',
type: 'object',
fields: [
{
name: 'href',
type: 'string',
},
],
},
],
// Block objects (custom content types)
blockObjects: [
{
name: 'image',
type: 'object',
fields: [
{
name: 'src',
type: 'string',
},
{
name: 'alt',
type: 'string',
},
{
name: 'caption',
type: 'string',
},
{
name: 'width',
type: 'number',
},
{
name: 'height',
type: 'number',
},
],
},
{
name: 'codeBlock',
type: 'object',
fields: [
{
name: 'code',
type: 'string',
},
{
name: 'language',
type: 'string',
},
],
},
],
});
// Type exports for use in components
export type EditorSchema = typeof editorSchema;

View File

@@ -1,169 +0,0 @@
/**
* Portable Text schema definition matching current RichTextEditor functionality
*/
import type {
PortableTextBlock,
ArbitraryTypedObject,
PortableTextMarkDefinition,
PortableTextSpan
} from '@portabletext/types';
// Define custom marks (inline formatting)
export interface StrongMark extends PortableTextMarkDefinition {
_type: 'strong';
}
export interface EmMark extends PortableTextMarkDefinition {
_type: 'em';
}
export interface UnderlineMark extends PortableTextMarkDefinition {
_type: 'underline';
}
export interface StrikeMark extends PortableTextMarkDefinition {
_type: 'strike';
}
export interface CodeMark extends PortableTextMarkDefinition {
_type: 'code';
}
// Custom block types for images (future enhancement)
export interface ImageBlock extends ArbitraryTypedObject {
_type: 'image';
src: string;
alt?: string;
caption?: string;
isProcessing?: boolean;
originalUrl?: string;
}
// Define the schema configuration
export const portableTextSchema = {
// Block styles (paragraph, headings)
styles: [
{ title: 'Normal', value: 'normal' },
{ title: 'Heading 1', value: 'h1' },
{ title: 'Heading 2', value: 'h2' },
{ title: 'Heading 3', value: 'h3' },
{ title: 'Heading 4', value: 'h4' },
{ title: 'Heading 5', value: 'h5' },
{ title: 'Heading 6', value: 'h6' },
{ title: 'Quote', value: 'blockquote' },
],
// List types
lists: [
{ title: 'Bullet', value: 'bullet' },
{ title: 'Number', value: 'number' },
],
// Marks (inline formatting)
marks: {
// Decorators
decorators: [
{ title: 'Strong', value: 'strong' },
{ title: 'Emphasis', value: 'em' },
{ title: 'Underline', value: 'underline' },
{ title: 'Strike', value: 'strike' },
{ title: 'Code', value: 'code' },
],
// Annotations (links, etc.)
annotations: [
{
title: 'URL',
name: 'link',
type: 'object',
fields: [
{
title: 'URL',
name: 'href',
type: 'url',
},
],
},
],
},
// Custom block types
blockTypes: [
{
title: 'Image',
name: 'image',
type: 'object',
fields: [
{ name: 'src', type: 'string', title: 'Image URL' },
{ name: 'alt', type: 'string', title: 'Alt Text' },
{ name: 'caption', type: 'string', title: 'Caption' },
{ name: 'isProcessing', type: 'boolean', title: 'Processing' },
{ name: 'originalUrl', type: 'string', title: 'Original URL' },
],
},
],
};
// Type definitions for our Portable Text content
export type CustomPortableTextBlock = PortableTextBlock | ImageBlock;
export type CustomMarkDefinition =
| StrongMark
| EmMark
| UnderlineMark
| StrikeMark
| CodeMark;
export type CustomPortableTextSpan = PortableTextSpan & {
marks?: string[];
};
// Helper function to create a basic block
export function createTextBlock(
text: string = '',
style: string = 'normal'
): PortableTextBlock {
return {
_type: 'block',
_key: generateKey(),
style,
markDefs: [],
children: [
{
_type: 'span',
_key: generateKey(),
text,
marks: [],
},
],
};
}
// Helper function to create an image block
export function createImageBlock(
src: string,
alt?: string,
caption?: string,
isProcessing?: boolean,
originalUrl?: string
): ImageBlock {
return {
_type: 'image',
_key: generateKey(),
src,
alt,
caption,
isProcessing,
originalUrl,
};
}
// Helper function to generate unique keys
function generateKey(): string {
return Math.random().toString(36).substr(2, 9);
}
// Default empty content
export const emptyPortableTextContent: CustomPortableTextBlock[] = [
createTextBlock('', 'normal')
];

View File

@@ -1,32 +0,0 @@
/**
* Progress tracking utilities for bulk operations
*/
export interface ProgressUpdate {
type: 'progress' | 'completed' | 'error';
current: number;
total: number;
message: string;
url?: string;
title?: string;
author?: string;
wordCount?: number;
totalWordCount?: number;
error?: string;
combinedStory?: any;
results?: any[];
summary?: any;
hasImages?: boolean;
imageWarnings?: string[];
}
// Global progress storage (in production, use Redis or database)
export const progressStore = new Map<string, ProgressUpdate[]>();
// Helper function for other routes to send progress updates
export function sendProgressUpdate(sessionId: string, update: ProgressUpdate) {
if (!progressStore.has(sessionId)) {
progressStore.set(sessionId, []);
}
progressStore.get(sessionId)!.push(update);
}

View File

@@ -129,7 +129,8 @@ export async function cleanHtml(html: string): Promise<string> {
const cheerio = await import('cheerio'); const cheerio = await import('cheerio');
const $ = cheerio.load(html, { const $ = cheerio.load(html, {
// Preserve self-closing tags like <br> // Preserve self-closing tags like <br>
xmlMode: false xmlMode: false,
decodeEntities: false
}); });
// Remove dangerous elements // Remove dangerous elements

View File

@@ -182,7 +182,7 @@ export function extractLinkText(
$: cheerio.CheerioAPI, $: cheerio.CheerioAPI,
config: LinkTextStrategy config: LinkTextStrategy
): string { ): string {
let searchScope: any; let searchScope: cheerio.Cheerio<cheerio.AnyNode>;
if (config.searchWithin) { if (config.searchWithin) {
searchScope = $(config.searchWithin); searchScope = $(config.searchWithin);
@@ -196,7 +196,7 @@ export function extractLinkText(
config.nearText.forEach(text => { config.nearText.forEach(text => {
if (foundText) return; // Already found if (foundText) return; // Already found
searchScope.find('*').each((_: any, elem: any) => { searchScope.find('*').each((_, elem) => {
const $elem = $(elem); const $elem = $(elem);
const elemText = $elem.text().toLowerCase(); const elemText = $elem.text().toLowerCase();

File diff suppressed because one or more lines are too long

3010
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff