mirror of
https://github.com/nfonteyne/octane-website.git
synced 2026-09-03 23:24:48 +02:00
update: dashboard page admin
This commit is contained in:
parent
a1ec2263a5
commit
1f9c290010
11 changed files with 248 additions and 2 deletions
24
public/admin.html
Normal file
24
public/admin.html
Normal file
|
|
@ -0,0 +1,24 @@
|
||||||
|
<!doctype html>
|
||||||
|
<html lang="fr">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
|
<title>Administration — Octane</title>
|
||||||
|
<script src="/js/theme-init.js"></script>
|
||||||
|
<link rel="stylesheet" href="/css/style.css">
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<header class="app-header">
|
||||||
|
<nav id="main-nav"></nav>
|
||||||
|
</header>
|
||||||
|
<main>
|
||||||
|
<div id="error"></div>
|
||||||
|
<div id="content">Chargement…</div>
|
||||||
|
</main>
|
||||||
|
|
||||||
|
<script src="/js/api.js"></script>
|
||||||
|
<script src="/js/render.js"></script>
|
||||||
|
<script src="/js/nav.js"></script>
|
||||||
|
<script src="/js/admin.js"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
|
@ -1294,3 +1294,32 @@ a.back-link:hover { color: var(--accent); }
|
||||||
}
|
}
|
||||||
|
|
||||||
.vote-review-detail { margin-top: 0.75rem; }
|
.vote-review-detail { margin-top: 0.75rem; }
|
||||||
|
|
||||||
|
/* ---------- Admin dashboard ---------- */
|
||||||
|
|
||||||
|
.admin-user-list { display: flex; flex-direction: column; gap: 0.6rem; }
|
||||||
|
|
||||||
|
.admin-user-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 0.75rem;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
padding: 0.6rem 0.85rem;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-user-identity {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-user-counts {
|
||||||
|
display: flex;
|
||||||
|
gap: 0.9rem;
|
||||||
|
color: var(--muted);
|
||||||
|
font-size: 0.88rem;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
|
||||||
75
public/js/admin.js
Normal file
75
public/js/admin.js
Normal file
|
|
@ -0,0 +1,75 @@
|
||||||
|
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 = `<div class="error-banner">${escapeHtml(message)}</div>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function statTile(value, label) {
|
||||||
|
return `
|
||||||
|
<div class="stat-tile">
|
||||||
|
<div class="stat-value">${value}</div>
|
||||||
|
<div class="stat-label">${escapeHtml(label)}</div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function adminUserRowTemplate(u) {
|
||||||
|
return `
|
||||||
|
<div class="admin-user-row">
|
||||||
|
<div class="admin-user-identity">
|
||||||
|
${avatarHtml(u, 'avatar-sm')}
|
||||||
|
<div>
|
||||||
|
<div class="card-title">${escapeHtml(u.name)}${u.isAdmin ? ' <span class="badge">admin</span>' : ''}</div>
|
||||||
|
<div class="card-subtitle">Membre depuis ${formatDate(u.createdAt)} · Dernière connexion ${formatDate(u.lastSeenAt)}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="admin-user-counts">
|
||||||
|
<span title="Morceaux ajoutés">🎵 ${u.songsAdded}</span>
|
||||||
|
<span title="Suggestions proposées">💡 ${u.suggestionsProposed}</span>
|
||||||
|
<span title="Votes exprimés">✔ ${u.votesCast}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
(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 = `
|
||||||
|
<h1>Administration</h1>
|
||||||
|
<p class="page-subtitle">Vue d'ensemble de l'activité et des données de l'application.</p>
|
||||||
|
|
||||||
|
<h2>Vue d'ensemble</h2>
|
||||||
|
<div class="stat-grid">
|
||||||
|
${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.)')}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h2>Activité des utilisateurs</h2>
|
||||||
|
<p class="note">La date de dernière connexion est approximative (dernière synchronisation du profil, pas un journal de connexion précis).</p>
|
||||||
|
<div class="panel admin-user-list">
|
||||||
|
${stats.users.map(adminUserRowTemplate).join('')}
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
} catch (err) {
|
||||||
|
showError(err.message);
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
|
@ -31,6 +31,7 @@ async function initNav(activePage) {
|
||||||
<a href="/setlist.html" class="${activePage === 'setlist' ? 'active' : ''}">Prochain concert</a>
|
<a href="/setlist.html" class="${activePage === 'setlist' ? 'active' : ''}">Prochain concert</a>
|
||||||
<a href="/calendar.html" class="${activePage === 'calendar' ? 'active' : ''}">Disponibilités</a>
|
<a href="/calendar.html" class="${activePage === 'calendar' ? 'active' : ''}">Disponibilités</a>
|
||||||
<a href="/history.html" class="${activePage === 'history' ? 'active' : ''}">Historique</a>
|
<a href="/history.html" class="${activePage === 'history' ? 'active' : ''}">Historique</a>
|
||||||
|
${me.isAdmin ? `<a href="/admin.html" class="${activePage === 'admin' ? 'active' : ''}">Administration</a>` : ''}
|
||||||
</div>
|
</div>
|
||||||
<div class="nav-user">
|
<div class="nav-user">
|
||||||
${me.authentikAccountUrl ? `<a href="${escapeHtml(me.authentikAccountUrl)}" target="_blank" rel="noopener" title="Gérer mon compte Authentik (mot de passe, etc.)">Mon compte</a>` : ''}
|
${me.authentikAccountUrl ? `<a href="${escapeHtml(me.authentikAccountUrl)}" target="_blank" rel="noopener" title="Gérer mon compte Authentik (mot de passe, etc.)">Mon compte</a>` : ''}
|
||||||
|
|
|
||||||
|
|
@ -138,7 +138,6 @@ router.get(
|
||||||
try {
|
try {
|
||||||
const userinfo = await client.fetchUserInfo(oidcConfig, tokens.access_token, claims.sub);
|
const userinfo = await client.fetchUserInfo(oidcConfig, tokens.access_token, claims.sub);
|
||||||
picture = userinfo.picture || picture;
|
picture = userinfo.picture || picture;
|
||||||
console.log('[auth] userinfo claims received:', Object.keys(userinfo), '— picture:', userinfo.picture || '(none)');
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.warn('[auth] failed to fetch userinfo for avatar:', err.message);
|
console.warn('[auth] failed to fetch userinfo for avatar:', err.message);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -130,6 +130,26 @@ async function removeSong(setlistId, setlistSongId) {
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function getStats() {
|
||||||
|
const { rows } = await pool.query(`
|
||||||
|
SELECT
|
||||||
|
COUNT(*) FILTER (WHERE concert_date < CURRENT_DATE)::int AS past_concerts,
|
||||||
|
COUNT(*) FILTER (WHERE concert_date >= CURRENT_DATE)::int AS upcoming_concerts,
|
||||||
|
COALESCE((
|
||||||
|
SELECT AVG(song_count)
|
||||||
|
FROM (
|
||||||
|
SELECT sl.id, COUNT(ss.id) AS song_count
|
||||||
|
FROM setlists sl
|
||||||
|
LEFT JOIN setlist_songs ss ON ss.setlist_id = sl.id
|
||||||
|
WHERE sl.concert_date < CURRENT_DATE
|
||||||
|
GROUP BY sl.id
|
||||||
|
) t
|
||||||
|
), 0)::numeric(10,1) AS avg_songs_per_concert
|
||||||
|
FROM setlists
|
||||||
|
`);
|
||||||
|
return rows[0];
|
||||||
|
}
|
||||||
|
|
||||||
module.exports = {
|
module.exports = {
|
||||||
findNext,
|
findNext,
|
||||||
findHistory,
|
findHistory,
|
||||||
|
|
@ -142,4 +162,5 @@ module.exports = {
|
||||||
replaceSongs,
|
replaceSongs,
|
||||||
addSong,
|
addSong,
|
||||||
removeSong,
|
removeSong,
|
||||||
|
getStats,
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -75,6 +75,16 @@ async function removeTutorial(songId, tutorialId) {
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function getStats() {
|
||||||
|
const { rows } = await pool.query(`
|
||||||
|
SELECT COUNT(*)::int AS total_songs,
|
||||||
|
COUNT(DISTINCT NULLIF(artist, ''))::int AS distinct_artists,
|
||||||
|
(SELECT COUNT(*)::int FROM song_tutorials) AS tutorials_added
|
||||||
|
FROM songs
|
||||||
|
`);
|
||||||
|
return rows[0];
|
||||||
|
}
|
||||||
|
|
||||||
module.exports = {
|
module.exports = {
|
||||||
findAll,
|
findAll,
|
||||||
findById,
|
findById,
|
||||||
|
|
@ -84,4 +94,5 @@ module.exports = {
|
||||||
findTutorials,
|
findTutorials,
|
||||||
addTutorial,
|
addTutorial,
|
||||||
removeTutorial,
|
removeTutorial,
|
||||||
|
getStats,
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -84,6 +84,18 @@ async function removeVote(suggestionId, userId) {
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function getStats() {
|
||||||
|
const { rows } = await pool.query(`
|
||||||
|
SELECT COUNT(*)::int AS total,
|
||||||
|
COUNT(*) FILTER (WHERE status = 'pending')::int AS pending,
|
||||||
|
COUNT(*) FILTER (WHERE status = 'approved')::int AS approved,
|
||||||
|
COUNT(*) FILTER (WHERE status = 'rejected')::int AS rejected,
|
||||||
|
(SELECT COUNT(*)::int FROM suggestion_votes) AS total_votes
|
||||||
|
FROM suggestions
|
||||||
|
`);
|
||||||
|
return rows[0];
|
||||||
|
}
|
||||||
|
|
||||||
async function promoteToSong(suggestionId, addedBy) {
|
async function promoteToSong(suggestionId, addedBy) {
|
||||||
const client = await pool.connect();
|
const client = await pool.connect();
|
||||||
try {
|
try {
|
||||||
|
|
@ -134,4 +146,5 @@ module.exports = {
|
||||||
upsertVote,
|
upsertVote,
|
||||||
removeVote,
|
removeVote,
|
||||||
promoteToSong,
|
promoteToSong,
|
||||||
|
getStats,
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -32,4 +32,19 @@ async function getActivityStats(userId) {
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
module.exports = { findById, upsertFromClaims, getActivityStats };
|
async function findAllWithActivity() {
|
||||||
|
const { rows } = await pool.query(`
|
||||||
|
SELECT u.id, u.name, u.avatar_url, u.is_admin, u.created_at, u.updated_at,
|
||||||
|
COALESCE(s.count, 0) AS songs_added,
|
||||||
|
COALESCE(sg.count, 0) AS suggestions_proposed,
|
||||||
|
COALESCE(v.count, 0) AS votes_cast
|
||||||
|
FROM users u
|
||||||
|
LEFT JOIN (SELECT added_by, COUNT(*)::int AS count FROM songs GROUP BY added_by) s ON s.added_by = u.id
|
||||||
|
LEFT JOIN (SELECT suggested_by, COUNT(*)::int AS count FROM suggestions GROUP BY suggested_by) sg ON sg.suggested_by = u.id
|
||||||
|
LEFT JOIN (SELECT user_id, COUNT(*)::int AS count FROM suggestion_votes GROUP BY user_id) v ON v.user_id = u.id
|
||||||
|
ORDER BY (COALESCE(s.count, 0) + COALESCE(sg.count, 0) + COALESCE(v.count, 0)) DESC, u.name
|
||||||
|
`);
|
||||||
|
return rows;
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = { findById, upsertFromClaims, getActivityStats, findAllWithActivity };
|
||||||
|
|
|
||||||
56
src/routes/admin.js
Normal file
56
src/routes/admin.js
Normal file
|
|
@ -0,0 +1,56 @@
|
||||||
|
const express = require('express');
|
||||||
|
const usersRepo = require('../repositories/usersRepo');
|
||||||
|
const songsRepo = require('../repositories/songsRepo');
|
||||||
|
const suggestionsRepo = require('../repositories/suggestionsRepo');
|
||||||
|
const setlistsRepo = require('../repositories/setlistsRepo');
|
||||||
|
const { requireAdmin } = require('../auth/middleware');
|
||||||
|
const asyncHandler = require('../lib/asyncHandler');
|
||||||
|
|
||||||
|
const router = express.Router();
|
||||||
|
|
||||||
|
router.get(
|
||||||
|
'/stats',
|
||||||
|
requireAdmin,
|
||||||
|
asyncHandler(async (req, res) => {
|
||||||
|
const [users, songs, suggestions, concerts] = await Promise.all([
|
||||||
|
usersRepo.findAllWithActivity(),
|
||||||
|
songsRepo.getStats(),
|
||||||
|
suggestionsRepo.getStats(),
|
||||||
|
setlistsRepo.getStats(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
userCount: users.length,
|
||||||
|
users: users.map((u) => ({
|
||||||
|
id: u.id,
|
||||||
|
name: u.name,
|
||||||
|
avatarUrl: u.avatar_url,
|
||||||
|
isAdmin: u.is_admin,
|
||||||
|
createdAt: u.created_at,
|
||||||
|
lastSeenAt: u.updated_at,
|
||||||
|
songsAdded: u.songs_added,
|
||||||
|
suggestionsProposed: u.suggestions_proposed,
|
||||||
|
votesCast: u.votes_cast,
|
||||||
|
})),
|
||||||
|
songs: {
|
||||||
|
totalSongs: songs.total_songs,
|
||||||
|
distinctArtists: songs.distinct_artists,
|
||||||
|
tutorialsAdded: songs.tutorials_added,
|
||||||
|
},
|
||||||
|
suggestions: {
|
||||||
|
total: suggestions.total,
|
||||||
|
pending: suggestions.pending,
|
||||||
|
approved: suggestions.approved,
|
||||||
|
rejected: suggestions.rejected,
|
||||||
|
totalVotes: suggestions.total_votes,
|
||||||
|
},
|
||||||
|
concerts: {
|
||||||
|
pastConcerts: concerts.past_concerts,
|
||||||
|
upcomingConcerts: concerts.upcoming_concerts,
|
||||||
|
avgSongsPerConcert: concerts.avg_songs_per_concert,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
module.exports = router;
|
||||||
|
|
@ -6,6 +6,7 @@ const suggestionsRoutes = require('./suggestions');
|
||||||
const setlistsRoutes = require('./setlists');
|
const setlistsRoutes = require('./setlists');
|
||||||
const musicSearchRoutes = require('./musicSearch');
|
const musicSearchRoutes = require('./musicSearch');
|
||||||
const calendarRoutes = require('./calendar');
|
const calendarRoutes = require('./calendar');
|
||||||
|
const adminRoutes = require('./admin');
|
||||||
|
|
||||||
const router = express.Router();
|
const router = express.Router();
|
||||||
|
|
||||||
|
|
@ -16,5 +17,6 @@ router.use('/suggestions', suggestionsRoutes);
|
||||||
router.use('/setlists', setlistsRoutes);
|
router.use('/setlists', setlistsRoutes);
|
||||||
router.use('/music-search', musicSearchRoutes);
|
router.use('/music-search', musicSearchRoutes);
|
||||||
router.use('/calendar', calendarRoutes);
|
router.use('/calendar', calendarRoutes);
|
||||||
|
router.use('/admin', adminRoutes);
|
||||||
|
|
||||||
module.exports = router;
|
module.exports = router;
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue