292 lines
10 KiB
TypeScript
292 lines
10 KiB
TypeScript
|
import React, { useState, useEffect, useCallback, useRef } from 'react';
|
||
|
|
||
|
interface PropertySuggestion {
|
||
|
id: string;
|
||
|
label: string;
|
||
|
availableInItems: string[]; // Wikidata IDs that have this property
|
||
|
}
|
||
|
|
||
|
interface PropertyInputProps {
|
||
|
propertyCache: Record<string, Record<string, string>>; // wikidataId -> properties
|
||
|
propertyLabels: Record<string, string>; // propertyId -> label
|
||
|
onAddProperty: (propertyId: string) => void;
|
||
|
onFetchPropertyLabels: (propertyIds: string[]) => Promise<Record<string, string>>;
|
||
|
className?: string;
|
||
|
}
|
||
|
|
||
|
export function PropertyInput({
|
||
|
propertyCache,
|
||
|
propertyLabels,
|
||
|
onAddProperty,
|
||
|
onFetchPropertyLabels,
|
||
|
className = ''
|
||
|
}: PropertyInputProps) {
|
||
|
const [inputValue, setInputValue] = useState('');
|
||
|
const [suggestions, setSuggestions] = useState<PropertySuggestion[]>([]);
|
||
|
const [showSuggestions, setShowSuggestions] = useState(false);
|
||
|
const [selectedIndex, setSelectedIndex] = useState(-1);
|
||
|
const [isLoading, setIsLoading] = useState(false);
|
||
|
|
||
|
const inputRef = useRef<HTMLInputElement>(null);
|
||
|
const suggestionsRef = useRef<HTMLDivElement>(null);
|
||
|
|
||
|
// Aggregate all available properties from all Wikidata items
|
||
|
const aggregateAvailableProperties = useCallback((): PropertySuggestion[] => {
|
||
|
const propertyMap = new Map<string, Set<string>>();
|
||
|
|
||
|
// Collect all properties from all cached Wikidata items
|
||
|
Object.entries(propertyCache).forEach(([wikidataId, properties]) => {
|
||
|
Object.keys(properties).forEach(propertyId => {
|
||
|
if (!propertyMap.has(propertyId)) {
|
||
|
propertyMap.set(propertyId, new Set());
|
||
|
}
|
||
|
propertyMap.get(propertyId)!.add(wikidataId);
|
||
|
});
|
||
|
});
|
||
|
|
||
|
// Convert to PropertySuggestion array
|
||
|
const suggestions: PropertySuggestion[] = [];
|
||
|
propertyMap.forEach((wikidataIds, propertyId) => {
|
||
|
const label = propertyLabels[propertyId] || propertyId;
|
||
|
suggestions.push({
|
||
|
id: propertyId,
|
||
|
label: label,
|
||
|
availableInItems: Array.from(wikidataIds)
|
||
|
});
|
||
|
});
|
||
|
|
||
|
// Sort by label for better UX
|
||
|
return suggestions.sort((a, b) => a.label.localeCompare(b.label));
|
||
|
}, [propertyCache, propertyLabels]);
|
||
|
|
||
|
// Update suggestions when propertyCache or propertyLabels change
|
||
|
useEffect(() => {
|
||
|
const availableProperties = aggregateAvailableProperties();
|
||
|
setSuggestions(availableProperties);
|
||
|
|
||
|
// Fetch labels for properties that don't have labels yet
|
||
|
const unlabeledProperties = availableProperties
|
||
|
.filter(prop => !propertyLabels[prop.id] || propertyLabels[prop.id] === prop.id)
|
||
|
.map(prop => prop.id);
|
||
|
|
||
|
if (unlabeledProperties.length > 0) {
|
||
|
setIsLoading(true);
|
||
|
onFetchPropertyLabels(unlabeledProperties)
|
||
|
.finally(() => setIsLoading(false));
|
||
|
}
|
||
|
}, [propertyCache, propertyLabels, aggregateAvailableProperties, onFetchPropertyLabels]);
|
||
|
|
||
|
// Filter suggestions based on input
|
||
|
const filteredSuggestions = suggestions.filter(suggestion =>
|
||
|
suggestion.label.toLowerCase().includes(inputValue.toLowerCase()) ||
|
||
|
suggestion.id.toLowerCase().includes(inputValue.toLowerCase())
|
||
|
);
|
||
|
|
||
|
// Handle input changes
|
||
|
const handleInputChange = useCallback((e: React.ChangeEvent<HTMLInputElement>) => {
|
||
|
const value = e.target.value;
|
||
|
setInputValue(value);
|
||
|
setShowSuggestions(value.trim().length > 0);
|
||
|
setSelectedIndex(-1);
|
||
|
}, []);
|
||
|
|
||
|
// Handle keyboard navigation
|
||
|
const handleKeyDown = useCallback((e: React.KeyboardEvent<HTMLInputElement>) => {
|
||
|
if (!showSuggestions || filteredSuggestions.length === 0) {
|
||
|
if (e.key === 'Enter') {
|
||
|
e.preventDefault();
|
||
|
// Try to add property directly if it looks like a property ID
|
||
|
if (inputValue.trim()) {
|
||
|
onAddProperty(inputValue.trim());
|
||
|
setInputValue('');
|
||
|
setShowSuggestions(false);
|
||
|
}
|
||
|
}
|
||
|
return;
|
||
|
}
|
||
|
|
||
|
switch (e.key) {
|
||
|
case 'ArrowDown':
|
||
|
e.preventDefault();
|
||
|
setSelectedIndex(prev =>
|
||
|
prev < filteredSuggestions.length - 1 ? prev + 1 : 0
|
||
|
);
|
||
|
break;
|
||
|
|
||
|
case 'ArrowUp':
|
||
|
e.preventDefault();
|
||
|
setSelectedIndex(prev =>
|
||
|
prev > 0 ? prev - 1 : filteredSuggestions.length - 1
|
||
|
);
|
||
|
break;
|
||
|
|
||
|
case 'Enter':
|
||
|
e.preventDefault();
|
||
|
if (selectedIndex >= 0 && selectedIndex < filteredSuggestions.length) {
|
||
|
const selectedProperty = filteredSuggestions[selectedIndex];
|
||
|
handlePropertySelect(selectedProperty);
|
||
|
} else if (inputValue.trim()) {
|
||
|
// Add property directly
|
||
|
onAddProperty(inputValue.trim());
|
||
|
setInputValue('');
|
||
|
setShowSuggestions(false);
|
||
|
}
|
||
|
break;
|
||
|
|
||
|
case 'Escape':
|
||
|
e.preventDefault();
|
||
|
setShowSuggestions(false);
|
||
|
setSelectedIndex(-1);
|
||
|
inputRef.current?.blur();
|
||
|
break;
|
||
|
|
||
|
case 'Tab':
|
||
|
setShowSuggestions(false);
|
||
|
setSelectedIndex(-1);
|
||
|
break;
|
||
|
}
|
||
|
}, [showSuggestions, filteredSuggestions, selectedIndex, inputValue, onAddProperty]);
|
||
|
|
||
|
// Handle property selection
|
||
|
const handlePropertySelect = useCallback((property: PropertySuggestion) => {
|
||
|
console.log('Property selected:', property);
|
||
|
onAddProperty(property.id);
|
||
|
setInputValue('');
|
||
|
setShowSuggestions(false);
|
||
|
setSelectedIndex(-1);
|
||
|
inputRef.current?.focus();
|
||
|
}, [onAddProperty]);
|
||
|
|
||
|
// Handle focus
|
||
|
const handleFocus = useCallback(() => {
|
||
|
// Show all suggestions immediately on focus
|
||
|
setShowSuggestions(true);
|
||
|
}, []);
|
||
|
|
||
|
// Handle blur
|
||
|
const handleBlur = useCallback((e: React.FocusEvent<HTMLInputElement>) => {
|
||
|
// Delay hiding suggestions to allow for clicks on suggestions
|
||
|
setTimeout(() => {
|
||
|
const relatedTarget = e.relatedTarget as HTMLElement;
|
||
|
if (!suggestionsRef.current?.contains(relatedTarget)) {
|
||
|
setShowSuggestions(false);
|
||
|
setSelectedIndex(-1);
|
||
|
}
|
||
|
}, 150);
|
||
|
}, []);
|
||
|
|
||
|
// Handle suggestion click
|
||
|
const handleSuggestionClick = useCallback((property: PropertySuggestion) => {
|
||
|
handlePropertySelect(property);
|
||
|
}, [handlePropertySelect]);
|
||
|
|
||
|
// Handle clicking outside to close suggestions
|
||
|
useEffect(() => {
|
||
|
const handleClickOutside = (event: MouseEvent) => {
|
||
|
const target = event.target as HTMLElement;
|
||
|
if (
|
||
|
inputRef.current &&
|
||
|
!inputRef.current.contains(target) &&
|
||
|
suggestionsRef.current &&
|
||
|
!suggestionsRef.current.contains(target)
|
||
|
) {
|
||
|
setShowSuggestions(false);
|
||
|
setSelectedIndex(-1);
|
||
|
}
|
||
|
};
|
||
|
|
||
|
if (showSuggestions) {
|
||
|
document.addEventListener('mousedown', handleClickOutside);
|
||
|
return () => document.removeEventListener('mousedown', handleClickOutside);
|
||
|
}
|
||
|
}, [showSuggestions]);
|
||
|
|
||
|
return (
|
||
|
<div className={`relative ${className}`}>
|
||
|
<input
|
||
|
ref={inputRef}
|
||
|
type="text"
|
||
|
value={inputValue}
|
||
|
onChange={handleInputChange}
|
||
|
onKeyDown={handleKeyDown}
|
||
|
onFocus={handleFocus}
|
||
|
onBlur={handleBlur}
|
||
|
placeholder="Add New Property"
|
||
|
className="px-4 py-3 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500 transition-colors duration-200 text-sm"
|
||
|
autoComplete="off"
|
||
|
/>
|
||
|
|
||
|
{/* Loading indicator */}
|
||
|
{isLoading && (
|
||
|
<div className="absolute right-2 top-1/2 transform -translate-y-1/2">
|
||
|
<div className="animate-spin rounded-full h-4 w-4 border-b-2 border-blue-500"></div>
|
||
|
</div>
|
||
|
)}
|
||
|
|
||
|
{/* Suggestions dropdown */}
|
||
|
{showSuggestions && filteredSuggestions.length > 0 && (
|
||
|
<div
|
||
|
ref={suggestionsRef}
|
||
|
className="absolute z-50 w-full mt-2 bg-white border border-gray-200 rounded-lg shadow-xl max-h-80 overflow-y-auto"
|
||
|
style={{
|
||
|
boxShadow: '0 10px 25px -5px rgba(0, 0, 0, 0.1), 0 10px 10px -5px rgba(0, 0, 0, 0.04)'
|
||
|
}}
|
||
|
>
|
||
|
<div className="py-2">
|
||
|
{filteredSuggestions.map((suggestion, index) => {
|
||
|
const isSelected = index === selectedIndex;
|
||
|
|
||
|
return (
|
||
|
<div
|
||
|
key={suggestion.id}
|
||
|
className={`
|
||
|
px-4 py-3 cursor-pointer transition-colors duration-150 ease-in-out
|
||
|
${isSelected
|
||
|
? 'bg-blue-50 text-blue-900 border-l-4 border-blue-500'
|
||
|
: 'hover:bg-gray-50 border-l-4 border-transparent'
|
||
|
}
|
||
|
`}
|
||
|
onClick={() => handleSuggestionClick(suggestion)}
|
||
|
onMouseEnter={() => setSelectedIndex(index)}
|
||
|
>
|
||
|
<div className="flex items-center justify-between">
|
||
|
<div className="flex-1 min-w-0">
|
||
|
<div className="font-medium text-sm text-gray-900 truncate">
|
||
|
{suggestion.label}
|
||
|
</div>
|
||
|
<div className="text-xs text-gray-500 mt-1">
|
||
|
ID: {suggestion.id}
|
||
|
</div>
|
||
|
<div className="text-xs text-blue-600 mt-1 font-medium">
|
||
|
Available in {suggestion.availableInItems.length} item{suggestion.availableInItems.length !== 1 ? 's' : ''}
|
||
|
</div>
|
||
|
</div>
|
||
|
<div className="ml-3 flex-shrink-0">
|
||
|
<svg
|
||
|
className={`w-4 h-4 transition-colors duration-150 ${isSelected ? 'text-blue-500' : 'text-gray-400'}`}
|
||
|
fill="none"
|
||
|
stroke="currentColor"
|
||
|
viewBox="0 0 24 24"
|
||
|
>
|
||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 6v6m0 0v6m0-6h6m-6 0H6" />
|
||
|
</svg>
|
||
|
</div>
|
||
|
</div>
|
||
|
</div>
|
||
|
);
|
||
|
})}
|
||
|
</div>
|
||
|
</div>
|
||
|
)}
|
||
|
|
||
|
{/* No suggestions message */}
|
||
|
{showSuggestions && inputValue.trim().length > 0 && filteredSuggestions.length === 0 && (
|
||
|
<div className="absolute z-50 w-full mt-2 bg-white border border-gray-200 rounded-lg shadow-xl p-4">
|
||
|
<div className="text-sm text-gray-600 text-center">
|
||
|
{`No matching properties found. Press Enter to add "${inputValue}" as a custom property.`}
|
||
|
</div>
|
||
|
</div>
|
||
|
)}
|
||
|
</div>
|
||
|
);
|
||
|
}
|