feat: add background image upload to banner builder
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -48,6 +48,9 @@ export function BannerBuilder({ onApply, onClose }: Props) {
|
||||
const [price, setPrice] = useState('')
|
||||
const [comparePrice, setComparePrice] = useState('')
|
||||
const [selectedImageUrl, setSelectedImageUrl] = useState<string | null>(null)
|
||||
const [backgroundBlob, setBackgroundBlob] = useState<Blob | null>(null)
|
||||
const [backgroundPreview, setBackgroundPreview] = useState<string | null>(null)
|
||||
const bgFileRef = useRef<HTMLInputElement>(null)
|
||||
|
||||
// Generation
|
||||
const [rendering, setRendering] = useState(false)
|
||||
@@ -112,6 +115,22 @@ export function BannerBuilder({ onApply, onClose }: Props) {
|
||||
}
|
||||
}
|
||||
|
||||
const handleBgFile = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = e.target.files?.[0]
|
||||
if (!file) return
|
||||
setBackgroundBlob(file)
|
||||
const url = URL.createObjectURL(file)
|
||||
if (backgroundPreview) URL.revokeObjectURL(backgroundPreview)
|
||||
setBackgroundPreview(url)
|
||||
e.target.value = ''
|
||||
}
|
||||
|
||||
const removeBg = () => {
|
||||
if (backgroundPreview) URL.revokeObjectURL(backgroundPreview)
|
||||
setBackgroundBlob(null)
|
||||
setBackgroundPreview(null)
|
||||
}
|
||||
|
||||
const getConfig = useCallback((): BannerConfig => ({
|
||||
scheme,
|
||||
promoLabel,
|
||||
@@ -119,7 +138,8 @@ export function BannerBuilder({ onApply, onClose }: Props) {
|
||||
price,
|
||||
comparePrice,
|
||||
productImageUrl: selectedImageUrl,
|
||||
}), [scheme, promoLabel, title, price, comparePrice, selectedImageUrl])
|
||||
backgroundImageBlob: backgroundBlob,
|
||||
}), [scheme, promoLabel, title, price, comparePrice, selectedImageUrl, backgroundBlob])
|
||||
|
||||
const generate = useCallback(async () => {
|
||||
setRendering(true)
|
||||
@@ -142,7 +162,7 @@ export function BannerBuilder({ onApply, onClose }: Props) {
|
||||
if (!title && !product) return
|
||||
const t = setTimeout(() => { generate() }, 600)
|
||||
return () => clearTimeout(t)
|
||||
}, [scheme, promoLabel, title, price, comparePrice, selectedImageUrl]) // eslint-disable-line react-hooks/exhaustive-deps
|
||||
}, [scheme, promoLabel, title, price, comparePrice, selectedImageUrl, backgroundBlob]) // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
const applyToSlide = async () => {
|
||||
if (!previewRef.current) return
|
||||
@@ -248,9 +268,40 @@ export function BannerBuilder({ onApply, onClose }: Props) {
|
||||
)}
|
||||
</section>
|
||||
|
||||
{/* Image de fond */}
|
||||
<section>
|
||||
<h3 className="text-xs font-semibold text-gray-300 uppercase tracking-wide mb-2">Image de fond</h3>
|
||||
{backgroundPreview ? (
|
||||
<div className="relative rounded overflow-hidden border border-slate-600">
|
||||
<img src={backgroundPreview} alt="Fond" className="w-full h-24 object-cover" />
|
||||
<button
|
||||
type="button"
|
||||
onClick={removeBg}
|
||||
className="absolute top-1 right-1 bg-black/70 hover:bg-red-900 text-white text-xs px-1.5 py-0.5 rounded"
|
||||
>
|
||||
✕ Retirer
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => bgFileRef.current?.click()}
|
||||
className="w-full py-3 border-2 border-dashed border-slate-600 hover:border-indigo-500 rounded text-xs text-slate-400 hover:text-indigo-400 transition-colors"
|
||||
>
|
||||
+ Uploader une image de fond
|
||||
</button>
|
||||
)}
|
||||
<input ref={bgFileRef} type="file" accept="image/*" className="hidden" onChange={handleBgFile} />
|
||||
{backgroundPreview && (
|
||||
<p className="text-xs text-slate-500 mt-1">Un overlay sombre est appliqué automatiquement pour la lisibilité du texte.</p>
|
||||
)}
|
||||
</section>
|
||||
|
||||
{/* Thème couleur */}
|
||||
<section>
|
||||
<h3 className="text-xs font-semibold text-gray-300 uppercase tracking-wide mb-2">Thème couleur</h3>
|
||||
<h3 className="text-xs font-semibold text-gray-300 uppercase tracking-wide mb-2">
|
||||
{backgroundPreview ? 'Couleur du texte' : 'Thème couleur'}
|
||||
</h3>
|
||||
<div className="grid grid-cols-2 gap-1.5">
|
||||
{schemeEntries.map(([key, s]) => (
|
||||
<button
|
||||
|
||||
@@ -113,6 +113,7 @@ export interface BannerConfig {
|
||||
price: string // ex: "79 €"
|
||||
comparePrice: string // ex: "119 €" (barré)
|
||||
productImageUrl: string | null
|
||||
backgroundImageBlob?: Blob | null // image de fond uploadée (remplace la couleur unie)
|
||||
}
|
||||
|
||||
export async function renderBanner(config: BannerConfig): Promise<Blob> {
|
||||
@@ -123,8 +124,28 @@ export async function renderBanner(config: BannerConfig): Promise<Blob> {
|
||||
const scheme = COLOR_SCHEMES[config.scheme]
|
||||
|
||||
// ── Fond ─────────────────────────────────────────────────────────────────
|
||||
if (config.backgroundImageBlob) {
|
||||
try {
|
||||
const bgImg = await createImageBitmap(config.backgroundImageBlob)
|
||||
// Cover : remplit tout le canvas en centrant
|
||||
const scale = Math.max(W / bgImg.width, H / bgImg.height)
|
||||
const dw = bgImg.width * scale
|
||||
const dh = bgImg.height * scale
|
||||
const dx = (W - dw) / 2
|
||||
const dy = (H - dh) / 2
|
||||
ctx.drawImage(bgImg, dx, dy, dw, dh)
|
||||
bgImg.close()
|
||||
// Overlay semi-transparent pour lisibilité du texte
|
||||
ctx.fillStyle = 'rgba(0,0,0,0.35)'
|
||||
ctx.fillRect(0, 0, W, H)
|
||||
} catch {
|
||||
ctx.fillStyle = scheme.bg
|
||||
ctx.fillRect(0, 0, W, H)
|
||||
}
|
||||
} else {
|
||||
ctx.fillStyle = scheme.bg
|
||||
ctx.fillRect(0, 0, W, H)
|
||||
}
|
||||
|
||||
// Zone texte : 55% gauche, zone image : 45% droite
|
||||
const textZoneW = Math.round(W * 0.55)
|
||||
|
||||
Reference in New Issue
Block a user