/** * ========================================================================= * PLATEFORME ECO-PANNEAU.FR - VERSION 1.0.0 * Socle UI (1/4) - Utilitaires, API et Logique Métier * ========================================================================= */ // ========================================================================= // 1. SYSTÈME DE NOTIFICATIONS GLOBAL (TOASTS) - DROP IN // ========================================================================= window.showToast = (message, type = 'info', duration = 4000) => { let container = document.getElementById('toast-container'); if (!container) { container = document.createElement('div'); container.id = 'toast-container'; container.className = 'fixed bottom-4 right-4 sm:bottom-6 sm:right-6 z-[99999] flex flex-col items-end gap-3 pointer-events-none'; document.body.appendChild(container); } const toast = document.createElement('div'); const bgColor = type === 'success' ? 'bg-emerald-600/95 border-emerald-500 text-white' : (type === 'error' ? 'bg-red-500/95 border-red-400 text-white' : (type === 'warning' ? 'bg-amber-500/95 border-amber-400 text-white' : 'bg-blue-600/95 border-blue-500 text-white')); const svgWrap = (path) => `${path}`; const iconSvg = type === 'success' ? svgWrap(window.svgPaths?.CheckCircle || '') : (type === 'error' || type === 'warning' ? svgWrap(window.svgPaths?.AlertTriangle || '') : svgWrap(window.svgPaths?.Info || '')); toast.className = `flex items-center gap-3 px-5 py-4 rounded-2xl border shadow-2xl ${bgColor} pointer-events-auto backdrop-blur-md max-w-xs transition-all duration-500 ease-out`; toast.innerHTML = `
${iconSvg}
${message}
`; toast.style.transform = 'translateY(-100vh)'; toast.style.opacity = '0'; container.appendChild(toast); void toast.offsetWidth; toast.style.transform = 'translateY(0)'; toast.style.opacity = '1'; setTimeout(() => { toast.style.transform = 'translateX(120%)'; setTimeout(() => { toast.remove(); if (container && container.childNodes.length === 0) container.remove(); }, 500); }, duration); }; // ========================================================================= // 2. UTILITAIRES ET LOGIQUE MÉTIER (BUSINESS LOGIC) // ========================================================================= window.apiFetch = async (endpoint, options = {}) => { const { method = 'POST', body, setLoading, successMessage, errorMessage = "Erreur réseau ou serveur" } = options; if (setLoading) setLoading(true); try { const fetchOptions = { method }; if (body) { const cleanBody = JSON.parse(JSON.stringify(body), (key, value) => { if (key === 'imageUrl' || key === 'maitreOuvrageLogoUrl' || key === 'logoUrl') { return undefined; } if (typeof value === 'string' && value.includes('data:image/')) { return value.replace(/(src|href)=["']data:image\/[^;]+;base64,[^"']+["']/gi, '$1="#"'); } return value; }); fetchOptions.body = JSON.stringify(cleanBody); } const res = await fetch(window.ECO_CONFIG.apiBaseUrl + endpoint, fetchOptions); const d = await res.json(); if (d.status === 'success') { if (successMessage) window.showToast(successMessage, "success"); return d; } else { window.showToast(d.message || errorMessage, "error"); return null; } } catch (e) { window.showToast(errorMessage, "error"); return null; } finally { if (setLoading) setLoading(false); } }; window.formatDate = (dateStr, fallback = '-') => { if (!dateStr) return fallback; const d = new Date(String(dateStr).replace(' ', 'T')); return isNaN(d.getTime()) ? fallback : d.toLocaleDateString('fr-FR'); }; window.getFileUrl = (type, id, isPrivate = false, isPreview = false) => { if (!id) return ''; let url = `?api=file/download&type=${type}&id=${id}`; if (isPrivate) url += '&private=1'; if (isPreview) url += '&preview=1'; return url; }; window.getPanelAccessRights = (allPanels = [], clientUid) => { const pendingInvites = allPanels.filter(p => p.client_uid !== clientUid && p.collaborators?.some(c => c.uid === clientUid && c.status === 'pending')); const ownedPanels = allPanels.filter(p => p.client_uid === clientUid); const acceptedCollabPanels = allPanels.filter(p => p.client_uid !== clientUid && p.collaborators?.some(c => c.uid === clientUid && c.status === 'accepted')); const visiblePanels = [...ownedPanels, ...acceptedCollabPanels]; return { pendingInvites, ownedPanels, acceptedCollabPanels, visiblePanels }; }; window.computeClientUnread = (interactions = [], myClientData = {}, visiblePanels = [], pendingInvites = []) => { let unread = 0; const expectedFullName = myClientData.full_name || 'Utilisateur'; const expectedCompany = myClientData.name || 'Société'; const expectedAuthorName = `${expectedFullName} (${expectedCompany})`; interactions.forEach(m => { if (m.resolved) return; const isMyOwnMessage = (m.authorType === 'Client' && ( m.author === 'Client' || m.author === myClientData.email || m.author === myClientData.full_name || m.author === myClientData.name || m.author === expectedAuthorName )); if (m.panneauId.startsWith('SUPPORT_')) { if (m.authorType !== 'Client') unread++; } else if (m.panneauId.startsWith('GROUP_')) { const pId = m.panneauId.replace('GROUP_', ''); const hasAccess = visiblePanels.some(p => p.id === pId) || pendingInvites.some(p => p.id === pId); if (hasAccess && !isMyOwnMessage && m.authorType !== 'Systeme') { unread++; } } else { if (m.authorType !== 'Client' && m.authorType !== 'Admin' && m.authorType !== 'Systeme') { unread++; } } }); return unread; }; window.uploadFile = async (file, type, onProgress, isPublic = false, isPrivate = false, clientUid = null, panneauId = null) => { let fileToSend = file; let previewB64s = {}; let totalPages = 1; let hasLocalProcessing = false; try { if (type === 'image' && file.type.startsWith('image/')) { if (onProgress) onProgress("optimisation", 10); const img = new Image(); const imgUrl = URL.createObjectURL(file); await new Promise((resolve, reject) => { img.onload = resolve; img.onerror = () => reject(new Error("Le navigateur n'a pas pu lire l'image.")); img.src = imgUrl; }); const canvas = document.createElement('canvas'); const ctx = canvas.getContext('2d'); let width = img.width; let height = img.height; const MAX_SIZE = 1920; if (width > MAX_SIZE || height > MAX_SIZE) { if (width > height) { height = Math.round((height * MAX_SIZE) / width); width = MAX_SIZE; } else { width = Math.round((width * MAX_SIZE) / height); height = MAX_SIZE; } } canvas.width = width; canvas.height = height; ctx.drawImage(img, 0, 0, width, height); URL.revokeObjectURL(imgUrl); const blob = await new Promise((resolve, reject) => { canvas.toBlob(b => { if (b) resolve(b); else { canvas.toBlob(b2 => { if (b2) resolve(b2); else reject(new Error("Échec de la conversion de l'image (toBlob).")); }, 'image/jpeg', 0.85); } }, 'image/webp', 0.85); }); const ext = blob.type === 'image/jpeg' ? '.jpg' : '.webp'; fileToSend = new File([blob], file.name.replace(/\.[^/.]+$/, "") + ext, { type: blob.type }); canvas.width = 0; canvas.height = 0; hasLocalProcessing = true; if (onProgress) onProgress("optimisation", 30); } else if (type === 'pdf' && file.type === 'application/pdf') { if (onProgress) onProgress("préparation", 5); if (!window.pdfjsLib) { await new Promise((resolve, reject) => { const script = document.createElement('script'); script.src = '_externe/pdfjs/pdf.min.js'; script.onload = () => { window.pdfjsLib.GlobalWorkerOptions.workerSrc = '_externe/pdfjs/pdf.worker.min.js'; resolve(); }; script.onerror = () => reject(new Error("Impossible de charger PDF.js.")); document.head.appendChild(script); }); } if (window.pdfjsLib) { if (onProgress) onProgress("préparation", 10); const arrayBuffer = await file.arrayBuffer(); const loadingTask = window.pdfjsLib.getDocument({ data: arrayBuffer }); const pdf = await loadingTask.promise; totalPages = Math.min(pdf.numPages, 50); const page1 = await pdf.getPage(1); const viewportThumb = page1.getViewport({ scale: 0.4 }); const canvasThumb = document.createElement('canvas'); canvasThumb.width = viewportThumb.width; canvasThumb.height = viewportThumb.height; await page1.render({ canvasContext: canvasThumb.getContext('2d'), viewport: viewportThumb }).promise; previewB64s['preview_b64_0'] = canvasThumb.toDataURL('image/webp', 0.6); canvasThumb.width = 0; canvasThumb.height = 0; page1.cleanup(); for (let i = 1; i <= totalPages; i++) { if (onProgress) onProgress(`page ${i}/${totalPages}`, 10 + Math.round((i/totalPages)*20)); const page = await pdf.getPage(i); const viewport = page.getViewport({ scale: 1.0 }); const canvas = document.createElement('canvas'); canvas.width = viewport.width; canvas.height = viewport.height; await page.render({ canvasContext: canvas.getContext('2d'), viewport }).promise; let dataUrl = canvas.toDataURL('image/webp', 0.7); if (!dataUrl || dataUrl === 'data:,') { dataUrl = canvas.toDataURL('image/jpeg', 0.7); } previewB64s[`preview_b64_${i}`] = dataUrl; canvas.width = 0; canvas.height = 0; page.cleanup(); } pdf.destroy(); hasLocalProcessing = true; } } } catch (e) { console.error("Erreur lors du traitement local du fichier :", e); window.showToast("Note : Le document est envoyé sans optimisation ni miniature locale (Aperçu web indisponible).", "warning"); hasLocalProcessing = false; fileToSend = file; previewB64s = {}; totalPages = 1; } return new Promise((resolve, reject) => { const formData = new FormData(); formData.append('file', fileToSend); formData.append('type', type); if (isPublic) formData.append('isPublic', 'true'); if (isPrivate) formData.append('is_private', 'true'); if (clientUid) formData.append('client_uid', clientUid); if (panneauId) formData.append('panneau_id', panneauId); if (Object.keys(previewB64s).length > 0) { for (const [key, b64] of Object.entries(previewB64s)) { formData.append(key, b64); } formData.append('numPages', totalPages); } const xhr = new XMLHttpRequest(); xhr.open('POST', window.ECO_CONFIG.apiBaseUrl + 'file/upload', true); xhr.upload.onprogress = (e) => { if (e.lengthComputable) { const base = hasLocalProcessing ? 30 : 0; const multiplier = hasLocalProcessing ? 70 : 100; const percent = Math.round(base + ((e.loaded / e.total) * multiplier)); if (onProgress) onProgress(`envoi ${Math.round((e.loaded / e.total) * 100)}%`, percent); } }; xhr.onload = () => { if (xhr.status >= 200 && xhr.status < 300) { try { const res = JSON.parse(xhr.responseText); if (res.status === 'success') { if (onProgress) onProgress("terminé", 100); const ret = new String(res.data.id); ret.id = res.data.id; ret.numPages = res.data.numPages || 1; resolve(ret); } else reject(res.message || 'Erreur upload serveur'); } catch (err) { reject('Réponse invalide du serveur'); } } else reject('Erreur HTTP ' + xhr.status); }; xhr.onerror = () => reject("Erreur réseau (déconnexion ou blocage)"); xhr.send(formData); }); }; // ========================================================================= // 3. HOOKS ET FACTORISATION UI // ========================================================================= /** * Hook personnalisé pour gérer l'état des modales/dialogues via l'URL * Centralise la logique SPA Layer 1 (modal) et Layer 2 (dialog) */ window.useUrlModal = () => { const { useState, useEffect } = React; const [activeModal, setActiveModal] = useState(new URLSearchParams(window.location.search).get('modal')); const [activeDialog, setActiveDialog] = useState(new URLSearchParams(window.location.search).get('dialog')); const [targetId, setTargetId] = useState(new URLSearchParams(window.location.search).get('targetId')); const [alertParam, setAlertParam] = useState(new URLSearchParams(window.location.search).get('alert')); useEffect(() => { const handlePopState = () => { const params = new URLSearchParams(window.location.search); setActiveModal(params.get('modal')); setActiveDialog(params.get('dialog')); setTargetId(params.get('targetId')); setAlertParam(params.get('alert')); }; window.addEventListener('popstate', handlePopState); return () => window.removeEventListener('popstate', handlePopState); }, []); const openModal = (type, id = null) => { const u = new URL(window.location); u.searchParams.set('modal', type); if (id) u.searchParams.set('targetId', id); window.history.pushState({ modal: type }, '', u); window.dispatchEvent(new Event('popstate')); }; const openDialog = (type, id = null) => { const u = new URL(window.location); u.searchParams.set('dialog', type); if (id) u.searchParams.set('targetId', id); window.history.pushState({ dialog: type }, '', u); window.dispatchEvent(new Event('popstate')); }; const setAlert = (type) => { const u = new URL(window.location); u.searchParams.set('alert', type); window.history.pushState({ alert: type }, '', u); window.dispatchEvent(new Event('popstate')); }; const closeCurrentLayer = () => { window.history.back(); }; return { activeModal, activeDialog, targetId, alertParam, openModal, openDialog, setAlert, closeCurrentLayer }; }; /** * Utilitaire pour récupérer les icônes de manière sécurisée sans polluer le scope global des composants */ window.getIcons = () => { const fallback = () => null; return new Proxy({}, { get: (target, prop) => { if (typeof window[prop] !== 'undefined') return window[prop]; return fallback; } }); }; /* EOF ========== [_www/_react/_ui_utils.jsx] */