diff --git a/src/App.tsx b/src/App.tsx index 8afe152..22fe30d 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1,5 +1,5 @@ -import { useState } 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 { 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, Info } from 'lucide-react'; // --- CONFIGURATION & CONSTANTES --- const DEFAULT_WIDTH = "600"; @@ -18,9 +18,44 @@ const FONT_OPTIONS = { interface Block { id: 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 | 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 ( +
+ {children} + {show && ( +
+ {text} + {/* Petite flèche vers le bas */} +
+
+ )} +
+ ); +}; + // Modèles de blocs par défaut const BLOCK_TEMPLATES: Record = { header: { @@ -29,7 +64,7 @@ const BLOCK_TEMPLATES: Record = { content: "BULLETIN MENSUEL\nD'INFORMATIONS SYNDiCALES", subtitle: 'AUTOMNE 2025', bgColor: '#eeeeee', - backgroundImage: '', // Nouveau champ image de fond + backgroundImage: '', textColor: PURPLE_COLOR, subtitleColor: ORANGE_COLOR, padding: '30', @@ -61,7 +96,8 @@ const BLOCK_TEMPLATES: Record = { text: { id: null, 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 s’accentue.', + // On utilise du HTML par défaut maintenant pour le RTE + 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 s’accentue.

', bgColor: '#ffffff', textColor: '#4a4a4a', padding: '20', @@ -132,28 +168,178 @@ const BLOCK_TEMPLATES: Record = { btn2Url: 'https://adherer.solidairesfinancespubliques.org/', btn2Color: '#22c55e', 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', - 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".', bgColor: '#ffffff', fontFamily: FONT_OPTIONS.sans.value } }; +// --- COMPOSANT MINI RTE (Rich Text Editor) --- +const SimpleRTE = ({ value, onChange }: { value: string, onChange: (val: string) => void }) => { + const editorRef = useRef(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 ( +
+ {/* Barre d'outils */} +
+ execCmd('bold')} icon={} title="Mettre en gras" /> + execCmd('italic')} icon={} title="Mettre en italique" /> + execCmd('underline')} icon={} title="Souligner" /> +
+ execCmd('insertUnorderedList')} icon={} title="Liste à puces" /> + execCmd('insertOrderedList')} icon={} title="Liste numérotée" /> +
+ + {/* Zone d'édition */} +
onChange(e.currentTarget.innerHTML)} + onBlur={(e) => onChange(e.currentTarget.innerHTML)} + style={{ fontFamily: 'Helvetica, Arial, sans-serif' }} + /> +
+ ); +}; + +// Bouton RTE avec Tooltip intégré via title +const RteButton = ({ onClick, icon, title }: { onClick: () => void, icon: any, title: string }) => ( + + + +); + + export default function NewsletterBuilder() { - const [blocks, setBlocks] = useState([ - { ...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 [blocks, setBlocks] = useState([]); // Initialisé vide pour l'effet de chargement const [selectedBlockId, setSelectedBlockId] = useState(null); const [globalBg, setGlobalBg] = useState('#f3f4f6'); 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 --- 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 newBlock = JSON.parse(JSON.stringify(template)); 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) => { try { + if (url.startsWith('data:')) return url; // Déjà en base64 const response = await fetch(url); if (!response.ok) throw new Error('Network response'); const blob = await response.blob(); @@ -233,6 +420,11 @@ export default function NewsletterBuilder() { const ctx = canvas.getContext('2d'); 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 fontSize = parseInt(block.fontSize) || 24; const subFontSize = parseInt(block.subtitleFontSize) || 20; @@ -247,8 +439,16 @@ export default function NewsletterBuilder() { const contentHeight = titleHeight + subtitleHeight; const canvasHeight = contentHeight + (padding * 2); - canvas.width = width; - canvas.height = canvasHeight; + // Définition de la taille physique du canvas (x3) + 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 ctx.fillStyle = block.bgColor; @@ -274,10 +474,6 @@ export default function NewsletterBuilder() { offsetY = (canvasHeight - drawHeight) / 2; } 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, ""); @@ -293,24 +489,7 @@ export default function NewsletterBuilder() { ctx.textAlign = 'left'; } - // 3. DESSINER LE TEXTE - 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 + // 3. DESSINER LA DÉCORATION (AVANT LE TEXTE pour qu'elle soit derrière) if (block.decoration && block.decoration.enabled) { ctx.save(); const decSize = parseInt(block.decoration.size) || 50; @@ -331,7 +510,25 @@ export default function NewsletterBuilder() { 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 ` - ${block.content.replace(/\n/g, '
')} + ${block.content} `; @@ -412,7 +609,7 @@ export default function NewsletterBuilder() {
- Logo + Logo
${block.slogan}
${block.address.replace(/\n/g, '
')}
@@ -428,7 +625,15 @@ export default function NewsletterBuilder() { return ` Newsletter - +
@@ -450,7 +655,6 @@ export default function NewsletterBuilder() { const base64Logo = await convertImageToBase64(block.logoSrc); return { ...block, logoSrc: base64Logo }; } 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); 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() {

Version Bulletin Mensuel

+ {/* SECTION GESTION PROJET */} +
+

Gestion Projet

+
+ + + +
+ + + + + + +
+
+
+

Ajouter un bloc

- } label="En-tête" onClick={() => addBlock('header')} /> - } label="Séparateur" onClick={() => addBlock('divider')} /> - } label="Titre" onClick={() => addBlock('title')} /> - } label="Texte" onClick={() => addBlock('text')} /> - } label="Lien Texte" onClick={() => addBlock('link')} /> - } label="Bouton" onClick={() => addBlock('button')} /> - } label="Image" onClick={() => addBlock('image')} /> - } label="Espace" onClick={() => addBlock('spacer')} /> - } label="Pied" onClick={() => addBlock('footer')} /> + + } label="En-tête" onClick={() => addBlock('header')} disabled={hasHeader} /> + + + } label="Séparateur" onClick={() => addBlock('divider')} /> + + + } label="Titre" onClick={() => addBlock('title')} /> + + + } label="Texte" onClick={() => addBlock('text')} /> + + + } label="Lien Texte" onClick={() => addBlock('link')} /> + + + } label="Bouton" onClick={() => addBlock('button')} /> + + + } label="Image" onClick={() => addBlock('image')} /> + + + } label="Espace" onClick={() => addBlock('spacer')} /> + + + } label="Pied" onClick={() => addBlock('footer')} disabled={hasFooter} /> +
@@ -522,9 +768,11 @@ export default function NewsletterBuilder() {
- + + +
@@ -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 ( - @@ -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 }) { if (!block) return null; + const handleImageUpload = (e: React.ChangeEvent, 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 (
@@ -573,8 +836,16 @@ function EditPanel({ block, updateBlock, deleteBlock }: { block: any, updateBloc
- {/* ÉDITION CONTENU (HEADER, TEXTE, LINK, BUTTON, TITLE) */} - {(['header', 'text', 'button', 'link', 'title'].includes(block.type)) && ( + {/* ÉDITION TEXTE AVEC RTE */} + {block.type === 'text' && ( +
+ + updateBlock(block.id, 'content', val)} /> +
+ )} + + {/* ÉDITION CONTENU SIMPLE (HEADER, LINK, BUTTON, TITLE) */} + {(['header', 'button', 'link', 'title'].includes(block.type)) && ( <>
@@ -608,14 +879,20 @@ function EditPanel({ block, updateBlock, deleteBlock }: { block: any, updateBloc {/* ÉDITION SPÉCIFIQUE HEADER : IMAGE DE FOND */} {block.type === 'header' && (
- + updateBlock(block.id, 'backgroundImage', e.target.value)} - className="w-full p-2 border border-gray-300 rounded text-sm" - placeholder="https://..." + className="w-full p-2 border border-gray-300 rounded text-sm mb-2" + placeholder="URL image (https://...)" /> +
+ +
)} @@ -681,7 +958,19 @@ function EditPanel({ block, updateBlock, deleteBlock }: { block: any, updateBloc updateBlock(block.id, 'btn2Color', e.target.value)} className="w-full h-6 p-0" />
updateBlock(block.id, 'btn2Url', e.target.value)} className="w-full p-1 border text-xs" /> - updateBlock(block.id, 'logoSrc', e.target.value)} className="w-full p-2 border border-gray-300 rounded text-sm mt-2" placeholder="Logo URL" /> + +
+
+ + updateBlock(block.id, 'logoWidth', e.target.value)} className="w-16 p-1 border border-gray-300 rounded text-xs mt-2" placeholder="px" /> +
+ updateBlock(block.id, 'logoSrc', e.target.value)} className="w-full p-2 border border-gray-300 rounded text-sm mb-1" placeholder="Logo URL" /> + +
+ updateBlock(block.id, 'slogan', e.target.value)} className="w-full p-2 border border-gray-300 rounded text-sm font-bold mt-2" />