feat: add mockup creation from image cards on images page
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -5,6 +5,7 @@ import { DropZone } from '@/components/images/DropZone'
|
|||||||
import { ImageCard } from '@/components/images/ImageCard'
|
import { ImageCard } from '@/components/images/ImageCard'
|
||||||
import { Lightbox } from '@/components/images/Lightbox'
|
import { Lightbox } from '@/components/images/Lightbox'
|
||||||
import { GlobalActions } from '@/components/images/GlobalActions'
|
import { GlobalActions } from '@/components/images/GlobalActions'
|
||||||
|
import { MockupModalFromImage } from '@/components/mockup/MockupModalFromImage'
|
||||||
import { ErrorBoundary } from '@/components/common/ErrorBoundary'
|
import { ErrorBoundary } from '@/components/common/ErrorBoundary'
|
||||||
import { useImages } from '@/hooks/useImages'
|
import { useImages } from '@/hooks/useImages'
|
||||||
import type { ImageItem } from '@/types/images'
|
import type { ImageItem } from '@/types/images'
|
||||||
@@ -12,6 +13,12 @@ import type { ImageItem } from '@/types/images'
|
|||||||
function ImagesPageInner() {
|
function ImagesPageInner() {
|
||||||
const { items, addFiles, setFeather, setAlphaThreshold, rotate, uploadOne, uploadAll, removeItem, assignProduct, reprocess, detour } = useImages()
|
const { items, addFiles, setFeather, setAlphaThreshold, rotate, uploadOne, uploadAll, removeItem, assignProduct, reprocess, detour } = useImages()
|
||||||
const [lightboxItem, setLightboxItem] = useState<ImageItem | null>(null)
|
const [lightboxItem, setLightboxItem] = useState<ImageItem | null>(null)
|
||||||
|
const [mockupItem, setMockupItem] = useState<ImageItem | null>(null)
|
||||||
|
|
||||||
|
const openMockup = useCallback((id: string) => {
|
||||||
|
const item = items.find(i => i.id === id)
|
||||||
|
if (item) setMockupItem(item)
|
||||||
|
}, [items])
|
||||||
const [selected, setSelected] = useState<Set<string>>(new Set())
|
const [selected, setSelected] = useState<Set<string>>(new Set())
|
||||||
|
|
||||||
const toggleSelect = useCallback((id: string, value: boolean) => {
|
const toggleSelect = useCallback((id: string, value: boolean) => {
|
||||||
@@ -101,6 +108,7 @@ function ImagesPageInner() {
|
|||||||
onAssignProduct={assignProduct}
|
onAssignProduct={assignProduct}
|
||||||
onReprocess={reprocess}
|
onReprocess={reprocess}
|
||||||
onDetour={detour}
|
onDetour={detour}
|
||||||
|
onMockup={openMockup}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
@@ -116,6 +124,9 @@ function ImagesPageInner() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Lightbox item={lightboxItem} onClose={() => setLightboxItem(null)} />
|
<Lightbox item={lightboxItem} onClose={() => setLightboxItem(null)} />
|
||||||
|
{mockupItem && (
|
||||||
|
<MockupModalFromImage item={mockupItem} onClose={() => setMockupItem(null)} />
|
||||||
|
)}
|
||||||
</>
|
</>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ interface ImageCardProps {
|
|||||||
onAssignProduct: (id: string, shopifyProductId: string, shopifyProductTitle: string) => void
|
onAssignProduct: (id: string, shopifyProductId: string, shopifyProductTitle: string) => void
|
||||||
onReprocess: (id: string) => void
|
onReprocess: (id: string) => void
|
||||||
onDetour: (id: string) => void
|
onDetour: (id: string) => void
|
||||||
|
onMockup: (id: string) => void
|
||||||
}
|
}
|
||||||
|
|
||||||
const STATUS_LABELS: Record<string, { label: string; color: string }> = {
|
const STATUS_LABELS: Record<string, { label: string; color: string }> = {
|
||||||
@@ -110,6 +111,7 @@ export function ImageCard({
|
|||||||
onAssignProduct,
|
onAssignProduct,
|
||||||
onReprocess,
|
onReprocess,
|
||||||
onDetour,
|
onDetour,
|
||||||
|
onMockup,
|
||||||
}: ImageCardProps) {
|
}: ImageCardProps) {
|
||||||
const statusInfo = STATUS_LABELS[item.status] ?? { label: item.status, color: 'text-slate-400' }
|
const statusInfo = STATUS_LABELS[item.status] ?? { label: item.status, color: 'text-slate-400' }
|
||||||
const isProcessing = ['pending', 'converting', 'processing', 'searching', 'uploading'].includes(item.status)
|
const isProcessing = ['pending', 'converting', 'processing', 'searching', 'uploading'].includes(item.status)
|
||||||
@@ -231,6 +233,15 @@ export function ImageCard({
|
|||||||
✂️ Détourer
|
✂️ Détourer
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
|
{(item.status === 'ready' || item.status === 'uploaded' || item.status === 'skipped' || item.status === 'converted') && (
|
||||||
|
<button
|
||||||
|
onClick={() => onMockup(item.id)}
|
||||||
|
title="Créer un mockup"
|
||||||
|
className="py-1.5 px-2 text-xs bg-slate-800 hover:bg-teal-900 text-slate-400 hover:text-teal-300 rounded"
|
||||||
|
>
|
||||||
|
🖼️
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
{(item.status === 'ready' || item.status === 'uploaded' || item.status === 'skipped' || item.status === 'error') && item.rawPngBlob && (
|
{(item.status === 'ready' || item.status === 'uploaded' || item.status === 'skipped' || item.status === 'error') && item.rawPngBlob && (
|
||||||
<button
|
<button
|
||||||
onClick={() => onReprocess(item.id)}
|
onClick={() => onReprocess(item.id)}
|
||||||
|
|||||||
@@ -0,0 +1,281 @@
|
|||||||
|
'use client'
|
||||||
|
import { useState, useEffect, useRef } from 'react'
|
||||||
|
import type { ImageItem } from '@/types/images'
|
||||||
|
import type { MockupTemplate } from '@/types/mockup'
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
item: ImageItem
|
||||||
|
onClose: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
function blobToBase64(blob: Blob): Promise<string> {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const reader = new FileReader()
|
||||||
|
reader.onload = () => resolve((reader.result as string).split(',')[1])
|
||||||
|
reader.onerror = reject
|
||||||
|
reader.readAsDataURL(blob)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export function MockupModalFromImage({ item, onClose }: Props) {
|
||||||
|
const [templates, setTemplates] = useState<MockupTemplate[]>([])
|
||||||
|
const [families, setFamilies] = useState<string[]>([])
|
||||||
|
const [family, setFamily] = useState('')
|
||||||
|
const [selectedTemplate, setSelectedTemplate] = useState<string | null>(null)
|
||||||
|
const [instruction, setInstruction] = useState('')
|
||||||
|
const [generating, setGenerating] = useState(false)
|
||||||
|
const [resultB64, setResultB64] = useState<string | null>(null)
|
||||||
|
const [uploading, setUploading] = useState(false)
|
||||||
|
const [uploadedUrl, setUploadedUrl] = useState<string | null>(null)
|
||||||
|
const [error, setError] = useState<string | null>(null)
|
||||||
|
const templateFileRef = useRef<HTMLInputElement>(null)
|
||||||
|
const [uploadingTemplate, setUploadingTemplate] = useState(false)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
Promise.all([
|
||||||
|
fetch('/api/mockup/templates').then(r => r.json()),
|
||||||
|
fetch('/api/sheets/tabs').then(r => r.json()),
|
||||||
|
]).then(([tplData, tabsData]) => {
|
||||||
|
setTemplates(tplData as MockupTemplate[])
|
||||||
|
const tabs: string[] = (tabsData.tabs ?? []).filter((t: string) =>
|
||||||
|
!['COLLECTIONS', 'LIVRAISONS', 'ECOPART', 'RECAP'].some(x => t.toUpperCase().includes(x))
|
||||||
|
)
|
||||||
|
setFamilies(tabs)
|
||||||
|
setFamily(tabs[0] ?? '')
|
||||||
|
})
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const filteredTemplates = templates.filter(t =>
|
||||||
|
t.family === family || t.family === 'Tous'
|
||||||
|
)
|
||||||
|
|
||||||
|
const uploadTemplate = async (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
|
const file = e.target.files?.[0]
|
||||||
|
if (!file) return
|
||||||
|
setUploadingTemplate(true)
|
||||||
|
const form = new FormData()
|
||||||
|
form.append('name', file.name.replace(/\.[^.]+$/, ''))
|
||||||
|
form.append('family', family || 'Tous')
|
||||||
|
form.append('description', '')
|
||||||
|
form.append('image', file)
|
||||||
|
const res = await fetch('/api/mockup/templates', { method: 'POST', body: form })
|
||||||
|
const tpl = await res.json() as MockupTemplate
|
||||||
|
setTemplates(prev => [...prev, tpl])
|
||||||
|
setSelectedTemplate(tpl.id)
|
||||||
|
setUploadingTemplate(false)
|
||||||
|
}
|
||||||
|
|
||||||
|
const generate = async () => {
|
||||||
|
if (!selectedTemplate) return
|
||||||
|
setGenerating(true)
|
||||||
|
setError(null)
|
||||||
|
setResultB64(null)
|
||||||
|
try {
|
||||||
|
// Utilise l'image détourée si disponible, sinon l'originale
|
||||||
|
const sourceBlob = item.processedBlob ?? item.originalBlob
|
||||||
|
const productImageBase64 = await blobToBase64(sourceBlob)
|
||||||
|
const res = await fetch('/api/mockup/generate', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({
|
||||||
|
templateId: selectedTemplate,
|
||||||
|
productImageBase64,
|
||||||
|
productName: item.shopifyProductTitle ?? item.ref,
|
||||||
|
family: family || 'Tous',
|
||||||
|
instruction: instruction || undefined,
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
const data = await res.json()
|
||||||
|
if (!res.ok) throw new Error(data.error)
|
||||||
|
setResultB64(data.imageBase64)
|
||||||
|
} catch (e) {
|
||||||
|
setError(e instanceof Error ? e.message : 'Erreur')
|
||||||
|
} finally {
|
||||||
|
setGenerating(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const upload = async () => {
|
||||||
|
if (!resultB64 || !item.shopifyProductId) return
|
||||||
|
setUploading(true)
|
||||||
|
try {
|
||||||
|
const res = await fetch('/api/images/upload', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({
|
||||||
|
shopifyProductId: item.shopifyProductId,
|
||||||
|
imageBase64: resultB64,
|
||||||
|
filename: `${item.ref}-mockup.jpg`,
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
const data = await res.json()
|
||||||
|
if (!res.ok) throw new Error(data.error)
|
||||||
|
setUploadedUrl(data.url)
|
||||||
|
} catch (e) {
|
||||||
|
setError(e instanceof Error ? e.message : 'Erreur upload')
|
||||||
|
} finally {
|
||||||
|
setUploading(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const downloadResult = () => {
|
||||||
|
if (!resultB64) return
|
||||||
|
const link = document.createElement('a')
|
||||||
|
link.href = `data:image/jpeg;base64,${resultB64}`
|
||||||
|
link.download = `${item.ref}-mockup.jpg`
|
||||||
|
link.click()
|
||||||
|
}
|
||||||
|
|
||||||
|
const sourceLabel = item.processedBlob ? 'image détourée' : 'image originale'
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className="fixed inset-0 z-50 flex items-center justify-center bg-black/70 p-4"
|
||||||
|
onClick={e => { if (e.target === e.currentTarget) onClose() }}
|
||||||
|
>
|
||||||
|
<div className="bg-slate-800 border border-slate-700 rounded-xl w-full max-w-xl max-h-[90vh] overflow-y-auto">
|
||||||
|
|
||||||
|
{/* Header */}
|
||||||
|
<div className="flex items-center justify-between p-4 border-b border-slate-700 sticky top-0 bg-slate-800 z-10">
|
||||||
|
<div>
|
||||||
|
<h2 className="text-white font-semibold text-sm">Créer un mockup</h2>
|
||||||
|
<p className="text-gray-400 text-xs mt-0.5">
|
||||||
|
{item.shopifyProductTitle ?? item.ref}
|
||||||
|
<span className="ml-2 text-indigo-300 font-mono">{item.ref}</span>
|
||||||
|
<span className="ml-2 text-slate-500">· {sourceLabel}</span>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<button type="button" onClick={onClose} className="text-gray-400 hover:text-white text-lg leading-none">✕</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="p-4 space-y-5">
|
||||||
|
|
||||||
|
{/* Famille */}
|
||||||
|
<section>
|
||||||
|
<h3 className="text-xs font-semibold text-gray-300 uppercase tracking-wide mb-2">Famille</h3>
|
||||||
|
<select
|
||||||
|
value={family}
|
||||||
|
onChange={e => setFamily(e.target.value)}
|
||||||
|
className="w-full bg-slate-700 border border-slate-600 rounded px-2 py-1.5 text-sm text-white focus:outline-none focus:ring-1 focus:ring-indigo-500"
|
||||||
|
>
|
||||||
|
{families.map(f => <option key={f}>{f}</option>)}
|
||||||
|
</select>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{/* Template */}
|
||||||
|
<section>
|
||||||
|
<div className="flex items-center justify-between mb-2">
|
||||||
|
<h3 className="text-xs font-semibold text-gray-300 uppercase tracking-wide">Template</h3>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => templateFileRef.current?.click()}
|
||||||
|
disabled={uploadingTemplate}
|
||||||
|
className="text-xs text-indigo-400 hover:text-indigo-300 disabled:opacity-40"
|
||||||
|
>
|
||||||
|
{uploadingTemplate ? 'Upload…' : '+ Nouveau'}
|
||||||
|
</button>
|
||||||
|
<input ref={templateFileRef} type="file" accept="image/*" className="hidden" onChange={uploadTemplate} />
|
||||||
|
</div>
|
||||||
|
{filteredTemplates.length === 0 ? (
|
||||||
|
<p className="text-xs text-gray-500">Aucun template pour cette famille. Uploadez-en un.</p>
|
||||||
|
) : (
|
||||||
|
<div className="grid grid-cols-4 gap-2">
|
||||||
|
{filteredTemplates.map(t => (
|
||||||
|
<button
|
||||||
|
key={t.id}
|
||||||
|
type="button"
|
||||||
|
onClick={() => setSelectedTemplate(t.id)}
|
||||||
|
className={`relative rounded-lg overflow-hidden border-2 transition-all ${selectedTemplate === t.id ? 'border-indigo-500' : 'border-slate-600 hover:border-slate-400'}`}
|
||||||
|
>
|
||||||
|
<img src={`/api/mockup/templates/image/${t.filename}`} alt={t.name} className="w-full h-16 object-cover" />
|
||||||
|
<div className="absolute bottom-0 left-0 right-0 bg-black/60 px-1 py-0.5">
|
||||||
|
<p className="text-xs text-white truncate">{t.name}</p>
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{/* Instruction libre */}
|
||||||
|
<section>
|
||||||
|
<label className="block text-xs font-semibold text-gray-300 uppercase tracking-wide mb-1">
|
||||||
|
Instruction personnalisée <span className="text-gray-500 font-normal normal-case">(optionnel)</span>
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={instruction}
|
||||||
|
onChange={e => setInstruction(e.target.value)}
|
||||||
|
placeholder="Ex: fond beige, lumière naturelle, ambiance chaleureuse…"
|
||||||
|
className="w-full bg-slate-700 border border-slate-600 rounded px-2 py-1.5 text-xs text-white focus:outline-none focus:ring-1 focus:ring-indigo-500"
|
||||||
|
/>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={generate}
|
||||||
|
disabled={!selectedTemplate || generating}
|
||||||
|
className="w-full py-2 rounded-lg bg-indigo-600 text-white text-sm font-medium disabled:opacity-40 hover:bg-indigo-500 transition-colors"
|
||||||
|
>
|
||||||
|
{generating ? 'Génération en cours…' : 'Générer le mockup'}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{error && <p className="text-xs text-red-400">{error}</p>}
|
||||||
|
|
||||||
|
{/* Résultat */}
|
||||||
|
{resultB64 && (
|
||||||
|
<div className="space-y-3">
|
||||||
|
<img
|
||||||
|
src={`data:image/jpeg;base64,${resultB64}`}
|
||||||
|
alt="Mockup"
|
||||||
|
className="w-full rounded-lg border border-slate-600"
|
||||||
|
/>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={instruction}
|
||||||
|
onChange={e => setInstruction(e.target.value)}
|
||||||
|
placeholder="Modifier l'instruction et regénérer…"
|
||||||
|
className="flex-1 bg-slate-700 border border-slate-600 rounded px-2 py-1.5 text-xs text-white focus:outline-none focus:ring-1 focus:ring-indigo-500"
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={generate}
|
||||||
|
disabled={generating}
|
||||||
|
className="px-3 py-1.5 rounded bg-slate-600 text-xs text-white hover:bg-slate-500 disabled:opacity-40"
|
||||||
|
>
|
||||||
|
{generating ? '…' : '↺'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={downloadResult}
|
||||||
|
className="flex-1 py-2 rounded-lg bg-slate-700 hover:bg-slate-600 text-white text-sm font-medium transition-colors"
|
||||||
|
>
|
||||||
|
↓ Télécharger
|
||||||
|
</button>
|
||||||
|
{item.shopifyProductId ? (
|
||||||
|
uploadedUrl ? (
|
||||||
|
<p className="flex-1 py-2 text-center text-xs text-green-400 self-center">✓ Uploadé sur Shopify</p>
|
||||||
|
) : (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={upload}
|
||||||
|
disabled={uploading}
|
||||||
|
className="flex-1 py-2 rounded-lg bg-emerald-600 hover:bg-emerald-500 text-white text-sm font-medium disabled:opacity-40 transition-colors"
|
||||||
|
>
|
||||||
|
{uploading ? 'Upload…' : '↑ Uploader sur Shopify'}
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
) : (
|
||||||
|
<p className="flex-1 py-2 text-center text-xs text-slate-500 self-center">Pas de produit associé</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user