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 [price, setPrice] = useState('')
|
||||||
const [comparePrice, setComparePrice] = useState('')
|
const [comparePrice, setComparePrice] = useState('')
|
||||||
const [selectedImageUrl, setSelectedImageUrl] = useState<string | null>(null)
|
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
|
// Generation
|
||||||
const [rendering, setRendering] = useState(false)
|
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 => ({
|
const getConfig = useCallback((): BannerConfig => ({
|
||||||
scheme,
|
scheme,
|
||||||
promoLabel,
|
promoLabel,
|
||||||
@@ -119,7 +138,8 @@ export function BannerBuilder({ onApply, onClose }: Props) {
|
|||||||
price,
|
price,
|
||||||
comparePrice,
|
comparePrice,
|
||||||
productImageUrl: selectedImageUrl,
|
productImageUrl: selectedImageUrl,
|
||||||
}), [scheme, promoLabel, title, price, comparePrice, selectedImageUrl])
|
backgroundImageBlob: backgroundBlob,
|
||||||
|
}), [scheme, promoLabel, title, price, comparePrice, selectedImageUrl, backgroundBlob])
|
||||||
|
|
||||||
const generate = useCallback(async () => {
|
const generate = useCallback(async () => {
|
||||||
setRendering(true)
|
setRendering(true)
|
||||||
@@ -142,7 +162,7 @@ export function BannerBuilder({ onApply, onClose }: Props) {
|
|||||||
if (!title && !product) return
|
if (!title && !product) return
|
||||||
const t = setTimeout(() => { generate() }, 600)
|
const t = setTimeout(() => { generate() }, 600)
|
||||||
return () => clearTimeout(t)
|
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 () => {
|
const applyToSlide = async () => {
|
||||||
if (!previewRef.current) return
|
if (!previewRef.current) return
|
||||||
@@ -248,9 +268,40 @@ export function BannerBuilder({ onApply, onClose }: Props) {
|
|||||||
)}
|
)}
|
||||||
</section>
|
</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 */}
|
{/* Thème couleur */}
|
||||||
<section>
|
<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">
|
<div className="grid grid-cols-2 gap-1.5">
|
||||||
{schemeEntries.map(([key, s]) => (
|
{schemeEntries.map(([key, s]) => (
|
||||||
<button
|
<button
|
||||||
|
|||||||
@@ -113,6 +113,7 @@ export interface BannerConfig {
|
|||||||
price: string // ex: "79 €"
|
price: string // ex: "79 €"
|
||||||
comparePrice: string // ex: "119 €" (barré)
|
comparePrice: string // ex: "119 €" (barré)
|
||||||
productImageUrl: string | null
|
productImageUrl: string | null
|
||||||
|
backgroundImageBlob?: Blob | null // image de fond uploadée (remplace la couleur unie)
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function renderBanner(config: BannerConfig): Promise<Blob> {
|
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]
|
const scheme = COLOR_SCHEMES[config.scheme]
|
||||||
|
|
||||||
// ── Fond ─────────────────────────────────────────────────────────────────
|
// ── Fond ─────────────────────────────────────────────────────────────────
|
||||||
ctx.fillStyle = scheme.bg
|
if (config.backgroundImageBlob) {
|
||||||
ctx.fillRect(0, 0, W, H)
|
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
|
// Zone texte : 55% gauche, zone image : 45% droite
|
||||||
const textZoneW = Math.round(W * 0.55)
|
const textZoneW = Math.round(W * 0.55)
|
||||||
|
|||||||
Reference in New Issue
Block a user