12 Commits

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

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

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

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

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-08 15:47:34 +02:00
18 changed files with 1234 additions and 2 deletions
+95
View File
@@ -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,
+2
View File
@@ -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",
+75
View File
@@ -0,0 +1,75 @@
import { POST } from '@/app/api/images/upload/route'
import { NextRequest } from 'next/server'
jest.mock('@/lib/shopify', () => ({
uploadProductImage: jest.fn(),
}))
import { uploadProductImage } from '@/lib/shopify'
const mockUpload = uploadProductImage as jest.Mock
describe('POST /api/images/upload', () => {
beforeEach(() => jest.clearAllMocks())
it('retourne 400 si shopifyProductId manquant', async () => {
const req = new NextRequest('http://localhost/api/images/upload', {
method: 'POST',
body: JSON.stringify({ imageBase64: 'abc', filename: 'test.jpg' }),
headers: { 'Content-Type': 'application/json' },
})
const res = await POST(req)
expect(res.status).toBe(400)
})
it('retourne 400 si imageBase64 manquant', async () => {
const req = new NextRequest('http://localhost/api/images/upload', {
method: 'POST',
body: JSON.stringify({ shopifyProductId: '123', filename: 'test.jpg' }),
headers: { 'Content-Type': 'application/json' },
})
const res = await POST(req)
expect(res.status).toBe(400)
})
it('retourne 400 si filename manquant', async () => {
const req = new NextRequest('http://localhost/api/images/upload', {
method: 'POST',
body: JSON.stringify({ shopifyProductId: '123', imageBase64: 'abc' }),
headers: { 'Content-Type': 'application/json' },
})
const res = await POST(req)
expect(res.status).toBe(400)
})
it('retourne l\'URL après upload réussi', async () => {
mockUpload.mockResolvedValue({ url: 'https://cdn.shopify.com/image.jpg' })
const req = new NextRequest('http://localhost/api/images/upload', {
method: 'POST',
body: JSON.stringify({
shopifyProductId: '123456',
imageBase64: 'base64data==',
filename: 'REF-1042.jpg',
}),
headers: { 'Content-Type': 'application/json' },
})
const res = await POST(req)
expect(res.status).toBe(200)
expect(await res.json()).toEqual({ url: 'https://cdn.shopify.com/image.jpg' })
expect(mockUpload).toHaveBeenCalledWith('123456', 'base64data==', 'REF-1042.jpg')
})
it('retourne 500 en cas d\'erreur Shopify', async () => {
mockUpload.mockRejectedValue(new Error('Shopify upload error: 422'))
const req = new NextRequest('http://localhost/api/images/upload', {
method: 'POST',
body: JSON.stringify({
shopifyProductId: '123',
imageBase64: 'abc',
filename: 'test.jpg',
}),
headers: { 'Content-Type': 'application/json' },
})
const res = await POST(req)
expect(res.status).toBe(500)
})
})
+53
View File
@@ -0,0 +1,53 @@
import { GET } from '@/app/api/products/search/route'
import { NextRequest } from 'next/server'
jest.mock('@/lib/shopify', () => ({
searchProductByRef: jest.fn(),
}))
import { searchProductByRef } from '@/lib/shopify'
const mockSearch = searchProductByRef as jest.Mock
describe('GET /api/products/search', () => {
beforeEach(() => jest.clearAllMocks())
it('retourne 400 si le paramètre ref est absent', async () => {
const req = new NextRequest('http://localhost/api/products/search')
const res = await GET(req)
expect(res.status).toBe(400)
const data = await res.json()
expect(data.error).toMatch(/ref/)
})
it('retourne 400 si ref est une chaîne vide', async () => {
const req = new NextRequest('http://localhost/api/products/search?ref=')
const res = await GET(req)
expect(res.status).toBe(400)
})
it('retourne { found: false } si produit introuvable', async () => {
mockSearch.mockResolvedValue({ found: false })
const req = new NextRequest('http://localhost/api/products/search?ref=REF-9999')
const res = await GET(req)
expect(res.status).toBe(200)
expect(await res.json()).toEqual({ found: false })
expect(mockSearch).toHaveBeenCalledWith('REF-9999')
})
it('retourne le produit trouvé', async () => {
mockSearch.mockResolvedValue({ found: true, shopifyId: '123456', title: 'Carrelage REF-1042' })
const req = new NextRequest('http://localhost/api/products/search?ref=REF-1042')
const res = await GET(req)
expect(res.status).toBe(200)
expect(await res.json()).toEqual({ found: true, shopifyId: '123456', title: 'Carrelage REF-1042' })
})
it('retourne 500 en cas d\'erreur Shopify', async () => {
mockSearch.mockRejectedValue(new Error('Shopify API error: 503'))
const req = new NextRequest('http://localhost/api/products/search?ref=REF-1042')
const res = await GET(req)
expect(res.status).toBe(500)
const data = await res.json()
expect(data.error).toBeTruthy()
})
})
+54 -2
View File
@@ -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)} />
</>
)
}
+31
View File
@@ -0,0 +1,31 @@
import { NextRequest, NextResponse } from 'next/server'
import { uploadProductImage } from '@/lib/shopify'
export async function POST(req: NextRequest) {
let body: Record<string, unknown>
try {
body = await req.json()
} catch {
return NextResponse.json({ error: 'Corps JSON invalide' }, { status: 400 })
}
const { shopifyProductId, imageBase64, filename } = body
if (!shopifyProductId || typeof shopifyProductId !== 'string') {
return NextResponse.json({ error: 'shopifyProductId requis' }, { status: 400 })
}
if (!imageBase64 || typeof imageBase64 !== 'string') {
return NextResponse.json({ error: 'imageBase64 requis' }, { status: 400 })
}
if (!filename || typeof filename !== 'string') {
return NextResponse.json({ error: 'filename requis' }, { status: 400 })
}
try {
const result = await uploadProductImage(shopifyProductId, imageBase64, filename)
return NextResponse.json(result)
} catch (err) {
const message = err instanceof Error ? err.message : 'Erreur inconnue'
return NextResponse.json({ error: message }, { status: 500 })
}
}
+18
View File
@@ -0,0 +1,18 @@
import { NextRequest, NextResponse } from 'next/server'
import { searchProductByRef } from '@/lib/shopify'
export async function GET(req: NextRequest) {
const ref = req.nextUrl.searchParams.get('ref')
if (!ref || ref.trim() === '') {
return NextResponse.json({ error: 'Paramètre ref requis' }, { status: 400 })
}
try {
const result = await searchProductByRef(ref.trim())
return NextResponse.json(result)
} catch (err) {
const message = err instanceof Error ? err.message : 'Erreur inconnue'
return NextResponse.json({ error: message }, { status: 500 })
}
}
+73
View File
@@ -0,0 +1,73 @@
'use client'
import { useCallback, useRef, useState } from 'react'
interface DropZoneProps {
onFiles: (files: File[]) => void
disabled?: boolean
}
const ACCEPTED_TYPES = ['.heic', '.heif', '.jpg', '.jpeg', '.png', '.zip']
const ACCEPT_ATTR = ACCEPTED_TYPES.join(',')
export function DropZone({ onFiles, disabled = false }: DropZoneProps) {
const [isDragging, setIsDragging] = useState(false)
const inputRef = useRef<HTMLInputElement>(null)
const handleFiles = useCallback(
(fileList: FileList | null) => {
if (!fileList) return
const files = Array.from(fileList).filter((f) => {
const ext = '.' + f.name.split('.').pop()?.toLowerCase()
return ACCEPTED_TYPES.includes(ext)
})
if (files.length > 0) onFiles(files)
},
[onFiles],
)
const onDragOver = (e: React.DragEvent) => {
e.preventDefault()
if (!disabled) setIsDragging(true)
}
const onDragLeave = () => setIsDragging(false)
const onDrop = (e: React.DragEvent) => {
e.preventDefault()
setIsDragging(false)
if (!disabled) handleFiles(e.dataTransfer.files)
}
return (
<div
onDragOver={onDragOver}
onDragLeave={onDragLeave}
onDrop={onDrop}
onClick={() => !disabled && inputRef.current?.click()}
className={`border-2 border-dashed rounded-xl p-8 text-center cursor-pointer transition-colors ${
isDragging
? 'border-indigo-400 bg-indigo-950/30'
: disabled
? 'border-slate-700 bg-slate-800/20 cursor-not-allowed'
: 'border-slate-600 bg-slate-800/10 hover:border-indigo-500 hover:bg-indigo-950/20'
}`}
>
<div className="text-3xl mb-2">🖼</div>
<p className="text-sm font-semibold text-slate-300">
Déposez vos images ici ou cliquez pour parcourir
</p>
<p className="text-xs text-slate-500 mt-1">
HEIC, JPG, PNG, ZIP le nom du fichier = référence produit
</p>
<input
ref={inputRef}
type="file"
multiple
accept={ACCEPT_ATTR}
className="hidden"
onChange={(e) => handleFiles(e.target.files)}
disabled={disabled}
/>
</div>
)
}
+37
View File
@@ -0,0 +1,37 @@
'use client'
import type { ImageItem } from '@/types/images'
interface GlobalActionsProps {
items: ImageItem[]
onUploadAll: () => void
}
export function GlobalActions({ items, onUploadAll }: GlobalActionsProps) {
if (items.length === 0) return null
const ready = items.filter((i) => i.status === 'ready').length
const processing = items.filter((i) => ['pending', 'converting', 'processing', 'searching', 'uploading'].includes(i.status)).length
const notFound = items.filter((i) => i.status === 'not_found').length
const uploaded = items.filter((i) => i.status === 'uploaded').length
const errors = items.filter((i) => i.status === 'error').length
return (
<div className="bg-slate-900 border border-slate-700 rounded-xl px-4 py-3 flex items-center gap-6 flex-wrap">
<div className="flex gap-4 text-xs text-slate-400 flex-1 flex-wrap">
{processing > 0 && <span> {processing} en traitement</span>}
{ready > 0 && <span className="text-green-400 font-semibold"> {ready} prêtes</span>}
{notFound > 0 && <span className="text-red-400"> {notFound} introuvables</span>}
{errors > 0 && <span className="text-red-400"> {errors} erreurs</span>}
{uploaded > 0 && <span className="text-slate-500"> {uploaded} uploadées</span>}
</div>
{ready > 0 && (
<button
onClick={onUploadAll}
className="bg-indigo-600 hover:bg-indigo-500 text-white text-sm font-semibold px-4 py-2 rounded-lg transition-colors"
>
Tout uploader ({ready})
</button>
)}
</div>
)
}
+146
View File
@@ -0,0 +1,146 @@
'use client'
import { useEffect, useState } from 'react'
import type { ImageItem } from '@/types/images'
interface ImageCardProps {
item: ImageItem
onFeatherChange: (id: string, value: number) => void
onRotate: (id: string, direction: 'cw' | 'ccw') => void
onUpload: (id: string) => void
onRemove: (id: string) => void
onOpenLightbox: (item: ImageItem) => void
}
const STATUS_LABELS: Record<string, { label: string; color: string }> = {
pending: { label: '⏳ En attente', color: 'text-slate-400' },
converting: { label: '🔄 Conversion HEIC', color: 'text-yellow-400' },
processing: { label: '🤖 Détourage IA', color: 'text-blue-400' },
searching: { label: '🔍 Recherche…', color: 'text-purple-400' },
ready: { label: '✓ Prêt', color: 'text-green-400' },
uploading: { label: '⬆️ Upload…', color: 'text-blue-400' },
uploaded: { label: '✅ Uploadé', color: 'text-green-500' },
not_found: { label: '❌ Réf. introuvable', color: 'text-red-400' },
error: { label: '❌ Erreur', color: 'text-red-400' },
}
function BlobThumbnail({ blob, alt }: { blob: Blob; alt: string }) {
const [url, setUrl] = useState<string | null>(null)
useEffect(() => {
const objectUrl = URL.createObjectURL(blob)
setUrl(objectUrl)
return () => URL.revokeObjectURL(objectUrl)
}, [blob])
if (!url) return <div className="w-full h-full bg-slate-700 animate-pulse" />
return <img src={url} alt={alt} className="w-full h-full object-contain" />
}
export function ImageCard({
item,
onFeatherChange,
onRotate,
onUpload,
onRemove,
onOpenLightbox,
}: ImageCardProps) {
const statusInfo = STATUS_LABELS[item.status] ?? { label: item.status, color: 'text-slate-400' }
const isProcessing = ['pending', 'converting', 'processing', 'searching', 'uploading'].includes(item.status)
const canAdjust = !!item.rawPngBlob && !isProcessing
return (
<div className="bg-slate-900 border border-slate-700 rounded-xl overflow-hidden flex flex-col">
{/* Miniatures avant / après */}
<div
className="relative h-40 bg-slate-800 cursor-pointer"
onClick={() => onOpenLightbox(item)}
>
<div className="absolute inset-0">
<BlobThumbnail blob={item.originalBlob} alt="Original" />
</div>
{item.processedBlob && (
<div className="absolute inset-0 border-l-2 border-indigo-500 left-1/2 w-1/2">
<BlobThumbnail blob={item.processedBlob} alt="Traité" />
</div>
)}
{/* Barre de progression pendant l'IA */}
{item.status === 'processing' && item.progress && (
<div className="absolute bottom-0 inset-x-0 bg-black/60 px-2 py-1">
<div className="h-1 bg-slate-700 rounded">
<div
className="h-1 bg-indigo-500 rounded transition-all"
style={{ width: `${item.progress.percent}%` }}
/>
</div>
<p className="text-xs text-slate-400 mt-0.5">{item.progress.key} {item.progress.percent}%</p>
</div>
)}
</div>
{/* Infos */}
<div className="p-3 flex flex-col gap-2 flex-1">
<div>
<p className="text-xs font-mono text-slate-300 truncate">{item.ref}</p>
{item.shopifyProductTitle && (
<p className="text-xs text-slate-500 truncate">{item.shopifyProductTitle}</p>
)}
<p className={`text-xs font-semibold mt-0.5 ${statusInfo.color}`}>{statusInfo.label}</p>
{item.error && <p className="text-xs text-red-400 mt-0.5 truncate" title={item.error}>{item.error}</p>}
</div>
{/* Contrôles feather + rotation */}
<div className={canAdjust ? '' : 'opacity-40 pointer-events-none'}>
<label className="block text-xs text-slate-500 mb-1">
Bords : {item.feather} px
</label>
<input
type="range"
min={0}
max={5}
step={1}
value={item.feather}
onChange={(e) => onFeatherChange(item.id, Number(e.target.value))}
className="w-full accent-indigo-500"
/>
<div className="flex gap-1 mt-2">
<button
onClick={() => onRotate(item.id, 'ccw')}
className="flex-1 py-1 text-xs bg-slate-800 hover:bg-slate-700 rounded text-slate-300"
> -90°</button>
<button
onClick={() => onRotate(item.id, 'cw')}
className="flex-1 py-1 text-xs bg-slate-800 hover:bg-slate-700 rounded text-slate-300"
> +90°</button>
</div>
</div>
{/* Boutons d'action */}
<div className="flex gap-2 mt-auto pt-1">
{item.status === 'ready' && (
<button
onClick={() => onUpload(item.id)}
className="flex-1 py-1.5 text-xs bg-indigo-600 hover:bg-indigo-500 text-white font-semibold rounded"
>
Uploader
</button>
)}
{item.status === 'uploaded' && item.uploadedUrl && (
<a
href={item.uploadedUrl}
target="_blank"
rel="noopener noreferrer"
className="flex-1 py-1.5 text-xs bg-green-800 hover:bg-green-700 text-white font-semibold rounded text-center"
>
Voir sur Shopify
</a>
)}
<button
onClick={() => onRemove(item.id)}
className="py-1.5 px-2 text-xs bg-slate-800 hover:bg-red-900 text-slate-400 hover:text-red-300 rounded"
></button>
</div>
</div>
</div>
)
}
+70
View File
@@ -0,0 +1,70 @@
'use client'
import { useEffect, useState } from 'react'
import type { ImageItem } from '@/types/images'
interface LightboxProps {
item: ImageItem | null
onClose: () => void
}
function BlobImg({ blob, alt }: { blob: Blob; alt: string }) {
const [url, setUrl] = useState<string | null>(null)
useEffect(() => {
const u = URL.createObjectURL(blob)
setUrl(u)
return () => URL.revokeObjectURL(u)
}, [blob])
if (!url) return null
return <img src={url} alt={alt} className="max-h-full max-w-full object-contain" />
}
export function Lightbox({ item, onClose }: LightboxProps) {
useEffect(() => {
const onKey = (e: KeyboardEvent) => { if (e.key === 'Escape') onClose() }
document.addEventListener('keydown', onKey)
return () => document.removeEventListener('keydown', onKey)
}, [onClose])
if (!item) return null
return (
<div
className="fixed inset-0 z-50 bg-black/90 flex items-center justify-center p-4"
onClick={onClose}
>
<div
className="bg-slate-900 rounded-xl max-w-5xl w-full max-h-[90vh] overflow-hidden flex flex-col"
onClick={(e) => e.stopPropagation()}
>
<div className="flex items-center justify-between px-4 py-3 border-b border-slate-700">
<div>
<span className="text-sm font-mono text-slate-300">{item.ref}</span>
{item.shopifyProductTitle && (
<span className="text-xs text-slate-500 ml-2">{item.shopifyProductTitle}</span>
)}
</div>
<button
onClick={onClose}
className="text-slate-400 hover:text-white text-xl leading-none"
></button>
</div>
<div className="flex flex-1 overflow-hidden min-h-0">
<div className="flex-1 flex flex-col items-center justify-center p-4 bg-slate-950 border-r border-slate-700">
<p className="text-xs text-slate-500 mb-2">Avant</p>
<BlobImg blob={item.originalBlob} alt="Avant" />
</div>
<div className="flex-1 flex flex-col items-center justify-center p-4 bg-white/5">
<p className="text-xs text-slate-500 mb-2">Après</p>
{item.processedBlob
? <BlobImg blob={item.processedBlob} alt="Après" />
: <p className="text-slate-600 text-sm">Traitement en cours</p>
}
</div>
</div>
</div>
</div>
)
}
+246
View File
@@ -0,0 +1,246 @@
'use client'
import { useReducer, useCallback } from 'react'
import type { ImageItem, ImageStatus } from '@/types/images'
// ── Reducer ───────────────────────────────────────────────────────────────────
type Action =
| { type: 'ADD'; items: ImageItem[] }
| { type: 'UPDATE'; id: string; patch: Partial<ImageItem> }
| { type: 'REMOVE'; id: string }
function reducer(state: ImageItem[], action: Action): ImageItem[] {
switch (action.type) {
case 'ADD':
return [...state, ...action.items]
case 'UPDATE':
return state.map((item) =>
item.id === action.id ? { ...item, ...action.patch } : item,
)
case 'REMOVE':
return state.filter((item) => item.id !== action.id)
}
}
// ── Helpers ───────────────────────────────────────────────────────────────────
function refFromFilename(filename: string): string {
const dot = filename.lastIndexOf('.')
return dot === -1 ? filename : filename.slice(0, dot)
}
function blobToBase64(blob: Blob): Promise<string> {
return new Promise((resolve, reject) => {
const reader = new FileReader()
reader.onload = () => {
const result = reader.result as string
// Supprimer le préfixe "data:image/jpeg;base64,"
const base64 = result.split(',')[1]
resolve(base64)
}
reader.onerror = () => reject(new Error('FileReader error'))
reader.readAsDataURL(blob)
})
}
// ── Hook ──────────────────────────────────────────────────────────────────────
export function useImages() {
const [items, dispatch] = useReducer(reducer, [])
const update = useCallback((id: string, patch: Partial<ImageItem>) => {
dispatch({ type: 'UPDATE', id, patch })
}, [])
// Recherche produit Shopify
const searchProduct = useCallback(
async (id: string, ref: string) => {
update(id, { status: 'searching' })
try {
const res = await fetch(`/api/products/search?ref=${encodeURIComponent(ref)}`)
const data = await res.json()
if (data.found) {
update(id, {
status: 'ready',
shopifyProductId: data.shopifyId,
shopifyProductTitle: data.title,
})
} else {
update(id, { status: 'not_found' })
}
} catch (err) {
update(id, { status: 'error', error: `Recherche produit : ${(err as Error).message}` })
}
},
[update],
)
// Pipeline complet pour un item (avec ref)
const runPipeline = useCallback(
async (id: string, blob: Blob, ref: string, feather: number, rotation: number) => {
const { convertHeicToJpeg } = await import('@/lib/client/heicConverter')
const { removeBackgroundRaw, compositeOnWhite } = await import('@/lib/client/bgRemover')
const { rotateBlob } = await import('@/lib/client/imageRotator')
let jpegBlob = blob
if (blob.type === 'image/heic' || blob.type === 'image/heif' || blob.type === '') {
try {
update(id, { status: 'converting' })
jpegBlob = await convertHeicToJpeg(blob)
} catch (err) {
update(id, { status: 'error', error: `Conversion HEIC : ${(err as Error).message}` })
return
}
}
let rawPng: Blob
try {
update(id, { status: 'processing', progress: null })
rawPng = await removeBackgroundRaw(jpegBlob, (p) => update(id, { progress: p }))
} catch (err) {
update(id, { status: 'error', error: `Détourage IA : ${(err as Error).message}` })
return
}
let finalBlob: Blob
try {
const composited = await compositeOnWhite(rawPng, feather)
finalBlob = await rotateBlob(composited, rotation)
} catch (err) {
update(id, { status: 'error', error: `Post-traitement : ${(err as Error).message}` })
return
}
update(id, { rawPngBlob: rawPng, processedBlob: finalBlob })
await searchProduct(id, ref)
},
[update, searchProduct],
)
// Recomposite rapide (feather ou rotation changés) — ne relance pas l'IA
const recomposite = useCallback(
async (id: string, rawPngBlob: Blob, feather: number, rotation: number) => {
const { compositeOnWhite } = await import('@/lib/client/bgRemover')
const { rotateBlob } = await import('@/lib/client/imageRotator')
try {
const composited = await compositeOnWhite(rawPngBlob, feather)
const finalBlob = await rotateBlob(composited, rotation)
update(id, { processedBlob: finalBlob, feather, rotation })
} catch (err) {
update(id, { status: 'error', error: `Retraitement : ${(err as Error).message}` })
}
},
[update],
)
// ── Actions exposées ───────────────────────────────────────────────────────
const addFiles = useCallback(
async (files: File[]) => {
const { readImagesFromZip } = await import('@/lib/client/zipImporter')
for (const file of files) {
if (file.name.toLowerCase().endsWith('.zip')) {
const entries = await readImagesFromZip(file)
const newItems: ImageItem[] = entries.map((entry) => ({
id: crypto.randomUUID(),
filename: `${entry.folder}/${entry.filename}.${entry.ext}`,
ref: entry.folder,
originalBlob: entry.blob,
rawPngBlob: null,
processedBlob: null,
rotation: 0,
feather: 2,
shopifyProductId: null,
shopifyProductTitle: null,
uploadedUrl: null,
status: 'pending' as ImageStatus,
error: null,
progress: null,
}))
dispatch({ type: 'ADD', items: newItems })
for (const item of newItems) {
runPipeline(item.id, item.originalBlob, item.ref, item.feather, item.rotation)
}
} else {
const ref = refFromFilename(file.name)
const newItem: ImageItem = {
id: crypto.randomUUID(),
filename: file.name,
ref,
originalBlob: file,
rawPngBlob: null,
processedBlob: null,
rotation: 0,
feather: 2,
shopifyProductId: null,
shopifyProductTitle: null,
uploadedUrl: null,
status: 'pending',
error: null,
progress: null,
}
dispatch({ type: 'ADD', items: [newItem] })
runPipeline(newItem.id, newItem.originalBlob, newItem.ref, newItem.feather, newItem.rotation)
}
}
},
[runPipeline],
)
const setFeather = useCallback(
(id: string, value: number) => {
const item = items.find((i) => i.id === id)
if (!item || !item.rawPngBlob) return
recomposite(id, item.rawPngBlob, value, item.rotation)
},
[items, recomposite],
)
const rotate = useCallback(
(id: string, direction: 'cw' | 'ccw') => {
const item = items.find((i) => i.id === id)
if (!item || !item.rawPngBlob) return
const delta = direction === 'cw' ? 90 : -90
const newRotation = ((item.rotation + delta) % 360 + 360) % 360
recomposite(id, item.rawPngBlob, item.feather, newRotation)
},
[items, recomposite],
)
const uploadOne = useCallback(
async (id: string) => {
const item = items.find((i) => i.id === id)
if (!item || item.status !== 'ready' || !item.processedBlob || !item.shopifyProductId) return
update(id, { status: 'uploading' })
try {
const base64 = await blobToBase64(item.processedBlob)
const filename = `${item.ref}.jpg`
const res = await fetch('/api/images/upload', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ shopifyProductId: item.shopifyProductId, imageBase64: base64, filename }),
})
const data = await res.json()
if (!res.ok) throw new Error(data.error || `HTTP ${res.status}`)
update(id, { status: 'uploaded', uploadedUrl: data.url })
} catch (err) {
update(id, { status: 'error', error: `Upload : ${(err as Error).message}` })
}
},
[items, update],
)
const uploadAll = useCallback(() => {
items.filter((i) => i.status === 'ready').forEach((i) => uploadOne(i.id))
}, [items, uploadOne])
const removeItem = useCallback((id: string) => {
dispatch({ type: 'REMOVE', id })
}, [])
return { items, addFiles, setFeather, rotate, uploadOne, uploadAll, removeItem }
}
+89
View File
@@ -0,0 +1,89 @@
const CDN_VERSION = '1.7.0'
const CDN_URL = `https://cdn.jsdelivr.net/npm/@imgly/background-removal@${CDN_VERSION}/dist/index.mjs`
let _removeBg: ((blob: Blob, opts: Record<string, unknown>) => Promise<Blob>) | null = null
async function loadLib() {
if (_removeBg) return _removeBg
const mod = await import(/* webpackIgnore: true */ CDN_URL)
_removeBg = mod.removeBackground
return _removeBg!
}
export interface ProgressEvent {
key: string
percent: number
}
/**
* Supprime le fond d'un Blob image via @imgly IA.
* Retourne un PNG avec fond transparent (à combiner avec compositeOnWhite).
*/
export async function removeBackgroundRaw(
blob: Blob,
onProgress?: (e: ProgressEvent) => void,
): Promise<Blob> {
const removeBg = await loadLib()
return removeBg(blob, {
output: { format: 'image/png', type: 'foreground', quality: 1 },
progress: (key: string, current: number, total: number) => {
if (onProgress && total > 0) {
onProgress({ key, percent: Math.round((current / total) * 100) })
}
},
})
}
/**
* Composite un PNG transparent sur fond blanc avec feather optionnel.
* @param pngBlob — PNG avec transparence (résultat de removeBackgroundRaw)
* @param feather — récupération des bords en px (0 = strict, 5 = doux)
*/
export function compositeOnWhite(pngBlob: Blob, feather: number): Promise<Blob> {
const objectUrl = URL.createObjectURL(pngBlob)
return new Promise((resolve, reject) => {
const img = new Image()
img.onload = () => {
const { width, height } = img
const fgCanvas = document.createElement('canvas')
fgCanvas.width = width
fgCanvas.height = height
const fgCtx = fgCanvas.getContext('2d')!
fgCtx.drawImage(img, 0, 0)
if (feather > 0) {
fgCtx.globalCompositeOperation = 'destination-over'
fgCtx.filter = `blur(${feather}px)`
fgCtx.drawImage(img, 0, 0)
fgCtx.filter = 'none'
fgCtx.globalCompositeOperation = 'source-over'
}
const out = document.createElement('canvas')
out.width = width
out.height = height
const ctx = out.getContext('2d')!
ctx.fillStyle = '#ffffff'
ctx.fillRect(0, 0, width, height)
ctx.drawImage(fgCanvas, 0, 0)
URL.revokeObjectURL(objectUrl)
out.toBlob(
(b) => (b ? resolve(b) : reject(new Error('canvas.toBlob a retourné null'))),
'image/jpeg',
0.92,
)
}
img.onerror = () => {
URL.revokeObjectURL(objectUrl)
reject(new Error('Impossible de charger le PNG'))
}
img.src = objectUrl
})
}
+31
View File
@@ -0,0 +1,31 @@
/**
* Convertit un Blob HEIC/HEIF en Blob JPEG via heic2any.
* À utiliser uniquement côté client (navigateur).
*/
export async function convertHeicToJpeg(blob: Blob): Promise<Blob> {
if (!(blob instanceof Blob)) {
throw new TypeError('L\'entrée doit être un Blob')
}
const type = blob.type.toLowerCase()
if (type !== '' && !['image/heic', 'image/heif'].includes(type)) {
throw new TypeError(`Format attendu : HEIC/HEIF. Reçu : ${blob.type || '(vide)'}`)
}
const heic2any = (await import('heic2any')).default
const timeout = new Promise<never>((_, reject) =>
setTimeout(() => reject(new Error('Timeout conversion HEIC (120s)')), 120_000),
)
const conversion = heic2any({ blob, toType: 'image/jpeg', quality: 0.92 })
const result = await Promise.race([conversion, timeout])
const output = Array.isArray(result) ? result[0] : result
if (!(output instanceof Blob)) {
throw new Error("heic2any n'a pas retourné un Blob")
}
return output
}
+44
View File
@@ -0,0 +1,44 @@
/**
* Pivote un Blob JPEG via Canvas.
* @param blob — image source (JPEG)
* @param degrees — angle en degrés (0, 90, 180, 270)
* @returns Blob JPEG pivoté, ou le blob original si degrees === 0
*/
export function rotateBlob(blob: Blob, degrees: number): Promise<Blob> {
const norm = ((degrees % 360) + 360) % 360
if (norm === 0) return Promise.resolve(blob)
const url = URL.createObjectURL(blob)
return new Promise((resolve, reject) => {
const img = new Image()
img.onload = () => {
const swap = norm === 90 || norm === 270
const w = swap ? img.naturalHeight : img.naturalWidth
const h = swap ? img.naturalWidth : img.naturalHeight
const canvas = document.createElement('canvas')
canvas.width = w
canvas.height = h
const ctx = canvas.getContext('2d')!
ctx.translate(w / 2, h / 2)
ctx.rotate((norm * Math.PI) / 180)
ctx.drawImage(img, -img.naturalWidth / 2, -img.naturalHeight / 2)
URL.revokeObjectURL(url)
canvas.toBlob(
(b) => (b ? resolve(b) : reject(new Error('rotateBlob : toBlob a retourné null'))),
'image/jpeg',
0.92,
)
}
img.onerror = () => {
URL.revokeObjectURL(url)
reject(new Error('rotateBlob : impossible de charger le blob'))
}
img.src = url
})
}
+47
View File
@@ -0,0 +1,47 @@
import JSZip from 'jszip'
import type { ZipEntry } from '@/types/images'
const IMAGE_EXTS = new Set(['heic', 'heif', 'jpg', 'jpeg', 'png'])
/**
* Extrait toutes les images d'un fichier ZIP.
* Structure attendue : dossier-ref/image.ext (le nom du dossier = référence produit).
*/
export async function readImagesFromZip(file: File): Promise<ZipEntry[]> {
const zip = await JSZip.loadAsync(file)
const entries: Array<{ relativePath: string; entry: JSZip.JSZipObject }> = []
zip.forEach((relativePath, entry) => {
if (!entry.dir) entries.push({ relativePath, entry })
})
const results: ZipEntry[] = []
for (const { relativePath, entry } of entries) {
if (relativePath.startsWith('__MACOSX/')) continue
const parts = relativePath.split('/').filter((p) => p.length > 0)
const filename = parts[parts.length - 1]
if (filename.startsWith('.')) continue
const dotIdx = filename.lastIndexOf('.')
if (dotIdx === -1) continue
const ext = filename.slice(dotIdx + 1).toLowerCase()
if (!IMAGE_EXTS.has(ext)) continue
const folder = parts.length > 1 ? parts[0] : filename.slice(0, dotIdx)
const isHeic = ext === 'heic' || ext === 'heif'
const mimeType = isHeic
? 'image/heic'
: ext === 'jpg' || ext === 'jpeg'
? 'image/jpeg'
: 'image/png'
const rawBlob = await entry.async('blob')
const blob = new Blob([rawBlob], { type: mimeType })
results.push({ folder, filename: filename.slice(0, dotIdx), ext, blob, isHeic })
}
return results
}
+83
View File
@@ -0,0 +1,83 @@
const API_VERSION = '2024-01'
function getConfig() {
const domain = process.env.SHOPIFY_STORE_DOMAIN
const token = process.env.SHOPIFY_ADMIN_API_TOKEN
if (!domain || !token) {
throw new Error('SHOPIFY_STORE_DOMAIN et SHOPIFY_ADMIN_API_TOKEN requis')
}
return {
baseUrl: `https://${domain}/admin/api/${API_VERSION}`,
headers: {
'X-Shopify-Access-Token': token,
'Content-Type': 'application/json',
},
}
}
export type ShopifyProductResult =
| { found: false }
| { found: true; shopifyId: string; title: string }
/**
* Recherche un produit Shopify par sa référence.
* Tente d'abord par handle (ex: "ref-1042"), puis par titre exact.
*/
export async function searchProductByRef(ref: string): Promise<ShopifyProductResult> {
const { baseUrl, headers } = getConfig()
// Normalise la référence en handle Shopify (minuscules, tirets)
const handle = ref.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '')
const tryFetch = async (params: string) => {
const res = await fetch(`${baseUrl}/products.json?${params}&fields=id,title,handle&limit=1`, { headers })
if (!res.ok) throw new Error(`Shopify API error: ${res.status}`)
const data = await res.json()
return (data.products ?? []) as Array<{ id: number; title: string; handle: string }>
}
let products = await tryFetch(`handle=${encodeURIComponent(handle)}`)
if (products.length === 0) {
products = await tryFetch(`title=${encodeURIComponent(ref)}`)
}
if (products.length === 0) return { found: false }
return {
found: true,
shopifyId: String(products[0].id),
title: products[0].title,
}
}
/**
* Upload une image (base64) vers un produit Shopify via l'API REST.
* @param shopifyProductId — ID numérique du produit (string)
* @param imageBase64 — contenu JPEG encodé en base64 (sans le préfixe data:)
* @param filename — nom de fichier avec extension (ex: "REF-1042.jpg")
*/
export async function uploadProductImage(
shopifyProductId: string,
imageBase64: string,
filename: string,
): Promise<{ url: string }> {
const { baseUrl, headers } = getConfig()
const res = await fetch(`${baseUrl}/products/${shopifyProductId}/images.json`, {
method: 'POST',
headers,
body: JSON.stringify({ image: { attachment: imageBase64, filename } }),
})
if (!res.ok) {
const body = await res.text()
throw new Error(`Shopify upload error: ${res.status}${body}`)
}
const data = await res.json()
if (!data.image?.src) {
throw new Error('Shopify response invalide : champ image.src manquant')
}
return { url: data.image.src as string }
}
+40
View File
@@ -0,0 +1,40 @@
export type ImageStatus =
| 'pending' // ajouté, pas encore traité
| 'converting' // HEIC → JPEG en cours
| 'processing' // détourage IA en cours
| 'searching' // recherche produit Shopify en cours
| 'ready' // prêt à uploader
| 'uploading' // upload Shopify en cours
| 'uploaded' // uploadé avec succès
| 'not_found' // référence produit introuvable dans Shopify
| 'error' // erreur à n'importe quelle étape
export interface ImageProgress {
key: string
percent: number
}
export interface ImageItem {
id: string
filename: string // nom de fichier original (ex: "REF-1042.heic")
ref: string // référence produit extraite (ex: "REF-1042")
originalBlob: Blob // image originale (pour la vue "avant")
rawPngBlob: Blob | null // PNG transparent issu du détourage IA (cache pour feather)
processedBlob: Blob | null // JPEG final sur fond blanc, pivoté (pour upload + vue "après")
rotation: number // 0, 90, 180, 270
feather: number // 05, défaut 2
shopifyProductId: string | null
shopifyProductTitle: string | null
uploadedUrl: string | null
status: ImageStatus
error: string | null
progress: ImageProgress | null
}
export interface ZipEntry {
folder: string // nom du dossier = référence produit
filename: string // nom de fichier dans le dossier
ext: string // extension sans point, en minuscules
blob: Blob // blob avec type MIME correct
isHeic: boolean
}