| |
| |
| |
| |
| |
|
|
| (() => { |
| 'use strict'; |
|
|
| |
| |
| |
|
|
| 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(), |
| pins: [], |
| receipts: [], |
| pendingPhone: '', |
| loginRole: 'benevole', |
| loadingHistory: false, |
| }; |
|
|
| const $ = (sel) => document.querySelector(sel); |
| const $$ = (sel) => Array.from(document.querySelectorAll(sel)); |
|
|
| |
| |
| |
|
|
| function escapeHtml(s) { |
| return String(s ?? '') |
| .replace(/&/g, '&') |
| .replace(/</g, '<') |
| .replace(/>/g, '>') |
| .replace(/"/g, '"') |
| .replace(/'/g, '''); |
| } |
|
|
| |
| function enrich(escaped) { |
| return escaped |
| .replace( |
| /(https?:\/\/[^\s<]+)/g, |
| (m) => `<a href="${m}" target="_blank" rel="noopener noreferrer">${m}</a>`, |
| ) |
| .replace(/(^|\s)(@[\wÀ-ÿ'-]+)/gu, '$1<span class="mention">$2</span>'); |
| } |
|
|
| function initials(name) { |
| |
| |
| const raw = String(name || '') |
| .trim() |
| .replace(/\btest_/gi, ''); |
| |
| |
| 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(); |
| } |
|
|
| |
| 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) { |
| |
| } |
| } |
| } |
|
|
| |
| |
| |
|
|
| async function api(path, options = {}) { |
| |
| |
| 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; |
| } |
|
|
| |
| |
| |
|
|
| 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'); |
| |
| |
| if (id === 'screen-admin') { |
| stopSystem(); |
| $('#app').classList.remove('desk'); |
| } |
| } |
|
|
| window.addEventListener('popstate', () => { |
| |
| 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; |
| } |
| } |
| }); |
|
|
| |
| |
| |
|
|
| function authError(message) { |
| $('#auth-error').textContent = message || ''; |
| } |
|
|
| function showAuthStep(step) { |
| $$('.auth-step').forEach((el) => { |
| el.hidden = el.id !== step; |
| }); |
| authError(''); |
| } |
|
|
| |
| |
| |
|
|
| function setRole(role) { |
| state.loginRole = role; |
| const isAdmin = role === 'admin'; |
| $('#role-pill-slot').innerHTML = `<span class="role-pill ${isAdmin ? '' : 'benevole'}"> |
| ${isAdmin ? 'Espace admin' : 'Espace bénévole'}</span>`; |
| 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')), |
| ); |
|
|
| |
| $('#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(); |
| }); |
|
|
| |
| $$('.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; |
| |
| |
| $('#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) { |
| |
| } |
| 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()); |
|
|
| |
| |
| |
|
|
| async function enterApp() { |
| showScreen('screen-groups'); |
| |
| |
| $('#app').classList.add('shell'); |
| $('#btn-admin').hidden = !state.me.is_superadmin; |
| await loadGroups(); |
| connectWS(); |
| refreshPushSwitch(); |
| loadHelpRequests(); |
|
|
| |
| 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); |
| } |
| } |
|
|
| |
| |
| |
|
|
| 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 = () => { |
| |
| |
| 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); |
| |
| state.wsRetry = Math.min(state.wsRetry + 1, 6); |
| setTimeout(connectWS, Math.min(1000 * 2 ** state.wsRetry, 15000)); |
| }; |
|
|
| ws.onerror = () => { |
| |
| }; |
| } |
|
|
| 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': { |
| |
| 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': |
| |
| |
| 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 = ` |
| <div class="avatar sm ${avatarClass(msg.author_phone)}">${escapeHtml(initials(msg.author_label))}</div> |
| <div style="flex:1;min-width:0"> |
| <div class="t">${escapeHtml(group ? group.name : 'Nouveau message')}</div> |
| <div class="b">${escapeHtml(msg.author_label)} : ${escapeHtml(preview)}</div> |
| </div>`; |
| 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); |
|
|
| |
| |
| |
|
|
| 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) { |
| |
| } |
| } |
|
|
| 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) { |
| |
| } |
| } |
| 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 = ` |
| <div class="empty"> |
| <svg><use href="#i-chat" /></svg> |
| <h3>${term ? 'Aucun résultat' : 'Aucun groupe'}</h3> |
| <p>${ |
| term |
| ? 'Essayez un autre terme de recherche.' |
| : "Vous n'appartenez encore à aucun groupe.<br />Un administrateur doit vous y ajouter." |
| }</p> |
| </div>`; |
| return; |
| } |
|
|
| |
| 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 `<div class="group-section"> |
| ${groupRow(root, false)} |
| ${kids.map((k) => groupRow(k, true)).join('')} |
| </div>`; |
| }) |
| .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 ` |
| <button class="group-row ${isChild ? 'child' : ''}" data-id="${g.id}"> |
| <div class="avatar ${isChild ? 'ember' : ''}">${escapeHtml(initials(g.name))}</div> |
| <div class="group-main"> |
| <div class="group-name"> |
| <span>${escapeHtml(g.name)}</span> |
| ${g.role === 'admin' ? '<span class="tag admin">Admin</span>' : ''} |
| ${g.notif_mode === 'mute' ? '<span class="tag muted">Muet</span>' : ''} |
| </div> |
| <div class="group-preview">${escapeHtml(preview)}</div> |
| </div> |
| <div class="group-meta"> |
| <span class="group-time">${last ? fmtRelative(last.created_at) : ''}</span> |
| ${g.unread ? `<span class="badge">${g.unread > 99 ? '99+' : g.unread}</span>` : ''} |
| </div> |
| </button>`; |
| } |
|
|
| $('#in-search').addEventListener('input', renderGroups); |
|
|
| |
| |
| |
|
|
| 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 = |
| '<div class="empty"><svg class="spin"><use href="#i-loader" /></svg></div>'; |
|
|
| 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'); |
| } |
| } |
|
|
| |
| |
| |
| |
|
|
| async function loadReceipts(groupId) { |
| try { |
| const res = await api(`/api/groups/${groupId}/receipts`); |
| state.receipts = res.members; |
| } catch (err) { |
| state.receipts = []; |
| } |
| } |
|
|
| |
| function others() { |
| return state.receipts.filter((m) => m.phone !== state.me.phone); |
| } |
|
|
| function readersOf(messageId) { |
| return others().filter((m) => m.last_read_id >= messageId); |
| } |
|
|
| |
| |
| |
| |
| 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) { |
| |
| 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 `<svg class="receipt ${level}" title="${escapeHtml(label)}"><use href="#${icon}"/></svg>`; |
| } |
|
|
| 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 |
| ? `<div class="readers-block"> |
| <div class="readers-title ${className}">${escapeHtml(title)} · ${people.length}</div> |
| ${people |
| .map( |
| (m) => `<div class="readers-row"> |
| <div class="avatar sm ${avatarClass(m.phone)}">${escapeHtml(initials(m.display_name))}</div> |
| <span>${escapeHtml(m.display_name)}</span> |
| ${m.online ? '<span class="dot online"></span>' : ''} |
| </div>`, |
| ) |
| .join('')} |
| </div>` |
| : ''; |
|
|
| openSheet(` |
| <h3>Accusés de lecture</h3> |
| <p class="sheet-note">« ${escapeHtml( |
| (message.kind === 'image' ? 'Photo' : message.body).slice(0, 90), |
| )} »</p> |
| ${block('Lu', read, 'read')} |
| ${block('Remis, pas encore lu', delivered, 'delivered')} |
| ${block('Pas encore remis', pending, 'pending')} |
| ${ |
| !others().length |
| ? '<p class="empty-note">Vous êtes seul dans ce groupe pour le moment.</p>' |
| : '' |
| } |
| `); |
| } |
|
|
| |
| |
| |
|
|
| 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) => `<div class="pin-item" data-pin="${m.id}"> |
| <svg><use href="#i-pin"/></svg> |
| <div class="pin-text"> |
| <strong>${escapeHtml(m.author_label)}</strong> |
| ${escapeHtml(m.kind === 'image' ? 'Photo épinglée' : m.body).slice(0, 160)} |
| </div> |
| </div>`, |
| ) |
| .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 = ` |
| <div class="empty"> |
| <svg><use href="#i-chat" /></svg> |
| <h3>Conversation vide</h3> |
| <p>Envoyez le premier message.</p> |
| </div>`; |
| return; |
| } |
|
|
| const parts = []; |
| if (state.hasMore) { |
| parts.push( |
| '<button class="btn-link" id="btn-more" style="margin-bottom:8px">Charger les messages précédents</button>', |
| ); |
| } |
|
|
| let lastDay = ''; |
| let prev = null; |
| for (const msg of state.messages) { |
| const day = fmtDay(msg.created_at); |
| if (day !== lastDay) { |
| parts.push(`<div class="day-sep">${escapeHtml(day)}</div>`); |
| lastDay = day; |
| prev = null; |
| } |
| if (msg.kind === 'system') { |
| parts.push(`<div class="sys-msg">${escapeHtml(msg.body)}</div>`); |
| prev = null; |
| continue; |
| } |
| |
| 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 = `<div class="reply-quote"> |
| <strong>${escapeHtml(parent ? parent.author_label : 'Message')}</strong> |
| <span>${escapeHtml(parent ? (parent.kind === 'image' ? '📷 Photo' : parent.body) : 'Message supprimé')}</span> |
| </div>`; |
| } |
|
|
| let body = ''; |
| if (msg.kind === 'image') { |
| const meta = msg.meta || {}; |
| body = `<div class="p2p-image" data-transfer="${escapeHtml(meta.transfer_id || '')}" |
| data-owner="${escapeHtml(msg.author_phone || '')}" |
| data-group="${msg.group_id}"> |
| <div class="p2p-state"> |
| <svg class="spin"><use href="#i-loader" /></svg> |
| <span>Photo · ${escapeHtml(fmtBytes(meta.size || 0))}</span> |
| </div> |
| <div class="p2p-bar"><span style="width:0%"></span></div> |
| </div>`; |
| if (msg.body) body += `<div class="msg-body">${enrich(escapeHtml(msg.body))}</div>`; |
| } else { |
| body = `<div class="msg-body">${enrich(escapeHtml(msg.body))}</div>`; |
| } |
|
|
| return ` |
| <div class="${cls}${msg.pinned_at ? ' pinned' : ''}" id="msg-${msg.id}" data-id="${msg.id}"> |
| ${ |
| mine |
| ? '' |
| : `<div class="msg-avatar avatar ${avatarClass(msg.author_phone)}">${escapeHtml(initials(msg.author_label))}</div>` |
| } |
| <div class="bubble"> |
| ${mine ? '' : `<div class="msg-author">${escapeHtml(msg.author_label)}</div>`} |
| ${quote} |
| ${body} |
| <div class="msg-time">${ |
| msg.pinned_at ? '<svg class="pin-mark"><use href="#i-pin"/></svg> ' : '' |
| }${fmtTime(msg.created_at)}${msg.edited_at ? ' · modifié' : ''}${receiptMark(msg)}</div> |
| </div> |
| </div>`; |
| } |
|
|
| 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(); |
| |
| thread.scrollTop = thread.scrollHeight - prevHeight; |
| } catch (err) { |
| toast(err.message, 'error'); |
| } finally { |
| state.loadingHistory = false; |
| } |
| } |
|
|
| |
| 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)); |
| }); |
| }); |
| } |
|
|
| |
|
|
| 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) => { |
| |
| 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 }, |
| }); |
| |
| |
| 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(); |
| }); |
|
|
| |
| |
| |
|
|
| function openSheet(html) { |
| $('#sheet').innerHTML = `<div class="sheet-grip"></div>${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(` |
| <h3>Message</h3> |
| ${ |
| mineAndSent |
| ? `<button class="sheet-action" data-act="readers"> |
| <svg><use href="#i-check2"/></svg> Voir qui a lu ce message</button>` |
| : '' |
| } |
| <button class="sheet-action" data-act="reply"><svg><use href="#i-reply"/></svg> Répondre</button> |
| ${ |
| msg.kind !== 'image' |
| ? '<button class="sheet-action" data-act="copy"><svg><use href="#i-copy"/></svg> Copier le texte</button>' |
| : '' |
| } |
| ${ |
| isAdmin |
| ? `<button class="sheet-action" data-act="pin"><svg><use href="#i-pin"/></svg> |
| ${msg.pinned_at ? 'Retirer des épingles' : 'Épingler dans le groupe'}</button>` |
| : '' |
| } |
| ${ |
| mine || isAdmin |
| ? '<button class="sheet-action danger" data-act="delete"><svg><use href="#i-trash"/></svg> Supprimer</button>' |
| : '' |
| } |
| `); |
|
|
| $('#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'); |
| } |
| } |
| }), |
| ); |
| } |
|
|
| |
|
|
| $('#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(` |
| <h3>${escapeHtml(info.name)}</h3> |
| ${info.description ? `<p style="color:var(--text-dim);font-size:14px;margin:-6px 0 14px">${escapeHtml(info.description)}</p>` : ''} |
| ${info.parent ? `<p style="color:var(--text-faint);font-size:12.5px;margin:-6px 0 14px">Sous-groupe de ${escapeHtml(info.parent.name)}</p>` : ''} |
| |
| <div style="font-size:11px;text-transform:uppercase;letter-spacing:.13em;color:var(--cyan-dim);font-weight:700;margin:6px 0 8px">Notifications</div> |
| <div class="segmented" id="seg-notif"> |
| <button data-mode="all" class="${mode === 'all' ? 'active' : ''}">Tous</button> |
| <button data-mode="mentions" class="${mode === 'mentions' ? 'active' : ''}">Mentions</button> |
| <button data-mode="mute" class="${mode === 'mute' ? 'active' : ''}">Muet</button> |
| </div> |
| |
| <div style="font-size:11px;text-transform:uppercase;letter-spacing:.13em;color:var(--cyan-dim);font-weight:700;margin:18px 0 4px"> |
| ${info.members.length} membre${info.members.length > 1 ? 's' : ''} |
| </div> |
| <div> |
| ${info.members |
| .map( |
| (m) => `<div class="admin-row"> |
| <div class="avatar sm ${avatarClass(m.phone)}">${escapeHtml(initials(m.display_name))}</div> |
| <div class="group-main"> |
| <div class="group-name">${escapeHtml(m.display_name)} |
| ${m.role === 'admin' ? '<span class="tag admin">Admin</span>' : ''}</div> |
| <div class="group-preview">${escapeHtml(m.phone_display)}</div> |
| </div> |
| ${ |
| isAdmin && m.phone !== state.me.phone |
| ? `<button class="btn btn-danger btn-sm" data-remove="${escapeHtml(m.phone)}">Retirer</button>` |
| : '' |
| } |
| </div>`, |
| ) |
| .join('')} |
| </div> |
| |
| ${ |
| isAdmin |
| ? `<div style="margin-top:16px"> |
| <div class="field"> |
| <label for="add-member-phone">Ajouter un membre</label> |
| <input id="add-member-phone" class="input-phone" type="tel" inputmode="numeric" placeholder="06 12 34 56 78" maxlength="14"/> |
| </div> |
| <button class="btn btn-ember btn-sm" id="btn-add-member" style="width:100%">Ajouter au groupe</button> |
| </div>` |
| : '' |
| } |
| `); |
|
|
| $('#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'); |
| } |
| }); |
|
|
| |
| |
| |
| |
| |
| |
| |
|
|
| 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(); |
| const incoming = new Map(); |
|
|
| |
|
|
| 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); |
| }); |
| } |
|
|
| |
|
|
| |
| 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'); |
| } |
| }); |
|
|
| |
|
|
| 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 = `<img src="${url}" alt="Photo" loading="lazy" />`; |
| node.querySelector('img').addEventListener('click', () => window.open(url, '_blank')); |
| } |
|
|
| function setImageState(node, text, error, progress) { |
| node.innerHTML = ` |
| <div class="p2p-state ${error ? 'error' : ''}"> |
| <svg class="${error ? '' : 'spin'}"><use href="#${error ? 'i-alert' : 'i-loader'}" /></svg> |
| <span>${escapeHtml(text)}</span> |
| </div> |
| ${progress !== undefined ? `<div class="p2p-bar"><span style="width:${progress}%"></span></div>` : ''}`; |
| } |
|
|
| 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; |
| } |
| |
| setTimeout(() => { |
| if (node.querySelector('.p2p-state') && !incoming.has(transferId)) { |
| setImageState(node, "Photo indisponible (expéditeur hors ligne).", true); |
| } |
| }, 20000); |
| } |
|
|
| |
| 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) { |
| |
| 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 }, |
| }); |
| } |
|
|
| |
| 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) { |
| |
| } |
| } |
| } |
|
|
| 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); |
|
|
| |
| if (!rec) { |
| try { |
| const header = JSON.parse(event.data); |
| incoming.set(transferId, { chunks: [], received: 0, total: header.size, type: header.type }); |
| } catch (e) { |
| |
| } |
| 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); |
| } |
| } |
| }; |
| } |
|
|
| |
| |
| |
|
|
| $('#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(); |
| }); |
|
|
| |
| |
| |
|
|
| 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); |
| |
| 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'); |
| } |
| }); |
|
|
| |
| |
| |
|
|
| $('#btn-admin').addEventListener('click', () => { |
| openOverlay('screen-admin'); |
| |
| |
| $('#app').classList.add('desk'); |
| |
| $$('.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)); |
| |
| const tab = btn.dataset.tab; |
| |
| 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; |
|
|
| |
| const select = $('#ng-parent'); |
| select.innerHTML = |
| '<option value="">— Aucun (groupe principal) —</option>' + |
| groups |
| .filter((g) => !g.parent_id) |
| .map((g) => `<option value="${g.id}">${escapeHtml(g.name)}</option>`) |
| .join(''); |
|
|
| |
| $('#in-import-group').innerHTML = |
| '<option value="">Aucun groupe</option>' + |
| groups |
| .map( |
| (g) => |
| `<option value="${g.id}">${g.parent_id ? '↳ ' : ''}${escapeHtml(g.name)}</option>`, |
| ) |
| .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 `<div class="admin-row"> |
| <div class="avatar sm ${g.parent_id ? 'ember' : ''}">${escapeHtml(initials(g.name))}</div> |
| <div class="group-main"> |
| <div class="group-name">${escapeHtml(g.name)}</div> |
| <div class="group-preview"> |
| ${parent ? `↳ ${escapeHtml(parent.name)} · ` : ''}${g.member_count} membre(s) · ${g.message_count} message(s) |
| </div> |
| </div> |
| <button class="btn btn-danger btn-sm" data-del-group="${g.id}">Suppr.</button> |
| </div>`; |
| }) |
| .join('') |
| : '<p style="color:var(--text-faint);font-size:14px">Aucun groupe pour le moment.</p>'; |
|
|
| $('#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'); |
| } |
| }); |
|
|
| |
| |
| |
| |
|
|
| 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); |
| |
| 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 += `<option value="${i}" ${i === selected ? 'selected' : ''}>${escapeHtml(label)}</option>`; |
| } |
| return html; |
| } |
|
|
| function renderImportPreview(res) { |
| const box = $('#import-result'); |
| const map = res.mapping; |
| const headers = res.preview.length ? [] : []; |
| |
| 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) => `<tr class="${p.known ? 'known' : ''}"> |
| <td>${escapeHtml(p.first_name || '—')}</td> |
| <td>${escapeHtml(p.last_name || '—')}</td> |
| <td>${escapeHtml(p.phone_display)}</td> |
| <td>${p.known ? 'déjà connu' : 'nouveau'}</td> |
| </tr>`, |
| ) |
| .join(''); |
|
|
| box.hidden = false; |
| box.innerHTML = ` |
| <div class="import-summary"> |
| <strong>${res.detected}</strong> contact(s) lus dans |
| ${escapeHtml(res.filename || 'le fichier')} — |
| <strong>${res.new}</strong> nouveau(x), ${res.existing} déjà connu(s)${ |
| res.rejected_count ? `, ${res.rejected_count} ligne(s) ignorée(s)` : '' |
| }. |
| </div> |
| <div class="import-mapping"> |
| <div>Colonnes reconnues : |
| <span class="tag">Prénom → ${escapeHtml(labels.first || labels.full || '—')}</span> |
| <span class="tag">Nom → ${escapeHtml(labels.last || labels.full || '—')}</span> |
| <span class="tag">Téléphone → ${escapeHtml(labels.phone || '—')}</span> |
| </div> |
| ${ |
| map.assumed_order |
| ? `<p class="import-warn">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.</p>` |
| : '' |
| } |
| </div> |
| <details class="import-fix"> |
| <summary>Corriger les colonnes</summary> |
| <div class="field"> |
| <label for="imp-first">Prénom</label> |
| <select id="imp-first">${columnOptions(width, map.first_col ?? map.full_col, headers)}</select> |
| </div> |
| <div class="field"> |
| <label for="imp-last">Nom</label> |
| <select id="imp-last">${columnOptions(width, map.last_col, headers)}</select> |
| </div> |
| <div class="field"> |
| <label for="imp-phone">Téléphone</label> |
| <select id="imp-phone">${columnOptions(width, map.phone_col, headers)}</select> |
| </div> |
| <button class="btn btn-ghost btn-sm" id="imp-recheck" style="width:100%"> |
| Relire avec ces colonnes |
| </button> |
| </details> |
| <table class="import-preview"> |
| <thead><tr><th>Prénom</th><th>Nom</th><th>Téléphone</th><th></th></tr></thead> |
| <tbody>${rows}</tbody> |
| </table> |
| ${ |
| res.rejected.length |
| ? `<details class="import-fix"><summary>${res.rejected_count} ligne(s) ignorée(s)</summary> |
| <ul class="import-rejected">${res.rejected |
| .map((r) => `<li>Ligne ${r.row} — ${escapeHtml(r.reason)} : ${escapeHtml(r.value)}</li>`) |
| .join('')}</ul></details>` |
| : '' |
| } |
| <button class="btn btn-ember btn-sm" id="imp-confirm" style="width:100%;margin-top:10px"> |
| Importer ${res.new} nouveau(x) contact(s) |
| </button> |
| <button class="btn-link" id="imp-cancel" style="width:100%">Annuler</button> |
| `; |
|
|
| $$('#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); |
| }); |
|
|
| |
| |
| |
|
|
| 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) => `<div class="alert-row ${a.status === 'pending' ? 'pending' : ''}"> |
| <div class="alert-head"> |
| <span class="tag flagged">${escapeHtml(a.category_label)}</span> |
| <span class="alert-time">${fmtDateTime(a.created_at)}</span> |
| </div> |
| <div class="alert-body">« ${escapeHtml(a.excerpt)} »</div> |
| <div class="alert-meta"> |
| ${escapeHtml(a.author || 'inconnu')} · terme |
| <strong>${escapeHtml(a.term)}</strong> |
| ${a.matched && a.matched !== a.term ? `(écrit « ${escapeHtml(a.matched)} »)` : ''} |
| </div> |
| ${ |
| a.status === 'pending' |
| ? `<button class="btn btn-ghost btn-sm" data-review="${a.id}">Marquer traité</button>` |
| : '' |
| } |
| </div>`, |
| ) |
| .join('') |
| : '<p class="empty-note">Aucun signalement.</p>'; |
|
|
| $('#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'); |
| } |
| }), |
| ); |
|
|
| |
| $('#terms-editor').innerHTML = Object.entries(res.categories) |
| .map( |
| ([key, label]) => `<div class="field"> |
| <label for="terms-${key}">${escapeHtml(label)}</label> |
| <textarea id="terms-${key}" data-terms="${key}" rows="5">${escapeHtml( |
| (res.terms[key] || []).join('\n'), |
| )}</textarea> |
| </div>`, |
| ) |
| .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'); |
| } |
| }); |
|
|
| |
| |
| |
| |
| |
|
|
| const SPARK_POINTS = 40; |
| |
| |
| 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 `<div class="sys-card"> |
| <div class="sys-label">${escapeHtml(label)}</div> |
| <div class="sys-value ${level}">${escapeHtml(value)}</div> |
| ${ |
| percent === null || percent === undefined |
| ? '' |
| : `<div class="meter"><span class="${level}" style="width:${Math.min(100, percent)}%"></span></div>` |
| } |
| ${hint ? `<div class="sys-hint">${escapeHtml(hint)}</div>` : ''} |
| </div>`; |
| } |
|
|
| 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); |
|
|
| |
| |
| |
| |
| 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)}` : '', |
| ), |
| |
| |
| |
| 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(); |
| }); |
|
|
| |
| |
| |
|
|
| 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) => `<div class="admin-row" data-sandbox-group="${g.id}"> |
| <div class="avatar sm ${g.parent_id ? 'ember' : ''}">${escapeHtml(initials(g.name))}</div> |
| <div class="group-main"> |
| <div class="group-name">${escapeHtml(g.name)}</div> |
| <div class="group-preview">${g.member_count} membre(s) · ${g.message_count} message(s)</div> |
| </div> |
| <button class="btn btn-ghost btn-sm">Ouvrir</button> |
| </div>`, |
| ) |
| .join('') |
| : `<p class="empty-note">Bac à sable vide. Il est créé au démarrage du conteneur |
| quand <code>SEED_DEMO=1</code>.</p>`; |
|
|
| $('#playground-users').innerHTML = res.users.length |
| ? res.users |
| .map( |
| (u) => `<div class="admin-row"> |
| <div class="avatar sm ${avatarClass(u.phone)}">${escapeHtml(initials(u.display_name))}</div> |
| <div class="group-main"> |
| <div class="group-name">${escapeHtml(u.display_name)} |
| ${u.is_group_admin ? '<span class="tag admin">Admin</span>' : ''}</div> |
| <div class="group-preview">${escapeHtml(u.phone_display)} · ${u.groups.length} groupe(s)</div> |
| </div> |
| </div>`, |
| ) |
| .join('') |
| : '<p class="empty-note">Aucun compte fictif.</p>'; |
|
|
| $('#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)`; |
|
|
| |
| const members = sandbox.users.filter((u) => u.groups.some((g) => g.group_id === groupId)); |
| $('#sandbox-as').innerHTML = |
| `<option value="">Moi (${escapeHtml(state.me.display_name)})</option>` + |
| members |
| .map((u) => `<option value="${escapeHtml(u.phone)}">${escapeHtml(u.display_name)}</option>`) |
| .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) => `<div class="msg theirs"> |
| <div class="msg-avatar avatar ${avatarClass(m.author_phone)}">${escapeHtml( |
| initials(m.author_label), |
| )}</div> |
| <div class="bubble"> |
| <div class="msg-author">${escapeHtml(m.author_label)}</div> |
| <div class="msg-body">${enrich(escapeHtml(m.body))}</div> |
| <div class="msg-time">${fmtTime(m.created_at)}</div> |
| </div> |
| </div>`, |
| ) |
| .join('') |
| : `<div class="empty"><svg><use href="#i-chat"/></svg><h3>Conversation vide</h3> |
| <p>Écrivez un message, en changeant d'auteur pour simuler un échange.</p></div>`; |
| 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(` |
| <h3>${escapeHtml(sandbox.current.name)}</h3> |
| <button class="sheet-action" id="sb-read"> |
| <svg><use href="#i-check"/></svg> Simuler la lecture par tous |
| </button> |
| <button class="sheet-action danger" id="sb-clear"> |
| <svg><use href="#i-trash"/></svg> Vider la conversation |
| </button> |
| `); |
| $('#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) => `<div class="admin-row"> |
| <span class="dot ${u.online ? 'online' : u.status === 'invited' ? 'invited' : u.status === 'blocked' ? 'blocked' : ''}"></span> |
| <div class="avatar sm ${avatarClass(u.phone)}">${escapeHtml(initials(u.display_name))}</div> |
| <div class="group-main"> |
| <div class="group-name">${escapeHtml(u.display_name)} |
| ${u.is_superadmin ? '<span class="tag admin">Super</span>' : ''} |
| ${u.flagged ? '<span class="tag flagged">Signalé</span>' : ''} |
| ${ |
| u.self_registered && !u.group_count |
| ? '<span class="tag muted">à affecter</span>' |
| : '' |
| }</div> |
| <div class="group-preview">${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)}` : ''}</div> |
| </div> |
| <button class="btn btn-ghost btn-sm" data-user="${escapeHtml(u.phone)}">Gérer</button> |
| </div>`, |
| ) |
| .join('') |
| : '<p style="color:var(--text-faint);font-size:14px">Aucun compte.</p>'; |
|
|
| $('#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])); |
| |
| 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(` |
| <h3>${escapeHtml(user.display_name)}</h3> |
| <p style="color:var(--text-faint);font-size:13px;margin:-8px 0 14px"> |
| ${escapeHtml(user.phone_display)} · ${ |
| user.status === 'active' ? 'compte actif' : user.status === 'invited' ? 'en attente d’activation' : 'bloqué' |
| } |
| </p> |
| |
| <div style="font-size:11px;text-transform:uppercase;letter-spacing:.13em;color:var(--cyan-dim);font-weight:700;margin:6px 0 8px"> |
| Groupes actuels (${mine.length}) |
| </div> |
| <div id="us-current" style="margin-bottom:14px"> |
| ${ |
| mine.length |
| ? mine |
| .map( |
| (g) => `<span class="chip ${g.role === 'admin' ? 'admin' : ''}"> |
| ${escapeHtml(g.name)}${g.role === 'admin' ? ' · admin' : ''} |
| <button data-unassign="${g.id}" title="Retirer">×</button> |
| </span>`, |
| ) |
| .join('') |
| : '<span style="color:var(--text-faint);font-size:13px">Aucun groupe.</span>' |
| } |
| </div> |
| |
| <div style="font-size:11px;text-transform:uppercase;letter-spacing:.13em;color:var(--cyan-dim);font-weight:700;margin:6px 0 8px"> |
| Affecter à plusieurs groupes |
| </div> |
| <div class="group-picker" id="us-picker"> |
| ${ |
| ordered.length |
| ? ordered |
| .map( |
| (g) => `<div class="pick-row ${g.parent_id ? 'child' : ''} ${ |
| mineById.has(g.id) ? 'member-of' : '' |
| }" data-gid="${g.id}"> |
| <span class="pick-box"></span> |
| <span style="flex:1;min-width:0">${escapeHtml(g.name)}</span> |
| ${mineById.has(g.id) ? '<span class="tag muted">déjà membre</span>' : ''} |
| </div>`, |
| ) |
| .join('') |
| : '<div class="chart-empty">Aucun groupe à proposer.</div>' |
| } |
| </div> |
| <div class="segmented" id="us-role" style="margin:10px 0"> |
| <button data-role="member" class="active">Membre</button> |
| <button data-role="admin">Administrateur</button> |
| </div> |
| <button class="btn btn-ember btn-sm" id="us-assign" style="width:100%"> |
| Ajouter aux groupes sélectionnés |
| </button> |
| |
| <div style="height:18px"></div> |
| <button class="sheet-action" id="us-super"> |
| <svg><use href="#i-shield"/></svg> |
| ${user.is_superadmin ? 'Retirer le rôle de superadmin' : 'Promouvoir superadmin'} |
| </button> |
| <button class="sheet-action" id="us-reset"> |
| <svg><use href="#i-key"/></svg> Réinitialiser le code PIN |
| </button> |
| <button class="sheet-action" id="us-flag"> |
| <svg><use href="#i-alert"/></svg> |
| ${user.flagged ? 'Lever le signalement' : 'Signaler ce compte'} |
| </button> |
| <button class="sheet-action" id="us-block"> |
| <svg><use href="#i-alert"/></svg> |
| ${user.status === 'blocked' ? 'Débloquer le compte' : 'Bloquer le compte'} |
| </button> |
| <button class="sheet-action danger" id="us-delete"> |
| <svg><use href="#i-trash"/></svg> Supprimer le compte |
| </button> |
| `); |
|
|
| 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'); |
| } |
| }; |
|
|
| |
| $('#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.', |
| ), |
| ); |
|
|
| |
| |
| $('#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]) => `<div class="stat"><div class="v">${v}</div><div class="k">${k}</div></div>`) |
| .join(''); |
|
|
| const st = s.storage; |
| $('#storage-bar').style.width = `${Math.min(st.percent, 100)}%`; |
| $('#storage-text').innerHTML = ` |
| <strong>${st.megabytes} Mo</strong> utilisés sur ${st.hard_limit_mb} Mo (${st.percent} %).<br /> |
| Rétention : les <strong>${st.keep_per_group} derniers messages</strong> par groupe, |
| purgés au-delà de <strong>${st.retention_days} jours</strong>.`; |
| } 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'); |
| } |
| }); |
|
|
| |
| |
| |
| |
| |
|
|
| const tip = document.createElement('div'); |
| tip.id = 'chart-tip'; |
| document.body.appendChild(tip); |
|
|
| function showTip(event, label, value, unit) { |
| tip.innerHTML = `${escapeHtml(label)} · <span class="tv">${value}</span> ${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); |
|
|
| |
| 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` |
| ); |
| } |
|
|
| |
| |
| |
| |
| |
| function barChart(host, data, { barW = 18, gap = 4, height = 120, labelEvery = 1, unit = 'messages' } = {}) { |
| if (!data.length) { |
| host.innerHTML = '<div class="chart-empty">Aucune donnée.</div>'; |
| 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; |
|
|
| |
| const ticks = [0, Math.round(max / 2), max].filter((v, i, a) => a.indexOf(v) === i); |
|
|
| const parts = [`<svg width="${width}" height="${height + padTop + padBottom}" role="img">`]; |
|
|
| for (const t of ticks) { |
| const y = padTop + height - scale(t); |
| parts.push( |
| `<line class="grid-line" x1="${padLeft}" y1="${y}" x2="${width - 4}" y2="${y}" />`, |
| `<text class="axis-label" x="${padLeft - 5}" y="${y + 3}" text-anchor="end">${t}</text>`, |
| ); |
| } |
|
|
| 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(`<path class="bar" d="${barPath(x, y, barW, h, 4)}" />`); |
| } |
| |
| parts.push( |
| `<rect class="bar-hit" x="${x - gap / 2}" y="${padTop}" width="${barW + gap}" height="${height}" ` + |
| `data-label="${escapeHtml(d.label)}" data-value="${d.count}" />`, |
| ); |
| |
| if (i === maxIndex && max > 0) { |
| parts.push( |
| `<text class="value-label" x="${x + barW / 2}" y="${y - 4}" text-anchor="middle">${max}</text>`, |
| ); |
| } |
| if (i % labelEvery === 0) { |
| parts.push( |
| `<text class="axis-label" x="${x + barW / 2}" y="${padTop + height + 12}" text-anchor="middle">${escapeHtml(d.label)}</text>`, |
| ); |
| } |
| }); |
|
|
| parts.push('</svg>'); |
| 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); |
| }); |
| } |
|
|
| |
| function rankList(host, items, { unit = 'messages' } = {}) { |
| if (!items.length) { |
| host.innerHTML = '<div class="chart-empty">Aucune donnée sur la période.</div>'; |
| return; |
| } |
| const max = Math.max(1, ...items.map((i) => i.value)); |
| host.innerHTML = items |
| .map( |
| (i) => `<div class="rank-row ${i.sub ? 'sub' : ''}"> |
| <div class="rank-label">${i.dot ? `<span class="dot ${i.dot}"></span>` : ''}${escapeHtml(i.label)}</div> |
| <div class="rank-value">${i.value} ${escapeHtml(unit)}</div> |
| <div class="rank-track"><div class="rank-fill" style="width:${Math.round((i.value / max) * 100)}%"></div></div> |
| </div>`, |
| ) |
| .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`; |
| } |
|
|
| |
|
|
| 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 = '<div class="chart-empty">Aucun événement.</div>'; |
| 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 `<div class="audit-row ${AUDIT_SEVERITY[e.action] || ''}"> |
| <div class="audit-time">${fmtRelative(e.at)}</div> |
| <div class="audit-main"> |
| <div class="audit-action">${escapeHtml(AUDIT_LABELS[e.action] || e.action)}</div> |
| <div class="audit-detail">${escapeHtml(bits.join(' · '))}</div> |
| </div> |
| </div>`; |
| }) |
| .join(''); |
| } |
|
|
| |
|
|
| 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) => |
| `<div class="kpi ${x.cls || ''}"><div class="v">${escapeHtml(String(x.v))}</div><div class="k">${x.k}</div></div>`, |
| ) |
| .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('<h3>Journal d\'audit</h3><div id="audit-full"></div>'); |
| renderAudit($('#audit-full'), res.entries); |
| } catch (err) { |
| toast(err.message, 'error'); |
| } |
| }); |
|
|
| |
| |
| |
|
|
| const HELP_REASONS = { |
| locked: 'Compte verrouillé', |
| blocked: 'Compte bloqué', |
| forgot_pin: 'Code PIN oublié', |
| other: 'Autre demande', |
| }; |
|
|
| async function loadHelpRequests() { |
| |
| |
| 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 = `<div class="help-card"> |
| <h4><svg style="width:16px;height:16px;stroke:currentColor;fill:none;stroke-width:2"><use href="#i-alert"/></svg> |
| ${res.count} demande${res.count > 1 ? 's' : ''} d'assistance</h4> |
| ${res.requests |
| .map( |
| (r) => `<div class="help-item" data-req="${r.id}"> |
| <div class="help-who">${escapeHtml(r.name)}</div> |
| <div class="help-meta">${escapeHtml(r.phone_display)} · ${escapeHtml( |
| HELP_REASONS[r.reason] || r.reason, |
| )} · ${fmtRelative(r.created_at)}</div> |
| ${r.message ? `<div class="help-msg">« ${escapeHtml(r.message)} »</div>` : ''} |
| <div class="help-actions"> |
| <button class="btn btn-primary btn-sm" data-act="unlock" data-id="${r.id}">Déverrouiller</button> |
| <button class="btn btn-ghost btn-sm" data-act="reset_pin" data-id="${r.id}">Réinit. PIN</button> |
| <button class="btn btn-ghost btn-sm" data-act="dismiss" data-id="${r.id}">Ignorer</button> |
| </div> |
| </div>`, |
| ) |
| .join('')} |
| </div>`; |
|
|
| 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) { |
| |
| host.innerHTML = ''; |
| } |
| } |
|
|
| |
|
|
| $$('[data-open-help]').forEach((b) => |
| b.addEventListener('click', () => { |
| showAuthStep('step-help'); |
| |
| 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; |
| } |
| }); |
|
|
| |
| |
| |
|
|
| if ('serviceWorker' in navigator) { |
| window.addEventListener('load', () => { |
| navigator.serviceWorker.register('/sw.js').catch(() => { |
| |
| }); |
| }); |
| 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) { |
| |
| } |
| } |
| } |
| }); |
|
|
| boot(); |
| })(); |
|
|