function formatDate(dateStr) { const d = new Date(dateStr); return d.toLocaleDateString('fr-FR', { year: 'numeric', month: 'long', day: 'numeric' }); } function showError(message) { document.getElementById('error').innerHTML = `
${escapeHtml(message)}
`; } function statTile(value, label) { return `
${value}
${escapeHtml(label)}
`; } function adminUserRowTemplate(u) { return `
${avatarHtml(u, 'avatar-sm')}
${escapeHtml(u.name)}${u.isAdmin ? ' admin' : ''}
Membre depuis ${formatDate(u.createdAt)} · Dernière connexion ${formatDate(u.lastSeenAt)}
🎵 ${u.songsAdded} 💡 ${u.suggestionsProposed} ✔ ${u.votesCast}
`; } // Shown for a registered feed instead of its full URL — an admin can already // see the real value via the "add calendar" flow when they set it, but the // list view doesn't need to keep re-displaying the plaintext on every load. function maskIcsUrl(url) { try { const u = new URL(url); return `${u.hostname}/••••`; } catch { return '••••'; } } function feedRowTemplate(feed) { return `
${feed.label ? `${escapeHtml(feed.label)} — ` : ''}${escapeHtml(maskIcsUrl(feed.icsUrl))}
`; } // Discord webhook URLs carry a secret token — mask it in the list view like // maskIcsUrl already does for ICS feed URLs. function maskDiscordUrl(url) { try { const u = new URL(url); const parts = u.pathname.split('/').filter(Boolean); const webhookId = parts[parts.length - 2]; return `${u.hostname}/…/${webhookId}/••••`; } catch { return '••••'; } } function discordWebhookRowTemplate(hook) { return `
${hook.label ? `${escapeHtml(hook.label)} — ` : ''}${escapeHtml(maskDiscordUrl(hook.url))}
`; } async function loadDiscordWebhooks() { const webhooks = await api.get('/api/admin/discord-webhooks'); const container = document.getElementById('discord-webhooks-list'); container.innerHTML = webhooks.length ? webhooks.map(discordWebhookRowTemplate).join('') : '

Aucun webhook configuré.

'; container.querySelectorAll('.remove-discord-webhook-btn').forEach((btn) => { btn.addEventListener('click', () => onRemoveDiscordWebhook(parseInt(btn.dataset.webhookId, 10))); }); } async function onAddDiscordWebhook(e) { e.preventDefault(); const form = e.target; const label = form.label.value.trim(); const url = form.url.value.trim(); const submitBtn = form.querySelector('button[type="submit"]'); submitBtn.disabled = true; submitBtn.textContent = 'Vérification…'; try { await api.post('/api/admin/discord-webhooks', { label, url }); form.reset(); await loadDiscordWebhooks(); } catch (err) { showError(err.message); } finally { submitBtn.disabled = false; submitBtn.textContent = '+ Ajouter'; } } async function onRemoveDiscordWebhook(id) { try { await api.del(`/api/admin/discord-webhooks/${id}`); await loadDiscordWebhooks(); } catch (err) { showError(err.message); } } function calendarUserRowTemplate(user) { return `
${avatarHtml(user, 'avatar-sm')}
${escapeHtml(user.name)}${user.isAdmin ? ' admin' : ''}
${user.feeds.length ? user.feeds.map(feedRowTemplate).join('') : '

Aucun calendrier configuré.

'}
`; } async function loadCalendarUsers() { const users = await api.get('/api/calendar/people/admin'); const container = document.getElementById('calendar-people-list'); container.innerHTML = users.length ? users.map(calendarUserRowTemplate).join('') : '

Aucun utilisateur pour le moment.

'; container.querySelectorAll('.add-feed-form').forEach((form) => { form.addEventListener('submit', (e) => onAddFeed(e, parseInt(form.dataset.userId, 10))); }); container.querySelectorAll('.remove-feed-btn').forEach((btn) => { btn.addEventListener('click', () => onRemoveFeed(parseInt(btn.dataset.feedId, 10))); }); } async function onAddFeed(e, userId) { e.preventDefault(); const form = e.target; const label = form.label.value.trim(); const icsUrl = form.icsUrl.value.trim(); const submitBtn = form.querySelector('button[type="submit"]'); submitBtn.disabled = true; submitBtn.textContent = 'Vérification…'; try { await api.post(`/api/calendar/people/${userId}/feeds`, { label, icsUrl }); await loadCalendarUsers(); } catch (err) { showError(err.message); submitBtn.disabled = false; submitBtn.textContent = '+ Ajouter'; } } async function onRemoveFeed(feedId) { try { await api.del(`/api/calendar/feeds/${feedId}`); await loadCalendarUsers(); } catch (err) { showError(err.message); } } async function loadSlotSettingsForm() { const settings = await api.get('/api/calendar/settings'); const form = document.getElementById('slot-settings-form'); form.weekdayStart.value = settings.weekdayStart; form.weekdayEnd.value = settings.weekdayEnd; form.weekendStart.value = settings.weekendStart; form.weekendEnd.value = settings.weekendEnd; form.marginMinutes.value = settings.marginMinutes; form.concertStart.value = settings.concertStart; form.concertEnd.value = settings.concertEnd; } async function onSaveSlotSettings(e) { e.preventDefault(); const form = e.target; const statusEl = document.getElementById('slot-settings-status'); statusEl.textContent = ''; try { await api.patch('/api/calendar/settings', { weekdayStart: form.weekdayStart.value, weekdayEnd: form.weekdayEnd.value, weekendStart: form.weekendStart.value, weekendEnd: form.weekendEnd.value, marginMinutes: parseInt(form.marginMinutes.value, 10), concertStart: form.concertStart.value, concertEnd: form.concertEnd.value, }); statusEl.textContent = 'Horaires enregistrés.'; } catch (err) { showError(err.message); } } (async function init() { const me = await initNav('admin'); if (!me.isAdmin) { window.location.href = '/index.html'; return; } try { const stats = await api.get('/api/admin/stats'); const container = document.getElementById('content'); container.innerHTML = `

Administration

Vue d'ensemble de l'activité et des données de l'application.

Vue d'ensemble

${statTile(stats.userCount, 'Utilisateurs')} ${statTile(stats.songs.totalSongs, 'Morceaux au répertoire')} ${statTile(stats.songs.distinctArtists, 'Artistes différents')} ${statTile(stats.songs.tutorialsAdded, 'Tutoriels ajoutés')} ${statTile(stats.suggestions.pending, 'Suggestions en attente')} ${statTile(stats.suggestions.approved, 'Suggestions approuvées')} ${statTile(stats.suggestions.totalVotes, 'Votes exprimés')} ${statTile(stats.concerts.pastConcerts, 'Concerts passés')} ${statTile(stats.concerts.upcomingConcerts, 'Concerts à venir')} ${statTile(stats.concerts.avgSongsPerConcert, 'Morceaux / concert (moy.)')}

Activité des utilisateurs

La date de dernière connexion est approximative (dernière synchronisation du profil, pas un journal de connexion précis).

${stats.users.map(adminUserRowTemplate).join('')}

Créneaux de répétition

Horaires utilisés pour calculer les disponibilités sur /calendar.html.

La marge élargit uniquement la vérification de disponibilité (avant/après le créneau), pour tenir compte du temps de trajet entre deux évènements — le créneau affiché ne change pas.

Comme la date d'un concert n'a pas d'heure enregistrée, ces horaires sont utilisés pour pré-remplir les liens « ajouter à mon agenda » sur /calendar.html et /concerts.html.

Calendriers des membres

Chaque utilisateur de l'application peut avoir plusieurs calendriers (Google, Outlook, Apple...). Seuls les utilisateurs avec au moins un calendrier configuré apparaissent sur /calendar.html. L'application ne conserve jamais le contenu de ces calendriers — seul un statut disponible/occupé par créneau est déduit et enregistré. Comment trouver le lien ICS d'un utilisateur ?

Chargement…

Notifications Discord

Chaque webhook reçoit un message quand une répétition est proposée, retirée, acceptée ou refusée. Créer un webhook : sur Discord, Paramètres du salon → Intégrations → Webhooks → Nouveau webhook → Copier l'URL.

Chargement…

`; document.getElementById('slot-settings-form').addEventListener('submit', onSaveSlotSettings); document.getElementById('add-discord-webhook-form').addEventListener('submit', onAddDiscordWebhook); await loadSlotSettingsForm(); await loadCalendarUsers(); await loadDiscordWebhooks(); } catch (err) { showError(err.message); } })();