Changing Authors

This commit is contained in:
Stefan Hardegger
2025-08-12 12:57:34 +02:00
parent 3b22d155db
commit 75c207970d
6 changed files with 297 additions and 17 deletions

View File

@@ -0,0 +1,231 @@
'use client';
import { useState, useEffect, useRef } from 'react';
import { authorApi } from '../../lib/api';
import { Author } from '../../types/api';
interface AuthorSelectorProps {
value: string;
onChange: (authorName: string, authorId?: string) => void;
placeholder?: string;
error?: string;
disabled?: boolean;
required?: boolean;
label?: string;
}
export default function AuthorSelector({
value,
onChange,
placeholder = 'Enter or select an author',
error,
disabled = false,
required = false,
label = 'Author'
}: AuthorSelectorProps) {
const [isOpen, setIsOpen] = useState(false);
const [authors, setAuthors] = useState<Author[]>([]);
const [filteredAuthors, setFilteredAuthors] = useState<Author[]>([]);
const [loading, setLoading] = useState(false);
const [inputValue, setInputValue] = useState(value || '');
const inputRef = useRef<HTMLInputElement>(null);
const dropdownRef = useRef<HTMLDivElement>(null);
// Load authors when component mounts or when dropdown opens
useEffect(() => {
const loadAuthors = async () => {
try {
setLoading(true);
const result = await authorApi.getAuthors({ page: 0, size: 100 }); // Get first 100 authors
setAuthors(result.content);
} catch (error) {
console.error('Failed to load authors:', error);
} finally {
setLoading(false);
}
};
if (isOpen && authors.length === 0) {
loadAuthors();
}
}, [isOpen, authors.length]);
// Filter authors based on input value
useEffect(() => {
if (!inputValue.trim()) {
setFilteredAuthors(authors);
} else {
const filtered = authors.filter(author =>
author.name.toLowerCase().includes(inputValue.toLowerCase())
);
setFilteredAuthors(filtered);
}
}, [inputValue, authors]);
// Update input value when prop value changes
useEffect(() => {
if (value !== inputValue) {
setInputValue(value || '');
}
}, [value]);
// Handle clicking outside to close dropdown
useEffect(() => {
const handleClickOutside = (event: MouseEvent) => {
if (dropdownRef.current && !dropdownRef.current.contains(event.target as Node)) {
setIsOpen(false);
}
};
if (isOpen) {
document.addEventListener('mousedown', handleClickOutside);
return () => document.removeEventListener('mousedown', handleClickOutside);
}
}, [isOpen]);
const handleInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const newValue = e.target.value;
setInputValue(newValue);
setIsOpen(true);
// Call onChange for free-form text entry (new author)
onChange(newValue);
};
const handleAuthorSelect = (author: Author) => {
setInputValue(author.name);
setIsOpen(false);
onChange(author.name, author.id);
};
const handleInputFocus = () => {
setIsOpen(true);
};
const handleKeyDown = (e: React.KeyboardEvent) => {
if (e.key === 'Escape') {
setIsOpen(false);
} else if (e.key === 'ArrowDown' && !isOpen) {
setIsOpen(true);
}
};
return (
<div className="relative" ref={dropdownRef}>
{label && (
<label className="block text-sm font-medium theme-header mb-2">
{label}{required && ' *'}
</label>
)}
<div className="relative">
<input
ref={inputRef}
type="text"
value={inputValue}
onChange={handleInputChange}
onFocus={handleInputFocus}
onKeyDown={handleKeyDown}
placeholder={placeholder}
disabled={disabled}
required={required}
className={`w-full px-3 py-2 border rounded-md shadow-sm focus:outline-none focus:ring-2 focus:border-transparent transition-colors ${
error
? 'border-red-300 focus:ring-red-500 theme-error'
: 'theme-border focus:ring-theme-accent focus:theme-accent-border'
} ${disabled ? 'theme-disabled' : 'theme-input'}`}
aria-expanded={isOpen}
aria-haspopup="listbox"
role="combobox"
/>
{/* Dropdown arrow */}
<div className="absolute inset-y-0 right-0 flex items-center pr-3 pointer-events-none">
<svg
className={`w-4 h-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 className="absolute z-50 w-full mt-1 theme-card theme-shadow border theme-border rounded-md max-h-60 overflow-auto">
{loading ? (
<div className="px-3 py-2 text-sm theme-text text-center">
Loading authors...
</div>
) : filteredAuthors.length > 0 ? (
<>
{/* Existing authors */}
<div className="py-1">
{filteredAuthors.map((author) => (
<button
key={author.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={() => handleAuthorSelect(author)}
>
<span>{author.name}</span>
<span className="text-xs theme-text-muted">
{author.storyCount} {author.storyCount === 1 ? 'story' : 'stories'}
</span>
</button>
))}
</div>
{/* New author option if input doesn't match exactly */}
{inputValue.trim() && !filteredAuthors.find(a => a.name.toLowerCase() === inputValue.toLowerCase()) && (
<>
<div className="border-t theme-border"></div>
<div className="py-1">
<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());
}}
>
<span className="font-medium">Create new author:</span> "{inputValue.trim()}"
</button>
</div>
</>
)}
</>
) : inputValue.trim() ? (
/* No matches, show create new option */
<div className="py-1">
<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());
}}
>
<span className="font-medium">Create new author:</span> "{inputValue.trim()}"
</button>
</div>
) : (
/* No authors loaded or empty input */
<div className="px-3 py-2 text-sm theme-text-muted text-center">
{authors.length === 0 ? 'No authors yet' : 'Type to search or create new author'}
</div>
)}
</div>
)}
{error && (
<p className="mt-1 text-sm text-red-600 dark:text-red-400">
{error}
</p>
)}
</div>
);
}