Compare commits
10 commits
7b9977db6b
...
2b4a8050d7
Author | SHA1 | Date | |
---|---|---|---|
2b4a8050d7 | |||
35e565467f | |||
862c2587c1 | |||
0432164522 | |||
79b90e770b | |||
6096cc0670 | |||
dcac90109f | |||
f75db7e3bd | |||
007abefed2 | |||
7dbed7cc34 |
12 changed files with 1712 additions and 158 deletions
10
.gitignore
vendored
10
.gitignore
vendored
|
@ -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
|
||||
|
|
90
README.md
90
README.md
|
@ -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
30
package-lock.json
generated
|
@ -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",
|
||||
|
|
|
@ -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",
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
}
|
|
@ -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`}
|
||||
>
|
||||
{children}
|
||||
<body className={inter.className}>
|
||||
<QueryProvider>
|
||||
{children}
|
||||
</QueryProvider>
|
||||
</body>
|
||||
</html>
|
||||
);
|
||||
|
|
213
src/app/page.tsx
213
src/app/page.tsx
|
@ -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>© 2025 CompareWare.</p>
|
||||
</div>
|
||||
</div>
|
||||
</footer>
|
||||
</div>
|
||||
);
|
||||
|
|
125
src/components/EditableCell.tsx
Normal file
125
src/components/EditableCell.tsx
Normal 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>
|
||||
);
|
||||
}
|
608
src/components/ItemsList.tsx
Normal file
608
src/components/ItemsList.tsx
Normal 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>
|
||||
);
|
||||
}
|
280
src/components/TypeaheadInput.tsx
Normal file
280
src/components/TypeaheadInput.tsx
Normal 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>
|
||||
);
|
||||
}
|
49
src/providers/QueryProvider.tsx
Normal file
49
src/providers/QueryProvider.tsx
Normal 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>
|
||||
);
|
||||
}
|
|
@ -57,7 +57,7 @@ export interface PropertyResponse {
|
|||
globalUsageCount: number;
|
||||
}
|
||||
|
||||
// Wikidata types (for future integration)
|
||||
// Wikidata types
|
||||
export interface WikidataSuggestion {
|
||||
id: string;
|
||||
label: string;
|
||||
|
|
Loading…
Add table
Reference in a new issue