243 lines
7.7 KiB
TypeScript
243 lines
7.7 KiB
TypeScript
'use client';
|
|
|
|
import { useState, useEffect, useRef, useCallback } from 'react';
|
|
import { seriesApi } 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 [loading, setLoading] = useState(false);
|
|
const [inputValue, setInputValue] = useState(value || '');
|
|
|
|
const inputRef = useRef<HTMLInputElement>(null);
|
|
const dropdownRef = useRef<HTMLDivElement>(null);
|
|
const debounceTimerRef = useRef<NodeJS.Timeout | null>(null);
|
|
|
|
// Search series dynamically based on input
|
|
const searchSeries = useCallback(async (query: string) => {
|
|
try {
|
|
setLoading(true);
|
|
|
|
if (!query.trim()) {
|
|
// If empty query, load recent/popular series
|
|
const result = await seriesApi.getSeries({ page: 0, size: 20, sortBy: 'name', sortDir: 'asc' });
|
|
setSeries(result.content);
|
|
} else {
|
|
// Search by name
|
|
const result = await seriesApi.searchSeriesByName(query, { page: 0, size: 20 });
|
|
setSeries(result.content);
|
|
}
|
|
} catch (error) {
|
|
console.error('Failed to search series:', error);
|
|
setSeries([]);
|
|
} finally {
|
|
setLoading(false);
|
|
}
|
|
}, []);
|
|
|
|
// Debounced search effect
|
|
useEffect(() => {
|
|
// Clear existing timer
|
|
if (debounceTimerRef.current) {
|
|
clearTimeout(debounceTimerRef.current);
|
|
}
|
|
|
|
// Only search when dropdown is open
|
|
if (isOpen) {
|
|
// Set new timer for debounced search
|
|
debounceTimerRef.current = setTimeout(() => {
|
|
searchSeries(inputValue);
|
|
}, 300); // 300ms debounce delay
|
|
}
|
|
|
|
// Cleanup timer on unmount
|
|
return () => {
|
|
if (debounceTimerRef.current) {
|
|
clearTimeout(debounceTimerRef.current);
|
|
}
|
|
};
|
|
}, [inputValue, isOpen, searchSeries]);
|
|
|
|
// Update internal value when prop changes
|
|
useEffect(() => {
|
|
setInputValue(value || '');
|
|
}, [value]);
|
|
|
|
// 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">Searching series...</div>
|
|
) : (
|
|
<>
|
|
{series.length > 0 ? (
|
|
series.map((s) => (
|
|
<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"
|
|
onClick={() => handleSeriesSelect(s)}
|
|
>
|
|
<span>{s.name}</span>
|
|
<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>
|
|
) : (
|
|
<div className="px-3 py-2 text-sm text-gray-500">Type to search for series or create a new one</div>
|
|
)}
|
|
</>
|
|
)}
|
|
|
|
{inputValue.trim() && !series.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 border-t theme-border"
|
|
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>
|
|
);
|
|
}
|