import React, { useState, useEffect, useCallback, useRef } from 'react'; import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; import { Item, WikidataSuggestion, PropertyLabel } from '@/types/database'; import { EditableCell } from './EditableCell'; import { TypeaheadInput } from './TypeaheadInput'; import { v4 as uuidv4 } from 'uuid'; interface ItemsListProps { url: string; } interface ItemsResponse { items: Item[]; selectedProperties: string[]; } // API functions async function loadItemsFromDb(url: string): Promise { console.log('[DEBUG] Loading items for URL:', url); const encodedUrl = encodeURIComponent(url); const apiUrl = `/api/urls/${encodedUrl}/items`; console.log('[DEBUG] Making request to API endpoint:', apiUrl); const response = await fetch(apiUrl); if (!response.ok) { const errorText = await response.text(); console.error('[ERROR] Server error:', response.status, errorText); throw new Error(`Server error (${response.status}): ${errorText}`); } const items = await response.json(); console.log('[DEBUG] Successfully parsed', items.length, 'items'); // Get selected properties const propertiesResponse = await fetch(`/api/urls/${encodedUrl}/properties`); if (!propertiesResponse.ok) { throw new Error(`Failed to fetch properties: ${propertiesResponse.status}`); } const selectedProperties = await propertiesResponse.json(); console.log('[DEBUG] Successfully received selected properties'); // Filter items to only include selected properties const filteredItems = items.map((item: Item) => ({ ...item, customProperties: Object.fromEntries( Object.entries(item.customProperties).filter(([key]) => selectedProperties.includes(key) ) ) })); return { items: filteredItems, selectedProperties }; } async function saveItemToDb(url: string, item: Item): Promise { console.log('[FRONTEND] Saving item - ID:', item.id, 'Name:', item.name); const encodedUrl = encodeURIComponent(url); const response = await fetch(`/api/urls/${encodedUrl}/items`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(item) }); if (!response.ok) { throw new Error(`Failed to save item: ${response.status}`); } } async function deleteItemFromDb(url: string, itemId: string): Promise { const encodedUrl = encodeURIComponent(url); const response = await fetch(`/api/urls/${encodedUrl}/items/${itemId}`, { method: 'DELETE' }); if (!response.ok) { throw new Error(`Failed to delete item: ${response.status}`); } } async function deletePropertyFromDb(url: string, property: string): Promise { const encodedUrl = encodeURIComponent(url); const response = await fetch(`/api/urls/${encodedUrl}/properties/${property}`, { method: 'DELETE' }); if (!response.ok) { throw new Error(`Failed to delete property: ${response.status}`); } } async function addPropertyToDb(url: string, property: string): Promise { const encodedUrl = encodeURIComponent(url); const response = await fetch(`/api/urls/${encodedUrl}/properties`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(property) }); if (!response.ok) { throw new Error(`Failed to add property: ${response.status}`); } } // Wikidata API functions async function fetchWikidataProperties(wikidataId: string): Promise> { const sparqlQuery = ` SELECT ?prop ?propLabel ?value ?valueLabel WHERE { wd:${wikidataId} ?prop ?statement. ?statement ?ps ?value. ?property wikibase:claim ?prop. ?property wikibase:statementProperty ?ps. SERVICE wikibase:label { bd:serviceParam wikibase:language "en". ?prop rdfs:label ?propLabel. ?value rdfs:label ?valueLabel. } } `; const url = `https://query.wikidata.org/sparql?query=${encodeURIComponent(sparqlQuery)}&format=json`; try { const response = await fetch(url, { headers: { 'Accept': 'application/json' } }); if (!response.ok) return {}; const data = await response.json(); const result: Record = {}; if (data.results?.bindings) { for (const binding of data.results.bindings) { const propUri = binding.prop?.value; const value = binding.valueLabel?.value || binding.value?.value || ''; if (propUri) { const propId = propUri.split('/').pop() || ''; result[propId] = value; } } } return result; } catch (error) { console.error('Error fetching Wikidata properties:', error); return {}; } } async function fetchPropertyLabels(propertyIds: string[]): Promise> { const cleanIds = propertyIds.map(id => id.replace('http://www.wikidata.org/prop/', '')); const propertyIdsStr = cleanIds.join(' wd:'); const sparqlQuery = ` SELECT ?prop ?propLabel WHERE { VALUES ?prop { wd:${propertyIdsStr} } SERVICE wikibase:label { bd:serviceParam wikibase:language "en". } } `; const url = `https://query.wikidata.org/sparql?query=${encodeURIComponent(sparqlQuery)}&format=json`; try { const response = await fetch(url, { headers: { 'Accept': 'application/json' } }); if (!response.ok) return {}; const data = await response.json(); const result: Record = {}; if (data.results?.bindings) { for (const binding of data.results.bindings) { const propUri = binding.prop?.value; const label = binding.propLabel?.value || ''; if (propUri) { const propId = propUri.split('/').pop() || ''; result[propId] = label; } } } return result; } catch (error) { console.error('Error fetching property labels:', error); return {}; } } async function fetchWikidataSuggestions(query: string): Promise { if (!query.trim()) return []; const url = `https://www.wikidata.org/w/api.php?action=wbsearchentities&search=${encodeURIComponent(query)}&language=en&limit=5&format=json&origin=*`; try { const response = await fetch(url); if (!response.ok) return []; const data = await response.json(); return data.search || []; } catch (error) { console.error('Error fetching Wikidata suggestions:', error); return []; } } export function ItemsList({ url }: ItemsListProps) { const queryClient = useQueryClient(); // State management const [items, setItems] = useState([]); const [selectedProperties, setSelectedProperties] = useState>({}); const [customProperties, setCustomProperties] = useState([]); const [propertyLabels, setPropertyLabels] = useState>({}); const [focusedCell, setFocusedCell] = useState(null); const [focusedItemId, setFocusedItemId] = useState(null); const [wikidataSuggestions, setWikidataSuggestions] = useState>({}); const [propertyCache, setPropertyCache] = useState>>({}); // Refs const newPropertyInputRef = useRef(null); // Load items from database const { data: itemsData, isLoading, error } = useQuery({ queryKey: ['items', url], queryFn: () => loadItemsFromDb(url), staleTime: 30000, // 30 seconds }); // Initialize state when data loads useEffect(() => { if (itemsData) { const { items: loadedItems, selectedProperties: selectedProps } = itemsData; if (loadedItems.length === 0) { // Initialize with one empty item const emptyItem: Item = { id: uuidv4(), name: '', description: '', wikidataId: undefined, customProperties: {} }; setItems([emptyItem]); } else { setItems(loadedItems); } // Set selected properties const selectedPropsMap: Record = {}; selectedProps.forEach(prop => { selectedPropsMap[prop] = true; }); setSelectedProperties(selectedPropsMap); // Extract custom properties const customProps = Array.from(new Set( loadedItems.flatMap(item => Object.keys(item.customProperties)) )); setCustomProperties(customProps); // Fetch property labels if (customProps.length > 0) { fetchPropertyLabels(customProps).then(labels => { setPropertyLabels(prev => ({ ...prev, ...labels })); }); } // Set initial focus if (loadedItems.length > 0 && !focusedItemId) { setFocusedItemId(loadedItems[0].id); } } }, [itemsData, focusedItemId]); // Mutations const saveItemMutation = useMutation({ mutationFn: (item: Item) => saveItemToDb(url, item), onSuccess: () => { queryClient.invalidateQueries({ queryKey: ['items', url] }); } }); const deleteItemMutation = useMutation({ mutationFn: (itemId: string) => deleteItemFromDb(url, itemId), onSuccess: () => { queryClient.invalidateQueries({ queryKey: ['items', url] }); } }); const deletePropertyMutation = useMutation({ mutationFn: (property: string) => deletePropertyFromDb(url, property), onSuccess: () => { queryClient.invalidateQueries({ queryKey: ['items', url] }); } }); const addPropertyMutation = useMutation({ mutationFn: (property: string) => addPropertyToDb(url, property), onSuccess: () => { queryClient.invalidateQueries({ queryKey: ['items', url] }); } }); // Event handlers const updateItem = useCallback((index: number, field: string, value: string) => { setItems(prevItems => { const newItems = [...prevItems]; const item = { ...newItems[index] }; if (field === 'name') { item.name = value; // Fetch Wikidata suggestions if (value.trim()) { fetchWikidataSuggestions(value).then(suggestions => { setWikidataSuggestions(prev => ({ ...prev, [`name-${index}`]: suggestions })); }); } } else if (field === 'description') { item.description = value; } else { item.customProperties = { ...item.customProperties, [field]: value }; } newItems[index] = item; // Auto-save saveItemMutation.mutate(item); // Add new row if editing last row and value is not empty if (index === newItems.length - 1 && value.trim()) { const newItem: Item = { id: uuidv4(), name: '', description: '', wikidataId: undefined, customProperties: {} }; newItems.push(newItem); saveItemMutation.mutate(newItem); } return newItems; }); }, [saveItemMutation]); const removeItem = useCallback((index: number) => { const itemId = items[index].id; deleteItemMutation.mutate(itemId); setItems(prev => prev.filter((_, i) => i !== index)); }, [items, deleteItemMutation]); const removeProperty = useCallback((property: string) => { deletePropertyMutation.mutate(property); setCustomProperties(prev => prev.filter(p => p !== property)); setSelectedProperties(prev => { const newSelected = { ...prev }; delete newSelected[property]; return newSelected; }); setItems(prev => prev.map(item => ({ ...item, customProperties: Object.fromEntries( Object.entries(item.customProperties).filter(([key]) => key !== property) ) }))); }, [deletePropertyMutation]); const addProperty = useCallback(async (property: string) => { const normalizedProperty = property.replace('http://www.wikidata.org/prop/', ''); if (!normalizedProperty || selectedProperties[normalizedProperty]) { return; } // Add to selected properties setSelectedProperties(prev => ({ ...prev, [normalizedProperty]: true })); setCustomProperties(prev => [...prev, normalizedProperty]); // Fetch label if not exists if (!propertyLabels[normalizedProperty]) { const labels = await fetchPropertyLabels([normalizedProperty]); setPropertyLabels(prev => ({ ...prev, ...labels })); } // Add to all items setItems(prev => prev.map(item => ({ ...item, customProperties: { ...item.customProperties, [normalizedProperty]: item.customProperties[normalizedProperty] || '' } }))); // Save to database addPropertyMutation.mutate(normalizedProperty); // Fetch Wikidata properties for items with wikidata_id items.forEach(async (item) => { if (item.wikidataId && !propertyCache[item.wikidataId]) { const properties = await fetchWikidataProperties(item.wikidataId); setPropertyCache(prev => ({ ...prev, [item.wikidataId!]: properties })); if (properties[normalizedProperty]) { setItems(prevItems => prevItems.map(prevItem => prevItem.wikidataId === item.wikidataId ? { ...prevItem, customProperties: { ...prevItem.customProperties, [normalizedProperty]: properties[normalizedProperty] } } : prevItem )); } } }); }, [selectedProperties, propertyLabels, items, propertyCache, addPropertyMutation]); const handleWikidataSelect = useCallback(async (suggestion: WikidataSuggestion, itemId: string) => { console.log('Wikidata selection for item:', itemId, suggestion); setItems(prev => prev.map(item => item.id === itemId ? { ...item, name: suggestion.label || '', description: suggestion.description || '', wikidataId: suggestion.id } : item )); // Fetch properties for this Wikidata item if (suggestion.id) { const properties = await fetchWikidataProperties(suggestion.id); setPropertyCache(prev => ({ ...prev, [suggestion.id]: properties })); // Update item with fetched properties setItems(prev => prev.map(item => item.id === itemId ? { ...item, customProperties: { ...item.customProperties, ...Object.fromEntries( Object.entries(properties).filter(([key]) => selectedProperties[key]) ) } } : item )); } }, [selectedProperties]); const handleNewPropertyKeyDown = useCallback((e: React.KeyboardEvent) => { if (e.key === 'Enter') { const input = e.currentTarget; const inputValue = input.value.trim(); if (inputValue) { // Extract property ID from "Label (P123)" format const propertyId = inputValue.includes(' (') && inputValue.endsWith(')') ? inputValue.split(' (').pop()?.slice(0, -1) || inputValue : inputValue; addProperty(propertyId); input.value = ''; } } }, [addProperty]); if (isLoading) { return
Loading items...
; } if (error) { return
Error loading items: {error.message}
; } return (

Items List

{items.map((item, index) => ( ))} {/* Name row */} {items.map((item, index) => ( ))} {/* Description row */} {items.map((item, index) => ( ))} {/* Custom property rows */} {customProperties.map((property) => { const propertyLabel = propertyLabels[property] || property; return ( {items.map((item, index) => ( ))} ); })}
Property {item.name || `Item ${index + 1}`}
Name updateItem(index, 'name', value)} onSelect={(suggestion) => handleWikidataSelect(suggestion, item.id)} fetchSuggestions={fetchWikidataSuggestions} onFocus={() => setFocusedItemId(item.id)} placeholder="Enter item name" />
Description updateItem(index, 'description', value)} focusedCell={focusedCell} setFocusedCell={setFocusedCell} cellKey={`description-${index}`} inputType="textarea" />
{propertyLabel} updateItem(index, property, value)} focusedCell={focusedCell} setFocusedCell={setFocusedCell} cellKey={`${property}-${index}`} inputType="textarea" />
{/* Add new property input */}
{Object.entries(propertyLabels).map(([propertyId, label]) => { const optionValue = `${label} (${propertyId})`; return ( ); })}
); }