diff --git a/src/hooks/useImages.ts b/src/hooks/useImages.ts new file mode 100644 index 0000000..25669bb --- /dev/null +++ b/src/hooks/useImages.ts @@ -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 } + | { 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 { + 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) => { + 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 } +}