Compare commits
30 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| cf5bbf91d5 | |||
| fe9c3b9ccb | |||
| 568fe0d38e | |||
| 683db58f4b | |||
| 4c1cf73435 | |||
| 901199c45d | |||
| 50469700d0 | |||
| a5ced27e93 | |||
| c9422be78e | |||
| 53008d1d43 | |||
| 9675366db9 | |||
| c97a7faaa9 | |||
| b25a712a69 | |||
| ee70ea377f | |||
| 8bd49050f6 | |||
| 79ebb6ced3 | |||
| d80e50e9de | |||
| f022238a40 | |||
| f23c954ecd | |||
| 96700ae252 | |||
| 99c3380d00 | |||
| 51f87c0672 | |||
| 8d06706a24 | |||
| 44b3b4dd8d | |||
| 38dc6efc09 | |||
| f9a14b009a | |||
| 2acf899465 | |||
| dbb155cb3a | |||
| 192def9008 | |||
| 1b60fd79f7 |
@@ -0,0 +1,10 @@
|
|||||||
|
node_modules
|
||||||
|
.next
|
||||||
|
.env
|
||||||
|
.env.local
|
||||||
|
.env.production
|
||||||
|
.env.development
|
||||||
|
data/
|
||||||
|
*.db
|
||||||
|
.git
|
||||||
|
README.md
|
||||||
@@ -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=
|
||||||
@@ -28,9 +28,18 @@ yarn-error.log*
|
|||||||
# local env files
|
# local env files
|
||||||
.env*.local
|
.env*.local
|
||||||
|
|
||||||
|
# prisma env (contains DATABASE_URL)
|
||||||
|
.env
|
||||||
|
|
||||||
|
# database files
|
||||||
|
/data/
|
||||||
|
|
||||||
# vercel
|
# vercel
|
||||||
.vercel
|
.vercel
|
||||||
|
|
||||||
# typescript
|
# typescript
|
||||||
*.tsbuildinfo
|
*.tsbuildinfo
|
||||||
next-env.d.ts
|
next-env.d.ts
|
||||||
|
|
||||||
|
/src/generated/prisma
|
||||||
|
.env.production
|
||||||
|
|||||||
+59
@@ -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"]
|
||||||
@@ -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
|
||||||
@@ -7,6 +7,9 @@ const config: Config = {
|
|||||||
coverageProvider: 'v8',
|
coverageProvider: 'v8',
|
||||||
testEnvironment: 'node',
|
testEnvironment: 'node',
|
||||||
setupFilesAfterEnv: ['<rootDir>/jest.setup.ts'],
|
setupFilesAfterEnv: ['<rootDir>/jest.setup.ts'],
|
||||||
|
moduleNameMapper: {
|
||||||
|
'^@/(.*)$': '<rootDir>/src/$1',
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
export default createJestConfig(config)
|
export default createJestConfig(config)
|
||||||
|
|||||||
+3
-1
@@ -1,4 +1,6 @@
|
|||||||
/** @type {import('next').NextConfig} */
|
/** @type {import('next').NextConfig} */
|
||||||
const nextConfig = {};
|
const nextConfig = {
|
||||||
|
output: 'standalone',
|
||||||
|
};
|
||||||
|
|
||||||
export default nextConfig;
|
export default nextConfig;
|
||||||
|
|||||||
Generated
+671
-7
@@ -8,8 +8,12 @@
|
|||||||
"name": "gestion-md",
|
"name": "gestion-md",
|
||||||
"version": "0.1.0",
|
"version": "0.1.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@prisma/adapter-better-sqlite3": "^7.8.0",
|
||||||
"@prisma/client": "^7.8.0",
|
"@prisma/client": "^7.8.0",
|
||||||
"bcryptjs": "^3.0.3",
|
"bcryptjs": "^3.0.3",
|
||||||
|
"better-sqlite3": "^12.10.0",
|
||||||
|
"heic2any": "^0.0.4",
|
||||||
|
"jszip": "^3.10.1",
|
||||||
"next": "14.2.35",
|
"next": "14.2.35",
|
||||||
"next-auth": "^4.24.14",
|
"next-auth": "^4.24.14",
|
||||||
"react": "^18",
|
"react": "^18",
|
||||||
@@ -31,6 +35,7 @@
|
|||||||
"prisma": "^7.8.0",
|
"prisma": "^7.8.0",
|
||||||
"tailwindcss": "^3.4.1",
|
"tailwindcss": "^3.4.1",
|
||||||
"ts-jest": "^29.4.11",
|
"ts-jest": "^29.4.11",
|
||||||
|
"ts-node": "^10.9.2",
|
||||||
"typescript": "^5"
|
"typescript": "^5"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -621,6 +626,30 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"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": {
|
"node_modules/@csstools/color-helpers": {
|
||||||
"version": "5.1.0",
|
"version": "5.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/@csstools/color-helpers/-/color-helpers-5.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/@csstools/color-helpers/-/color-helpers-5.1.0.tgz",
|
||||||
@@ -1938,6 +1967,16 @@
|
|||||||
"url": "https://opencollective.com/pkgr"
|
"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": {
|
"node_modules/@prisma/client": {
|
||||||
"version": "7.8.0",
|
"version": "7.8.0",
|
||||||
"resolved": "https://registry.npmjs.org/@prisma/client/-/client-7.8.0.tgz",
|
"resolved": "https://registry.npmjs.org/@prisma/client/-/client-7.8.0.tgz",
|
||||||
@@ -1985,7 +2024,6 @@
|
|||||||
"version": "7.8.0",
|
"version": "7.8.0",
|
||||||
"resolved": "https://registry.npmjs.org/@prisma/debug/-/debug-7.8.0.tgz",
|
"resolved": "https://registry.npmjs.org/@prisma/debug/-/debug-7.8.0.tgz",
|
||||||
"integrity": "sha512-p+QZReysDUqXC+mk17q9a+Y/qzh4c2KYliDK30buYUyfrGeTGSyfmc0AIrJRhZJrLHhRiJa9Au/J72h3C+szvA==",
|
"integrity": "sha512-p+QZReysDUqXC+mk17q9a+Y/qzh4c2KYliDK30buYUyfrGeTGSyfmc0AIrJRhZJrLHhRiJa9Au/J72h3C+szvA==",
|
||||||
"devOptional": true,
|
|
||||||
"license": "Apache-2.0"
|
"license": "Apache-2.0"
|
||||||
},
|
},
|
||||||
"node_modules/@prisma/dev": {
|
"node_modules/@prisma/dev": {
|
||||||
@@ -2014,6 +2052,15 @@
|
|||||||
"zeptomatch": "2.1.0"
|
"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": {
|
"node_modules/@prisma/engines": {
|
||||||
"version": "7.8.0",
|
"version": "7.8.0",
|
||||||
"resolved": "https://registry.npmjs.org/@prisma/engines/-/engines-7.8.0.tgz",
|
"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": {
|
"node_modules/@tybys/wasm-util": {
|
||||||
"version": "0.10.2",
|
"version": "0.10.2",
|
||||||
"resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.2.tgz",
|
"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"
|
"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": {
|
"node_modules/agent-base": {
|
||||||
"version": "7.1.4",
|
"version": "7.1.4",
|
||||||
"resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz",
|
"resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz",
|
||||||
@@ -4208,6 +4296,26 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"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": {
|
"node_modules/baseline-browser-mapping": {
|
||||||
"version": "2.10.34",
|
"version": "2.10.34",
|
||||||
"resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.34.tgz",
|
"resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.34.tgz",
|
||||||
@@ -4237,6 +4345,20 @@
|
|||||||
"devOptional": true,
|
"devOptional": true,
|
||||||
"license": "MIT"
|
"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": {
|
"node_modules/binary-extensions": {
|
||||||
"version": "2.3.0",
|
"version": "2.3.0",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
@@ -4248,6 +4370,26 @@
|
|||||||
"url": "https://github.com/sponsors/sindresorhus"
|
"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": {
|
"node_modules/brace-expansion": {
|
||||||
"version": "1.1.15",
|
"version": "1.1.15",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
@@ -4325,6 +4467,30 @@
|
|||||||
"node-int64": "^0.4.0"
|
"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": {
|
"node_modules/buffer-from": {
|
||||||
"version": "1.1.2",
|
"version": "1.1.2",
|
||||||
"resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz",
|
"resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz",
|
||||||
@@ -4572,6 +4738,12 @@
|
|||||||
"node": ">= 6"
|
"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": {
|
"node_modules/ci-info": {
|
||||||
"version": "4.4.0",
|
"version": "4.4.0",
|
||||||
"resolved": "https://registry.npmjs.org/ci-info/-/ci-info-4.4.0.tgz",
|
"resolved": "https://registry.npmjs.org/ci-info/-/ci-info-4.4.0.tgz",
|
||||||
@@ -4724,6 +4896,19 @@
|
|||||||
"node": ">= 0.6"
|
"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": {
|
"node_modules/cross-spawn": {
|
||||||
"version": "7.0.6",
|
"version": "7.0.6",
|
||||||
"devOptional": true,
|
"devOptional": true,
|
||||||
@@ -4864,6 +5049,21 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"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": {
|
"node_modules/dedent": {
|
||||||
"version": "1.7.2",
|
"version": "1.7.2",
|
||||||
"resolved": "https://registry.npmjs.org/dedent/-/dedent-1.7.2.tgz",
|
"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": {
|
"node_modules/deep-is": {
|
||||||
"version": "0.1.4",
|
"version": "0.1.4",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
@@ -4971,6 +5180,15 @@
|
|||||||
"devOptional": true,
|
"devOptional": true,
|
||||||
"license": "MIT"
|
"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": {
|
"node_modules/detect-newline": {
|
||||||
"version": "3.1.0",
|
"version": "3.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/detect-newline/-/detect-newline-3.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/detect-newline/-/detect-newline-3.1.0.tgz",
|
||||||
@@ -4986,6 +5204,16 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "Apache-2.0"
|
"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": {
|
"node_modules/dlv": {
|
||||||
"version": "1.1.3",
|
"version": "1.1.3",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
@@ -5087,6 +5315,15 @@
|
|||||||
"node": ">=14"
|
"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": {
|
"node_modules/entities": {
|
||||||
"version": "6.0.1",
|
"version": "6.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/entities/-/entities-6.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/entities/-/entities-6.0.1.tgz",
|
||||||
@@ -5745,6 +5982,15 @@
|
|||||||
"node": ">= 0.8.0"
|
"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": {
|
"node_modules/expect": {
|
||||||
"version": "30.4.1",
|
"version": "30.4.1",
|
||||||
"resolved": "https://registry.npmjs.org/expect/-/expect-30.4.1.tgz",
|
"resolved": "https://registry.npmjs.org/expect/-/expect-30.4.1.tgz",
|
||||||
@@ -5880,6 +6126,12 @@
|
|||||||
"node": "^10.12.0 || >=12.0.0"
|
"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": {
|
"node_modules/fill-range": {
|
||||||
"version": "7.1.1",
|
"version": "7.1.1",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
@@ -5953,6 +6205,12 @@
|
|||||||
"url": "https://github.com/sponsors/isaacs"
|
"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": {
|
"node_modules/fs.realpath": {
|
||||||
"version": "1.0.0",
|
"version": "1.0.0",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
@@ -6145,6 +6403,12 @@
|
|||||||
"giget": "dist/cli.mjs"
|
"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": {
|
"node_modules/glob": {
|
||||||
"version": "10.3.10",
|
"version": "10.3.10",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
@@ -6364,6 +6628,12 @@
|
|||||||
"node": ">= 0.4"
|
"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": {
|
"node_modules/hono": {
|
||||||
"version": "4.12.23",
|
"version": "4.12.23",
|
||||||
"resolved": "https://registry.npmjs.org/hono/-/hono-4.12.23.tgz",
|
"resolved": "https://registry.npmjs.org/hono/-/hono-4.12.23.tgz",
|
||||||
@@ -6456,6 +6726,26 @@
|
|||||||
"url": "https://opencollective.com/express"
|
"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": {
|
"node_modules/ignore": {
|
||||||
"version": "5.3.2",
|
"version": "5.3.2",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
@@ -6464,6 +6754,12 @@
|
|||||||
"node": ">= 4"
|
"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": {
|
"node_modules/import-fresh": {
|
||||||
"version": "3.3.1",
|
"version": "3.3.1",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
@@ -6528,7 +6824,12 @@
|
|||||||
},
|
},
|
||||||
"node_modules/inherits": {
|
"node_modules/inherits": {
|
||||||
"version": "2.0.4",
|
"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"
|
"license": "ISC"
|
||||||
},
|
},
|
||||||
"node_modules/internal-slot": {
|
"node_modules/internal-slot": {
|
||||||
@@ -8279,6 +8580,54 @@
|
|||||||
"node": ">=4.0"
|
"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": {
|
"node_modules/keyv": {
|
||||||
"version": "4.5.4",
|
"version": "4.5.4",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
@@ -8325,6 +8674,15 @@
|
|||||||
"node": ">= 0.8.0"
|
"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": {
|
"node_modules/lilconfig": {
|
||||||
"version": "3.1.3",
|
"version": "3.1.3",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
@@ -8494,6 +8852,18 @@
|
|||||||
"node": ">=6"
|
"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": {
|
"node_modules/min-indent": {
|
||||||
"version": "1.0.1",
|
"version": "1.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/min-indent/-/min-indent-1.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/min-indent/-/min-indent-1.0.1.tgz",
|
||||||
@@ -8517,7 +8887,6 @@
|
|||||||
},
|
},
|
||||||
"node_modules/minimist": {
|
"node_modules/minimist": {
|
||||||
"version": "1.2.8",
|
"version": "1.2.8",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"funding": {
|
"funding": {
|
||||||
"url": "https://github.com/sponsors/ljharb"
|
"url": "https://github.com/sponsors/ljharb"
|
||||||
@@ -8531,6 +8900,12 @@
|
|||||||
"node": ">=16 || 14 >=14.17"
|
"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": {
|
"node_modules/ms": {
|
||||||
"version": "2.1.3",
|
"version": "2.1.3",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
@@ -8596,6 +8971,12 @@
|
|||||||
"node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1"
|
"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": {
|
"node_modules/napi-postinstall": {
|
||||||
"version": "0.3.4",
|
"version": "0.3.4",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
@@ -8730,6 +9111,18 @@
|
|||||||
"node": "^10 || ^12 || >=14"
|
"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": {
|
"node_modules/node-exports-info": {
|
||||||
"version": "1.6.0",
|
"version": "1.6.0",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
@@ -9077,7 +9470,6 @@
|
|||||||
},
|
},
|
||||||
"node_modules/once": {
|
"node_modules/once": {
|
||||||
"version": "1.4.0",
|
"version": "1.4.0",
|
||||||
"dev": true,
|
|
||||||
"license": "ISC",
|
"license": "ISC",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"wrappy": "1"
|
"wrappy": "1"
|
||||||
@@ -9212,6 +9604,12 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "BlueOak-1.0.0"
|
"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": {
|
"node_modules/parent-module": {
|
||||||
"version": "1.0.1",
|
"version": "1.0.1",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
@@ -9638,6 +10036,33 @@
|
|||||||
"preact": ">=10"
|
"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": {
|
"node_modules/prelude-ls": {
|
||||||
"version": "1.2.1",
|
"version": "1.2.1",
|
||||||
"dev": true,
|
"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": {
|
"node_modules/prop-types": {
|
||||||
"version": "15.8.1",
|
"version": "15.8.1",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
@@ -9715,6 +10146,16 @@
|
|||||||
"devOptional": true,
|
"devOptional": true,
|
||||||
"license": "ISC"
|
"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": {
|
"node_modules/punycode": {
|
||||||
"version": "2.3.1",
|
"version": "2.3.1",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
@@ -9759,6 +10200,30 @@
|
|||||||
],
|
],
|
||||||
"license": "MIT"
|
"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": {
|
"node_modules/rc9": {
|
||||||
"version": "3.0.1",
|
"version": "3.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/rc9/-/rc9-3.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/rc9/-/rc9-3.0.1.tgz",
|
||||||
@@ -9820,6 +10285,20 @@
|
|||||||
"pify": "^2.3.0"
|
"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": {
|
"node_modules/readdirp": {
|
||||||
"version": "3.6.0",
|
"version": "3.6.0",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
@@ -10144,6 +10623,26 @@
|
|||||||
"url": "https://github.com/sponsors/ljharb"
|
"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": {
|
"node_modules/safe-push-apply": {
|
||||||
"version": "1.0.0",
|
"version": "1.0.0",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
@@ -10204,7 +10703,6 @@
|
|||||||
},
|
},
|
||||||
"node_modules/semver": {
|
"node_modules/semver": {
|
||||||
"version": "7.8.2",
|
"version": "7.8.2",
|
||||||
"dev": true,
|
|
||||||
"license": "ISC",
|
"license": "ISC",
|
||||||
"bin": {
|
"bin": {
|
||||||
"semver": "bin/semver.js"
|
"semver": "bin/semver.js"
|
||||||
@@ -10262,6 +10760,12 @@
|
|||||||
"node": ">= 0.4"
|
"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": {
|
"node_modules/shebang-command": {
|
||||||
"version": "2.0.0",
|
"version": "2.0.0",
|
||||||
"devOptional": true,
|
"devOptional": true,
|
||||||
@@ -10360,6 +10864,51 @@
|
|||||||
"url": "https://github.com/sponsors/isaacs"
|
"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": {
|
"node_modules/slash": {
|
||||||
"version": "3.0.0",
|
"version": "3.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/slash/-/slash-3.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/slash/-/slash-3.0.0.tgz",
|
||||||
@@ -10468,6 +11017,15 @@
|
|||||||
"node": ">=10.0.0"
|
"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": {
|
"node_modules/string-length": {
|
||||||
"version": "4.0.2",
|
"version": "4.0.2",
|
||||||
"resolved": "https://registry.npmjs.org/string-length/-/string-length-4.0.2.tgz",
|
"resolved": "https://registry.npmjs.org/string-length/-/string-length-4.0.2.tgz",
|
||||||
@@ -11128,6 +11686,34 @@
|
|||||||
"url": "https://github.com/sponsors/ljharb"
|
"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": {
|
"node_modules/test-exclude": {
|
||||||
"version": "6.0.0",
|
"version": "6.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/test-exclude/-/test-exclude-6.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/test-exclude/-/test-exclude-6.0.0.tgz",
|
||||||
@@ -11390,6 +11976,57 @@
|
|||||||
"url": "https://github.com/sponsors/sindresorhus"
|
"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": {
|
"node_modules/tsconfig-paths": {
|
||||||
"version": "3.15.0",
|
"version": "3.15.0",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
@@ -11405,6 +12042,18 @@
|
|||||||
"version": "2.8.1",
|
"version": "2.8.1",
|
||||||
"license": "0BSD"
|
"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": {
|
"node_modules/type-check": {
|
||||||
"version": "0.4.0",
|
"version": "0.4.0",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
@@ -11632,7 +12281,6 @@
|
|||||||
},
|
},
|
||||||
"node_modules/util-deprecate": {
|
"node_modules/util-deprecate": {
|
||||||
"version": "1.0.2",
|
"version": "1.0.2",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/uuid": {
|
"node_modules/uuid": {
|
||||||
@@ -11645,6 +12293,13 @@
|
|||||||
"uuid": "dist/bin/uuid"
|
"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": {
|
"node_modules/v8-to-istanbul": {
|
||||||
"version": "9.3.0",
|
"version": "9.3.0",
|
||||||
"resolved": "https://registry.npmjs.org/v8-to-istanbul/-/v8-to-istanbul-9.3.0.tgz",
|
"resolved": "https://registry.npmjs.org/v8-to-istanbul/-/v8-to-istanbul-9.3.0.tgz",
|
||||||
@@ -11958,7 +12613,6 @@
|
|||||||
},
|
},
|
||||||
"node_modules/wrappy": {
|
"node_modules/wrappy": {
|
||||||
"version": "1.0.2",
|
"version": "1.0.2",
|
||||||
"dev": true,
|
|
||||||
"license": "ISC"
|
"license": "ISC"
|
||||||
},
|
},
|
||||||
"node_modules/write-file-atomic": {
|
"node_modules/write-file-atomic": {
|
||||||
@@ -12081,6 +12735,16 @@
|
|||||||
"node": ">=8"
|
"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": {
|
"node_modules/yocto-queue": {
|
||||||
"version": "0.1.0",
|
"version": "0.1.0",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
|
|||||||
+10
-1
@@ -6,11 +6,19 @@
|
|||||||
"dev": "next dev",
|
"dev": "next dev",
|
||||||
"build": "next build",
|
"build": "next build",
|
||||||
"start": "next start",
|
"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": {
|
"dependencies": {
|
||||||
|
"@prisma/adapter-better-sqlite3": "^7.8.0",
|
||||||
"@prisma/client": "^7.8.0",
|
"@prisma/client": "^7.8.0",
|
||||||
"bcryptjs": "^3.0.3",
|
"bcryptjs": "^3.0.3",
|
||||||
|
"better-sqlite3": "^12.10.0",
|
||||||
|
"heic2any": "^0.0.4",
|
||||||
|
"jszip": "^3.10.1",
|
||||||
"next": "14.2.35",
|
"next": "14.2.35",
|
||||||
"next-auth": "^4.24.14",
|
"next-auth": "^4.24.14",
|
||||||
"react": "^18",
|
"react": "^18",
|
||||||
@@ -32,6 +40,7 @@
|
|||||||
"prisma": "^7.8.0",
|
"prisma": "^7.8.0",
|
||||||
"tailwindcss": "^3.4.1",
|
"tailwindcss": "^3.4.1",
|
||||||
"ts-jest": "^29.4.11",
|
"ts-jest": "^29.4.11",
|
||||||
|
"ts-node": "^10.9.2",
|
||||||
"typescript": "^5"
|
"typescript": "^5"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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");
|
||||||
@@ -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"
|
||||||
@@ -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("[]")
|
||||||
|
}
|
||||||
@@ -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()
|
||||||
@@ -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())
|
||||||
Executable
+6
@@ -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
|
||||||
@@ -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)
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -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()
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -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')
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -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)
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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)} />
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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 }
|
||||||
@@ -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 })
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
@@ -1,6 +1,7 @@
|
|||||||
import type { Metadata } from 'next'
|
import type { Metadata } from 'next'
|
||||||
import { Inter } from 'next/font/google'
|
import { Inter } from 'next/font/google'
|
||||||
import './globals.css'
|
import './globals.css'
|
||||||
|
import { SessionProvider } from '@/components/providers/SessionProvider'
|
||||||
|
|
||||||
const inter = Inter({ subsets: ['latin'] })
|
const inter = Inter({ subsets: ['latin'] })
|
||||||
|
|
||||||
@@ -12,7 +13,9 @@ export const metadata: Metadata = {
|
|||||||
export default function RootLayout({ children }: { children: React.ReactNode }) {
|
export default function RootLayout({ children }: { children: React.ReactNode }) {
|
||||||
return (
|
return (
|
||||||
<html lang="fr">
|
<html lang="fr">
|
||||||
<body className={inter.className}>{children}</body>
|
<body className={inter.className}>
|
||||||
|
<SessionProvider>{children}</SessionProvider>
|
||||||
|
</body>
|
||||||
</html>
|
</html>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
}
|
||||||
@@ -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 }
|
||||||
|
}
|
||||||
@@ -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 }
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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
|
||||||
@@ -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()
|
||||||
|
}
|
||||||
@@ -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 }
|
||||||
|
}
|
||||||
@@ -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).*)',
|
||||||
|
],
|
||||||
|
}
|
||||||
@@ -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 // 0–5, 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
|
||||||
|
}
|
||||||
Vendored
+17
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user