Compare commits

...

10 commits

12 changed files with 1712 additions and 158 deletions

10
.gitignore vendored
View file

@ -10,6 +10,7 @@
!.yarn/releases
!.yarn/versions
# testing
/coverage
@ -40,5 +41,14 @@ yarn-error.log*
*.tsbuildinfo
next-env.d.ts
# database files
dev.db
*.db
*.sqlite
*.sqlite3
prisma/*.db
prisma/*.sqlite
prisma/*.sqlite3
/src/generated/prisma
/.qodo

View file

@ -1,9 +1,75 @@
# Compareware
A modern web application for comparing items and their properties, built with Next.js and Prisma.
## What is Compareware?
Compareware allows users to compare different items by their properties in a structured, easy-to-use interface. Users can:
- Add items for comparison via URLs
- Select relevant properties for comparison
- View side-by-side comparisons of items
- Manage and customize comparison criteria
## Tech Stack
- **Frontend**: Next.js 15 with TypeScript
- **Database**: SQLite with Prisma ORM
- **Styling**: Tailwind CSS
- **Runtime**: Node.js
## Getting Started
First, run the development server:
### Prerequisites
```bash
- Node.js 18+
- npm, yarn, pnpm, or bun
### Installation
1. Clone the repository:
```
git clone git@forge.ftt.gmbh:ryanmwangi/Compareware_next.js.git
cd compareware-nextjs
```
Install dependencies:
```
npm install
# or
yarn install
# or
pnpm install
```
# Set up the database:
Generate Prisma client
```
npx prisma generate
```
Create and migrate the database
```
npx prisma migrate dev
```
or if you don't have migrations set up:
```
npx prisma db push
```
(Optional) Seed the database with initial data:
```
npx prisma db seed
```
Running the Development Server
```
npm run dev
# or
yarn dev
@ -13,23 +79,5 @@ pnpm dev
bun dev
```
Open [http://localhost:3000](http://localhost:3000) with your browser to see the result.
Open http://localhost:3000 with your browser to see the application.
You can start editing the page by modifying `app/page.tsx`. The page auto-updates as you edit the file.
This project uses [`next/font`](https://nextjs.org/docs/app/building-your-application/optimizing/fonts) to automatically optimize and load [Geist](https://vercel.com/font), a new font family for Vercel.
## Learn More
To learn more about Next.js, take a look at the following resources:
- [Next.js Documentation](https://nextjs.org/docs) - learn about Next.js features and API.
- [Learn Next.js](https://nextjs.org/learn) - an interactive Next.js tutorial.
You can check out [the Next.js GitHub repository](https://github.com/vercel/next.js) - your feedback and contributions are welcome!
## Deploy on Vercel
The easiest way to deploy your Next.js app is to use the [Vercel Platform](https://vercel.com/new?utm_medium=default-template&filter=next.js&utm_source=create-next-app&utm_campaign=create-next-app-readme) from the creators of Next.js.
Check out our [Next.js deployment documentation](https://nextjs.org/docs/app/building-your-application/deploying) for more details.

30
package-lock.json generated
View file

@ -9,7 +9,8 @@
"version": "0.1.0",
"dependencies": {
"@prisma/client": "^6.9.0",
"@tanstack/react-query": "^5.17.0",
"@tanstack/react-query": "^5.80.7",
"@tanstack/react-query-devtools": "^5.80.7",
"next": "15.3.3",
"prisma": "^6.9.0",
"react": "^19.0.0",
@ -1802,6 +1803,16 @@
"url": "https://github.com/sponsors/tannerlinsley"
}
},
"node_modules/@tanstack/query-devtools": {
"version": "5.80.0",
"resolved": "https://registry.npmjs.org/@tanstack/query-devtools/-/query-devtools-5.80.0.tgz",
"integrity": "sha512-D6gH4asyjaoXrCOt5vG5Og/YSj0D/TxwNQgtLJIgWbhbWCC/emu2E92EFoVHh4ppVWg1qT2gKHvKyQBEFZhCuA==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/tannerlinsley"
}
},
"node_modules/@tanstack/react-query": {
"version": "5.80.7",
"resolved": "https://registry.npmjs.org/@tanstack/react-query/-/react-query-5.80.7.tgz",
@ -1818,6 +1829,23 @@
"react": "^18 || ^19"
}
},
"node_modules/@tanstack/react-query-devtools": {
"version": "5.80.7",
"resolved": "https://registry.npmjs.org/@tanstack/react-query-devtools/-/react-query-devtools-5.80.7.tgz",
"integrity": "sha512-7Dz/19fVo0i+jgLVBabV5vfGOlLyN5L1w8w1/ogFhe6ItNNsNA+ZgNTbtiKpbR3CcX2WDRRTInz1uMSmHzTsoQ==",
"license": "MIT",
"dependencies": {
"@tanstack/query-devtools": "5.80.0"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/tannerlinsley"
},
"peerDependencies": {
"@tanstack/react-query": "^5.80.7",
"react": "^18 || ^19"
}
},
"node_modules/@tybys/wasm-util": {
"version": "0.9.0",
"resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.9.0.tgz",

View file

@ -15,7 +15,8 @@
},
"dependencies": {
"@prisma/client": "^6.9.0",
"@tanstack/react-query": "^5.17.0",
"@tanstack/react-query": "^5.80.7",
"@tanstack/react-query-devtools": "^5.80.7",
"next": "15.3.3",
"prisma": "^6.9.0",
"react": "^19.0.0",

View file

@ -1,26 +1,412 @@
@import "tailwindcss";
@import "tailwindcss/preflight";
@import "tailwindcss/utilities";
/* CSS Custom Properties (CSS Variables) */
:root {
--background: #ffffff;
--foreground: #171717;
}
--foreground-rgb: 0, 0, 0;
--background-start-rgb: 214, 219, 220;
--background-end-rgb: 255, 255, 255;
@theme inline {
--color-background: var(--background);
--color-foreground: var(--foreground);
--font-sans: var(--font-geist-sans);
--font-mono: var(--font-geist-mono);
/* Custom color variables for consistency */
--color-primary: #3b82f6;
--color-primary-hover: #2563eb;
--color-danger: #ef4444;
--color-danger-hover: #dc2626;
--color-warning: #f59e0b;
--color-warning-hover: #d97706;
--color-success: #10b981;
--color-success-hover: #059669;
/* Border and spacing variables */
--border-color: #d1d5db;
--border-radius: 0.375rem;
--spacing-xs: 0.25rem;
--spacing-sm: 0.5rem;
--spacing-md: 1rem;
}
@media (prefers-color-scheme: dark) {
:root {
--background: #0a0a0a;
--foreground: #ededed;
--foreground-rgb: 255, 255, 255;
--background-start-rgb: 0, 0, 0;
--background-end-rgb: 0, 0, 0;
--border-color: #374151;
}
}
/* Base body styles */
body {
background: var(--background);
color: var(--foreground);
font-family: Arial, Helvetica, sans-serif;
color: rgb(var(--foreground-rgb));
background: linear-gradient(
to bottom,
transparent,
rgb(var(--background-end-rgb))
)
rgb(var(--background-start-rgb));
}
/* =================================
TABLE STYLES
================================= */
table {
border-collapse: collapse;
width: 100%;
margin-bottom: var(--spacing-md);
background-color: white;
border-radius: var(--border-radius);
overflow: hidden;
box-shadow: 0 1px 3px 0 rgba(0, 0, 0, 0.1), 0 1px 2px 0 rgba(0, 0, 0, 0.06);
}
table th,
table td {
border: 1px solid var(--border-color);
padding: var(--spacing-sm);
text-align: left;
vertical-align: top;
position: relative;
min-width: 120px;
}
table th {
background-color: #f9fafb;
font-weight: 600;
position: sticky;
top: 0;
z-index: 10;
border-bottom: 2px solid var(--border-color);
}
/* Dark mode table styles */
@media (prefers-color-scheme: dark) {
table {
background-color: #1f2937;
}
table th {
background-color: #374151;
color: #f9fafb;
}
table td {
background-color: #1f2937;
color: #f9fafb;
}
}
/* =================================
DELETE BUTTON STYLES
================================= */
.delete-button {
position: absolute;
top: 2px;
right: 2px;
width: 20px;
height: 20px;
background-color: var(--color-danger);
color: white;
border: none;
border-radius: 50%;
font-size: 12px;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
z-index: 5;
transition: background-color 0.2s ease-in-out;
}
.delete-button:hover {
background-color: var(--color-danger-hover);
transform: scale(1.1);
}
.delete-button:focus {
outline: 2px solid var(--color-danger);
outline-offset: 2px;
}
.item-delete {
background-color: var(--color-danger);
}
.property-delete {
background-color: var(--color-warning);
}
.property-delete:hover {
background-color: var(--color-warning-hover);
}
/* =================================
EDITABLE CELL STYLES
================================= */
.editable-cell {
position: relative;
width: 100%;
min-height: 2rem;
}
.editable-cell input,
.editable-cell textarea {
width: 100%;
border: none;
outline: none;
background: transparent;
resize: none;
padding: var(--spacing-xs);
font-family: inherit;
font-size: inherit;
transition: all 0.2s ease-in-out;
}
.editable-cell input:focus,
.editable-cell textarea:focus {
background-color: #fef3c7;
border: 2px solid var(--color-warning);
border-radius: var(--border-radius);
}
.focused-cell {
background-color: #fef3c7 !important;
border: 2px solid var(--color-warning) !important;
border-radius: var(--border-radius);
}
/* =================================
TYPEAHEAD STYLES
================================= */
.typeahead-container {
position: relative;
width: 100%;
}
.suggestions-dropdown {
position: absolute;
top: 100%;
left: 0;
right: 0;
background: white;
border: 1px solid var(--border-color);
border-top: none;
border-radius: 0 0 var(--border-radius) var(--border-radius);
box-shadow: 0 10px 15px -3px rgba(0, 0, 0, 0.1), 0 4px 6px -2px rgba(0, 0, 0, 0.05);
z-index: 50;
max-height: 200px;
overflow-y: auto;
}
.suggestion-item {
padding: var(--spacing-sm);
cursor: pointer;
border-bottom: 1px solid #f3f4f6;
transition: background-color 0.15s ease-in-out;
}
.suggestion-item:hover,
.suggestion-item.selected {
background-color: #dbeafe;
}
.suggestion-item:last-child {
border-bottom: none;
}
/* Dark mode typeahead styles */
@media (prefers-color-scheme: dark) {
.suggestions-dropdown {
background: #374151;
border-color: #4b5563;
}
.suggestion-item {
color: #f9fafb;
border-bottom-color: #4b5563;
}
.suggestion-item:hover,
.suggestion-item.selected {
background-color: #4b5563;
}
}
/* =================================
FORM INPUT STYLES
================================= */
#new-property {
padding: var(--spacing-sm);
border: 1px solid var(--border-color);
border-radius: var(--border-radius);
width: 300px;
margin-right: var(--spacing-sm);
transition: border-color 0.2s ease-in-out, box-shadow 0.2s ease-in-out;
}
#new-property:focus {
outline: none;
border-color: var(--color-primary);
box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.1);
}
/* =================================
LOADING SPINNER
================================= */
.loading-spinner {
display: inline-block;
width: 16px;
height: 16px;
border: 2px solid #f3f4f6;
border-radius: 50%;
border-top-color: var(--color-primary);
animation: spin 1s ease-in-out infinite;
}
@keyframes spin {
to {
transform: rotate(360deg);
}
}
/* =================================
RESPONSIVE DESIGN
================================= */
.table-container {
overflow-x: auto;
border-radius: var(--border-radius);
border: 1px solid var(--border-color);
}
@media (max-width: 768px) {
table {
min-width: 600px;
}
table th,
table td {
min-width: 100px;
padding: var(--spacing-xs);
font-size: 0.875rem;
}
.delete-button {
width: 18px;
height: 18px;
font-size: 10px;
}
#new-property {
width: 250px;
}
}
@media (max-width: 480px) {
table th,
table td {
min-width: 80px;
padding: 0.125rem;
font-size: 0.75rem;
}
#new-property {
width: 200px;
}
}
/* =================================
UTILITY CLASSES
================================= */
.text-truncate {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.visually-hidden {
position: absolute;
width: 1px;
height: 1px;
padding: 0;
margin: -1px;
overflow: hidden;
clip: rect(0, 0, 0, 0);
white-space: nowrap;
border: 0;
}
/* =================================
ANIMATIONS
================================= */
@keyframes fadeIn {
from {
opacity: 0;
transform: translateY(-10px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
@keyframes slideIn {
from {
opacity: 0;
transform: translateX(-20px);
}
to {
opacity: 1;
transform: translateX(0);
}
}
.new-row {
animation: fadeIn 0.3s ease-in-out;
}
.slide-in {
animation: slideIn 0.2s ease-in-out;
}
/* =================================
ACCESSIBILITY IMPROVEMENTS
================================= */
/* Focus indicators */
button:focus,
input:focus,
textarea:focus,
select:focus {
outline: 2px solid var(--color-primary);
outline-offset: 2px;
}
/* Reduced motion for users who prefer it */
@media (prefers-reduced-motion: reduce) {
*,
*::before,
*::after {
animation-duration: 0.01ms !important;
animation-iteration-count: 1 !important;
transition-duration: 0.01ms !important;
}
}
/* High contrast mode support */
@media (prefers-contrast: high) {
table th,
table td {
border-width: 2px;
}
.delete-button {
border: 2px solid white;
}
}

View file

@ -1,33 +1,27 @@
import type { Metadata } from "next";
import { Geist, Geist_Mono } from "next/font/google";
import "./globals.css";
import type { Metadata } from 'next';
import { Inter } from 'next/font/google';
import { QueryProvider } from '@/providers/QueryProvider';
import './globals.css';
const geistSans = Geist({
variable: "--font-geist-sans",
subsets: ["latin"],
});
const geistMono = Geist_Mono({
variable: "--font-geist-mono",
subsets: ["latin"],
});
const inter = Inter({ subsets: ['latin'] });
export const metadata: Metadata = {
title: "Create Next App",
description: "Generated by create next app",
title: 'CompareWare - Item Comparison Tool',
description: 'Compare and manage items with their properties using Wikidata integration',
keywords: ['comparison', 'items', 'wikidata', 'properties', 'database'],
};
export default function RootLayout({
children,
}: Readonly<{
}: {
children: React.ReactNode;
}>) {
}) {
return (
<html lang="en">
<body
className={`${geistSans.variable} ${geistMono.variable} antialiased`}
>
<body className={inter.className}>
<QueryProvider>
{children}
</QueryProvider>
</body>
</html>
);

View file

@ -1,102 +1,127 @@
import Image from "next/image";
'use client';
import React, { Suspense } from 'react';
import { ItemsList } from '@/components/ItemsList';
// Loading component for better UX
function LoadingSpinner() {
return (
<div className="flex items-center justify-center min-h-[400px]">
<div className="flex flex-col items-center space-y-4">
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-blue-500"></div>
<span className="text-gray-600 text-sm">Loading items...</span>
</div>
</div>
);
}
// Error boundary component
function ErrorFallback({ error, resetError }: { error: Error; resetError: () => void }) {
return (
<div className="flex flex-col items-center justify-center min-h-[400px] p-8">
<div className="bg-red-50 border border-red-200 rounded-lg p-6 max-w-md w-full">
<h2 className="text-red-800 text-lg font-semibold mb-2">
Something went wrong
</h2>
<p className="text-red-600 text-sm mb-4">
{error.message || 'An unexpected error occurred while loading the items.'}
</p>
<div className="flex space-x-3">
<button
onClick={resetError}
className="bg-red-600 text-white px-4 py-2 rounded hover:bg-red-700 transition-colors text-sm"
>
Try again
</button>
<button
onClick={() => window.location.reload()}
className="bg-gray-600 text-white px-4 py-2 rounded hover:bg-gray-700 transition-colors text-sm"
>
Reload page
</button>
</div>
</div>
</div>
);
}
// Simple error boundary implementation
class ErrorBoundary extends React.Component<
{ children: React.ReactNode; fallback: (error: Error, resetError: () => void) => React.ReactNode },
{ hasError: boolean; error?: Error }
> {
constructor(props: { children: React.ReactNode; fallback: (error: Error, resetError: () => void) => React.ReactNode }) {
super(props);
this.state = { hasError: false };
}
static getDerivedStateFromError(error: Error) {
return { hasError: true, error };
}
componentDidCatch(error: Error, errorInfo: React.ErrorInfo) {
console.error('Error caught by boundary:', error, errorInfo);
}
render() {
if (this.state.hasError && this.state.error) {
return this.props.fallback(this.state.error, () => {
this.setState({ hasError: false, error: undefined });
});
}
return this.props.children;
}
}
export default function Home() {
return (
<div className="grid grid-rows-[20px_1fr_20px] items-center justify-items-center min-h-screen p-8 pb-20 gap-16 sm:p-20 font-[family-name:var(--font-geist-sans)]">
<main className="flex flex-col gap-[32px] row-start-2 items-center sm:items-start">
<Image
className="dark:invert"
src="/next.svg"
alt="Next.js logo"
width={180}
height={38}
priority
/>
<ol className="list-inside list-decimal text-sm/6 text-center sm:text-left font-[family-name:var(--font-geist-mono)]">
<li className="mb-2 tracking-[-.01em]">
Get started by editing{" "}
<code className="bg-black/[.05] dark:bg-white/[.06] px-1 py-0.5 rounded font-[family-name:var(--font-geist-mono)] font-semibold">
src/app/page.tsx
</code>
.
</li>
<li className="tracking-[-.01em]">
Save and see your changes instantly.
</li>
</ol>
// Get the current URL for the ItemsList component
const currentUrl = typeof window !== 'undefined' ? window.location.href : 'default';
<div className="flex gap-4 items-center flex-col sm:flex-row">
<a
className="rounded-full border border-solid border-transparent transition-colors flex items-center justify-center bg-foreground text-background gap-2 hover:bg-[#383838] dark:hover:bg-[#ccc] font-medium text-sm sm:text-base h-10 sm:h-12 px-4 sm:px-5 sm:w-auto"
href="https://vercel.com/new?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
target="_blank"
rel="noopener noreferrer"
>
<Image
className="dark:invert"
src="/vercel.svg"
alt="Vercel logomark"
width={20}
height={20}
/>
Deploy now
</a>
<a
className="rounded-full border border-solid border-black/[.08] dark:border-white/[.145] transition-colors flex items-center justify-center hover:bg-[#f2f2f2] dark:hover:bg-[#1a1a1a] hover:border-transparent font-medium text-sm sm:text-base h-10 sm:h-12 px-4 sm:px-5 w-full sm:w-auto md:w-[158px]"
href="https://nextjs.org/docs?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
target="_blank"
rel="noopener noreferrer"
>
Read our docs
</a>
return (
<div className="min-h-screen bg-gray-50">
{/* Header */}
<header className="bg-white shadow-sm border-b">
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
<div className="flex justify-between items-center h-16">
<div className="flex items-center">
<h1 className="text-xl font-semibold text-gray-900">
CompareWare
</h1>
</div>
<div className="flex items-center space-x-4">
{/* Add navigation items here if needed */}
<div className="text-sm text-gray-500">
Item Comparison Tool
</div>
</div>
</div>
</div>
</header>
{/* Main content */}
<main className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
<div className="mb-8">
<h2 className="text-2xl font-bold text-gray-900 mb-2">
Items Comparison
</h2>
</div>
{/* Items list with error boundary and suspense */}
<ErrorBoundary fallback={(error, resetError) => <ErrorFallback error={error} resetError={resetError} />}>
<Suspense fallback={<LoadingSpinner />}>
<ItemsList url={currentUrl} />
</Suspense>
</ErrorBoundary>
</main>
<footer className="row-start-3 flex gap-[24px] flex-wrap items-center justify-center">
<a
className="flex items-center gap-2 hover:underline hover:underline-offset-4"
href="https://nextjs.org/learn?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
target="_blank"
rel="noopener noreferrer"
>
<Image
aria-hidden
src="/file.svg"
alt="File icon"
width={16}
height={16}
/>
Learn
</a>
<a
className="flex items-center gap-2 hover:underline hover:underline-offset-4"
href="https://vercel.com/templates?framework=next.js&utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
target="_blank"
rel="noopener noreferrer"
>
<Image
aria-hidden
src="/window.svg"
alt="Window icon"
width={16}
height={16}
/>
Examples
</a>
<a
className="flex items-center gap-2 hover:underline hover:underline-offset-4"
href="https://nextjs.org?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
target="_blank"
rel="noopener noreferrer"
>
<Image
aria-hidden
src="/globe.svg"
alt="Globe icon"
width={16}
height={16}
/>
Go to nextjs.org
</a>
{/* Footer */}
<footer className="bg-white border-t mt-16">
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
<div className="text-center text-gray-500 text-sm">
<p>&copy; 2025 CompareWare.</p>
</div>
</div>
</footer>
</div>
);

View file

@ -0,0 +1,125 @@
import React, { useState, useEffect, useRef, useCallback } from 'react';
interface EditableCellProps {
value: string;
onInput: (value: string) => void;
focusedCell: string | null;
setFocusedCell: (cellKey: string | null) => void;
cellKey: string;
inputType?: 'text' | 'textarea';
onFocus?: () => void;
onBlur?: () => void;
placeholder?: string;
className?: string;
}
export function EditableCell({
value,
onInput,
focusedCell,
setFocusedCell,
cellKey,
inputType = 'text',
onFocus,
onBlur,
placeholder = '',
className = ''
}: EditableCellProps) {
const [localValue, setLocalValue] = useState(value);
const inputRef = useRef<HTMLInputElement>(null);
const textareaRef = useRef<HTMLTextAreaElement>(null);
// Update local value when prop changes
useEffect(() => {
setLocalValue(value);
}, [value]);
// Handle focus when this cell becomes focused
useEffect(() => {
if (focusedCell === cellKey) {
const element = inputType === 'textarea' ? textareaRef.current : inputRef.current;
if (element) {
element.focus();
// Select all text when focusing
element.select();
}
}
}, [focusedCell, cellKey, inputType]);
const handleInput = useCallback((e: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement>) => {
const newValue = e.target.value;
console.log('Input event:', newValue);
setLocalValue(newValue);
}, []);
const commitInput = useCallback(() => {
console.log('Committing input:', localValue);
onInput(localValue);
}, [localValue, onInput]);
const handleFocus = useCallback(() => {
console.log('Focus gained for key:', cellKey);
setFocusedCell(cellKey);
onFocus?.();
}, [cellKey, setFocusedCell, onFocus]);
const handleBlur = useCallback(() => {
console.log('Focus lost');
setFocusedCell(null);
commitInput();
onBlur?.();
}, [setFocusedCell, commitInput, onBlur]);
const handleKeyDown = useCallback((e: React.KeyboardEvent) => {
if (e.key === 'Enter' && inputType === 'text') {
// For text inputs, commit on Enter
e.preventDefault();
commitInput();
setFocusedCell(null);
} else if (e.key === 'Escape') {
// Reset to original value on Escape
setLocalValue(value);
setFocusedCell(null);
}
}, [inputType, commitInput, setFocusedCell, value]);
const baseClassName = `
w-full px-2 py-1 border-none outline-none resize-none
focus:ring-2 focus:ring-blue-500 focus:ring-inset
${className}
`.trim();
if (inputType === 'textarea') {
return (
<div className="editable-cell">
<textarea
ref={textareaRef}
value={localValue}
onChange={handleInput}
onFocus={handleFocus}
onBlur={handleBlur}
onKeyDown={handleKeyDown}
placeholder={placeholder}
className={`${baseClassName} min-h-[2rem] max-h-32`}
rows={1}
/>
</div>
);
}
return (
<div className="editable-cell">
<input
ref={inputRef}
type="text"
value={localValue}
onChange={handleInput}
onFocus={handleFocus}
onBlur={handleBlur}
onKeyDown={handleKeyDown}
placeholder={placeholder}
className={baseClassName}
/>
</div>
);
}

View file

@ -0,0 +1,608 @@
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>
);
}

View file

@ -0,0 +1,280 @@
import React, { useState, useEffect, useRef, useCallback } from 'react';
import { WikidataSuggestion } from '@/types/database';
interface TypeaheadInputProps {
value: string;
onInput: (value: string) => void;
onSelect: (suggestion: WikidataSuggestion) => void;
fetchSuggestions: (query: string) => Promise<WikidataSuggestion[]>;
onFocus?: () => void;
onBlur?: () => void;
placeholder?: string;
className?: string;
}
export function TypeaheadInput({
value,
onInput,
onSelect,
fetchSuggestions,
onFocus,
onBlur,
placeholder = '',
className = ''
}: TypeaheadInputProps) {
const [localValue, setLocalValue] = useState(value);
const [suggestions, setSuggestions] = useState<WikidataSuggestion[]>([]);
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);
const timeoutRef = useRef<NodeJS.Timeout | undefined>(undefined);
// Update local value when prop changes
useEffect(() => {
setLocalValue(value);
}, [value]);
// Debounced suggestion fetching
const debouncedFetchSuggestions = useCallback(async (query: string) => {
if (timeoutRef.current) {
clearTimeout(timeoutRef.current);
}
timeoutRef.current = setTimeout(async () => {
if (query.trim().length < 2) {
setSuggestions([]);
setShowSuggestions(false);
setIsLoading(false);
return;
}
setIsLoading(true);
try {
console.log('Fetching suggestions for:', query);
const results = await fetchSuggestions(query);
setSuggestions(results);
setShowSuggestions(results.length > 0);
setSelectedIndex(-1);
} catch (error) {
console.error('Error fetching suggestions:', error);
setSuggestions([]);
setShowSuggestions(false);
} finally {
setIsLoading(false);
}
}, 300); // 300ms debounce
}, [fetchSuggestions]);
// Handle input changes
const handleInput = useCallback((e: React.ChangeEvent<HTMLInputElement>) => {
const newValue = e.target.value;
setLocalValue(newValue);
onInput(newValue);
debouncedFetchSuggestions(newValue);
}, [onInput, debouncedFetchSuggestions]);
// Handle keyboard navigation
const handleKeyDown = useCallback((e: React.KeyboardEvent<HTMLInputElement>) => {
if (!showSuggestions || suggestions.length === 0) {
if (e.key === 'Enter') {
e.preventDefault();
// Commit the current value
onInput(localValue);
}
return;
}
switch (e.key) {
case 'ArrowDown':
e.preventDefault();
setSelectedIndex(prev =>
prev < suggestions.length - 1 ? prev + 1 : 0
);
break;
case 'ArrowUp':
e.preventDefault();
setSelectedIndex(prev =>
prev > 0 ? prev - 1 : suggestions.length - 1
);
break;
case 'Enter':
e.preventDefault();
if (selectedIndex >= 0 && selectedIndex < suggestions.length) {
const selectedSuggestion = suggestions[selectedIndex];
handleSelect(selectedSuggestion);
} else {
// No suggestion selected, just commit the current value
onInput(localValue);
setShowSuggestions(false);
}
break;
case 'Escape':
e.preventDefault();
setShowSuggestions(false);
setSelectedIndex(-1);
inputRef.current?.blur();
break;
case 'Tab':
// Allow tab to close suggestions
setShowSuggestions(false);
setSelectedIndex(-1);
break;
}
}, [showSuggestions, suggestions, selectedIndex, localValue, onInput]);
// Handle suggestion selection
const handleSelect = useCallback((suggestion: WikidataSuggestion) => {
console.log('Suggestion selected:', suggestion);
// Update local value with the selected suggestion
const newValue = suggestion.label || '';
setLocalValue(newValue);
onInput(newValue);
// Call the selection handler
onSelect(suggestion);
// Hide suggestions
setShowSuggestions(false);
setSelectedIndex(-1);
// Keep focus on input
inputRef.current?.focus();
}, [onInput, onSelect]);
// Handle focus
const handleFocus = useCallback(() => {
console.log('TypeaheadInput focused');
onFocus?.();
// Show suggestions if we have them and there's a value
if (suggestions.length > 0 && localValue.trim().length >= 2) {
setShowSuggestions(true);
}
}, [onFocus, suggestions.length, localValue]);
// Handle blur
const handleBlur = useCallback((e: React.FocusEvent<HTMLInputElement>) => {
// Delay hiding suggestions to allow for clicks on suggestions
setTimeout(() => {
// Check if the new focus target is within our suggestions
const relatedTarget = e.relatedTarget as HTMLElement;
if (!suggestionsRef.current?.contains(relatedTarget)) {
setShowSuggestions(false);
setSelectedIndex(-1);
onBlur?.();
}
}, 150);
}, [onBlur]);
// Handle clicking on suggestions
const handleSuggestionClick = useCallback((suggestion: WikidataSuggestion) => {
handleSelect(suggestion);
}, [handleSelect]);
// 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]);
// Cleanup timeout on unmount
useEffect(() => {
return () => {
if (timeoutRef.current) {
clearTimeout(timeoutRef.current);
}
};
}, []);
const baseInputClassName = `
w-full px-2 py-1 border border-gray-300 rounded
focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent
${className}
`.trim();
return (
<div className="relative">
<input
ref={inputRef}
type="text"
value={localValue}
onChange={handleInput}
onKeyDown={handleKeyDown}
onFocus={handleFocus}
onBlur={handleBlur}
placeholder={placeholder}
className={baseInputClassName}
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 && suggestions.length > 0 && (
<div
ref={suggestionsRef}
className="absolute z-50 w-full mt-1 bg-white border border-gray-300 rounded-md shadow-lg max-h-60 overflow-y-auto"
>
{suggestions.map((suggestion, index) => {
const isSelected = index === selectedIndex;
const label = suggestion.label || '';
const description = suggestion.description || '';
return (
<div
key={suggestion.id}
className={`
px-3 py-2 cursor-pointer border-b border-gray-100 last:border-b-0
${isSelected
? 'bg-blue-100 text-blue-900'
: 'hover:bg-gray-50'
}
`}
onClick={() => handleSuggestionClick(suggestion)}
onMouseEnter={() => setSelectedIndex(index)}
>
<div className="font-medium text-sm">{label}</div>
{description && (
<div className="text-xs text-gray-600 mt-1 truncate">
{description}
</div>
)}
<div className="text-xs text-gray-400 mt-1">
ID: {suggestion.id}
</div>
</div>
);
})}
</div>
)}
</div>
);
}

View file

@ -0,0 +1,49 @@
'use client';
import React from 'react';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { ReactQueryDevtools } from '@tanstack/react-query-devtools';
// Create a client with optimized settings for your app
const queryClient = new QueryClient({
defaultOptions: {
queries: {
// Stale time: how long data stays fresh (5 minutes)
staleTime: 5 * 60 * 1000,
// Cache time: how long data stays in cache when unused (10 minutes)
gcTime: 10 * 60 * 1000,
// Retry failed requests 3 times
retry: 3,
// Retry delay increases exponentially
retryDelay: (attemptIndex) => Math.min(1000 * 2 ** attemptIndex, 30000),
// Refetch on window focus (useful for data consistency)
refetchOnWindowFocus: false,
// Refetch on reconnect
refetchOnReconnect: true,
},
mutations: {
// Retry failed mutations once
retry: 1,
// Retry delay for mutations
retryDelay: 1000,
},
},
});
interface QueryProviderProps {
children: React.ReactNode;
}
export function QueryProvider({ children }: QueryProviderProps) {
return (
<QueryClientProvider client={queryClient}>
{children}
{/* Only show devtools in development */}
{process.env.NODE_ENV === 'development' && (
<ReactQueryDevtools
initialIsOpen={false}
/>
)}
</QueryClientProvider>
);
}

View file

@ -57,7 +57,7 @@ export interface PropertyResponse {
globalUsageCount: number;
}
// Wikidata types (for future integration)
// Wikidata types
export interface WikidataSuggestion {
id: string;
label: string;