update: user can add musics, concerts and all

This commit is contained in:
Nathan FONTEYNE 2026-07-08 13:30:14 +02:00
parent 0683108c8e
commit 692b19a846
7 changed files with 92 additions and 42 deletions

View file

@ -220,19 +220,19 @@ erDiagram
| Page | Accès | Description | | Page | Accès | Description |
|---|---|---| |---|---|---|
| `/index.html` | Tous (lecture), admin (écriture) | Répertoire des morceaux travaillés, liens de tutos par morceau et par instrument | | `/index.html` | Tous (lecture et écriture) | Répertoire des morceaux travaillés, liens/vignettes YouTube et Spotify, tutos embarqués par morceau et par instrument |
| `/suggestions.html` | Tous | Proposer un morceau (avec lien YouTube embarqué), voter approuver/rejeter avec commentaire, attribué nominativement | | `/suggestions.html` | Tous | Proposer un morceau (avec lien YouTube embarqué + note libre), voter approuver/rejeter avec commentaire, attribué nominativement |
| `/setlist.html` | Tous (lecture), admin (écriture) | Setlist du prochain concert : ordre des morceaux, notes, section rappel | | `/setlist.html` | Tous (lecture et écriture) | Setlist du prochain concert : choix des morceaux du répertoire, ordre, notes, section rappel |
| `/history.html`, `/history-detail.html` | Tous (lecture seule) | Historique des setlists des concerts passés | | `/history.html`, `/history-detail.html` | Tous (lecture seule) | Historique des setlists des concerts passés |
Le mode par défaut est la consultation ; seule la page **Suggestions** est interactive (chaque vote est attribué à la personne connectée). Le mode par défaut est la consultation ; les pages Répertoire, Setlist et Suggestions sont interactives pour toute personne connectée (chaque action reste attribuée nominativement via Authentik).
## Rôles ## Rôles
- **Membre** : consulte tout, propose des suggestions, vote/commente. - **Membre** : tout le monde — consulte, ajoute/modifie/supprime des morceaux du répertoire et leurs tutos, crée/modifie des concerts et leur setlist, propose des suggestions, vote/commente.
- **Admin** : en plus, gère le répertoire, les tutos, promeut une suggestion approuvée en morceau du répertoire, crée/édite les setlists. - **Admin** : en plus, modère les suggestions (promouvoir une suggestion approuvée en morceau du répertoire, la rejeter, la supprimer).
Le rôle admin est déterminé par un claim `groups` renvoyé par Authentik (voir configuration ci-dessous), recalculé à chaque connexion — Authentik reste la seule source de vérité des rôles. Le rôle admin n'est volontairement pas plus étendu pour l'instant : son périmètre exact (au-delà de la modération des suggestions) reste ouvert et pourra évoluer. Il n'y a pas de gestion des utilisateurs dans l'application elle-même — Authentik reste la seule source de vérité pour qui a accès et qui est admin (claim `groups`, recalculé à chaque connexion).
## Prérequis ## Prérequis

View file

@ -281,6 +281,37 @@ h3 { font-size: 1rem; margin: 0 0 0.5rem; }
.song-thumb:hover a.play-overlay { opacity: 1; } .song-thumb:hover a.play-overlay { opacity: 1; }
/* ---------- Tutorial cards (embedded links per instrument) ---------- */
.tutorial-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(150px, 1fr));
gap: 0.7rem;
margin-top: 0.6rem;
}
.tutorial-card {
display: flex;
flex-direction: column;
gap: 0.35rem;
}
.tutorial-thumb {
width: 100%;
height: 84px;
}
.tutorial-meta { font-size: 0.8rem; }
.tutorial-label {
margin-top: 0.2rem;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.tutorial-label a { color: var(--accent); text-decoration: none; }
.song-body { .song-body {
flex: 1 1 auto; flex: 1 1 auto;
min-width: 0; min-width: 0;

View file

@ -15,7 +15,7 @@
<p class="page-subtitle">Les morceaux travaillés par le groupe, avec liens d'écoute et tutos par instrument.</p> <p class="page-subtitle">Les morceaux travaillés par le groupe, avec liens d'écoute et tutos par instrument.</p>
<div id="error"></div> <div id="error"></div>
<div id="admin-add-song" class="panel" style="display:none"> <div class="panel">
<h2 style="margin-top:0">Ajouter un morceau</h2> <h2 style="margin-top:0">Ajouter un morceau</h2>
<form id="add-song-form" class="stacked-form"> <form id="add-song-form" class="stacked-form">
<label>Titre <input name="title" required></label> <label>Titre <input name="title" required></label>

View file

@ -62,13 +62,12 @@ function songCardTemplate(song) {
<button class="secondary icon-btn toggle-tutorials" data-id="${song.id}"> <button class="secondary icon-btn toggle-tutorials" data-id="${song.id}">
${isOpen ? 'Masquer les tutos' : `Tutos (${song.tutorial_count})`} ${isOpen ? 'Masquer les tutos' : `Tutos (${song.tutorial_count})`}
</button> </button>
${me && me.isAdmin ? `<button class="secondary icon-btn edit-song" data-id="${song.id}">Modifier</button>` : ''} <button class="secondary icon-btn edit-song" data-id="${song.id}">Modifier</button>
</div> </div>
</div> </div>
</div> </div>
<div class="tutorials-panel panel" style="${isOpen ? '' : 'display:none'}"> <div class="tutorials-panel panel" style="${isOpen ? '' : 'display:none'}">
<div class="tag-list" data-tutorials-for="${song.id}"><p class="empty">Chargement</p></div> <div class="tutorial-grid" data-tutorials-for="${song.id}"><p class="empty">Chargement</p></div>
${me && me.isAdmin ? `
<form class="inline-form add-tutorial-form" data-song-id="${song.id}"> <form class="inline-form add-tutorial-form" data-song-id="${song.id}">
<label>Instrument <label>Instrument
<select name="instrumentId" required>${instrumentOptions()}</select> <select name="instrumentId" required>${instrumentOptions()}</select>
@ -76,7 +75,7 @@ function songCardTemplate(song) {
<label>Lien <input name="url" type="url" required placeholder="https://..."></label> <label>Lien <input name="url" type="url" required placeholder="https://..."></label>
<label>Libellé <input name="label" placeholder="ex: tuto solo"></label> <label>Libellé <input name="label" placeholder="ex: tuto solo"></label>
<button type="submit">Ajouter le tuto</button> <button type="submit">Ajouter le tuto</button>
</form>` : ''} </form>
</div> </div>
</div> </div>
`; `;
@ -101,6 +100,35 @@ function editSongCardTemplate(song) {
`; `;
} }
function tutorialCardTemplate(t) {
const thumb = youtubeThumbnailUrl(t.url);
if (thumb) {
return `
<div class="tutorial-card" data-tutorial-id="${t.id}">
<div class="song-thumb tutorial-thumb">
<img src="${thumb}" alt="${escapeHtml(t.label || t.instrument_name)}" loading="lazy">
<a class="play-overlay" href="${escapeHtml(t.url)}" target="_blank" rel="noopener" title="Regarder sur YouTube">&#9658;</a>
</div>
<div class="tutorial-meta">
<span class="tag">${escapeHtml(t.instrument_name)}</span>
<div class="tutorial-label">${escapeHtml(t.label || 'Tuto')}</div>
</div>
<button class="secondary icon-btn remove-tutorial" data-song-id="${t.song_id}" data-id="${t.id}">Retirer</button>
</div>
`;
}
return `
<div class="tutorial-card" data-tutorial-id="${t.id}">
<div class="song-thumb tutorial-thumb placeholder">&#128279;</div>
<div class="tutorial-meta">
<span class="tag">${escapeHtml(t.instrument_name)}</span>
<div class="tutorial-label"><a href="${escapeHtml(t.url)}" target="_blank" rel="noopener">${escapeHtml(t.label || t.url)}</a></div>
</div>
<button class="secondary icon-btn remove-tutorial" data-song-id="${t.song_id}" data-id="${t.id}">Retirer</button>
</div>
`;
}
function renderSongs() { function renderSongs() {
const container = document.getElementById('songs-list'); const container = document.getElementById('songs-list');
renderList(container, songs, songCardTemplate, 'Aucun morceau au répertoire pour le moment.'); renderList(container, songs, songCardTemplate, 'Aucun morceau au répertoire pour le moment.');
@ -152,11 +180,10 @@ async function loadTutorials(songId) {
container.innerHTML = '<p class="empty">Aucun lien pour le moment.</p>'; container.innerHTML = '<p class="empty">Aucun lien pour le moment.</p>';
return; return;
} }
container.innerHTML = detail.tutorials container.innerHTML = detail.tutorials.map(tutorialCardTemplate).join('');
.map( container.querySelectorAll('.remove-tutorial').forEach((btn) => {
(t) => `<span class="tag">${escapeHtml(t.instrument_name)}: <a href="${escapeHtml(t.url)}" target="_blank" rel="noopener">${escapeHtml(t.label || t.url)}</a></span>` btn.addEventListener('click', () => onRemoveTutorial(parseInt(btn.dataset.songId, 10), parseInt(btn.dataset.id, 10)));
) });
.join('');
} catch (err) { } catch (err) {
showError(err.message); showError(err.message);
} }
@ -181,6 +208,18 @@ async function onAddTutorial(e) {
} }
} }
async function onRemoveTutorial(songId, tutorialId) {
try {
await api.del(`/api/songs/${songId}/tutorials/${tutorialId}`);
const song = songs.find((s) => s.id === songId);
if (song) song.tutorial_count -= 1;
await loadTutorials(songId);
renderSongs();
} catch (err) {
showError(err.message);
}
}
async function onAddSong(e) { async function onAddSong(e) {
e.preventDefault(); e.preventDefault();
const form = e.target; const form = e.target;
@ -233,9 +272,6 @@ function showError(message) {
(async function init() { (async function init() {
me = await initNav('repertoire'); me = await initNav('repertoire');
await loadInstruments(); await loadInstruments();
if (me.isAdmin) { document.getElementById('add-song-form').addEventListener('submit', onAddSong);
document.getElementById('admin-add-song').style.display = 'block';
document.getElementById('add-song-form').addEventListener('submit', onAddSong);
}
await loadSongs(); await loadSongs();
})(); })();

View file

@ -30,17 +30,15 @@ function readOnlyView() {
? `<ol class="setlist">${encore.map((s) => `<li><span class="row-title">${escapeHtml(s.title)}</span> <span class="row-artist">— ${escapeHtml(s.artist)}</span>${s.note ? `<span class="note">${escapeHtml(s.note)}</span>` : ''}</li>`).join('')}</ol>` ? `<ol class="setlist">${encore.map((s) => `<li><span class="row-title">${escapeHtml(s.title)}</span> <span class="row-artist">— ${escapeHtml(s.artist)}</span>${s.note ? `<span class="note">${escapeHtml(s.note)}</span>` : ''}</li>`).join('')}</ol>`
: '<p class="empty">Aucun morceau de rappel prévu.</p>'} : '<p class="empty">Aucun morceau de rappel prévu.</p>'}
</div> </div>
${me.isAdmin ? '<button id="edit-btn" class="secondary" style="margin-top:1rem">Modifier la setlist</button>' : ''} <button id="edit-btn" class="secondary" style="margin-top:1rem">Modifier la setlist</button>
`; `;
} }
function renderReadOnly() { function renderReadOnly() {
const container = document.getElementById('content'); const container = document.getElementById('content');
if (!setlist) { if (!setlist) {
container.innerHTML = me.isAdmin container.innerHTML = `<p class="empty">Aucun concert à venir.</p>${metaFormTemplate()}`;
? `<p class="empty">Aucun concert à venir.</p>${metaFormTemplate()}` attachMetaFormHandler();
: '<p class="empty">Aucun concert à venir pour le moment.</p>';
if (me.isAdmin) attachMetaFormHandler();
return; return;
} }
container.innerHTML = readOnlyView(); container.innerHTML = readOnlyView();
@ -249,9 +247,7 @@ function showError(message) {
(async function init() { (async function init() {
me = await initNav('setlist'); me = await initNav('setlist');
if (me.isAdmin) { allSongs = await api.get('/api/songs');
allSongs = await api.get('/api/songs');
}
setlist = await api.get('/api/setlists/next'); setlist = await api.get('/api/setlists/next');
renderReadOnly(); renderReadOnly();
})(); })();

View file

@ -1,6 +1,5 @@
const express = require('express'); const express = require('express');
const setlistsRepo = require('../repositories/setlistsRepo'); const setlistsRepo = require('../repositories/setlistsRepo');
const { requireAdmin } = require('../auth/middleware');
const asyncHandler = require('../lib/asyncHandler'); const asyncHandler = require('../lib/asyncHandler');
const router = express.Router(); const router = express.Router();
@ -37,7 +36,6 @@ router.get(
router.post( router.post(
'/', '/',
requireAdmin,
asyncHandler(async (req, res) => { asyncHandler(async (req, res) => {
const { name, venue, concertDate } = req.body || {}; const { name, venue, concertDate } = req.body || {};
if (!concertDate) return res.status(400).json({ error: 'concert_date_required' }); if (!concertDate) return res.status(400).json({ error: 'concert_date_required' });
@ -48,7 +46,6 @@ router.post(
router.patch( router.patch(
'/:id', '/:id',
requireAdmin,
asyncHandler(async (req, res) => { asyncHandler(async (req, res) => {
const { name, venue, concertDate } = req.body || {}; const { name, venue, concertDate } = req.body || {};
if (!concertDate) return res.status(400).json({ error: 'concert_date_required' }); if (!concertDate) return res.status(400).json({ error: 'concert_date_required' });
@ -60,7 +57,6 @@ router.patch(
router.delete( router.delete(
'/:id', '/:id',
requireAdmin,
asyncHandler(async (req, res) => { asyncHandler(async (req, res) => {
await setlistsRepo.remove(req.params.id); await setlistsRepo.remove(req.params.id);
res.status(204).end(); res.status(204).end();
@ -69,7 +65,6 @@ router.delete(
router.put( router.put(
'/:id/songs', '/:id/songs',
requireAdmin,
asyncHandler(async (req, res) => { asyncHandler(async (req, res) => {
const { songs } = req.body || {}; const { songs } = req.body || {};
if (!Array.isArray(songs)) return res.status(400).json({ error: 'songs_array_required' }); if (!Array.isArray(songs)) return res.status(400).json({ error: 'songs_array_required' });
@ -93,7 +88,6 @@ router.put(
router.post( router.post(
'/:id/songs', '/:id/songs',
requireAdmin,
asyncHandler(async (req, res) => { asyncHandler(async (req, res) => {
const { songId, position, note, isEncore } = req.body || {}; const { songId, position, note, isEncore } = req.body || {};
if (!songId || typeof position !== 'number') { if (!songId || typeof position !== 'number') {
@ -113,7 +107,6 @@ router.post(
router.delete( router.delete(
'/:id/songs/:setlistSongId', '/:id/songs/:setlistSongId',
requireAdmin,
asyncHandler(async (req, res) => { asyncHandler(async (req, res) => {
await setlistsRepo.removeSong(req.params.id, req.params.setlistSongId); await setlistsRepo.removeSong(req.params.id, req.params.setlistSongId);
res.status(204).end(); res.status(204).end();

View file

@ -1,6 +1,5 @@
const express = require('express'); const express = require('express');
const songsRepo = require('../repositories/songsRepo'); const songsRepo = require('../repositories/songsRepo');
const { requireAdmin } = require('../auth/middleware');
const asyncHandler = require('../lib/asyncHandler'); const asyncHandler = require('../lib/asyncHandler');
const { isValidYoutubeUrl } = require('../lib/youtube'); const { isValidYoutubeUrl } = require('../lib/youtube');
const { isValidSpotifyUrl } = require('../lib/spotify'); const { isValidSpotifyUrl } = require('../lib/spotify');
@ -32,7 +31,6 @@ router.get(
router.post( router.post(
'/', '/',
requireAdmin,
asyncHandler(async (req, res) => { asyncHandler(async (req, res) => {
const { title, artist, notes, youtubeUrl, spotifyUrl } = req.body || {}; const { title, artist, notes, youtubeUrl, spotifyUrl } = req.body || {};
if (!title || !title.trim() || !artist || !artist.trim()) { if (!title || !title.trim() || !artist || !artist.trim()) {
@ -54,7 +52,6 @@ router.post(
router.patch( router.patch(
'/:id', '/:id',
requireAdmin,
asyncHandler(async (req, res) => { asyncHandler(async (req, res) => {
const { title, artist, notes, youtubeUrl, spotifyUrl } = req.body || {}; const { title, artist, notes, youtubeUrl, spotifyUrl } = req.body || {};
if (!title || !title.trim() || !artist || !artist.trim()) { if (!title || !title.trim() || !artist || !artist.trim()) {
@ -76,7 +73,6 @@ router.patch(
router.delete( router.delete(
'/:id', '/:id',
requireAdmin,
asyncHandler(async (req, res) => { asyncHandler(async (req, res) => {
try { try {
await songsRepo.remove(req.params.id); await songsRepo.remove(req.params.id);
@ -94,7 +90,6 @@ router.delete(
router.post( router.post(
'/:id/tutorials', '/:id/tutorials',
requireAdmin,
asyncHandler(async (req, res) => { asyncHandler(async (req, res) => {
const { instrumentId, url, label } = req.body || {}; const { instrumentId, url, label } = req.body || {};
if (!instrumentId || !url || !url.trim()) { if (!instrumentId || !url || !url.trim()) {
@ -114,7 +109,6 @@ router.post(
router.delete( router.delete(
'/:songId/tutorials/:id', '/:songId/tutorials/:id',
requireAdmin,
asyncHandler(async (req, res) => { asyncHandler(async (req, res) => {
await songsRepo.removeTutorial(req.params.songId, req.params.id); await songsRepo.removeTutorial(req.params.songId, req.params.id);
res.status(204).end(); res.status(204).end();