26 Commits

Author SHA1 Message Date
C_Ma_For d16cd1fbf5 feat: implémenter le dashboard avec KPIs, dernière sync et actions rapides
- Nouvel endpoint /api/dashboard/stats : comptes produits Shopify (actifs/brouillons/sans image) + dernière sync depuis Prisma
- Nouvel endpoint /api/dashboard/pending : nombre de produits en attente depuis Google Sheets (lazy)
- Page dashboard remplace le stub avec 4 KPIs, bloc dernière sync, et raccourcis vers les sections

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-11 22:42:57 +02:00
C_Ma_For fecf56ff09 fix: retry 429 avec backoff + sérialisation uploadAll + 409 traité comme skipped 2026-06-11 14:37:12 +02:00
C_Ma_For 85b0590d85 fix: split avant/après avec overflow-hidden et largeur dynamique 2026-06-11 13:40:14 +02:00
C_Ma_For 725a7508b0 feat: vérification image existante sur Shopify avant upload (statut 'Déjà sur Shopify') 2026-06-11 13:35:47 +02:00
C_Ma_For 4df074f02d fix: image sortie 1000x1000 centrée + affichage split avant/après corrigé 2026-06-11 13:14:41 +02:00
C_Ma_For 06cda68929 fix: match UGS comme mot entier n'importe où dans le nom de fichier 2026-06-11 12:51:27 +02:00
C_Ma_For c19510d413 fix: match UGS en début de nom de fichier exact (évite 30 de matcher 30827) 2026-06-11 12:47:26 +02:00
C_Ma_For 2de5c6670d fix: recherche produit par SKU Shopify avec fallback sans suffixe (30827-dessus → 30827) 2026-06-11 12:20:27 +02:00
C_Ma_For 6a08fccb7a fix: recherche UGS par contains dans le nom de fichier (ex: 30827-dessus contient 30827) 2026-06-10 09:38:16 +02:00
C_Ma_For da52080e3e fix: match UGS en préfixe dans search-sheets pour les images avec suffixe (dessus, -2, etc.) 2026-06-10 09:17:22 +02:00
C_Ma_For f0b6ddf145 fix: propager le type réel du métachamp Shopify pour éviter les erreurs 422 2026-06-09 21:54:41 +02:00
C_Ma_For d1e78e91e4 feat: sélection manuelle de produit Shopify quand réf. introuvable 2026-06-09 21:00:45 +02:00
C_Ma_For 071f126244 fix: re-ajouter import map onnxruntime-web pour détourage IA CDN 2026-06-09 14:54:29 +02:00
C_Ma_For 415b7db643 fix: détecter ligne d'en-têtes dynamiquement (row 1 vide sur OUTILLAGE etc, row 2 = vrais headers) 2026-06-09 14:50:23 +02:00
C_Ma_For 8ede847ba0 fix: recherche produit via Google Sheets (UGS col C → Shopify ID col A) + fallback Shopify 2026-06-09 13:41:53 +02:00
C_Ma_For 193edc80aa fix: originalUrl mis à jour avec JPEG après conversion HEIC (aperçu original noir) 2026-06-09 13:27:10 +02:00
C_Ma_For 1f8db29b15 fix: ErrorBoundary autour de ImagesPage pour capturer crash IA 2026-06-09 13:19:45 +02:00
C_Ma_For 4b9db5bebf fix: revert @imgly npm import → CDN + ErrorBoundary + next.config cleanup 2026-06-09 13:15:04 +02:00
C_Ma_For a77f44b800 fix: @imgly/background-removal via npm + publicPath CDN + asyncWebAssembly webpack 2026-06-09 12:27:18 +02:00
C_Ma_For 0b26a61f90 fix: import map onnxruntime-web pour @imgly/background-removal CDN 2026-06-09 12:09:58 +02:00
C_Ma_For 2efe339576 fix: conversion HEIC serveur via heic-convert (WASM, supporte HEIC Apple vs sharp AVIF only) 2026-06-09 11:50:00 +02:00
C_Ma_For c4edcb568c feat: conversion HEIC serveur via sharp (fallback fiable Chrome/Firefox) 2026-06-09 11:24:24 +02:00
C_Ma_For e3246d022e fix: conversion HEIC — ajout fallback img+canvas pour Chrome macOS 2026-06-09 11:11:17 +02:00
C_Ma_For 9ab8b3ef60 fix: conversion HEIC native d'abord (Safari/macOS), heic2any fallback 30s 2026-06-09 11:07:04 +02:00
C_Ma_For 7da453606e fix(ui): MetafieldResolver — défaut skip, UX claire create/map/ignore + labels 2026-06-09 10:56:50 +02:00
C_Ma_For 8d591bb769 fix: fetchMetafieldDefinitions + ensureMetafieldDefinition via GraphQL (REST n'expose pas cet endpoint) 2026-06-09 10:46:34 +02:00
22 changed files with 1853 additions and 107 deletions
+36
View File
@@ -0,0 +1,36 @@
This is a [Next.js](https://nextjs.org) project bootstrapped with [`create-next-app`](https://nextjs.org/docs/app/api-reference/cli/create-next-app).
## Getting Started
First, run the development server:
```bash
npm run dev
# or
yarn dev
# or
pnpm dev
# or
bun dev
```
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.
+799 -5
View File
@@ -8,17 +8,21 @@
"name": "gestion-md",
"version": "0.1.0",
"dependencies": {
"@imgly/background-removal": "^1.7.0",
"@prisma/adapter-better-sqlite3": "^7.8.0",
"@prisma/client": "^7.8.0",
"bcryptjs": "^3.0.3",
"better-sqlite3": "^12.10.0",
"googleapis": "^173.0.0",
"heic-convert": "^2.1.0",
"heic2any": "^0.0.4",
"jszip": "^3.10.1",
"next": "14.2.35",
"next-auth": "^4.24.14",
"onnxruntime-web": "^1.21.0",
"react": "^18",
"react-dom": "^18"
"react-dom": "^18",
"sharp": "^0.34.5"
},
"devDependencies": {
"@testing-library/jest-dom": "^6.9.1",
@@ -28,6 +32,7 @@
"@types/node": "^20",
"@types/react": "^18",
"@types/react-dom": "^18",
"@types/sharp": "^0.31.1",
"eslint": "^8",
"eslint-config-next": "14.2.35",
"jest": "^30.4.2",
@@ -812,7 +817,6 @@
"version": "1.10.0",
"resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.10.0.tgz",
"integrity": "sha512-ewvYlk86xUoGI0zQRNq/mC+16R1QeDlKQy21Ki3oSYXNgLb45GV1P6A0M+/s6nyCuNDqe5VpaY84BzXGwVbwFA==",
"dev": true,
"license": "MIT",
"optional": true,
"dependencies": {
@@ -928,6 +932,533 @@
"dev": true,
"license": "BSD-3-Clause"
},
"node_modules/@img/colour": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/@img/colour/-/colour-1.1.0.tgz",
"integrity": "sha512-Td76q7j57o/tLVdgS746cYARfSyxk8iEfRxewL9h4OMzYhbW4TAcppl0mT4eyqXddh6L/jwoM75mo7ixa/pCeQ==",
"license": "MIT",
"engines": {
"node": ">=18"
}
},
"node_modules/@img/sharp-darwin-arm64": {
"version": "0.34.5",
"resolved": "https://registry.npmjs.org/@img/sharp-darwin-arm64/-/sharp-darwin-arm64-0.34.5.tgz",
"integrity": "sha512-imtQ3WMJXbMY4fxb/Ndp6HBTNVtWCUI0WdobyheGf5+ad6xX8VIDO8u2xE4qc/fr08CKG/7dDseFtn6M6g/r3w==",
"cpu": [
"arm64"
],
"license": "Apache-2.0",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
},
"optionalDependencies": {
"@img/sharp-libvips-darwin-arm64": "1.2.4"
}
},
"node_modules/@img/sharp-darwin-x64": {
"version": "0.34.5",
"resolved": "https://registry.npmjs.org/@img/sharp-darwin-x64/-/sharp-darwin-x64-0.34.5.tgz",
"integrity": "sha512-YNEFAF/4KQ/PeW0N+r+aVVsoIY0/qxxikF2SWdp+NRkmMB7y9LBZAVqQ4yhGCm/H3H270OSykqmQMKLBhBJDEw==",
"cpu": [
"x64"
],
"license": "Apache-2.0",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
},
"optionalDependencies": {
"@img/sharp-libvips-darwin-x64": "1.2.4"
}
},
"node_modules/@img/sharp-libvips-darwin-arm64": {
"version": "1.2.4",
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-arm64/-/sharp-libvips-darwin-arm64-1.2.4.tgz",
"integrity": "sha512-zqjjo7RatFfFoP0MkQ51jfuFZBnVE2pRiaydKJ1G/rHZvnsrHAOcQALIi9sA5co5xenQdTugCvtb1cuf78Vf4g==",
"cpu": [
"arm64"
],
"license": "LGPL-3.0-or-later",
"optional": true,
"os": [
"darwin"
],
"funding": {
"url": "https://opencollective.com/libvips"
}
},
"node_modules/@img/sharp-libvips-darwin-x64": {
"version": "1.2.4",
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-x64/-/sharp-libvips-darwin-x64-1.2.4.tgz",
"integrity": "sha512-1IOd5xfVhlGwX+zXv2N93k0yMONvUlANylbJw1eTah8K/Jtpi15KC+WSiaX/nBmbm2HxRM1gZ0nSdjSsrZbGKg==",
"cpu": [
"x64"
],
"license": "LGPL-3.0-or-later",
"optional": true,
"os": [
"darwin"
],
"funding": {
"url": "https://opencollective.com/libvips"
}
},
"node_modules/@img/sharp-libvips-linux-arm": {
"version": "1.2.4",
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm/-/sharp-libvips-linux-arm-1.2.4.tgz",
"integrity": "sha512-bFI7xcKFELdiNCVov8e44Ia4u2byA+l3XtsAj+Q8tfCwO6BQ8iDojYdvoPMqsKDkuoOo+X6HZA0s0q11ANMQ8A==",
"cpu": [
"arm"
],
"libc": [
"glibc"
],
"license": "LGPL-3.0-or-later",
"optional": true,
"os": [
"linux"
],
"funding": {
"url": "https://opencollective.com/libvips"
}
},
"node_modules/@img/sharp-libvips-linux-arm64": {
"version": "1.2.4",
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm64/-/sharp-libvips-linux-arm64-1.2.4.tgz",
"integrity": "sha512-excjX8DfsIcJ10x1Kzr4RcWe1edC9PquDRRPx3YVCvQv+U5p7Yin2s32ftzikXojb1PIFc/9Mt28/y+iRklkrw==",
"cpu": [
"arm64"
],
"libc": [
"glibc"
],
"license": "LGPL-3.0-or-later",
"optional": true,
"os": [
"linux"
],
"funding": {
"url": "https://opencollective.com/libvips"
}
},
"node_modules/@img/sharp-libvips-linux-ppc64": {
"version": "1.2.4",
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-ppc64/-/sharp-libvips-linux-ppc64-1.2.4.tgz",
"integrity": "sha512-FMuvGijLDYG6lW+b/UvyilUWu5Ayu+3r2d1S8notiGCIyYU/76eig1UfMmkZ7vwgOrzKzlQbFSuQfgm7GYUPpA==",
"cpu": [
"ppc64"
],
"libc": [
"glibc"
],
"license": "LGPL-3.0-or-later",
"optional": true,
"os": [
"linux"
],
"funding": {
"url": "https://opencollective.com/libvips"
}
},
"node_modules/@img/sharp-libvips-linux-riscv64": {
"version": "1.2.4",
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-riscv64/-/sharp-libvips-linux-riscv64-1.2.4.tgz",
"integrity": "sha512-oVDbcR4zUC0ce82teubSm+x6ETixtKZBh/qbREIOcI3cULzDyb18Sr/Wcyx7NRQeQzOiHTNbZFF1UwPS2scyGA==",
"cpu": [
"riscv64"
],
"libc": [
"glibc"
],
"license": "LGPL-3.0-or-later",
"optional": true,
"os": [
"linux"
],
"funding": {
"url": "https://opencollective.com/libvips"
}
},
"node_modules/@img/sharp-libvips-linux-s390x": {
"version": "1.2.4",
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-s390x/-/sharp-libvips-linux-s390x-1.2.4.tgz",
"integrity": "sha512-qmp9VrzgPgMoGZyPvrQHqk02uyjA0/QrTO26Tqk6l4ZV0MPWIW6LTkqOIov+J1yEu7MbFQaDpwdwJKhbJvuRxQ==",
"cpu": [
"s390x"
],
"libc": [
"glibc"
],
"license": "LGPL-3.0-or-later",
"optional": true,
"os": [
"linux"
],
"funding": {
"url": "https://opencollective.com/libvips"
}
},
"node_modules/@img/sharp-libvips-linux-x64": {
"version": "1.2.4",
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-x64/-/sharp-libvips-linux-x64-1.2.4.tgz",
"integrity": "sha512-tJxiiLsmHc9Ax1bz3oaOYBURTXGIRDODBqhveVHonrHJ9/+k89qbLl0bcJns+e4t4rvaNBxaEZsFtSfAdquPrw==",
"cpu": [
"x64"
],
"libc": [
"glibc"
],
"license": "LGPL-3.0-or-later",
"optional": true,
"os": [
"linux"
],
"funding": {
"url": "https://opencollective.com/libvips"
}
},
"node_modules/@img/sharp-libvips-linuxmusl-arm64": {
"version": "1.2.4",
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-arm64/-/sharp-libvips-linuxmusl-arm64-1.2.4.tgz",
"integrity": "sha512-FVQHuwx1IIuNow9QAbYUzJ+En8KcVm9Lk5+uGUQJHaZmMECZmOlix9HnH7n1TRkXMS0pGxIJokIVB9SuqZGGXw==",
"cpu": [
"arm64"
],
"libc": [
"musl"
],
"license": "LGPL-3.0-or-later",
"optional": true,
"os": [
"linux"
],
"funding": {
"url": "https://opencollective.com/libvips"
}
},
"node_modules/@img/sharp-libvips-linuxmusl-x64": {
"version": "1.2.4",
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-x64/-/sharp-libvips-linuxmusl-x64-1.2.4.tgz",
"integrity": "sha512-+LpyBk7L44ZIXwz/VYfglaX/okxezESc6UxDSoyo2Ks6Jxc4Y7sGjpgU9s4PMgqgjj1gZCylTieNamqA1MF7Dg==",
"cpu": [
"x64"
],
"libc": [
"musl"
],
"license": "LGPL-3.0-or-later",
"optional": true,
"os": [
"linux"
],
"funding": {
"url": "https://opencollective.com/libvips"
}
},
"node_modules/@img/sharp-linux-arm": {
"version": "0.34.5",
"resolved": "https://registry.npmjs.org/@img/sharp-linux-arm/-/sharp-linux-arm-0.34.5.tgz",
"integrity": "sha512-9dLqsvwtg1uuXBGZKsxem9595+ujv0sJ6Vi8wcTANSFpwV/GONat5eCkzQo/1O6zRIkh0m/8+5BjrRr7jDUSZw==",
"cpu": [
"arm"
],
"libc": [
"glibc"
],
"license": "Apache-2.0",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
},
"optionalDependencies": {
"@img/sharp-libvips-linux-arm": "1.2.4"
}
},
"node_modules/@img/sharp-linux-arm64": {
"version": "0.34.5",
"resolved": "https://registry.npmjs.org/@img/sharp-linux-arm64/-/sharp-linux-arm64-0.34.5.tgz",
"integrity": "sha512-bKQzaJRY/bkPOXyKx5EVup7qkaojECG6NLYswgktOZjaXecSAeCWiZwwiFf3/Y+O1HrauiE3FVsGxFg8c24rZg==",
"cpu": [
"arm64"
],
"libc": [
"glibc"
],
"license": "Apache-2.0",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
},
"optionalDependencies": {
"@img/sharp-libvips-linux-arm64": "1.2.4"
}
},
"node_modules/@img/sharp-linux-ppc64": {
"version": "0.34.5",
"resolved": "https://registry.npmjs.org/@img/sharp-linux-ppc64/-/sharp-linux-ppc64-0.34.5.tgz",
"integrity": "sha512-7zznwNaqW6YtsfrGGDA6BRkISKAAE1Jo0QdpNYXNMHu2+0dTrPflTLNkpc8l7MUP5M16ZJcUvysVWWrMefZquA==",
"cpu": [
"ppc64"
],
"libc": [
"glibc"
],
"license": "Apache-2.0",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
},
"optionalDependencies": {
"@img/sharp-libvips-linux-ppc64": "1.2.4"
}
},
"node_modules/@img/sharp-linux-riscv64": {
"version": "0.34.5",
"resolved": "https://registry.npmjs.org/@img/sharp-linux-riscv64/-/sharp-linux-riscv64-0.34.5.tgz",
"integrity": "sha512-51gJuLPTKa7piYPaVs8GmByo7/U7/7TZOq+cnXJIHZKavIRHAP77e3N2HEl3dgiqdD/w0yUfiJnII77PuDDFdw==",
"cpu": [
"riscv64"
],
"libc": [
"glibc"
],
"license": "Apache-2.0",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
},
"optionalDependencies": {
"@img/sharp-libvips-linux-riscv64": "1.2.4"
}
},
"node_modules/@img/sharp-linux-s390x": {
"version": "0.34.5",
"resolved": "https://registry.npmjs.org/@img/sharp-linux-s390x/-/sharp-linux-s390x-0.34.5.tgz",
"integrity": "sha512-nQtCk0PdKfho3eC5MrbQoigJ2gd1CgddUMkabUj+rBevs8tZ2cULOx46E7oyX+04WGfABgIwmMC0VqieTiR4jg==",
"cpu": [
"s390x"
],
"libc": [
"glibc"
],
"license": "Apache-2.0",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
},
"optionalDependencies": {
"@img/sharp-libvips-linux-s390x": "1.2.4"
}
},
"node_modules/@img/sharp-linux-x64": {
"version": "0.34.5",
"resolved": "https://registry.npmjs.org/@img/sharp-linux-x64/-/sharp-linux-x64-0.34.5.tgz",
"integrity": "sha512-MEzd8HPKxVxVenwAa+JRPwEC7QFjoPWuS5NZnBt6B3pu7EG2Ge0id1oLHZpPJdn3OQK+BQDiw9zStiHBTJQQQQ==",
"cpu": [
"x64"
],
"libc": [
"glibc"
],
"license": "Apache-2.0",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
},
"optionalDependencies": {
"@img/sharp-libvips-linux-x64": "1.2.4"
}
},
"node_modules/@img/sharp-linuxmusl-arm64": {
"version": "0.34.5",
"resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-arm64/-/sharp-linuxmusl-arm64-0.34.5.tgz",
"integrity": "sha512-fprJR6GtRsMt6Kyfq44IsChVZeGN97gTD331weR1ex1c1rypDEABN6Tm2xa1wE6lYb5DdEnk03NZPqA7Id21yg==",
"cpu": [
"arm64"
],
"libc": [
"musl"
],
"license": "Apache-2.0",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
},
"optionalDependencies": {
"@img/sharp-libvips-linuxmusl-arm64": "1.2.4"
}
},
"node_modules/@img/sharp-linuxmusl-x64": {
"version": "0.34.5",
"resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-x64/-/sharp-linuxmusl-x64-0.34.5.tgz",
"integrity": "sha512-Jg8wNT1MUzIvhBFxViqrEhWDGzqymo3sV7z7ZsaWbZNDLXRJZoRGrjulp60YYtV4wfY8VIKcWidjojlLcWrd8Q==",
"cpu": [
"x64"
],
"libc": [
"musl"
],
"license": "Apache-2.0",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
},
"optionalDependencies": {
"@img/sharp-libvips-linuxmusl-x64": "1.2.4"
}
},
"node_modules/@img/sharp-wasm32": {
"version": "0.34.5",
"resolved": "https://registry.npmjs.org/@img/sharp-wasm32/-/sharp-wasm32-0.34.5.tgz",
"integrity": "sha512-OdWTEiVkY2PHwqkbBI8frFxQQFekHaSSkUIJkwzclWZe64O1X4UlUjqqqLaPbUpMOQk6FBu/HtlGXNblIs0huw==",
"cpu": [
"wasm32"
],
"license": "Apache-2.0 AND LGPL-3.0-or-later AND MIT",
"optional": true,
"dependencies": {
"@emnapi/runtime": "^1.7.0"
},
"engines": {
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
}
},
"node_modules/@img/sharp-win32-arm64": {
"version": "0.34.5",
"resolved": "https://registry.npmjs.org/@img/sharp-win32-arm64/-/sharp-win32-arm64-0.34.5.tgz",
"integrity": "sha512-WQ3AgWCWYSb2yt+IG8mnC6Jdk9Whs7O0gxphblsLvdhSpSTtmu69ZG1Gkb6NuvxsNACwiPV6cNSZNzt0KPsw7g==",
"cpu": [
"arm64"
],
"license": "Apache-2.0 AND LGPL-3.0-or-later",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
}
},
"node_modules/@img/sharp-win32-ia32": {
"version": "0.34.5",
"resolved": "https://registry.npmjs.org/@img/sharp-win32-ia32/-/sharp-win32-ia32-0.34.5.tgz",
"integrity": "sha512-FV9m/7NmeCmSHDD5j4+4pNI8Cp3aW+JvLoXcTUo0IqyjSfAZJ8dIUmijx1qaJsIiU+Hosw6xM5KijAWRJCSgNg==",
"cpu": [
"ia32"
],
"license": "Apache-2.0 AND LGPL-3.0-or-later",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
}
},
"node_modules/@img/sharp-win32-x64": {
"version": "0.34.5",
"resolved": "https://registry.npmjs.org/@img/sharp-win32-x64/-/sharp-win32-x64-0.34.5.tgz",
"integrity": "sha512-+29YMsqY2/9eFEiW93eqWnuLcWcufowXewwSNIT6UwZdUUCrM3oFjMWH/Z6/TMmb4hlFenmfAVbpWeup2jryCw==",
"cpu": [
"x64"
],
"license": "Apache-2.0 AND LGPL-3.0-or-later",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
}
},
"node_modules/@imgly/background-removal": {
"version": "1.7.0",
"resolved": "https://registry.npmjs.org/@imgly/background-removal/-/background-removal-1.7.0.tgz",
"integrity": "sha512-/1ZryrMYg2ckIvJKoTu5Np50JfYMVffDMlVmppw/BdbN3pBTN7e6stI5/7E/LVh9DDzz6J588s7sWqul3fy5wA==",
"license": "SEE LICENSE IN LICENSE.md",
"dependencies": {
"lodash-es": "^4.17.21",
"ndarray": "~1.0.0",
"zod": "^3.23.8"
},
"peerDependencies": {
"onnxruntime-web": "1.21.0"
}
},
"node_modules/@isaacs/cliui": {
"version": "8.0.2",
"license": "ISC",
@@ -2246,6 +2777,69 @@
}
}
},
"node_modules/@protobufjs/aspromise": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/@protobufjs/aspromise/-/aspromise-1.1.2.tgz",
"integrity": "sha512-j+gKExEuLmKwvz3OgROXtrJ2UG2x8Ch2YZUxahh+s1F2HZ+wAceUNLkvy6zKCPVRkU++ZWQrdxsUeQXmcg4uoQ==",
"license": "BSD-3-Clause"
},
"node_modules/@protobufjs/base64": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/@protobufjs/base64/-/base64-1.1.2.tgz",
"integrity": "sha512-AZkcAA5vnN/v4PDqKyMR5lx7hZttPDgClv83E//FMNhR2TMcLUhfRUBHCmSl0oi9zMgDDqRUJkSxO3wm85+XLg==",
"license": "BSD-3-Clause"
},
"node_modules/@protobufjs/codegen": {
"version": "2.0.5",
"resolved": "https://registry.npmjs.org/@protobufjs/codegen/-/codegen-2.0.5.tgz",
"integrity": "sha512-zgXFLzW3Ap33e6d0Wlj4MGIm6Ce8O89n/apUaGNB/jx+hw+ruWEp7EwGUshdLKVRCxZW12fp9r40E1mQrf/34g==",
"license": "BSD-3-Clause"
},
"node_modules/@protobufjs/eventemitter": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/@protobufjs/eventemitter/-/eventemitter-1.1.1.tgz",
"integrity": "sha512-vW1GmwMZNnL+gMRaovlh9yZX74kc+TTU3FObkkurpMaRtBfLP3ldjS9KQWlwZgraRE0+dheEEoAxdzcJQ8eXZg==",
"license": "BSD-3-Clause"
},
"node_modules/@protobufjs/fetch": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/@protobufjs/fetch/-/fetch-1.1.1.tgz",
"integrity": "sha512-GpptLrs57adMSuHi3VNj0mAF8dwh36LMaYF6XyJ6JMWlVsc+t42tm1HSEDmOs3A8fC9yyeisgLhsTVQokOZ0zw==",
"license": "BSD-3-Clause",
"dependencies": {
"@protobufjs/aspromise": "^1.1.1"
}
},
"node_modules/@protobufjs/float": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/@protobufjs/float/-/float-1.0.2.tgz",
"integrity": "sha512-Ddb+kVXlXst9d+R9PfTIxh1EdNkgoRe5tOX6t01f1lYWOvJnSPDBlG241QLzcyPdoNTsblLUdujGSE4RzrTZGQ==",
"license": "BSD-3-Clause"
},
"node_modules/@protobufjs/inquire": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/@protobufjs/inquire/-/inquire-1.1.2.tgz",
"integrity": "sha512-pa0vFRuws4wkvaXKK1uXZMAwAX4/t8ANaJo45iw/oQHNQ9q5xUzwgFmVJGXiga2BeN+zpX7Vf9vmsiIa2J+MUw==",
"license": "BSD-3-Clause"
},
"node_modules/@protobufjs/path": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/@protobufjs/path/-/path-1.1.2.tgz",
"integrity": "sha512-6JOcJ5Tm08dOHAbdR3GrvP+yUUfkjG5ePsHYczMFLq3ZmMkAD98cDgcT2iA1lJ9NVwFd4tH/iSSoe44YWkltEA==",
"license": "BSD-3-Clause"
},
"node_modules/@protobufjs/pool": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/@protobufjs/pool/-/pool-1.1.0.tgz",
"integrity": "sha512-0kELaGSIDBKvcgS4zkjz1PeddatrjYcmMWOlAuAPwAeccUrPHdUqo/J6LiymHHEiJT5NrF1UVwxY14f+fy4WQw==",
"license": "BSD-3-Clause"
},
"node_modules/@protobufjs/utf8": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/@protobufjs/utf8/-/utf8-1.1.1.tgz",
"integrity": "sha512-oOAWABowe8EAbMyWKM0tYDKi8Yaox52D+HWZhAIJqQXbqe0xI/GV7FhLWqlEKreMkfDjshR5FKgi3mnle0h6Eg==",
"license": "BSD-3-Clause"
},
"node_modules/@radix-ui/primitive": {
"version": "1.1.3",
"resolved": "https://registry.npmjs.org/@radix-ui/primitive/-/primitive-1.1.3.tgz",
@@ -2709,7 +3303,6 @@
},
"node_modules/@types/node": {
"version": "20.19.42",
"dev": true,
"license": "MIT",
"dependencies": {
"undici-types": "~6.21.0"
@@ -2737,6 +3330,16 @@
"@types/react": "^18.0.0"
}
},
"node_modules/@types/sharp": {
"version": "0.31.1",
"resolved": "https://registry.npmjs.org/@types/sharp/-/sharp-0.31.1.tgz",
"integrity": "sha512-5nWwamN9ZFHXaYEincMSuza8nNfOof8nmO+mcI+Agx1uMUk4/pQnNIcix+9rLPXzKrm1pS34+6WRDbDV0Jn7ag==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/node": "*"
}
},
"node_modules/@types/stack-utils": {
"version": "2.0.3",
"resolved": "https://registry.npmjs.org/@types/stack-utils/-/stack-utils-2.0.3.tgz",
@@ -6214,6 +6817,12 @@
"node": "^10.12.0 || >=12.0.0"
}
},
"node_modules/flatbuffers": {
"version": "25.9.23",
"resolved": "https://registry.npmjs.org/flatbuffers/-/flatbuffers-25.9.23.tgz",
"integrity": "sha512-MI1qs7Lo4Syw0EOzUl0xjs2lsoeqFku44KpngfIduHBYvzm8h2+7K8YMQh1JtVVVrUvhLpNwqVi4DERegUJhPQ==",
"license": "Apache-2.0"
},
"node_modules/flatted": {
"version": "3.4.2",
"dev": true,
@@ -6718,6 +7327,12 @@
"node": ">=18"
}
},
"node_modules/guid-typescript": {
"version": "1.0.9",
"resolved": "https://registry.npmjs.org/guid-typescript/-/guid-typescript-1.0.9.tgz",
"integrity": "sha512-Y8T4vYhEfwJOTbouREvG+3XDsjr8E3kIr7uf+JZ0BYloFsttiHU0WfvANVsR7TxNUJa/WpCnw/Ino/p+DeBhBQ==",
"license": "ISC"
},
"node_modules/handlebars": {
"version": "4.7.9",
"resolved": "https://registry.npmjs.org/handlebars/-/handlebars-4.7.9.tgz",
@@ -6818,6 +7433,32 @@
"node": ">= 0.4"
}
},
"node_modules/heic-convert": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/heic-convert/-/heic-convert-2.1.0.tgz",
"integrity": "sha512-1qDuRvEHifTVAj3pFIgkqGgJIr0M3X7cxEPjEp0oG4mo8GFjq99DpCo8Eg3kg17Cy0MTjxpFdoBHOatj7ZVKtg==",
"license": "ISC",
"dependencies": {
"heic-decode": "^2.0.0",
"jpeg-js": "^0.4.4",
"pngjs": "^6.0.0"
},
"engines": {
"node": ">=12.0.0"
}
},
"node_modules/heic-decode": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/heic-decode/-/heic-decode-2.1.0.tgz",
"integrity": "sha512-0fB3O3WMk38+PScbHLVp66jcNhsZ/ErtQ6u2lMYu/YxXgbBtl+oKOhGQHa4RpvE68k8IzbWkABzHnyAIjR758A==",
"license": "ISC",
"dependencies": {
"libheif-js": "^1.19.8"
},
"engines": {
"node": ">=8.0.0"
}
},
"node_modules/heic2any": {
"version": "0.0.4",
"resolved": "https://registry.npmjs.org/heic2any/-/heic2any-0.0.4.tgz",
@@ -7034,6 +7675,12 @@
"node": ">= 0.4"
}
},
"node_modules/iota-array": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/iota-array/-/iota-array-1.0.0.tgz",
"integrity": "sha512-pZ2xT+LOHckCatGQ3DcG/a+QuEqvoxqkiL7tvE8nn3uuu+f6i1TtpB5/FtWFbxUuVr5PZCx8KskuGatbJDXOWA==",
"license": "MIT"
},
"node_modules/is-array-buffer": {
"version": "3.0.5",
"dev": true,
@@ -7115,6 +7762,12 @@
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/is-buffer": {
"version": "1.1.6",
"resolved": "https://registry.npmjs.org/is-buffer/-/is-buffer-1.1.6.tgz",
"integrity": "sha512-NcdALwpXkTm5Zvvbk7owOUSvVvBKDgKP5/ewfXEznmQFfs4ZRmanOeKBTjRVjka3QFoN6XJ+9F3USqfHqTaU5w==",
"license": "MIT"
},
"node_modules/is-bun-module": {
"version": "2.0.0",
"dev": true,
@@ -8641,6 +9294,12 @@
"url": "https://github.com/sponsors/panva"
}
},
"node_modules/jpeg-js": {
"version": "0.4.4",
"resolved": "https://registry.npmjs.org/jpeg-js/-/jpeg-js-0.4.4.tgz",
"integrity": "sha512-WZzeDOEtTOBK4Mdsar0IqEU5sMr3vSV2RqkAIzUEV2BHnUfKGyswWFPFwK5EeDo93K3FohSHbLAjj0s1Wzd+dg==",
"license": "BSD-3-Clause"
},
"node_modules/js-tokens": {
"version": "4.0.0",
"license": "MIT"
@@ -8890,6 +9549,15 @@
"node": ">= 0.8.0"
}
},
"node_modules/libheif-js": {
"version": "1.19.8",
"resolved": "https://registry.npmjs.org/libheif-js/-/libheif-js-1.19.8.tgz",
"integrity": "sha512-vQJWusIxO7wavpON1dusciL8Go9jsIQ+EUrckauFYAiSTjcmLAsuJh3SszLpvkwPci3JcL41ek2n+LUZGFpPIQ==",
"license": "LGPL-3.0",
"engines": {
"node": ">=8.0.0"
}
},
"node_modules/lie": {
"version": "3.3.0",
"resolved": "https://registry.npmjs.org/lie/-/lie-3.3.0.tgz",
@@ -8929,6 +9597,12 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/lodash-es": {
"version": "4.18.1",
"resolved": "https://registry.npmjs.org/lodash-es/-/lodash-es-4.18.1.tgz",
"integrity": "sha512-J8xewKD/Gk22OZbhpOVSwcs60zhd95ESDwezOFuA3/099925PdHJ7OFHNTGtajL3AlZkykD32HykiMo+BIBI8A==",
"license": "MIT"
},
"node_modules/lodash.memoize": {
"version": "4.1.2",
"resolved": "https://registry.npmjs.org/lodash.memoize/-/lodash.memoize-4.1.2.tgz",
@@ -8945,7 +9619,6 @@
"version": "5.3.2",
"resolved": "https://registry.npmjs.org/long/-/long-5.3.2.tgz",
"integrity": "sha512-mNAgZ1GmyNhD7AuqnTG3/VQ26o760+ZYBPKjPvugO8+nLbYfX6TVpJPseBvopbdY+qpZ/lKUnmEc1LeZYS3QAA==",
"devOptional": true,
"license": "Apache-2.0"
},
"node_modules/loose-envify": {
@@ -9208,6 +9881,16 @@
"dev": true,
"license": "MIT"
},
"node_modules/ndarray": {
"version": "1.0.19",
"resolved": "https://registry.npmjs.org/ndarray/-/ndarray-1.0.19.tgz",
"integrity": "sha512-B4JHA4vdyZU30ELBw3g7/p9bZupyew5a7tX1Y/gGeF2hafrPaQZhgrGQfsvgfYbgdFZjYwuEcnaobeM/WMW+HQ==",
"license": "MIT",
"dependencies": {
"iota-array": "^1.0.0",
"is-buffer": "^1.0.2"
}
},
"node_modules/neo-async": {
"version": "2.6.2",
"resolved": "https://registry.npmjs.org/neo-async/-/neo-async-2.6.2.tgz",
@@ -9740,6 +10423,26 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/onnxruntime-common": {
"version": "1.21.0",
"resolved": "https://registry.npmjs.org/onnxruntime-common/-/onnxruntime-common-1.21.0.tgz",
"integrity": "sha512-Q632iLLrtCAVOTO65dh2+mNbQir/QNTVBG3h/QdZBpns7mZ0RYbLRBgGABPbpU9351AgYy7SJf1WaeVwMrBFPQ==",
"license": "MIT"
},
"node_modules/onnxruntime-web": {
"version": "1.21.0",
"resolved": "https://registry.npmjs.org/onnxruntime-web/-/onnxruntime-web-1.21.0.tgz",
"integrity": "sha512-adzOe+7uI7lKz6pQNbAsLMQd2Fq5Jhmoxd8LZjJr8m3KvbFyiYyRxRiC57/XXD+jb18voppjeGAjoZmskXG+7A==",
"license": "MIT",
"dependencies": {
"flatbuffers": "^25.1.24",
"guid-typescript": "^1.0.9",
"long": "^5.2.3",
"onnxruntime-common": "1.21.0",
"platform": "^1.3.6",
"protobufjs": "^7.2.4"
}
},
"node_modules/openid-client": {
"version": "5.7.1",
"resolved": "https://registry.npmjs.org/openid-client/-/openid-client-5.7.1.tgz",
@@ -10070,6 +10773,21 @@
"pathe": "^2.0.3"
}
},
"node_modules/platform": {
"version": "1.3.6",
"resolved": "https://registry.npmjs.org/platform/-/platform-1.3.6.tgz",
"integrity": "sha512-fnWVljUchTro6RiCFvCXBbNhJc2NijN7oIQxbwsyL0buWJPG85v81ehlHI9fXrJsMNgTofEoWIQeClKpgxFLrg==",
"license": "MIT"
},
"node_modules/pngjs": {
"version": "6.0.0",
"resolved": "https://registry.npmjs.org/pngjs/-/pngjs-6.0.0.tgz",
"integrity": "sha512-TRzzuFRRmEoSW/p1KVAmiOgPco2Irlah+bGFCeNfJXxxYGwSw7YwAOAcd7X28K/m5bjBWKsC29KyoMfHbypayg==",
"license": "MIT",
"engines": {
"node": ">=12.13.0"
}
},
"node_modules/possible-typed-array-names": {
"version": "1.1.0",
"dev": true,
@@ -10393,6 +11111,30 @@
"devOptional": true,
"license": "ISC"
},
"node_modules/protobufjs": {
"version": "7.6.2",
"resolved": "https://registry.npmjs.org/protobufjs/-/protobufjs-7.6.2.tgz",
"integrity": "sha512-N9EiLovGEQOJSPF26Ij7qUGvahfEnq0eeYZ02aigIedkmz1qZSwjnP9SBITHJuF/6MYbIW4HDN8zdYjsjqJKXQ==",
"hasInstallScript": true,
"license": "BSD-3-Clause",
"dependencies": {
"@protobufjs/aspromise": "^1.1.2",
"@protobufjs/base64": "^1.1.2",
"@protobufjs/codegen": "^2.0.5",
"@protobufjs/eventemitter": "^1.1.1",
"@protobufjs/fetch": "^1.1.1",
"@protobufjs/float": "^1.0.2",
"@protobufjs/inquire": "^1.1.2",
"@protobufjs/path": "^1.1.2",
"@protobufjs/pool": "^1.1.0",
"@protobufjs/utf8": "^1.1.1",
"@types/node": ">=13.7.0",
"long": "^5.3.2"
},
"engines": {
"node": ">=12.0.0"
}
},
"node_modules/pump": {
"version": "3.0.4",
"resolved": "https://registry.npmjs.org/pump/-/pump-3.0.4.tgz",
@@ -11028,6 +11770,50 @@
"integrity": "sha512-MATJdZp8sLqDl/68LfQmbP8zKPLQNV6BIZoIgrscFDQ+RsvK/BxeDQOgyxKKoh0y/8h3BqVFnCqQ/gd+reiIXA==",
"license": "MIT"
},
"node_modules/sharp": {
"version": "0.34.5",
"resolved": "https://registry.npmjs.org/sharp/-/sharp-0.34.5.tgz",
"integrity": "sha512-Ou9I5Ft9WNcCbXrU9cMgPBcCK8LiwLqcbywW3t4oDV37n1pzpuNLsYiAV8eODnjbtQlSDwZ2cUEeQz4E54Hltg==",
"hasInstallScript": true,
"license": "Apache-2.0",
"dependencies": {
"@img/colour": "^1.0.0",
"detect-libc": "^2.1.2",
"semver": "^7.7.3"
},
"engines": {
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
},
"optionalDependencies": {
"@img/sharp-darwin-arm64": "0.34.5",
"@img/sharp-darwin-x64": "0.34.5",
"@img/sharp-libvips-darwin-arm64": "1.2.4",
"@img/sharp-libvips-darwin-x64": "1.2.4",
"@img/sharp-libvips-linux-arm": "1.2.4",
"@img/sharp-libvips-linux-arm64": "1.2.4",
"@img/sharp-libvips-linux-ppc64": "1.2.4",
"@img/sharp-libvips-linux-riscv64": "1.2.4",
"@img/sharp-libvips-linux-s390x": "1.2.4",
"@img/sharp-libvips-linux-x64": "1.2.4",
"@img/sharp-libvips-linuxmusl-arm64": "1.2.4",
"@img/sharp-libvips-linuxmusl-x64": "1.2.4",
"@img/sharp-linux-arm": "0.34.5",
"@img/sharp-linux-arm64": "0.34.5",
"@img/sharp-linux-ppc64": "0.34.5",
"@img/sharp-linux-riscv64": "0.34.5",
"@img/sharp-linux-s390x": "0.34.5",
"@img/sharp-linux-x64": "0.34.5",
"@img/sharp-linuxmusl-arm64": "0.34.5",
"@img/sharp-linuxmusl-x64": "0.34.5",
"@img/sharp-wasm32": "0.34.5",
"@img/sharp-win32-arm64": "0.34.5",
"@img/sharp-win32-ia32": "0.34.5",
"@img/sharp-win32-x64": "0.34.5"
}
},
"node_modules/shebang-command": {
"version": "2.0.0",
"license": "MIT",
@@ -12449,7 +13235,6 @@
},
"node_modules/undici-types": {
"version": "6.21.0",
"dev": true,
"license": "MIT"
},
"node_modules/unrs-resolver": {
@@ -13021,6 +13806,15 @@
"grammex": "^3.1.11",
"graphmatch": "^1.1.0"
}
},
"node_modules/zod": {
"version": "3.25.76",
"resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz",
"integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==",
"license": "MIT",
"funding": {
"url": "https://github.com/sponsors/colinhacks"
}
}
}
}
+6 -1
View File
@@ -13,17 +13,21 @@
"seed": "ts-node --compiler-options '{\"module\":\"CommonJS\"}' prisma/seed.ts"
},
"dependencies": {
"@imgly/background-removal": "^1.7.0",
"@prisma/adapter-better-sqlite3": "^7.8.0",
"@prisma/client": "^7.8.0",
"bcryptjs": "^3.0.3",
"better-sqlite3": "^12.10.0",
"googleapis": "^173.0.0",
"heic-convert": "^2.1.0",
"heic2any": "^0.0.4",
"jszip": "^3.10.1",
"next": "14.2.35",
"next-auth": "^4.24.14",
"onnxruntime-web": "^1.21.0",
"react": "^18",
"react-dom": "^18"
"react-dom": "^18",
"sharp": "^0.34.5"
},
"devDependencies": {
"@testing-library/jest-dom": "^6.9.1",
@@ -33,6 +37,7 @@
"@types/node": "^20",
"@types/react": "^18",
"@types/react-dom": "^18",
"@types/sharp": "^0.31.1",
"eslint": "^8",
"eslint-config-next": "14.2.35",
"jest": "^30.4.2",
+12 -2
View File
@@ -5,11 +5,12 @@ import { DropZone } from '@/components/images/DropZone'
import { ImageCard } from '@/components/images/ImageCard'
import { Lightbox } from '@/components/images/Lightbox'
import { GlobalActions } from '@/components/images/GlobalActions'
import { ErrorBoundary } from '@/components/common/ErrorBoundary'
import { useImages } from '@/hooks/useImages'
import type { ImageItem } from '@/types/images'
export default function ImagesPage() {
const { items, addFiles, setFeather, rotate, uploadOne, uploadAll, removeItem } = useImages()
function ImagesPageInner() {
const { items, addFiles, setFeather, rotate, uploadOne, uploadAll, removeItem, assignProduct } = useImages()
const [lightboxItem, setLightboxItem] = useState<ImageItem | null>(null)
return (
@@ -41,6 +42,7 @@ export default function ImagesPage() {
onUpload={uploadOne}
onRemove={removeItem}
onOpenLightbox={setLightboxItem}
onAssignProduct={assignProduct}
/>
))}
</div>
@@ -59,3 +61,11 @@ export default function ImagesPage() {
</>
)
}
export default function ImagesPage() {
return (
<ErrorBoundary>
<ImagesPageInner />
</ErrorBoundary>
)
}
+268 -1
View File
@@ -1,9 +1,276 @@
'use client'
import { useEffect, useState, useCallback } from 'react'
import Link from 'next/link'
import { Header } from '@/components/layout/Header'
interface ProductStats {
active: number
draft: number
total: number
withoutImages: number
}
interface LastSync {
createdAt: string
userName: string
status: string
created: number
updated: number
deactivated: number
errors: number
}
interface DashboardStats {
products: ProductStats
lastSync: LastSync | null
}
function StatCard({
icon,
label,
value,
sub,
href,
loading,
error,
accent,
}: {
icon: string
label: string
value: string | number | null
sub?: string
href?: string
loading?: boolean
error?: boolean
accent?: 'indigo' | 'amber' | 'green' | 'red'
}) {
const accentClass = {
indigo: 'border-indigo-500/30 bg-indigo-500/5',
amber: 'border-amber-500/30 bg-amber-500/5',
green: 'border-green-500/30 bg-green-500/5',
red: 'border-red-500/30 bg-red-500/5',
}[accent ?? 'indigo']
const content = (
<div className={`rounded-xl border p-5 flex flex-col gap-1 ${accentClass} ${href ? 'hover:brightness-110 transition-all cursor-pointer' : ''}`}>
<span className="text-xl">{icon}</span>
<p className="text-xs text-slate-400 mt-1">{label}</p>
{loading ? (
<p className="text-2xl font-bold text-slate-600 animate-pulse"></p>
) : error ? (
<p className="text-sm text-red-400">Erreur</p>
) : (
<p className="text-2xl font-bold text-white">{value ?? '—'}</p>
)}
{sub && <p className="text-xs text-slate-500">{sub}</p>}
</div>
)
return href ? <Link href={href}>{content}</Link> : content
}
function ActionCard({ href, icon, label, description }: { href: string; icon: string; label: string; description: string }) {
return (
<Link
href={href}
className="flex items-start gap-3 bg-slate-800 hover:bg-slate-700 border border-slate-700 rounded-xl p-4 transition-colors group"
>
<span className="text-2xl mt-0.5">{icon}</span>
<div>
<p className="text-sm font-semibold text-white group-hover:text-indigo-300 transition-colors">{label}</p>
<p className="text-xs text-slate-400 mt-0.5">{description}</p>
</div>
</Link>
)
}
function formatDate(iso: string) {
return new Intl.DateTimeFormat('fr-FR', {
day: '2-digit', month: 'short', year: 'numeric',
hour: '2-digit', minute: '2-digit',
}).format(new Date(iso))
}
export default function DashboardPage() {
const [stats, setStats] = useState<DashboardStats | null>(null)
const [statsError, setStatsError] = useState(false)
const [statsLoading, setStatsLoading] = useState(true)
const [pendingCount, setPendingCount] = useState<number | null>(null)
const [pendingLoading, setPendingLoading] = useState(false)
const [pendingError, setPendingError] = useState(false)
useEffect(() => {
fetch('/api/dashboard/stats')
.then((r) => r.json())
.then((data) => {
if (data.error) throw new Error(data.error)
setStats(data)
})
.catch(() => setStatsError(true))
.finally(() => setStatsLoading(false))
}, [])
const loadPending = useCallback(async () => {
setPendingLoading(true)
setPendingError(false)
try {
const r = await fetch('/api/dashboard/pending')
const data = await r.json()
if (data.error) throw new Error(data.error)
setPendingCount(data.count)
} catch {
setPendingError(true)
} finally {
setPendingLoading(false)
}
}, [])
const lastSync = stats?.lastSync
const syncStatusColor =
lastSync?.status === 'success' ? 'text-green-400' :
lastSync?.status === 'error' ? 'text-red-400' : 'text-slate-400'
return (
<>
<Header title="📊 Dashboard" />
<div className="p-6"><p className="text-gray-400 text-sm">Dashboard à implémenter (Plan 4)</p></div>
<div className="p-6 flex flex-col gap-8 flex-1 overflow-y-auto">
{/* ── KPIs ─────────────────────────────────────────────── */}
<section>
<h2 className="text-xs font-semibold text-slate-500 uppercase tracking-wider mb-3">Vue d&apos;ensemble</h2>
<div className="grid grid-cols-2 lg:grid-cols-4 gap-3">
<StatCard
icon="📦"
label="Produits actifs"
value={stats?.products.active ?? null}
sub={stats ? `${stats.products.draft} en brouillon` : undefined}
loading={statsLoading}
error={statsError}
accent="green"
href="/produits"
/>
<StatCard
icon="🖼️"
label="Sans image"
value={stats?.products.withoutImages ?? null}
sub={stats ? `sur ${stats.products.total} produits` : undefined}
loading={statsLoading}
error={statsError}
accent={stats && stats.products.withoutImages > 0 ? 'amber' : 'green'}
href="/images"
/>
<StatCard
icon="🔄"
label="Dernière sync"
value={lastSync ? formatDate(lastSync.createdAt) : 'Aucune'}
sub={lastSync ? `par ${lastSync.userName}` : undefined}
loading={statsLoading}
error={statsError}
accent="indigo"
href="/sync"
/>
{/* Pending — chargement lazy */}
<div
className="rounded-xl border border-indigo-500/30 bg-indigo-500/5 p-5 flex flex-col gap-1 cursor-pointer hover:brightness-110 transition-all"
onClick={pendingCount === null && !pendingLoading ? loadPending : undefined}
>
<span className="text-xl"></span>
<p className="text-xs text-slate-400 mt-1">En attente de création</p>
{pendingLoading ? (
<p className="text-2xl font-bold text-slate-600 animate-pulse"></p>
) : pendingError ? (
<p className="text-sm text-red-400">Erreur réessayer</p>
) : pendingCount !== null ? (
<p className="text-2xl font-bold text-white">{pendingCount}</p>
) : (
<button className="text-xs text-indigo-400 hover:text-indigo-300 text-left mt-1">
Calculer
</button>
)}
{pendingCount !== null && (
<p className="text-xs text-slate-500">depuis Google Sheets</p>
)}
</div>
</div>
</section>
{/* ── Dernière sync détail ──────────────────────────────── */}
{!statsLoading && lastSync && (
<section>
<h2 className="text-xs font-semibold text-slate-500 uppercase tracking-wider mb-3">Dernière synchronisation</h2>
<div className="bg-slate-800 border border-slate-700 rounded-xl p-5 flex flex-col gap-3">
<div className="flex items-center justify-between">
<div>
<p className="text-sm font-semibold text-white">{formatDate(lastSync.createdAt)}</p>
<p className="text-xs text-slate-400">par {lastSync.userName}</p>
</div>
<span className={`text-xs font-medium px-2 py-1 rounded-full border ${
lastSync.status === 'success'
? 'bg-green-900/30 border-green-700 text-green-400'
: 'bg-red-900/30 border-red-700 text-red-400'
}`}>
{lastSync.status === 'success' ? '✅ Succès' : '❌ Erreur'}
</span>
</div>
<div className="grid grid-cols-4 gap-3 pt-2 border-t border-slate-700">
{[
{ label: 'Créations', value: lastSync.created, color: 'text-green-400' },
{ label: 'Mises à jour', value: lastSync.updated, color: 'text-blue-400' },
{ label: 'Désactivés', value: lastSync.deactivated, color: 'text-amber-400' },
{ label: 'Erreurs', value: lastSync.errors, color: 'text-red-400' },
].map(({ label, value, color }) => (
<div key={label} className="text-center">
<p className={`text-xl font-bold ${color}`}>{value}</p>
<p className="text-xs text-slate-500">{label}</p>
</div>
))}
</div>
<div className="flex justify-end">
<Link href="/sync" className="text-xs text-indigo-400 hover:text-indigo-300 transition-colors">
Nouvelle sync
</Link>
</div>
</div>
</section>
)}
{/* ── Actions rapides ───────────────────────────────────── */}
<section>
<h2 className="text-xs font-semibold text-slate-500 uppercase tracking-wider mb-3">Actions rapides</h2>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
<ActionCard
href="/images"
icon="🖼️"
label="Uploader des images"
description="Convertir HEIC, détourer et envoyer sur Shopify"
/>
<ActionCard
href="/sync"
icon="🔄"
label="Synchroniser le catalogue"
description="Comparer Google Sheets avec Shopify et appliquer les changements"
/>
<ActionCard
href="/creation"
icon="✨"
label="Créer des produits"
description="Ajouter les produits en attente depuis Google Sheets"
/>
<ActionCard
href="/produits"
icon="📦"
label="Voir le catalogue"
description="Parcourir et filtrer tous les produits Shopify"
/>
</div>
</section>
</div>
</>
)
}
+20
View File
@@ -0,0 +1,20 @@
import { NextResponse } from 'next/server'
import { getServerSession } from 'next-auth'
import { authOptions } from '@/lib/auth'
import { fetchPendingProducts } from '@/lib/creationSheets'
import { prisma } from '@/lib/prisma'
export async function GET() {
const session = await getServerSession(authOptions)
if (!session) return NextResponse.json({ error: 'Non authentifié' }, { status: 401 })
try {
const existing = await prisma.productCreation.findMany({ select: { sheetTab: true, sheetRow: true } })
const alreadyCreated = new Set(existing.map((e) => `${e.sheetTab}:${e.sheetRow}`))
const pending = await fetchPendingProducts(alreadyCreated)
return NextResponse.json({ count: pending.length })
} catch (err) {
const message = err instanceof Error ? err.message : 'Erreur inconnue'
return NextResponse.json({ error: message }, { status: 500 })
}
}
+88
View File
@@ -0,0 +1,88 @@
import { NextResponse } from 'next/server'
import { getServerSession } from 'next-auth'
import { authOptions } from '@/lib/auth'
import { prisma } from '@/lib/prisma'
const API_VERSION = '2024-01'
function getShopifyConfig() {
const domain = process.env.SHOPIFY_STORE_DOMAIN
const token = process.env.SHOPIFY_ADMIN_API_TOKEN
if (!domain || !token) throw new Error('SHOPIFY_STORE_DOMAIN et SHOPIFY_ADMIN_API_TOKEN requis')
return {
baseUrl: `https://${domain}/admin/api/${API_VERSION}`,
headers: { 'X-Shopify-Access-Token': token, 'Content-Type': 'application/json' },
}
}
function extractNextUrl(linkHeader: string | null): string | null {
if (!linkHeader) return null
const match = linkHeader.match(/<([^>]+)>;\s*rel="next"/)
return match ? match[1] : null
}
interface LightProduct {
id: number
status: string
images: Array<{ id: number }>
}
async function fetchProductsLight(status: 'active' | 'draft'): Promise<LightProduct[]> {
const { baseUrl, headers } = getShopifyConfig()
const all: LightProduct[] = []
let url: string | null =
`${baseUrl}/products.json?limit=250&fields=id,status,images&status=${status}`
while (url) {
const res = await fetch(url, { headers, cache: 'no-store' })
if (!res.ok) throw new Error(`Shopify fetch error (${status}): ${res.status}`)
const data = await res.json()
all.push(...(data.products as LightProduct[]))
url = extractNextUrl(res.headers.get('Link'))
}
return all
}
export async function GET() {
const session = await getServerSession(authOptions)
if (!session) return NextResponse.json({ error: 'Non authentifié' }, { status: 401 })
try {
const [activeProducts, draftProducts, lastSyncRun] = await Promise.all([
fetchProductsLight('active'),
fetchProductsLight('draft'),
prisma.syncRun.findFirst({
orderBy: { createdAt: 'desc' },
include: { user: { select: { name: true } } },
}),
])
const allProducts = [...activeProducts, ...draftProducts]
const withoutImages = allProducts.filter((p) => p.images.length === 0).length
const lastSync = lastSyncRun
? {
createdAt: lastSyncRun.createdAt.toISOString(),
userName: lastSyncRun.user.name,
status: lastSyncRun.status,
created: lastSyncRun.created,
updated: lastSyncRun.updated,
deactivated: lastSyncRun.deactivated,
errors: lastSyncRun.errors,
}
: null
return NextResponse.json({
products: {
active: activeProducts.length,
draft: draftProducts.length,
total: allProducts.length,
withoutImages,
},
lastSync,
})
} catch (err) {
const message = err instanceof Error ? err.message : 'Erreur inconnue'
return NextResponse.json({ error: message }, { status: 500 })
}
}
+35
View File
@@ -0,0 +1,35 @@
import { NextRequest, NextResponse } from 'next/server'
import { getServerSession } from 'next-auth'
import { authOptions } from '@/lib/auth'
import convert from 'heic-convert'
export async function POST(req: NextRequest) {
const session = await getServerSession(authOptions)
if (!session) return NextResponse.json({ error: 'Non authentifié' }, { status: 401 })
try {
const formData = await req.formData()
const file = formData.get('file') as File | null
if (!file) return NextResponse.json({ error: 'Fichier manquant' }, { status: 400 })
const inputBuffer = Buffer.from(await file.arrayBuffer())
// heic-convert utilise libheif WASM — supporte HEIC/HEIF sur tous les environnements
const outputBuffer = await convert({
buffer: inputBuffer,
format: 'JPEG',
quality: 0.92,
})
const jpeg = Buffer.from(outputBuffer)
return new NextResponse(jpeg.buffer as ArrayBuffer, {
headers: {
'Content-Type': 'image/jpeg',
'Content-Length': String(jpeg.length),
},
})
} catch (err) {
const message = err instanceof Error ? err.message : 'Erreur conversion'
console.error('[convert-heic API]', message)
return NextResponse.json({ error: message }, { status: 500 })
}
}
@@ -0,0 +1,68 @@
import { NextRequest, NextResponse } from 'next/server'
import { getServerSession } from 'next-auth'
import { authOptions } from '@/lib/auth'
/**
* Cherche un produit dans Google Sheets par sa référence (UGS, col C).
* Retourne le Shopify ID (col A) et le titre (col D) si trouvé.
* Parcourt tous les onglets.
*/
export async function GET(req: NextRequest) {
const session = await getServerSession(authOptions)
if (!session) return NextResponse.json({ found: false }, { status: 401 })
const { searchParams } = new URL(req.url)
const ref = searchParams.get('ref')?.trim()
if (!ref) return NextResponse.json({ found: false }, { status: 400 })
const apiKey = process.env.GOOGLE_API_KEY
const sheetId = process.env.GOOGLE_SHEETS_ID
if (!apiKey || !sheetId) {
return NextResponse.json({ found: false, error: 'GOOGLE_API_KEY et GOOGLE_SHEETS_ID requis' }, { status: 500 })
}
try {
// Récupère la liste des onglets
const metaRes = await fetch(
`https://sheets.googleapis.com/v4/spreadsheets/${sheetId}?key=${apiKey}&fields=sheets.properties.title`,
)
if (!metaRes.ok) throw new Error(`Sheets metadata error: ${metaRes.status}`)
const meta = await metaRes.json()
const tabs: string[] = (meta.sheets as Array<{ properties: { title: string } }>).map(
s => s.properties.title,
)
for (const tab of tabs) {
const range = encodeURIComponent(`${tab}!A2:D`)
const res = await fetch(
`https://sheets.googleapis.com/v4/spreadsheets/${sheetId}/values/${range}?key=${apiKey}`,
)
if (!res.ok) continue
const data = await res.json()
const rows = (data.values ?? []) as string[][]
for (const row of rows) {
const shopifyId = (row[0] ?? '').trim() // col A = ID Shopify
// col C = UGS (index 2 car on commence à A)
const ugs = (row[2] ?? '').trim()
// L'UGS doit apparaître comme mot entier dans le nom de fichier
// (entouré de séparateurs ou en début/fin) — ex: "dessus-30827-vue" ✓, "308270" ✗
const refLower = ref.toLowerCase()
const ugsLower = ugs.toLowerCase()
const wordBoundary = new RegExp(`(^|[^a-z0-9])${ugsLower.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}([^a-z0-9]|$)`)
const isMatch = ugs && shopifyId && wordBoundary.test(refLower)
if (isMatch) {
const title = (row[3] ?? '').trim() // col D = Nom
return NextResponse.json({ found: true, shopifyId, title })
}
}
}
return NextResponse.json({ found: false })
} catch (err) {
const message = err instanceof Error ? err.message : 'Erreur'
return NextResponse.json({ found: false, error: message }, { status: 500 })
}
}
+18
View File
@@ -13,6 +13,24 @@ export const metadata: Metadata = {
export default function RootLayout({ children }: { children: React.ReactNode }) {
return (
<html lang="fr">
<head>
{/* Import map requis par @imgly/background-removal chargé depuis CDN.
Doit être placé avant tout script module — Next.js le respecte car
le layout est rendu côté serveur avant injection des chunks. */}
<script
type="importmap"
// eslint-disable-next-line react/no-danger
dangerouslySetInnerHTML={{
__html: JSON.stringify({
imports: {
'onnxruntime-web': 'https://cdn.jsdelivr.net/npm/onnxruntime-web@1.21.0/dist/ort.all.min.mjs',
'onnxruntime-web/webgpu': 'https://cdn.jsdelivr.net/npm/onnxruntime-web@1.21.0/dist/ort.webgpu.min.mjs',
'onnxruntime-web/wasm': 'https://cdn.jsdelivr.net/npm/onnxruntime-web@1.21.0/dist/ort.wasm.min.mjs',
},
}),
}}
/>
</head>
<body className={inter.className}>
<SessionProvider>{children}</SessionProvider>
</body>
+42
View File
@@ -0,0 +1,42 @@
'use client'
import { Component, type ReactNode } from 'react'
interface Props {
children: ReactNode
fallback?: (error: Error, reset: () => void) => ReactNode
}
interface State {
error: Error | null
}
export class ErrorBoundary extends Component<Props, State> {
state: State = { error: null }
static getDerivedStateFromError(error: Error): State {
return { error }
}
reset = () => this.setState({ error: null })
render() {
if (this.state.error) {
if (this.props.fallback) return this.props.fallback(this.state.error, this.reset)
return (
<div className="p-6">
<div className="bg-red-900/30 border border-red-700 rounded-xl p-6 text-center space-y-3">
<p className="text-red-300 font-semibold">Une erreur est survenue</p>
<p className="text-red-400 text-sm font-mono">{this.state.error.message}</p>
<button
onClick={this.reset}
className="px-4 py-2 bg-slate-700 hover:bg-slate-600 text-white rounded-lg text-sm"
>
Réessayer
</button>
</div>
</div>
)
}
return this.props.children
}
}
+59 -14
View File
@@ -27,8 +27,9 @@ export function MetafieldResolver({ specificColumns, onResolved }: Props) {
setMatched(data.matched)
setUnmatched(data.unmatched)
const init: Record<string, MetafieldResolution> = { ...data.matched }
// Défaut : 'skip' — l'utilisateur choisit explicitement ce qu'il veut créer
data.unmatched.forEach((col: string) => {
init[col] = { columnHeader: col, action: 'create', namespace: 'custom', key: normalizeMetafieldKey(col) }
init[col] = { columnHeader: col, action: 'skip', namespace: 'custom', key: normalizeMetafieldKey(col) }
})
setResolutions(init)
})
@@ -69,47 +70,91 @@ export function MetafieldResolver({ specificColumns, onResolved }: Props) {
)}
{unmatched.length > 0 && (
<div>
<h3 className="text-sm font-medium text-yellow-400 mb-2">
{unmatched.length} colonne{unmatched.length > 1 ? 's' : ''} sans correspondance action requise
</h3>
<div className="mb-3 p-3 bg-slate-700/50 rounded-lg border border-slate-600">
<p className="text-xs text-gray-400">
Ces colonnes n&apos;ont pas de métachamp correspondant dans Shopify.
Choisissez pour chacune : <span className="text-indigo-300 font-medium">Créer nouveau</span> (nouveau métachamp Shopify),
<span className="text-blue-300 font-medium"> Associer</span> (utiliser un existant), ou
<span className="text-gray-300 font-medium"> Ignorer</span> (ne pas synchroniser).
</p>
</div>
<div className="space-y-2">
{unmatched.map(col => {
const res = resolutions[col]
if (!res) return null
return (
<div key={col} className="p-3 bg-slate-700 rounded-lg space-y-2">
<div key={col} className={`p-3 rounded-lg space-y-2 border ${res.action === 'skip' ? 'bg-slate-700/40 border-slate-700' : res.action === 'create' ? 'bg-indigo-900/20 border-indigo-700/50' : 'bg-blue-900/20 border-blue-700/50'}`}>
<div className="flex items-center justify-between">
<p className="text-sm text-white font-medium">{col}</p>
{res.action === 'create' && <span className="text-xs text-indigo-300"> Sera créé dans Shopify</span>}
{res.action === 'map' && <span className="text-xs text-blue-300"> Associé à un existant</span>}
{res.action === 'skip' && <span className="text-xs text-gray-500"> Ignoré</span>}
</div>
<div className="flex gap-2">
{(['create', 'map', 'skip'] as const).map(action => (
<button
key={action}
type="button"
onClick={() => updateResolution(col, { action })}
className={`px-3 py-1 rounded text-xs font-medium transition-colors ${res.action === action ? 'bg-indigo-600 text-white' : 'bg-slate-600 text-gray-300 hover:bg-slate-500'}`}
className={`px-3 py-1 rounded text-xs font-medium transition-colors ${
res.action === action
? action === 'create' ? 'bg-indigo-600 text-white'
: action === 'map' ? 'bg-blue-600 text-white'
: 'bg-slate-500 text-white'
: 'bg-slate-600 text-gray-400 hover:bg-slate-500 hover:text-gray-200'
}`}
>
{action === 'create' ? 'Créer nouveau' : action === 'map' ? 'Associer existant' : 'Ignorer'}
{action === 'create' ? '+ Créer nouveau' : action === 'map' ? 'Associer existant' : 'Ignorer'}
</button>
))}
</div>
{res.action === 'create' && (
<div className="flex gap-2">
<input value={res.key} onChange={e => updateResolution(col, { key: e.target.value })} placeholder="clé (ex: puissance)"
className="flex-1 bg-slate-600 border border-slate-500 rounded px-2 py-1 text-xs text-white" />
<input value={res.namespace} onChange={e => updateResolution(col, { namespace: e.target.value })} placeholder="namespace"
className="w-24 bg-slate-600 border border-slate-500 rounded px-2 py-1 text-xs text-white" />
<div className="flex gap-2 mt-1">
<div className="flex-1">
<label className="block text-xs text-gray-400 mb-1">Clé Shopify</label>
<input
type="text"
value={res.key}
onChange={e => updateResolution(col, { key: e.target.value })}
placeholder="ex: puissance"
className="w-full bg-slate-600 border border-slate-500 rounded px-2 py-1 text-xs text-white focus:outline-none focus:ring-1 focus:ring-indigo-500"
/>
</div>
<div className="w-28">
<label className="block text-xs text-gray-400 mb-1">Namespace</label>
<input
type="text"
value={res.namespace}
onChange={e => updateResolution(col, { namespace: e.target.value })}
placeholder="custom"
className="w-full bg-slate-600 border border-slate-500 rounded px-2 py-1 text-xs text-white focus:outline-none focus:ring-1 focus:ring-indigo-500"
/>
</div>
</div>
)}
{res.action === 'map' && (
<select value={res.existingDefinitionId ?? ''}
<div>
<label className="block text-xs text-gray-400 mb-1">Métachamp existant</label>
<select
value={res.existingDefinitionId ?? ''}
onChange={e => { const def = definitions.find(d => d.id === e.target.value); if (def) updateResolution(col, { existingDefinitionId: def.id, key: def.key, namespace: def.namespace }) }}
className="w-full bg-slate-600 border border-slate-500 rounded px-2 py-1 text-xs text-white">
className="w-full bg-slate-600 border border-slate-500 rounded px-2 py-1 text-xs text-white focus:outline-none focus:ring-1 focus:ring-blue-500"
>
<option value="">-- Choisir un métachamp existant --</option>
{definitions.map(d => <option key={d.id} value={d.id}>{d.name} ({d.namespace}.{d.key})</option>)}
</select>
</div>
)}
</div>
)
})}
</div>
{/* Résumé des actions */}
<div className="mt-3 flex gap-4 text-xs text-gray-400">
<span className="text-indigo-300 font-medium">{Object.values(resolutions).filter(r => r.action === 'create').length} à créer</span>
<span className="text-blue-300 font-medium">{Object.values(resolutions).filter(r => r.action === 'map').length} à associer</span>
<span>{Object.values(resolutions).filter(r => r.action === 'skip').length} ignorés</span>
</div>
</div>
)}
</div>
+62 -2
View File
@@ -9,6 +9,7 @@ interface ImageCardProps {
onUpload: (id: string) => void
onRemove: (id: string) => void
onOpenLightbox: (item: ImageItem) => void
onAssignProduct: (id: string, shopifyProductId: string, shopifyProductTitle: string) => void
}
const STATUS_LABELS: Record<string, { label: string; color: string }> = {
@@ -19,6 +20,7 @@ const STATUS_LABELS: Record<string, { label: string; color: string }> = {
ready: { label: '✓ Prêt', color: 'text-green-400' },
uploading: { label: '⬆️ Upload…', color: 'text-blue-400' },
uploaded: { label: '✅ Uploadé', color: 'text-green-500' },
skipped: { label: '⏭️ Déjà sur Shopify', color: 'text-slate-400' },
not_found: { label: '❌ Réf. introuvable', color: 'text-red-400' },
error: { label: '❌ Erreur', color: 'text-red-400' },
}
@@ -36,6 +38,59 @@ function BlobThumbnail({ blob, alt }: { blob: Blob; alt: string }) {
return <img src={url} alt={alt} className="w-full h-full object-contain" />
}
function ProductSearch({ itemId, onAssign }: { itemId: string; onAssign: (id: string, shopifyProductId: string, title: string) => void }) {
const [query, setQuery] = useState('')
const [results, setResults] = useState<{ shopifyId: string; title: string }[]>([])
const [loading, setLoading] = useState(false)
async function search() {
if (!query.trim()) return
setLoading(true)
try {
const res = await fetch(`/api/products/search?ref=${encodeURIComponent(query.trim())}`)
const data = await res.json()
if (data.found) setResults([{ shopifyId: data.shopifyId, title: data.title }])
else setResults([])
} finally {
setLoading(false)
}
}
return (
<div className="flex flex-col gap-1.5 mt-1">
<div className="flex gap-1">
<input
type="text"
value={query}
onChange={(e) => setQuery(e.target.value)}
onKeyDown={(e) => e.key === 'Enter' && search()}
placeholder="Rechercher un produit…"
className="flex-1 px-2 py-1 text-xs bg-slate-800 border border-slate-600 rounded text-slate-200 placeholder-slate-500 focus:outline-none focus:border-indigo-500"
/>
<button
onClick={search}
disabled={loading}
className="px-2 py-1 text-xs bg-slate-700 hover:bg-slate-600 rounded text-slate-300 disabled:opacity-50"
>
{loading ? '…' : '🔍'}
</button>
</div>
{results.length === 0 && !loading && query && (
<p className="text-xs text-slate-500">Aucun résultat</p>
)}
{results.map((r) => (
<button
key={r.shopifyId}
onClick={() => onAssign(itemId, r.shopifyId, r.title)}
className="text-left px-2 py-1.5 text-xs bg-slate-800 hover:bg-indigo-900 border border-slate-600 hover:border-indigo-500 rounded text-slate-200 truncate"
>
{r.title}
</button>
))}
</div>
)
}
export function ImageCard({
item,
onFeatherChange,
@@ -43,6 +98,7 @@ export function ImageCard({
onUpload,
onRemove,
onOpenLightbox,
onAssignProduct,
}: ImageCardProps) {
const statusInfo = STATUS_LABELS[item.status] ?? { label: item.status, color: 'text-slate-400' }
const isProcessing = ['pending', 'converting', 'processing', 'searching', 'uploading'].includes(item.status)
@@ -55,11 +111,11 @@ export function ImageCard({
className="relative h-40 bg-slate-800 cursor-pointer"
onClick={() => onOpenLightbox(item)}
>
<div className="absolute inset-0">
<div className={`absolute top-0 bottom-0 left-0 overflow-hidden ${item.processedBlob ? 'w-1/2' : 'w-full'}`}>
<BlobThumbnail blob={item.originalBlob} alt="Original" />
</div>
{item.processedBlob && (
<div className="absolute inset-0 border-l-2 border-indigo-500 left-1/2 w-1/2">
<div className="absolute top-0 bottom-0 right-0 w-1/2 overflow-hidden border-l-2 border-indigo-500">
<BlobThumbnail blob={item.processedBlob} alt="Traité" />
</div>
)}
@@ -88,6 +144,10 @@ export function ImageCard({
{item.error && <p className="text-xs text-red-400 mt-0.5 truncate" title={item.error}>{item.error}</p>}
</div>
{item.status === 'not_found' && (
<ProductSearch itemId={item.id} onAssign={onAssignProduct} />
)}
{/* Contrôles feather + rotation */}
<div className={canAdjust ? '' : 'opacity-40 pointer-events-none'}>
<label className="block text-xs text-slate-500 mb-1">
+31 -4
View File
@@ -57,6 +57,19 @@ export function useImages() {
async (id: string, ref: string) => {
update(id, { status: 'searching' })
try {
// 1. Cherche d'abord dans Google Sheets (col C = UGS → col A = Shopify ID)
const sheetsRes = await fetch(`/api/products/search-sheets?ref=${encodeURIComponent(ref)}`)
const sheetsData = await sheetsRes.json()
if (sheetsData.found) {
update(id, {
status: 'ready',
shopifyProductId: sheetsData.shopifyId,
shopifyProductTitle: sheetsData.title,
})
return
}
// 2. Fallback : recherche directe dans Shopify par handle/titre
const res = await fetch(`/api/products/search?ref=${encodeURIComponent(ref)}`)
const data = await res.json()
if (data.found) {
@@ -88,6 +101,8 @@ export function useImages() {
try {
update(id, { status: 'converting' })
jpegBlob = await convertHeicToJpeg(blob)
// Met à jour originalBlob avec le JPEG converti (Chrome ne peut pas afficher HEIC)
update(id, { originalBlob: jpegBlob })
} catch (err) {
update(id, { status: 'error', error: `Conversion HEIC : ${(err as Error).message}` })
return
@@ -226,7 +241,7 @@ export function useImages() {
})
const data = await res.json()
if (!res.ok) throw new Error(data.error || `HTTP ${res.status}`)
update(id, { status: 'uploaded', uploadedUrl: data.url })
update(id, { status: data.skipped ? 'skipped' : 'uploaded', uploadedUrl: data.url })
} catch (err) {
update(id, { status: 'error', error: `Upload : ${(err as Error).message}` })
}
@@ -234,13 +249,25 @@ export function useImages() {
[items, update],
)
const uploadAll = useCallback(() => {
items.filter((i) => i.status === 'ready').forEach((i) => uploadOne(i.id))
const uploadAll = useCallback(async () => {
const ready = items.filter((i) => i.status === 'ready')
for (const item of ready) {
await uploadOne(item.id)
// Pause de 500ms entre chaque upload pour éviter le rate limit Shopify
await new Promise(r => setTimeout(r, 500))
}
}, [items, uploadOne])
const removeItem = useCallback((id: string) => {
dispatch({ type: 'REMOVE', id })
}, [])
return { items, addFiles, setFeather, rotate, uploadOne, uploadAll, removeItem }
const assignProduct = useCallback(
(id: string, shopifyProductId: string, shopifyProductTitle: string) => {
update(id, { shopifyProductId, shopifyProductTitle, status: 'ready' })
},
[update],
)
return { items, addFiles, setFeather, rotate, uploadOne, uploadAll, removeItem, assignProduct }
}
+16 -7
View File
@@ -1,6 +1,6 @@
const CDN_VERSION = '1.7.0'
const CDN_URL = `https://cdn.jsdelivr.net/npm/@imgly/background-removal@${CDN_VERSION}/dist/index.mjs`
const CDN_URL = 'https://cdn.jsdelivr.net/npm/@imgly/background-removal@1.7.0/dist/index.mjs'
// eslint-disable-next-line @typescript-eslint/no-explicit-any
let _removeBg: ((blob: Blob, opts: Record<string, unknown>) => Promise<Blob>) | null = null
async function loadLib() {
@@ -24,7 +24,6 @@ export async function removeBackgroundRaw(
onProgress?: (e: ProgressEvent) => void,
): Promise<Blob> {
const removeBg = await loadLib()
return removeBg(blob, {
output: { format: 'image/png', type: 'foreground', quality: 1 },
progress: (key: string, current: number, total: number) => {
@@ -64,13 +63,23 @@ export function compositeOnWhite(pngBlob: Blob, feather: number): Promise<Blob>
fgCtx.globalCompositeOperation = 'source-over'
}
// Canvas de sortie 1000×1000 blanc, sujet centré avec padding de 5%
const SIZE = 1000
const PADDING = 0.05
const available = Math.round(SIZE * (1 - PADDING * 2))
const scale = Math.min(available / width, available / height)
const dw = Math.round(width * scale)
const dh = Math.round(height * scale)
const dx = Math.round((SIZE - dw) / 2)
const dy = Math.round((SIZE - dh) / 2)
const out = document.createElement('canvas')
out.width = width
out.height = height
out.width = SIZE
out.height = SIZE
const ctx = out.getContext('2d')!
ctx.fillStyle = '#ffffff'
ctx.fillRect(0, 0, width, height)
ctx.drawImage(fgCanvas, 0, 0)
ctx.fillRect(0, 0, SIZE, SIZE)
ctx.drawImage(fgCanvas, dx, dy, dw, dh)
URL.revokeObjectURL(objectUrl)
out.toBlob(
+86 -4
View File
@@ -1,10 +1,12 @@
/**
* Convertit un Blob HEIC/HEIF en Blob JPEG via heic2any.
* Convertit un Blob HEIC/HEIF en Blob JPEG.
* Tente d'abord la conversion native du navigateur (instantané sur Safari/macOS),
* puis repasse sur heic2any si le natif échoue (Chrome, Firefox).
* À utiliser uniquement côté client (navigateur).
*/
export async function convertHeicToJpeg(blob: Blob): Promise<Blob> {
if (!(blob instanceof Blob)) {
throw new TypeError('L\'entrée doit être un Blob')
throw new TypeError("L'entrée doit être un Blob")
}
const type = blob.type.toLowerCase()
@@ -12,14 +14,35 @@ export async function convertHeicToJpeg(blob: Blob): Promise<Blob> {
throw new TypeError(`Format attendu : HEIC/HEIF. Reçu : ${blob.type || '(vide)'}`)
}
// Méthode 1 : conversion native (Safari 14+, macOS Chrome 105+)
try {
const result = await convertNative(blob)
console.log('[heicConverter] ✅ Conversion native réussie')
return result
} catch (e) {
console.warn(`[heicConverter] Conversion native échouée (${(e as Error).message}), repasse sur heic2any…`)
}
// Méthode 2 : conversion serveur via /api/images/convert-heic (sharp, fiable sur tous les navigateurs)
try {
const result = await convertViaServer(blob)
console.log('[heicConverter] ✅ Conversion serveur réussie')
return result
} catch (e) {
console.warn(`[heicConverter] Conversion serveur échouée (${(e as Error).message}), repasse sur heic2any…`)
}
// Méthode 3 : heic2any fallback (plus lent, ~10-30s)
const heic2any = (await import('heic2any')).default
const timeout = new Promise<never>((_, reject) =>
setTimeout(() => reject(new Error('Timeout conversion HEIC (120s)')), 120_000),
setTimeout(
() => reject(new Error('Timeout conversion HEIC (30s). Essayez de convertir le fichier en JPG avant de le déposer.')),
30_000,
),
)
const conversion = heic2any({ blob, toType: 'image/jpeg', quality: 0.92 })
const result = await Promise.race([conversion, timeout])
const output = Array.isArray(result) ? result[0] : result
@@ -27,5 +50,64 @@ export async function convertHeicToJpeg(blob: Blob): Promise<Blob> {
throw new Error("heic2any n'a pas retourné un Blob")
}
console.log('[heicConverter] ✅ Conversion heic2any réussie')
return output
}
async function convertNative(blob: Blob): Promise<Blob> {
const typed = blob.type ? blob : new Blob([blob], { type: 'image/heic' })
// Tentative 1 : createImageBitmap (Safari 14+)
try {
const bitmap = await createImageBitmap(typed)
const canvas = document.createElement('canvas')
canvas.width = bitmap.width
canvas.height = bitmap.height
canvas.getContext('2d')!.drawImage(bitmap, 0, 0)
bitmap.close()
return await canvasToBlob(canvas)
} catch {
// Pas supporté — essai suivant
}
// Tentative 2 : <img> + canvas (Chrome sur macOS — utilise le décodeur HEIC natif de macOS)
return new Promise<Blob>((resolve, reject) => {
const url = URL.createObjectURL(typed)
const img = new Image()
img.onload = () => {
const canvas = document.createElement('canvas')
canvas.width = img.naturalWidth
canvas.height = img.naturalHeight
canvas.getContext('2d')!.drawImage(img, 0, 0)
URL.revokeObjectURL(url)
canvasToBlob(canvas).then(resolve).catch(reject)
}
img.onerror = () => {
URL.revokeObjectURL(url)
reject(new Error('Décodage HEIC non supporté par ce navigateur'))
}
img.src = url
})
}
async function convertViaServer(blob: Blob): Promise<Blob> {
const formData = new FormData()
const file = blob instanceof File ? blob : new File([blob], 'image.heic', { type: 'image/heic' })
formData.append('file', file)
const res = await fetch('/api/images/convert-heic', { method: 'POST', body: formData })
if (!res.ok) {
const err = await res.json().catch(() => ({ error: `HTTP ${res.status}` }))
throw new Error(err.error ?? `HTTP ${res.status}`)
}
return res.blob()
}
function canvasToBlob(canvas: HTMLCanvasElement): Promise<Blob> {
return new Promise((resolve, reject) => {
canvas.toBlob(
b => (b ? resolve(b) : reject(new Error('canvas.toBlob a retourné null'))),
'image/jpeg',
0.92,
)
})
}
+11 -4
View File
@@ -32,7 +32,7 @@ export function extractSpecificFields(headers: string[], row: string[]): Record<
return result
}
export function parseTabRows(tab: string, headers: string[], rows: string[][]): PendingProduct[] {
export function parseTabRows(tab: string, headers: string[], rows: string[][], dataStartRow = 2): PendingProduct[] {
const idx = {
id: findColIndex(headers, 'ID'),
sku: findColIndex(headers, 'UGS'),
@@ -60,7 +60,7 @@ export function parseTabRows(tab: string, headers: string[], rows: string[][]):
if (id !== '' || publie !== '1') return null
return {
tab,
rowNumber: rowIdx + 2,
rowNumber: dataStartRow + rowIdx,
sku: get(row, idx.sku),
title: get(row, idx.nom),
priceMarket: get(row, idx.prixMarche).replace(',', '.'),
@@ -106,8 +106,15 @@ export async function fetchPendingProducts(
const data = await res.json()
const rows = (data.values ?? []) as string[][]
if (rows.length < 2) continue
const [headers, ...dataRows] = rows
const pending = parseTabRows(tab, headers, dataRows).filter(
// La ligne d'en-têtes est la première ligne qui contient "ID" en col A
// (certains onglets ont une ligne de section en row 1, les vrais en-têtes sont en row 2)
const headerRowIdx = rows.findIndex(r => (r[0] ?? '').trim() === 'ID')
if (headerRowIdx === -1) continue // pas un onglet produit
const headers = rows[headerRowIdx]
const dataRows = rows.slice(headerRowIdx + 1)
// Ignorer les onglets sans colonne "Publié" (COLLECTIONS, LIVRAISONS, etc.)
if (findColIndex(headers, 'Publié') === -1) continue
const pending = parseTabRows(tab, headers, dataRows, headerRowIdx + 2).filter(
p => !alreadyCreatedRows.has(`${p.tab}:${p.rowNumber}`),
)
all.push(...pending)
+94 -19
View File
@@ -21,34 +21,62 @@ export type ShopifyProductResult =
/**
* Recherche un produit Shopify par sa référence.
* Tente d'abord par handle (ex: "ref-1042"), puis par titre exact.
* Stratégie : SKU exact → SKU sans suffixe → handle → titre.
* Les images peuvent être nommées "30827-dessus.jpg" alors que le SKU est "30827".
*/
export async function searchProductByRef(ref: string): Promise<ShopifyProductResult> {
const { baseUrl, headers } = getConfig()
// Normalise la référence en handle Shopify (minuscules, tirets)
const handle = ref.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '')
const tryFetch = async (params: string) => {
const res = await fetch(`${baseUrl}/products.json?${params}&fields=id,title,handle&limit=1`, { headers })
if (!res.ok) throw new Error(`Shopify API error: ${res.status}`)
// Recherche par SKU via l'API variants
const searchBySku = async (sku: string): Promise<ShopifyProductResult> => {
const res = await fetch(
`${baseUrl}/variants.json?sku=${encodeURIComponent(sku)}&fields=id,sku,product_id&limit=1`,
{ headers },
)
if (!res.ok) return { found: false }
const data = await res.json()
return (data.products ?? []) as Array<{ id: number; title: string; handle: string }>
const variants = (data.variants ?? []) as Array<{ id: number; sku: string; product_id: number }>
if (variants.length === 0) return { found: false }
// Récupère le titre du produit
const productRes = await fetch(
`${baseUrl}/products/${variants[0].product_id}.json?fields=id,title`,
{ headers },
)
if (!productRes.ok) return { found: false }
const productData = await productRes.json()
return {
found: true,
shopifyId: String(variants[0].product_id),
title: productData.product?.title ?? sku,
}
}
// 1. SKU exact (ex: "30827")
let result = await searchBySku(ref)
if (result.found) return result
// 2. SKU sans suffixe : "30827-dessus" → "30827" (retire tout après le dernier séparateur)
const withoutSuffix = ref.replace(/[-_ ][^-_ ]*$/, '')
if (withoutSuffix !== ref) {
result = await searchBySku(withoutSuffix)
if (result.found) return result
}
// 3. Fallback handle Shopify
const handle = ref.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '')
const tryFetch = async (params: string) => {
const res = await fetch(`${baseUrl}/products.json?${params}&fields=id,title&limit=1`, { headers })
if (!res.ok) return []
const data = await res.json()
return (data.products ?? []) as Array<{ id: number; title: string }>
}
let products = await tryFetch(`handle=${encodeURIComponent(handle)}`)
if (products.length === 0) {
products = await tryFetch(`title=${encodeURIComponent(ref)}`)
}
if (products.length === 0) products = await tryFetch(`title=${encodeURIComponent(ref)}`)
if (products.length === 0) return { found: false }
return {
found: true,
shopifyId: String(products[0].id),
title: products[0].title,
}
return { found: true, shopifyId: String(products[0].id), title: products[0].title }
}
/**
@@ -57,19 +85,63 @@ export async function searchProductByRef(ref: string): Promise<ShopifyProductRes
* @param imageBase64 — contenu JPEG encodé en base64 (sans le préfixe data:)
* @param filename — nom de fichier avec extension (ex: "REF-1042.jpg")
*/
/**
* Vérifie si une image avec ce nom de fichier existe déjà sur le produit.
* Compare le nom de base (sans extension) avec les src des images existantes.
*/
export async function productImageExists(
shopifyProductId: string,
filename: string,
): Promise<string | null> {
const { baseUrl, headers } = getConfig()
const res = await fetch(`${baseUrl}/products/${shopifyProductId}/images.json?fields=id,src`, { headers })
if (!res.ok) return null
const data = await res.json()
const images = (data.images ?? []) as Array<{ id: number; src: string }>
// Compare le nom de base sans extension (ex: "30827" dans "30827.jpg")
const baseName = filename.replace(/\.[^.]+$/, '').toLowerCase()
const existing = images.find(img => {
const imgFile = img.src.split('/').pop()?.split('?')[0] ?? ''
return imgFile.replace(/\.[^.]+$/, '').toLowerCase() === baseName
})
return existing ? existing.src : null
}
export async function uploadProductImage(
shopifyProductId: string,
imageBase64: string,
filename: string,
): Promise<{ url: string }> {
): Promise<{ url: string; skipped?: boolean }> {
const { baseUrl, headers } = getConfig()
// Vérifie si l'image existe déjà
const existingUrl = await productImageExists(shopifyProductId, filename)
if (existingUrl) return { url: existingUrl, skipped: true }
// Retry avec backoff exponentiel (gère le 429 rate limit)
const MAX_RETRIES = 4
let delay = 1000
for (let attempt = 0; attempt <= MAX_RETRIES; attempt++) {
const res = await fetch(`${baseUrl}/products/${shopifyProductId}/images.json`, {
method: 'POST',
headers,
body: JSON.stringify({ image: { attachment: imageBase64, filename } }),
})
// 409 Conflict = image déjà présente → traiter comme skipped
if (res.status === 409) {
return { url: `https://placeholder/already-exists/${filename}`, skipped: true }
}
// 429 Too Many Requests → attendre et réessayer
if (res.status === 429 && attempt < MAX_RETRIES) {
const retryAfter = parseInt(res.headers.get('Retry-After') ?? '0', 10)
await new Promise(r => setTimeout(r, retryAfter > 0 ? retryAfter * 1000 : delay))
delay *= 2
continue
}
if (!res.ok) {
const body = await res.text()
throw new Error(`Shopify upload error: ${res.status}${body}`)
@@ -81,3 +153,6 @@ export async function uploadProductImage(
}
return { url: data.image.src as string }
}
throw new Error('Shopify upload error: trop de tentatives (rate limit persistant)')
}
+77 -22
View File
@@ -35,7 +35,7 @@ export function matchMetafields(columnHeaders: string[], definitions: MetafieldD
const key = normalizeMetafieldKey(header)
const def = definitions.find(d => d.key === key)
if (def) {
matched[header] = { columnHeader: header, action: 'map', namespace: def.namespace, key: def.key, existingDefinitionId: def.id }
matched[header] = { columnHeader: header, action: 'map', namespace: def.namespace, key: def.key, type: def.type, existingDefinitionId: def.id }
} else {
unmatched.push(header)
}
@@ -43,43 +43,98 @@ export function matchMetafields(columnHeaders: string[], definitions: MetafieldD
return { matched, unmatched }
}
/**
* Récupère toutes les définitions de métachamps produit via l'API GraphQL Admin.
* L'API REST ne dispose pas de cet endpoint — seul GraphQL l'expose.
*/
export async function fetchMetafieldDefinitions(): Promise<MetafieldDefinition[]> {
const { baseUrl, headers } = getConfig()
const res = await fetch(`${baseUrl}/metafield_definitions.json?owner_type=PRODUCT&limit=250`, { headers })
if (!res.ok) throw new Error(`Shopify metafield definitions error: ${res.status}`)
const domain = process.env.SHOPIFY_STORE_DOMAIN
const token = process.env.SHOPIFY_ADMIN_API_TOKEN
if (!domain || !token) throw new Error('SHOPIFY_STORE_DOMAIN et SHOPIFY_ADMIN_API_TOKEN requis')
const query = `{
metafieldDefinitions(ownerType: PRODUCT, first: 250) {
edges {
node {
id
namespace
key
name
type { name }
}
}
}
}`
const res = await fetch(`https://${domain}/admin/api/2024-01/graphql.json`, {
method: 'POST',
headers: { 'X-Shopify-Access-Token': token, 'Content-Type': 'application/json' },
body: JSON.stringify({ query }),
})
if (!res.ok) throw new Error(`Shopify GraphQL error: ${res.status}`)
const data = await res.json()
return (data.metafield_definitions ?? []).map((d: {
id: string | number; namespace: string; key: string; name: string; type: { name: string } | string
if (data.errors) {
// Si les définitions ne sont pas accessibles, on retourne une liste vide
// plutôt que de bloquer — l'utilisateur verra toutes les colonnes comme "non matchées"
console.warn('[shopifyMetafields] GraphQL errors:', data.errors)
return []
}
return (data.data?.metafieldDefinitions?.edges ?? []).map((edge: {
node: { id: string; namespace: string; key: string; name: string; type: { name: string } }
}) => ({
id: String(d.id),
namespace: d.namespace,
key: d.key,
name: d.name,
type: typeof d.type === 'string' ? d.type : d.type?.name ?? 'single_line_text_field',
id: edge.node.id,
namespace: edge.node.namespace,
key: edge.node.key,
name: edge.node.name,
type: edge.node.type?.name ?? 'single_line_text_field',
}))
}
/**
* Crée une définition de métachamp produit via GraphQL si elle n'existe pas encore.
*/
export async function ensureMetafieldDefinition(resolution: MetafieldResolution): Promise<void> {
if (resolution.action !== 'create') return
const { baseUrl, headers } = getConfig()
const res = await fetch(`${baseUrl}/metafield_definitions.json`, {
const domain = process.env.SHOPIFY_STORE_DOMAIN
const token = process.env.SHOPIFY_ADMIN_API_TOKEN
if (!domain || !token) throw new Error('SHOPIFY_STORE_DOMAIN et SHOPIFY_ADMIN_API_TOKEN requis')
const mutation = `mutation CreateMetafieldDefinition($definition: MetafieldDefinitionInput!) {
metafieldDefinitionCreate(definition: $definition) {
createdDefinition { id key namespace }
userErrors { field message }
}
}`
const res = await fetch(`https://${domain}/admin/api/2024-01/graphql.json`, {
method: 'POST',
headers,
headers: { 'X-Shopify-Access-Token': token, 'Content-Type': 'application/json' },
body: JSON.stringify({
metafield_definition: {
query: mutation,
variables: {
definition: {
namespace: resolution.namespace,
key: resolution.key,
name: resolution.columnHeader,
type: 'single_line_text_field',
owner_type: 'PRODUCT',
ownerType: 'PRODUCT',
},
},
}),
})
if (!res.ok) {
const err = await res.text()
if (!err.includes('taken') && !err.includes('already')) {
throw new Error(`Shopify create metafield definition error: ${res.status}${err}`)
}
if (!res.ok) throw new Error(`Shopify GraphQL metafield create error: ${res.status}`)
const data = await res.json()
const errors = data.data?.metafieldDefinitionCreate?.userErrors ?? []
const isAlreadyTaken = errors.some((e: { message: string }) =>
e.message.toLowerCase().includes('taken') || e.message.toLowerCase().includes('already'),
)
if (errors.length > 0 && !isAlreadyTaken) {
throw new Error(`Shopify metafield definition error: ${errors.map((e: { message: string }) => e.message).join(', ')}`)
}
}
@@ -94,7 +149,7 @@ export async function setProductMetafield(
method: 'POST',
headers,
body: JSON.stringify({
metafield: { namespace: resolution.namespace, key: resolution.key, value, type: 'single_line_text_field' },
metafield: { namespace: resolution.namespace, key: resolution.key, value, type: resolution.type ?? 'single_line_text_field' },
}),
})
if (!res.ok) {
+1
View File
@@ -33,6 +33,7 @@ export interface MetafieldResolution {
action: 'create' | 'map' | 'skip'
namespace: string // "custom" par défaut
key: string // clé normalisée
type?: string // type Shopify réel, ex: 'multi_line_text_field', 'number_decimal'
existingDefinitionId?: string // si action === 'map'
}
+1
View File
@@ -0,0 +1 @@
declare module 'heic-convert';
+1
View File
@@ -6,6 +6,7 @@ export type ImageStatus =
| 'ready' // prêt à uploader
| 'uploading' // upload Shopify en cours
| 'uploaded' // uploadé avec succès
| 'skipped' // image déjà présente sur le produit Shopify
| 'not_found' // référence produit introuvable dans Shopify
| 'error' // erreur à n'importe quelle étape