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/releases
|
||||||
!.yarn/versions
|
!.yarn/versions
|
||||||
|
|
||||||
|
|
||||||
# testing
|
# testing
|
||||||
/coverage
|
/coverage
|
||||||
|
|
||||||
|
@ -40,5 +41,14 @@ yarn-error.log*
|
||||||
*.tsbuildinfo
|
*.tsbuildinfo
|
||||||
next-env.d.ts
|
next-env.d.ts
|
||||||
|
|
||||||
|
# database files
|
||||||
|
dev.db
|
||||||
|
*.db
|
||||||
|
*.sqlite
|
||||||
|
*.sqlite3
|
||||||
|
prisma/*.db
|
||||||
|
prisma/*.sqlite
|
||||||
|
prisma/*.sqlite3
|
||||||
|
|
||||||
/src/generated/prisma
|
/src/generated/prisma
|
||||||
/.qodo
|
/.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
|
## 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
|
npm run dev
|
||||||
# or
|
# or
|
||||||
yarn dev
|
yarn dev
|
||||||
|
@ -13,23 +79,5 @@ pnpm dev
|
||||||
bun 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",
|
"version": "0.1.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@prisma/client": "^6.9.0",
|
"@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",
|
"next": "15.3.3",
|
||||||
"prisma": "^6.9.0",
|
"prisma": "^6.9.0",
|
||||||
"react": "^19.0.0",
|
"react": "^19.0.0",
|
||||||
|
@ -1802,6 +1803,16 @@
|
||||||
"url": "https://github.com/sponsors/tannerlinsley"
|
"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": {
|
"node_modules/@tanstack/react-query": {
|
||||||
"version": "5.80.7",
|
"version": "5.80.7",
|
||||||
"resolved": "https://registry.npmjs.org/@tanstack/react-query/-/react-query-5.80.7.tgz",
|
"resolved": "https://registry.npmjs.org/@tanstack/react-query/-/react-query-5.80.7.tgz",
|
||||||
|
@ -1818,6 +1829,23 @@
|
||||||
"react": "^18 || ^19"
|
"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": {
|
"node_modules/@tybys/wasm-util": {
|
||||||
"version": "0.9.0",
|
"version": "0.9.0",
|
||||||
"resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.9.0.tgz",
|
"resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.9.0.tgz",
|
||||||
|
|
|
@ -15,7 +15,8 @@
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@prisma/client": "^6.9.0",
|
"@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",
|
"next": "15.3.3",
|
||||||
"prisma": "^6.9.0",
|
"prisma": "^6.9.0",
|
||||||
"react": "^19.0.0",
|
"react": "^19.0.0",
|
||||||
|
|
|
@ -1,26 +1,412 @@
|
||||||
@import "tailwindcss";
|
@import "tailwindcss/preflight";
|
||||||
|
@import "tailwindcss/utilities";
|
||||||
|
|
||||||
|
/* CSS Custom Properties (CSS Variables) */
|
||||||
:root {
|
:root {
|
||||||
--background: #ffffff;
|
--foreground-rgb: 0, 0, 0;
|
||||||
--foreground: #171717;
|
--background-start-rgb: 214, 219, 220;
|
||||||
}
|
--background-end-rgb: 255, 255, 255;
|
||||||
|
|
||||||
@theme inline {
|
/* Custom color variables for consistency */
|
||||||
--color-background: var(--background);
|
--color-primary: #3b82f6;
|
||||||
--color-foreground: var(--foreground);
|
--color-primary-hover: #2563eb;
|
||||||
--font-sans: var(--font-geist-sans);
|
--color-danger: #ef4444;
|
||||||
--font-mono: var(--font-geist-mono);
|
--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) {
|
@media (prefers-color-scheme: dark) {
|
||||||
:root {
|
:root {
|
||||||
--background: #0a0a0a;
|
--foreground-rgb: 255, 255, 255;
|
||||||
--foreground: #ededed;
|
--background-start-rgb: 0, 0, 0;
|
||||||
|
--background-end-rgb: 0, 0, 0;
|
||||||
|
--border-color: #374151;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Base body styles */
|
||||||
body {
|
body {
|
||||||
background: var(--background);
|
color: rgb(var(--foreground-rgb));
|
||||||
color: var(--foreground);
|
background: linear-gradient(
|
||||||
font-family: Arial, Helvetica, sans-serif;
|
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 type { Metadata } from 'next';
|
||||||
import { Geist, Geist_Mono } from "next/font/google";
|
import { Inter } from 'next/font/google';
|
||||||
import "./globals.css";
|
import { QueryProvider } from '@/providers/QueryProvider';
|
||||||
|
import './globals.css';
|
||||||
|
|
||||||
const geistSans = Geist({
|
const inter = Inter({ subsets: ['latin'] });
|
||||||
variable: "--font-geist-sans",
|
|
||||||
subsets: ["latin"],
|
|
||||||
});
|
|
||||||
|
|
||||||
const geistMono = Geist_Mono({
|
|
||||||
variable: "--font-geist-mono",
|
|
||||||
subsets: ["latin"],
|
|
||||||
});
|
|
||||||
|
|
||||||
export const metadata: Metadata = {
|
export const metadata: Metadata = {
|
||||||
title: "Create Next App",
|
title: 'CompareWare - Item Comparison Tool',
|
||||||
description: "Generated by create next app",
|
description: 'Compare and manage items with their properties using Wikidata integration',
|
||||||
|
keywords: ['comparison', 'items', 'wikidata', 'properties', 'database'],
|
||||||
};
|
};
|
||||||
|
|
||||||
export default function RootLayout({
|
export default function RootLayout({
|
||||||
children,
|
children,
|
||||||
}: Readonly<{
|
}: {
|
||||||
children: React.ReactNode;
|
children: React.ReactNode;
|
||||||
}>) {
|
}) {
|
||||||
return (
|
return (
|
||||||
<html lang="en">
|
<html lang="en">
|
||||||
<body
|
<body className={inter.className}>
|
||||||
className={`${geistSans.variable} ${geistMono.variable} antialiased`}
|
<QueryProvider>
|
||||||
>
|
{children}
|
||||||
{children}
|
</QueryProvider>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</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() {
|
export default function Home() {
|
||||||
return (
|
// Get the current URL for the ItemsList component
|
||||||
<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)]">
|
const currentUrl = typeof window !== 'undefined' ? window.location.href : 'default';
|
||||||
<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>
|
|
||||||
|
|
||||||
<div className="flex gap-4 items-center flex-col sm:flex-row">
|
return (
|
||||||
<a
|
<div className="min-h-screen bg-gray-50">
|
||||||
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"
|
{/* Header */}
|
||||||
href="https://vercel.com/new?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
|
<header className="bg-white shadow-sm border-b">
|
||||||
target="_blank"
|
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
||||||
rel="noopener noreferrer"
|
<div className="flex justify-between items-center h-16">
|
||||||
>
|
<div className="flex items-center">
|
||||||
<Image
|
<h1 className="text-xl font-semibold text-gray-900">
|
||||||
className="dark:invert"
|
CompareWare
|
||||||
src="/vercel.svg"
|
</h1>
|
||||||
alt="Vercel logomark"
|
</div>
|
||||||
width={20}
|
<div className="flex items-center space-x-4">
|
||||||
height={20}
|
{/* Add navigation items here if needed */}
|
||||||
/>
|
<div className="text-sm text-gray-500">
|
||||||
Deploy now
|
Item Comparison Tool
|
||||||
</a>
|
</div>
|
||||||
<a
|
</div>
|
||||||
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]"
|
</div>
|
||||||
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>
|
</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>
|
</main>
|
||||||
<footer className="row-start-3 flex gap-[24px] flex-wrap items-center justify-center">
|
|
||||||
<a
|
{/* Footer */}
|
||||||
className="flex items-center gap-2 hover:underline hover:underline-offset-4"
|
<footer className="bg-white border-t mt-16">
|
||||||
href="https://nextjs.org/learn?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
|
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
|
||||||
target="_blank"
|
<div className="text-center text-gray-500 text-sm">
|
||||||
rel="noopener noreferrer"
|
<p>© 2025 CompareWare.</p>
|
||||||
>
|
</div>
|
||||||
<Image
|
</div>
|
||||||
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>
|
||||||
</div>
|
</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;
|
globalUsageCount: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Wikidata types (for future integration)
|
// Wikidata types
|
||||||
export interface WikidataSuggestion {
|
export interface WikidataSuggestion {
|
||||||
id: string;
|
id: string;
|
||||||
label: string;
|
label: string;
|
||||||
|
|
Loading…
Add table
Reference in a new issue