Files
storycove/frontend/src/components/stories/SeriesSelector.tsx
2025-08-18 19:03:42 +02:00

290 lines
10 KiB
TypeScript

'use client';
import { useState, useEffect, useRef } from 'react';
import { seriesApi, storyApi } from '../../lib/api';
import { Series } 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: &quot;{inputValue.trim()}&quot;
</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 &quot;{inputValue.trim()}&quot;
</button>
)}
</>
)}
</div>
)}
{error && (
<p className="mt-1 text-sm text-red-600 dark:text-red-400">{error}</p>
)}
</div>
);
}