Spaces:
Running
Running
| // admin.js | |
| // Logique d'administration pour la gestion des messages (ES Module) - AVEC SUPPORT RÔLES | |
| /** | |
| * Utilitaire de sélection raccourci | |
| * @param {string} selector | |
| * @returns {Element|null} | |
| */ | |
| function $(selector) { | |
| return document.querySelector(selector); | |
| } | |
| /** | |
| * Affiche une notification toast | |
| * @param {string} message | |
| * @param {'success'|'error'} type | |
| */ | |
| function showToast(message, type = 'success') { | |
| const toastEl = $('#toast'); | |
| if (!toastEl) return; | |
| toastEl.textContent = message; | |
| toastEl.className = `toast ${type} show`; | |
| setTimeout(() => { | |
| toastEl.classList.remove('show'); | |
| }, 3000); | |
| } | |
| /** | |
| * Échappe le HTML pour prévenir les XSS | |
| * @param {string} unsafe | |
| * @returns {string} | |
| */ | |
| function escapeHtml(unsafe) { | |
| return String(unsafe).replace(/[&<>"']/g, (c) => ({ | |
| '&': '&', | |
| '<': '<', | |
| '>': '>', | |
| '"': '"', | |
| "'": ''' | |
| }[c])); | |
| } | |
| /** | |
| * Formate un timestamp Unix (secondes) en date locale FR | |
| * @param {number} timestamp | |
| * @returns {string} | |
| */ | |
| function formatDate(timestamp) { | |
| return new Date(timestamp * 1000).toLocaleString('fr-FR'); | |
| } | |
| // --- Logique Principale --- | |
| const SLUG = new URLSearchParams(window.location.search).get('link'); | |
| const container = $('#messagesContainer'); | |
| const slugTitle = $('#slugTitle'); | |
| // Vérification immédiate de l'ID lien | |
| if (!SLUG) { | |
| document.body.innerHTML = '<div style="padding:2rem; text-align:center; color:var(--danger)">ID lien manquant (?link=ID)</div>'; | |
| throw new Error('No Link ID provided in URL'); | |
| } | |
| /** | |
| * Charge les messages via l'API | |
| */ | |
| async function loadMessages() { | |
| try { | |
| const res = await fetch(`/api/admin/links/${SLUG}/messages`, { | |
| credentials: 'include' | |
| }); | |
| if (!res.ok) { | |
| if (res.status === 401 || res.status === 403) { | |
| container.innerHTML = `<div class="empty" style="color:var(--danger)">🚫 Accès refusé : Vous n'êtes pas collaborateur sur ce lien.</div>`; | |
| throw new Error('Non autorisé (propriétaire/collaborateur uniquement)'); | |
| } | |
| throw new Error(`Erreur serveur (${res.status})`); | |
| } | |
| // Nouveau format : { messages: [...], myRole: 'owner'|'moderator'|'viewer' } | |
| const data = await res.json(); | |
| const messages = data.messages || []; | |
| const myRole = data.myRole || 'viewer'; | |
| // Mise à jour du titre avec rôle | |
| slugTitle.textContent = messages.length | |
| ? `ID ${SLUG} (${messages.length} messages) • Rôle: ${myRole}` | |
| : `ID ${SLUG} • Rôle: ${myRole}`; | |
| renderMessages(messages, myRole); | |
| } catch (error) { | |
| console.error('Load error:', error); | |
| if (!container.innerHTML.includes('Accès refusé')) { | |
| container.innerHTML = `<div class="empty" style="color:var(--danger)">${escapeHtml(error.message)}</div>`; | |
| } | |
| } | |
| } | |
| /** | |
| * Rend la liste des messages dans le DOM | |
| * @param {Array<Object>} messages | |
| * @param {'owner'|'moderator'|'viewer'} myRole | |
| */ | |
| function renderMessages(messages, myRole) { | |
| const canBan = ['owner', 'moderator'].includes(myRole); | |
| if (!messages.length) { | |
| container.innerHTML = '<div class="empty">Aucun message.</div>'; | |
| return; | |
| } | |
| container.innerHTML = messages.map(msg => ` | |
| <div class="msg-row" data-id="${escapeHtml(msg.id)}" data-ip="${escapeHtml(msg.ip_hash)}"> | |
| <div> | |
| <div class="msg-content"> | |
| ${msg.content ? escapeHtml(msg.content) : '<em style="color:var(--muted)">[Image seulement]</em>'} | |
| </div> | |
| ${msg.images?.length ? ` | |
| <div class="msg-images"> | |
| ${msg.images.map(img => | |
| `<img src="${escapeHtml(img.url)}" alt="${escapeHtml(img.filename)}" loading="lazy">` | |
| ).join('')} | |
| </div> | |
| ` : ''} | |
| </div> | |
| <div class="msg-meta"> | |
| <span>🕒 ${formatDate(msg.created_at)}</span> | |
| <span>🔒 IP: <code>${escapeHtml(msg.ip_hash.slice(0, 12))}...</code></span> | |
| </div> | |
| <div class="msg-actions"> | |
| ${canBan ? ` | |
| <button | |
| class="btn btn-danger" | |
| data-action="ban" | |
| ${msg.banned ? 'disabled' : ''} | |
| > | |
| ${msg.banned ? '✅ Banni' : '🚫 Bannir IP'} | |
| </button> | |
| ` : ''} | |
| <button | |
| class="btn btn-secondary" | |
| data-action="copyip" | |
| title="Copier hash IP" | |
| > | |
| ${escapeHtml(msg.ip_hash.slice(0, 8))} | |
| </button> | |
| </div> | |
| </div> | |
| `).join(''); | |
| attachEventListeners(); | |
| } | |
| /** | |
| * Attache les écouteurs d'événements sur les boutons générés | |
| */ | |
| function attachEventListeners() { | |
| // Boutons Ban (seulement s'ils existent dans le DOM grâce au canBan) | |
| container.querySelectorAll('[data-action="ban"]').forEach(btn => { | |
| btn.onclick = async (e) => { | |
| const row = e.target.closest('.msg-row'); | |
| const ipHash = row.dataset.ip; | |
| if (!confirm(`Bannir cet IP (${ipHash.slice(0,12)}...) de ce lien ?`)) return; | |
| try { | |
| const res = await fetch(`/api/admin/links/${SLUG}/ban`, { | |
| method: 'POST', | |
| headers: { 'Content-Type': 'application/json' }, | |
| credentials: 'include', | |
| body: JSON.stringify({ ip_hash: ipHash }) | |
| }); | |
| if (!res.ok) { | |
| const errData = await res.json().catch(() => ({ error: 'Erreur inconnue' })); | |
| throw new Error(errData.error || 'Échec du bannissement'); | |
| } | |
| showToast('IP bannie avec succès'); | |
| // Mise à jour UI optimiste | |
| e.target.textContent = '✅ Banni'; | |
| e.target.classList.add('banned'); | |
| e.target.disabled = true; | |
| } catch (error) { | |
| console.error('Ban error:', error); | |
| showToast(error.message, 'error'); | |
| } | |
| }; | |
| }); | |
| // Boutons Copy IP | |
| container.querySelectorAll('[data-action="copyip"]').forEach(btn => { | |
| btn.onclick = () => { | |
| const text = btn.textContent.trim(); | |
| navigator.clipboard.writeText(text).then(() => { | |
| showToast('Hash IP copié dans le presse-papiers'); | |
| }).catch(() => { | |
| showToast('Échec de la copie', 'error'); | |
| }); | |
| }; | |
| }); | |
| } | |
| // Initialisation | |
| loadMessages(); |