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; form.rehearsalConfirmThreshold.value = settings.rehearsalConfirmThreshold; } 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, rehearsalConfirmThreshold: parseInt(form.rehearsalConfirmThreshold.value, 10), }); 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.

Une répétition proposée reste une « suggestion » tant que ce nombre de votes « accepter » n'est pas atteint ; elle passe ensuite en répétition confirmée (dans l'application et dans le flux ICS).

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); } })();