From 460ec358ca70974c1bee3f4e264c4478cd0e1c8b Mon Sep 17 00:00:00 2001 From: Stefan Hardegger Date: Thu, 14 Aug 2025 19:46:50 +0200 Subject: [PATCH] New Switchable Library Layout --- frontend/src/app/add-story/page.tsx | 470 ++++++++++++++- frontend/src/app/authors/[id]/page.tsx | 4 +- frontend/src/app/authors/page.tsx | 101 ++-- frontend/src/app/import/bulk/page.tsx | 2 +- frontend/src/app/import/page.tsx | 538 ++---------------- frontend/src/app/library/page.tsx | 324 ++++------- frontend/src/app/settings/page.tsx | 56 ++ frontend/src/components/layout/Header.tsx | 42 +- .../src/components/layout/ImportLayout.tsx | 10 +- .../src/components/library/MinimalLayout.tsx | 220 +++++++ .../src/components/library/SidebarLayout.tsx | 181 ++++++ .../src/components/library/ToolbarLayout.tsx | 211 +++++++ frontend/src/hooks/useLibraryLayout.ts | 29 + frontend/tsconfig.tsbuildinfo | 2 +- 14 files changed, 1384 insertions(+), 806 deletions(-) create mode 100644 frontend/src/components/library/MinimalLayout.tsx create mode 100644 frontend/src/components/library/SidebarLayout.tsx create mode 100644 frontend/src/components/library/ToolbarLayout.tsx create mode 100644 frontend/src/hooks/useLibraryLayout.ts diff --git a/frontend/src/app/add-story/page.tsx b/frontend/src/app/add-story/page.tsx index 422356a..47cb6a9 100644 --- a/frontend/src/app/add-story/page.tsx +++ b/frontend/src/app/add-story/page.tsx @@ -1,39 +1,465 @@ 'use client'; -import { useEffect } 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 { 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 AuthorSelector from '../../components/stories/AuthorSelector'; +import { storyApi, authorApi } from '../../lib/api'; -export default function AddStoryRedirectPage() { +export default function AddStoryPage() { + const [formData, setFormData] = useState({ + title: '', + summary: '', + authorName: '', + authorId: undefined as string | undefined, + 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); + const router = useRouter(); const searchParams = useSearchParams(); + const { isAuthenticated } = useAuth(); + // Handle URL parameters useEffect(() => { - // Redirect to the new /import route while preserving query parameters - const mode = searchParams.get('mode'); const authorId = searchParams.get('authorId'); const from = searchParams.get('from'); - 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; + // 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(); } - router.replace(redirectUrl); - }, [router, searchParams]); + // 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 + ) => { + 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 validateForm = () => { + const newErrors: Record = {}; + + 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; + }; + + 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, + // 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); + + // 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); + } + }; return ( -
-
-
-

Redirecting...

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

{errors.success}

+
+ )} + +
+ {/* Title */} + + + {/* Author Selector */} + + + {/* 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 */} +
+ +