Image Handling
This commit is contained in:
@@ -1,7 +1,7 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useEffect, useRef } from 'react';
|
||||
import { seriesApi, storyApi } from '../../lib/api';
|
||||
import { useState, useEffect, useRef, useCallback } from 'react';
|
||||
import { seriesApi } from '../../lib/api';
|
||||
import { Series } from '../../types/api';
|
||||
|
||||
interface SeriesSelectorProps {
|
||||
@@ -27,97 +27,63 @@ export default function SeriesSelector({
|
||||
}: 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);
|
||||
const debounceTimerRef = useRef<NodeJS.Timeout | null>(null);
|
||||
|
||||
// Load series and author-series mappings when component mounts or when dropdown opens
|
||||
// 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(() => {
|
||||
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);
|
||||
// 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);
|
||||
}
|
||||
};
|
||||
|
||||
if (isOpen && series.length === 0) {
|
||||
loadSeriesData();
|
||||
}
|
||||
}, [isOpen, series.length]);
|
||||
}, [inputValue, isOpen, searchSeries]);
|
||||
|
||||
// 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) => {
|
||||
@@ -138,7 +104,7 @@ export default function SeriesSelector({
|
||||
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);
|
||||
};
|
||||
@@ -178,7 +144,7 @@ export default function SeriesSelector({
|
||||
{required && <span className="text-red-500 ml-1">*</span>}
|
||||
</label>
|
||||
)}
|
||||
|
||||
|
||||
<div className="relative">
|
||||
<input
|
||||
ref={inputRef}
|
||||
@@ -194,7 +160,7 @@ export default function SeriesSelector({
|
||||
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
|
||||
@@ -215,39 +181,26 @@ export default function SeriesSelector({
|
||||
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>
|
||||
<div className="px-3 py-2 text-sm theme-text">Searching 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>
|
||||
);
|
||||
})
|
||||
{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() && (
|
||||
{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"
|
||||
@@ -258,17 +211,16 @@ export default function SeriesSelector({
|
||||
>
|
||||
Create new series: "{inputValue.trim()}"
|
||||
</button>
|
||||
)}
|
||||
{!inputValue.trim() && (
|
||||
<div className="px-3 py-2 text-sm text-gray-500">No series found</div>
|
||||
) : (
|
||||
<div className="px-3 py-2 text-sm text-gray-500">Type to search for series or create a new one</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
{inputValue.trim() && !filteredSeries.some(s => s.name.toLowerCase() === inputValue.toLowerCase()) && (
|
||||
|
||||
{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"
|
||||
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());
|
||||
@@ -287,4 +239,4 @@ export default function SeriesSelector({
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user