Compare commits
No commits in common. "2b4a8050d7abe5e64b228773feb4bd95d5e94882" and "7b9977db6b0ccc997e5b18f92dcd1d46bb795f46" have entirely different histories.
2b4a8050d7
...
7b9977db6b
12 changed files with 154 additions and 1708 deletions
10
.gitignore
vendored
10
.gitignore
vendored
|
@ -10,7 +10,6 @@
|
|||
!.yarn/releases
|
||||
!.yarn/versions
|
||||
|
||||
|
||||
# testing
|
||||
/coverage
|
||||
|
||||
|
@ -41,14 +40,5 @@ 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,75 +1,9 @@
|
|||
# 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
|
||||
|
||||
### Prerequisites
|
||||
First, run the development server:
|
||||
|
||||
- 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
|
||||
```
|
||||
```bash
|
||||
npm run dev
|
||||
# or
|
||||
yarn dev
|
||||
|
@ -79,5 +13,23 @@ pnpm dev
|
|||
bun dev
|
||||
```
|
||||
|
||||
Open http://localhost:3000 with your browser to see the application.
|
||||
Open [http://localhost:3000](http://localhost:3000) with your browser to see the result.
|
||||
|
||||
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,8 +9,7 @@
|
|||
"version": "0.1.0",
|
||||
"dependencies": {
|
||||
"@prisma/client": "^6.9.0",
|
||||
"@tanstack/react-query": "^5.80.7",
|
||||
"@tanstack/react-query-devtools": "^5.80.7",
|
||||
"@tanstack/react-query": "^5.17.0",
|
||||
"next": "15.3.3",
|
||||
"prisma": "^6.9.0",
|
||||
"react": "^19.0.0",
|
||||
|
@ -1803,16 +1802,6 @@
|
|||
"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",
|
||||
|
@ -1829,23 +1818,6 @@
|
|||
"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,8 +15,7 @@
|
|||
},
|
||||
"dependencies": {
|
||||
"@prisma/client": "^6.9.0",
|
||||
"@tanstack/react-query": "^5.80.7",
|
||||
"@tanstack/react-query-devtools": "^5.80.7",
|
||||
"@tanstack/react-query": "^5.17.0",
|
||||
"next": "15.3.3",
|
||||
"prisma": "^6.9.0",
|
||||
"react": "^19.0.0",
|
||||
|
|
|
@ -1,412 +1,26 @@
|
|||
@import "tailwindcss/preflight";
|
||||
@import "tailwindcss/utilities";
|
||||
@import "tailwindcss";
|
||||
|
||||
/* CSS Custom Properties (CSS Variables) */
|
||||
:root {
|
||||
--foreground-rgb: 0, 0, 0;
|
||||
--background-start-rgb: 214, 219, 220;
|
||||
--background-end-rgb: 255, 255, 255;
|
||||
|
||||
/* 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;
|
||||
--background: #ffffff;
|
||||
--foreground: #171717;
|
||||
}
|
||||
|
||||
@theme inline {
|
||||
--color-background: var(--background);
|
||||
--color-foreground: var(--foreground);
|
||||
--font-sans: var(--font-geist-sans);
|
||||
--font-mono: var(--font-geist-mono);
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: dark) {
|
||||
:root {
|
||||
--foreground-rgb: 255, 255, 255;
|
||||
--background-start-rgb: 0, 0, 0;
|
||||
--background-end-rgb: 0, 0, 0;
|
||||
--border-color: #374151;
|
||||
--background: #0a0a0a;
|
||||
--foreground: #ededed;
|
||||
}
|
||||
}
|
||||
|
||||
/* Base body styles */
|
||||
body {
|
||||
color: rgb(var(--foreground-rgb));
|
||||
background: linear-gradient(
|
||||
to bottom,
|
||||
transparent,
|
||||
rgb(var(--background-end-rgb))
|
||||
)
|
||||
rgb(var(--background-start-rgb));
|
||||
background: var(--background);
|
||||
color: var(--foreground);
|
||||
font-family: Arial, Helvetica, sans-serif;
|
||||
}
|
||||
|
||||
/* =================================
|
||||
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,28 +1,34 @@
|
|||
import type { Metadata } from 'next';
|
||||
import { Inter } from 'next/font/google';
|
||||
import { QueryProvider } from '@/providers/QueryProvider';
|
||||
import './globals.css';
|
||||
import type { Metadata } from "next";
|
||||
import { Geist, Geist_Mono } from "next/font/google";
|
||||
import "./globals.css";
|
||||
|
||||
const inter = Inter({ subsets: ['latin'] });
|
||||
const geistSans = Geist({
|
||||
variable: "--font-geist-sans",
|
||||
subsets: ["latin"],
|
||||
});
|
||||
|
||||
const geistMono = Geist_Mono({
|
||||
variable: "--font-geist-mono",
|
||||
subsets: ["latin"],
|
||||
});
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: 'CompareWare - Item Comparison Tool',
|
||||
description: 'Compare and manage items with their properties using Wikidata integration',
|
||||
keywords: ['comparison', 'items', 'wikidata', 'properties', 'database'],
|
||||
title: "Create Next App",
|
||||
description: "Generated by create next app",
|
||||
};
|
||||
|
||||
export default function RootLayout({
|
||||
children,
|
||||
}: {
|
||||
}: Readonly<{
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
}>) {
|
||||
return (
|
||||
<html lang="en">
|
||||
<body className={inter.className}>
|
||||
<QueryProvider>
|
||||
{children}
|
||||
</QueryProvider>
|
||||
<body
|
||||
className={`${geistSans.variable} ${geistMono.variable} antialiased`}
|
||||
>
|
||||
{children}
|
||||
</body>
|
||||
</html>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
213
src/app/page.tsx
213
src/app/page.tsx
|
@ -1,128 +1,103 @@
|
|||
'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;
|
||||
}
|
||||
}
|
||||
import Image from "next/image";
|
||||
|
||||
export default function Home() {
|
||||
// Get the current URL for the ItemsList component
|
||||
const currentUrl = typeof window !== 'undefined' ? window.location.href : 'default';
|
||||
|
||||
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>
|
||||
<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>
|
||||
|
||||
{/* 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 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>
|
||||
</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 */}
|
||||
<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 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>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
@ -1,125 +0,0 @@
|
|||
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>
|
||||
);
|
||||
}
|
|
@ -1,608 +0,0 @@
|
|||
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>
|
||||
);
|
||||
}
|
|
@ -1,280 +0,0 @@
|
|||
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>
|
||||
);
|
||||
}
|
|
@ -1,49 +0,0 @@
|
|||
'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
|
||||
// Wikidata types (for future integration)
|
||||
export interface WikidataSuggestion {
|
||||
id: string;
|
||||
label: string;
|
||||
|
|
Loading…
Add table
Reference in a new issue