Series auto complete
This commit is contained in:
@@ -10,6 +10,7 @@ import TagInput from '../../components/stories/TagInput';
|
|||||||
import RichTextEditor from '../../components/stories/RichTextEditor';
|
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 { storyApi, authorApi } from '../../lib/api';
|
import { storyApi, authorApi } from '../../lib/api';
|
||||||
|
|
||||||
export default function AddStoryPage() {
|
export default function AddStoryPage() {
|
||||||
@@ -22,6 +23,7 @@ export default function AddStoryPage() {
|
|||||||
sourceUrl: '',
|
sourceUrl: '',
|
||||||
tags: [] as string[],
|
tags: [] as string[],
|
||||||
seriesName: '',
|
seriesName: '',
|
||||||
|
seriesId: undefined as string | undefined,
|
||||||
volume: '',
|
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 validateForm = () => {
|
||||||
const newErrors: Record<string, string> = {};
|
const newErrors: Record<string, string> = {};
|
||||||
|
|
||||||
@@ -252,7 +267,8 @@ export default function AddStoryPage() {
|
|||||||
contentHtml: formData.contentHtml,
|
contentHtml: formData.contentHtml,
|
||||||
sourceUrl: formData.sourceUrl || undefined,
|
sourceUrl: formData.sourceUrl || undefined,
|
||||||
volume: formData.seriesName ? parseInt(formData.volume) : 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)
|
// Send authorId if we have it (existing author), otherwise send authorName (new author)
|
||||||
...(formData.authorId ? { authorId: formData.authorId } : { authorName: formData.authorName }),
|
...(formData.authorId ? { authorId: formData.authorId } : { authorName: formData.authorName }),
|
||||||
tagNames: formData.tags.length > 0 ? formData.tags : undefined,
|
tagNames: formData.tags.length > 0 ? formData.tags : undefined,
|
||||||
@@ -405,12 +421,13 @@ export default function AddStoryPage() {
|
|||||||
|
|
||||||
{/* Series and Volume */}
|
{/* Series and Volume */}
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||||
<Input
|
<SeriesSelector
|
||||||
label="Series (optional)"
|
label="Series (optional)"
|
||||||
value={formData.seriesName}
|
value={formData.seriesName}
|
||||||
onChange={handleInputChange('seriesName')}
|
onChange={handleSeriesChange}
|
||||||
placeholder="Enter series name if part of a series"
|
placeholder="Select or enter series name if part of a series"
|
||||||
error={errors.seriesName}
|
error={errors.seriesName}
|
||||||
|
authorId={formData.authorId}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<Input
|
<Input
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import TagSuggestions from '../../../../components/tags/TagSuggestions';
|
|||||||
import RichTextEditor from '../../../../components/stories/RichTextEditor';
|
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 LoadingSpinner from '../../../../components/ui/LoadingSpinner';
|
import LoadingSpinner from '../../../../components/ui/LoadingSpinner';
|
||||||
import { storyApi } from '../../../../lib/api';
|
import { storyApi } from '../../../../lib/api';
|
||||||
import { Story } from '../../../../types/api';
|
import { Story } from '../../../../types/api';
|
||||||
@@ -33,6 +34,7 @@ export default function EditStoryPage() {
|
|||||||
sourceUrl: '',
|
sourceUrl: '',
|
||||||
tags: [] as string[],
|
tags: [] as string[],
|
||||||
seriesName: '',
|
seriesName: '',
|
||||||
|
seriesId: undefined as string | undefined,
|
||||||
volume: '',
|
volume: '',
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -55,6 +57,7 @@ export default function EditStoryPage() {
|
|||||||
sourceUrl: storyData.sourceUrl || '',
|
sourceUrl: storyData.sourceUrl || '',
|
||||||
tags: storyData.tags?.map(tag => tag.name) || [],
|
tags: storyData.tags?.map(tag => tag.name) || [],
|
||||||
seriesName: storyData.seriesName || '',
|
seriesName: storyData.seriesName || '',
|
||||||
|
seriesId: storyData.seriesId,
|
||||||
volume: storyData.volume?.toString() || '',
|
volume: storyData.volume?.toString() || '',
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} 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 validateForm = () => {
|
||||||
const newErrors: Record<string, string> = {};
|
const newErrors: Record<string, string> = {};
|
||||||
|
|
||||||
@@ -161,7 +177,8 @@ export default function EditStoryPage() {
|
|||||||
contentHtml: formData.contentHtml,
|
contentHtml: formData.contentHtml,
|
||||||
sourceUrl: formData.sourceUrl || undefined,
|
sourceUrl: formData.sourceUrl || undefined,
|
||||||
volume: formData.seriesName && formData.volume ? parseInt(formData.volume) : 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)
|
// Send authorId if we have it (existing author), otherwise send authorName (new/changed author)
|
||||||
...(formData.authorId ? { authorId: formData.authorId } : { authorName: formData.authorName }),
|
...(formData.authorId ? { authorId: formData.authorId } : { authorName: formData.authorName }),
|
||||||
tagNames: formData.tags,
|
tagNames: formData.tags,
|
||||||
@@ -326,12 +343,13 @@ export default function EditStoryPage() {
|
|||||||
{/* Series and Volume */}
|
{/* Series and Volume */}
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||||
<div>
|
<div>
|
||||||
<Input
|
<SeriesSelector
|
||||||
label="Series (optional)"
|
label="Series (optional)"
|
||||||
value={formData.seriesName}
|
value={formData.seriesName}
|
||||||
onChange={handleInputChange('seriesName')}
|
onChange={handleSeriesChange}
|
||||||
placeholder="Enter series name if part of a series"
|
placeholder="Select or enter series name if part of a series"
|
||||||
error={errors.seriesName}
|
error={errors.seriesName}
|
||||||
|
authorId={formData.authorId}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
290
frontend/src/components/stories/SeriesSelector.tsx
Normal file
290
frontend/src/components/stories/SeriesSelector.tsx
Normal file
@@ -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<Series[]>([]);
|
||||||
|
const [filteredSeries, setFilteredSeries] = useState<Series[]>([]);
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [inputValue, setInputValue] = useState(value || '');
|
||||||
|
const [authorSeriesMap, setAuthorSeriesMap] = useState<Record<string, string[]>>({});
|
||||||
|
|
||||||
|
const inputRef = useRef<HTMLInputElement>(null);
|
||||||
|
const dropdownRef = useRef<HTMLDivElement>(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<string, string[]> = {};
|
||||||
|
|
||||||
|
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<HTMLInputElement>) => {
|
||||||
|
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 (
|
||||||
|
<div className="relative">
|
||||||
|
{label && (
|
||||||
|
<label className="block text-sm font-medium theme-header mb-2">
|
||||||
|
{label}
|
||||||
|
{required && <span className="text-red-500 ml-1">*</span>}
|
||||||
|
</label>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="relative">
|
||||||
|
<input
|
||||||
|
ref={inputRef}
|
||||||
|
type="text"
|
||||||
|
value={inputValue}
|
||||||
|
onChange={handleInputChange}
|
||||||
|
onFocus={handleInputFocus}
|
||||||
|
onBlur={handleInputBlur}
|
||||||
|
onKeyDown={handleKeyDown}
|
||||||
|
placeholder={placeholder}
|
||||||
|
disabled={disabled}
|
||||||
|
className={`w-full px-3 py-2 border rounded-lg theme-card theme-text theme-border focus:outline-none focus:ring-2 focus:ring-theme-accent focus:border-transparent disabled:opacity-50 disabled:cursor-not-allowed ${
|
||||||
|
error ? 'border-red-500 focus:ring-red-500' : ''
|
||||||
|
}`}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Dropdown Arrow */}
|
||||||
|
<div className="absolute inset-y-0 right-0 flex items-center pr-3 pointer-events-none">
|
||||||
|
<svg
|
||||||
|
className={`h-4 w-4 theme-text transition-transform ${isOpen ? 'rotate-180' : ''}`}
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
>
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" />
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Dropdown */}
|
||||||
|
{isOpen && (
|
||||||
|
<div
|
||||||
|
ref={dropdownRef}
|
||||||
|
className="absolute z-50 w-full mt-1 bg-white dark:bg-gray-800 border theme-border rounded-lg shadow-lg max-h-60 overflow-y-auto"
|
||||||
|
>
|
||||||
|
{loading ? (
|
||||||
|
<div className="px-3 py-2 text-sm theme-text">Loading series...</div>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
{filteredSeries.length > 0 ? (
|
||||||
|
filteredSeries.map((s) => {
|
||||||
|
const isAuthorSeries = authorId && authorSeriesMap[authorId]?.includes(s.name);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={s.id}
|
||||||
|
type="button"
|
||||||
|
className={`w-full text-left px-3 py-2 text-sm theme-text hover:theme-accent-light hover:theme-accent-text transition-colors flex items-center justify-between ${
|
||||||
|
isAuthorSeries ? 'bg-blue-50 dark:bg-blue-900/20 border-l-2 border-blue-400' : ''
|
||||||
|
}`}
|
||||||
|
onClick={() => handleSeriesSelect(s)}
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span>{s.name}</span>
|
||||||
|
{isAuthorSeries && (
|
||||||
|
<span className="text-xs px-1.5 py-0.5 rounded-full bg-blue-100 dark:bg-blue-800 text-blue-800 dark:text-blue-100">
|
||||||
|
Author
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<span className="text-xs text-gray-500">
|
||||||
|
{s.storyCount} {s.storyCount === 1 ? 'story' : 'stories'}
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
{inputValue.trim() && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="w-full text-left px-3 py-2 text-sm theme-text hover:theme-accent-light hover:theme-accent-text transition-colors"
|
||||||
|
onClick={() => {
|
||||||
|
setIsOpen(false);
|
||||||
|
onChange(inputValue.trim());
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Create new series: "{inputValue.trim()}"
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
{!inputValue.trim() && (
|
||||||
|
<div className="px-3 py-2 text-sm text-gray-500">No series found</div>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{inputValue.trim() && !filteredSeries.some(s => s.name.toLowerCase() === inputValue.toLowerCase()) && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="w-full text-left px-3 py-2 text-sm theme-text hover:theme-accent-light hover:theme-accent-text transition-colors"
|
||||||
|
onClick={() => {
|
||||||
|
setIsOpen(false);
|
||||||
|
onChange(inputValue.trim());
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Use "{inputValue.trim()}"
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<p className="mt-1 text-sm text-red-600 dark:text-red-400">{error}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user