Compareware_next.js/src/components/ItemsList.tsx

608 lines
No EOL
20 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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<ItemsResponse> {
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<void> {
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<void> {
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<void> {
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<void> {
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<Record<string, string>> {
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<string, string> = {};
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<Record<string, string>> {
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<string, string> = {};
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<WikidataSuggestion[]> {
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<Item[]>([]);
const [selectedProperties, setSelectedProperties] = useState<Record<string, boolean>>({});
const [customProperties, setCustomProperties] = useState<string[]>([]);
const [propertyLabels, setPropertyLabels] = useState<Record<string, string>>({});
const [focusedCell, setFocusedCell] = useState<string | null>(null);
const [focusedItemId, setFocusedItemId] = useState<string | null>(null);
const [wikidataSuggestions, setWikidataSuggestions] = useState<Record<string, WikidataSuggestion[]>>({});
const [propertyCache, setPropertyCache] = useState<Record<string, Record<string, string>>>({});
// Refs
const newPropertyInputRef = useRef<HTMLInputElement>(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<string, boolean> = {};
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<HTMLInputElement>) => {
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 <div className="p-4">Loading items...</div>;
}
if (error) {
return <div className="p-4 text-red-600">Error loading items: {error.message}</div>;
}
return (
<div className="p-4">
<h1 className="text-2xl font-bold mb-4">Items List</h1>
<div className="overflow-x-auto">
<table className="min-w-full border-collapse border border-gray-300">
<thead>
<tr>
<th className="border border-gray-300 p-2 bg-gray-100">Property</th>
{items.map((item, index) => (
<th key={item.id} className="border border-gray-300 p-2 bg-gray-100 relative">
{item.name || `Item ${index + 1}`}
<button
onClick={() => removeItem(index)}
className="absolute top-1 right-1 w-5 h-5 bg-red-500 text-white rounded-full text-xs hover:bg-red-600"
title="Delete item"
>
×
</button>
</th>
))}
</tr>
</thead>
<tbody>
{/* Name row */}
<tr>
<td className="border border-gray-300 p-2 font-medium">Name</td>
{items.map((item, index) => (
<td key={`name-${item.id}`} className="border border-gray-300 p-2">
<TypeaheadInput
value={item.name}
onInput={(value) => updateItem(index, 'name', value)}
onSelect={(suggestion) => handleWikidataSelect(suggestion, item.id)}
fetchSuggestions={fetchWikidataSuggestions}
onFocus={() => setFocusedItemId(item.id)}
placeholder="Enter item name"
/>
</td>
))}
</tr>
{/* Description row */}
<tr>
<td className="border border-gray-300 p-2 font-medium">Description</td>
{items.map((item, index) => (
<td key={`description-${item.id}`} className="border border-gray-300 p-2">
<EditableCell
value={item.description}
onInput={(value) => updateItem(index, 'description', value)}
focusedCell={focusedCell}
setFocusedCell={setFocusedCell}
cellKey={`description-${index}`}
inputType="textarea"
/>
</td>
))}
</tr>
{/* Custom property rows */}
{customProperties.map((property) => {
const propertyLabel = propertyLabels[property] || property;
return (
<tr key={property}>
<td className="border border-gray-300 p-2 font-medium relative">
{propertyLabel}
<button
onClick={() => removeProperty(property)}
className="absolute top-1 right-1 w-5 h-5 bg-red-500 text-white rounded-full text-xs hover:bg-red-600"
title="Delete property"
>
×
</button>
</td>
{items.map((item, index) => (
<td key={`${property}-${item.id}`} className="border border-gray-300 p-2">
<EditableCell
value={item.customProperties[property] || ''}
onInput={(value) => updateItem(index, property, value)}
focusedCell={focusedCell}
setFocusedCell={setFocusedCell}
cellKey={`${property}-${index}`}
inputType="textarea"
/>
</td>
))}
</tr>
);
})}
</tbody>
</table>
</div>
{/* Add new property input */}
<div className="mt-4">
<input
ref={newPropertyInputRef}
type="text"
placeholder="Add New Property"
onKeyDown={handleNewPropertyKeyDown}
className="px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
list="properties"
/>
<datalist id="properties">
{Object.entries(propertyLabels).map(([propertyId, label]) => {
const optionValue = `${label} (${propertyId})`;
return (
<option key={propertyId} value={optionValue}>
{optionValue}
</option>
);
})}
</datalist>
</div>
</div>
);
}