mirror of
https://github.com/nfonteyne/octane-website.git
synced 2026-09-03 23:24:48 +02:00
update: user can add musics, concerts and all
This commit is contained in:
parent
0683108c8e
commit
692b19a846
7 changed files with 92 additions and 42 deletions
14
README.md
14
README.md
|
|
@ -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
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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">►</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">🔗</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();
|
||||||
})();
|
})();
|
||||||
|
|
|
||||||
|
|
@ -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();
|
||||||
})();
|
})();
|
||||||
|
|
|
||||||
|
|
@ -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();
|
||||||
|
|
|
||||||
|
|
@ -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();
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue