Compare commits
12 Commits
b25a712a69
...
cf5bbf91d5
| Author | SHA1 | Date | |
|---|---|---|---|
| cf5bbf91d5 | |||
| fe9c3b9ccb | |||
| 568fe0d38e | |||
| 683db58f4b | |||
| 4c1cf73435 | |||
| 901199c45d | |||
| 50469700d0 | |||
| a5ced27e93 | |||
| c9422be78e | |||
| 53008d1d43 | |||
| 9675366db9 | |||
| c97a7faaa9 |
Generated
+95
@@ -12,6 +12,8 @@
|
|||||||
"@prisma/client": "^7.8.0",
|
"@prisma/client": "^7.8.0",
|
||||||
"bcryptjs": "^3.0.3",
|
"bcryptjs": "^3.0.3",
|
||||||
"better-sqlite3": "^12.10.0",
|
"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",
|
||||||
@@ -4894,6 +4896,12 @@
|
|||||||
"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": {
|
"node_modules/create-require": {
|
||||||
"version": "1.1.1",
|
"version": "1.1.1",
|
||||||
"resolved": "https://registry.npmjs.org/create-require/-/create-require-1.1.1.tgz",
|
"resolved": "https://registry.npmjs.org/create-require/-/create-require-1.1.1.tgz",
|
||||||
@@ -6620,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",
|
||||||
@@ -6740,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,
|
||||||
@@ -8560,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,
|
||||||
@@ -8606,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,
|
||||||
@@ -9527,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,
|
||||||
@@ -10028,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,
|
||||||
@@ -10671,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,
|
||||||
|
|||||||
@@ -17,6 +17,8 @@
|
|||||||
"@prisma/client": "^7.8.0",
|
"@prisma/client": "^7.8.0",
|
||||||
"bcryptjs": "^3.0.3",
|
"bcryptjs": "^3.0.3",
|
||||||
"better-sqlite3": "^12.10.0",
|
"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",
|
||||||
|
|||||||
@@ -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()
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -1,9 +1,61 @@
|
|||||||
|
'use client'
|
||||||
|
import { useState } from 'react'
|
||||||
import { Header } from '@/components/layout/Header'
|
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() {
|
export default function ImagesPage() {
|
||||||
|
const { items, addFiles, setFeather, rotate, uploadOne, uploadAll, removeItem } = useImages()
|
||||||
|
const [lightboxItem, setLightboxItem] = useState<ImageItem | null>(null)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<Header title="🖼️ Images" />
|
<Header
|
||||||
<div className="p-6"><p className="text-gray-400 text-sm">Module Images — à implémenter (Plan 2)</p></div>
|
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,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 })
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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,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,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,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,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
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user