diff --git a/frontend/src/app/add-story/page.tsx b/frontend/src/app/add-story/page.tsx index 4345301..422356a 100644 --- a/frontend/src/app/add-story/page.tsx +++ b/frontend/src/app/add-story/page.tsx @@ -1,564 +1,39 @@ 'use client'; -import { useState, useRef, useEffect } from 'react'; +import { useEffect } from 'react'; import { useRouter, useSearchParams } from 'next/navigation'; -import { useAuth } from '../../contexts/AuthContext'; -import AppLayout from '../../components/layout/AppLayout'; -import { Input, Textarea } from '../../components/ui/Input'; -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 { storyApi, authorApi } from '../../lib/api'; -export default function AddStoryPage() { - const [importMode, setImportMode] = useState<'manual' | 'url'>('manual'); - const [importUrl, setImportUrl] = useState(''); - const [scraping, setScraping] = useState(false); - const [formData, setFormData] = useState({ - title: '', - summary: '', - authorName: '', - contentHtml: '', - sourceUrl: '', - tags: [] as string[], - seriesName: '', - volume: '', - }); - - const [coverImage, setCoverImage] = useState(null); - const [loading, setLoading] = useState(false); - const [errors, setErrors] = useState>({}); - 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); - +export default function AddStoryRedirectPage() { const router = useRouter(); const searchParams = useSearchParams(); - const { isAuthenticated } = useAuth(); - // Pre-fill author if authorId is provided in URL useEffect(() => { + // Redirect to the new /import route while preserving query parameters + const mode = searchParams.get('mode'); const authorId = searchParams.get('authorId'); - if (authorId) { - const loadAuthor = async () => { - try { - const author = await authorApi.getAuthor(authorId); - setFormData(prev => ({ - ...prev, - authorName: author.name - })); - } catch (error) { - console.error('Failed to load author:', error); - } - }; - loadAuthor(); - } - }, [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 || '', - 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 - ) => { - setFormData(prev => ({ - ...prev, - [field]: e.target.value - })); + const from = searchParams.get('from'); - // 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 handleImportFromUrl = async () => { - if (!importUrl.trim()) { - setErrors({ importUrl: 'URL is required' }); - return; - } - - setScraping(true); - setErrors({}); - - try { - const response = await fetch('/scrape/story', { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - }, - body: JSON.stringify({ url: importUrl }), - }); - - if (!response.ok) { - const errorData = await response.json(); - throw new Error(errorData.error || 'Failed to scrape story'); - } - - const scrapedStory = await response.json(); - - // Pre-fill the form with scraped data - setFormData({ - title: scrapedStory.title || '', - summary: scrapedStory.summary || '', - authorName: scrapedStory.author || '', - contentHtml: scrapedStory.content || '', - sourceUrl: scrapedStory.sourceUrl || importUrl, - tags: scrapedStory.tags || [], - seriesName: '', - volume: '', - }); - - // Switch to manual mode so user can edit the pre-filled data - setImportMode('manual'); - setImportUrl(''); - - // Show success message - setErrors({ success: 'Story data imported successfully! Review and edit as needed before saving.' }); - } catch (error: any) { - console.error('Failed to import story:', error); - setErrors({ importUrl: error.message }); - } finally { - setScraping(false); - } - }; - - const validateForm = () => { - const newErrors: Record = {}; - - if (!formData.title.trim()) { - newErrors.title = 'Title is required'; + let redirectUrl = '/import'; + const queryParams = new URLSearchParams(); + + if (mode) queryParams.set('mode', mode); + if (authorId) queryParams.set('authorId', authorId); + if (from) queryParams.set('from', from); + + const queryString = queryParams.toString(); + if (queryString) { + redirectUrl += '?' + queryString; } - 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; - }; - - 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, - seriesName: formData.seriesName || undefined, - authorName: formData.authorName || undefined, - tagNames: formData.tags.length > 0 ? formData.tags : undefined, - }; - - const story = await storyApi.createStory(storyData); - - // If there's a cover image, upload it separately - if (coverImage) { - await storyApi.uploadCover(story.id, coverImage); - } - - router.push(`/stories/${story.id}`); - } 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); - } - }; + router.replace(redirectUrl); + }, [router, searchParams]); return ( - -
-
-

Add New Story

-

- Add a story to your personal collection -

-
- - {/* Import Mode Toggle */} -
-
- - -
-
- - {/* URL Import Section */} - {importMode === 'url' && ( -
-

Import Story from URL

-

- Enter a URL from a supported story site to automatically extract the story content, title, author, and other metadata. -

- -
- setImportUrl(e.target.value)} - placeholder="https://example.com/story-url" - error={errors.importUrl} - disabled={scraping} - /> - -
- - - -
- -
-

- Need to import multiple stories at once? -

- -
- -
-

Supported Sites:

-

Archive of Our Own, DeviantArt, FanFiction.Net, Literotica, Royal Road, Wattpad, and more

-
-
-
- )} - - {/* Success Message */} - {errors.success && ( -
-

{errors.success}

-
- )} - - {importMode === 'manual' && ( -
- {/* Title */} - - - {/* Author */} - - - {/* Duplicate Warning */} - {duplicateWarning.show && ( -
-
-
- ⚠️ -
-
-

- Potential Duplicate Detected -

-

- Found {duplicateWarning.count} existing {duplicateWarning.count === 1 ? 'story' : 'stories'} with the same title and author: -

-
    - {duplicateWarning.duplicates.map((duplicate, index) => ( -
  • - • {duplicate.title} by {duplicate.authorName} - - (added {new Date(duplicate.createdAt).toLocaleDateString()}) - -
  • - ))} -
-

- You can still create this story if it's different from the existing ones. -

-
-
-
- )} - - {/* Checking indicator */} - {checkingDuplicates && ( -
-
- Checking for duplicates... -
- )} - - {/* Summary */} -
- -