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",
|
||||
"bcryptjs": "^3.0.3",
|
||||
"better-sqlite3": "^12.10.0",
|
||||
"heic2any": "^0.0.4",
|
||||
"jszip": "^3.10.1",
|
||||
"next": "14.2.35",
|
||||
"next-auth": "^4.24.14",
|
||||
"react": "^18",
|
||||
@@ -4894,6 +4896,12 @@
|
||||
"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",
|
||||
@@ -6620,6 +6628,12 @@
|
||||
"node": ">= 0.4"
|
||||
}
|
||||
},
|
||||
"node_modules/heic2any": {
|
||||
"version": "0.0.4",
|
||||
"resolved": "https://registry.npmjs.org/heic2any/-/heic2any-0.0.4.tgz",
|
||||
"integrity": "sha512-3lLnZiDELfabVH87htnRolZ2iehX9zwpRyGNz22GKXIu0fznlblf0/ftppXKNqS26dqFSeqfIBhAmAj/uSp0cA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/hono": {
|
||||
"version": "4.12.23",
|
||||
"resolved": "https://registry.npmjs.org/hono/-/hono-4.12.23.tgz",
|
||||
@@ -6740,6 +6754,12 @@
|
||||
"node": ">= 4"
|
||||
}
|
||||
},
|
||||
"node_modules/immediate": {
|
||||
"version": "3.0.6",
|
||||
"resolved": "https://registry.npmjs.org/immediate/-/immediate-3.0.6.tgz",
|
||||
"integrity": "sha512-XXOFtyqDjNDAQxVfYxuF7g9Il/IbWmmlQg2MYKOH8ExIT1qg6xc4zyS3HaEEATgs1btfzxq15ciUiY7gjSXRGQ==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/import-fresh": {
|
||||
"version": "3.3.1",
|
||||
"dev": true,
|
||||
@@ -8560,6 +8580,54 @@
|
||||
"node": ">=4.0"
|
||||
}
|
||||
},
|
||||
"node_modules/jszip": {
|
||||
"version": "3.10.1",
|
||||
"resolved": "https://registry.npmjs.org/jszip/-/jszip-3.10.1.tgz",
|
||||
"integrity": "sha512-xXDvecyTpGLrqFrvkrUSoxxfJI5AH7U8zxxtVclpsUtMCq4JQ290LY8AW5c7Ggnr/Y/oK+bQMbqK2qmtk3pN4g==",
|
||||
"license": "(MIT OR GPL-3.0-or-later)",
|
||||
"dependencies": {
|
||||
"lie": "~3.3.0",
|
||||
"pako": "~1.0.2",
|
||||
"readable-stream": "~2.3.6",
|
||||
"setimmediate": "^1.0.5"
|
||||
}
|
||||
},
|
||||
"node_modules/jszip/node_modules/isarray": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz",
|
||||
"integrity": "sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/jszip/node_modules/readable-stream": {
|
||||
"version": "2.3.8",
|
||||
"resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.8.tgz",
|
||||
"integrity": "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"core-util-is": "~1.0.0",
|
||||
"inherits": "~2.0.3",
|
||||
"isarray": "~1.0.0",
|
||||
"process-nextick-args": "~2.0.0",
|
||||
"safe-buffer": "~5.1.1",
|
||||
"string_decoder": "~1.1.1",
|
||||
"util-deprecate": "~1.0.1"
|
||||
}
|
||||
},
|
||||
"node_modules/jszip/node_modules/safe-buffer": {
|
||||
"version": "5.1.2",
|
||||
"resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz",
|
||||
"integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/jszip/node_modules/string_decoder": {
|
||||
"version": "1.1.1",
|
||||
"resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz",
|
||||
"integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"safe-buffer": "~5.1.0"
|
||||
}
|
||||
},
|
||||
"node_modules/keyv": {
|
||||
"version": "4.5.4",
|
||||
"dev": true,
|
||||
@@ -8606,6 +8674,15 @@
|
||||
"node": ">= 0.8.0"
|
||||
}
|
||||
},
|
||||
"node_modules/lie": {
|
||||
"version": "3.3.0",
|
||||
"resolved": "https://registry.npmjs.org/lie/-/lie-3.3.0.tgz",
|
||||
"integrity": "sha512-UaiMJzeWRlEujzAuw5LokY1L5ecNQYZKfmyZ9L7wDHb/p5etKaxXhohBcrw0EYby+G/NA52vRSN4N39dxHAIwQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"immediate": "~3.0.5"
|
||||
}
|
||||
},
|
||||
"node_modules/lilconfig": {
|
||||
"version": "3.1.3",
|
||||
"dev": true,
|
||||
@@ -9527,6 +9604,12 @@
|
||||
"dev": true,
|
||||
"license": "BlueOak-1.0.0"
|
||||
},
|
||||
"node_modules/pako": {
|
||||
"version": "1.0.11",
|
||||
"resolved": "https://registry.npmjs.org/pako/-/pako-1.0.11.tgz",
|
||||
"integrity": "sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw==",
|
||||
"license": "(MIT AND Zlib)"
|
||||
},
|
||||
"node_modules/parent-module": {
|
||||
"version": "1.0.1",
|
||||
"dev": true,
|
||||
@@ -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": {
|
||||
"version": "15.8.1",
|
||||
"dev": true,
|
||||
@@ -10671,6 +10760,12 @@
|
||||
"node": ">= 0.4"
|
||||
}
|
||||
},
|
||||
"node_modules/setimmediate": {
|
||||
"version": "1.0.5",
|
||||
"resolved": "https://registry.npmjs.org/setimmediate/-/setimmediate-1.0.5.tgz",
|
||||
"integrity": "sha512-MATJdZp8sLqDl/68LfQmbP8zKPLQNV6BIZoIgrscFDQ+RsvK/BxeDQOgyxKKoh0y/8h3BqVFnCqQ/gd+reiIXA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/shebang-command": {
|
||||
"version": "2.0.0",
|
||||
"devOptional": true,
|
||||
|
||||
@@ -17,6 +17,8 @@
|
||||
"@prisma/client": "^7.8.0",
|
||||
"bcryptjs": "^3.0.3",
|
||||
"better-sqlite3": "^12.10.0",
|
||||
"heic2any": "^0.0.4",
|
||||
"jszip": "^3.10.1",
|
||||
"next": "14.2.35",
|
||||
"next-auth": "^4.24.14",
|
||||
"react": "^18",
|
||||
|
||||
@@ -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 { 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" />
|
||||
<div className="p-6"><p className="text-gray-400 text-sm">Module Images — à implémenter (Plan 2)</p></div>
|
||||
<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,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