diff --git a/frontend/src/app/add-story/page.tsx b/frontend/src/app/add-story/page.tsx index f43fbcb..e07942e 100644 --- a/frontend/src/app/add-story/page.tsx +++ b/frontend/src/app/add-story/page.tsx @@ -10,6 +10,7 @@ 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() { @@ -22,6 +23,7 @@ export default function AddStoryPage() { sourceUrl: '', tags: [] as string[], seriesName: '', + seriesId: undefined as string | undefined, volume: '', }); @@ -208,6 +210,19 @@ export default function AddStoryPage() { } }; + 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 = {}; @@ -252,7 +267,8 @@ export default function AddStoryPage() { contentHtml: formData.contentHtml, sourceUrl: formData.sourceUrl || undefined, volume: formData.seriesName ? parseInt(formData.volume) : undefined, - seriesName: formData.seriesName || 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, @@ -405,12 +421,13 @@ export default function AddStoryPage() { {/* Series and Volume */}
- tag.name) || [], seriesName: storyData.seriesName || '', + seriesId: storyData.seriesId, volume: storyData.volume?.toString() || '', }); } catch (error) { @@ -117,6 +120,19 @@ export default function EditStoryPage() { } }; + 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 = {}; @@ -161,7 +177,8 @@ export default function EditStoryPage() { contentHtml: formData.contentHtml, sourceUrl: formData.sourceUrl || undefined, volume: formData.seriesName && formData.volume ? parseInt(formData.volume) : undefined, - seriesName: formData.seriesName, // Send empty string to explicitly clear series + // Send seriesId if we have it (existing series), otherwise send seriesName (new/changed series) + ...(formData.seriesId ? { seriesId: formData.seriesId } : { seriesName: formData.seriesName }), // Send authorId if we have it (existing author), otherwise send authorName (new/changed author) ...(formData.authorId ? { authorId: formData.authorId } : { authorName: formData.authorName }), tagNames: formData.tags, @@ -326,12 +343,13 @@ export default function EditStoryPage() { {/* Series and Volume */}
-
diff --git a/frontend/src/components/stories/SeriesSelector.tsx b/frontend/src/components/stories/SeriesSelector.tsx new file mode 100644 index 0000000..0346b67 --- /dev/null +++ b/frontend/src/components/stories/SeriesSelector.tsx @@ -0,0 +1,290 @@ +'use client'; + +import { useState, useEffect, useRef } from 'react'; +import { seriesApi, storyApi } from '../../lib/api'; +import { Series, Story } from '../../types/api'; + +interface SeriesSelectorProps { + value: string; + onChange: (seriesName: string, seriesId?: string) => void; + placeholder?: string; + error?: string; + disabled?: boolean; + required?: boolean; + label?: string; + authorId?: string; // Optional author ID to prioritize that author's series +} + +export default function SeriesSelector({ + value, + onChange, + placeholder = 'Enter or select a series', + error, + disabled = false, + required = false, + label = 'Series', + authorId +}: SeriesSelectorProps) { + const [isOpen, setIsOpen] = useState(false); + const [series, setSeries] = useState([]); + const [filteredSeries, setFilteredSeries] = useState([]); + const [loading, setLoading] = useState(false); + const [inputValue, setInputValue] = useState(value || ''); + const [authorSeriesMap, setAuthorSeriesMap] = useState>({}); + + const inputRef = useRef(null); + const dropdownRef = useRef(null); + + // Load series and author-series mappings when component mounts or when dropdown opens + useEffect(() => { + const loadSeriesData = async () => { + try { + setLoading(true); + + // Load all series + const seriesResult = await seriesApi.getSeries({ page: 0, size: 100 }); // Get first 100 series + setSeries(seriesResult.content); + + // Load some recent stories to build author-series mapping + // This gives us a sample of which authors have written in which series + try { + const storiesResult = await storyApi.getStories({ page: 0, size: 200 }); // Get recent stories + const newAuthorSeriesMap: Record = {}; + + storiesResult.content.forEach(story => { + if (story.authorId && story.seriesName) { + if (!newAuthorSeriesMap[story.authorId]) { + newAuthorSeriesMap[story.authorId] = []; + } + if (!newAuthorSeriesMap[story.authorId].includes(story.seriesName)) { + newAuthorSeriesMap[story.authorId].push(story.seriesName); + } + } + }); + + setAuthorSeriesMap(newAuthorSeriesMap); + } catch (error) { + console.error('Failed to load author-series mapping:', error); + // Continue without author prioritization if this fails + } + } catch (error) { + console.error('Failed to load series:', error); + } finally { + setLoading(false); + } + }; + + if (isOpen && series.length === 0) { + loadSeriesData(); + } + }, [isOpen, series.length]); + + // Update internal value when prop changes + useEffect(() => { + setInputValue(value || ''); + }, [value]); + + // Filter and sort series based on input and author priority + useEffect(() => { + let filtered: Series[]; + + if (!inputValue.trim()) { + filtered = [...series]; + } else { + filtered = series.filter(s => + s.name.toLowerCase().includes(inputValue.toLowerCase()) + ); + } + + // Sort series: prioritize those from the current author if authorId is provided + if (authorId && authorSeriesMap[authorId]) { + const authorSeriesNames = authorSeriesMap[authorId]; + + filtered.sort((a, b) => { + const aIsAuthorSeries = authorSeriesNames.includes(a.name); + const bIsAuthorSeries = authorSeriesNames.includes(b.name); + + if (aIsAuthorSeries && !bIsAuthorSeries) return -1; // a first + if (!aIsAuthorSeries && bIsAuthorSeries) return 1; // b first + + // If both or neither are author series, sort alphabetically + return a.name.localeCompare(b.name); + }); + } else { + // No author prioritization, just sort alphabetically + filtered.sort((a, b) => a.name.localeCompare(b.name)); + } + + setFilteredSeries(filtered); + }, [inputValue, series, authorId, authorSeriesMap]); + + // Handle clicks outside to close dropdown + useEffect(() => { + const handleClickOutside = (event: MouseEvent) => { + if ( + dropdownRef.current && + !dropdownRef.current.contains(event.target as Node) && + !inputRef.current?.contains(event.target as Node) + ) { + setIsOpen(false); + } + }; + + document.addEventListener('mousedown', handleClickOutside); + return () => document.removeEventListener('mousedown', handleClickOutside); + }, []); + + const handleInputChange = (e: React.ChangeEvent) => { + const newValue = e.target.value; + setInputValue(newValue); + setIsOpen(true); + + // If user is typing and it doesn't match any existing series exactly, clear the seriesId + onChange(newValue, undefined); + }; + + const handleInputFocus = () => { + setIsOpen(true); + }; + + const handleSeriesSelect = (selectedSeries: Series) => { + setInputValue(selectedSeries.name); + setIsOpen(false); + onChange(selectedSeries.name, selectedSeries.id); + inputRef.current?.blur(); + }; + + const handleInputBlur = () => { + // Small delay to allow clicks on dropdown items + setTimeout(() => { + if (!dropdownRef.current?.contains(document.activeElement)) { + setIsOpen(false); + } + }, 200); + }; + + const handleKeyDown = (e: React.KeyboardEvent) => { + if (e.key === 'Escape') { + setIsOpen(false); + inputRef.current?.blur(); + } + }; + + return ( +
+ {label && ( + + )} + +
+ + + {/* Dropdown Arrow */} +
+ + + +
+
+ + {/* Dropdown */} + {isOpen && ( +
+ {loading ? ( +
Loading series...
+ ) : ( + <> + {filteredSeries.length > 0 ? ( + filteredSeries.map((s) => { + const isAuthorSeries = authorId && authorSeriesMap[authorId]?.includes(s.name); + + return ( + + ); + }) + ) : ( + <> + {inputValue.trim() && ( + + )} + {!inputValue.trim() && ( +
No series found
+ )} + + )} + + {inputValue.trim() && !filteredSeries.some(s => s.name.toLowerCase() === inputValue.toLowerCase()) && ( + + )} + + )} +
+ )} + + {error && ( +

{error}

+ )} +
+ ); +} \ No newline at end of file