/* ===================================================================== MSG MOTOC — client PWA Vanilla JS, aucun build. Sections : état, API, auth, WebSocket, groupes, conversation, images P2P (WebRTC), réglages, administration. ===================================================================== */ (() => { 'use strict'; /* ------------------------------------------------------------------ */ /* État */ /* ------------------------------------------------------------------ */ const state = { token: localStorage.getItem('motoc_token') || '', me: null, groups: [], groupsById: new Map(), currentGroup: null, messages: [], oldestId: null, hasMore: false, replyTo: null, ws: null, wsRetry: 0, typing: new Map(), // group_id -> Map(phone -> timestamp) pins: [], receipts: [], // état de lecture des membres du groupe ouvert pendingPhone: '', loginRole: 'benevole', loadingHistory: false, }; const $ = (sel) => document.querySelector(sel); const $$ = (sel) => Array.from(document.querySelectorAll(sel)); /* ------------------------------------------------------------------ */ /* Utilitaires */ /* ------------------------------------------------------------------ */ function escapeHtml(s) { return String(s ?? '') .replace(/&/g, '&') .replace(//g, '>') .replace(/"/g, '"') .replace(/'/g, '''); } // Applique les liens et les mentions sur du texte DÉJÀ échappé. function enrich(escaped) { return escaped .replace( /(https?:\/\/[^\s<]+)/g, (m) => `${m}`, ) .replace(/(^|\s)(@[\wÀ-ÿ'-]+)/gu, '$1$2'); } function initials(name) { // Les comptes du bac à sable sont tous préfixés « test_ » : sans cette // coupe, les vingt avatars afficheraient le même « TE ». const raw = String(name || '') .trim() .replace(/\btest_/gi, ''); // Un compte pas encore activé n'a que son numéro : « 06 11 22 » ne fait // pas des initiales lisibles, on retombe sur un neutre. if (!raw || /^[\d\s+.-]+$/.test(raw)) return '?'; const parts = raw.split(/\s+/).filter(Boolean); if (!parts.length) return '?'; if (parts.length === 1) return parts[0].slice(0, 2).toUpperCase(); return (parts[0][0] + parts[1][0]).toUpperCase(); } // Couleur d'avatar dérivée du numéro : stable, et alternée cyan/braise. function avatarClass(phone) { let h = 0; for (const c of String(phone || '')) h = (h * 31 + c.charCodeAt(0)) >>> 0; return h % 2 === 0 ? '' : 'ember'; } function fmtTime(ms) { return new Date(ms).toLocaleTimeString('fr-FR', { hour: '2-digit', minute: '2-digit' }); } function fmtDay(ms) { const d = new Date(ms); const today = new Date(); const yest = new Date(); yest.setDate(today.getDate() - 1); const same = (a, b) => a.toDateString() === b.toDateString(); if (same(d, today)) return "Aujourd'hui"; if (same(d, yest)) return 'Hier'; return d.toLocaleDateString('fr-FR', { weekday: 'long', day: 'numeric', month: 'long' }); } function fmtRelative(ms) { if (!ms) return ''; const diff = Date.now() - ms; if (diff < 60000) return "à l'instant"; if (diff < 3600000) return `${Math.floor(diff / 60000)} min`; if (diff < 86400000) return fmtTime(ms); const d = new Date(ms); return d.toLocaleDateString('fr-FR', { day: '2-digit', month: '2-digit' }); } function fmtBytes(n) { if (n === null || n === undefined) return '—'; if (n < 1024) return `${Math.round(n)} o`; if (n < 1048576) return `${(n / 1024).toFixed(0)} Ko`; if (n < 1073741824) return `${(n / 1048576).toFixed(1)} Mo`; return `${(n / 1073741824).toFixed(2)} Go`; } function fmtDateTime(ms) { return new Date(ms).toLocaleString('fr-FR', { day: '2-digit', month: '2-digit', hour: '2-digit', minute: '2-digit', }); } function toast(message, kind = '') { const el = document.createElement('div'); el.className = `toast ${kind}`; el.textContent = message; $('#toasts').appendChild(el); setTimeout(() => { el.style.opacity = '0'; el.style.transition = 'opacity .3s'; setTimeout(() => el.remove(), 300); }, 3600); } function vibrate(pattern) { if (navigator.vibrate) { try { navigator.vibrate(pattern); } catch (e) { /* ignoré */ } } } /* ------------------------------------------------------------------ */ /* API */ /* ------------------------------------------------------------------ */ async function api(path, options = {}) { // Un FormData part tel quel : le navigateur pose lui-même le Content-Type // avec la frontière multipart, qu'on ne peut pas écrire à la main. const isForm = options.body instanceof FormData; const headers = { ...(isForm ? {} : { 'Content-Type': 'application/json' }), ...(options.headers || {}), }; if (state.token) headers.Authorization = `Bearer ${state.token}`; const res = await fetch(path, { ...options, headers, body: options.body ? (isForm ? options.body : JSON.stringify(options.body)) : undefined, }); let data = null; try { data = await res.json(); } catch (e) { data = null; } if (!res.ok) { if (res.status === 401 && state.token) { logout(true); throw new Error('Session expirée. Reconnectez-vous.'); } throw new Error((data && data.detail) || `Erreur ${res.status}`); } return data; } /* ------------------------------------------------------------------ */ /* Navigation entre écrans */ /* ------------------------------------------------------------------ */ function showScreen(id) { $$('.screen').forEach((s) => s.classList.remove('active')); $(`#${id}`).classList.add('active'); } function openOverlay(id) { $(`#${id}`).classList.add('active'); history.pushState({ overlay: id }, ''); } function closeOverlay(id) { $(`#${id}`).classList.remove('active'); // Quitter le panneau d'administration arrête le sondage matériel et // rend à l'application son cadre étroit. if (id === 'screen-admin') { stopSystem(); $('#app').classList.remove('desk'); } } window.addEventListener('popstate', () => { // Le bouton « retour » ferme la vue superposée la plus haute. for (const id of ['screen-sandbox', 'screen-chat', 'screen-admin', 'screen-settings']) { const el = $(`#${id}`); if (el.classList.contains('active')) { if (id === 'screen-chat') leaveChat(false); else closeOverlay(id); return; } } }); /* ------------------------------------------------------------------ */ /* Authentification */ /* ------------------------------------------------------------------ */ function authError(message) { $('#auth-error').textContent = message || ''; } function showAuthStep(step) { $$('.auth-step').forEach((el) => { el.hidden = el.id !== step; }); authError(''); } /* --- Choix du profil : Admin ou Bénévole ---------------------------- */ // « Admin » signifie superadmin. La vérification est faite côté serveur // après le PIN : l'écran ne révèle jamais quels numéros sont admins. function setRole(role) { state.loginRole = role; const isAdmin = role === 'admin'; $('#role-pill-slot').innerHTML = ` ${isAdmin ? 'Espace admin' : 'Espace bénévole'}`; showAuthStep('step-phone'); setTimeout(() => $('#in-phone').focus(), 120); } $$('.role-card').forEach((card) => card.addEventListener('click', () => setRole(card.dataset.role)), ); $$('[data-back-to-role]').forEach((b) => b.addEventListener('click', () => showAuthStep('step-role')), ); // Saisie du téléphone : groupée par deux, chiffres uniquement. $('#in-phone').addEventListener('input', (e) => { const digits = e.target.value.replace(/\D/g, '').slice(0, 10); e.target.value = digits.replace(/(\d{2})(?=\d)/g, '$1 ').trim(); }); // Champs PIN : chiffres uniquement. $$('.input-pin').forEach((input) => { input.addEventListener('input', (e) => { e.target.value = e.target.value.replace(/\D/g, '').slice(0, 6); }); }); async function submitPhone() { const phone = $('#in-phone').value.replace(/\D/g, ''); if (phone.length !== 10) return authError('Saisissez un numéro à 10 chiffres.'); const btn = $('#btn-phone'); btn.disabled = true; try { const res = await api('/api/auth/check', { method: 'POST', body: { phone } }); state.pendingPhone = phone; if (res.state === 'register') { $('#reg-phone').textContent = res.phone_display; // Numéro jamais vu : on prévient que les groupes viendront plus tard, // sinon l'écran suivant paraît cassé (aucun groupe à l'arrivée). $('#reg-newcomer').hidden = !res.new_account; showAuthStep('step-register'); setTimeout(() => $('#in-first').focus(), 120); } else if (res.state === 'login') { $('#login-name').textContent = res.display_name || 'Bienvenue'; $('#login-phone').textContent = res.phone_display; showAuthStep('step-login'); setTimeout(() => $('#in-pin').focus(), 120); } else { authError(res.message || 'Numéro non reconnu.'); } } catch (err) { authError(err.message); } finally { btn.disabled = false; } } async function submitRegister() { const first = $('#in-first').value.trim(); const last = $('#in-last').value.trim(); const nick = $('#in-nick').value.trim(); const pin1 = $('#in-pin1').value; const pin2 = $('#in-pin2').value; if (!first) return authError('Le prénom est obligatoire.'); if (!last) return authError('Le nom est obligatoire.'); if (pin1.length !== 6) return authError('Le code PIN doit contenir 6 chiffres.'); if (pin1 !== pin2) return authError('Les deux codes PIN ne correspondent pas.'); const btn = $('#btn-register'); btn.disabled = true; try { const res = await api('/api/auth/register', { method: 'POST', body: { phone: state.pendingPhone, first_name: first, last_name: last, nickname: nick, pin: pin1, role: state.loginRole, }, }); onAuthenticated(res); } catch (err) { authError(err.message); } finally { btn.disabled = false; } } async function submitLogin() { const pin = $('#in-pin').value; if (pin.length !== 6) return authError('Le code PIN doit contenir 6 chiffres.'); const btn = $('#btn-login'); btn.disabled = true; try { const res = await api('/api/auth/login', { method: 'POST', body: { phone: state.pendingPhone, pin, role: state.loginRole }, }); onAuthenticated(res); } catch (err) { authError(err.message); $('#in-pin').value = ''; } finally { btn.disabled = false; } } function onAuthenticated(res) { state.token = res.token; state.me = res.user; localStorage.setItem('motoc_token', res.token); $('#in-pin').value = $('#in-pin1').value = $('#in-pin2').value = ''; enterApp(); } function logout(silent) { state.token = ''; state.me = null; localStorage.removeItem('motoc_token'); if (state.ws) { try { state.ws.close(); } catch (e) { /* ignoré */ } state.ws = null; } $$('.screen.overlay').forEach((s) => s.classList.remove('active')); $('#app').classList.remove('shell', 'desk'); showScreen('screen-auth'); showAuthStep('step-role'); if (!silent) toast('Vous êtes déconnecté.'); } $('#btn-phone').addEventListener('click', submitPhone); $('#btn-register').addEventListener('click', submitRegister); $('#btn-login').addEventListener('click', submitLogin); $$('[data-back-to-phone]').forEach((b) => b.addEventListener('click', () => showAuthStep('step-phone')), ); $('#in-phone').addEventListener('keydown', (e) => e.key === 'Enter' && submitPhone()); $('#in-pin').addEventListener('keydown', (e) => e.key === 'Enter' && submitLogin()); $('#in-pin2').addEventListener('keydown', (e) => e.key === 'Enter' && submitRegister()); /* ------------------------------------------------------------------ */ /* Entrée dans l'application */ /* ------------------------------------------------------------------ */ async function enterApp() { showScreen('screen-groups'); // Passe l'application en disposition « poste de travail » : au-delà de // 1024 px, la liste des groupes et la conversation cohabitent. $('#app').classList.add('shell'); $('#btn-admin').hidden = !state.me.is_superadmin; await loadGroups(); connectWS(); refreshPushSwitch(); loadHelpRequests(); // Ouverture directe d'un groupe depuis une notification. const target = new URLSearchParams(location.search).get('groupe'); if (target) { history.replaceState(null, '', '/'); const g = state.groupsById.get(Number(target)); if (g) openChat(g.id); } } async function boot() { if (!state.token) { showScreen('screen-auth'); return; } try { state.me = await api('/api/me'); await enterApp(); } catch (err) { logout(true); } } /* ------------------------------------------------------------------ */ /* WebSocket */ /* ------------------------------------------------------------------ */ function setConnState(text, ok) { const el = $('#conn-state'); el.textContent = text; el.style.color = ok ? 'var(--text-faint)' : 'var(--ember)'; } function connectWS() { if (!state.token) return; if (state.ws && state.ws.readyState <= 1) return; const proto = location.protocol === 'https:' ? 'wss' : 'ws'; const ws = new WebSocket(`${proto}://${location.host}/ws`); state.ws = ws; setConnState('Connexion…', false); ws.onopen = () => { // Le token part dans une frame, jamais dans l'URL : une query string // finit dans les logs de proxy et l'historique du navigateur. ws.send(JSON.stringify({ type: 'auth', token: state.token })); state.wsRetry = 0; setConnState('Authentification…', false); if (state.currentGroup) wsSend({ type: 'focus', group_id: state.currentGroup.id }); }; ws.onmessage = (event) => { let data; try { data = JSON.parse(event.data); } catch (e) { return; } handleWsMessage(data); }; ws.onclose = (event) => { state.ws = null; if (event.code === 4001) { logout(true); return; } if (event.code === 4002) { setConnState('Trop d’appareils connectés', false); return; } setConnState('Hors ligne', false); // Reconnexion en exponential backoff, plafonnée à 15 s. state.wsRetry = Math.min(state.wsRetry + 1, 6); setTimeout(connectWS, Math.min(1000 * 2 ** state.wsRetry, 15000)); }; ws.onerror = () => { /* onclose prend le relais */ }; } function wsSend(payload) { if (state.ws && state.ws.readyState === 1) { state.ws.send(JSON.stringify(payload)); return true; } return false; } function handleWsMessage(data) { switch (data.type) { case 'ready': setConnState('En ligne', true); break; case 'message': onIncomingMessage(data.message); break; case 'message_deleted': if (state.currentGroup && state.currentGroup.id === data.group_id) { state.messages = state.messages.filter((m) => m.id !== data.id); renderThread(); } loadGroups(); break; case 'typing': onTyping(data); break; case 'groups_changed': loadGroups(); break; case 'help_request': toast(`${data.label} demande de l'aide pour se connecter.`); vibrate([40, 60, 40]); loadHelpRequests(); break; case 'read_receipt': { // Fait avancer les coches sans recharger toute la conversation. if (!state.currentGroup || state.currentGroup.id !== data.group_id) break; const member = state.receipts.find((m) => m.phone === data.phone); if (member) { member.last_read_id = Math.max(member.last_read_id, data.last_read_id); member.online = true; renderThread(); } break; } case 'moderation_alert': toast( `Signalement · ${data.alert.category_label} — ${data.alert.author} dans ${data.alert.group_name}`, 'error', ); vibrate([40, 60, 40]); if ($('#screen-admin').classList.contains('active')) loadModeration(); break; case 'new_self_registration': // Le compte existe mais n'a aucun groupe : sans cette alerte, la // personne attendrait indéfiniment devant une liste vide. toast(`${data.label} vient de s'inscrire. À affecter à un groupe.`); vibrate([40, 60, 40]); loadAdminUsers(); break; case 'message_pinned': if (state.currentGroup && state.currentGroup.id === data.group_id) { const existing = state.messages.find((m) => m.id === data.message.id); if (existing) existing.pinned_at = data.message.pinned_at; loadPins(data.group_id); renderThread(); } break; case 'rtc-signal': handleRtcSignal(data); break; case 'image-request': handleImageRequest(data); break; } } function onIncomingMessage(msg) { const open = state.currentGroup && state.currentGroup.id === msg.group_id; if (open) { if (state.messages.some((m) => m.id === msg.id)) return; const atBottom = isThreadAtBottom(); state.messages.push(msg); renderThread(); if (atBottom) scrollThreadToBottom(); markRead(msg.group_id, msg.id); } else if (msg.author_phone !== state.me.phone && msg.kind !== 'system') { showInAppNotif(msg); vibrate(40); } loadGroups(); } function showInAppNotif(msg) { const group = state.groupsById.get(msg.group_id); const el = document.createElement('div'); el.className = 'inapp-notif'; const preview = msg.kind === 'image' ? '📷 Photo' : msg.body; el.innerHTML = `
${escapeHtml(initials(msg.author_label))}
${escapeHtml(group ? group.name : 'Nouveau message')}
${escapeHtml(msg.author_label)} : ${escapeHtml(preview)}
`; el.addEventListener('click', () => { el.remove(); openChat(msg.group_id); }); document.body.appendChild(el); setTimeout(() => el.remove(), 4500); } function onTyping(data) { if (!state.currentGroup || state.currentGroup.id !== data.group_id) return; if (data.phone === state.me.phone) return; let map = state.typing.get(data.group_id); if (!map) { map = new Map(); state.typing.set(data.group_id, map); } map.set(data.phone, { name: data.name, at: Date.now() }); renderTyping(); } function renderTyping() { const line = $('#typing-line'); if (!state.currentGroup) { line.textContent = ''; return; } const map = state.typing.get(state.currentGroup.id); if (!map) { line.textContent = ''; return; } const now = Date.now(); for (const [phone, info] of map) if (now - info.at > 4000) map.delete(phone); const names = Array.from(map.values()).map((v) => v.name); if (!names.length) line.textContent = ''; else if (names.length === 1) line.textContent = `${names[0]} écrit…`; else line.textContent = `${names.slice(0, 2).join(', ')} écrivent…`; } setInterval(renderTyping, 1500); /* ------------------------------------------------------------------ */ /* Liste des groupes */ /* ------------------------------------------------------------------ */ async function loadGroups() { try { const res = await api('/api/groups'); state.groups = res.groups; state.groupsById = new Map(res.groups.map((g) => [g.id, g])); renderGroups(); updateBadge(); } catch (err) { /* la reconnexion réessaiera */ } } function updateBadge() { const total = state.groups.reduce((sum, g) => sum + g.unread, 0); if (navigator.setAppBadge) { try { total ? navigator.setAppBadge(total) : navigator.clearAppBadge(); } catch (e) { /* ignoré */ } } document.title = total ? `(${total}) MSG MOTOC` : 'MSG MOTOC'; } function renderGroups() { const term = $('#in-search').value.trim().toLowerCase(); const list = $('#groups-list'); let groups = state.groups; if (term) groups = groups.filter((g) => g.name.toLowerCase().includes(term)); if (!groups.length) { list.innerHTML = `

${term ? 'Aucun résultat' : 'Aucun groupe'}

${ term ? 'Essayez un autre terme de recherche.' : "Vous n'appartenez encore à aucun groupe.
Un administrateur doit vous y ajouter." }

`; return; } // Arborescence à deux niveaux : groupe principal puis sous-groupes. const ids = new Set(groups.map((g) => g.id)); const roots = groups.filter((g) => !g.parent_id || !ids.has(g.parent_id)); const children = new Map(); for (const g of groups) { if (g.parent_id && ids.has(g.parent_id)) { if (!children.has(g.parent_id)) children.set(g.parent_id, []); children.get(g.parent_id).push(g); } } const html = roots .map((root) => { const kids = children.get(root.id) || []; return `
${groupRow(root, false)} ${kids.map((k) => groupRow(k, true)).join('')}
`; }) .join(''); list.innerHTML = html; list.querySelectorAll('.group-row').forEach((row) => { row.addEventListener('click', () => openChat(Number(row.dataset.id))); }); } function groupRow(g, isChild) { const last = g.last_message; let preview = 'Aucun message'; if (last) { if (last.kind === 'system') preview = last.body; else if (last.kind === 'image') preview = `${last.author_label} : 📷 Photo`; else preview = `${last.author_label} : ${last.body}`; } return ` `; } $('#in-search').addEventListener('input', renderGroups); /* ------------------------------------------------------------------ */ /* Conversation */ /* ------------------------------------------------------------------ */ async function openChat(groupId) { const group = state.groupsById.get(groupId); if (!group) return; state.currentGroup = group; state.messages = []; state.replyTo = null; updateReplyBar(); $('#chat-title').textContent = group.name; $('#chat-sub').textContent = `${group.member_count} membre${group.member_count > 1 ? 's' : ''}`; $('#thread').innerHTML = '
'; openOverlay('screen-chat'); wsSend({ type: 'focus', group_id: groupId }); loadPins(groupId); await loadReceipts(groupId); try { const res = await api(`/api/groups/${groupId}/messages`); state.messages = res.messages; state.hasMore = res.has_more; state.oldestId = res.messages.length ? res.messages[0].id : null; renderThread(); scrollThreadToBottom(); if (res.messages.length) { markRead(groupId, res.messages[res.messages.length - 1].id); } } catch (err) { toast(err.message, 'error'); } } /* --- Accusés de réception ------------------------------------------- */ // Un seul chargement par conversation : l'état de lecture de chaque membre. // Les coches de chaque message s'en déduisent par comparaison d'identifiant, // ce qui évite de stocker un accusé par message et par destinataire. async function loadReceipts(groupId) { try { const res = await api(`/api/groups/${groupId}/receipts`); state.receipts = res.members; } catch (err) { state.receipts = []; } } // Destinataires : tout le monde sauf soi. function others() { return state.receipts.filter((m) => m.phone !== state.me.phone); } function readersOf(messageId) { return others().filter((m) => m.last_read_id >= messageId); } // « Remis » : le destinataire s'est connecté après l'envoi, donc son // appareil a reçu le message. C'est une approximation assumée — le seul // moyen d'être exact serait un accusé par message et par appareil, dont le // coût de stockage est disproportionné ici. function deliveredTo(message) { return others().filter((m) => m.online || m.last_seen >= message.created_at); } function receiptState(message) { const total = others().length; if (!total) return { level: 'sent', label: 'Envoyé' }; const read = readersOf(message.id).length; if (read >= total) return { level: 'read', label: 'Lu par tout le monde' }; if (read > 0) return { level: 'read-partial', label: `Lu par ${read} sur ${total}` }; const delivered = deliveredTo(message).length; if (delivered >= total) return { level: 'delivered', label: 'Remis à tout le monde' }; if (delivered > 0) return { level: 'delivered', label: `Remis à ${delivered} sur ${total}` }; return { level: 'sent', label: 'Envoyé' }; } function receiptMark(message) { // Uniquement sur ses propres messages : ailleurs, l'information n'a pas de sens. if (message.author_phone !== state.me.phone || message.kind === 'system') return ''; const { level, label } = receiptState(message); const icon = level === 'sent' ? 'i-check' : 'i-check2'; return ``; } async function showReaders(message) { await loadReceipts(message.group_id); const read = readersOf(message.id); const delivered = deliveredTo(message).filter((m) => m.last_read_id < message.id); const pending = others().filter( (m) => m.last_read_id < message.id && !delivered.includes(m), ); const block = (title, people, className) => people.length ? `
${escapeHtml(title)} · ${people.length}
${people .map( (m) => `
${escapeHtml(initials(m.display_name))}
${escapeHtml(m.display_name)} ${m.online ? '' : ''}
`, ) .join('')}
` : ''; openSheet(`

Accusés de lecture

« ${escapeHtml( (message.kind === 'image' ? 'Photo' : message.body).slice(0, 90), )} »

${block('Lu', read, 'read')} ${block('Remis, pas encore lu', delivered, 'delivered')} ${block('Pas encore remis', pending, 'pending')} ${ !others().length ? '

Vous êtes seul dans ce groupe pour le moment.

' : '' } `); } /* --- Messages épinglés ---------------------------------------------- */ // Bandeau permanent en haut du fil : les consignes de service doivent // rester visibles sans remonter l'historique. async function loadPins(groupId) { const bar = $('#pin-bar'); try { const res = await api(`/api/groups/${groupId}/pins`); state.pins = res.messages; if (!res.messages.length) { bar.hidden = true; bar.innerHTML = ''; return; } bar.hidden = false; bar.innerHTML = res.messages .map( (m) => `
${escapeHtml(m.author_label)} ${escapeHtml(m.kind === 'image' ? 'Photo épinglée' : m.body).slice(0, 160)}
`, ) .join(''); bar.querySelectorAll('[data-pin]').forEach((item) => item.addEventListener('click', () => { const target = document.getElementById(`msg-${item.dataset.pin}`); if (target) target.scrollIntoView({ behavior: 'smooth', block: 'center' }); else toast("Ce message n'est pas dans la partie chargée du fil."); }), ); } catch (err) { bar.hidden = true; } } function leaveChat(pop = true) { closeOverlay('screen-chat'); state.currentGroup = null; wsSend({ type: 'focus', group_id: null }); loadGroups(); if (pop) history.back(); } function markRead(groupId, lastId) { wsSend({ type: 'read', group_id: groupId, last_read_id: lastId }); const g = state.groupsById.get(groupId); if (g && g.unread) { g.unread = 0; renderGroups(); updateBadge(); } } function isThreadAtBottom() { const t = $('#thread'); return t.scrollHeight - t.scrollTop - t.clientHeight < 120; } function scrollThreadToBottom() { const t = $('#thread'); requestAnimationFrame(() => { t.scrollTop = t.scrollHeight; }); } function renderThread() { const thread = $('#thread'); if (!state.messages.length) { thread.innerHTML = `

Conversation vide

Envoyez le premier message.

`; return; } const parts = []; if (state.hasMore) { parts.push( '', ); } let lastDay = ''; let prev = null; for (const msg of state.messages) { const day = fmtDay(msg.created_at); if (day !== lastDay) { parts.push(`
${escapeHtml(day)}
`); lastDay = day; prev = null; } if (msg.kind === 'system') { parts.push(`
${escapeHtml(msg.body)}
`); prev = null; continue; } // Regroupement : même auteur, à moins de 5 minutes d'écart. const grouped = prev && prev.author_phone === msg.author_phone && msg.created_at - prev.created_at < 300000; parts.push(renderMessage(msg, grouped)); prev = msg; } thread.innerHTML = parts.join(''); const more = $('#btn-more'); if (more) more.addEventListener('click', loadOlder); attachMessageHandlers(); hydrateImages(); } function renderMessage(msg, grouped) { const mine = msg.author_phone === state.me.phone; const cls = `msg ${mine ? 'mine' : 'theirs'} ${grouped ? 'grouped' : ''}`; let quote = ''; if (msg.reply_to) { const parent = state.messages.find((m) => m.id === msg.reply_to); quote = `
${escapeHtml(parent ? parent.author_label : 'Message')} ${escapeHtml(parent ? (parent.kind === 'image' ? '📷 Photo' : parent.body) : 'Message supprimé')}
`; } let body = ''; if (msg.kind === 'image') { const meta = msg.meta || {}; body = `
Photo · ${escapeHtml(fmtBytes(meta.size || 0))}
`; if (msg.body) body += `
${enrich(escapeHtml(msg.body))}
`; } else { body = `
${enrich(escapeHtml(msg.body))}
`; } return `
${ mine ? '' : `
${escapeHtml(initials(msg.author_label))}
` }
${mine ? '' : `
${escapeHtml(msg.author_label)}
`} ${quote} ${body}
${ msg.pinned_at ? ' ' : '' }${fmtTime(msg.created_at)}${msg.edited_at ? ' · modifié' : ''}${receiptMark(msg)}
`; } async function loadOlder() { if (state.loadingHistory || !state.oldestId) return; state.loadingHistory = true; const thread = $('#thread'); const prevHeight = thread.scrollHeight; try { const res = await api( `/api/groups/${state.currentGroup.id}/messages?before=${state.oldestId}`, ); if (res.messages.length) { state.messages = res.messages.concat(state.messages); state.oldestId = res.messages[0].id; } state.hasMore = res.has_more; renderThread(); // On conserve la position de lecture après insertion en tête. thread.scrollTop = thread.scrollHeight - prevHeight; } catch (err) { toast(err.message, 'error'); } finally { state.loadingHistory = false; } } // Appui long (ou clic droit) : actions sur le message. function attachMessageHandlers() { $('#thread') .querySelectorAll('.msg') .forEach((el) => { let timer = null; const start = () => { timer = setTimeout(() => { vibrate(18); openMessageSheet(Number(el.dataset.id)); }, 480); }; const cancel = () => timer && clearTimeout(timer); el.addEventListener('pointerdown', start); el.addEventListener('pointerup', cancel); el.addEventListener('pointerleave', cancel); el.addEventListener('pointercancel', cancel); el.addEventListener('contextmenu', (e) => { e.preventDefault(); openMessageSheet(Number(el.dataset.id)); }); }); } /* --- Envoi ---------------------------------------------------------- */ const input = $('#in-message'); function autoGrow() { input.style.height = 'auto'; input.style.height = `${Math.min(input.scrollHeight, 130)}px`; $('#btn-send').disabled = !input.value.trim(); } input.addEventListener('input', () => { autoGrow(); throttledTyping(); }); input.addEventListener('keydown', (e) => { // Entrée envoie sur ordinateur ; sur mobile le clavier insère un saut de ligne. if (e.key === 'Enter' && !e.shiftKey && window.matchMedia('(min-width: 720px)').matches) { e.preventDefault(); sendMessage(); } }); let typingAt = 0; function throttledTyping() { const now = Date.now(); if (now - typingAt > 2500 && state.currentGroup) { typingAt = now; wsSend({ type: 'typing', group_id: state.currentGroup.id }); } } async function sendMessage() { const body = input.value.trim(); if (!body || !state.currentGroup) return; const groupId = state.currentGroup.id; input.value = ''; autoGrow(); const replyTo = state.replyTo ? state.replyTo.id : null; state.replyTo = null; updateReplyBar(); try { const res = await api(`/api/groups/${groupId}/messages`, { method: 'POST', body: { body, kind: 'text', reply_to: replyTo }, }); // Le message revient par le WebSocket : rien à insérer ici. // Terme surveillé : le message est bien parti, on le signale seulement. if (res.message && res.message.warning) toast(res.message.warning); } catch (err) { toast(err.message, 'error'); input.value = body; autoGrow(); } } $('#btn-send').addEventListener('click', sendMessage); $$('[data-close-chat]').forEach((b) => b.addEventListener('click', () => leaveChat())); $$('[data-close-overlay]').forEach((b) => b.addEventListener('click', () => { closeOverlay(b.dataset.closeOverlay); history.back(); }), ); function updateReplyBar() { const bar = $('#reply-bar'); if (!state.replyTo) { bar.classList.remove('show'); return; } bar.classList.add('show'); $('#reply-author').textContent = state.replyTo.author_label; $('#reply-text').textContent = state.replyTo.kind === 'image' ? '📷 Photo' : state.replyTo.body.slice(0, 80); } $('#btn-cancel-reply').addEventListener('click', () => { state.replyTo = null; updateReplyBar(); }); $('#thread').addEventListener('scroll', () => { if ($('#thread').scrollTop < 60 && state.hasMore && !state.loadingHistory) loadOlder(); }); /* ------------------------------------------------------------------ */ /* Feuilles contextuelles */ /* ------------------------------------------------------------------ */ function openSheet(html) { $('#sheet').innerHTML = `
${html}`; $('#sheet-backdrop').classList.add('open'); } function closeSheet() { $('#sheet-backdrop').classList.remove('open'); } $('#sheet-backdrop').addEventListener('click', (e) => { if (e.target === $('#sheet-backdrop')) closeSheet(); }); function openMessageSheet(id) { const msg = state.messages.find((m) => m.id === id); if (!msg) return; const mine = msg.author_phone === state.me.phone; const isAdmin = state.currentGroup && state.currentGroup.role === 'admin'; const mineAndSent = mine && msg.kind !== 'system'; openSheet(`

Message

${ mineAndSent ? `` : '' } ${ msg.kind !== 'image' ? '' : '' } ${ isAdmin ? `` : '' } ${ mine || isAdmin ? '' : '' } `); $('#sheet') .querySelectorAll('[data-act]') .forEach((btn) => btn.addEventListener('click', async () => { const act = btn.dataset.act; closeSheet(); if (act === 'readers') { showReaders(msg); } else if (act === 'reply') { state.replyTo = msg; updateReplyBar(); input.focus(); } else if (act === 'copy') { try { await navigator.clipboard.writeText(msg.body); toast('Texte copié.', 'ok'); } catch (e) { toast('Copie impossible.', 'error'); } } else if (act === 'pin') { try { const res = await api(`/api/messages/${msg.id}/pin`, { method: 'POST', body: { pinned: !msg.pinned_at }, }); msg.pinned_at = res.message.pinned_at; toast(msg.pinned_at ? 'Message épinglé.' : 'Épingle retirée.', 'ok'); loadPins(msg.group_id); renderThread(); } catch (err) { toast(err.message, 'error'); } } else if (act === 'delete') { try { await api(`/api/messages/${msg.id}`, { method: 'DELETE' }); state.messages = state.messages.filter((m) => m.id !== msg.id); renderThread(); } catch (err) { toast(err.message, 'error'); } } }), ); } /* --- Fiche du groupe ------------------------------------------------ */ $('#btn-group-info').addEventListener('click', async () => { if (!state.currentGroup) return; const gid = state.currentGroup.id; try { const info = await api(`/api/groups/${gid}`); const mode = state.currentGroup.notif_mode || 'all'; const isAdmin = info.role === 'admin'; openSheet(`

${escapeHtml(info.name)}

${info.description ? `

${escapeHtml(info.description)}

` : ''} ${info.parent ? `

Sous-groupe de ${escapeHtml(info.parent.name)}

` : ''}
Notifications
${info.members.length} membre${info.members.length > 1 ? 's' : ''}
${info.members .map( (m) => `
${escapeHtml(initials(m.display_name))}
${escapeHtml(m.display_name)} ${m.role === 'admin' ? 'Admin' : ''}
${escapeHtml(m.phone_display)}
${ isAdmin && m.phone !== state.me.phone ? `` : '' }
`, ) .join('')}
${ isAdmin ? `
` : '' } `); $('#seg-notif') .querySelectorAll('button') .forEach((b) => b.addEventListener('click', async () => { try { await api(`/api/groups/${gid}/prefs`, { method: 'PATCH', body: { notif_mode: b.dataset.mode }, }); state.currentGroup.notif_mode = b.dataset.mode; $('#seg-notif') .querySelectorAll('button') .forEach((x) => x.classList.toggle('active', x === b)); renderGroups(); } catch (err) { toast(err.message, 'error'); } }), ); $('#sheet') .querySelectorAll('[data-remove]') .forEach((b) => b.addEventListener('click', async () => { if (!confirm('Retirer cette personne du groupe ?')) return; try { await api(`/api/groups/${gid}/members/${b.dataset.remove}`, { method: 'DELETE' }); closeSheet(); toast('Membre retiré.', 'ok'); } catch (err) { toast(err.message, 'error'); } }), ); const addBtn = $('#btn-add-member'); if (addBtn) { const phoneInput = $('#add-member-phone'); phoneInput.addEventListener('input', (e) => { const digits = e.target.value.replace(/\D/g, '').slice(0, 10); e.target.value = digits.replace(/(\d{2})(?=\d)/g, '$1 ').trim(); }); addBtn.addEventListener('click', async () => { try { await api(`/api/groups/${gid}/members`, { method: 'POST', body: { phone: phoneInput.value.replace(/\D/g, ''), role: 'member' }, }); closeSheet(); toast('Membre ajouté.', 'ok'); } catch (err) { toast(err.message, 'error'); } }); } } catch (err) { toast(err.message, 'error'); } }); /* ------------------------------------------------------------------ */ /* Images en pair-à-pair (WebRTC) */ /* ------------------------------------------------------------------ */ /* Le serveur ne relaie que la signalisation : les octets de l'image passent directement d'un appareil à l'autre. Chaque photo envoyée est conservée localement par l'expéditeur, qui la resservira aux pairs tant qu'il est en ligne. */ const RTC_CONFIG = { iceServers: [{ urls: ['stun:stun.l.google.com:19302', 'stun:stun1.l.google.com:19302'] }], }; const CHUNK = 16 * 1024; const peers = new Map(); // transfer_id + pair -> RTCPeerConnection const incoming = new Map(); // transfer_id -> { chunks, received, total } /* --- Cache local (IndexedDB) --------------------------------------- */ let idbPromise = null; function idb() { if (idbPromise) return idbPromise; idbPromise = new Promise((resolve, reject) => { const req = indexedDB.open('motoc-images', 1); req.onupgradeneeded = () => { const db = req.result; if (!db.objectStoreNames.contains('img')) db.createObjectStore('img', { keyPath: 'id' }); }; req.onsuccess = () => resolve(req.result); req.onerror = () => reject(req.error); }); return idbPromise; } async function idbPut(id, blob) { const db = await idb(); return new Promise((resolve, reject) => { const tx = db.transaction('img', 'readwrite'); tx.objectStore('img').put({ id, blob, ts: Date.now() }); tx.oncomplete = resolve; tx.onerror = () => reject(tx.error); }); } async function idbGet(id) { const db = await idb(); return new Promise((resolve) => { const tx = db.transaction('img', 'readonly'); const req = tx.objectStore('img').get(id); req.onsuccess = () => resolve(req.result ? req.result.blob : null); req.onerror = () => resolve(null); }); } /* --- Envoi ---------------------------------------------------------- */ // Réduction avant envoi : 1600 px max, JPEG qualité 0.82. async function downscale(file) { const bitmap = await createImageBitmap(file); const max = 1600; let { width, height } = bitmap; if (width > max || height > max) { const ratio = Math.min(max / width, max / height); width = Math.round(width * ratio); height = Math.round(height * ratio); } const canvas = document.createElement('canvas'); canvas.width = width; canvas.height = height; canvas.getContext('2d').drawImage(bitmap, 0, 0, width, height); bitmap.close(); const blob = await new Promise((r) => canvas.toBlob(r, 'image/jpeg', 0.82)); return { blob, width, height }; } $('#btn-image').addEventListener('click', () => $('#file-image').click()); $('#file-image').addEventListener('change', async (e) => { const file = e.target.files[0]; e.target.value = ''; if (!file || !state.currentGroup) return; if (!file.type.startsWith('image/')) return toast('Ce fichier n’est pas une image.', 'error'); try { toast('Préparation de la photo…'); const { blob, width, height } = await downscale(file); const transferId = `${state.me.phone}-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`; await idbPut(transferId, blob); await api(`/api/groups/${state.currentGroup.id}/messages`, { method: 'POST', body: { body: '', kind: 'image', meta: { transfer_id: transferId, name: file.name, size: blob.size, width, height, type: 'image/jpeg', }, }, }); } catch (err) { toast(err.message || 'Envoi de la photo impossible.', 'error'); } }); /* --- Affichage / récupération --------------------------------------- */ async function hydrateImages() { const nodes = $('#thread').querySelectorAll('.p2p-image[data-transfer]'); for (const node of nodes) { const id = node.dataset.transfer; if (!id) continue; const blob = await idbGet(id); if (blob) { showImage(node, blob); } else if (node.dataset.owner && node.dataset.owner !== state.me.phone) { requestImage(node); } else { setImageState(node, 'Photo indisponible sur cet appareil.', true); } } } function showImage(node, blob) { const url = URL.createObjectURL(blob); node.innerHTML = `Photo`; node.querySelector('img').addEventListener('click', () => window.open(url, '_blank')); } function setImageState(node, text, error, progress) { node.innerHTML = `
${escapeHtml(text)}
${progress !== undefined ? `
` : ''}`; } function requestImage(node) { const transferId = node.dataset.transfer; const owner = node.dataset.owner; setImageState(node, 'Réception en cours…', false, 0); const sent = wsSend({ type: 'image-request', to: owner, transfer_id: transferId, group_id: Number(node.dataset.group), }); if (!sent) { setImageState(node, 'Hors ligne : photo indisponible.', true); return; } // Sans réponse au bout de 20 s, l'expéditeur est probablement absent. setTimeout(() => { if (node.querySelector('.p2p-state') && !incoming.has(transferId)) { setImageState(node, "Photo indisponible (expéditeur hors ligne).", true); } }, 20000); } // Côté expéditeur : un pair réclame une photo qu'on possède. async function handleImageRequest(data) { const blob = await idbGet(data.transfer_id); if (!blob) return; const key = `${data.transfer_id}:${data.from}`; const pc = new RTCPeerConnection(RTC_CONFIG); peers.set(key, pc); pc.onicecandidate = (e) => { if (e.candidate) { wsSend({ type: 'rtc-signal', to: data.from, transfer_id: data.transfer_id, signal: { candidate: e.candidate }, }); } }; pc.onconnectionstatechange = () => { if (['failed', 'closed', 'disconnected'].includes(pc.connectionState)) { pc.close(); peers.delete(key); } }; const channel = pc.createDataChannel('img'); channel.binaryType = 'arraybuffer'; channel.onopen = async () => { const buffer = await blob.arrayBuffer(); channel.send(JSON.stringify({ size: buffer.byteLength, type: blob.type })); let offset = 0; const pump = () => { while (offset < buffer.byteLength) { // Backpressure : on laisse le buffer se vider avant de continuer. if (channel.bufferedAmount > 4 * CHUNK) { channel.onbufferedamountlow = pump; channel.bufferedAmountLowThreshold = 2 * CHUNK; return; } channel.send(buffer.slice(offset, offset + CHUNK)); offset += CHUNK; } channel.onbufferedamountlow = null; }; pump(); }; const offer = await pc.createOffer(); await pc.setLocalDescription(offer); wsSend({ type: 'rtc-signal', to: data.from, transfer_id: data.transfer_id, signal: { sdp: pc.localDescription }, }); } // Réception de la signalisation (des deux côtés). async function handleRtcSignal(data) { const key = `${data.transfer_id}:${data.from}`; let pc = peers.get(key); const signal = data.signal || {}; if (signal.sdp && signal.sdp.type === 'offer') { pc = new RTCPeerConnection(RTC_CONFIG); peers.set(key, pc); pc.onicecandidate = (e) => { if (e.candidate) { wsSend({ type: 'rtc-signal', to: data.from, transfer_id: data.transfer_id, signal: { candidate: e.candidate }, }); } }; pc.ondatachannel = (e) => setupReceiveChannel(e.channel, data.transfer_id, key); await pc.setRemoteDescription(signal.sdp); const answer = await pc.createAnswer(); await pc.setLocalDescription(answer); wsSend({ type: 'rtc-signal', to: data.from, transfer_id: data.transfer_id, signal: { sdp: pc.localDescription }, }); } else if (signal.sdp && signal.sdp.type === 'answer' && pc) { await pc.setRemoteDescription(signal.sdp); } else if (signal.candidate && pc) { try { await pc.addIceCandidate(signal.candidate); } catch (e) { /* candidat tardif : sans conséquence */ } } } function setupReceiveChannel(channel, transferId, key) { channel.binaryType = 'arraybuffer'; const node = () => $('#thread').querySelector(`.p2p-image[data-transfer="${CSS.escape(transferId)}"]`); channel.onmessage = async (event) => { const rec = incoming.get(transferId); // Premier message : l'en-tête JSON décrivant le transfert. if (!rec) { try { const header = JSON.parse(event.data); incoming.set(transferId, { chunks: [], received: 0, total: header.size, type: header.type }); } catch (e) { /* en-tête illisible */ } return; } rec.chunks.push(event.data); rec.received += event.data.byteLength; const el = node(); if (el) { const pct = Math.round((rec.received / rec.total) * 100); setImageState(el, `Réception… ${pct} %`, false, pct); } if (rec.received >= rec.total) { const blob = new Blob(rec.chunks, { type: rec.type || 'image/jpeg' }); incoming.delete(transferId); await idbPut(transferId, blob); const target = node(); if (target) showImage(target, blob); channel.close(); const pc = peers.get(key); if (pc) { pc.close(); peers.delete(key); } } }; } /* ------------------------------------------------------------------ */ /* Réglages */ /* ------------------------------------------------------------------ */ $('#btn-settings').addEventListener('click', () => { $('#set-first').value = state.me.first_name || ''; $('#set-last').value = state.me.last_name || ''; $('#set-nick').value = state.me.nickname || ''; $('#set-phone').value = state.me.phone_display || ''; refreshPushSwitch(); openOverlay('screen-settings'); }); $('#btn-save-profile').addEventListener('click', async () => { try { state.me = await api('/api/me', { method: 'PATCH', body: { first_name: $('#set-first').value.trim(), last_name: $('#set-last').value.trim(), nickname: $('#set-nick').value.trim(), }, }); toast('Profil enregistré.', 'ok'); } catch (err) { toast(err.message, 'error'); } }); $('#btn-change-pin').addEventListener('click', async () => { const oldPin = $('#set-pin-old').value; const newPin = $('#set-pin-new').value; if (oldPin.length !== 6 || newPin.length !== 6) { return toast('Les deux codes doivent contenir 6 chiffres.', 'error'); } try { const res = await api('/api/me/pin', { method: 'POST', body: { current_pin: oldPin, new_pin: newPin }, }); state.token = res.token; localStorage.setItem('motoc_token', res.token); $('#set-pin-old').value = $('#set-pin-new').value = ''; toast('Code PIN modifié.', 'ok'); } catch (err) { toast(err.message, 'error'); } }); $('#btn-logout').addEventListener('click', () => { if (confirm('Se déconnecter de cet appareil ?')) logout(); }); /* ------------------------------------------------------------------ */ /* Notifications push */ /* ------------------------------------------------------------------ */ function b64ToUint8(base64) { const padded = (base64 + '='.repeat((4 - (base64.length % 4)) % 4)) .replace(/-/g, '+') .replace(/_/g, '/'); const raw = atob(padded); return Uint8Array.from(raw, (c) => c.charCodeAt(0)); } const pushSupported = () => 'serviceWorker' in navigator && 'PushManager' in window && 'Notification' in window; async function currentSubscription() { if (!pushSupported()) return null; const reg = await navigator.serviceWorker.ready; return reg.pushManager.getSubscription(); } async function refreshPushSwitch() { const sw = $('#sw-push'); const desc = $('#push-desc'); if (!pushSupported()) { sw.classList.remove('on'); desc.textContent = "Non pris en charge par ce navigateur."; return; } if (Notification.permission === 'denied') { sw.classList.remove('on'); desc.textContent = 'Bloquées dans les réglages du navigateur.'; return; } const sub = await currentSubscription(); sw.classList.toggle('on', !!sub); // Sur iOS, le push n'existe que si l'app est installée sur l'écran d'accueil. const standalone = window.matchMedia('(display-mode: standalone)').matches || navigator.standalone; const isIOS = /iPad|iPhone|iPod/.test(navigator.userAgent); if (isIOS && !standalone) { desc.textContent = 'Sur iPhone : « Partager » puis « Sur l’écran d’accueil ».'; } else { desc.textContent = sub ? 'Activées sur cet appareil.' : 'Recevoir les messages même application fermée.'; } } $('#sw-push').addEventListener('click', async () => { if (!pushSupported()) return toast('Notifications non prises en charge.', 'error'); const sw = $('#sw-push'); try { const existing = await currentSubscription(); if (existing) { await api('/api/push/unsubscribe', { method: 'POST', body: { endpoint: existing.endpoint }, }); await existing.unsubscribe(); toast('Notifications désactivées.'); } else { const permission = await Notification.requestPermission(); if (permission !== 'granted') { toast('Autorisation refusée.', 'error'); return refreshPushSwitch(); } const { public_key: key } = await api('/api/push/key'); const reg = await navigator.serviceWorker.ready; const sub = await reg.pushManager.subscribe({ userVisibleOnly: true, applicationServerKey: b64ToUint8(key), }); await api('/api/push/subscribe', { method: 'POST', body: { subscription: sub.toJSON() } }); toast('Notifications activées.', 'ok'); } } catch (err) { toast(err.message || 'Activation impossible.', 'error'); } refreshPushSwitch(); }); $('#btn-test-push').addEventListener('click', async () => { try { await api('/api/push/test', { method: 'POST' }); toast('Notification test envoyée.', 'ok'); } catch (err) { toast(err.message, 'error'); } }); /* ------------------------------------------------------------------ */ /* Administration (superadmin) */ /* ------------------------------------------------------------------ */ $('#btn-admin').addEventListener('click', () => { openOverlay('screen-admin'); // Libère le cadre « téléphone » : le panneau superadmin est le seul // écran conçu pour être lu en grand. $('#app').classList.add('desk'); // On rouvre toujours sur le tableau de bord, pas sur le dernier onglet vu. $$('.tabs button').forEach((b) => b.classList.toggle('active', b.dataset.tab === 'tab-dash')); $$('.tab-panel').forEach((p) => p.classList.toggle('active', p.id === 'tab-dash')); loadDashboard(); loadHelpRequests(); loadAdminGroups(); loadAdminUsers(); loadStats(); }); $$('.tabs button').forEach((btn) => btn.addEventListener('click', () => { $$('.tabs button').forEach((b) => b.classList.toggle('active', b === btn)); $$('.tab-panel').forEach((p) => p.classList.toggle('active', p.id === btn.dataset.tab)); // Les données du bord vieillissent vite : on rafraîchit à chaque retour. const tab = btn.dataset.tab; // La supervision matérielle ne sonde que si son onglet est à l'écran. if (tab !== 'tab-system') stopSystem(); if (tab === 'tab-dash') { loadDashboard(); loadHelpRequests(); } else if (tab === 'tab-stats') { loadStats(); } else if (tab === 'tab-moderation') { loadModeration(); } else if (tab === 'tab-system') { startSystem(); } else if (tab === 'tab-playground') { loadPlayground(); } }), ); async function loadAdminGroups() { try { const res = await api('/api/admin/groups'); const groups = res.groups; // Alimente le sélecteur de parent (uniquement les groupes racines). const select = $('#ng-parent'); select.innerHTML = '' + groups .filter((g) => !g.parent_id) .map((g) => ``) .join(''); // Sélecteur de l'import : tous les groupes, sous-groupes compris. $('#in-import-group').innerHTML = '' + groups .map( (g) => ``, ) .join(''); const byId = new Map(groups.map((g) => [g.id, g])); $('#admin-groups').innerHTML = groups.length ? groups .map((g) => { const parent = g.parent_id ? byId.get(g.parent_id) : null; return `
${escapeHtml(initials(g.name))}
${escapeHtml(g.name)}
${parent ? `↳ ${escapeHtml(parent.name)} · ` : ''}${g.member_count} membre(s) · ${g.message_count} message(s)
`; }) .join('') : '

Aucun groupe pour le moment.

'; $('#admin-groups') .querySelectorAll('[data-del-group]') .forEach((b) => b.addEventListener('click', async () => { const g = byId.get(Number(b.dataset.delGroup)); if ( !confirm( `Supprimer « ${g.name} » ?\n\nLes messages et les sous-groupes seront définitivement effacés.`, ) ) return; try { await api(`/api/admin/groups/${g.id}`, { method: 'DELETE' }); toast('Groupe supprimé.', 'ok'); loadAdminGroups(); loadGroups(); } catch (err) { toast(err.message, 'error'); } }), ); } catch (err) { toast(err.message, 'error'); } } $('#btn-create-group').addEventListener('click', async () => { const name = $('#ng-name').value.trim(); if (!name) return toast('Donnez un nom au groupe.', 'error'); try { await api('/api/admin/groups', { method: 'POST', body: { name, parent_id: $('#ng-parent').value ? Number($('#ng-parent').value) : null, description: $('#ng-desc').value.trim(), }, }); $('#ng-name').value = $('#ng-desc').value = ''; toast('Groupe créé.', 'ok'); loadAdminGroups(); loadGroups(); } catch (err) { toast(err.message, 'error'); } }); $('#btn-add-users').addEventListener('click', async () => { const phones = $('#nu-phones').value.trim(); if (!phones) return toast('Saisissez au moins un numéro.', 'error'); try { const res = await api('/api/admin/users/bulk', { method: 'POST', body: { phones } }); $('#nu-phones').value = ''; const bits = [`${res.added.length} ajouté(s)`]; if (res.skipped.length) bits.push(`${res.skipped.length} déjà connu(s)`); if (res.invalid.length) bits.push(`${res.invalid.length} invalide(s)`); toast(bits.join(' · '), res.added.length ? 'ok' : 'error'); loadAdminUsers(); loadStats(); } catch (err) { toast(err.message, 'error'); } }); /* --- Import d'un tableur de bénévoles ------------------------------- */ // Deux temps : un aperçu qui montre les colonnes reconnues, puis l'import. // Le fichier est renvoyé au serveur à la confirmation — rien n'est gardé // entre les deux appels, ni ici ni côté serveur. let importFile = null; function importFormData(dryRun) { const form = new FormData(); form.append('file', importFile); form.append('dry_run', dryRun ? 'true' : 'false'); const group = $('#in-import-group').value; if (group) form.append('group_id', group); // Colonnes corrigées à la main depuis l'aperçu, s'il y en a. for (const [field, id] of [ ['first_col', '#imp-first'], ['last_col', '#imp-last'], ['phone_col', '#imp-phone'], ]) { const select = $(id); if (select && select.value !== '' && select.dataset.dirty === '1') { form.append(field, select.value); } } return form; } function columnOptions(count, selected, headers) { let html = ''; for (let i = 0; i < count; i += 1) { const label = headers[i] ? `${headers[i]}` : `Colonne ${i + 1}`; html += ``; } return html; } function renderImportPreview(res) { const box = $('#import-result'); const map = res.mapping; const headers = res.preview.length ? [] : []; // Nombre de colonnes proposées : au moins celles repérées par le serveur. const width = Math.max( (map.phone_col ?? 0) + 1, (map.first_col ?? 0) + 1, (map.last_col ?? 0) + 1, (map.full_col ?? 0) + 1, 3, ); const labels = map.labels; const rows = res.preview .map( (p) => ` ${escapeHtml(p.first_name || '—')} ${escapeHtml(p.last_name || '—')} ${escapeHtml(p.phone_display)} ${p.known ? 'déjà connu' : 'nouveau'} `, ) .join(''); box.hidden = false; box.innerHTML = `
${res.detected} contact(s) lus dans ${escapeHtml(res.filename || 'le fichier')} — ${res.new} nouveau(x), ${res.existing} déjà connu(s)${ res.rejected_count ? `, ${res.rejected_count} ligne(s) ignorée(s)` : '' }.
Colonnes reconnues : Prénom → ${escapeHtml(labels.first || labels.full || '—')} Nom → ${escapeHtml(labels.last || labels.full || '—')} Téléphone → ${escapeHtml(labels.phone || '—')}
${ map.assumed_order ? `

Aucun en-tête dans ce fichier : l'ordre « prénom puis nom » est une hypothèse. Vérifiez l'aperçu ci-dessous et corrigez si besoin.

` : '' }
Corriger les colonnes
${rows}
PrénomNomTéléphone
${ res.rejected.length ? `
${res.rejected_count} ligne(s) ignorée(s)
` : '' } `; $$('#imp-first, #imp-last, #imp-phone').forEach((select) => select.addEventListener('change', () => { select.dataset.dirty = '1'; }), ); $('#imp-recheck').addEventListener('click', () => runImport(true)); $('#imp-cancel').addEventListener('click', resetImport); $('#imp-confirm').addEventListener('click', () => runImport(false)); } function resetImport() { importFile = null; $('#in-import-file').value = ''; $('#import-result').hidden = true; $('#import-result').innerHTML = ''; } async function runImport(dryRun) { if (!importFile) return; const button = dryRun ? $('#btn-pick-file') : $('#imp-confirm'); if (button) button.disabled = true; try { const res = await api('/api/admin/users/import', { method: 'POST', body: importFormData(dryRun), }); if (dryRun) { renderImportPreview(res); } else { const bits = [`${res.added} compte(s) créé(s)`]; if (res.updated) bits.push(`${res.updated} mis à jour`); if (res.joined) bits.push(`${res.joined} ajouté(s) au groupe`); toast(bits.join(' · '), 'ok'); resetImport(); loadAdminUsers(); loadAdminGroups(); loadStats(); } } catch (err) { toast(err.message, 'error'); } finally { if (button) button.disabled = false; } } $('#btn-pick-file').addEventListener('click', () => $('#in-import-file').click()); $('#in-import-file').addEventListener('change', (e) => { importFile = e.target.files[0] || null; if (importFile) runImport(true); }); /* ------------------------------------------------------------------ */ /* Modération */ /* ------------------------------------------------------------------ */ let moderationStatus = 'pending'; async function loadModeration() { try { const res = await api( `/api/admin/moderation?status=${encodeURIComponent(moderationStatus)}&limit=100`, ); const badge = $('#moderation-badge'); badge.hidden = !res.pending; badge.textContent = res.pending; $('#moderation-list').innerHTML = res.alerts.length ? res.alerts .map( (a) => `
${escapeHtml(a.category_label)} ${fmtDateTime(a.created_at)}
« ${escapeHtml(a.excerpt)} »
${escapeHtml(a.author || 'inconnu')} · terme ${escapeHtml(a.term)} ${a.matched && a.matched !== a.term ? `(écrit « ${escapeHtml(a.matched)} »)` : ''}
${ a.status === 'pending' ? `` : '' }
`, ) .join('') : '

Aucun signalement.

'; $('#moderation-list') .querySelectorAll('[data-review]') .forEach((b) => b.addEventListener('click', async () => { try { await api(`/api/admin/moderation/${b.dataset.review}/review`, { method: 'POST' }); loadModeration(); } catch (err) { toast(err.message, 'error'); } }), ); // Éditeur de la liste surveillée : un textarea par catégorie. $('#terms-editor').innerHTML = Object.entries(res.categories) .map( ([key, label]) => `
`, ) .join(''); } catch (err) { toast(err.message, 'error'); } } $('#seg-moderation') .querySelectorAll('button') .forEach((b) => b.addEventListener('click', () => { moderationStatus = b.dataset.status; $('#seg-moderation') .querySelectorAll('button') .forEach((x) => x.classList.toggle('active', x === b)); loadModeration(); }), ); $('#btn-save-terms').addEventListener('click', async () => { const terms = {}; $$('[data-terms]').forEach((area) => { terms[area.dataset.terms] = area.value .split('\n') .map((line) => line.trim()) .filter(Boolean); }); try { await api('/api/admin/moderation/terms', { method: 'PUT', body: { terms } }); toast('Liste enregistrée.', 'ok'); loadModeration(); } catch (err) { toast(err.message, 'error'); } }); /* ------------------------------------------------------------------ */ /* Supervision machine et réseau */ /* ------------------------------------------------------------------ */ // Sondage toutes les 3 s tant que l'onglet est ouvert ET la page visible : // inutile de mesurer une machine que personne ne regarde. const SPARK_POINTS = 40; // Surtout pas `history` : le nom masquerait `window.history` dans toute la // portée du module, et la navigation entre écrans cesserait de fonctionner. const samples = { cpu: [], rx: [], tx: [] }; let systemTimer = null; let systemPaused = false; function pushSample(series, value) { series.push(Number.isFinite(value) ? value : 0); while (series.length > SPARK_POINTS) series.shift(); } function drawSpark(id, series, max) { const svg = $(id); if (!svg) return; const line = svg.querySelector('polyline'); const box = svg.viewBox.baseVal; const ceiling = Math.max(max || 0, ...series, 1); const step = series.length > 1 ? box.width / (series.length - 1) : box.width; line.setAttribute( 'points', series .map((v, i) => `${(i * step).toFixed(1)},${(box.height - (v / ceiling) * box.height).toFixed(1)}`) .join(' '), ); } function gauge(label, value, percent, hint) { const level = percent >= 90 ? 'danger' : percent >= 70 ? 'warn' : ''; return `
${escapeHtml(label)}
${escapeHtml(value)}
${ percent === null || percent === undefined ? '' : `
` } ${hint ? `
${escapeHtml(hint)}
` : ''}
`; } function renderSystem(s) { const na = 'indisponible'; const cpu = s.cpu || {}; const mem = s.memory || {}; const disk = s.disk || {}; const net = s.network || {}; const proc = s.process || {}; pushSample(samples.cpu, cpu.percent || 0); pushSample(samples.rx, net.rx_rate || 0); pushSample(samples.tx, net.tx_rate || 0); // `has` et non la simple véracité : une valeur nulle est une mesure // légitime (un disque vide, un process qui n'a rien écrit), pas une // absence de mesure. La confondre affichait « indisponible » sur un // disque parfaitement lisible mais encore vide. const has = (v) => v !== null && v !== undefined; $('#sys-grid').innerHTML = [ gauge( 'Processeur', has(cpu.percent) ? `${cpu.percent} %` : na, cpu.percent, cpu.scope === 'machine' ? `machine entière · ${cpu.cores || '?'} cœur(s)` : `${cpu.allocated_cores || cpu.cores || '?'} cœur(s) alloué(s)`, ), gauge( 'Mémoire', has(mem.used) ? `${fmtBytes(mem.used)} / ${fmtBytes(mem.total)}` : na, mem.percent, mem.limited_by_cgroup ? 'limite du conteneur' : 'mémoire de la machine', ), gauge( 'Disque persistant', has(disk.used) ? disk.dedicated ? `${fmtBytes(disk.used)} / ${fmtBytes(disk.total)}` : fmtBytes(disk.used) : na, disk.percent, disk.dedicated ? disk.path : `${disk.path} · volume non dédié, place restante inconnue`, ), gauge( 'Base de données', s.database ? fmtBytes(s.database.size) : na, s.database ? (s.database.size / s.database.hard_limit) * 100 : null, s.database ? `purge agressive au-delà de ${fmtBytes(s.database.soft_limit)}` : '', ), // La charge est celle de la machine hôte, partagée avec les autres // Spaces : elle ne dit rien de la santé de celui-ci, et l'afficher en // rouge ferait sonner une alarme sur laquelle personne ne peut agir. gauge( 'Charge de l’hôte', cpu.load ? cpu.load.map((v) => v.toFixed(2)).join(' · ') : na, null, 'machine partagée · 1 / 5 / 15 min', ), gauge( 'Temps réel', s.realtime ? `${s.realtime.connections} connexion(s)` : na, null, s.realtime ? `${s.realtime.online_users} utilisateur(s) en ligne` : '', ), gauge( 'Process', has(proc.rss) ? fmtBytes(proc.rss) : na, null, `${proc.threads || '?'} fils · ${proc.open_files || '?'} fichiers ouverts`, ), gauge('En service depuis', fmtDuration(s.app_uptime), null, 'redémarrage du serveur'), ].join(''); $('#net-rx').textContent = net.rx_rate === null || net.rx_rate === undefined ? '—' : `${fmtBytes(net.rx_rate)}/s`; $('#net-tx').textContent = net.tx_rate === null || net.tx_rate === undefined ? '—' : `${fmtBytes(net.tx_rate)}/s`; drawSpark('#spark-rx', samples.rx); drawSpark('#spark-tx', samples.tx); drawSpark('#spark-cpu', samples.cpu, 100); const span = samples.cpu.length * 3; $('#cpu-detail').textContent = s.available ? `Échelle 0–100 %. ${samples.cpu.length} mesure(s) sur ${ span < 60 ? `${span} secondes` : `${Math.round(span / 60)} minutes` }.` : "Ce système n'expose pas /proc : les mesures matérielles ne sont pas disponibles ici. Elles le seront dans le conteneur."; $('#sys-dot').className = `sys-dot ${s.available ? 'live' : 'off'}`; $('#sys-status').textContent = s.available ? `Mesure en direct · ${new Date(s.at).toLocaleTimeString('fr-FR')}` : 'Mesures matérielles indisponibles sur ce système'; } async function pollSystem() { if (systemPaused || document.hidden) return; try { renderSystem(await api('/api/admin/system')); } catch (err) { $('#sys-status').textContent = err.message; } } function startSystem() { stopSystem(); pollSystem(); systemTimer = setInterval(pollSystem, 3000); } function stopSystem() { if (systemTimer) clearInterval(systemTimer); systemTimer = null; } $('#btn-sys-toggle').addEventListener('click', () => { systemPaused = !systemPaused; $('#btn-sys-toggle').textContent = systemPaused ? 'Reprendre' : 'Suspendre'; if (!systemPaused) pollSystem(); }); /* ------------------------------------------------------------------ */ /* Bac à sable */ /* ------------------------------------------------------------------ */ const sandbox = { groups: [], users: [], current: null }; async function loadPlayground() { try { const res = await api('/api/admin/playground'); sandbox.groups = res.groups; sandbox.users = res.users; $('#playground-groups').innerHTML = res.groups.length ? res.groups .map( (g) => `
${escapeHtml(initials(g.name))}
${escapeHtml(g.name)}
${g.member_count} membre(s) · ${g.message_count} message(s)
`, ) .join('') : `

Bac à sable vide. Il est créé au démarrage du conteneur quand SEED_DEMO=1.

`; $('#playground-users').innerHTML = res.users.length ? res.users .map( (u) => `
${escapeHtml(initials(u.display_name))}
${escapeHtml(u.display_name)} ${u.is_group_admin ? 'Admin' : ''}
${escapeHtml(u.phone_display)} · ${u.groups.length} groupe(s)
`, ) .join('') : '

Aucun compte fictif.

'; $('#playground-groups') .querySelectorAll('[data-sandbox-group]') .forEach((row) => row.addEventListener('click', () => openSandbox(Number(row.dataset.sandboxGroup))), ); } catch (err) { toast(err.message, 'error'); } } async function openSandbox(groupId) { const group = sandbox.groups.find((g) => g.id === groupId); if (!group) return; sandbox.current = group; $('#sandbox-title').textContent = group.name; $('#sandbox-sub').textContent = `${group.member_count} membre(s) fictif(s)`; // Le sélecteur d'auteur : soi-même, ou n'importe quel compte fictif du groupe. const members = sandbox.users.filter((u) => u.groups.some((g) => g.group_id === groupId)); $('#sandbox-as').innerHTML = `` + members .map((u) => ``) .join(''); openOverlay('screen-sandbox'); await loadSandboxThread(); } async function loadSandboxThread() { if (!sandbox.current) return; try { const res = await api(`/api/admin/playground/groups/${sandbox.current.id}/messages`); const thread = $('#sandbox-thread'); thread.innerHTML = res.messages.length ? res.messages .map( (m) => `
${escapeHtml( initials(m.author_label), )}
${escapeHtml(m.author_label)}
${enrich(escapeHtml(m.body))}
${fmtTime(m.created_at)}
`, ) .join('') : `

Conversation vide

Écrivez un message, en changeant d'auteur pour simuler un échange.

`; thread.scrollTop = thread.scrollHeight; } catch (err) { toast(err.message, 'error'); } } $('#btn-sandbox-send').addEventListener('click', async () => { const input = $('#sandbox-message'); const body = input.value.trim(); if (!body || !sandbox.current) return; try { const res = await api('/api/admin/playground/messages', { method: 'POST', body: { group_id: sandbox.current.id, phone: $('#sandbox-as').value, body }, }); input.value = ''; if (res.message.warning) toast(res.message.warning); loadSandboxThread(); loadModeration(); } catch (err) { toast(err.message, 'error'); } }); $('#sandbox-message').addEventListener('keydown', (e) => { if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); $('#btn-sandbox-send').click(); } }); $('#btn-sandbox-menu').addEventListener('click', () => { if (!sandbox.current) return; openSheet(`

${escapeHtml(sandbox.current.name)}

`); $('#sb-read').addEventListener('click', async () => { try { const res = await api(`/api/admin/playground/groups/${sandbox.current.id}/read`, { method: 'POST', }); closeSheet(); toast(`${res.readers} compte(s) ont lu la conversation.`, 'ok'); } catch (err) { toast(err.message, 'error'); } }); $('#sb-clear').addEventListener('click', async () => { try { await api(`/api/admin/playground/groups/${sandbox.current.id}/clear`, { method: 'POST' }); closeSheet(); loadSandboxThread(); loadPlayground(); } catch (err) { toast(err.message, 'error'); } }); }); let userSearchTimer = null; $('#in-user-search').addEventListener('input', () => { clearTimeout(userSearchTimer); userSearchTimer = setTimeout(loadAdminUsers, 260); }); async function loadAdminUsers() { try { const term = encodeURIComponent($('#in-user-search').value.trim()); const res = await api(`/api/admin/users?search=${term}&limit=200`); $('#admin-users').innerHTML = res.users.length ? res.users .map( (u) => `
${escapeHtml(initials(u.display_name))}
${escapeHtml(u.display_name)} ${u.is_superadmin ? 'Super' : ''} ${u.flagged ? 'Signalé' : ''} ${ u.self_registered && !u.group_count ? 'à affecter' : '' }
${escapeHtml(u.phone_display)} · ${u.group_count} groupe(s)${ u.status === 'invited' ? ' · en attente' : u.status === 'blocked' ? ' · bloqué' : '' }${u.flagged && u.flag_reason ? ` · ${escapeHtml(u.flag_reason)}` : ''}
`, ) .join('') : '

Aucun compte.

'; $('#admin-users') .querySelectorAll('[data-user]') .forEach((b) => b.addEventListener('click', () => openUserSheet(res.users.find((u) => u.phone === b.dataset.user)), ), ); } catch (err) { toast(err.message, 'error'); } } async function openUserSheet(user) { if (!user) return; const [allGroups, mine] = await Promise.all([ api('/api/admin/groups').then((r) => r.groups), api(`/api/admin/users/${user.phone}/groups`).then((r) => r.groups), ]); const mineById = new Map(mine.map((g) => [g.id, g])); // Les sous-groupes s'affichent sous leur parent pour rester lisibles. const ordered = []; for (const g of allGroups.filter((x) => !x.parent_id)) { ordered.push(g); ordered.push(...allGroups.filter((x) => x.parent_id === g.id)); } ordered.push(...allGroups.filter((x) => x.parent_id && !allGroups.some((p) => p.id === x.parent_id))); openSheet(`

${escapeHtml(user.display_name)}

${escapeHtml(user.phone_display)} · ${ user.status === 'active' ? 'compte actif' : user.status === 'invited' ? 'en attente d’activation' : 'bloqué' }

Groupes actuels (${mine.length})
${ mine.length ? mine .map( (g) => ` ${escapeHtml(g.name)}${g.role === 'admin' ? ' · admin' : ''} `, ) .join('') : 'Aucun groupe.' }
Affecter à plusieurs groupes
${ ordered.length ? ordered .map( (g) => `
${escapeHtml(g.name)} ${mineById.has(g.id) ? 'déjà membre' : ''}
`, ) .join('') : '
Aucun groupe à proposer.
' }
`); let role = 'member'; $('#us-role') .querySelectorAll('button') .forEach((b) => b.addEventListener('click', () => { role = b.dataset.role; $('#us-role') .querySelectorAll('button') .forEach((x) => x.classList.toggle('active', x === b)); }), ); const patch = async (body, message) => { try { await api(`/api/admin/users/${user.phone}`, { method: 'PATCH', body }); closeSheet(); toast(message, 'ok'); loadAdminUsers(); } catch (err) { toast(err.message, 'error'); } }; // Sélection multiple : un numéro peut appartenir à autant de groupes qu'on veut. $('#us-picker') .querySelectorAll('.pick-row') .forEach((row) => row.addEventListener('click', () => row.classList.toggle('on'))); $('#us-current') .querySelectorAll('[data-unassign]') .forEach((b) => b.addEventListener('click', async () => { try { await api(`/api/admin/users/${user.phone}/groups/${b.dataset.unassign}`, { method: 'DELETE', }); closeSheet(); toast('Retiré du groupe.', 'ok'); loadAdminUsers(); loadAdminGroups(); } catch (err) { toast(err.message, 'error'); } }), ); $('#us-assign').addEventListener('click', async () => { const ids = Array.from($('#us-picker').querySelectorAll('.pick-row.on')).map((r) => Number(r.dataset.gid), ); if (!ids.length) return toast('Sélectionnez au moins un groupe.', 'error'); try { const res = await api(`/api/admin/users/${user.phone}/groups`, { method: 'POST', body: { group_ids: ids, role }, }); closeSheet(); toast(`Ajouté à ${res.added.length} groupe(s).`, 'ok'); loadAdminGroups(); loadAdminUsers(); loadGroups(); } catch (err) { toast(err.message, 'error'); } }); $('#us-super').addEventListener('click', () => patch( { is_superadmin: !user.is_superadmin }, user.is_superadmin ? 'Rôle superadmin retiré.' : 'Superadmin promu.', ), ); // Signaler ne coupe pas l'accès : le compte remonte simplement en tête // d'annuaire avec son motif, en attendant qu'on tranche. $('#us-flag').addEventListener('click', async () => { let reason = ''; if (!user.flagged) { reason = prompt('Motif du signalement (visible par les superadmins) :', '') || ''; if (reason === null) return; } try { await api(`/api/admin/users/${user.phone}/flag`, { method: 'POST', body: { flagged: !user.flagged, reason }, }); closeSheet(); toast(user.flagged ? 'Signalement levé.' : 'Compte signalé.', 'ok'); loadAdminUsers(); } catch (err) { toast(err.message, 'error'); } }); $('#us-block').addEventListener('click', () => patch( { status: user.status === 'blocked' ? 'active' : 'blocked' }, user.status === 'blocked' ? 'Compte débloqué.' : 'Compte bloqué.', ), ); $('#us-reset').addEventListener('click', async () => { if (!confirm('Réinitialiser le code PIN ? La personne devra en choisir un nouveau.')) return; try { const res = await api(`/api/admin/users/${user.phone}/reset-pin`, { method: 'POST' }); closeSheet(); toast(res.message, 'ok'); loadAdminUsers(); } catch (err) { toast(err.message, 'error'); } }); $('#us-delete').addEventListener('click', async () => { if (!confirm(`Supprimer définitivement le compte ${user.phone_display} ?`)) return; try { await api(`/api/admin/users/${user.phone}`, { method: 'DELETE' }); closeSheet(); toast('Compte supprimé.', 'ok'); loadAdminUsers(); loadStats(); } catch (err) { toast(err.message, 'error'); } }); } async function loadStats() { try { const s = await api('/api/admin/stats'); $('#stat-grid').innerHTML = [ ['Comptes', s.users.total], ['Actifs', s.users.active], ['En attente', s.users.invited], ['En ligne', s.users.online], ['Groupes', s.groups.total], ['Sous-groupes', s.groups.subgroups], ['Messages', s.messages.total], ['Dernières 24 h', s.messages.last_24h], ] .map(([k, v]) => `
${v}
${k}
`) .join(''); const st = s.storage; $('#storage-bar').style.width = `${Math.min(st.percent, 100)}%`; $('#storage-text').innerHTML = ` ${st.megabytes} Mo utilisés sur ${st.hard_limit_mb} Mo (${st.percent} %).
Rétention : les ${st.keep_per_group} derniers messages par groupe, purgés au-delà de ${st.retention_days} jours.`; } catch (err) { toast(err.message, 'error'); } } $('#btn-purge').addEventListener('click', async () => { if (!confirm('Lancer la purge de rétention maintenant ?')) return; try { const r = await api('/api/admin/purge', { method: 'POST' }); toast( `${r.deleted_age + r.deleted_overflow} message(s) supprimé(s) · ${(r.size_after / 1e6).toFixed(1)} Mo`, 'ok', ); loadStats(); } catch (err) { toast(err.message, 'error'); } }); /* ------------------------------------------------------------------ */ /* Tableau de bord — graphiques */ /* ------------------------------------------------------------------ */ /* Toutes les séries sont mono-série (une magnitude dans le temps ou un classement) : une seule teinte, pas de légende, base à zéro. */ const tip = document.createElement('div'); tip.id = 'chart-tip'; document.body.appendChild(tip); function showTip(event, label, value, unit) { tip.innerHTML = `${escapeHtml(label)} · ${value} ${escapeHtml(unit)}`; tip.style.display = 'block'; const rect = tip.getBoundingClientRect(); let x = event.clientX - rect.width / 2; x = Math.max(8, Math.min(x, window.innerWidth - rect.width - 8)); tip.style.left = `${x}px`; tip.style.top = `${Math.max(8, event.clientY - rect.height - 12)}px`; } function hideTip() { tip.style.display = 'none'; } document.addEventListener('scroll', hideTip, true); // Barre à extrémité haute arrondie, ancrée sur la ligne de base. function barPath(x, y, w, h, r) { const rr = Math.max(0, Math.min(r, w / 2, h)); return ( `M${x},${y + h}` + `L${x},${y + rr}` + `Q${x},${y} ${x + rr},${y}` + `L${x + w - rr},${y}` + `Q${x + w},${y} ${x + w},${y + rr}` + `L${x + w},${y + h}Z` ); } /** * Histogramme vertical mono-série. * @param {HTMLElement} host conteneur * @param {Array<{label:string,count:number}>} data */ function barChart(host, data, { barW = 18, gap = 4, height = 120, labelEvery = 1, unit = 'messages' } = {}) { if (!data.length) { host.innerHTML = '
Aucune donnée.
'; return; } const padLeft = 26; const padBottom = 18; const padTop = 12; const plotW = data.length * (barW + gap) - gap; const width = padLeft + plotW + 6; const max = Math.max(1, ...data.map((d) => d.count)); const scale = (v) => (v / max) * height; // Trois repères suffisent : 0, milieu, maximum. const ticks = [0, Math.round(max / 2), max].filter((v, i, a) => a.indexOf(v) === i); const parts = [``]; for (const t of ticks) { const y = padTop + height - scale(t); parts.push( ``, `${t}`, ); } const maxIndex = data.findIndex((d) => d.count === max); data.forEach((d, i) => { const x = padLeft + i * (barW + gap); const h = scale(d.count); const y = padTop + height - h; if (d.count > 0) { parts.push(``); } // Zone de survol pleine hauteur : plus facile à viser qu'une barre fine. parts.push( ``, ); // Label direct sur le seul maximum, jamais sur chaque barre. if (i === maxIndex && max > 0) { parts.push( `${max}`, ); } if (i % labelEvery === 0) { parts.push( `${escapeHtml(d.label)}`, ); } }); parts.push(''); host.className = 'chart'; host.innerHTML = parts.join(''); host.querySelectorAll('.bar-hit').forEach((hit) => { const show = (e) => showTip(e, hit.dataset.label, hit.dataset.value, unit); hit.addEventListener('pointerenter', show); hit.addEventListener('pointermove', show); hit.addEventListener('pointerdown', show); hit.addEventListener('pointerleave', hideTip); }); } /** Classement en barres horizontales (lisible sur mobile, pas de rotation de texte). */ function rankList(host, items, { unit = 'messages' } = {}) { if (!items.length) { host.innerHTML = '
Aucune donnée sur la période.
'; return; } const max = Math.max(1, ...items.map((i) => i.value)); host.innerHTML = items .map( (i) => `
${i.dot ? `` : ''}${escapeHtml(i.label)}
${i.value} ${escapeHtml(unit)}
`, ) .join(''); } function fmtDuration(seconds) { const d = Math.floor(seconds / 86400); const h = Math.floor((seconds % 86400) / 3600); const m = Math.floor((seconds % 3600) / 60); if (d) return `${d} j ${h} h`; if (h) return `${h} h ${m} min`; return `${m} min`; } /* --- Journal d'audit ------------------------------------------------- */ const AUDIT_LABELS = { login: 'Connexion', login_failed: 'Échec de connexion', account_locked: 'Compte verrouillé', account_activated: 'Compte activé', rate_limit: 'Rate limit atteint', ws_flood: 'Flood WebSocket', help_request: "Demande d'assistance", help_request_unknown: "Demande d'assistance (numéro inconnu)", help_unlock: 'Déverrouillage', help_reset_pin: 'PIN réinitialisé', help_dismiss: 'Demande classée', user_whitelisted: 'Numéro enregistré', users_bulk_import: 'Import de numéros', user_groups_assigned: 'Affectation de groupes', user_group_removed: 'Retrait de groupe', server_start: 'Démarrage du serveur', }; const AUDIT_SEVERITY = { login_failed: 'sev-warn', account_locked: 'sev-bad', rate_limit: 'sev-warn', ws_flood: 'sev-bad', help_request: 'sev-warn', help_request_unknown: 'sev-warn', login: 'sev-ok', account_activated: 'sev-ok', help_unlock: 'sev-ok', }; function renderAudit(host, entries) { if (!entries.length) { host.innerHTML = '
Aucun événement.
'; return; } host.innerHTML = entries .map((e) => { const bits = []; if (e.actor) bits.push(e.actor); if (e.target && e.target !== e.actor) bits.push(`→ ${e.target}`); if (e.detail) bits.push(e.detail); if (e.ip && e.ip !== 'inconnu') bits.push(e.ip); return `
${fmtRelative(e.at)}
${escapeHtml(AUDIT_LABELS[e.action] || e.action)}
${escapeHtml(bits.join(' · '))}
`; }) .join(''); } /* --- Chargement du tableau de bord ----------------------------------- */ async function loadDashboard() { try { const d = await api('/api/admin/dashboard'); const k = d.kpis; $('#dash-kpis').innerHTML = [ { v: k.users_online, k: 'En ligne', cls: 'accent' }, { v: k.weekly_active, k: 'Actifs 7 j', cls: 'accent' }, { v: k.users_active, k: 'Comptes actifs' }, { v: k.users_invited, k: 'En attente', cls: k.users_invited ? 'warn' : '' }, { v: k.users_blocked, k: 'Bloqués', cls: k.users_blocked ? 'alert' : '' }, { v: k.groups_total, k: 'Groupes' }, { v: k.messages_24h, k: 'Msg 24 h' }, { v: k.messages_7d, k: 'Msg 7 j' }, { v: k.images_30d, k: 'Photos 30 j' }, { v: `${k.activation_rate} %`, k: 'Activation' }, { v: k.push_subscriptions, k: 'Abonnés push' }, { v: fmtDuration(k.uptime_seconds), k: 'Uptime' }, ] .map( (x) => `
${escapeHtml(String(x.v))}
${x.k}
`, ) .join(''); const totalDaily = d.daily.reduce((s, x) => s + x.count, 0); $('#daily-note').textContent = `${d.daily.length} jours · ${totalDaily} au total`; barChart($('#chart-daily'), d.daily, { barW: 18, gap: 4, labelEvery: 2 }); const totalHourly = d.hourly.reduce((s, x) => s + x.count, 0); $('#hourly-note').textContent = `${totalHourly} message(s)`; barChart($('#chart-hourly'), d.hourly, { barW: 11, gap: 3, height: 90, labelEvery: 3 }); rankList( $('#chart-groups'), d.top_groups.map((g) => ({ label: g.name, value: g.messages, sub: g.is_subgroup })), ); rankList( $('#chart-members'), d.top_members.map((m) => ({ label: m.name, value: m.messages, dot: m.online ? 'online' : '', })), ); renderAudit($('#audit-list'), d.audit.slice(0, 12)); } catch (err) { toast(err.message, 'error'); } } $('#btn-audit-all').addEventListener('click', async () => { try { const res = await api('/api/admin/audit?limit=200'); openSheet('

Journal d\'audit

'); renderAudit($('#audit-full'), res.entries); } catch (err) { toast(err.message, 'error'); } }); /* ------------------------------------------------------------------ */ /* Demandes d'assistance (comptes verrouillés) */ /* ------------------------------------------------------------------ */ const HELP_REASONS = { locked: 'Compte verrouillé', blocked: 'Compte bloqué', forgot_pin: 'Code PIN oublié', other: 'Autre demande', }; async function loadHelpRequests() { // Deux points d'affichage : le tableau de bord (superadmin) et la liste // des groupes (tout admin de groupe). const hosts = [$('#help-requests-card'), $('#help-requests-inline')].filter(Boolean); if (!hosts.length) return; const host = { set innerHTML(v) { hosts.forEach((h) => (h.innerHTML = v)); }, querySelectorAll: (s) => hosts.flatMap((h) => Array.from(h.querySelectorAll(s))) }; try { const res = await api('/api/help-requests'); const pip = $('#btn-admin').querySelector('.pip'); if (res.count) { if (pip) pip.textContent = res.count; else { const el = document.createElement('span'); el.className = 'pip'; el.textContent = res.count; $('#btn-admin').appendChild(el); } } else if (pip) { pip.remove(); } if (!res.count) { host.innerHTML = ''; return; } host.innerHTML = `

${res.count} demande${res.count > 1 ? 's' : ''} d'assistance

${res.requests .map( (r) => `
${escapeHtml(r.name)}
${escapeHtml(r.phone_display)} · ${escapeHtml( HELP_REASONS[r.reason] || r.reason, )} · ${fmtRelative(r.created_at)}
${r.message ? `
« ${escapeHtml(r.message)} »
` : ''}
`, ) .join('')}
`; host.querySelectorAll('[data-act]').forEach((b) => b.addEventListener('click', async () => { const action = b.dataset.act; if (action === 'reset_pin' && !confirm('Réinitialiser le code PIN de cette personne ?')) { return; } try { const r = await api(`/api/help-requests/${b.dataset.id}/resolve`, { method: 'POST', body: { action }, }); toast(r.message, 'ok'); loadHelpRequests(); loadAdminUsers(); } catch (err) { toast(err.message, 'error'); } }), ); } catch (err) { // Un membre sans rôle d'admin n'a rien à voir ici : échec silencieux. host.innerHTML = ''; } } /* --- Côté utilisateur : demander de l'aide depuis l'écran d'accueil --- */ $$('[data-open-help]').forEach((b) => b.addEventListener('click', () => { showAuthStep('step-help'); // Pré-sélectionne le motif le plus probable d'après le message d'erreur. const err = $('#auth-error').textContent; $('#in-help-reason').value = /bloqué/i.test(err) ? 'blocked' : /tentatives|verrouill/i.test(err) ? 'locked' : 'forgot_pin'; }), ); $('#btn-help-send').addEventListener('click', async () => { const btn = $('#btn-help-send'); btn.disabled = true; try { const res = await api('/api/auth/help', { method: 'POST', body: { phone: state.pendingPhone, reason: $('#in-help-reason').value, message: $('#in-help-msg').value.trim(), }, }); $('#in-help-msg').value = ''; showAuthStep('step-phone'); authError(''); toast(res.message, 'ok'); } catch (err) { authError(err.message); } finally { btn.disabled = false; } }); /* ------------------------------------------------------------------ */ /* Service worker et cycle de vie */ /* ------------------------------------------------------------------ */ if ('serviceWorker' in navigator) { window.addEventListener('load', () => { navigator.serviceWorker.register('/sw.js').catch(() => { /* hors ligne au premier chargement */ }); }); navigator.serviceWorker.addEventListener('message', (event) => { if (event.data && event.data.type === 'open-group' && event.data.group_id) { openChat(Number(event.data.group_id)); } }); } document.addEventListener('visibilitychange', () => { if (document.visibilityState === 'visible') { connectWS(); loadGroups(); if (navigator.clearAppBadge) { try { navigator.clearAppBadge(); } catch (e) { /* ignoré */ } } } }); boot(); })();