feat: composant ImageCard avec miniatures avant/après, feather, rotation, upload
This commit is contained in:
@@ -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>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user