550 lines
19 KiB
TypeScript
550 lines
19 KiB
TypeScript
'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>
|
||
</>
|
||
);
|
||
} |