pop up aide
header footer bloqué
upload photo
etc....
This commit is contained in:
streaper2
2025-12-19 18:09:58 +01:00
parent c0c71874c7
commit 0a3214a6fd

View File

@@ -1,5 +1,5 @@
import { useState } from 'react'; import { 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 } 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, Info } from 'lucide-react';
// --- CONFIGURATION & CONSTANTES --- // --- CONFIGURATION & CONSTANTES ---
const DEFAULT_WIDTH = "600"; const DEFAULT_WIDTH = "600";
@@ -18,9 +18,44 @@ const FONT_OPTIONS = {
interface Block { interface Block {
id: string; id: string;
type: string; type: string;
[key: string]: any; // Permet d'accéder à n'importe quelle propriété dynamiquement [key: string]: any;
} }
// --- COMPOSANT TOOLTIP (AIDE AU SURVOL) ---
const Tooltip = ({ text, children }: { text: string; children: React.ReactNode }) => {
const [show, setShow] = useState(false);
const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const handleMouseEnter = () => {
// Démarre le timer de 2 secondes
timerRef.current = setTimeout(() => {
setShow(true);
}, 2000);
};
const handleMouseLeave = () => {
// Annule le timer si la souris part avant les 2 secondes
if (timerRef.current) {
clearTimeout(timerRef.current);
timerRef.current = null;
}
setShow(false);
};
return (
<div className="relative flex items-center justify-center w-full" onMouseEnter={handleMouseEnter} onMouseLeave={handleMouseLeave}>
{children}
{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">
{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>
)}
</div>
);
};
// Modèles de blocs par défaut // Modèles de blocs par défaut
const BLOCK_TEMPLATES: Record<string, any> = { const BLOCK_TEMPLATES: Record<string, any> = {
header: { header: {
@@ -29,7 +64,7 @@ const BLOCK_TEMPLATES: Record<string, any> = {
content: "BULLETIN MENSUEL\nD'INFORMATIONS SYNDiCALES", content: "BULLETIN MENSUEL\nD'INFORMATIONS SYNDiCALES",
subtitle: 'AUTOMNE 2025', subtitle: 'AUTOMNE 2025',
bgColor: '#eeeeee', bgColor: '#eeeeee',
backgroundImage: '', // Nouveau champ image de fond backgroundImage: '',
textColor: PURPLE_COLOR, textColor: PURPLE_COLOR,
subtitleColor: ORANGE_COLOR, subtitleColor: ORANGE_COLOR,
padding: '30', padding: '30',
@@ -61,7 +96,8 @@ const BLOCK_TEMPLATES: Record<string, any> = {
text: { text: {
id: null, id: null,
type: 'text', type: 'text',
content: 'Les 10 et 18 septembre ainsi que le 2 octobre ont rappelé que la colère ne sétait pas éteinte pendant lété. Au-delà des chiffres de grève, la dégradation des conditions de travail des personnels de la DGFiP saccentue.', // 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>',
bgColor: '#ffffff', bgColor: '#ffffff',
textColor: '#4a4a4a', textColor: '#4a4a4a',
padding: '20', padding: '20',
@@ -132,28 +168,178 @@ 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
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\nBoite 24\n80 rue de Montreuil\n75011 PARIS\nTél. 01.89.16.48.49\nhttps://solidairesfinancespubliques.org\ncontact@solidairesfinancespubliques.org', 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".',
bgColor: '#ffffff', bgColor: '#ffffff',
fontFamily: FONT_OPTIONS.sans.value fontFamily: FONT_OPTIONS.sans.value
} }
}; };
// --- COMPOSANT MINI RTE (Rich Text Editor) ---
const SimpleRTE = ({ value, onChange }: { value: string, onChange: (val: string) => void }) => {
const editorRef = useRef<HTMLDivElement>(null);
// Fonction pour exécuter les commandes d'édition
const execCmd = (command: string, value: string | undefined = undefined) => {
document.execCommand(command, false, value);
if (editorRef.current) {
onChange(editorRef.current.innerHTML);
}
};
// Synchronisation initiale et lors des changements externes importants
useEffect(() => {
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) {
editorRef.current.innerHTML = value;
}
}
}, [value]);
return (
<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">
<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('underline')} icon={<Underline size={14} />} title="Souligner" />
<div className="w-px h-4 bg-gray-300 mx-1"></div>
<RteButton onClick={() => execCmd('insertUnorderedList')} icon={<List size={14} />} title="Liste à puces" />
<RteButton onClick={() => execCmd('insertOrderedList')} icon={<ListOrdered size={14} />} title="Liste numérotée" />
</div>
{/* Zone d'édition */}
<div
ref={editorRef}
contentEditable
className="p-3 min-h-[150px] text-sm focus:outline-none focus:ring-2 focus:ring-inset focus:ring-indigo-200"
onInput={(e) => onChange(e.currentTarget.innerHTML)}
onBlur={(e) => onChange(e.currentTarget.innerHTML)}
style={{ fontFamily: 'Helvetica, Arial, sans-serif' }}
/>
</div>
);
};
// Bouton RTE avec Tooltip intégré via title
const RteButton = ({ onClick, icon, title }: { onClick: () => void, icon: any, title: string }) => (
<Tooltip text={title}>
<button
onClick={(e) => { e.preventDefault(); onClick(); }}
className="p-1.5 hover:bg-gray-200 rounded text-gray-700"
type="button"
>
{icon}
</button>
</Tooltip>
);
export default function NewsletterBuilder() { export default function NewsletterBuilder() {
const [blocks, setBlocks] = useState<Block[]>([ const [blocks, setBlocks] = useState<Block[]>([]); // Initialisé vide pour l'effet de chargement
{ ...BLOCK_TEMPLATES.header, id: 'init-1' },
{ ...BLOCK_TEMPLATES.title, id: 'init-title' },
{ ...BLOCK_TEMPLATES.text, id: 'init-2' },
{ ...BLOCK_TEMPLATES.link, id: 'init-link' }
]);
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 hasFooter = blocks.some(b => b.type === 'footer');
// --- CHARGEMENT & SAUVEGARDE (LOCAL STORAGE) ---
// Charger au démarrage
useEffect(() => {
const savedBlocks = localStorage.getItem('newsletter_blocks');
const savedBg = localStorage.getItem('newsletter_bg');
if (savedBlocks) {
try {
setBlocks(JSON.parse(savedBlocks));
} catch (e) {
console.error("Erreur lecture sauvegarde", e);
loadDefaults();
}
} else {
loadDefaults();
}
if (savedBg) setGlobalBg(savedBg);
}, []);
const loadDefaults = () => {
setBlocks([
{ ...BLOCK_TEMPLATES.header, id: 'init-1' },
{ ...BLOCK_TEMPLATES.title, id: 'init-title' },
{ ...BLOCK_TEMPLATES.text, id: 'init-2' },
{ ...BLOCK_TEMPLATES.link, id: 'init-link' }
]);
};
const saveProject = () => {
localStorage.setItem('newsletter_blocks', JSON.stringify(blocks));
localStorage.setItem('newsletter_bg', globalBg);
alert("Projet sauvegardé avec succès dans le navigateur !");
};
// Réinitialisation COMPLETE (Usine)
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 ?")) {
localStorage.removeItem('newsletter_blocks');
localStorage.removeItem('newsletter_bg');
loadDefaults();
setGlobalBg('#f3f4f6');
setSelectedBlockId(null);
}
};
// Réinitialisation CORPS UNIQUEMENT (Garde Header & Footer)
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.")) {
// 1. Trouver les blocs existants à conserver
const currentHeader = blocks.find(b => b.type === 'header');
const currentFooter = blocks.find(b => b.type === 'footer');
// 2. Créer les blocs du corps par défaut
const defaultBody = [
{ ...BLOCK_TEMPLATES.title, id: `reset-title-${Date.now()}` },
{ ...BLOCK_TEMPLATES.text, id: `reset-text-${Date.now()}` },
{ ...BLOCK_TEMPLATES.link, id: `reset-link-${Date.now()}` }
];
// 3. Reconstruire la liste
const newBlocks = [];
// Ajouter Header (actuel ou nouveau si absent)
if (currentHeader) {
newBlocks.push(currentHeader);
} else {
newBlocks.push({ ...BLOCK_TEMPLATES.header, id: `new-header-${Date.now()}` });
}
// Ajouter Corps
newBlocks.push(...defaultBody);
// Ajouter Footer (actuel s'il existe)
if (currentFooter) {
newBlocks.push(currentFooter);
}
setBlocks(newBlocks);
setSelectedBlockId(null);
}
};
// --- 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 === 'footer' && hasFooter) return;
const template = BLOCK_TEMPLATES[type]; const template = BLOCK_TEMPLATES[type];
const newBlock = JSON.parse(JSON.stringify(template)); const newBlock = JSON.parse(JSON.stringify(template));
newBlock.id = `block-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`; newBlock.id = `block-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`;
@@ -196,6 +382,7 @@ export default function NewsletterBuilder() {
const convertImageToBase64 = async (url: string) => { const convertImageToBase64 = async (url: string) => {
try { try {
if (url.startsWith('data:')) return url; // Déjà en base64
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();
@@ -233,6 +420,11 @@ 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 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;
@@ -247,8 +439,16 @@ export default function NewsletterBuilder() {
const contentHeight = titleHeight + subtitleHeight; const contentHeight = titleHeight + subtitleHeight;
const canvasHeight = contentHeight + (padding * 2); const canvasHeight = contentHeight + (padding * 2);
canvas.width = width; // Définition de la taille physique du canvas (x3)
canvas.height = canvasHeight; canvas.width = width * scale;
canvas.height = canvasHeight * scale;
// On scale le contexte pour continuer à dessiner avec des coordonnées logiques
ctx.scale(scale, scale);
// Amélioration de la qualité de rendu
ctx.imageSmoothingEnabled = true;
ctx.imageSmoothingQuality = 'high';
// 1. DESSINER LE FOND // 1. DESSINER LE FOND
ctx.fillStyle = block.bgColor; ctx.fillStyle = block.bgColor;
@@ -274,10 +474,6 @@ export default function NewsletterBuilder() {
offsetY = (canvasHeight - drawHeight) / 2; offsetY = (canvasHeight - drawHeight) / 2;
} }
ctx.drawImage(bgImageObj, offsetX, offsetY, drawWidth, drawHeight); ctx.drawImage(bgImageObj, offsetX, offsetY, drawWidth, drawHeight);
// Optionnel : Ajouter un overlay semi-transparent pour la lisibilité
// ctx.fillStyle = 'rgba(255, 255, 255, 0.7)';
// ctx.fillRect(0, 0, width, canvasHeight);
} }
const cleanFontFamily = block.fontFamily.replace(/'/g, "").replace(/"/g, ""); const cleanFontFamily = block.fontFamily.replace(/'/g, "").replace(/"/g, "");
@@ -293,24 +489,7 @@ export default function NewsletterBuilder() {
ctx.textAlign = 'left'; ctx.textAlign = 'left';
} }
// 3. DESSINER LE TEXTE // 3. DESSINER LA DÉCORATION (AVANT LE TEXTE pour qu'elle soit derrière)
ctx.font = `${fontSize}px ${cleanFontFamily}`;
ctx.fillStyle = block.textColor;
ctx.textBaseline = 'top';
lines.forEach((line: string, i: number) => {
const y = padding + (i * fontSize * lineHeightRatio);
ctx.fillText(line, x, y);
});
if (block.subtitle) {
ctx.font = `${subFontSize}px ${cleanFontFamily}`;
ctx.fillStyle = block.subtitleColor || ORANGE_COLOR;
const subY = padding + titleHeight + gap;
ctx.fillText(block.subtitle, x, subY);
}
// 4. DESSINER LA DÉCORATION
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;
@@ -331,7 +510,25 @@ export default function NewsletterBuilder() {
ctx.restore(); ctx.restore();
} }
resolve(canvas.toDataURL('image/png')); // 4. DESSINER LE TEXTE (PAR DESSUS LA DÉCORATION)
ctx.font = `${fontSize}px ${cleanFontFamily}`;
ctx.fillStyle = block.textColor;
ctx.textBaseline = 'top';
lines.forEach((line: string, i: number) => {
const y = padding + (i * fontSize * lineHeightRatio);
ctx.fillText(line, x, y);
});
if (block.subtitle) {
ctx.font = `${subFontSize}px ${cleanFontFamily}`;
ctx.fillStyle = block.subtitleColor || ORANGE_COLOR;
const subY = padding + titleHeight + gap;
ctx.fillText(block.subtitle, x, subY);
}
// Conversion en PNG haute qualité
resolve(canvas.toDataURL('image/png', 1.0));
}); });
}; };
@@ -369,7 +566,7 @@ export default function NewsletterBuilder() {
return ` return `
<tr> <tr>
<td align="${block.align}" style="background-color: ${block.bgColor}; padding: ${block.padding}px; color: ${block.textColor}; font-size: ${block.fontSize}px; line-height: ${block.lineHeight}; ${commonStyle}"> <td align="${block.align}" style="background-color: ${block.bgColor}; padding: ${block.padding}px; color: ${block.textColor}; font-size: ${block.fontSize}px; line-height: ${block.lineHeight}; ${commonStyle}">
${block.content.replace(/\n/g, '<br>')} ${block.content}
</td> </td>
</tr>`; </tr>`;
@@ -412,7 +609,7 @@ export default function NewsletterBuilder() {
</td></tr> </td></tr>
<tr><td style="padding: 0 10px;"><div style="border-bottom: 1px dotted ${ORANGE_COLOR}; height: 1px; width: 100%;"></div></td></tr> <tr><td style="padding: 0 10px;"><div style="border-bottom: 1px dotted ${ORANGE_COLOR}; height: 1px; width: 100%;"></div></td></tr>
<tr><td align="center" style="background-color: #ffffff; padding: 20px; ${commonStyle}"> <tr><td align="center" style="background-color: #ffffff; padding: 20px; ${commonStyle}">
<img src="${block.logoSrc}" alt="Logo" width="80" height="80" style="display: block; border: 0; border-radius: 50%; margin-bottom: 10px;" /> <img src="${block.logoSrc}" alt="Logo" width="${block.logoWidth || 80}" style="display: block; border: 0; margin-bottom: 10px; max-width: 100%; height: auto;" />
<div style="font-style: italic; font-size: 14px; margin-bottom: 15px; font-weight: bold;">${block.slogan}</div> <div style="font-style: italic; font-size: 14px; margin-bottom: 15px; font-weight: bold;">${block.slogan}</div>
<div style="font-size: 14px; line-height: 1.4; color: #333;">${block.address.replace(/\n/g, '<br>')}</div> <div style="font-size: 14px; line-height: 1.4; color: #333;">${block.address.replace(/\n/g, '<br>')}</div>
</td></tr> </td></tr>
@@ -428,7 +625,15 @@ export default function NewsletterBuilder() {
return `<!DOCTYPE html> return `<!DOCTYPE html>
<html lang="fr"> <html lang="fr">
<head><meta charset="UTF-8"><meta name="viewport" content="width=device-width, initial-scale=1.0"><title>Newsletter</title> <head><meta charset="UTF-8"><meta name="viewport" content="width=device-width, initial-scale=1.0"><title>Newsletter</title>
<style>body { margin: 0; padding: 0; -webkit-text-size-adjust: 100%; -ms-text-size-adjust: 100%; } img { border: 0; height: auto; line-height: 100%; outline: none; text-decoration: none; } table { border-collapse: collapse !important; } body { height: 100% !important; margin: 0 !important; padding: 0 !important; width: 100% !important; }</style> <style>
body { margin: 0; padding: 0; -webkit-text-size-adjust: 100%; -ms-text-size-adjust: 100%; }
img { border: 0; height: auto; line-height: 100%; outline: none; text-decoration: none; }
table { border-collapse: collapse !important; }
body { height: 100% !important; margin: 0 !important; padding: 0 !important; width: 100% !important; }
ul, ol { margin-top: 0; margin-bottom: 10px; padding-left: 20px; }
li { margin-bottom: 5px; }
p { margin-top: 0; margin-bottom: 10px; }
</style>
</head> </head>
<body style="margin: 0; padding: 0; background-color: ${globalBg};"> <body style="margin: 0; padding: 0; background-color: ${globalBg};">
<table border="0" cellpadding="0" cellspacing="0" width="100%"><tr><td align="center" bgcolor="${globalBg}" style="padding: 20px 0;"> <table border="0" cellpadding="0" cellspacing="0" width="100%"><tr><td align="center" bgcolor="${globalBg}" style="padding: 20px 0;">
@@ -450,7 +655,6 @@ export default function NewsletterBuilder() {
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') {
// Cette fonction gère maintenant l'image de fond et la dessine dans le canvas
const base64Header = await createHeaderImage(block); const base64Header = await createHeaderImage(block);
return { type: 'image', src: base64Header, alt: block.content.replace(/\n/g, ' '), width: '100%', align: 'center', padding: 0, bgColor: block.bgColor, isHeaderResult: true }; return { type: 'image', src: base64Header, alt: block.content.replace(/\n/g, ' '), width: '100%', align: 'center', padding: 0, bgColor: block.bgColor, isHeaderResult: true };
} }
@@ -482,18 +686,60 @@ export default function NewsletterBuilder() {
<p className="text-xs text-slate-500 mt-1">Version Bulletin Mensuel</p> <p className="text-xs text-slate-500 mt-1">Version Bulletin Mensuel</p>
</div> </div>
{/* SECTION GESTION PROJET */}
<div className="p-4 border-b border-gray-200 bg-orange-50">
<h2 className="text-xs font-semibold text-orange-700 uppercase tracking-wider mb-3">Gestion Projet</h2>
<div className="space-y-2">
<Tooltip text="Enregistre votre travail actuel dans le navigateur pour le retrouver plus tard.">
<button onClick={saveProject} className="w-full flex items-center justify-center gap-1 bg-white border border-orange-200 text-orange-700 hover:bg-orange-100 py-2 rounded text-xs font-medium">
<Save size={14} /> Sauvegarder Projet
</button>
</Tooltip>
<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.">
<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">
<FileX size={14} /> Reset Corps
</button>
</Tooltip>
<Tooltip text="Efface tout et restaure la configuration par défaut du bulletin.">
<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">
<RotateCcw size={14} /> Reset Tout
</button>
</Tooltip>
</div>
</div>
</div>
<div className="p-4 border-b border-gray-200"> <div className="p-4 border-b border-gray-200">
<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">
<BlockButton icon={<Layout size={16} />} label="En-tête" onClick={() => addBlock('header')} /> <Tooltip text="Ajoute le bandeau supérieur avec le titre et le sous-titre du bulletin.">
<BlockButton icon={<Minus size={16} />} label="Séparateur" onClick={() => addBlock('divider')} /> <BlockButton icon={<Layout size={16} />} label="En-tête" onClick={() => addBlock('header')} disabled={hasHeader} />
<BlockButton icon={<Heading size={16} />} label="Titre" onClick={() => addBlock('title')} /> </Tooltip>
<BlockButton icon={<Type size={16} />} label="Texte" onClick={() => addBlock('text')} /> <Tooltip text="Ajoute une ligne horizontale pour séparer les sections.">
<BlockButton icon={<LinkIcon size={16} />} label="Lien Texte" onClick={() => addBlock('link')} /> <BlockButton icon={<Minus size={16} />} label="Séparateur" onClick={() => addBlock('divider')} />
<BlockButton icon={<MousePointer2 size={16} />} label="Bouton" onClick={() => addBlock('button')} /> </Tooltip>
<BlockButton icon={<ImageIcon size={16} />} label="Image" onClick={() => addBlock('image')} /> <Tooltip text="Ajoute un titre de section en violet et majuscules.">
<BlockButton icon={<AlignRight size={16} />} label="Espace" onClick={() => addBlock('spacer')} /> <BlockButton icon={<Heading size={16} />} label="Titre" onClick={() => addBlock('title')} />
<BlockButton icon={<Code size={16} />} label="Pied" onClick={() => addBlock('footer')} /> </Tooltip>
<Tooltip text="Ajoute un paragraphe de texte riche éditable.">
<BlockButton icon={<Type size={16} />} label="Texte" onClick={() => addBlock('text')} />
</Tooltip>
<Tooltip text="Ajoute un lien hypertexte simple aligné à droite.">
<BlockButton icon={<LinkIcon size={16} />} label="Lien Texte" onClick={() => addBlock('link')} />
</Tooltip>
<Tooltip text="Ajoute un bouton d'action coloré (ex: Lire la suite).">
<BlockButton icon={<MousePointer2 size={16} />} label="Bouton" onClick={() => addBlock('button')} />
</Tooltip>
<Tooltip text="Ajoute une image ou une photo.">
<BlockButton icon={<ImageIcon size={16} />} label="Image" onClick={() => addBlock('image')} />
</Tooltip>
<Tooltip text="Ajoute un espace vide vertical pour aérer la mise en page.">
<BlockButton icon={<AlignRight size={16} />} label="Espace" onClick={() => addBlock('spacer')} />
</Tooltip>
<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} />
</Tooltip>
</div> </div>
</div> </div>
@@ -522,9 +768,11 @@ export default function NewsletterBuilder() {
</div> </div>
<div className="p-4 border-t border-gray-200 bg-slate-50"> <div className="p-4 border-t border-gray-200 bg-slate-50">
<button onClick={downloadHTML} disabled={isExporting} className={`w-full flex items-center justify-center gap-2 py-3 px-4 rounded-md font-medium transition-colors shadow-sm ${isExporting ? 'bg-indigo-400 cursor-wait' : 'bg-indigo-600 hover:bg-indigo-700'} text-white`}> <Tooltip text="Générer et télécharger le fichier HTML final prêt à l'envoi.">
{isExporting ? <><Loader2 className="animate-spin" size={18} /> Traitement...</> : <><Download size={18} /> Exporter HTML</>} <button onClick={downloadHTML} disabled={isExporting} className={`w-full flex items-center justify-center gap-2 py-3 px-4 rounded-md font-medium transition-colors shadow-sm ${isExporting ? 'bg-indigo-400 cursor-wait' : 'bg-indigo-600 hover:bg-indigo-700'} text-white`}>
</button> {isExporting ? <><Loader2 className="animate-spin" size={18} /> Traitement...</> : <><Download size={18} /> Exporter HTML</>}
</button>
</Tooltip>
</div> </div>
</div> </div>
@@ -553,9 +801,13 @@ export default function NewsletterBuilder() {
); );
} }
function BlockButton({ icon, label, onClick }: { icon: any, label: string, onClick: () => void }) { function BlockButton({ icon, label, onClick, disabled }: { icon: any, label: string, onClick: () => void, disabled?: boolean }) {
return ( return (
<button onClick={onClick} className="flex flex-col items-center justify-center p-3 bg-white border border-gray-200 rounded hover:bg-indigo-50 hover:border-indigo-200 hover:text-indigo-600 transition-all gap-2 shadow-sm"> <button
onClick={disabled ? undefined : onClick}
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'}`}
>
{icon} {icon}
<span className="text-xs font-medium text-center">{label}</span> <span className="text-xs font-medium text-center">{label}</span>
</button> </button>
@@ -565,6 +817,17 @@ function BlockButton({ icon, label, onClick }: { icon: any, label: string, onCli
function EditPanel({ block, updateBlock, deleteBlock }: { block: any, updateBlock: (id: string, field: string, value: any) => void, deleteBlock: (id: string) => void }) { function EditPanel({ block, updateBlock, deleteBlock }: { block: any, updateBlock: (id: string, field: string, value: any) => void, deleteBlock: (id: string) => void }) {
if (!block) return null; if (!block) return null;
const handleImageUpload = (e: React.ChangeEvent<HTMLInputElement>, field: string) => {
const file = e.target.files?.[0];
if (file) {
const reader = new FileReader();
reader.onloadend = () => {
updateBlock(block.id, field, reader.result);
};
reader.readAsDataURL(file);
}
};
return ( return (
<div className="space-y-6"> <div className="space-y-6">
<div className="flex justify-between items-center border-b pb-2"> <div className="flex justify-between items-center border-b pb-2">
@@ -573,8 +836,16 @@ function EditPanel({ block, updateBlock, deleteBlock }: { block: any, updateBloc
</div> </div>
<div className="space-y-4"> <div className="space-y-4">
{/* ÉDITION CONTENU (HEADER, TEXTE, LINK, BUTTON, TITLE) */} {/* ÉDITION TEXTE AVEC RTE */}
{(['header', 'text', 'button', 'link', 'title'].includes(block.type)) && ( {block.type === 'text' && (
<div>
<label className="block text-xs font-semibold text-slate-500 mb-1">Contenu</label>
<SimpleRTE value={block.content} onChange={(val) => updateBlock(block.id, 'content', val)} />
</div>
)}
{/* ÉDITION CONTENU SIMPLE (HEADER, LINK, BUTTON, TITLE) */}
{(['header', 'button', 'link', 'title'].includes(block.type)) && (
<> <>
<div> <div>
<label className="block text-xs font-semibold text-slate-500 mb-1">Contenu</label> <label className="block text-xs font-semibold text-slate-500 mb-1">Contenu</label>
@@ -608,14 +879,20 @@ function EditPanel({ block, updateBlock, deleteBlock }: { block: any, updateBloc
{/* ÉDITION SPÉCIFIQUE HEADER : IMAGE DE FOND */} {/* ÉDITION SPÉCIFIQUE HEADER : IMAGE DE FOND */}
{block.type === 'header' && ( {block.type === 'header' && (
<div> <div>
<label className="block text-xs font-semibold text-slate-500 mb-1">Image de fond (URL)</label> <label className="block text-xs font-semibold text-slate-500 mb-1">Image de fond</label>
<input <input
type="text" type="text"
value={block.backgroundImage || ''} value={block.backgroundImage || ''}
onChange={(e) => updateBlock(block.id, 'backgroundImage', e.target.value)} onChange={(e) => updateBlock(block.id, 'backgroundImage', e.target.value)}
className="w-full p-2 border border-gray-300 rounded text-sm" className="w-full p-2 border border-gray-300 rounded text-sm mb-2"
placeholder="https://..." 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">
<Upload size={12} /> Téléverser une image
<input type="file" accept="image/*" className="hidden" onChange={(e) => handleImageUpload(e, 'backgroundImage')} />
</label>
</div>
</div> </div>
)} )}
@@ -681,7 +958,19 @@ function EditPanel({ block, updateBlock, deleteBlock }: { block: any, updateBloc
<input type="color" value={block.btn2Color} onChange={(e) => updateBlock(block.id, 'btn2Color', e.target.value)} className="w-full h-6 p-0" /> <input type="color" value={block.btn2Color} onChange={(e) => updateBlock(block.id, 'btn2Color', e.target.value)} className="w-full h-6 p-0" />
</div> </div>
<input type="text" value={block.btn2Url} onChange={(e) => updateBlock(block.id, 'btn2Url', e.target.value)} className="w-full p-1 border text-xs" /> <input type="text" value={block.btn2Url} onChange={(e) => updateBlock(block.id, 'btn2Url', e.target.value)} className="w-full p-1 border text-xs" />
<input type="text" value={block.logoSrc} onChange={(e) => updateBlock(block.id, 'logoSrc', e.target.value)} className="w-full p-2 border border-gray-300 rounded text-sm mt-2" placeholder="Logo URL" />
<div>
<div className="flex justify-between gap-2 mb-1">
<label className="block text-xs font-semibold text-slate-500 mb-1 mt-2">Logo</label>
<input type="number" value={block.logoWidth || 80} onChange={(e) => updateBlock(block.id, 'logoWidth', e.target.value)} className="w-16 p-1 border border-gray-300 rounded text-xs mt-2" placeholder="px" />
</div>
<input type="text" value={block.logoSrc} onChange={(e) => updateBlock(block.id, 'logoSrc', e.target.value)} className="w-full p-2 border border-gray-300 rounded text-sm mb-1" placeholder="Logo URL" />
<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 w-fit">
<Upload size={12} /> Téléverser un logo
<input type="file" accept="image/*" className="hidden" onChange={(e) => handleImageUpload(e, 'logoSrc')} />
</label>
</div>
<input type="text" value={block.slogan} onChange={(e) => updateBlock(block.id, 'slogan', e.target.value)} className="w-full p-2 border border-gray-300 rounded text-sm font-bold mt-2" /> <input type="text" value={block.slogan} onChange={(e) => updateBlock(block.id, 'slogan', e.target.value)} className="w-full p-2 border border-gray-300 rounded text-sm font-bold mt-2" />
<textarea rows={4} className="w-full p-2 border border-gray-300 rounded text-sm mt-2" value={block.address} onChange={(e) => updateBlock(block.id, 'address', e.target.value)} /> <textarea rows={4} className="w-full p-2 border border-gray-300 rounded text-sm mt-2" value={block.address} onChange={(e) => updateBlock(block.id, 'address', e.target.value)} />
<textarea rows={4} className="w-full p-2 border border-gray-300 rounded text-xs text-gray-500 mt-2" value={block.legalText} onChange={(e) => updateBlock(block.id, 'legalText', e.target.value)} /> <textarea rows={4} className="w-full p-2 border border-gray-300 rounded text-xs text-gray-500 mt-2" value={block.legalText} onChange={(e) => updateBlock(block.id, 'legalText', e.target.value)} />
@@ -689,10 +978,30 @@ function EditPanel({ block, updateBlock, deleteBlock }: { block: any, updateBloc
)} )}
{/* URL COMMUNE */} {/* URL COMMUNE */}
{(block.type === 'image' || block.type === 'button' || block.type === 'link') && ( {(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> <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 */}
{block.type === 'image' && (
<div>
<label className="block text-xs font-semibold text-slate-500 mb-1">Image</label>
<input
type="text"
value={block.src}
onChange={(e) => updateBlock(block.id, 'src', e.target.value)}
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">
<Upload size={12} /> Téléverser une image
<input type="file" accept="image/*" className="hidden" onChange={(e) => handleImageUpload(e, 'src')} />
</label>
</div>
</div>
)}
{/* ALIGNEMENT */} {/* ALIGNEMENT */}
{block.type !== 'spacer' && block.type !== 'footer' && block.type !== 'divider' && ( {block.type !== 'spacer' && block.type !== 'footer' && block.type !== 'divider' && (
<div> <div>
@@ -746,7 +1055,13 @@ function PreviewBlock({ block }: { block: any }) {
backgroundSize: 'cover', backgroundSize: 'cover',
backgroundPosition: 'center', backgroundPosition: 'center',
}}> }}>
<h1 style={{...textStyle, fontWeight: 'normal', lineHeight: '1.1', position: 'relative', zIndex: 1}}>{block.content}</h1> {/* DECORATION (Z-INDEX 0) POUR ÊTRE DERRIÈRE */}
{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>
)}
{/* TEXTE (Z-INDEX 10) POUR ÊTRE DEVANT */}
<h1 style={{...textStyle, fontWeight: 'normal', lineHeight: '1.1', position: 'relative', zIndex: 10}}>{block.content}</h1>
{block.subtitle && ( {block.subtitle && (
<div style={{ <div style={{
color: block.subtitleColor || ORANGE_COLOR, color: block.subtitleColor || ORANGE_COLOR,
@@ -754,14 +1069,11 @@ function PreviewBlock({ block }: { block: any }) {
marginTop: '10px', marginTop: '10px',
fontFamily: fontFamily, fontFamily: fontFamily,
position: 'relative', position: 'relative',
zIndex: 1 zIndex: 10
}}> }}>
{block.subtitle} {block.subtitle}
</div> </div>
)} )}
{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> </div>
{/* RESTAURATION DE LA LIGNE ORANGE */} {/* RESTAURATION DE LA LIGNE ORANGE */}
<div style={{ backgroundColor: ORANGE_COLOR, height: '1px', width: '100%' }}></div> <div style={{ backgroundColor: ORANGE_COLOR, height: '1px', width: '100%' }}></div>
@@ -789,7 +1101,7 @@ function PreviewBlock({ block }: { block: any }) {
</div> </div>
); );
case 'text': case 'text':
return <div style={containerStyle}><div style={textStyle}>{block.content}</div></div>; return <div style={containerStyle} dangerouslySetInnerHTML={{ __html: block.content }} />;
case 'link': case 'link':
return ( return (
<div style={containerStyle}> <div style={containerStyle}>
@@ -808,7 +1120,7 @@ function PreviewBlock({ block }: { block: any }) {
</div> </div>
<div style={{ padding: '0 10px' }}><div style={{ borderBottom: `1px dotted ${ORANGE_COLOR}`, width: '100%', height: '1px' }}></div></div> <div style={{ padding: '0 10px' }}><div style={{ borderBottom: `1px dotted ${ORANGE_COLOR}`, width: '100%', height: '1px' }}></div></div>
<div style={{ padding: '20px', textAlign: 'center' }}> <div style={{ padding: '20px', textAlign: 'center' }}>
<img src={block.logoSrc} alt="logo" style={{ width: '80px', height: '80px', borderRadius: '50%', margin: '0 auto 10px auto', display: 'block', objectFit: 'cover' }} /> <img src={block.logoSrc} alt="logo" style={{ width: `${block.logoWidth || 80}px`, height: 'auto', display: 'block', margin: '0 auto 10px auto', maxWidth: '100%' }} />
<div style={{ fontWeight: 'bold', fontStyle: 'italic', fontSize: '14px', marginBottom: '10px' }}>{block.slogan}</div> <div style={{ fontWeight: 'bold', fontStyle: 'italic', fontSize: '14px', marginBottom: '10px' }}>{block.slogan}</div>
<div style={{ whiteSpace: 'pre-wrap', fontSize: '14px', lineHeight: '1.4' }}>{block.address}</div> <div style={{ whiteSpace: 'pre-wrap', fontSize: '14px', lineHeight: '1.4' }}>{block.address}</div>
</div> </div>