30 Commits

Author SHA1 Message Date
C_Ma_For cf5bbf91d5 feat: module Images complet — drop, traitement IA, Shopify, upload 2026-06-08 20:00:20 +02:00
C_Ma_For fe9c3b9ccb fix: inclure 'uploading' dans le compteur processing de GlobalActions 2026-06-08 19:18:40 +02:00
C_Ma_For 568fe0d38e feat: composants Lightbox plein écran + GlobalActions barre d'upload 2026-06-08 17:45:22 +02:00
C_Ma_For 683db58f4b feat: composant ImageCard avec miniatures avant/après, feather, rotation, upload 2026-06-08 17:44:45 +02:00
C_Ma_For 4c1cf73435 feat: composant DropZone drag & drop HEIC/JPG/PNG/ZIP 2026-06-08 17:43:40 +02:00
C_Ma_For 901199c45d feat: hook useImages — pipeline HEIC→IA→feather→rotation→search→upload 2026-06-08 17:41:00 +02:00
C_Ma_For 50469700d0 feat: utilitaires client heicConverter, bgRemover, imageRotator, zipImporter (TS)
Porte les 4 utilitaires de traitement d'images en TypeScript pour utilisation côté client :
- heicConverter.ts : convertit HEIC/HEIF en JPEG via heic2any
- bgRemover.ts : supprime les fonds d'images avec @imgly/background-removal + composite sur blanc
- imageRotator.ts : pivote les images JPEG via Canvas
- zipImporter.ts : extrait les images d'un fichier ZIP avec structure dossier-ref/image.ext

Tous les modules utilisent des APIs du navigateur et doivent être importés dynamiquement depuis du code 'use client'.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-08 17:30:01 +02:00
C_Ma_For a5ced27e93 feat: API route POST /api/images/upload (TDD) 2026-06-08 17:25:40 +02:00
C_Ma_For c9422be78e feat: API route GET /api/products/search (TDD)
Implémentation test-driven du endpoint de recherche de produits par référence.
- Tests unitaires complets validant les cas nominaux et d'erreur
- Validation du paramètre ref avec gestion des chaînes vides
- Intégration avec searchProductByRef de lib/shopify
- Gestion appropriée des erreurs HTTP (400, 500)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-08 17:15:58 +02:00
C_Ma_For 53008d1d43 fix: valider data.image.src dans uploadProductImage 2026-06-08 17:10:26 +02:00
C_Ma_For 9675366db9 feat: client Shopify Admin REST API (search + upload) 2026-06-08 16:21:55 +02:00
C_Ma_For c97a7faaa9 feat: types ImageItem + dépendances heic2any jszip
- Install heic2any and jszip dependencies
- Create src/types/images.ts with ImageItem, ImageStatus, ImageProgress, and ZipEntry types
- heic2any includes bundled TypeScript types (no declaration file needed)
- ImageItem is the central data model for the image processing module with support for:
  - HEIC to JPEG conversion
  - AI-powered background removal
  - Shopify product lookup and upload
  - Progress tracking and error handling
  - Rotation and feather adjustments (cached PNG blob for fast re-processing)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-08 15:47:34 +02:00
C_Ma_For b25a712a69 fix: correct adapter import name PrismaBetterSqlite3 in seed.mjs 2026-06-07 11:42:57 +02:00
C_Ma_For ee70ea377f fix: use better-sqlite3 adapter in seed.mjs (Prisma 7 requirement) 2026-06-07 11:41:42 +02:00
C_Ma_For 8bd49050f6 fix: add .dockerignore to exclude .env files from build context (was overriding DATABASE_URL) 2026-06-07 02:00:21 +02:00
C_Ma_For 79ebb6ced3 fix: use npm ci --omit=dev in runner (resolves all transitive prisma deps) 2026-06-07 01:37:03 +02:00
C_Ma_For d80e50e9de fix: recreate .bin/prisma as symlink in runner (preserves __dirname for WASM resolution) 2026-06-07 01:32:37 +02:00
C_Ma_For f022238a40 fix: copy full node_modules/prisma package for WASM files in runner stage 2026-06-07 01:28:41 +02:00
C_Ma_For f23c954ecd chore: add public/ directory (required by Next.js standalone build) 2026-06-07 01:25:00 +02:00
C_Ma_For 96700ae252 fix: eslint disable-next-line for required any casts (NextAuth v4 limitation) 2026-06-07 01:22:07 +02:00
C_Ma_For 99c3380d00 feat: dockerfile + docker-compose + déploiement VPS gestion.materiaux-destock.fr 2026-06-07 00:27:16 +02:00
C_Ma_For 51f87c0672 feat: app shell avec sidebar, header et pages placeholder
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-07 00:20:57 +02:00
C_Ma_For 8d06706a24 feat: page login avec gestion erreurs et rate limiting
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-07 00:20:36 +02:00
C_Ma_For 44b3b4dd8d fix: improve type safety in NextAuth callbacks (remove any, add explanatory comments) 2026-06-07 00:08:11 +02:00
C_Ma_For 38dc6efc09 feat: middleware protection routes + restriction admin /parametres
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-07 00:05:28 +02:00
C_Ma_For f9a14b009a feat: NextAuth credentials + JWT 8h + rate limiting
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-07 00:05:14 +02:00
C_Ma_For 2acf899465 feat: rate limiting connexion (5 essais / 15 min) 2026-06-06 17:24:19 +02:00
C_Ma_For dbb155cb3a fix: remove hardcoded seed password from package.json (use SEED_ADMIN_PASSWORD env var) 2026-06-06 17:22:25 +02:00
C_Ma_For 192def9008 feat: prisma schema User + SyncRun + seed admin
Sets up Prisma 7 with SQLite (better-sqlite3 adapter), User and SyncRun models, migration, and admin seed account for mathieu.fort@gmail.com.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-06 17:15:54 +02:00
C_Ma_For 1b60fd79f7 chore: replace boilerplate page.tsx with minimal placeholder 2026-06-06 16:21:34 +02:00
52 changed files with 2602 additions and 111 deletions
+10
View File
@@ -0,0 +1,10 @@
node_modules
.next
.env
.env.local
.env.production
.env.development
data/
*.db
.git
README.md
+18
View File
@@ -0,0 +1,18 @@
# Auth
NEXTAUTH_URL=https://gestion.materiaux-destock.fr
NEXTAUTH_SECRET=<générer avec: openssl rand -base64 32>
# Shopify (remplir lors du Plan 2)
SHOPIFY_STORE_DOMAIN=materiaux-destock.myshopify.com
SHOPIFY_ADMIN_API_TOKEN=shpat_...
# Google Sheets (remplir lors du Plan 3)
GOOGLE_SERVICE_ACCOUNT_EMAIL=...@....iam.gserviceaccount.com
GOOGLE_PRIVATE_KEY="-----BEGIN RSA PRIVATE KEY-----\n..."
GOOGLE_SHEETS_ID=
# Base de données
DATABASE_URL="file:./data/app.db"
# Seed (premier déploiement uniquement)
SEED_ADMIN_PASSWORD=
+9
View File
@@ -28,9 +28,18 @@ yarn-error.log*
# local env files
.env*.local
# prisma env (contains DATABASE_URL)
.env
# database files
/data/
# vercel
.vercel
# typescript
*.tsbuildinfo
next-env.d.ts
/src/generated/prisma
.env.production
+59
View File
@@ -0,0 +1,59 @@
# Étape 1 : dépendances complètes (dev incluses pour le build)
FROM node:20-alpine AS deps
WORKDIR /app
RUN apk add --no-cache python3 make g++
COPY package*.json ./
RUN npm ci
# Étape 2 : build Next.js
FROM node:20-alpine AS builder
WORKDIR /app
RUN apk add --no-cache python3 make g++
COPY --from=deps /app/node_modules ./node_modules
COPY . .
ENV DATABASE_URL="file:./data/app.db"
RUN npx prisma generate
RUN npm run build
# Étape 3 : image de production
FROM node:20-alpine AS runner
WORKDIR /app
ENV NODE_ENV=production
# Outils natifs requis pour better-sqlite3
RUN apk add --no-cache python3 make g++
RUN addgroup --system --gid 1001 nodejs \
&& adduser --system --uid 1001 nextjs
# Installer uniquement les dépendances de production
# (résout automatiquement toutes les dépendances transitives : effect, dotenv, etc.)
COPY package*.json ./
RUN npm ci --omit=dev
# Ajouter le CLI Prisma (devDependency → non installé par --omit=dev)
COPY --from=builder /app/node_modules/prisma ./node_modules/prisma
RUN ln -sf /app/node_modules/prisma/build/index.js /app/node_modules/.bin/prisma
# Client Prisma généré (spécifique à la plateforme linux/alpine)
COPY --from=builder /app/node_modules/.prisma ./node_modules/.prisma
# Artifacts du build Next.js
COPY --from=builder /app/public ./public
COPY --from=builder /app/.next/standalone ./
COPY --from=builder /app/.next/static ./.next/static
# Schema + migrations Prisma
COPY --from=builder /app/prisma ./prisma
COPY --from=builder /app/prisma.config.ts ./prisma.config.ts
COPY scripts/start.sh ./start.sh
RUN mkdir -p data && chown -R nextjs:nodejs data . && chmod +x start.sh
USER nextjs
EXPOSE 3000
ENV PORT=3000
ENV HOSTNAME="0.0.0.0"
CMD ["sh", "start.sh"]
+15
View File
@@ -0,0 +1,15 @@
services:
app:
build: .
image: gestion-materiaux-destock:latest
restart: unless-stopped
env_file:
- .env
volumes:
- ./data:/app/data
networks:
- web
networks:
web:
external: true
+3
View File
@@ -7,6 +7,9 @@ const config: Config = {
coverageProvider: 'v8',
testEnvironment: 'node',
setupFilesAfterEnv: ['<rootDir>/jest.setup.ts'],
moduleNameMapper: {
'^@/(.*)$': '<rootDir>/src/$1',
},
}
export default createJestConfig(config)
+3 -1
View File
@@ -1,4 +1,6 @@
/** @type {import('next').NextConfig} */
const nextConfig = {};
const nextConfig = {
output: 'standalone',
};
export default nextConfig;
+671 -7
View File
@@ -8,8 +8,12 @@
"name": "gestion-md",
"version": "0.1.0",
"dependencies": {
"@prisma/adapter-better-sqlite3": "^7.8.0",
"@prisma/client": "^7.8.0",
"bcryptjs": "^3.0.3",
"better-sqlite3": "^12.10.0",
"heic2any": "^0.0.4",
"jszip": "^3.10.1",
"next": "14.2.35",
"next-auth": "^4.24.14",
"react": "^18",
@@ -31,6 +35,7 @@
"prisma": "^7.8.0",
"tailwindcss": "^3.4.1",
"ts-jest": "^29.4.11",
"ts-node": "^10.9.2",
"typescript": "^5"
}
},
@@ -621,6 +626,30 @@
"dev": true,
"license": "MIT"
},
"node_modules/@cspotcode/source-map-support": {
"version": "0.8.1",
"resolved": "https://registry.npmjs.org/@cspotcode/source-map-support/-/source-map-support-0.8.1.tgz",
"integrity": "sha512-IchNf6dN4tHoMFIn/7OE8LWZ19Y6q/67Bmf6vnGREv8RSbBVb9LPJxEcnwrcwX6ixSvaiGoomAUvu4YSxXrVgw==",
"dev": true,
"license": "MIT",
"dependencies": {
"@jridgewell/trace-mapping": "0.3.9"
},
"engines": {
"node": ">=12"
}
},
"node_modules/@cspotcode/source-map-support/node_modules/@jridgewell/trace-mapping": {
"version": "0.3.9",
"resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.9.tgz",
"integrity": "sha512-3Belt6tdc8bPgAtbcmdtNJlirVoTmEb5e2gC94PnkwEW9jI6CAHUeoG85tjWP5WquqfavoMtMwiG4P926ZKKuQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"@jridgewell/resolve-uri": "^3.0.3",
"@jridgewell/sourcemap-codec": "^1.4.10"
}
},
"node_modules/@csstools/color-helpers": {
"version": "5.1.0",
"resolved": "https://registry.npmjs.org/@csstools/color-helpers/-/color-helpers-5.1.0.tgz",
@@ -1938,6 +1967,16 @@
"url": "https://opencollective.com/pkgr"
}
},
"node_modules/@prisma/adapter-better-sqlite3": {
"version": "7.8.0",
"resolved": "https://registry.npmjs.org/@prisma/adapter-better-sqlite3/-/adapter-better-sqlite3-7.8.0.tgz",
"integrity": "sha512-9p0HvQNaRoOaUForDcp1MPIjylmCamYQjQpEogpdesLr14g3NwAsYR3bHL9wFi8tn21TPDVQPzcZi+SxXEokSA==",
"license": "Apache-2.0",
"dependencies": {
"@prisma/driver-adapter-utils": "7.8.0",
"better-sqlite3": "^12.6.0"
}
},
"node_modules/@prisma/client": {
"version": "7.8.0",
"resolved": "https://registry.npmjs.org/@prisma/client/-/client-7.8.0.tgz",
@@ -1985,7 +2024,6 @@
"version": "7.8.0",
"resolved": "https://registry.npmjs.org/@prisma/debug/-/debug-7.8.0.tgz",
"integrity": "sha512-p+QZReysDUqXC+mk17q9a+Y/qzh4c2KYliDK30buYUyfrGeTGSyfmc0AIrJRhZJrLHhRiJa9Au/J72h3C+szvA==",
"devOptional": true,
"license": "Apache-2.0"
},
"node_modules/@prisma/dev": {
@@ -2014,6 +2052,15 @@
"zeptomatch": "2.1.0"
}
},
"node_modules/@prisma/driver-adapter-utils": {
"version": "7.8.0",
"resolved": "https://registry.npmjs.org/@prisma/driver-adapter-utils/-/driver-adapter-utils-7.8.0.tgz",
"integrity": "sha512-/Q13o0ZT0rjc1Xk0Q9KhZYwuq2EW/vSbWUBKfgEKkaCuB/Sg6bqnjmTZqC5cD4d6y1vfFAEwBRzfzoSMIVJ55A==",
"license": "Apache-2.0",
"dependencies": {
"@prisma/debug": "7.8.0"
}
},
"node_modules/@prisma/engines": {
"version": "7.8.0",
"resolved": "https://registry.npmjs.org/@prisma/engines/-/engines-7.8.0.tgz",
@@ -2480,6 +2527,34 @@
}
}
},
"node_modules/@tsconfig/node10": {
"version": "1.0.12",
"resolved": "https://registry.npmjs.org/@tsconfig/node10/-/node10-1.0.12.tgz",
"integrity": "sha512-UCYBaeFvM11aU2y3YPZ//O5Rhj+xKyzy7mvcIoAjASbigy8mHMryP5cK7dgjlz2hWxh1g5pLw084E0a/wlUSFQ==",
"dev": true,
"license": "MIT"
},
"node_modules/@tsconfig/node12": {
"version": "1.0.11",
"resolved": "https://registry.npmjs.org/@tsconfig/node12/-/node12-1.0.11.tgz",
"integrity": "sha512-cqefuRsh12pWyGsIoBKJA9luFu3mRxCA+ORZvA4ktLSzIuCUtWVxGIuXigEwO5/ywWFMZ2QEGKWvkZG1zDMTag==",
"dev": true,
"license": "MIT"
},
"node_modules/@tsconfig/node14": {
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/@tsconfig/node14/-/node14-1.0.3.tgz",
"integrity": "sha512-ysT8mhdixWK6Hw3i1V2AeRqZ5WfXg1G43mqoYlM2nc6388Fq5jcXyr5mRsqViLx/GJYdoL0bfXD8nmF+Zn/Iow==",
"dev": true,
"license": "MIT"
},
"node_modules/@tsconfig/node16": {
"version": "1.0.4",
"resolved": "https://registry.npmjs.org/@tsconfig/node16/-/node16-1.0.4.tgz",
"integrity": "sha512-vxhUy4J8lyeyinH7Azl1pdd43GJhZH/tP2weN8TntQblOY+A0XbT8DJk1/oCPuOOyg/Ja757rG0CgHcWC8OfMA==",
"dev": true,
"license": "MIT"
},
"node_modules/@tybys/wasm-util": {
"version": "0.10.2",
"resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.2.tgz",
@@ -3313,6 +3388,19 @@
"acorn": "^6.0.0 || ^7.0.0 || ^8.0.0"
}
},
"node_modules/acorn-walk": {
"version": "8.3.5",
"resolved": "https://registry.npmjs.org/acorn-walk/-/acorn-walk-8.3.5.tgz",
"integrity": "sha512-HEHNfbars9v4pgpW6SO1KSPkfoS0xVOM/9UzkJltjlsHZmJasxg8aXkuZa7SMf8vKGIBhpUsPluQSqhJFCqebw==",
"dev": true,
"license": "MIT",
"dependencies": {
"acorn": "^8.11.0"
},
"engines": {
"node": ">=0.4.0"
}
},
"node_modules/agent-base": {
"version": "7.1.4",
"resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz",
@@ -4208,6 +4296,26 @@
"dev": true,
"license": "MIT"
},
"node_modules/base64-js": {
"version": "1.5.1",
"resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz",
"integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/feross"
},
{
"type": "patreon",
"url": "https://www.patreon.com/feross"
},
{
"type": "consulting",
"url": "https://feross.org/support"
}
],
"license": "MIT"
},
"node_modules/baseline-browser-mapping": {
"version": "2.10.34",
"resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.34.tgz",
@@ -4237,6 +4345,20 @@
"devOptional": true,
"license": "MIT"
},
"node_modules/better-sqlite3": {
"version": "12.10.0",
"resolved": "https://registry.npmjs.org/better-sqlite3/-/better-sqlite3-12.10.0.tgz",
"integrity": "sha512-CyzaZRQKyHkB2ZInfTTl2nvT33EbDpjkLEbE8/Zck3Ll6O0qqvuGdrJ45HgtH+HykRg88ITY3AdreBGN70aBSQ==",
"hasInstallScript": true,
"license": "MIT",
"dependencies": {
"bindings": "^1.5.0",
"prebuild-install": "^7.1.1"
},
"engines": {
"node": "20.x || 22.x || 23.x || 24.x || 25.x || 26.x"
}
},
"node_modules/binary-extensions": {
"version": "2.3.0",
"dev": true,
@@ -4248,6 +4370,26 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/bindings": {
"version": "1.5.0",
"resolved": "https://registry.npmjs.org/bindings/-/bindings-1.5.0.tgz",
"integrity": "sha512-p2q/t/mhvuOj/UeLlV6566GD/guowlr0hHxClI0W9m7MWYkL1F0hLo+0Aexs9HSPCtR1SXQ0TD3MMKrXZajbiQ==",
"license": "MIT",
"dependencies": {
"file-uri-to-path": "1.0.0"
}
},
"node_modules/bl": {
"version": "4.1.0",
"resolved": "https://registry.npmjs.org/bl/-/bl-4.1.0.tgz",
"integrity": "sha512-1W07cM9gS6DcLperZfFSj+bWLtaPGSOHWhPiGzXmvVJbRLdG82sH/Kn8EtW1VqWVA54AKf2h5k5BbnIbwF3h6w==",
"license": "MIT",
"dependencies": {
"buffer": "^5.5.0",
"inherits": "^2.0.4",
"readable-stream": "^3.4.0"
}
},
"node_modules/brace-expansion": {
"version": "1.1.15",
"dev": true,
@@ -4325,6 +4467,30 @@
"node-int64": "^0.4.0"
}
},
"node_modules/buffer": {
"version": "5.7.1",
"resolved": "https://registry.npmjs.org/buffer/-/buffer-5.7.1.tgz",
"integrity": "sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/feross"
},
{
"type": "patreon",
"url": "https://www.patreon.com/feross"
},
{
"type": "consulting",
"url": "https://feross.org/support"
}
],
"license": "MIT",
"dependencies": {
"base64-js": "^1.3.1",
"ieee754": "^1.1.13"
}
},
"node_modules/buffer-from": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz",
@@ -4572,6 +4738,12 @@
"node": ">= 6"
}
},
"node_modules/chownr": {
"version": "1.1.4",
"resolved": "https://registry.npmjs.org/chownr/-/chownr-1.1.4.tgz",
"integrity": "sha512-jJ0bqzaylmJtVnNgzTeSOs8DPavpbYgEr/b0YL8/2GO3xJEhInFmhKMUnEJQjZumK7KXGFhUy89PrsJWlakBVg==",
"license": "ISC"
},
"node_modules/ci-info": {
"version": "4.4.0",
"resolved": "https://registry.npmjs.org/ci-info/-/ci-info-4.4.0.tgz",
@@ -4724,6 +4896,19 @@
"node": ">= 0.6"
}
},
"node_modules/core-util-is": {
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/core-util-is/-/core-util-is-1.0.3.tgz",
"integrity": "sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ==",
"license": "MIT"
},
"node_modules/create-require": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/create-require/-/create-require-1.1.1.tgz",
"integrity": "sha512-dcKFX3jn0MpIaXjisoRvexIJVEKzaq7z2rZKxf+MSr9TkdmHmsU4m2lcLojrj/FHl8mk5VxMmYA+ftRkP/3oKQ==",
"dev": true,
"license": "MIT"
},
"node_modules/cross-spawn": {
"version": "7.0.6",
"devOptional": true,
@@ -4864,6 +5049,21 @@
"dev": true,
"license": "MIT"
},
"node_modules/decompress-response": {
"version": "6.0.0",
"resolved": "https://registry.npmjs.org/decompress-response/-/decompress-response-6.0.0.tgz",
"integrity": "sha512-aW35yZM6Bb/4oJlZncMH2LCoZtJXTRxES17vE3hoRiowU2kWHaJKFkSBDnDR+cm9J+9QhXmREyIfv0pji9ejCQ==",
"license": "MIT",
"dependencies": {
"mimic-response": "^3.1.0"
},
"engines": {
"node": ">=10"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/dedent": {
"version": "1.7.2",
"resolved": "https://registry.npmjs.org/dedent/-/dedent-1.7.2.tgz",
@@ -4879,6 +5079,15 @@
}
}
},
"node_modules/deep-extend": {
"version": "0.6.0",
"resolved": "https://registry.npmjs.org/deep-extend/-/deep-extend-0.6.0.tgz",
"integrity": "sha512-LOHxIOaPYdHlJRtCQfDIVZtfw/ufM8+rVj649RIHzcm/vGwQRXFt6OPqIFWsm2XEMrNIEtWR64sY1LEKD2vAOA==",
"license": "MIT",
"engines": {
"node": ">=4.0.0"
}
},
"node_modules/deep-is": {
"version": "0.1.4",
"dev": true,
@@ -4971,6 +5180,15 @@
"devOptional": true,
"license": "MIT"
},
"node_modules/detect-libc": {
"version": "2.1.2",
"resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz",
"integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==",
"license": "Apache-2.0",
"engines": {
"node": ">=8"
}
},
"node_modules/detect-newline": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/detect-newline/-/detect-newline-3.1.0.tgz",
@@ -4986,6 +5204,16 @@
"dev": true,
"license": "Apache-2.0"
},
"node_modules/diff": {
"version": "4.0.4",
"resolved": "https://registry.npmjs.org/diff/-/diff-4.0.4.tgz",
"integrity": "sha512-X07nttJQkwkfKfvTPG/KSnE2OMdcUCao6+eXF3wmnIQRn2aPAHH3VxDbDOdegkd6JbPsXqShpvEOHfAT+nCNwQ==",
"dev": true,
"license": "BSD-3-Clause",
"engines": {
"node": ">=0.3.1"
}
},
"node_modules/dlv": {
"version": "1.1.3",
"dev": true,
@@ -5087,6 +5315,15 @@
"node": ">=14"
}
},
"node_modules/end-of-stream": {
"version": "1.4.5",
"resolved": "https://registry.npmjs.org/end-of-stream/-/end-of-stream-1.4.5.tgz",
"integrity": "sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg==",
"license": "MIT",
"dependencies": {
"once": "^1.4.0"
}
},
"node_modules/entities": {
"version": "6.0.1",
"resolved": "https://registry.npmjs.org/entities/-/entities-6.0.1.tgz",
@@ -5745,6 +5982,15 @@
"node": ">= 0.8.0"
}
},
"node_modules/expand-template": {
"version": "2.0.3",
"resolved": "https://registry.npmjs.org/expand-template/-/expand-template-2.0.3.tgz",
"integrity": "sha512-XYfuKMvj4O35f/pOXLObndIRvyQ+/+6AhODh+OKWj9S9498pHHn/IMszH+gt0fBCRWMNfk1ZSp5x3AifmnI2vg==",
"license": "(MIT OR WTFPL)",
"engines": {
"node": ">=6"
}
},
"node_modules/expect": {
"version": "30.4.1",
"resolved": "https://registry.npmjs.org/expect/-/expect-30.4.1.tgz",
@@ -5880,6 +6126,12 @@
"node": "^10.12.0 || >=12.0.0"
}
},
"node_modules/file-uri-to-path": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/file-uri-to-path/-/file-uri-to-path-1.0.0.tgz",
"integrity": "sha512-0Zt+s3L7Vf1biwWZ29aARiVYLx7iMGnEUl9x33fbB/j3jR81u/O2LbqK+Bm1CDSNDKVtJ/YjwY7TUd5SkeLQLw==",
"license": "MIT"
},
"node_modules/fill-range": {
"version": "7.1.1",
"dev": true,
@@ -5953,6 +6205,12 @@
"url": "https://github.com/sponsors/isaacs"
}
},
"node_modules/fs-constants": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/fs-constants/-/fs-constants-1.0.0.tgz",
"integrity": "sha512-y6OAwoSIf7FyjMIv94u+b5rdheZEjzR63GTyZJm5qh4Bi+2YgwLCcI/fPFZkL5PSixOt6ZNKm+w+Hfp/Bciwow==",
"license": "MIT"
},
"node_modules/fs.realpath": {
"version": "1.0.0",
"dev": true,
@@ -6145,6 +6403,12 @@
"giget": "dist/cli.mjs"
}
},
"node_modules/github-from-package": {
"version": "0.0.0",
"resolved": "https://registry.npmjs.org/github-from-package/-/github-from-package-0.0.0.tgz",
"integrity": "sha512-SyHy3T1v2NUXn29OsWdxmK6RwHD+vkj3v8en8AOBZ1wBQ/hCAQ5bAQTD02kW4W9tUp/3Qh6J8r9EvntiyCmOOw==",
"license": "MIT"
},
"node_modules/glob": {
"version": "10.3.10",
"dev": true,
@@ -6364,6 +6628,12 @@
"node": ">= 0.4"
}
},
"node_modules/heic2any": {
"version": "0.0.4",
"resolved": "https://registry.npmjs.org/heic2any/-/heic2any-0.0.4.tgz",
"integrity": "sha512-3lLnZiDELfabVH87htnRolZ2iehX9zwpRyGNz22GKXIu0fznlblf0/ftppXKNqS26dqFSeqfIBhAmAj/uSp0cA==",
"license": "MIT"
},
"node_modules/hono": {
"version": "4.12.23",
"resolved": "https://registry.npmjs.org/hono/-/hono-4.12.23.tgz",
@@ -6456,6 +6726,26 @@
"url": "https://opencollective.com/express"
}
},
"node_modules/ieee754": {
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz",
"integrity": "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/feross"
},
{
"type": "patreon",
"url": "https://www.patreon.com/feross"
},
{
"type": "consulting",
"url": "https://feross.org/support"
}
],
"license": "BSD-3-Clause"
},
"node_modules/ignore": {
"version": "5.3.2",
"dev": true,
@@ -6464,6 +6754,12 @@
"node": ">= 4"
}
},
"node_modules/immediate": {
"version": "3.0.6",
"resolved": "https://registry.npmjs.org/immediate/-/immediate-3.0.6.tgz",
"integrity": "sha512-XXOFtyqDjNDAQxVfYxuF7g9Il/IbWmmlQg2MYKOH8ExIT1qg6xc4zyS3HaEEATgs1btfzxq15ciUiY7gjSXRGQ==",
"license": "MIT"
},
"node_modules/import-fresh": {
"version": "3.3.1",
"dev": true,
@@ -6528,7 +6824,12 @@
},
"node_modules/inherits": {
"version": "2.0.4",
"dev": true,
"license": "ISC"
},
"node_modules/ini": {
"version": "1.3.8",
"resolved": "https://registry.npmjs.org/ini/-/ini-1.3.8.tgz",
"integrity": "sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew==",
"license": "ISC"
},
"node_modules/internal-slot": {
@@ -8279,6 +8580,54 @@
"node": ">=4.0"
}
},
"node_modules/jszip": {
"version": "3.10.1",
"resolved": "https://registry.npmjs.org/jszip/-/jszip-3.10.1.tgz",
"integrity": "sha512-xXDvecyTpGLrqFrvkrUSoxxfJI5AH7U8zxxtVclpsUtMCq4JQ290LY8AW5c7Ggnr/Y/oK+bQMbqK2qmtk3pN4g==",
"license": "(MIT OR GPL-3.0-or-later)",
"dependencies": {
"lie": "~3.3.0",
"pako": "~1.0.2",
"readable-stream": "~2.3.6",
"setimmediate": "^1.0.5"
}
},
"node_modules/jszip/node_modules/isarray": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz",
"integrity": "sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==",
"license": "MIT"
},
"node_modules/jszip/node_modules/readable-stream": {
"version": "2.3.8",
"resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.8.tgz",
"integrity": "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==",
"license": "MIT",
"dependencies": {
"core-util-is": "~1.0.0",
"inherits": "~2.0.3",
"isarray": "~1.0.0",
"process-nextick-args": "~2.0.0",
"safe-buffer": "~5.1.1",
"string_decoder": "~1.1.1",
"util-deprecate": "~1.0.1"
}
},
"node_modules/jszip/node_modules/safe-buffer": {
"version": "5.1.2",
"resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz",
"integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==",
"license": "MIT"
},
"node_modules/jszip/node_modules/string_decoder": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz",
"integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==",
"license": "MIT",
"dependencies": {
"safe-buffer": "~5.1.0"
}
},
"node_modules/keyv": {
"version": "4.5.4",
"dev": true,
@@ -8325,6 +8674,15 @@
"node": ">= 0.8.0"
}
},
"node_modules/lie": {
"version": "3.3.0",
"resolved": "https://registry.npmjs.org/lie/-/lie-3.3.0.tgz",
"integrity": "sha512-UaiMJzeWRlEujzAuw5LokY1L5ecNQYZKfmyZ9L7wDHb/p5etKaxXhohBcrw0EYby+G/NA52vRSN4N39dxHAIwQ==",
"license": "MIT",
"dependencies": {
"immediate": "~3.0.5"
}
},
"node_modules/lilconfig": {
"version": "3.1.3",
"dev": true,
@@ -8494,6 +8852,18 @@
"node": ">=6"
}
},
"node_modules/mimic-response": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/mimic-response/-/mimic-response-3.1.0.tgz",
"integrity": "sha512-z0yWI+4FDrrweS8Zmt4Ej5HdJmky15+L2e6Wgn3+iK5fWzb6T3fhNFq2+MeTRb064c6Wr4N/wv0DzQTjNzHNGQ==",
"license": "MIT",
"engines": {
"node": ">=10"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/min-indent": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/min-indent/-/min-indent-1.0.1.tgz",
@@ -8517,7 +8887,6 @@
},
"node_modules/minimist": {
"version": "1.2.8",
"dev": true,
"license": "MIT",
"funding": {
"url": "https://github.com/sponsors/ljharb"
@@ -8531,6 +8900,12 @@
"node": ">=16 || 14 >=14.17"
}
},
"node_modules/mkdirp-classic": {
"version": "0.5.3",
"resolved": "https://registry.npmjs.org/mkdirp-classic/-/mkdirp-classic-0.5.3.tgz",
"integrity": "sha512-gKLcREMhtuZRwRAfqP3RFW+TK4JqApVBtOIftVgjuABpAtpxhPGaDcfvbhNvD0B8iD1oUr/txX35NjcaY6Ns/A==",
"license": "MIT"
},
"node_modules/ms": {
"version": "2.1.3",
"dev": true,
@@ -8596,6 +8971,12 @@
"node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1"
}
},
"node_modules/napi-build-utils": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/napi-build-utils/-/napi-build-utils-2.0.0.tgz",
"integrity": "sha512-GEbrYkbfF7MoNaoh2iGG84Mnf/WZfB0GdGEsM8wz7Expx/LlWf5U8t9nvJKXSp3qr5IsEbK04cBGhol/KwOsWA==",
"license": "MIT"
},
"node_modules/napi-postinstall": {
"version": "0.3.4",
"dev": true,
@@ -8730,6 +9111,18 @@
"node": "^10 || ^12 || >=14"
}
},
"node_modules/node-abi": {
"version": "3.92.0",
"resolved": "https://registry.npmjs.org/node-abi/-/node-abi-3.92.0.tgz",
"integrity": "sha512-KdHvFWZjEKDf0cakgFjebl371GPsISX2oZHcuyKqM7DtogIsHrqKeLTo8wBHxaXRAQlY2PsPlZmfo+9ZCxEREQ==",
"license": "MIT",
"dependencies": {
"semver": "^7.3.5"
},
"engines": {
"node": ">=10"
}
},
"node_modules/node-exports-info": {
"version": "1.6.0",
"dev": true,
@@ -9077,7 +9470,6 @@
},
"node_modules/once": {
"version": "1.4.0",
"dev": true,
"license": "ISC",
"dependencies": {
"wrappy": "1"
@@ -9212,6 +9604,12 @@
"dev": true,
"license": "BlueOak-1.0.0"
},
"node_modules/pako": {
"version": "1.0.11",
"resolved": "https://registry.npmjs.org/pako/-/pako-1.0.11.tgz",
"integrity": "sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw==",
"license": "(MIT AND Zlib)"
},
"node_modules/parent-module": {
"version": "1.0.1",
"dev": true,
@@ -9638,6 +10036,33 @@
"preact": ">=10"
}
},
"node_modules/prebuild-install": {
"version": "7.1.3",
"resolved": "https://registry.npmjs.org/prebuild-install/-/prebuild-install-7.1.3.tgz",
"integrity": "sha512-8Mf2cbV7x1cXPUILADGI3wuhfqWvtiLA1iclTDbFRZkgRQS0NqsPZphna9V+HyTEadheuPmjaJMsbzKQFOzLug==",
"deprecated": "No longer maintained. Please contact the author of the relevant native addon; alternatives are available.",
"license": "MIT",
"dependencies": {
"detect-libc": "^2.0.0",
"expand-template": "^2.0.3",
"github-from-package": "0.0.0",
"minimist": "^1.2.3",
"mkdirp-classic": "^0.5.3",
"napi-build-utils": "^2.0.0",
"node-abi": "^3.3.0",
"pump": "^3.0.0",
"rc": "^1.2.7",
"simple-get": "^4.0.0",
"tar-fs": "^2.0.0",
"tunnel-agent": "^0.6.0"
},
"bin": {
"prebuild-install": "bin.js"
},
"engines": {
"node": ">=10"
}
},
"node_modules/prelude-ls": {
"version": "1.2.1",
"dev": true,
@@ -9686,6 +10111,12 @@
}
}
},
"node_modules/process-nextick-args": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/process-nextick-args/-/process-nextick-args-2.0.1.tgz",
"integrity": "sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==",
"license": "MIT"
},
"node_modules/prop-types": {
"version": "15.8.1",
"dev": true,
@@ -9715,6 +10146,16 @@
"devOptional": true,
"license": "ISC"
},
"node_modules/pump": {
"version": "3.0.4",
"resolved": "https://registry.npmjs.org/pump/-/pump-3.0.4.tgz",
"integrity": "sha512-VS7sjc6KR7e1ukRFhQSY5LM2uBWAUPiOPa/A3mkKmiMwSmRFUITt0xuj+/lesgnCv+dPIEYlkzrcyXgquIHMcA==",
"license": "MIT",
"dependencies": {
"end-of-stream": "^1.1.0",
"once": "^1.3.1"
}
},
"node_modules/punycode": {
"version": "2.3.1",
"dev": true,
@@ -9759,6 +10200,30 @@
],
"license": "MIT"
},
"node_modules/rc": {
"version": "1.2.8",
"resolved": "https://registry.npmjs.org/rc/-/rc-1.2.8.tgz",
"integrity": "sha512-y3bGgqKj3QBdxLbLkomlohkvsA8gdAiUQlSBJnBhfn+BPxg4bc62d8TcBW15wavDfgexCgccckhcZvywyQYPOw==",
"license": "(BSD-2-Clause OR MIT OR Apache-2.0)",
"dependencies": {
"deep-extend": "^0.6.0",
"ini": "~1.3.0",
"minimist": "^1.2.0",
"strip-json-comments": "~2.0.1"
},
"bin": {
"rc": "cli.js"
}
},
"node_modules/rc/node_modules/strip-json-comments": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-2.0.1.tgz",
"integrity": "sha512-4gB8na07fecVVkOI6Rs4e7T6NOTki5EmL7TUduTs6bu3EdnSycntVJ4re8kgZA+wx9IueI2Y11bfbgwtzuE0KQ==",
"license": "MIT",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/rc9": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/rc9/-/rc9-3.0.1.tgz",
@@ -9820,6 +10285,20 @@
"pify": "^2.3.0"
}
},
"node_modules/readable-stream": {
"version": "3.6.2",
"resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz",
"integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==",
"license": "MIT",
"dependencies": {
"inherits": "^2.0.3",
"string_decoder": "^1.1.1",
"util-deprecate": "^1.0.1"
},
"engines": {
"node": ">= 6"
}
},
"node_modules/readdirp": {
"version": "3.6.0",
"dev": true,
@@ -10144,6 +10623,26 @@
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/safe-buffer": {
"version": "5.2.1",
"resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz",
"integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/feross"
},
{
"type": "patreon",
"url": "https://www.patreon.com/feross"
},
{
"type": "consulting",
"url": "https://feross.org/support"
}
],
"license": "MIT"
},
"node_modules/safe-push-apply": {
"version": "1.0.0",
"dev": true,
@@ -10204,7 +10703,6 @@
},
"node_modules/semver": {
"version": "7.8.2",
"dev": true,
"license": "ISC",
"bin": {
"semver": "bin/semver.js"
@@ -10262,6 +10760,12 @@
"node": ">= 0.4"
}
},
"node_modules/setimmediate": {
"version": "1.0.5",
"resolved": "https://registry.npmjs.org/setimmediate/-/setimmediate-1.0.5.tgz",
"integrity": "sha512-MATJdZp8sLqDl/68LfQmbP8zKPLQNV6BIZoIgrscFDQ+RsvK/BxeDQOgyxKKoh0y/8h3BqVFnCqQ/gd+reiIXA==",
"license": "MIT"
},
"node_modules/shebang-command": {
"version": "2.0.0",
"devOptional": true,
@@ -10360,6 +10864,51 @@
"url": "https://github.com/sponsors/isaacs"
}
},
"node_modules/simple-concat": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/simple-concat/-/simple-concat-1.0.1.tgz",
"integrity": "sha512-cSFtAPtRhljv69IK0hTVZQ+OfE9nePi/rtJmw5UjHeVyVroEqJXP1sFztKUy1qU+xvz3u/sfYJLa947b7nAN2Q==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/feross"
},
{
"type": "patreon",
"url": "https://www.patreon.com/feross"
},
{
"type": "consulting",
"url": "https://feross.org/support"
}
],
"license": "MIT"
},
"node_modules/simple-get": {
"version": "4.0.1",
"resolved": "https://registry.npmjs.org/simple-get/-/simple-get-4.0.1.tgz",
"integrity": "sha512-brv7p5WgH0jmQJr1ZDDfKDOSeWWg+OVypG99A/5vYGPqJ6pxiaHLy8nxtFjBA7oMa01ebA9gfh1uMCFqOuXxvA==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/feross"
},
{
"type": "patreon",
"url": "https://www.patreon.com/feross"
},
{
"type": "consulting",
"url": "https://feross.org/support"
}
],
"license": "MIT",
"dependencies": {
"decompress-response": "^6.0.0",
"once": "^1.3.1",
"simple-concat": "^1.0.0"
}
},
"node_modules/slash": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/slash/-/slash-3.0.0.tgz",
@@ -10468,6 +11017,15 @@
"node": ">=10.0.0"
}
},
"node_modules/string_decoder": {
"version": "1.3.0",
"resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz",
"integrity": "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==",
"license": "MIT",
"dependencies": {
"safe-buffer": "~5.2.0"
}
},
"node_modules/string-length": {
"version": "4.0.2",
"resolved": "https://registry.npmjs.org/string-length/-/string-length-4.0.2.tgz",
@@ -11128,6 +11686,34 @@
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/tar-fs": {
"version": "2.1.4",
"resolved": "https://registry.npmjs.org/tar-fs/-/tar-fs-2.1.4.tgz",
"integrity": "sha512-mDAjwmZdh7LTT6pNleZ05Yt65HC3E+NiQzl672vQG38jIrehtJk/J3mNwIg+vShQPcLF/LV7CMnDW6vjj6sfYQ==",
"license": "MIT",
"dependencies": {
"chownr": "^1.1.1",
"mkdirp-classic": "^0.5.2",
"pump": "^3.0.0",
"tar-stream": "^2.1.4"
}
},
"node_modules/tar-stream": {
"version": "2.2.0",
"resolved": "https://registry.npmjs.org/tar-stream/-/tar-stream-2.2.0.tgz",
"integrity": "sha512-ujeqbceABgwMZxEJnk2HDY2DlnUZ+9oEcb1KzTVfYHio0UE6dG71n60d8D2I4qNvleWrrXpmjpt7vZeF1LnMZQ==",
"license": "MIT",
"dependencies": {
"bl": "^4.0.3",
"end-of-stream": "^1.4.1",
"fs-constants": "^1.0.0",
"inherits": "^2.0.3",
"readable-stream": "^3.1.1"
},
"engines": {
"node": ">=6"
}
},
"node_modules/test-exclude": {
"version": "6.0.0",
"resolved": "https://registry.npmjs.org/test-exclude/-/test-exclude-6.0.0.tgz",
@@ -11390,6 +11976,57 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/ts-node": {
"version": "10.9.2",
"resolved": "https://registry.npmjs.org/ts-node/-/ts-node-10.9.2.tgz",
"integrity": "sha512-f0FFpIdcHgn8zcPSbf1dRevwt047YMnaiJM3u2w2RewrB+fob/zePZcrOyQoLMMO7aBIddLcQIEK5dYjkLnGrQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"@cspotcode/source-map-support": "^0.8.0",
"@tsconfig/node10": "^1.0.7",
"@tsconfig/node12": "^1.0.7",
"@tsconfig/node14": "^1.0.0",
"@tsconfig/node16": "^1.0.2",
"acorn": "^8.4.1",
"acorn-walk": "^8.1.1",
"arg": "^4.1.0",
"create-require": "^1.1.0",
"diff": "^4.0.1",
"make-error": "^1.1.1",
"v8-compile-cache-lib": "^3.0.1",
"yn": "3.1.1"
},
"bin": {
"ts-node": "dist/bin.js",
"ts-node-cwd": "dist/bin-cwd.js",
"ts-node-esm": "dist/bin-esm.js",
"ts-node-script": "dist/bin-script.js",
"ts-node-transpile-only": "dist/bin-transpile.js",
"ts-script": "dist/bin-script-deprecated.js"
},
"peerDependencies": {
"@swc/core": ">=1.2.50",
"@swc/wasm": ">=1.2.50",
"@types/node": "*",
"typescript": ">=2.7"
},
"peerDependenciesMeta": {
"@swc/core": {
"optional": true
},
"@swc/wasm": {
"optional": true
}
}
},
"node_modules/ts-node/node_modules/arg": {
"version": "4.1.3",
"resolved": "https://registry.npmjs.org/arg/-/arg-4.1.3.tgz",
"integrity": "sha512-58S9QDqG0Xx27YwPSt9fJxivjYl432YCwfDMfZ+71RAqUrZef7LrKQZ3LHLOwCS4FLNBplP533Zx895SeOCHvA==",
"dev": true,
"license": "MIT"
},
"node_modules/tsconfig-paths": {
"version": "3.15.0",
"dev": true,
@@ -11405,6 +12042,18 @@
"version": "2.8.1",
"license": "0BSD"
},
"node_modules/tunnel-agent": {
"version": "0.6.0",
"resolved": "https://registry.npmjs.org/tunnel-agent/-/tunnel-agent-0.6.0.tgz",
"integrity": "sha512-McnNiV1l8RYeY8tBgEpuodCC1mLUdbSN+CYBL7kJsJNInOP8UjDDEwdk6Mw60vdLLrr5NHKZhMAOSrR2NZuQ+w==",
"license": "Apache-2.0",
"dependencies": {
"safe-buffer": "^5.0.1"
},
"engines": {
"node": "*"
}
},
"node_modules/type-check": {
"version": "0.4.0",
"dev": true,
@@ -11632,7 +12281,6 @@
},
"node_modules/util-deprecate": {
"version": "1.0.2",
"dev": true,
"license": "MIT"
},
"node_modules/uuid": {
@@ -11645,6 +12293,13 @@
"uuid": "dist/bin/uuid"
}
},
"node_modules/v8-compile-cache-lib": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/v8-compile-cache-lib/-/v8-compile-cache-lib-3.0.1.tgz",
"integrity": "sha512-wa7YjyUGfNZngI/vtK0UHAN+lgDCxBPCylVXGp0zu59Fz5aiGtNXaq3DhIov063MorB+VfufLh3JlF2KdTK3xg==",
"dev": true,
"license": "MIT"
},
"node_modules/v8-to-istanbul": {
"version": "9.3.0",
"resolved": "https://registry.npmjs.org/v8-to-istanbul/-/v8-to-istanbul-9.3.0.tgz",
@@ -11958,7 +12613,6 @@
},
"node_modules/wrappy": {
"version": "1.0.2",
"dev": true,
"license": "ISC"
},
"node_modules/write-file-atomic": {
@@ -12081,6 +12735,16 @@
"node": ">=8"
}
},
"node_modules/yn": {
"version": "3.1.1",
"resolved": "https://registry.npmjs.org/yn/-/yn-3.1.1.tgz",
"integrity": "sha512-Ux4ygGWsu2c7isFWe8Yu1YluJmqVhxqK2cLXNQA5AcC3QfbGNpM7fu0Y8b/z16pXLnFxZYvWhd3fhBY9DLmC6Q==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=6"
}
},
"node_modules/yocto-queue": {
"version": "0.1.0",
"dev": true,
+10 -1
View File
@@ -6,11 +6,19 @@
"dev": "next dev",
"build": "next build",
"start": "next start",
"lint": "next lint"
"lint": "next lint",
"db:seed": "ts-node --compiler-options '{\"module\":\"CommonJS\"}' prisma/seed.ts"
},
"prisma": {
"seed": "ts-node --compiler-options '{\"module\":\"CommonJS\"}' prisma/seed.ts"
},
"dependencies": {
"@prisma/adapter-better-sqlite3": "^7.8.0",
"@prisma/client": "^7.8.0",
"bcryptjs": "^3.0.3",
"better-sqlite3": "^12.10.0",
"heic2any": "^0.0.4",
"jszip": "^3.10.1",
"next": "14.2.35",
"next-auth": "^4.24.14",
"react": "^18",
@@ -32,6 +40,7 @@
"prisma": "^7.8.0",
"tailwindcss": "^3.4.1",
"ts-jest": "^29.4.11",
"ts-node": "^10.9.2",
"typescript": "^5"
}
}
+14
View File
@@ -0,0 +1,14 @@
// This file was generated by Prisma, and assumes you have installed the following:
// npm install --save-dev prisma dotenv
import "dotenv/config";
import { defineConfig } from "prisma/config";
export default defineConfig({
schema: "prisma/schema.prisma",
migrations: {
path: "prisma/migrations",
},
datasource: {
url: process.env["DATABASE_URL"],
},
});
@@ -0,0 +1,26 @@
-- CreateTable
CREATE TABLE "User" (
"id" TEXT NOT NULL PRIMARY KEY,
"email" TEXT NOT NULL,
"name" TEXT NOT NULL,
"password" TEXT NOT NULL,
"role" TEXT NOT NULL DEFAULT 'gestionnaire',
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
);
-- CreateTable
CREATE TABLE "SyncRun" (
"id" TEXT NOT NULL PRIMARY KEY,
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
"userId" TEXT NOT NULL,
"status" TEXT NOT NULL,
"created" INTEGER NOT NULL DEFAULT 0,
"updated" INTEGER NOT NULL DEFAULT 0,
"deactivated" INTEGER NOT NULL DEFAULT 0,
"errors" INTEGER NOT NULL DEFAULT 0,
"log" TEXT NOT NULL DEFAULT '[]',
CONSTRAINT "SyncRun_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User" ("id") ON DELETE RESTRICT ON UPDATE CASCADE
);
-- CreateIndex
CREATE UNIQUE INDEX "User_email_key" ON "User"("email");
+3
View File
@@ -0,0 +1,3 @@
# Please do not edit this file manually
# It should be added in your version-control system (e.g., Git)
provider = "sqlite"
+30
View File
@@ -0,0 +1,30 @@
generator client {
provider = "prisma-client-js"
}
datasource db {
provider = "sqlite"
}
model User {
id String @id @default(cuid())
email String @unique
name String
password String
role String @default("gestionnaire")
createdAt DateTime @default(now())
syncRuns SyncRun[]
}
model SyncRun {
id String @id @default(cuid())
createdAt DateTime @default(now())
userId String
user User @relation(fields: [userId], references: [id])
status String
created Int @default(0)
updated Int @default(0)
deactivated Int @default(0)
errors Int @default(0)
log String @default("[]")
}
+31
View File
@@ -0,0 +1,31 @@
import { PrismaClient } from '@prisma/client'
import { PrismaBetterSqlite3 } from '@prisma/adapter-better-sqlite3'
import { hash } from 'bcryptjs'
import path from 'path'
const password = process.env.SEED_ADMIN_PASSWORD
if (!password) {
console.error('SEED_ADMIN_PASSWORD requis')
process.exit(1)
}
const dbUrl = process.env.DATABASE_URL ?? 'file:./data/app.db'
const dbPath = dbUrl.startsWith('file:')
? path.resolve(process.cwd(), dbUrl.slice('file:'.length))
: dbUrl
const adapter = new PrismaBetterSqlite3({ url: dbPath })
const prisma = new PrismaClient({ adapter })
await prisma.user.upsert({
where: { email: 'mathieu.fort@gmail.com' },
update: {},
create: {
email: 'mathieu.fort@gmail.com',
name: 'Mathieu',
password: await hash(password, 12),
role: 'admin',
},
})
console.log('✅ Compte admin créé : mathieu.fort@gmail.com')
await prisma.$disconnect()
+33
View File
@@ -0,0 +1,33 @@
import { PrismaClient } from '@prisma/client'
import { PrismaBetterSqlite3 } from '@prisma/adapter-better-sqlite3'
import { hash } from 'bcryptjs'
import path from 'path'
const dbUrl = process.env.DATABASE_URL ?? 'file:./data/dev.db'
const dbPath = dbUrl.startsWith('file:')
? path.resolve(process.cwd(), dbUrl.slice('file:'.length))
: dbUrl
const adapter = new PrismaBetterSqlite3({ url: dbPath })
const prisma = new PrismaClient({ adapter } as any)
async function main() {
const password = process.env.SEED_ADMIN_PASSWORD
if (!password) throw new Error('SEED_ADMIN_PASSWORD env var requis')
await prisma.user.upsert({
where: { email: 'mathieu.fort@gmail.com' },
update: {},
create: {
email: 'mathieu.fort@gmail.com',
name: 'Mathieu',
password: await hash(password, 12),
role: 'admin',
},
})
console.log('✅ Compte admin créé : mathieu.fort@gmail.com')
}
main()
.catch(console.error)
.finally(() => prisma.$disconnect())
View File
+6
View File
@@ -0,0 +1,6 @@
#!/bin/sh
set -e
echo "▶ Migration base de données…"
node_modules/.bin/prisma migrate deploy
echo "▶ Démarrage du serveur…"
exec node server.js
+75
View File
@@ -0,0 +1,75 @@
import { POST } from '@/app/api/images/upload/route'
import { NextRequest } from 'next/server'
jest.mock('@/lib/shopify', () => ({
uploadProductImage: jest.fn(),
}))
import { uploadProductImage } from '@/lib/shopify'
const mockUpload = uploadProductImage as jest.Mock
describe('POST /api/images/upload', () => {
beforeEach(() => jest.clearAllMocks())
it('retourne 400 si shopifyProductId manquant', async () => {
const req = new NextRequest('http://localhost/api/images/upload', {
method: 'POST',
body: JSON.stringify({ imageBase64: 'abc', filename: 'test.jpg' }),
headers: { 'Content-Type': 'application/json' },
})
const res = await POST(req)
expect(res.status).toBe(400)
})
it('retourne 400 si imageBase64 manquant', async () => {
const req = new NextRequest('http://localhost/api/images/upload', {
method: 'POST',
body: JSON.stringify({ shopifyProductId: '123', filename: 'test.jpg' }),
headers: { 'Content-Type': 'application/json' },
})
const res = await POST(req)
expect(res.status).toBe(400)
})
it('retourne 400 si filename manquant', async () => {
const req = new NextRequest('http://localhost/api/images/upload', {
method: 'POST',
body: JSON.stringify({ shopifyProductId: '123', imageBase64: 'abc' }),
headers: { 'Content-Type': 'application/json' },
})
const res = await POST(req)
expect(res.status).toBe(400)
})
it('retourne l\'URL après upload réussi', async () => {
mockUpload.mockResolvedValue({ url: 'https://cdn.shopify.com/image.jpg' })
const req = new NextRequest('http://localhost/api/images/upload', {
method: 'POST',
body: JSON.stringify({
shopifyProductId: '123456',
imageBase64: 'base64data==',
filename: 'REF-1042.jpg',
}),
headers: { 'Content-Type': 'application/json' },
})
const res = await POST(req)
expect(res.status).toBe(200)
expect(await res.json()).toEqual({ url: 'https://cdn.shopify.com/image.jpg' })
expect(mockUpload).toHaveBeenCalledWith('123456', 'base64data==', 'REF-1042.jpg')
})
it('retourne 500 en cas d\'erreur Shopify', async () => {
mockUpload.mockRejectedValue(new Error('Shopify upload error: 422'))
const req = new NextRequest('http://localhost/api/images/upload', {
method: 'POST',
body: JSON.stringify({
shopifyProductId: '123',
imageBase64: 'abc',
filename: 'test.jpg',
}),
headers: { 'Content-Type': 'application/json' },
})
const res = await POST(req)
expect(res.status).toBe(500)
})
})
+53
View File
@@ -0,0 +1,53 @@
import { GET } from '@/app/api/products/search/route'
import { NextRequest } from 'next/server'
jest.mock('@/lib/shopify', () => ({
searchProductByRef: jest.fn(),
}))
import { searchProductByRef } from '@/lib/shopify'
const mockSearch = searchProductByRef as jest.Mock
describe('GET /api/products/search', () => {
beforeEach(() => jest.clearAllMocks())
it('retourne 400 si le paramètre ref est absent', async () => {
const req = new NextRequest('http://localhost/api/products/search')
const res = await GET(req)
expect(res.status).toBe(400)
const data = await res.json()
expect(data.error).toMatch(/ref/)
})
it('retourne 400 si ref est une chaîne vide', async () => {
const req = new NextRequest('http://localhost/api/products/search?ref=')
const res = await GET(req)
expect(res.status).toBe(400)
})
it('retourne { found: false } si produit introuvable', async () => {
mockSearch.mockResolvedValue({ found: false })
const req = new NextRequest('http://localhost/api/products/search?ref=REF-9999')
const res = await GET(req)
expect(res.status).toBe(200)
expect(await res.json()).toEqual({ found: false })
expect(mockSearch).toHaveBeenCalledWith('REF-9999')
})
it('retourne le produit trouvé', async () => {
mockSearch.mockResolvedValue({ found: true, shopifyId: '123456', title: 'Carrelage REF-1042' })
const req = new NextRequest('http://localhost/api/products/search?ref=REF-1042')
const res = await GET(req)
expect(res.status).toBe(200)
expect(await res.json()).toEqual({ found: true, shopifyId: '123456', title: 'Carrelage REF-1042' })
})
it('retourne 500 en cas d\'erreur Shopify', async () => {
mockSearch.mockRejectedValue(new Error('Shopify API error: 503'))
const req = new NextRequest('http://localhost/api/products/search?ref=REF-1042')
const res = await GET(req)
expect(res.status).toBe(500)
const data = await res.json()
expect(data.error).toBeTruthy()
})
})
+69
View File
@@ -0,0 +1,69 @@
import { authOptions } from '@/lib/auth'
// Mock Prisma
jest.mock('@/lib/prisma', () => ({
prisma: {
user: {
findUnique: jest.fn(),
},
},
}))
// Mock rate-limit
jest.mock('@/lib/rate-limit', () => ({
checkRateLimit: jest.fn(() => true),
recordFailedAttempt: jest.fn(),
clearAttempts: jest.fn(),
}))
import { prisma } from '@/lib/prisma'
import { checkRateLimit, recordFailedAttempt, clearAttempts } from '@/lib/rate-limit'
const mockFindUnique = prisma.user.findUnique as jest.Mock
describe('NextAuth authorize', () => {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const authorize = (authOptions.providers[0] as any).options.authorize
beforeEach(() => jest.clearAllMocks())
it('retourne null si credentials manquants', async () => {
const result = await authorize({ email: '', password: '' }, {})
expect(result).toBeNull()
})
it('retourne null si utilisateur introuvable', async () => {
mockFindUnique.mockResolvedValue(null)
const result = await authorize(
{ email: 'a@b.com', password: 'wrong' },
{ headers: {} }
)
expect(result).toBeNull()
expect(recordFailedAttempt).toHaveBeenCalled()
})
it('retourne l\'utilisateur si mot de passe correct', async () => {
const { hash } = await import('bcryptjs')
const hashedPwd = await hash('correct', 12)
mockFindUnique.mockResolvedValue({
id: 'user1',
email: 'a@b.com',
name: 'Alice',
role: 'gestionnaire',
password: hashedPwd,
})
const result = await authorize(
{ email: 'a@b.com', password: 'correct' },
{ headers: {} }
)
expect(result).toEqual({ id: 'user1', email: 'a@b.com', name: 'Alice', role: 'gestionnaire' })
expect(clearAttempts).toHaveBeenCalled()
})
it('lève RATE_LIMITED si bloqué', async () => {
;(checkRateLimit as jest.Mock).mockReturnValue(false)
await expect(
authorize({ email: 'a@b.com', password: 'x' }, { headers: {} })
).rejects.toThrow('RATE_LIMITED')
})
})
+36
View File
@@ -0,0 +1,36 @@
import {
checkRateLimit,
recordFailedAttempt,
clearAttempts,
_resetForTests,
} from '@/lib/rate-limit'
beforeEach(() => _resetForTests())
describe('checkRateLimit', () => {
it('autorise une nouvelle clé', () => {
expect(checkRateLimit('ip:user@test.com')).toBe(true)
})
it('autorise après moins de 5 tentatives', () => {
const key = 'ip:user@test.com'
recordFailedAttempt(key)
recordFailedAttempt(key)
recordFailedAttempt(key)
recordFailedAttempt(key)
expect(checkRateLimit(key)).toBe(true)
})
it('bloque après 5 tentatives', () => {
const key = 'ip:user@test.com'
for (let i = 0; i < 5; i++) recordFailedAttempt(key)
expect(checkRateLimit(key)).toBe(false)
})
it('débloque après clearAttempts', () => {
const key = 'ip:user@test.com'
for (let i = 0; i < 5; i++) recordFailedAttempt(key)
clearAttempts(key)
expect(checkRateLimit(key)).toBe(true)
})
})
+97
View File
@@ -0,0 +1,97 @@
'use client'
import { signIn } from 'next-auth/react'
import { useState, FormEvent } from 'react'
import { useRouter } from 'next/navigation'
export default function LoginPage() {
const [email, setEmail] = useState('')
const [password, setPassword] = useState('')
const [error, setError] = useState('')
const [loading, setLoading] = useState(false)
const router = useRouter()
async function handleSubmit(e: FormEvent) {
e.preventDefault()
setLoading(true)
setError('')
const result = await signIn('credentials', {
email,
password,
redirect: false,
})
setLoading(false)
if (result?.error === 'RATE_LIMITED') {
setError('Trop de tentatives. Réessayez dans 15 minutes.')
} else if (result?.error) {
setError('Email ou mot de passe incorrect.')
} else {
router.push('/')
router.refresh()
}
}
return (
<div className="min-h-screen bg-slate-950 flex items-center justify-center p-4">
<div className="w-full max-w-sm">
<div className="text-center mb-8">
<h1 className="text-2xl font-extrabold text-white">
Matériaux<span className="text-indigo-400">Destock</span>
</h1>
<p className="text-slate-400 text-sm mt-1">Interface de gestion</p>
</div>
<form
onSubmit={handleSubmit}
className="bg-slate-900 rounded-xl p-6 space-y-4 border border-slate-800"
>
{error && (
<div className="bg-red-950 border border-red-800 text-red-300 text-sm rounded-lg px-3 py-2">
{error}
</div>
)}
<div>
<label className="block text-xs font-semibold text-slate-400 mb-1 uppercase tracking-wide">
Email
</label>
<input
type="email"
value={email}
onChange={e => setEmail(e.target.value)}
required
autoComplete="email"
className="w-full bg-slate-800 border border-slate-700 rounded-lg px-3 py-2.5 text-sm text-white placeholder-slate-500 focus:outline-none focus:border-indigo-500 transition-colors"
placeholder="vous@example.com"
/>
</div>
<div>
<label className="block text-xs font-semibold text-slate-400 mb-1 uppercase tracking-wide">
Mot de passe
</label>
<input
type="password"
value={password}
onChange={e => setPassword(e.target.value)}
required
autoComplete="current-password"
className="w-full bg-slate-800 border border-slate-700 rounded-lg px-3 py-2.5 text-sm text-white placeholder-slate-500 focus:outline-none focus:border-indigo-500 transition-colors"
placeholder="••••••••"
/>
</div>
<button
type="submit"
disabled={loading}
className="w-full bg-indigo-600 hover:bg-indigo-700 disabled:opacity-50 text-white font-semibold py-2.5 rounded-lg text-sm transition-colors"
>
{loading ? 'Connexion…' : 'Se connecter'}
</button>
</form>
</div>
</div>
)
}
+61
View File
@@ -0,0 +1,61 @@
'use client'
import { useState } from 'react'
import { Header } from '@/components/layout/Header'
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 { useImages } from '@/hooks/useImages'
import type { ImageItem } from '@/types/images'
export default function ImagesPage() {
const { items, addFiles, setFeather, rotate, uploadOne, uploadAll, removeItem } = useImages()
const [lightboxItem, setLightboxItem] = useState<ImageItem | null>(null)
return (
<>
<Header
title="🖼️ Images"
actions={
<span className="text-xs text-slate-500">
{items.length > 0 ? `${items.length} image${items.length > 1 ? 's' : ''}` : ''}
</span>
}
/>
<div className="p-6 flex flex-col gap-4 flex-1 overflow-y-auto">
<DropZone onFiles={addFiles} />
{items.length > 0 && (
<GlobalActions items={items} onUploadAll={uploadAll} />
)}
{items.length > 0 && (
<div className="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-4 xl:grid-cols-5 gap-4">
{items.map((item) => (
<ImageCard
key={item.id}
item={item}
onFeatherChange={setFeather}
onRotate={rotate}
onUpload={uploadOne}
onRemove={removeItem}
onOpenLightbox={setLightboxItem}
/>
))}
</div>
)}
{items.length === 0 && (
<div className="text-center py-12 text-slate-600">
<p className="text-4xl mb-3">📂</p>
<p className="text-sm">Déposez des images pour commencer</p>
<p className="text-xs mt-1">Le nom du fichier (sans extension) doit correspondre à une référence produit Shopify</p>
</div>
)}
</div>
<Lightbox item={lightboxItem} onClose={() => setLightboxItem(null)} />
</>
)
}
+10
View File
@@ -0,0 +1,10 @@
import { Sidebar } from '@/components/layout/Sidebar'
export default function DashboardLayout({ children }: { children: React.ReactNode }) {
return (
<div className="flex min-h-screen bg-gray-50">
<Sidebar />
<main className="flex-1 flex flex-col overflow-hidden">{children}</main>
</div>
)
}
+9
View File
@@ -0,0 +1,9 @@
import { Header } from '@/components/layout/Header'
export default function DashboardPage() {
return (
<>
<Header title="📊 Dashboard" />
<div className="p-6"><p className="text-gray-400 text-sm">Dashboard à implémenter (Plan 4)</p></div>
</>
)
}
+9
View File
@@ -0,0 +1,9 @@
import { Header } from '@/components/layout/Header'
export default function ParametresPage() {
return (
<>
<Header title="⚙️ Paramètres" />
<div className="p-6"><p className="text-gray-400 text-sm">Module Paramètres à implémenter (Plan 4)</p></div>
</>
)
}
+9
View File
@@ -0,0 +1,9 @@
import { Header } from '@/components/layout/Header'
export default function ProduitsPage() {
return (
<>
<Header title="📦 Produits" />
<div className="p-6"><p className="text-gray-400 text-sm">Module Produits à implémenter (Plan 4)</p></div>
</>
)
}
+9
View File
@@ -0,0 +1,9 @@
import { Header } from '@/components/layout/Header'
export default function SyncPage() {
return (
<>
<Header title="🔄 Sync Sheets" />
<div className="p-6"><p className="text-gray-400 text-sm">Module Sync à implémenter (Plan 3)</p></div>
</>
)
}
+5
View File
@@ -0,0 +1,5 @@
import NextAuth from 'next-auth'
import { authOptions } from '@/lib/auth'
const handler = NextAuth(authOptions)
export { handler as GET, handler as POST }
+31
View File
@@ -0,0 +1,31 @@
import { NextRequest, NextResponse } from 'next/server'
import { uploadProductImage } from '@/lib/shopify'
export async function POST(req: NextRequest) {
let body: Record<string, unknown>
try {
body = await req.json()
} catch {
return NextResponse.json({ error: 'Corps JSON invalide' }, { status: 400 })
}
const { shopifyProductId, imageBase64, filename } = body
if (!shopifyProductId || typeof shopifyProductId !== 'string') {
return NextResponse.json({ error: 'shopifyProductId requis' }, { status: 400 })
}
if (!imageBase64 || typeof imageBase64 !== 'string') {
return NextResponse.json({ error: 'imageBase64 requis' }, { status: 400 })
}
if (!filename || typeof filename !== 'string') {
return NextResponse.json({ error: 'filename requis' }, { status: 400 })
}
try {
const result = await uploadProductImage(shopifyProductId, imageBase64, filename)
return NextResponse.json(result)
} catch (err) {
const message = err instanceof Error ? err.message : 'Erreur inconnue'
return NextResponse.json({ error: message }, { status: 500 })
}
}
+18
View File
@@ -0,0 +1,18 @@
import { NextRequest, NextResponse } from 'next/server'
import { searchProductByRef } from '@/lib/shopify'
export async function GET(req: NextRequest) {
const ref = req.nextUrl.searchParams.get('ref')
if (!ref || ref.trim() === '') {
return NextResponse.json({ error: 'Paramètre ref requis' }, { status: 400 })
}
try {
const result = await searchProductByRef(ref.trim())
return NextResponse.json(result)
} catch (err) {
const message = err instanceof Error ? err.message : 'Erreur inconnue'
return NextResponse.json({ error: message }, { status: 500 })
}
}
+4 -1
View File
@@ -1,6 +1,7 @@
import type { Metadata } from 'next'
import { Inter } from 'next/font/google'
import './globals.css'
import { SessionProvider } from '@/components/providers/SessionProvider'
const inter = Inter({ subsets: ['latin'] })
@@ -12,7 +13,9 @@ export const metadata: Metadata = {
export default function RootLayout({ children }: { children: React.ReactNode }) {
return (
<html lang="fr">
<body className={inter.className}>{children}</body>
<body className={inter.className}>
<SessionProvider>{children}</SessionProvider>
</body>
</html>
)
}
-101
View File
@@ -1,101 +0,0 @@
import Image from "next/image";
export default function Home() {
return (
<div className="grid grid-rows-[20px_1fr_20px] items-center justify-items-center min-h-screen p-8 pb-20 gap-16 sm:p-20 font-[family-name:var(--font-geist-sans)]">
<main className="flex flex-col gap-8 row-start-2 items-center sm:items-start">
<Image
className="dark:invert"
src="https://nextjs.org/icons/next.svg"
alt="Next.js logo"
width={180}
height={38}
priority
/>
<ol className="list-inside list-decimal text-sm text-center sm:text-left font-[family-name:var(--font-geist-mono)]">
<li className="mb-2">
Get started by editing{" "}
<code className="bg-black/[.05] dark:bg-white/[.06] px-1 py-0.5 rounded font-semibold">
src/app/page.tsx
</code>
.
</li>
<li>Save and see your changes instantly.</li>
</ol>
<div className="flex gap-4 items-center flex-col sm:flex-row">
<a
className="rounded-full border border-solid border-transparent transition-colors flex items-center justify-center bg-foreground text-background gap-2 hover:bg-[#383838] dark:hover:bg-[#ccc] text-sm sm:text-base h-10 sm:h-12 px-4 sm:px-5"
href="https://vercel.com/new?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
target="_blank"
rel="noopener noreferrer"
>
<Image
className="dark:invert"
src="https://nextjs.org/icons/vercel.svg"
alt="Vercel logomark"
width={20}
height={20}
/>
Deploy now
</a>
<a
className="rounded-full border border-solid border-black/[.08] dark:border-white/[.145] transition-colors flex items-center justify-center hover:bg-[#f2f2f2] dark:hover:bg-[#1a1a1a] hover:border-transparent text-sm sm:text-base h-10 sm:h-12 px-4 sm:px-5 sm:min-w-44"
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>
</main>
<footer className="row-start-3 flex gap-6 flex-wrap items-center justify-center">
<a
className="flex items-center gap-2 hover:underline hover:underline-offset-4"
href="https://nextjs.org/learn?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
target="_blank"
rel="noopener noreferrer"
>
<Image
aria-hidden
src="https://nextjs.org/icons/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="https://nextjs.org/icons/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="https://nextjs.org/icons/globe.svg"
alt="Globe icon"
width={16}
height={16}
/>
Go to nextjs.org
</a>
</footer>
</div>
);
}
+73
View File
@@ -0,0 +1,73 @@
'use client'
import { useCallback, useRef, useState } from 'react'
interface DropZoneProps {
onFiles: (files: File[]) => void
disabled?: boolean
}
const ACCEPTED_TYPES = ['.heic', '.heif', '.jpg', '.jpeg', '.png', '.zip']
const ACCEPT_ATTR = ACCEPTED_TYPES.join(',')
export function DropZone({ onFiles, disabled = false }: DropZoneProps) {
const [isDragging, setIsDragging] = useState(false)
const inputRef = useRef<HTMLInputElement>(null)
const handleFiles = useCallback(
(fileList: FileList | null) => {
if (!fileList) return
const files = Array.from(fileList).filter((f) => {
const ext = '.' + f.name.split('.').pop()?.toLowerCase()
return ACCEPTED_TYPES.includes(ext)
})
if (files.length > 0) onFiles(files)
},
[onFiles],
)
const onDragOver = (e: React.DragEvent) => {
e.preventDefault()
if (!disabled) setIsDragging(true)
}
const onDragLeave = () => setIsDragging(false)
const onDrop = (e: React.DragEvent) => {
e.preventDefault()
setIsDragging(false)
if (!disabled) handleFiles(e.dataTransfer.files)
}
return (
<div
onDragOver={onDragOver}
onDragLeave={onDragLeave}
onDrop={onDrop}
onClick={() => !disabled && inputRef.current?.click()}
className={`border-2 border-dashed rounded-xl p-8 text-center cursor-pointer transition-colors ${
isDragging
? 'border-indigo-400 bg-indigo-950/30'
: disabled
? 'border-slate-700 bg-slate-800/20 cursor-not-allowed'
: 'border-slate-600 bg-slate-800/10 hover:border-indigo-500 hover:bg-indigo-950/20'
}`}
>
<div className="text-3xl mb-2">🖼</div>
<p className="text-sm font-semibold text-slate-300">
Déposez vos images ici ou cliquez pour parcourir
</p>
<p className="text-xs text-slate-500 mt-1">
HEIC, JPG, PNG, ZIP le nom du fichier = référence produit
</p>
<input
ref={inputRef}
type="file"
multiple
accept={ACCEPT_ATTR}
className="hidden"
onChange={(e) => handleFiles(e.target.files)}
disabled={disabled}
/>
</div>
)
}
+37
View File
@@ -0,0 +1,37 @@
'use client'
import type { ImageItem } from '@/types/images'
interface GlobalActionsProps {
items: ImageItem[]
onUploadAll: () => void
}
export function GlobalActions({ items, onUploadAll }: GlobalActionsProps) {
if (items.length === 0) return null
const ready = items.filter((i) => i.status === 'ready').length
const processing = items.filter((i) => ['pending', 'converting', 'processing', 'searching', 'uploading'].includes(i.status)).length
const notFound = items.filter((i) => i.status === 'not_found').length
const uploaded = items.filter((i) => i.status === 'uploaded').length
const errors = items.filter((i) => i.status === 'error').length
return (
<div className="bg-slate-900 border border-slate-700 rounded-xl px-4 py-3 flex items-center gap-6 flex-wrap">
<div className="flex gap-4 text-xs text-slate-400 flex-1 flex-wrap">
{processing > 0 && <span> {processing} en traitement</span>}
{ready > 0 && <span className="text-green-400 font-semibold"> {ready} prêtes</span>}
{notFound > 0 && <span className="text-red-400"> {notFound} introuvables</span>}
{errors > 0 && <span className="text-red-400"> {errors} erreurs</span>}
{uploaded > 0 && <span className="text-slate-500"> {uploaded} uploadées</span>}
</div>
{ready > 0 && (
<button
onClick={onUploadAll}
className="bg-indigo-600 hover:bg-indigo-500 text-white text-sm font-semibold px-4 py-2 rounded-lg transition-colors"
>
Tout uploader ({ready})
</button>
)}
</div>
)
}
+146
View File
@@ -0,0 +1,146 @@
'use client'
import { useEffect, useState } from 'react'
import type { ImageItem } from '@/types/images'
interface ImageCardProps {
item: ImageItem
onFeatherChange: (id: string, value: number) => void
onRotate: (id: string, direction: 'cw' | 'ccw') => void
onUpload: (id: string) => void
onRemove: (id: string) => void
onOpenLightbox: (item: ImageItem) => void
}
const STATUS_LABELS: Record<string, { label: string; color: string }> = {
pending: { label: '⏳ En attente', color: 'text-slate-400' },
converting: { label: '🔄 Conversion HEIC', color: 'text-yellow-400' },
processing: { label: '🤖 Détourage IA', color: 'text-blue-400' },
searching: { label: '🔍 Recherche…', color: 'text-purple-400' },
ready: { label: '✓ Prêt', color: 'text-green-400' },
uploading: { label: '⬆️ Upload…', color: 'text-blue-400' },
uploaded: { label: '✅ Uploadé', color: 'text-green-500' },
not_found: { label: '❌ Réf. introuvable', color: 'text-red-400' },
error: { label: '❌ Erreur', color: 'text-red-400' },
}
function BlobThumbnail({ blob, alt }: { blob: Blob; alt: string }) {
const [url, setUrl] = useState<string | null>(null)
useEffect(() => {
const objectUrl = URL.createObjectURL(blob)
setUrl(objectUrl)
return () => URL.revokeObjectURL(objectUrl)
}, [blob])
if (!url) return <div className="w-full h-full bg-slate-700 animate-pulse" />
return <img src={url} alt={alt} className="w-full h-full object-contain" />
}
export function ImageCard({
item,
onFeatherChange,
onRotate,
onUpload,
onRemove,
onOpenLightbox,
}: ImageCardProps) {
const statusInfo = STATUS_LABELS[item.status] ?? { label: item.status, color: 'text-slate-400' }
const isProcessing = ['pending', 'converting', 'processing', 'searching', 'uploading'].includes(item.status)
const canAdjust = !!item.rawPngBlob && !isProcessing
return (
<div className="bg-slate-900 border border-slate-700 rounded-xl overflow-hidden flex flex-col">
{/* Miniatures avant / après */}
<div
className="relative h-40 bg-slate-800 cursor-pointer"
onClick={() => onOpenLightbox(item)}
>
<div className="absolute inset-0">
<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">
<BlobThumbnail blob={item.processedBlob} alt="Traité" />
</div>
)}
{/* Barre de progression pendant l'IA */}
{item.status === 'processing' && item.progress && (
<div className="absolute bottom-0 inset-x-0 bg-black/60 px-2 py-1">
<div className="h-1 bg-slate-700 rounded">
<div
className="h-1 bg-indigo-500 rounded transition-all"
style={{ width: `${item.progress.percent}%` }}
/>
</div>
<p className="text-xs text-slate-400 mt-0.5">{item.progress.key} {item.progress.percent}%</p>
</div>
)}
</div>
{/* Infos */}
<div className="p-3 flex flex-col gap-2 flex-1">
<div>
<p className="text-xs font-mono text-slate-300 truncate">{item.ref}</p>
{item.shopifyProductTitle && (
<p className="text-xs text-slate-500 truncate">{item.shopifyProductTitle}</p>
)}
<p className={`text-xs font-semibold mt-0.5 ${statusInfo.color}`}>{statusInfo.label}</p>
{item.error && <p className="text-xs text-red-400 mt-0.5 truncate" title={item.error}>{item.error}</p>}
</div>
{/* Contrôles feather + rotation */}
<div className={canAdjust ? '' : 'opacity-40 pointer-events-none'}>
<label className="block text-xs text-slate-500 mb-1">
Bords : {item.feather} px
</label>
<input
type="range"
min={0}
max={5}
step={1}
value={item.feather}
onChange={(e) => onFeatherChange(item.id, Number(e.target.value))}
className="w-full accent-indigo-500"
/>
<div className="flex gap-1 mt-2">
<button
onClick={() => onRotate(item.id, 'ccw')}
className="flex-1 py-1 text-xs bg-slate-800 hover:bg-slate-700 rounded text-slate-300"
> -90°</button>
<button
onClick={() => onRotate(item.id, 'cw')}
className="flex-1 py-1 text-xs bg-slate-800 hover:bg-slate-700 rounded text-slate-300"
> +90°</button>
</div>
</div>
{/* Boutons d'action */}
<div className="flex gap-2 mt-auto pt-1">
{item.status === 'ready' && (
<button
onClick={() => onUpload(item.id)}
className="flex-1 py-1.5 text-xs bg-indigo-600 hover:bg-indigo-500 text-white font-semibold rounded"
>
Uploader
</button>
)}
{item.status === 'uploaded' && item.uploadedUrl && (
<a
href={item.uploadedUrl}
target="_blank"
rel="noopener noreferrer"
className="flex-1 py-1.5 text-xs bg-green-800 hover:bg-green-700 text-white font-semibold rounded text-center"
>
Voir sur Shopify
</a>
)}
<button
onClick={() => onRemove(item.id)}
className="py-1.5 px-2 text-xs bg-slate-800 hover:bg-red-900 text-slate-400 hover:text-red-300 rounded"
></button>
</div>
</div>
</div>
)
}
+70
View File
@@ -0,0 +1,70 @@
'use client'
import { useEffect, useState } from 'react'
import type { ImageItem } from '@/types/images'
interface LightboxProps {
item: ImageItem | null
onClose: () => void
}
function BlobImg({ blob, alt }: { blob: Blob; alt: string }) {
const [url, setUrl] = useState<string | null>(null)
useEffect(() => {
const u = URL.createObjectURL(blob)
setUrl(u)
return () => URL.revokeObjectURL(u)
}, [blob])
if (!url) return null
return <img src={url} alt={alt} className="max-h-full max-w-full object-contain" />
}
export function Lightbox({ item, onClose }: LightboxProps) {
useEffect(() => {
const onKey = (e: KeyboardEvent) => { if (e.key === 'Escape') onClose() }
document.addEventListener('keydown', onKey)
return () => document.removeEventListener('keydown', onKey)
}, [onClose])
if (!item) return null
return (
<div
className="fixed inset-0 z-50 bg-black/90 flex items-center justify-center p-4"
onClick={onClose}
>
<div
className="bg-slate-900 rounded-xl max-w-5xl w-full max-h-[90vh] overflow-hidden flex flex-col"
onClick={(e) => e.stopPropagation()}
>
<div className="flex items-center justify-between px-4 py-3 border-b border-slate-700">
<div>
<span className="text-sm font-mono text-slate-300">{item.ref}</span>
{item.shopifyProductTitle && (
<span className="text-xs text-slate-500 ml-2">{item.shopifyProductTitle}</span>
)}
</div>
<button
onClick={onClose}
className="text-slate-400 hover:text-white text-xl leading-none"
></button>
</div>
<div className="flex flex-1 overflow-hidden min-h-0">
<div className="flex-1 flex flex-col items-center justify-center p-4 bg-slate-950 border-r border-slate-700">
<p className="text-xs text-slate-500 mb-2">Avant</p>
<BlobImg blob={item.originalBlob} alt="Avant" />
</div>
<div className="flex-1 flex flex-col items-center justify-center p-4 bg-white/5">
<p className="text-xs text-slate-500 mb-2">Après</p>
{item.processedBlob
? <BlobImg blob={item.processedBlob} alt="Après" />
: <p className="text-slate-600 text-sm">Traitement en cours</p>
}
</div>
</div>
</div>
</div>
)
}
+13
View File
@@ -0,0 +1,13 @@
interface HeaderProps {
title: string
actions?: React.ReactNode
}
export function Header({ title, actions }: HeaderProps) {
return (
<div className="bg-white border-b border-gray-200 px-6 py-4 flex items-center justify-between">
<h1 className="text-base font-bold text-gray-900">{title}</h1>
{actions && <div className="flex items-center gap-3">{actions}</div>}
</div>
)
}
+72
View File
@@ -0,0 +1,72 @@
'use client'
import Link from 'next/link'
import { usePathname } from 'next/navigation'
import { signOut, useSession } from 'next-auth/react'
type NavItem = {
href: string
label: string
icon: string
adminOnly?: boolean
}
const NAV_ITEMS: NavItem[] = [
{ href: '/', label: 'Dashboard', icon: '📊' },
{ href: '/produits', label: 'Produits', icon: '📦' },
{ href: '/images', label: 'Images', icon: '🖼️' },
{ href: '/sync', label: 'Sync Sheets', icon: '🔄' },
{ href: '/parametres', label: 'Paramètres', icon: '⚙️', adminOnly: true },
]
export function Sidebar() {
const pathname = usePathname()
const { data: session } = useSession()
const isAdmin = session?.user?.role === 'admin'
const visible = NAV_ITEMS.filter(item => !item.adminOnly || isAdmin)
return (
<aside className="w-48 bg-slate-900 flex flex-col h-screen sticky top-0 border-r border-slate-800">
{/* Logo */}
<div className="px-4 py-4 border-b border-slate-800">
<span className="text-sm font-extrabold text-white tracking-tight">
Matériaux<span className="text-indigo-400">Destock</span>
</span>
</div>
{/* Navigation */}
<nav className="flex-1 py-3 overflow-y-auto">
{visible.map(item => {
const isActive =
item.href === '/' ? pathname === '/' : pathname.startsWith(item.href)
return (
<Link
key={item.href}
href={item.href}
className={`flex items-center gap-2.5 px-4 py-2 text-xs border-l-2 transition-colors ${
isActive
? 'bg-slate-800 text-white border-indigo-500 font-semibold'
: 'text-slate-400 border-transparent hover:text-slate-200 hover:bg-slate-800/50'
}`}
>
<span className="text-sm">{item.icon}</span>
{item.label}
</Link>
)
})}
</nav>
{/* User */}
<div className="px-4 py-3 border-t border-slate-800">
<p className="text-xs text-slate-400 truncate mb-1">{session?.user?.name}</p>
<p className="text-xs text-slate-600 truncate mb-2">{session?.user?.email}</p>
<button
onClick={() => signOut({ callbackUrl: '/login' })}
className="text-xs text-slate-500 hover:text-slate-300 transition-colors"
>
Déconnexion
</button>
</div>
</aside>
)
}
@@ -0,0 +1,6 @@
'use client'
import { SessionProvider as NextAuthSessionProvider } from 'next-auth/react'
export function SessionProvider({ children }: { children: React.ReactNode }) {
return <NextAuthSessionProvider>{children}</NextAuthSessionProvider>
}
+246
View File
@@ -0,0 +1,246 @@
'use client'
import { useReducer, useCallback } from 'react'
import type { ImageItem, ImageStatus } from '@/types/images'
// ── Reducer ───────────────────────────────────────────────────────────────────
type Action =
| { type: 'ADD'; items: ImageItem[] }
| { type: 'UPDATE'; id: string; patch: Partial<ImageItem> }
| { type: 'REMOVE'; id: string }
function reducer(state: ImageItem[], action: Action): ImageItem[] {
switch (action.type) {
case 'ADD':
return [...state, ...action.items]
case 'UPDATE':
return state.map((item) =>
item.id === action.id ? { ...item, ...action.patch } : item,
)
case 'REMOVE':
return state.filter((item) => item.id !== action.id)
}
}
// ── Helpers ───────────────────────────────────────────────────────────────────
function refFromFilename(filename: string): string {
const dot = filename.lastIndexOf('.')
return dot === -1 ? filename : filename.slice(0, dot)
}
function blobToBase64(blob: Blob): Promise<string> {
return new Promise((resolve, reject) => {
const reader = new FileReader()
reader.onload = () => {
const result = reader.result as string
// Supprimer le préfixe "data:image/jpeg;base64,"
const base64 = result.split(',')[1]
resolve(base64)
}
reader.onerror = () => reject(new Error('FileReader error'))
reader.readAsDataURL(blob)
})
}
// ── Hook ──────────────────────────────────────────────────────────────────────
export function useImages() {
const [items, dispatch] = useReducer(reducer, [])
const update = useCallback((id: string, patch: Partial<ImageItem>) => {
dispatch({ type: 'UPDATE', id, patch })
}, [])
// Recherche produit Shopify
const searchProduct = useCallback(
async (id: string, ref: string) => {
update(id, { status: 'searching' })
try {
const res = await fetch(`/api/products/search?ref=${encodeURIComponent(ref)}`)
const data = await res.json()
if (data.found) {
update(id, {
status: 'ready',
shopifyProductId: data.shopifyId,
shopifyProductTitle: data.title,
})
} else {
update(id, { status: 'not_found' })
}
} catch (err) {
update(id, { status: 'error', error: `Recherche produit : ${(err as Error).message}` })
}
},
[update],
)
// Pipeline complet pour un item (avec ref)
const runPipeline = useCallback(
async (id: string, blob: Blob, ref: string, feather: number, rotation: number) => {
const { convertHeicToJpeg } = await import('@/lib/client/heicConverter')
const { removeBackgroundRaw, compositeOnWhite } = await import('@/lib/client/bgRemover')
const { rotateBlob } = await import('@/lib/client/imageRotator')
let jpegBlob = blob
if (blob.type === 'image/heic' || blob.type === 'image/heif' || blob.type === '') {
try {
update(id, { status: 'converting' })
jpegBlob = await convertHeicToJpeg(blob)
} catch (err) {
update(id, { status: 'error', error: `Conversion HEIC : ${(err as Error).message}` })
return
}
}
let rawPng: Blob
try {
update(id, { status: 'processing', progress: null })
rawPng = await removeBackgroundRaw(jpegBlob, (p) => update(id, { progress: p }))
} catch (err) {
update(id, { status: 'error', error: `Détourage IA : ${(err as Error).message}` })
return
}
let finalBlob: Blob
try {
const composited = await compositeOnWhite(rawPng, feather)
finalBlob = await rotateBlob(composited, rotation)
} catch (err) {
update(id, { status: 'error', error: `Post-traitement : ${(err as Error).message}` })
return
}
update(id, { rawPngBlob: rawPng, processedBlob: finalBlob })
await searchProduct(id, ref)
},
[update, searchProduct],
)
// Recomposite rapide (feather ou rotation changés) — ne relance pas l'IA
const recomposite = useCallback(
async (id: string, rawPngBlob: Blob, feather: number, rotation: number) => {
const { compositeOnWhite } = await import('@/lib/client/bgRemover')
const { rotateBlob } = await import('@/lib/client/imageRotator')
try {
const composited = await compositeOnWhite(rawPngBlob, feather)
const finalBlob = await rotateBlob(composited, rotation)
update(id, { processedBlob: finalBlob, feather, rotation })
} catch (err) {
update(id, { status: 'error', error: `Retraitement : ${(err as Error).message}` })
}
},
[update],
)
// ── Actions exposées ───────────────────────────────────────────────────────
const addFiles = useCallback(
async (files: File[]) => {
const { readImagesFromZip } = await import('@/lib/client/zipImporter')
for (const file of files) {
if (file.name.toLowerCase().endsWith('.zip')) {
const entries = await readImagesFromZip(file)
const newItems: ImageItem[] = entries.map((entry) => ({
id: crypto.randomUUID(),
filename: `${entry.folder}/${entry.filename}.${entry.ext}`,
ref: entry.folder,
originalBlob: entry.blob,
rawPngBlob: null,
processedBlob: null,
rotation: 0,
feather: 2,
shopifyProductId: null,
shopifyProductTitle: null,
uploadedUrl: null,
status: 'pending' as ImageStatus,
error: null,
progress: null,
}))
dispatch({ type: 'ADD', items: newItems })
for (const item of newItems) {
runPipeline(item.id, item.originalBlob, item.ref, item.feather, item.rotation)
}
} else {
const ref = refFromFilename(file.name)
const newItem: ImageItem = {
id: crypto.randomUUID(),
filename: file.name,
ref,
originalBlob: file,
rawPngBlob: null,
processedBlob: null,
rotation: 0,
feather: 2,
shopifyProductId: null,
shopifyProductTitle: null,
uploadedUrl: null,
status: 'pending',
error: null,
progress: null,
}
dispatch({ type: 'ADD', items: [newItem] })
runPipeline(newItem.id, newItem.originalBlob, newItem.ref, newItem.feather, newItem.rotation)
}
}
},
[runPipeline],
)
const setFeather = useCallback(
(id: string, value: number) => {
const item = items.find((i) => i.id === id)
if (!item || !item.rawPngBlob) return
recomposite(id, item.rawPngBlob, value, item.rotation)
},
[items, recomposite],
)
const rotate = useCallback(
(id: string, direction: 'cw' | 'ccw') => {
const item = items.find((i) => i.id === id)
if (!item || !item.rawPngBlob) return
const delta = direction === 'cw' ? 90 : -90
const newRotation = ((item.rotation + delta) % 360 + 360) % 360
recomposite(id, item.rawPngBlob, item.feather, newRotation)
},
[items, recomposite],
)
const uploadOne = useCallback(
async (id: string) => {
const item = items.find((i) => i.id === id)
if (!item || item.status !== 'ready' || !item.processedBlob || !item.shopifyProductId) return
update(id, { status: 'uploading' })
try {
const base64 = await blobToBase64(item.processedBlob)
const filename = `${item.ref}.jpg`
const res = await fetch('/api/images/upload', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ shopifyProductId: item.shopifyProductId, imageBase64: base64, filename }),
})
const data = await res.json()
if (!res.ok) throw new Error(data.error || `HTTP ${res.status}`)
update(id, { status: 'uploaded', uploadedUrl: data.url })
} catch (err) {
update(id, { status: 'error', error: `Upload : ${(err as Error).message}` })
}
},
[items, update],
)
const uploadAll = useCallback(() => {
items.filter((i) => i.status === 'ready').forEach((i) => uploadOne(i.id))
}, [items, uploadOne])
const removeItem = useCallback((id: string) => {
dispatch({ type: 'REMOVE', id })
}, [])
return { items, addFiles, setFeather, rotate, uploadOne, uploadAll, removeItem }
}
+70
View File
@@ -0,0 +1,70 @@
import type { NextAuthOptions } from 'next-auth'
import CredentialsProvider from 'next-auth/providers/credentials'
import { compare } from 'bcryptjs'
import { prisma } from './prisma'
import { checkRateLimit, recordFailedAttempt, clearAttempts } from './rate-limit'
export const authOptions: NextAuthOptions = {
session: {
strategy: 'jwt',
maxAge: 8 * 60 * 60, // 8 heures
},
pages: {
signIn: '/login',
},
callbacks: {
async jwt({ token, user }) {
if (user) {
// NextAuth v4 : les types de base n'incluent pas les champs custom.
// Le module augmentation dans next-auth.d.ts couvre le type Session/JWT
// mais pas le paramètre `user` du callback jwt. Cast nécessaire.
const u = user as typeof user & { role: string }
token.role = u.role
token.id = u.id
}
return token
},
async session({ session, token }) {
if (session.user) {
// session.user est déjà augmenté via next-auth.d.ts (id + role)
session.user.id = token.id as string
session.user.role = token.role as string
}
return session
},
},
providers: [
CredentialsProvider({
name: 'Credentials',
credentials: {
email: { label: 'Email', type: 'email' },
password: { label: 'Mot de passe', type: 'password' },
},
async authorize(credentials, req) {
if (!credentials?.email || !credentials?.password) return null
// NextAuth v4 : req est un RequestInternal partiel sans typings complets.
// Le cast est nécessaire pour accéder à x-forwarded-for.
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const ip = (req as any)?.headers?.['x-forwarded-for']?.split(',')[0]?.trim() ?? 'unknown'
const key = `${ip}:${credentials.email}`
if (!checkRateLimit(key)) {
throw new Error('RATE_LIMITED')
}
const user = await prisma.user.findUnique({
where: { email: credentials.email },
})
if (!user || !(await compare(credentials.password, user.password))) {
recordFailedAttempt(key)
return null
}
clearAttempts(key)
return { id: user.id, email: user.email, name: user.name, role: user.role }
},
}),
],
}
+89
View File
@@ -0,0 +1,89 @@
const CDN_VERSION = '1.7.0'
const CDN_URL = `https://cdn.jsdelivr.net/npm/@imgly/background-removal@${CDN_VERSION}/dist/index.mjs`
let _removeBg: ((blob: Blob, opts: Record<string, unknown>) => Promise<Blob>) | null = null
async function loadLib() {
if (_removeBg) return _removeBg
const mod = await import(/* webpackIgnore: true */ CDN_URL)
_removeBg = mod.removeBackground
return _removeBg!
}
export interface ProgressEvent {
key: string
percent: number
}
/**
* Supprime le fond d'un Blob image via @imgly IA.
* Retourne un PNG avec fond transparent (à combiner avec compositeOnWhite).
*/
export async function removeBackgroundRaw(
blob: Blob,
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) => {
if (onProgress && total > 0) {
onProgress({ key, percent: Math.round((current / total) * 100) })
}
},
})
}
/**
* Composite un PNG transparent sur fond blanc avec feather optionnel.
* @param pngBlob — PNG avec transparence (résultat de removeBackgroundRaw)
* @param feather — récupération des bords en px (0 = strict, 5 = doux)
*/
export function compositeOnWhite(pngBlob: Blob, feather: number): Promise<Blob> {
const objectUrl = URL.createObjectURL(pngBlob)
return new Promise((resolve, reject) => {
const img = new Image()
img.onload = () => {
const { width, height } = img
const fgCanvas = document.createElement('canvas')
fgCanvas.width = width
fgCanvas.height = height
const fgCtx = fgCanvas.getContext('2d')!
fgCtx.drawImage(img, 0, 0)
if (feather > 0) {
fgCtx.globalCompositeOperation = 'destination-over'
fgCtx.filter = `blur(${feather}px)`
fgCtx.drawImage(img, 0, 0)
fgCtx.filter = 'none'
fgCtx.globalCompositeOperation = 'source-over'
}
const out = document.createElement('canvas')
out.width = width
out.height = height
const ctx = out.getContext('2d')!
ctx.fillStyle = '#ffffff'
ctx.fillRect(0, 0, width, height)
ctx.drawImage(fgCanvas, 0, 0)
URL.revokeObjectURL(objectUrl)
out.toBlob(
(b) => (b ? resolve(b) : reject(new Error('canvas.toBlob a retourné null'))),
'image/jpeg',
0.92,
)
}
img.onerror = () => {
URL.revokeObjectURL(objectUrl)
reject(new Error('Impossible de charger le PNG'))
}
img.src = objectUrl
})
}
+31
View File
@@ -0,0 +1,31 @@
/**
* Convertit un Blob HEIC/HEIF en Blob JPEG via heic2any.
* À 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')
}
const type = blob.type.toLowerCase()
if (type !== '' && !['image/heic', 'image/heif'].includes(type)) {
throw new TypeError(`Format attendu : HEIC/HEIF. Reçu : ${blob.type || '(vide)'}`)
}
const heic2any = (await import('heic2any')).default
const timeout = new Promise<never>((_, reject) =>
setTimeout(() => reject(new Error('Timeout conversion HEIC (120s)')), 120_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
if (!(output instanceof Blob)) {
throw new Error("heic2any n'a pas retourné un Blob")
}
return output
}
+44
View File
@@ -0,0 +1,44 @@
/**
* Pivote un Blob JPEG via Canvas.
* @param blob — image source (JPEG)
* @param degrees — angle en degrés (0, 90, 180, 270)
* @returns Blob JPEG pivoté, ou le blob original si degrees === 0
*/
export function rotateBlob(blob: Blob, degrees: number): Promise<Blob> {
const norm = ((degrees % 360) + 360) % 360
if (norm === 0) return Promise.resolve(blob)
const url = URL.createObjectURL(blob)
return new Promise((resolve, reject) => {
const img = new Image()
img.onload = () => {
const swap = norm === 90 || norm === 270
const w = swap ? img.naturalHeight : img.naturalWidth
const h = swap ? img.naturalWidth : img.naturalHeight
const canvas = document.createElement('canvas')
canvas.width = w
canvas.height = h
const ctx = canvas.getContext('2d')!
ctx.translate(w / 2, h / 2)
ctx.rotate((norm * Math.PI) / 180)
ctx.drawImage(img, -img.naturalWidth / 2, -img.naturalHeight / 2)
URL.revokeObjectURL(url)
canvas.toBlob(
(b) => (b ? resolve(b) : reject(new Error('rotateBlob : toBlob a retourné null'))),
'image/jpeg',
0.92,
)
}
img.onerror = () => {
URL.revokeObjectURL(url)
reject(new Error('rotateBlob : impossible de charger le blob'))
}
img.src = url
})
}
+47
View File
@@ -0,0 +1,47 @@
import JSZip from 'jszip'
import type { ZipEntry } from '@/types/images'
const IMAGE_EXTS = new Set(['heic', 'heif', 'jpg', 'jpeg', 'png'])
/**
* Extrait toutes les images d'un fichier ZIP.
* Structure attendue : dossier-ref/image.ext (le nom du dossier = référence produit).
*/
export async function readImagesFromZip(file: File): Promise<ZipEntry[]> {
const zip = await JSZip.loadAsync(file)
const entries: Array<{ relativePath: string; entry: JSZip.JSZipObject }> = []
zip.forEach((relativePath, entry) => {
if (!entry.dir) entries.push({ relativePath, entry })
})
const results: ZipEntry[] = []
for (const { relativePath, entry } of entries) {
if (relativePath.startsWith('__MACOSX/')) continue
const parts = relativePath.split('/').filter((p) => p.length > 0)
const filename = parts[parts.length - 1]
if (filename.startsWith('.')) continue
const dotIdx = filename.lastIndexOf('.')
if (dotIdx === -1) continue
const ext = filename.slice(dotIdx + 1).toLowerCase()
if (!IMAGE_EXTS.has(ext)) continue
const folder = parts.length > 1 ? parts[0] : filename.slice(0, dotIdx)
const isHeic = ext === 'heic' || ext === 'heif'
const mimeType = isHeic
? 'image/heic'
: ext === 'jpg' || ext === 'jpeg'
? 'image/jpeg'
: 'image/png'
const rawBlob = await entry.async('blob')
const blob = new Blob([rawBlob], { type: mimeType })
results.push({ folder, filename: filename.slice(0, dotIdx), ext, blob, isHeic })
}
return results
}
+23
View File
@@ -0,0 +1,23 @@
import { PrismaClient } from '@prisma/client'
import { PrismaBetterSqlite3 } from '@prisma/adapter-better-sqlite3'
import path from 'path'
function createPrismaClient() {
const dbUrl = process.env.DATABASE_URL ?? 'file:./data/dev.db'
// Parse the file path from "file:./data/dev.db" format
const dbPath = dbUrl.startsWith('file:')
? path.resolve(process.cwd(), dbUrl.slice('file:'.length))
: dbUrl
const adapter = new PrismaBetterSqlite3({ url: dbPath })
return new PrismaClient({
adapter,
log: process.env.NODE_ENV === 'development' ? ['error'] : [],
})
}
const globalForPrisma = globalThis as unknown as { prisma: PrismaClient }
export const prisma = globalForPrisma.prisma ?? createPrismaClient()
if (process.env.NODE_ENV !== 'production') globalForPrisma.prisma = prisma
+32
View File
@@ -0,0 +1,32 @@
const MAX_ATTEMPTS = 5
const BLOCK_DURATION_MS = 15 * 60 * 1000 // 15 minutes
type Entry = { count: number; blockedAt: number | null }
let attempts = new Map<string, Entry>()
export function checkRateLimit(key: string): boolean {
const now = Date.now()
const entry = attempts.get(key)
if (!entry) return true
if (entry.blockedAt !== null) {
if (now - entry.blockedAt < BLOCK_DURATION_MS) return false
attempts.delete(key)
}
return true
}
export function recordFailedAttempt(key: string): void {
const entry = attempts.get(key) ?? { count: 0, blockedAt: null }
entry.count += 1
if (entry.count >= MAX_ATTEMPTS) entry.blockedAt = Date.now()
attempts.set(key, entry)
}
export function clearAttempts(key: string): void {
attempts.delete(key)
}
/** Réinitialisation pour les tests uniquement */
export function _resetForTests(): void {
attempts = new Map()
}
+83
View File
@@ -0,0 +1,83 @@
const API_VERSION = '2024-01'
function getConfig() {
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',
},
}
}
export type ShopifyProductResult =
| { found: false }
| { found: true; shopifyId: string; title: string }
/**
* Recherche un produit Shopify par sa référence.
* Tente d'abord par handle (ex: "ref-1042"), puis par titre exact.
*/
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}`)
const data = await res.json()
return (data.products ?? []) as Array<{ id: number; title: string; handle: string }>
}
let products = await tryFetch(`handle=${encodeURIComponent(handle)}`)
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,
}
}
/**
* Upload une image (base64) vers un produit Shopify via l'API REST.
* @param shopifyProductId — ID numérique du produit (string)
* @param imageBase64 — contenu JPEG encodé en base64 (sans le préfixe data:)
* @param filename — nom de fichier avec extension (ex: "REF-1042.jpg")
*/
export async function uploadProductImage(
shopifyProductId: string,
imageBase64: string,
filename: string,
): Promise<{ url: string }> {
const { baseUrl, headers } = getConfig()
const res = await fetch(`${baseUrl}/products/${shopifyProductId}/images.json`, {
method: 'POST',
headers,
body: JSON.stringify({ image: { attachment: imageBase64, filename } }),
})
if (!res.ok) {
const body = await res.text()
throw new Error(`Shopify upload error: ${res.status}${body}`)
}
const data = await res.json()
if (!data.image?.src) {
throw new Error('Shopify response invalide : champ image.src manquant')
}
return { url: data.image.src as string }
}
+27
View File
@@ -0,0 +1,27 @@
import { withAuth } from 'next-auth/middleware'
import { NextResponse } from 'next/server'
export default withAuth(
function middleware(req) {
const { token } = req.nextauth
const { pathname } = req.nextUrl
// Seuls les admins accèdent à /parametres
if (pathname.startsWith('/parametres') && token?.role !== 'admin') {
return NextResponse.redirect(new URL('/', req.url))
}
return NextResponse.next()
},
{
callbacks: {
authorized: ({ token }) => !!token,
},
}
)
export const config = {
matcher: [
'/((?!login|api/auth|_next/static|_next/image|favicon\\.ico).*)',
],
}
+40
View File
@@ -0,0 +1,40 @@
export type ImageStatus =
| 'pending' // ajouté, pas encore traité
| 'converting' // HEIC → JPEG en cours
| 'processing' // détourage IA en cours
| 'searching' // recherche produit Shopify en cours
| 'ready' // prêt à uploader
| 'uploading' // upload Shopify en cours
| 'uploaded' // uploadé avec succès
| 'not_found' // référence produit introuvable dans Shopify
| 'error' // erreur à n'importe quelle étape
export interface ImageProgress {
key: string
percent: number
}
export interface ImageItem {
id: string
filename: string // nom de fichier original (ex: "REF-1042.heic")
ref: string // référence produit extraite (ex: "REF-1042")
originalBlob: Blob // image originale (pour la vue "avant")
rawPngBlob: Blob | null // PNG transparent issu du détourage IA (cache pour feather)
processedBlob: Blob | null // JPEG final sur fond blanc, pivoté (pour upload + vue "après")
rotation: number // 0, 90, 180, 270
feather: number // 05, défaut 2
shopifyProductId: string | null
shopifyProductTitle: string | null
uploadedUrl: string | null
status: ImageStatus
error: string | null
progress: ImageProgress | null
}
export interface ZipEntry {
folder: string // nom du dossier = référence produit
filename: string // nom de fichier dans le dossier
ext: string // extension sans point, en minuscules
blob: Blob // blob avec type MIME correct
isHeic: boolean
}
+17
View File
@@ -0,0 +1,17 @@
import type { DefaultSession, DefaultJWT } from 'next-auth'
declare module 'next-auth' {
interface Session {
user: DefaultSession['user'] & {
id: string
role: string
}
}
}
declare module 'next-auth/jwt' {
interface JWT extends DefaultJWT {
id: string
role: string
}
}