ajout de l'option base64 pour le header

This commit is contained in:
streaper2
2026-02-25 10:00:16 +01:00
parent 6e241f1cc0
commit 00d344d2a0

View File

@@ -1,4 +1,4 @@
import { useState, useRef, useEffect } from 'react'; import React, { useState, useRef, useEffect } from 'react';
import { Download, Type, Image as ImageIcon, Layout, MousePointer2, Trash2, Code, AlignLeft, AlignCenter, AlignRight, Loader2, Link as LinkIcon, Smile, Minus, Heading, Bold, Italic, Underline, List, ListOrdered, Upload, Save, RotateCcw, FileX } from 'lucide-react'; import { Download, Type, Image as ImageIcon, Layout, MousePointer2, Trash2, Code, AlignLeft, AlignCenter, AlignRight, Loader2, Link as LinkIcon, Smile, Minus, Heading, Bold, Italic, Underline, List, ListOrdered, Upload, Save, RotateCcw, FileX } from 'lucide-react';
// --- CONFIGURATION & CONSTANTES --- // --- CONFIGURATION & CONSTANTES ---
@@ -22,19 +22,17 @@ interface Block {
} }
// --- COMPOSANT TOOLTIP (AIDE AU SURVOL) --- // --- COMPOSANT TOOLTIP (AIDE AU SURVOL) ---
const Tooltip = ({ text, children }: { text: string; children: React.ReactNode }) => { const Tooltip = ({ text, children, className = "w-full" }: { text: string; children: React.ReactNode; className?: string }) => {
const [show, setShow] = useState(false); const [show, setShow] = useState(false);
const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null); const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const handleMouseEnter = () => { const handleMouseEnter = () => {
// Démarre le timer de 2 secondes
timerRef.current = setTimeout(() => { timerRef.current = setTimeout(() => {
setShow(true); setShow(true);
}, 2000); }, 2000);
}; };
const handleMouseLeave = () => { const handleMouseLeave = () => {
// Annule le timer si la souris part avant les 2 secondes
if (timerRef.current) { if (timerRef.current) {
clearTimeout(timerRef.current); clearTimeout(timerRef.current);
timerRef.current = null; timerRef.current = null;
@@ -43,12 +41,11 @@ const Tooltip = ({ text, children }: { text: string; children: React.ReactNode }
}; };
return ( return (
<div className="relative flex items-center justify-center w-full" onMouseEnter={handleMouseEnter} onMouseLeave={handleMouseLeave}> <div className={`relative flex items-center justify-center ${className}`} onMouseEnter={handleMouseEnter} onMouseLeave={handleMouseLeave}>
{children} {children}
{show && ( {show && (
<div className="absolute bottom-full mb-2 left-1/2 -translate-x-1/2 px-3 py-2 bg-slate-800 text-white text-xs rounded shadow-lg z-50 w-max max-w-[200px] text-center pointer-events-none animate-in fade-in zoom-in duration-200"> <div className="absolute bottom-full mb-2 left-1/2 -translate-x-1/2 px-3 py-2 bg-slate-800 text-white text-xs rounded shadow-lg z-50 w-max max-w-[200px] text-center pointer-events-none animate-in fade-in zoom-in duration-200">
{text} {text}
{/* Petite flèche vers le bas */}
<div className="absolute top-full left-1/2 -translate-x-1/2 border-4 border-transparent border-t-slate-800"></div> <div className="absolute top-full left-1/2 -translate-x-1/2 border-4 border-transparent border-t-slate-800"></div>
</div> </div>
)} )}
@@ -65,6 +62,8 @@ const BLOCK_TEMPLATES: Record<string, any> = {
subtitle: 'AUTOMNE 2025', subtitle: 'AUTOMNE 2025',
bgColor: '#eeeeee', bgColor: '#eeeeee',
backgroundImage: '', backgroundImage: '',
url: '', // Lien de redirection
exportAsImage: true, // Nouveau: Détermine si l'en-tête est converti en Data Base64
textColor: PURPLE_COLOR, textColor: PURPLE_COLOR,
subtitleColor: ORANGE_COLOR, subtitleColor: ORANGE_COLOR,
padding: '30', padding: '30',
@@ -96,7 +95,6 @@ const BLOCK_TEMPLATES: Record<string, any> = {
text: { text: {
id: null, id: null,
type: 'text', type: 'text',
// On utilise du HTML par défaut maintenant pour le RTE
content: '<p>Les 10 et 18 septembre ainsi que le 2 octobre ont rappelé que la colère ne sétait pas éteinte pendant lété.</p><p>Au-delà des chiffres de grève, la dégradation des conditions de travail des personnels de la DGFiP saccentue.</p>', content: '<p>Les 10 et 18 septembre ainsi que le 2 octobre ont rappelé que la colère ne sétait pas éteinte pendant lété.</p><p>Au-delà des chiffres de grève, la dégradation des conditions de travail des personnels de la DGFiP saccentue.</p>',
bgColor: '#ffffff', bgColor: '#ffffff',
textColor: '#4a4a4a', textColor: '#4a4a4a',
@@ -145,6 +143,7 @@ const BLOCK_TEMPLATES: Record<string, any> = {
id: null, id: null,
type: 'image', type: 'image',
src: 'https://via.placeholder.com/600x300', src: 'https://via.placeholder.com/600x300',
url: '', // Ajout possible d'un lien
alt: 'Image de description', alt: 'Image de description',
bgColor: '#ffffff', bgColor: '#ffffff',
padding: '0', padding: '0',
@@ -168,7 +167,7 @@ const BLOCK_TEMPLATES: Record<string, any> = {
btn2Url: 'https://adherer.solidairesfinancespubliques.org/', btn2Url: 'https://adherer.solidairesfinancespubliques.org/',
btn2Color: '#22c55e', btn2Color: '#22c55e',
logoSrc: 'https://solidairesfinancespubliques.org/images/logo/pastille-122.png', logoSrc: 'https://solidairesfinancespubliques.org/images/logo/pastille-122.png',
logoWidth: '80', // Nouvelle propriété pour la taille du logo logoWidth: '80',
slogan: 'Solidaires Finances Publiques est la première force syndicale de la DGFiP', slogan: 'Solidaires Finances Publiques est la première force syndicale de la DGFiP',
address: 'Solidaires Finances Publiques\nSection DG et PR\n\n139 rue de Bercy\nBâtiment VAUBAN pièce 0060 Sud 1\n75012 PARIS\n\nTél. 01 53 18 60 36 - 01 53 18 60 14 - 01 53 18 41 47\n\nhttps://sections.solidairesfinancespubliques.info/b38/\nsolidairesfinancespubliques.servicescentraux@dgfip.finances.gouv.fr', address: 'Solidaires Finances Publiques\nSection DG et PR\n\n139 rue de Bercy\nBâtiment VAUBAN pièce 0060 Sud 1\n75012 PARIS\n\nTél. 01 53 18 60 36 - 01 53 18 60 14 - 01 53 18 41 47\n\nhttps://sections.solidairesfinancespubliques.info/b38/\nsolidairesfinancespubliques.servicescentraux@dgfip.finances.gouv.fr',
legalText: 'Vous êtes destinataire de ce message d\'origine syndicale conformément aux dispositions de l\'article 8 de l\'arrêté du 4 novembre 2014 relatif aux conditions générales d\'utilisation par les organisations syndicales. Vous pouvez vous désabonner en répondant "Désabonnement".', legalText: 'Vous êtes destinataire de ce message d\'origine syndicale conformément aux dispositions de l\'article 8 de l\'arrêté du 4 novembre 2014 relatif aux conditions générales d\'utilisation par les organisations syndicales. Vous pouvez vous désabonner en répondant "Désabonnement".',
@@ -181,7 +180,6 @@ const BLOCK_TEMPLATES: Record<string, any> = {
const SimpleRTE = ({ value, onChange }: { value: string, onChange: (val: string) => void }) => { const SimpleRTE = ({ value, onChange }: { value: string, onChange: (val: string) => void }) => {
const editorRef = useRef<HTMLDivElement>(null); const editorRef = useRef<HTMLDivElement>(null);
// Fonction pour exécuter les commandes d'édition
const execCmd = (command: string, value: string | undefined = undefined) => { const execCmd = (command: string, value: string | undefined = undefined) => {
document.execCommand(command, false, value); document.execCommand(command, false, value);
if (editorRef.current) { if (editorRef.current) {
@@ -189,11 +187,8 @@ const SimpleRTE = ({ value, onChange }: { value: string, onChange: (val: string)
} }
}; };
// Synchronisation initiale et lors des changements externes importants
useEffect(() => { useEffect(() => {
if (editorRef.current && editorRef.current.innerHTML !== value) { if (editorRef.current && editorRef.current.innerHTML !== value) {
// Petite protection pour ne pas perdre le focus/curseur si le contenu est similaire
// Dans une vraie app prod, on utiliserait une lib comme Slate ou TipTap
if (document.activeElement !== editorRef.current) { if (document.activeElement !== editorRef.current) {
editorRef.current.innerHTML = value; editorRef.current.innerHTML = value;
} }
@@ -202,7 +197,6 @@ const SimpleRTE = ({ value, onChange }: { value: string, onChange: (val: string)
return ( return (
<div className="border border-gray-300 rounded overflow-hidden bg-white"> <div className="border border-gray-300 rounded overflow-hidden bg-white">
{/* Barre d'outils */}
<div className="flex items-center gap-1 p-1 border-b border-gray-200 bg-gray-50 flex-wrap"> <div className="flex items-center gap-1 p-1 border-b border-gray-200 bg-gray-50 flex-wrap">
<RteButton onClick={() => execCmd('bold')} icon={<Bold size={14} />} title="Mettre en gras" /> <RteButton onClick={() => execCmd('bold')} icon={<Bold size={14} />} title="Mettre en gras" />
<RteButton onClick={() => execCmd('italic')} icon={<Italic size={14} />} title="Mettre en italique" /> <RteButton onClick={() => execCmd('italic')} icon={<Italic size={14} />} title="Mettre en italique" />
@@ -212,7 +206,6 @@ const SimpleRTE = ({ value, onChange }: { value: string, onChange: (val: string)
<RteButton onClick={() => execCmd('insertOrderedList')} icon={<ListOrdered size={14} />} title="Liste numérotée" /> <RteButton onClick={() => execCmd('insertOrderedList')} icon={<ListOrdered size={14} />} title="Liste numérotée" />
</div> </div>
{/* Zone d'édition */}
<div <div
ref={editorRef} ref={editorRef}
contentEditable contentEditable
@@ -225,9 +218,8 @@ const SimpleRTE = ({ value, onChange }: { value: string, onChange: (val: string)
); );
}; };
// Bouton RTE avec Tooltip intégré via title
const RteButton = ({ onClick, icon, title }: { onClick: () => void, icon: any, title: string }) => ( const RteButton = ({ onClick, icon, title }: { onClick: () => void, icon: any, title: string }) => (
<Tooltip text={title}> <Tooltip text={title} className="w-auto">
<button <button
onClick={(e) => { e.preventDefault(); onClick(); }} onClick={(e) => { e.preventDefault(); onClick(); }}
className="p-1.5 hover:bg-gray-200 rounded text-gray-700" className="p-1.5 hover:bg-gray-200 rounded text-gray-700"
@@ -240,18 +232,18 @@ const RteButton = ({ onClick, icon, title }: { onClick: () => void, icon: any, t
export default function NewsletterBuilder() { export default function NewsletterBuilder() {
const [blocks, setBlocks] = useState<Block[]>([]); // Initialisé vide pour l'effet de chargement const [blocks, setBlocks] = useState<Block[]>([]);
const [selectedBlockId, setSelectedBlockId] = useState<string | null>(null); const [selectedBlockId, setSelectedBlockId] = useState<string | null>(null);
const [globalBg, setGlobalBg] = useState('#f3f4f6'); const [globalBg, setGlobalBg] = useState('#f3f4f6');
const [isExporting, setIsExporting] = useState(false); const [isExporting, setIsExporting] = useState(false);
// Vérification de la présence des blocs uniques
const hasHeader = blocks.some(b => b.type === 'header'); const hasHeader = blocks.some(b => b.type === 'header');
const hasFooter = blocks.some(b => b.type === 'footer'); const hasFooter = blocks.some(b => b.type === 'footer');
// --- CHARGEMENT & SAUVEGARDE (LOCAL STORAGE) --- const getSelectedBlock = () => blocks.find(b => b.id === selectedBlockId);
const selectedBlock = getSelectedBlock();
// Charger au démarrage // --- CHARGEMENT & SAUVEGARDE ---
useEffect(() => { useEffect(() => {
const savedBlocks = localStorage.getItem('newsletter_blocks'); const savedBlocks = localStorage.getItem('newsletter_blocks');
const savedBg = localStorage.getItem('newsletter_bg'); const savedBg = localStorage.getItem('newsletter_bg');
@@ -285,9 +277,8 @@ export default function NewsletterBuilder() {
alert("Projet sauvegardé avec succès dans le navigateur !"); alert("Projet sauvegardé avec succès dans le navigateur !");
}; };
// Réinitialisation COMPLETE (Usine)
const resetProject = () => { const resetProject = () => {
if (confirm("Attention : Cela va effacer votre sauvegarde actuelle et remettre les contenus par défaut (y compris l'en-tête et le pied de page). Continuer ?")) { if (confirm("Attention : Cela va effacer votre sauvegarde actuelle et remettre les contenus par défaut. Continuer ?")) {
localStorage.removeItem('newsletter_blocks'); localStorage.removeItem('newsletter_blocks');
localStorage.removeItem('newsletter_bg'); localStorage.removeItem('newsletter_bg');
loadDefaults(); loadDefaults();
@@ -296,37 +287,23 @@ export default function NewsletterBuilder() {
} }
}; };
// Réinitialisation CORPS UNIQUEMENT (Garde Header & Footer)
const resetBody = () => { const resetBody = () => {
if (confirm("Voulez-vous réinitialiser le corps du message ?\n\nVotre EN-TÊTE et votre PIED DE PAGE actuels seront CONSERVÉS.\nSeul le contenu central sera remplacé par le modèle par défaut.")) { if (confirm("Voulez-vous réinitialiser le corps du message ?\n\nVotre EN-TÊTE et votre PIED DE PAGE actuels seront CONSERVÉS.")) {
// 1. Trouver les blocs existants à conserver
const currentHeader = blocks.find(b => b.type === 'header'); const currentHeader = blocks.find(b => b.type === 'header');
const currentFooter = blocks.find(b => b.type === 'footer'); const currentFooter = blocks.find(b => b.type === 'footer');
// 2. Créer les blocs du corps par défaut
const defaultBody = [ const defaultBody = [
{ ...BLOCK_TEMPLATES.title, id: `reset-title-${Date.now()}` }, { ...BLOCK_TEMPLATES.title, id: `reset-title-${Date.now()}` },
{ ...BLOCK_TEMPLATES.text, id: `reset-text-${Date.now()}` }, { ...BLOCK_TEMPLATES.text, id: `reset-text-${Date.now()}` },
{ ...BLOCK_TEMPLATES.link, id: `reset-link-${Date.now()}` } { ...BLOCK_TEMPLATES.link, id: `reset-link-${Date.now()}` }
]; ];
// 3. Reconstruire la liste
const newBlocks = []; const newBlocks = [];
if (currentHeader) newBlocks.push(currentHeader);
// Ajouter Header (actuel ou nouveau si absent) else newBlocks.push({ ...BLOCK_TEMPLATES.header, id: `new-header-${Date.now()}` });
if (currentHeader) {
newBlocks.push(currentHeader);
} else {
newBlocks.push({ ...BLOCK_TEMPLATES.header, id: `new-header-${Date.now()}` });
}
// Ajouter Corps
newBlocks.push(...defaultBody); newBlocks.push(...defaultBody);
if (currentFooter) newBlocks.push(currentFooter);
// Ajouter Footer (actuel s'il existe)
if (currentFooter) {
newBlocks.push(currentFooter);
}
setBlocks(newBlocks); setBlocks(newBlocks);
setSelectedBlockId(null); setSelectedBlockId(null);
@@ -334,9 +311,7 @@ export default function NewsletterBuilder() {
}; };
// --- LOGIQUE D'ÉDITION --- // --- LOGIQUE D'ÉDITION ---
const addBlock = (type: string) => { const addBlock = (type: string) => {
// Si header ou footer déjà présent, on n'ajoute pas (sécurité supplémentaire)
if (type === 'header' && hasHeader) return; if (type === 'header' && hasHeader) return;
if (type === 'footer' && hasFooter) return; if (type === 'footer' && hasFooter) return;
@@ -376,13 +351,10 @@ export default function NewsletterBuilder() {
} }
}; };
const getSelectedBlock = () => blocks.find(b => b.id === selectedBlockId);
// --- MOTEUR D'EXPORT --- // --- MOTEUR D'EXPORT ---
const convertImageToBase64 = async (url: string) => { const convertImageToBase64 = async (url: string) => {
try { try {
if (url.startsWith('data:')) return url; // Déjà en base64 if (url.startsWith('data:')) return url;
const response = await fetch(url); const response = await fetch(url);
if (!response.ok) throw new Error('Network response'); if (!response.ok) throw new Error('Network response');
const blob = await response.blob(); const blob = await response.blob();
@@ -399,7 +371,6 @@ export default function NewsletterBuilder() {
}; };
const createHeaderImage = async (block: any) => { const createHeaderImage = async (block: any) => {
// Si une image de fond est présente, on la charge d'abord
let bgImageObj: HTMLImageElement | null = null; let bgImageObj: HTMLImageElement | null = null;
if (block.backgroundImage) { if (block.backgroundImage) {
try { try {
@@ -411,7 +382,7 @@ export default function NewsletterBuilder() {
else resolve(null); else resolve(null);
}); });
} catch (e) { } catch (e) {
console.warn("Failed to load background image for header canvas", e); console.warn("Failed to load background image", e);
} }
} }
@@ -420,11 +391,7 @@ export default function NewsletterBuilder() {
const ctx = canvas.getContext('2d'); const ctx = canvas.getContext('2d');
if (!ctx) return; if (!ctx) return;
// FACTEUR D'ÉCHELLE POUR HAUTE DÉFINITION (Retina/Print)
// On multiplie par 3 pour avoir une image 3x plus grande que nécessaire
// Le navigateur la réduira, ce qui créera un rendu très net.
const scale = 3; const scale = 3;
const padding = parseInt(block.padding) || 0; const padding = parseInt(block.padding) || 0;
const fontSize = parseInt(block.fontSize) || 24; const fontSize = parseInt(block.fontSize) || 24;
const subFontSize = parseInt(block.subtitleFontSize) || 20; const subFontSize = parseInt(block.subtitleFontSize) || 20;
@@ -435,39 +402,29 @@ export default function NewsletterBuilder() {
const lines = block.content.split('\n'); const lines = block.content.split('\n');
const titleHeight = lines.length * (fontSize * lineHeightRatio); const titleHeight = lines.length * (fontSize * lineHeightRatio);
const subtitleHeight = block.subtitle ? (subFontSize * lineHeightRatio) + gap : 0; const subtitleHeight = block.subtitle ? (subFontSize * lineHeightRatio) + gap : 0;
const contentHeight = titleHeight + subtitleHeight; const contentHeight = titleHeight + subtitleHeight;
const canvasHeight = contentHeight + (padding * 2); const canvasHeight = contentHeight + (padding * 2);
// Définition de la taille physique du canvas (x3)
canvas.width = width * scale; canvas.width = width * scale;
canvas.height = canvasHeight * scale; canvas.height = canvasHeight * scale;
// On scale le contexte pour continuer à dessiner avec des coordonnées logiques
ctx.scale(scale, scale); ctx.scale(scale, scale);
// Amélioration de la qualité de rendu
ctx.imageSmoothingEnabled = true; ctx.imageSmoothingEnabled = true;
ctx.imageSmoothingQuality = 'high'; ctx.imageSmoothingQuality = 'high';
// 1. DESSINER LE FOND
ctx.fillStyle = block.bgColor; ctx.fillStyle = block.bgColor;
ctx.fillRect(0, 0, width, canvasHeight); ctx.fillRect(0, 0, width, canvasHeight);
// 2. DESSINER L'IMAGE DE FOND (Mode Cover)
if (bgImageObj) { if (bgImageObj) {
const imgRatio = bgImageObj.width / bgImageObj.height; const imgRatio = bgImageObj.width / bgImageObj.height;
const canvasRatio = width / canvasHeight; const canvasRatio = width / canvasHeight;
let drawWidth, drawHeight, offsetX, offsetY; let drawWidth, drawHeight, offsetX, offsetY;
if (imgRatio > canvasRatio) { if (imgRatio > canvasRatio) {
// Image plus large que le canvas (rogner côtés)
drawHeight = canvasHeight; drawHeight = canvasHeight;
drawWidth = bgImageObj.width * (canvasHeight / bgImageObj.height); drawWidth = bgImageObj.width * (canvasHeight / bgImageObj.height);
offsetX = (width - drawWidth) / 2; offsetX = (width - drawWidth) / 2;
offsetY = 0; offsetY = 0;
} else { } else {
// Image plus haute que le canvas (rogner haut/bas)
drawWidth = width; drawWidth = width;
drawHeight = bgImageObj.height * (width / bgImageObj.width); drawHeight = bgImageObj.height * (width / bgImageObj.width);
offsetX = 0; offsetX = 0;
@@ -477,7 +434,6 @@ export default function NewsletterBuilder() {
} }
const cleanFontFamily = block.fontFamily.replace(/'/g, "").replace(/"/g, ""); const cleanFontFamily = block.fontFamily.replace(/'/g, "").replace(/"/g, "");
let x = padding; let x = padding;
if (block.align === 'center') { if (block.align === 'center') {
x = width / 2; x = width / 2;
@@ -489,7 +445,6 @@ export default function NewsletterBuilder() {
ctx.textAlign = 'left'; ctx.textAlign = 'left';
} }
// 3. DESSINER LA DÉCORATION (AVANT LE TEXTE pour qu'elle soit derrière)
if (block.decoration && block.decoration.enabled) { if (block.decoration && block.decoration.enabled) {
ctx.save(); ctx.save();
const decSize = parseInt(block.decoration.size) || 50; const decSize = parseInt(block.decoration.size) || 50;
@@ -510,7 +465,6 @@ export default function NewsletterBuilder() {
ctx.restore(); ctx.restore();
} }
// 4. DESSINER LE TEXTE (PAR DESSUS LA DÉCORATION)
ctx.font = `${fontSize}px ${cleanFontFamily}`; ctx.font = `${fontSize}px ${cleanFontFamily}`;
ctx.fillStyle = block.textColor; ctx.fillStyle = block.textColor;
ctx.textBaseline = 'top'; ctx.textBaseline = 'top';
@@ -527,7 +481,6 @@ export default function NewsletterBuilder() {
ctx.fillText(block.subtitle, x, subY); ctx.fillText(block.subtitle, x, subY);
} }
// Conversion en PNG haute qualité
resolve(canvas.toDataURL('image/png', 1.0)); resolve(canvas.toDataURL('image/png', 1.0));
}); });
}; };
@@ -536,15 +489,24 @@ export default function NewsletterBuilder() {
const renderBlockHTML = (block: any) => { const renderBlockHTML = (block: any) => {
const fontFamily = block.fontFamily || FONT_OPTIONS.sans.value; const fontFamily = block.fontFamily || FONT_OPTIONS.sans.value;
const commonStyle = `font-family: ${fontFamily}; box-sizing: border-box;`; const commonStyle = `font-family: ${fontFamily}; box-sizing: border-box;`;
const borderRow = `<tr><td style="background-color: ${ORANGE_COLOR}; height: 1px; font-size: 0; line-height: 0;">&nbsp;</td></tr>`; const borderRow = `<tr><td style="background-color: ${ORANGE_COLOR}; height: 1px; font-size: 0; line-height: 0;">&nbsp;</td></tr>`;
switch (block.type) { switch (block.type) {
case 'header': case 'header':
return ` // Ce cas est appelé SEULEMENT si block.exportAsImage est FAUX
<tr><td align="${block.align}" style="background-color: ${block.bgColor}; padding: ${block.padding}px; background-image: url('${block.backgroundImage}'); background-size: cover; background-position: center;"> let headerContent = `
<h1 style="margin: 0; color: ${block.textColor}; font-size: ${block.fontSize}px; ${commonStyle} line-height: 1.1;">${block.content.replace(/\n/g, '<br>')}</h1> <h1 style="margin: 0; color: ${block.textColor}; font-size: ${block.fontSize}px; ${commonStyle} line-height: 1.1;">${block.content.replace(/\n/g, '<br>')}</h1>
${block.subtitle ? `<div style="color: ${block.subtitleColor || ORANGE_COLOR}; font-size: ${block.subtitleFontSize || 20}px; margin-top: 10px; ${commonStyle}">${block.subtitle}</div>` : ''} ${block.subtitle ? `<div style="color: ${block.subtitleColor || ORANGE_COLOR}; font-size: ${block.subtitleFontSize || 20}px; margin-top: 10px; ${commonStyle}">${block.subtitle}</div>` : ''}
`;
// On ajoute le lien de redirection global si présent
if (block.url) {
headerContent = `<a href="${block.url}" target="_blank" style="text-decoration:none; display:block;">${headerContent}</a>`;
}
return `
<tr><td align="${block.align}" style="background-color: ${block.bgColor}; padding: ${block.padding}px; background-image: url('${block.backgroundImage}'); background-size: cover; background-position: center;">
${headerContent}
</td></tr> </td></tr>
${borderRow}`; ${borderRow}`;
@@ -592,7 +554,9 @@ export default function NewsletterBuilder() {
</tr>`; </tr>`;
case 'image': case 'image':
const imgHtml = `<tr><td align="${block.align}" style="background-color: ${block.bgColor}; padding: ${block.padding}px;"><img src="${block.src}" alt="${block.alt}" width="${block.width === '100%' ? '600' : block.width}" style="display: block; border: 0; max-width: 100%; width: ${block.width}; height: auto;" /></td></tr>`; const innerImg = `<img src="${block.src}" alt="${block.alt}" width="${block.width === '100%' ? '600' : block.width}" style="display: block; border: 0; max-width: 100%; width: ${block.width}; height: auto;" />`;
const imgContent = block.url ? `<a href="${block.url}" target="_blank" style="text-decoration:none;">${innerImg}</a>` : innerImg;
const imgHtml = `<tr><td align="${block.align}" style="background-color: ${block.bgColor}; padding: ${block.padding}px;">${imgContent}</td></tr>`;
return block.isHeaderResult ? imgHtml + borderRow : imgHtml; return block.isHeaderResult ? imgHtml + borderRow : imgHtml;
case 'spacer': case 'spacer':
@@ -655,8 +619,15 @@ p { margin-top: 0; margin-bottom: 10px; }
const base64Logo = await convertImageToBase64(block.logoSrc); const base64Logo = await convertImageToBase64(block.logoSrc);
return { ...block, logoSrc: base64Logo }; return { ...block, logoSrc: base64Logo };
} else if (block.type === 'header') { } else if (block.type === 'header') {
const base64Header = await createHeaderImage(block); // La case dicte le comportement de l'export
return { type: 'image', src: base64Header, alt: block.content.replace(/\n/g, ' '), width: '100%', align: 'center', padding: 0, bgColor: block.bgColor, isHeaderResult: true }; if (block.exportAsImage !== false) {
const base64Header = await createHeaderImage(block);
// On transforme en bloc "image" pour le rendu final
return { type: 'image', src: base64Header, alt: block.content.replace(/\n/g, ' '), width: '100%', align: 'center', padding: 0, bgColor: block.bgColor, isHeaderResult: true, url: block.url };
} else {
// Si on garde en HTML, on laisse le bloc tel quel pour le générateur
return block;
}
} }
return block; return block;
})); }));
@@ -696,13 +667,13 @@ p { margin-top: 0; margin-bottom: 10px; }
</button> </button>
</Tooltip> </Tooltip>
<div className="flex gap-2"> <div className="flex gap-2">
<Tooltip text="Remet à zéro le contenu (titre, texte, liens) mais garde votre en-tête et pied de page personnalisés."> <Tooltip text="Remet à zéro le contenu (titre, texte, liens) mais garde votre en-tête et pied de page personnalisés." className="flex-1">
<button onClick={resetBody} className="flex-1 flex items-center justify-center gap-1 bg-white border border-yellow-500 text-yellow-700 hover:bg-yellow-50 py-2 rounded text-xs font-medium"> <button onClick={resetBody} className="w-full flex items-center justify-center gap-1 bg-white border border-yellow-500 text-yellow-700 hover:bg-yellow-50 py-2 rounded text-xs font-medium">
<FileX size={14} /> Reset Corps <FileX size={14} /> Reset Corps
</button> </button>
</Tooltip> </Tooltip>
<Tooltip text="Efface tout et restaure la configuration par défaut du bulletin."> <Tooltip text="Efface tout et restaure la configuration par défaut du bulletin." className="flex-1">
<button onClick={resetProject} className="flex-1 flex items-center justify-center gap-1 bg-white border border-red-200 text-red-600 hover:bg-red-50 py-2 rounded text-xs font-medium"> <button onClick={resetProject} className="w-full flex items-center justify-center gap-1 bg-white border border-red-200 text-red-600 hover:bg-red-50 py-2 rounded text-xs font-medium">
<RotateCcw size={14} /> Reset Tout <RotateCcw size={14} /> Reset Tout
</button> </button>
</Tooltip> </Tooltip>
@@ -714,31 +685,31 @@ p { margin-top: 0; margin-bottom: 10px; }
<h2 className="text-xs font-semibold text-slate-500 uppercase tracking-wider mb-3">Ajouter un bloc</h2> <h2 className="text-xs font-semibold text-slate-500 uppercase tracking-wider mb-3">Ajouter un bloc</h2>
<div className="grid grid-cols-3 gap-2"> <div className="grid grid-cols-3 gap-2">
<Tooltip text="Ajoute le bandeau supérieur avec le titre et le sous-titre du bulletin."> <Tooltip text="Ajoute le bandeau supérieur avec le titre et le sous-titre du bulletin.">
<BlockButton icon={<Layout size={16} />} label="En-tête" onClick={() => addBlock('header')} disabled={hasHeader} /> <BlockButton icon={<Layout size={16} />} label="En-tête" onClick={() => addBlock('header')} disabled={hasHeader} isActive={selectedBlock?.type === 'header'} />
</Tooltip> </Tooltip>
<Tooltip text="Ajoute une ligne horizontale pour séparer les sections."> <Tooltip text="Ajoute une ligne horizontale pour séparer les sections.">
<BlockButton icon={<Minus size={16} />} label="Séparateur" onClick={() => addBlock('divider')} /> <BlockButton icon={<Minus size={16} />} label="Séparateur" onClick={() => addBlock('divider')} isActive={selectedBlock?.type === 'divider'} />
</Tooltip> </Tooltip>
<Tooltip text="Ajoute un titre de section en violet et majuscules."> <Tooltip text="Ajoute un titre de section en violet et majuscules.">
<BlockButton icon={<Heading size={16} />} label="Titre" onClick={() => addBlock('title')} /> <BlockButton icon={<Heading size={16} />} label="Titre" onClick={() => addBlock('title')} isActive={selectedBlock?.type === 'title'} />
</Tooltip> </Tooltip>
<Tooltip text="Ajoute un paragraphe de texte riche éditable."> <Tooltip text="Ajoute un paragraphe de texte riche éditable.">
<BlockButton icon={<Type size={16} />} label="Texte" onClick={() => addBlock('text')} /> <BlockButton icon={<Type size={16} />} label="Texte" onClick={() => addBlock('text')} isActive={selectedBlock?.type === 'text'} />
</Tooltip> </Tooltip>
<Tooltip text="Ajoute un lien hypertexte simple aligné à droite."> <Tooltip text="Ajoute un lien hypertexte simple aligné à droite.">
<BlockButton icon={<LinkIcon size={16} />} label="Lien Texte" onClick={() => addBlock('link')} /> <BlockButton icon={<LinkIcon size={16} />} label="Lien Texte" onClick={() => addBlock('link')} isActive={selectedBlock?.type === 'link'} />
</Tooltip> </Tooltip>
<Tooltip text="Ajoute un bouton d'action coloré (ex: Lire la suite)."> <Tooltip text="Ajoute un bouton d'action coloré (ex: Lire la suite).">
<BlockButton icon={<MousePointer2 size={16} />} label="Bouton" onClick={() => addBlock('button')} /> <BlockButton icon={<MousePointer2 size={16} />} label="Bouton" onClick={() => addBlock('button')} isActive={selectedBlock?.type === 'button'} />
</Tooltip> </Tooltip>
<Tooltip text="Ajoute une image ou une photo."> <Tooltip text="Ajoute une image ou une photo.">
<BlockButton icon={<ImageIcon size={16} />} label="Image" onClick={() => addBlock('image')} /> <BlockButton icon={<ImageIcon size={16} />} label="Image" onClick={() => addBlock('image')} isActive={selectedBlock?.type === 'image'} />
</Tooltip> </Tooltip>
<Tooltip text="Ajoute un espace vide vertical pour aérer la mise en page."> <Tooltip text="Ajoute un espace vide vertical pour aérer la mise en page.">
<BlockButton icon={<AlignRight size={16} />} label="Espace" onClick={() => addBlock('spacer')} /> <BlockButton icon={<AlignRight size={16} />} label="Espace" onClick={() => addBlock('spacer')} isActive={selectedBlock?.type === 'spacer'} />
</Tooltip> </Tooltip>
<Tooltip text="Ajoute le pied de page avec logo, adresse et mentions légales."> <Tooltip text="Ajoute le pied de page avec logo, adresse et mentions légales.">
<BlockButton icon={<Code size={16} />} label="Pied" onClick={() => addBlock('footer')} disabled={hasFooter} /> <BlockButton icon={<Code size={16} />} label="Pied" onClick={() => addBlock('footer')} disabled={hasFooter} isActive={selectedBlock?.type === 'footer'} />
</Tooltip> </Tooltip>
</div> </div>
</div> </div>
@@ -801,12 +772,15 @@ p { margin-top: 0; margin-bottom: 10px; }
); );
} }
function BlockButton({ icon, label, onClick, disabled }: { icon: any, label: string, onClick: () => void, disabled?: boolean }) { function BlockButton({ icon, label, onClick, disabled, isActive }: { icon: any, label: string, onClick: () => void, disabled?: boolean, isActive?: boolean }) {
return ( return (
<button <button
onClick={disabled ? undefined : onClick} onClick={disabled ? undefined : onClick}
disabled={disabled} disabled={disabled}
className={`flex flex-col items-center justify-center p-3 bg-white border border-gray-200 rounded transition-all gap-2 shadow-sm w-full ${disabled ? 'opacity-50 cursor-not-allowed bg-gray-100' : 'hover:bg-indigo-50 hover:border-indigo-200 hover:text-indigo-600'}`} className={`flex flex-col items-center justify-center p-3 border rounded transition-all gap-2 shadow-sm w-full
${disabled ? 'opacity-50 cursor-not-allowed bg-gray-100 border-gray-200' :
isActive ? 'bg-indigo-50 border-indigo-500 text-indigo-700 ring-1 ring-indigo-500' :
'bg-white border-gray-200 hover:bg-indigo-50 hover:border-indigo-200 hover:text-indigo-600'}`}
> >
{icon} {icon}
<span className="text-xs font-medium text-center">{label}</span> <span className="text-xs font-medium text-center">{label}</span>
@@ -876,21 +850,43 @@ function EditPanel({ block, updateBlock, deleteBlock }: { block: any, updateBloc
</> </>
)} )}
{/* ÉDITION SPÉCIFIQUE HEADER : IMAGE DE FOND */} {/* ÉDITION SPÉCIFIQUE HEADER : IMAGE DE FOND & OPTIONS D'EXPORT */}
{block.type === 'header' && ( {block.type === 'header' && (
<div> <div className="space-y-4">
<label className="block text-xs font-semibold text-slate-500 mb-1">Image de fond</label> <div>
<input <label className="block text-xs font-semibold text-slate-500 mb-1">Image de fond (URL)</label>
type="text" <input
value={block.backgroundImage || ''} type="text"
onChange={(e) => updateBlock(block.id, 'backgroundImage', e.target.value)} value={block.backgroundImage || ''}
className="w-full p-2 border border-gray-300 rounded text-sm mb-2" onChange={(e) => updateBlock(block.id, 'backgroundImage', e.target.value)}
placeholder="URL image (https://...)" className="w-full p-2 border border-gray-300 rounded text-sm mb-2"
/> placeholder="URL image (https://...)"
<div className="flex items-center gap-2"> />
<label className="cursor-pointer bg-slate-100 hover:bg-slate-200 text-slate-600 px-3 py-2 rounded text-xs flex items-center gap-1 border border-slate-300"> <div className="flex items-center gap-2">
<Upload size={12} /> Téléverser une image <label className="cursor-pointer bg-slate-100 hover:bg-slate-200 text-slate-600 px-3 py-2 rounded text-xs flex items-center gap-1 border border-slate-300">
<input type="file" accept="image/*" className="hidden" onChange={(e) => handleImageUpload(e, 'backgroundImage')} /> <Upload size={12} /> Téléverser une image
<input type="file" accept="image/*" className="hidden" onChange={(e) => handleImageUpload(e, 'backgroundImage')} />
</label>
</div>
</div>
{/* NOUVEAU BLOC: Paramètres d'export */}
<div className="bg-slate-50 p-3 rounded border border-slate-200 space-y-3">
<h4 className="text-xs font-bold text-slate-700 uppercase">Options d'export</h4>
<label className="flex gap-2 cursor-pointer text-xs font-medium text-slate-700 items-start">
<input
type="checkbox"
checked={block.exportAsImage !== false}
onChange={(e) => updateBlock(block.id, 'exportAsImage', e.target.checked)}
className="cursor-pointer mt-0.5"
/>
<div>
<span className="block mb-1">Convertir tout l'en-tête en image Base64</span>
<span className="text-[10px] text-slate-500 font-normal leading-tight block">
Coché : le texte et le fond fusionnent. <br/>
Décoché : garde le format HTML (le fond reste un lien URL, la décoration est masquée).
</span>
</div>
</label> </label>
</div> </div>
</div> </div>
@@ -977,15 +973,10 @@ function EditPanel({ block, updateBlock, deleteBlock }: { block: any, updateBloc
</div> </div>
)} )}
{/* URL COMMUNE */}
{(block.type === 'button' || block.type === 'link') && (
<div><label className="block text-xs font-semibold text-slate-500 mb-1">Lien / Src</label><input type="text" value={block.url || block.src} onChange={(e) => updateBlock(block.id, block.type === 'image' ? 'src' : 'url', e.target.value)} className="w-full p-2 border border-gray-300 rounded text-sm" /></div>
)}
{/* UPLOAD IMAGE SPÉCIFIQUE */} {/* UPLOAD IMAGE SPÉCIFIQUE */}
{block.type === 'image' && ( {block.type === 'image' && (
<div> <div>
<label className="block text-xs font-semibold text-slate-500 mb-1">Image</label> <label className="block text-xs font-semibold text-slate-500 mb-1">Source de l'image (URL ou fichier)</label>
<input <input
type="text" type="text"
value={block.src} value={block.src}
@@ -1002,6 +993,20 @@ function EditPanel({ block, updateBlock, deleteBlock }: { block: any, updateBloc
</div> </div>
)} )}
{/* LIEN DE REDIRECTION (COMMUN POUR IMAGE, HEADER, BUTTON, LINK) */}
{(block.type === 'button' || block.type === 'link' || block.type === 'image' || block.type === 'header') && (
<div>
<label className="block text-xs font-semibold text-slate-500 mb-1">Lien de redirection (au clic)</label>
<input
type="text"
value={block.url || ''}
onChange={(e) => updateBlock(block.id, 'url', e.target.value)}
className="w-full p-2 border border-gray-300 rounded text-sm"
placeholder="https://..."
/>
</div>
)}
{/* ALIGNEMENT */} {/* ALIGNEMENT */}
{block.type !== 'spacer' && block.type !== 'footer' && block.type !== 'divider' && ( {block.type !== 'spacer' && block.type !== 'footer' && block.type !== 'divider' && (
<div> <div>
@@ -1045,7 +1050,7 @@ function PreviewBlock({ block }: { block: any }) {
switch (block.type) { switch (block.type) {
case 'header': case 'header':
return ( const headerPreview = (
<> <>
<div style={{ <div style={{
...containerStyle, ...containerStyle,
@@ -1055,12 +1060,12 @@ function PreviewBlock({ block }: { block: any }) {
backgroundSize: 'cover', backgroundSize: 'cover',
backgroundPosition: 'center', backgroundPosition: 'center',
}}> }}>
{/* DECORATION (Z-INDEX 0) POUR ÊTRE DERRIÈRE */} {/* DECORATION */}
{block.decoration?.enabled && ( {block.decoration?.enabled && (
<div style={{ position: 'absolute', top: '50%', left: '50%', transform: `translate(-50%, -50%) translate(${block.decoration.xOffset}px, ${block.decoration.yOffset}px) rotate(${block.decoration.rotation}deg)`, color: block.decoration.color, fontSize: `${block.decoration.size}px`, fontFamily: fontFamily, pointerEvents: 'none', zIndex: 0, lineHeight: 1 }}>{block.decoration.content}</div> <div style={{ position: 'absolute', top: '50%', left: '50%', transform: `translate(-50%, -50%) translate(${block.decoration.xOffset}px, ${block.decoration.yOffset}px) rotate(${block.decoration.rotation}deg)`, color: block.decoration.color, fontSize: `${block.decoration.size}px`, fontFamily: fontFamily, pointerEvents: 'none', zIndex: 0, lineHeight: 1 }}>{block.decoration.content}</div>
)} )}
{/* TEXTE (Z-INDEX 10) POUR ÊTRE DEVANT */} {/* TEXTE */}
<h1 style={{...textStyle, fontWeight: 'normal', lineHeight: '1.1', position: 'relative', zIndex: 10}}>{block.content}</h1> <h1 style={{...textStyle, fontWeight: 'normal', lineHeight: '1.1', position: 'relative', zIndex: 10}}>{block.content}</h1>
{block.subtitle && ( {block.subtitle && (
<div style={{ <div style={{
@@ -1079,6 +1084,7 @@ function PreviewBlock({ block }: { block: any }) {
<div style={{ backgroundColor: ORANGE_COLOR, height: '1px', width: '100%' }}></div> <div style={{ backgroundColor: ORANGE_COLOR, height: '1px', width: '100%' }}></div>
</> </>
); );
return block.url ? <a href={block.url} onClick={(e) => e.preventDefault()} style={{textDecoration:'none', display:'block', cursor:'pointer'}}>{headerPreview}</a> : headerPreview;
case 'title': case 'title':
return ( return (
@@ -1129,7 +1135,8 @@ function PreviewBlock({ block }: { block: any }) {
</div> </div>
); );
case 'image': case 'image':
return <div style={containerStyle}><img src={block.src} alt={block.alt} style={{ maxWidth: '100%', width: block.width === '100%' ? '100%' : block.width, height: 'auto', display: 'inline-block' }} /></div>; const innerImg = <img src={block.src} alt={block.alt} style={{ maxWidth: '100%', width: block.width === '100%' ? '100%' : block.width, height: 'auto', display: 'inline-block' }} />;
return <div style={containerStyle}>{block.url ? <a href={block.url} onClick={(e) => e.preventDefault()}>{innerImg}</a> : innerImg}</div>;
case 'button': case 'button':
return ( return (
<div style={containerStyle}> <div style={containerStyle}>