update: add calendar to app

This commit is contained in:
Nathan FONTEYNE 2026-07-08 15:53:25 +02:00
parent b17715eaff
commit 08717c24bc
29 changed files with 1478 additions and 313 deletions

337
public/js/calendar.js Normal file
View file

@ -0,0 +1,337 @@
let state = {
people: [],
slots: [],
personIds: [],
};
async function loadPeople() {
state.people = await api.get('/api/calendar/people');
state.personIds = state.people.map((p) => p.id);
}
async function loadSlots() {
state.slots = await api.get('/api/calendar/slots?weeks=3');
renderCalendar();
}
async function loadLastChecked() {
const { last_checked } = await api.get('/api/calendar/last-checked');
const el = document.getElementById('last-checked');
el.textContent = last_checked
? 'Dernière mise à jour : ' + formatDatetime(last_checked)
: 'Aucune donnée pour le moment — cliquez sur Actualiser';
}
function isoDate(d) {
return d.getFullYear() + '-' + String(d.getMonth() + 1).padStart(2, '0') + '-' + String(d.getDate()).padStart(2, '0');
}
function shortMonth(d) {
return d.toLocaleDateString('fr-FR', { month: 'short' });
}
function formatTime(iso) {
return new Date(iso).toLocaleTimeString('fr-FR', { hour: '2-digit', minute: '2-digit', timeZone: 'Europe/Paris' });
}
function formatDatetime(iso) {
return new Date(iso).toLocaleString('fr-FR', {
day: '2-digit', month: '2-digit', year: 'numeric', hour: '2-digit', minute: '2-digit',
});
}
function renderCalendar() {
const grid = document.getElementById('calendar-grid');
grid.innerHTML = '';
const today = new Date();
today.setHours(0, 0, 0, 0);
const selectedSet = new Set(state.personIds);
const slotMap = new Map();
for (const slot of state.slots) slotMap.set(slot.slot_date, slot);
// Grid starts on the Monday of the current week so columns align Mon->Sun.
const start = new Date(today);
const dow = start.getDay();
start.setDate(start.getDate() + (dow === 0 ? -6 : 1 - dow));
const daysBeforeToday = Math.round((today - start) / 86400000);
const totalDays = Math.ceil((daysBeforeToday + 21) / 7) * 7;
for (let i = 0; i < totalDays; i++) {
const date = new Date(start);
date.setDate(start.getDate() + i);
const isPastDay = date < today;
const isBeyond21 = i >= daysBeforeToday + 21;
const isToday = date.getTime() === today.getTime();
const slot = slotMap.get(isoDate(date));
const cell = document.createElement('div');
cell.className = 'cal-cell' + (isToday ? ' today' : '');
if (isBeyond21) {
cell.classList.add('empty');
grid.appendChild(cell);
continue;
}
const dateLabel = document.createElement('div');
dateLabel.className = 'cell-date';
dateLabel.innerHTML = `<span class="day-num">${date.getDate()}</span>${shortMonth(date)}`;
cell.appendChild(dateLabel);
if (isPastDay) {
cell.classList.add('empty');
const lbl = document.createElement('div');
lbl.className = 'no-slot-label';
lbl.textContent = 'passé';
cell.appendChild(lbl);
} else if (slot) {
const visible = slot.people.filter((p) => selectedSet.has(p.id));
const avail = visible.filter((p) => p.is_available);
cell.classList.add('has-slot');
if (visible.length > 0) {
if (avail.length === visible.length) {
cell.classList.add('all-available');
} else {
const unavailRatio = (visible.length - avail.length) / visible.length;
if (unavailRatio <= 0.2) cell.classList.add('heat-1');
else if (unavailRatio <= 0.4) cell.classList.add('heat-2');
else if (unavailRatio <= 0.6) cell.classList.add('heat-3');
else if (unavailRatio <= 0.8) cell.classList.add('heat-4');
else cell.classList.add('heat-5');
}
}
const timeEl = document.createElement('div');
timeEl.className = 'cell-time';
timeEl.textContent = formatTime(slot.lower) + '' + formatTime(slot.upper);
cell.appendChild(timeEl);
const dotsEl = document.createElement('div');
dotsEl.className = 'cell-dots';
for (const person of visible) {
const dot = document.createElement('div');
dot.className = 'cell-dot' + (person.is_available ? '' : ' busy');
dot.style.backgroundColor = person.color;
dot.title = person.name + (person.is_available ? ' ✓' : ' ✗');
dotsEl.appendChild(dot);
}
cell.appendChild(dotsEl);
cell.addEventListener('click', () => openModal(date, slot, visible));
const countEl = document.createElement('div');
countEl.className = 'cell-count';
countEl.textContent = avail.length + '/' + visible.length;
cell.appendChild(countEl);
} else {
cell.classList.add('empty');
const lbl = document.createElement('div');
lbl.className = 'no-slot-label';
lbl.textContent = 'aucune donnée';
cell.appendChild(lbl);
}
grid.appendChild(cell);
}
}
function buildFilters() {
const listEl = document.getElementById('people-list');
for (const person of state.people) {
const row = document.createElement('label');
row.className = 'person-toggle';
row.dataset.id = person.id;
const dot = document.createElement('span');
dot.className = 'person-dot';
dot.style.backgroundColor = person.color;
row.appendChild(dot);
row.appendChild(document.createTextNode(person.name));
row.addEventListener('click', () => {
const id = person.id;
if (state.personIds.includes(id)) {
if (state.personIds.length === 1) return;
state.personIds = state.personIds.filter((x) => x !== id);
row.classList.add('excluded');
} else {
state.personIds.push(id);
row.classList.remove('excluded');
}
renderCalendar();
});
listEl.appendChild(row);
}
buildLegend();
document.getElementById('btn-refresh').addEventListener('click', async () => {
const btn = document.getElementById('btn-refresh');
btn.disabled = true;
btn.textContent = 'Actualisation…';
showToast('Déclenchement du workflow n8n…');
try {
await api.post('/api/calendar/refresh', {});
showToast('Workflow en cours — en attente des résultats…');
pollWorkflowStatus(btn);
} catch (err) {
const message = err.message === 'n8n_not_configured'
? "L'actualisation automatique n'est pas configurée (n8n)."
: err.message;
showToast(message, true);
resetRefreshButton(btn);
}
});
}
function resetRefreshButton(btn) {
btn.disabled = false;
btn.textContent = 'Actualiser les disponibilités';
}
function pollWorkflowStatus(btn, maxMs = 180000, intervalMs = 4000) {
const started = Date.now();
const timer = setInterval(async () => {
if (Date.now() - started > maxMs) {
clearInterval(timer);
showToast('Le workflow a expiré — aucun résultat après 3 minutes', true);
resetRefreshButton(btn);
return;
}
try {
const data = await api.get('/api/calendar/workflow-status');
if (data.status === 'success') {
clearInterval(timer);
showToast('Calendrier mis à jour !');
resetRefreshButton(btn);
await loadSlots();
await loadLastChecked();
} else if (data.status === 'error') {
clearInterval(timer);
const detail = data.node ? ` (nœud : ${data.node})` : '';
showToast('Erreur du workflow : ' + (data.message || 'inconnue') + detail, true);
resetRefreshButton(btn);
}
} catch (err) {
/* transient, keep polling */
}
}, intervalMs);
}
function openModal(date, slot, visible) {
const avail = visible.filter((p) => p.is_available);
const busy = visible.filter((p) => !p.is_available);
document.getElementById('modal-date').textContent = date.toLocaleDateString('fr-FR', {
weekday: 'long', day: 'numeric', month: 'long', year: 'numeric', timeZone: 'Europe/Paris',
});
document.getElementById('modal-time').textContent = formatTime(slot.lower) + ' ' + formatTime(slot.upper);
const renderPeople = (list, containerId) => {
const el = document.getElementById(containerId);
el.innerHTML = '';
if (!list.length) {
const em = document.createElement('div');
em.className = 'modal-empty empty';
em.textContent = 'Aucune';
el.appendChild(em);
return;
}
for (const person of list) {
const row = document.createElement('div');
row.className = 'modal-person';
const dot = document.createElement('span');
dot.className = 'person-dot';
dot.style.backgroundColor = person.color;
row.appendChild(dot);
row.appendChild(document.createTextNode(person.name));
el.appendChild(row);
}
};
renderPeople(avail, 'modal-available');
renderPeople(busy, 'modal-busy');
document.getElementById('modal-overlay').classList.remove('hidden');
}
function closeModal() {
document.getElementById('modal-overlay').classList.add('hidden');
}
function openFilters() {
document.getElementById('filters-panel').classList.add('open');
document.getElementById('filters-backdrop').classList.add('open');
}
function closeFilters() {
document.getElementById('filters-panel').classList.remove('open');
document.getElementById('filters-backdrop').classList.remove('open');
}
function buildLegend() {
const el = document.getElementById('legend');
for (const person of state.people) {
const item = document.createElement('div');
item.className = 'legend-item';
const dot = document.createElement('span');
dot.className = 'legend-dot';
dot.style.backgroundColor = person.color;
item.appendChild(dot);
item.appendChild(document.createTextNode(person.name));
el.appendChild(item);
}
const allItem = document.createElement('div');
allItem.className = 'legend-item';
allItem.innerHTML = '<span class="legend-dot legend-dot-all"></span> Tout le monde libre';
el.appendChild(allItem);
}
function showToast(msg, isError = false, duration = 5000) {
const el = document.getElementById('toast');
el.textContent = msg;
el.classList.remove('hidden', 'error');
if (isError) el.classList.add('error');
clearTimeout(el._timer);
el._timer = setTimeout(() => el.classList.add('hidden'), duration);
}
function showError(message) {
document.getElementById('error').innerHTML = `<div class="error-banner">${escapeHtml(message)}</div>`;
}
(async function init() {
await initNav('calendar');
document.getElementById('modal-close').addEventListener('click', closeModal);
document.getElementById('modal-overlay').addEventListener('click', (e) => {
if (e.target === e.currentTarget) closeModal();
});
document.addEventListener('keydown', (e) => {
if (e.key === 'Escape') {
closeModal();
closeFilters();
}
});
document.getElementById('btn-filters-toggle').addEventListener('click', openFilters);
document.getElementById('btn-filters-close').addEventListener('click', closeFilters);
document.getElementById('filters-backdrop').addEventListener('click', closeFilters);
try {
await loadPeople();
buildFilters();
await loadSlots();
await loadLastChecked();
} catch (err) {
showError(err.message);
}
})();

View file

@ -1,12 +1,3 @@
function formatDate(dateStr) {
const d = new Date(dateStr);
return d.toLocaleDateString('fr-FR', { weekday: 'long', year: 'numeric', month: 'long', day: 'numeric' });
}
function showError(message) {
document.getElementById('error').innerHTML = `<div class="error-banner">${escapeHtml(message)}</div>`;
}
(async function init() {
await initNav('history');
const params = new URLSearchParams(window.location.search);
@ -16,31 +7,23 @@ function showError(message) {
container.innerHTML = '<p class="empty">Concert introuvable.</p>';
return;
}
try {
const setlist = await api.get(`/api/setlists/${id}`);
const main = setlist.songs.filter((s) => !s.is_encore);
const encore = setlist.songs.filter((s) => s.is_encore);
const rowHtml = (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>`;
container.innerHTML = `
<div class="card">
<div class="card-title">${escapeHtml(setlist.name || 'Concert')}</div>
<div class="card-subtitle">${escapeHtml(setlist.venue || '')} · ${formatDate(setlist.concert_date)}</div>
</div>
<div class="setlist-section">
<h3>Setlist</h3>
${main.length
? `<ol class="setlist">${main.map(rowHtml).join('')}</ol>`
: '<p class="empty">Aucun morceau enregistré.</p>'}
</div>
<div class="setlist-section">
<h3>Rappel</h3>
${encore.length
? `<ol class="setlist">${encore.map(rowHtml).join('')}</ol>`
: '<p class="empty">Aucun rappel enregistré.</p>'}
</div>
`;
} catch (err) {
showError(err.message);
}
const allSongs = await api.get('/api/songs');
const editor = createSetlistEditor({
allSongs,
getSetlist: async () => {
try {
return await api.get(`/api/setlists/${id}`);
} catch (err) {
return null;
}
},
emptyMessage: 'Concert introuvable.',
allowDelete: true,
onDeleted: () => {
window.location.href = '/history.html';
},
});
await editor.load();
})();

View file

@ -29,6 +29,7 @@ async function initNav(activePage) {
<a href="/index.html" class="${activePage === 'repertoire' ? 'active' : ''}">Répertoire</a>
<a href="/suggestions.html" class="${activePage === 'suggestions' ? 'active' : ''}">Suggestions</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="/history.html" class="${activePage === 'history' ? 'active' : ''}">Historique</a>
</div>
<div class="nav-user">

View file

@ -1,253 +1,12 @@
let me = null;
let allSongs = [];
let setlist = null;
let mainRows = [];
let encoreRows = [];
let addSongId = null;
function formatDate(dateStr) {
const d = new Date(dateStr);
return d.toLocaleDateString('fr-FR', { weekday: 'long', year: 'numeric', month: 'long', day: 'numeric' });
}
function readOnlyView() {
const main = setlist.songs.filter((s) => !s.is_encore);
const encore = setlist.songs.filter((s) => s.is_encore);
return `
<div class="card">
<div class="card-title">${escapeHtml(setlist.name || 'Concert')}</div>
<div class="card-subtitle">${escapeHtml(setlist.venue || '')} · ${formatDate(setlist.concert_date)}</div>
</div>
<div class="setlist-section">
<h3>Setlist</h3>
${main.length
? `<ol class="setlist">${main.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 pour le moment.</p>'}
</div>
<div class="setlist-section">
<h3>Rappel</h3>
${encore.length
? `<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>'}
</div>
<button id="edit-btn" class="secondary" style="margin-top:1rem">Modifier la setlist</button>
`;
}
function renderReadOnly() {
const container = document.getElementById('content');
if (!setlist) {
container.innerHTML = `<p class="empty">Aucun concert à venir.</p>${metaFormTemplate()}`;
attachMetaFormHandler();
return;
}
container.innerHTML = readOnlyView();
const editBtn = document.getElementById('edit-btn');
if (editBtn) editBtn.addEventListener('click', enterEditMode);
}
function metaFormTemplate() {
return `
<div class="panel">
<h3>${setlist ? 'Détails du concert' : 'Créer le prochain concert'}</h3>
<form id="meta-form" class="inline-form">
<label>Nom <input name="name" value="${escapeHtml(setlist?.name || '')}"></label>
<label>Lieu <input name="venue" value="${escapeHtml(setlist?.venue || '')}"></label>
<label>Date <input type="date" name="concertDate" value="${setlist ? setlist.concert_date.slice(0, 10) : ''}" required></label>
<button type="submit">${setlist ? 'Enregistrer' : 'Créer le concert'}</button>
</form>
</div>
`;
}
function usedSongIds() {
return new Set([...mainRows, ...encoreRows].map((r) => r.songId));
}
function availableSongOptions() {
const used = usedSongIds();
const available = allSongs.filter((s) => !used.has(s.id));
if (!available.length) return '<option value="">(tous les morceaux sont déjà dans la setlist)</option>';
return available.map((s) => `<option value="${s.id}">${escapeHtml(s.title)}${escapeHtml(s.artist)}</option>`).join('');
}
function rowTemplate(row, index, section, total) {
return `
<div class="setlist-row" data-section="${section}" data-index="${index}">
<div class="row-index">${index + 1}</div>
<div class="row-main">
<div class="row-title">${escapeHtml(row.title)}</div>
<div class="row-artist">${escapeHtml(row.artist)}</div>
</div>
<input class="row-note-input" data-field="note" placeholder="Note (optionnel)" value="${escapeHtml(row.note || '')}">
<div class="row-actions">
<button type="button" class="secondary icon-btn move-up" ${index === 0 ? 'disabled' : ''} title="Monter">&#8593;</button>
<button type="button" class="secondary icon-btn move-down" ${index === total - 1 ? 'disabled' : ''} title="Descendre">&#8595;</button>
<button type="button" class="secondary icon-btn move-section" title="${section === 'main' ? 'Déplacer en rappel' : 'Déplacer au programme'}">${section === 'main' ? '&#8677; Rappel' : '&#8676; Programme'}</button>
<button type="button" class="danger icon-btn remove-row" title="Retirer">&times;</button>
</div>
</div>
`;
}
function editView() {
return `
${metaFormTemplate()}
${setlist ? `
<div class="panel">
<h3>Ajouter un morceau du répertoire</h3>
<form id="add-song-form" class="inline-form">
<label>Morceau
<select id="add-song-select">${availableSongOptions()}</select>
</label>
<button type="button" id="add-to-main-btn" class="secondary">Ajouter au programme</button>
<button type="button" id="add-to-encore-btn" class="secondary">Ajouter au rappel</button>
</form>
</div>
<div class="setlist-section">
<h3>Programme principal</h3>
<div class="setlist-rows" id="main-rows">
${mainRows.length ? mainRows.map((r, i) => rowTemplate(r, i, 'main', mainRows.length)).join('') : '<p class="empty">Aucun morceau. Ajoutez-en depuis le répertoire ci-dessus.</p>'}
</div>
</div>
<div class="setlist-section">
<h3>Rappel</h3>
<div class="setlist-rows" id="encore-rows">
${encoreRows.length ? encoreRows.map((r, i) => rowTemplate(r, i, 'encore', encoreRows.length)).join('') : '<p class="empty">Aucun morceau de rappel prévu.</p>'}
</div>
</div>
<div class="inline-form" style="margin-top:1.25rem">
<button type="button" id="save-songs-btn">Enregistrer la setlist</button>
<button type="button" id="cancel-edit-btn" class="secondary">Retour</button>
</div>` : ''}
`;
}
function enterEditMode() {
mainRows = setlist.songs
.filter((s) => !s.is_encore)
.map((s) => ({ setlistSongId: s.id, songId: s.song_id, title: s.title, artist: s.artist, note: s.note }));
encoreRows = setlist.songs
.filter((s) => s.is_encore)
.map((s) => ({ setlistSongId: s.id, songId: s.song_id, title: s.title, artist: s.artist, note: s.note }));
renderEdit();
}
function renderEdit() {
const container = document.getElementById('content');
container.innerHTML = editView();
attachMetaFormHandler();
attachEditHandlers();
}
function attachMetaFormHandler() {
const form = document.getElementById('meta-form');
if (!form) return;
form.addEventListener('submit', async (e) => {
e.preventDefault();
const name = form.name.value.trim();
const venue = form.venue.value.trim();
const concertDate = form.concertDate.value;
try {
if (setlist) {
setlist = await api.patch(`/api/setlists/${setlist.id}`, { name, venue, concertDate });
setlist.songs = (await api.get(`/api/setlists/${setlist.id}`)).songs;
} else {
setlist = await api.post('/api/setlists', { name, venue, concertDate });
setlist.songs = [];
}
enterEditMode();
} catch (err) {
showError(err.message);
}
});
}
function rowsForSection(section) {
return section === 'main' ? mainRows : encoreRows;
}
function attachEditHandlers() {
const addSelect = document.getElementById('add-song-select');
const addToMain = document.getElementById('add-to-main-btn');
const addToEncore = document.getElementById('add-to-encore-btn');
if (addSelect) {
if (!addSelect.value) addSongId = null;
addSelect.addEventListener('change', () => { addSongId = addSelect.value ? parseInt(addSelect.value, 10) : null; });
}
if (addToMain) addToMain.addEventListener('click', () => addSongToSection('main', addSelect));
if (addToEncore) addToEncore.addEventListener('click', () => addSongToSection('encore', addSelect));
document.querySelectorAll('.setlist-row').forEach((rowEl) => {
const section = rowEl.dataset.section;
const index = parseInt(rowEl.dataset.index, 10);
const rows = rowsForSection(section);
rowEl.querySelector('.row-note-input').addEventListener('change', (e) => {
rows[index].note = e.target.value;
});
rowEl.querySelector('.move-up')?.addEventListener('click', () => {
if (index === 0) return;
[rows[index - 1], rows[index]] = [rows[index], rows[index - 1]];
renderEdit();
});
rowEl.querySelector('.move-down')?.addEventListener('click', () => {
if (index === rows.length - 1) return;
[rows[index + 1], rows[index]] = [rows[index], rows[index + 1]];
renderEdit();
});
rowEl.querySelector('.move-section').addEventListener('click', () => {
const [row] = rows.splice(index, 1);
if (section === 'main') encoreRows.push(row);
else mainRows.push(row);
renderEdit();
});
rowEl.querySelector('.remove-row').addEventListener('click', () => {
rows.splice(index, 1);
renderEdit();
});
});
const saveBtn = document.getElementById('save-songs-btn');
if (saveBtn) saveBtn.addEventListener('click', onSaveSongs);
const cancelBtn = document.getElementById('cancel-edit-btn');
if (cancelBtn) cancelBtn.addEventListener('click', renderReadOnly);
}
function addSongToSection(section, selectEl) {
const songId = selectEl.value ? parseInt(selectEl.value, 10) : null;
if (!songId) return;
const song = allSongs.find((s) => s.id === songId);
if (!song) return;
const row = { setlistSongId: null, songId: song.id, title: song.title, artist: song.artist, note: '' };
if (section === 'main') mainRows.push(row);
else encoreRows.push(row);
renderEdit();
}
async function onSaveSongs() {
try {
const payload = [
...mainRows.map((r, i) => ({ songId: r.songId, position: i + 1, note: r.note, isEncore: false })),
...encoreRows.map((r, i) => ({ songId: r.songId, position: i + 1, note: r.note, isEncore: true })),
];
setlist = await api.put(`/api/setlists/${setlist.id}/songs`, { songs: payload });
renderReadOnly();
} catch (err) {
showError(err.message);
}
}
function showError(message) {
document.getElementById('error').innerHTML = `<div class="error-banner">${escapeHtml(message)}</div>`;
}
(async function init() {
me = await initNav('setlist');
allSongs = await api.get('/api/songs');
setlist = await api.get('/api/setlists/next');
renderReadOnly();
await initNav('setlist');
const allSongs = await api.get('/api/songs');
const editor = createSetlistEditor({
allSongs,
getSetlist: () => api.get('/api/setlists/next'),
createSetlist: (data) => api.post('/api/setlists', data),
emptyMessage: 'Aucun concert à venir pour le moment.',
});
await editor.load();
})();

278
public/js/setlistEditor.js Normal file
View file

@ -0,0 +1,278 @@
// Shared setlist editor used by both "prochain concert" (setlist.js) and
// "historique" (history-detail.js): choose songs from the repertoire, order
// them, add per-song notes, mark encore songs, save. Parameterized so the
// caller decides how to load/create/delete the underlying setlist.
function createSetlistEditor({
containerId = 'content',
allSongs,
getSetlist,
createSetlist = null,
emptyMessage = 'Aucun concert.',
allowDelete = false,
onDeleted = null,
}) {
let setlist = null;
let mainRows = [];
let encoreRows = [];
function formatDate(dateStr) {
const d = new Date(dateStr);
return d.toLocaleDateString('fr-FR', { weekday: 'long', year: 'numeric', month: 'long', day: 'numeric' });
}
function container() {
return document.getElementById(containerId);
}
function showError(message) {
document.getElementById('error').innerHTML = `<div class="error-banner">${escapeHtml(message)}</div>`;
}
function readOnlyView() {
const main = setlist.songs.filter((s) => !s.is_encore);
const encore = setlist.songs.filter((s) => s.is_encore);
const rowHtml = (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>`;
return `
<div class="card">
<div class="card-title">${escapeHtml(setlist.name || 'Concert')}</div>
<div class="card-subtitle">${escapeHtml(setlist.venue || '')} · ${formatDate(setlist.concert_date)}</div>
</div>
<div class="setlist-section">
<h3>Setlist</h3>
${main.length ? `<ol class="setlist">${main.map(rowHtml).join('')}</ol>` : '<p class="empty">Aucun morceau pour le moment.</p>'}
</div>
<div class="setlist-section">
<h3>Rappel</h3>
${encore.length ? `<ol class="setlist">${encore.map(rowHtml).join('')}</ol>` : '<p class="empty">Aucun morceau de rappel prévu.</p>'}
</div>
<div class="inline-form" style="margin-top:1rem">
<button id="edit-btn" class="secondary">Modifier</button>
${allowDelete ? '<button id="delete-btn" class="danger">Supprimer ce concert</button>' : ''}
</div>
`;
}
function renderReadOnly() {
if (!setlist) {
container().innerHTML = createSetlist
? `<p class="empty">${escapeHtml(emptyMessage)}</p>${metaFormTemplate()}`
: `<p class="empty">${escapeHtml(emptyMessage)}</p>`;
if (createSetlist) attachMetaFormHandler();
return;
}
container().innerHTML = readOnlyView();
document.getElementById('edit-btn').addEventListener('click', enterEditMode);
const deleteBtn = document.getElementById('delete-btn');
if (deleteBtn) deleteBtn.addEventListener('click', onDelete);
}
async function onDelete() {
try {
await api.del(`/api/setlists/${setlist.id}`);
if (onDeleted) onDeleted();
} catch (err) {
showError(err.message);
}
}
function metaFormTemplate() {
return `
<div class="panel">
<h3>${setlist ? 'Détails du concert' : 'Créer le prochain concert'}</h3>
<form id="meta-form" class="inline-form">
<label>Nom <input name="name" value="${escapeHtml(setlist?.name || '')}"></label>
<label>Lieu <input name="venue" value="${escapeHtml(setlist?.venue || '')}"></label>
<label>Date <input type="date" name="concertDate" value="${setlist ? setlist.concert_date.slice(0, 10) : ''}" required></label>
<button type="submit">${setlist ? 'Enregistrer' : 'Créer le concert'}</button>
</form>
</div>
`;
}
function usedSongIds() {
return new Set([...mainRows, ...encoreRows].map((r) => r.songId));
}
function availableSongOptions() {
const used = usedSongIds();
const available = allSongs.filter((s) => !used.has(s.id));
if (!available.length) return '<option value="">(tous les morceaux sont déjà dans la setlist)</option>';
return available.map((s) => `<option value="${s.id}">${escapeHtml(s.title)}${escapeHtml(s.artist)}</option>`).join('');
}
function rowTemplate(row, index, section, total) {
return `
<div class="setlist-row" data-section="${section}" data-index="${index}">
<div class="row-index">${index + 1}</div>
<div class="row-main">
<div class="row-title">${escapeHtml(row.title)}</div>
<div class="row-artist">${escapeHtml(row.artist)}</div>
</div>
<input class="row-note-input" data-field="note" placeholder="Note (optionnel)" value="${escapeHtml(row.note || '')}">
<div class="row-actions">
<button type="button" class="secondary icon-btn move-up" ${index === 0 ? 'disabled' : ''} title="Monter">&#8593;</button>
<button type="button" class="secondary icon-btn move-down" ${index === total - 1 ? 'disabled' : ''} title="Descendre">&#8595;</button>
<button type="button" class="secondary icon-btn move-section" title="${section === 'main' ? 'Déplacer en rappel' : 'Déplacer au programme'}">${section === 'main' ? '&#8677; Rappel' : '&#8676; Programme'}</button>
<button type="button" class="danger icon-btn remove-row" title="Retirer">&times;</button>
</div>
</div>
`;
}
function editView() {
return `
${metaFormTemplate()}
${setlist ? `
<div class="panel">
<h3>Ajouter un morceau du répertoire</h3>
<form id="add-song-form" class="inline-form">
<label>Morceau
<select id="add-song-select">${availableSongOptions()}</select>
</label>
<button type="button" id="add-to-main-btn" class="secondary">Ajouter au programme</button>
<button type="button" id="add-to-encore-btn" class="secondary">Ajouter au rappel</button>
</form>
</div>
<div class="setlist-section">
<h3>Programme principal</h3>
<div class="setlist-rows" id="main-rows">
${mainRows.length ? mainRows.map((r, i) => rowTemplate(r, i, 'main', mainRows.length)).join('') : '<p class="empty">Aucun morceau. Ajoutez-en depuis le répertoire ci-dessus.</p>'}
</div>
</div>
<div class="setlist-section">
<h3>Rappel</h3>
<div class="setlist-rows" id="encore-rows">
${encoreRows.length ? encoreRows.map((r, i) => rowTemplate(r, i, 'encore', encoreRows.length)).join('') : '<p class="empty">Aucun morceau de rappel prévu.</p>'}
</div>
</div>
<div class="inline-form" style="margin-top:1.25rem">
<button type="button" id="save-songs-btn">Enregistrer la setlist</button>
<button type="button" id="cancel-edit-btn" class="secondary">Retour</button>
</div>` : ''}
`;
}
function enterEditMode() {
mainRows = setlist.songs
.filter((s) => !s.is_encore)
.map((s) => ({ setlistSongId: s.id, songId: s.song_id, title: s.title, artist: s.artist, note: s.note }));
encoreRows = setlist.songs
.filter((s) => s.is_encore)
.map((s) => ({ setlistSongId: s.id, songId: s.song_id, title: s.title, artist: s.artist, note: s.note }));
renderEdit();
}
function renderEdit() {
container().innerHTML = editView();
attachMetaFormHandler();
attachEditHandlers();
}
function attachMetaFormHandler() {
const form = document.getElementById('meta-form');
if (!form) return;
form.addEventListener('submit', async (e) => {
e.preventDefault();
const name = form.name.value.trim();
const venue = form.venue.value.trim();
const concertDate = form.concertDate.value;
try {
if (setlist) {
setlist = await api.patch(`/api/setlists/${setlist.id}`, { name, venue, concertDate });
setlist.songs = (await api.get(`/api/setlists/${setlist.id}`)).songs;
} else {
setlist = await createSetlist({ name, venue, concertDate });
setlist.songs = setlist.songs || [];
}
enterEditMode();
} catch (err) {
showError(err.message);
}
});
}
function rowsForSection(section) {
return section === 'main' ? mainRows : encoreRows;
}
function attachEditHandlers() {
const addSelect = document.getElementById('add-song-select');
const addToMain = document.getElementById('add-to-main-btn');
const addToEncore = document.getElementById('add-to-encore-btn');
if (addToMain) addToMain.addEventListener('click', () => addSongToSection('main', addSelect));
if (addToEncore) addToEncore.addEventListener('click', () => addSongToSection('encore', addSelect));
document.querySelectorAll('.setlist-row').forEach((rowEl) => {
const section = rowEl.dataset.section;
const index = parseInt(rowEl.dataset.index, 10);
const rows = rowsForSection(section);
rowEl.querySelector('.row-note-input').addEventListener('change', (e) => {
rows[index].note = e.target.value;
});
rowEl.querySelector('.move-up')?.addEventListener('click', () => {
if (index === 0) return;
[rows[index - 1], rows[index]] = [rows[index], rows[index - 1]];
renderEdit();
});
rowEl.querySelector('.move-down')?.addEventListener('click', () => {
if (index === rows.length - 1) return;
[rows[index + 1], rows[index]] = [rows[index], rows[index + 1]];
renderEdit();
});
rowEl.querySelector('.move-section').addEventListener('click', () => {
const [row] = rows.splice(index, 1);
if (section === 'main') encoreRows.push(row);
else mainRows.push(row);
renderEdit();
});
rowEl.querySelector('.remove-row').addEventListener('click', () => {
rows.splice(index, 1);
renderEdit();
});
});
const saveBtn = document.getElementById('save-songs-btn');
if (saveBtn) saveBtn.addEventListener('click', onSaveSongs);
const cancelBtn = document.getElementById('cancel-edit-btn');
if (cancelBtn) cancelBtn.addEventListener('click', renderReadOnly);
}
function addSongToSection(section, selectEl) {
const songId = selectEl.value ? parseInt(selectEl.value, 10) : null;
if (!songId) return;
const song = allSongs.find((s) => s.id === songId);
if (!song) return;
const row = { setlistSongId: null, songId: song.id, title: song.title, artist: song.artist, note: '' };
if (section === 'main') mainRows.push(row);
else encoreRows.push(row);
renderEdit();
}
async function onSaveSongs() {
try {
const payload = [
...mainRows.map((r, i) => ({ songId: r.songId, position: i + 1, note: r.note, isEncore: false })),
...encoreRows.map((r, i) => ({ songId: r.songId, position: i + 1, note: r.note, isEncore: true })),
];
setlist = await api.put(`/api/setlists/${setlist.id}/songs`, { songs: payload });
renderReadOnly();
} catch (err) {
showError(err.message);
}
}
return {
async load() {
try {
setlist = await getSetlist();
renderReadOnly();
} catch (err) {
showError(err.message);
}
},
};
}

View file

@ -56,6 +56,7 @@ function detailTemplate(s) {
const myVote = s.votes.find((v) => v.user_id === me.id);
return `
${embed ? `<div class="youtube-embed"><iframe src="${embed}" allowfullscreen></iframe></div>` : `<p><a href="${escapeHtml(s.youtube_url)}" target="_blank" rel="noopener">${escapeHtml(s.youtube_url)}</a></p>`}
${s.spotify_url ? `<div class="song-links"><a class="pill-link spotify" href="${escapeHtml(s.spotify_url)}" target="_blank" rel="noopener">&#9835; Spotify</a></div>` : ''}
${s.description ? `<div class="suggestion-note">${escapeHtml(s.description)}</div>` : ''}
<form class="inline-form vote-form" data-id="${s.id}">
@ -73,14 +74,14 @@ function detailTemplate(s) {
${s.votes.length ? s.votes.map(voteItemTemplate).join('') : '<p class="empty">Aucun vote pour le moment.</p>'}
</div>
${me.isAdmin ? `
<form class="inline-form admin-actions" data-id="${s.id}">
<button type="button" class="promote-btn" data-id="${s.id}" ${s.promoted_song_id ? 'disabled' : ''}>
${s.promoted_song_id ? 'Déjà au répertoire' : 'Promouvoir au répertoire'}
${s.promoted_song_id ? 'Déjà au répertoire' : 'Ajouter au répertoire'}
</button>
${me.isAdmin ? `
<button type="button" class="secondary reject-btn" data-id="${s.id}">Marquer rejeté</button>
<button type="button" class="danger delete-btn" data-id="${s.id}">Supprimer</button>
</form>` : ''}
<button type="button" class="danger delete-btn" data-id="${s.id}">Supprimer</button>` : ''}
</form>
`;
}
@ -188,9 +189,10 @@ async function onAddSuggestion(e) {
const title = form.title.value.trim();
const artist = form.artist.value.trim();
const youtubeUrl = form.youtubeUrl.value.trim();
const spotifyUrl = form.spotifyUrl.value.trim();
const description = form.description.value.trim();
try {
await api.post('/api/suggestions', { title, artist, youtubeUrl, description });
await api.post('/api/suggestions', { title, artist, youtubeUrl, spotifyUrl, description });
form.reset();
document.getElementById('suggestion-link-status').textContent = '';
if (suggestionAutocomplete) suggestionAutocomplete.close();
@ -217,16 +219,20 @@ async function onSuggestionCandidateSelected(candidate) {
document.getElementById('suggestion-artist-input').value = candidate.artist;
const statusEl = document.getElementById('suggestion-link-status');
statusEl.textContent = 'Recherche du lien YouTube…';
statusEl.textContent = 'Recherche des liens YouTube / Spotify…';
try {
const links = await api.get(
`/api/music-search/links?title=${encodeURIComponent(candidate.title)}&artist=${encodeURIComponent(candidate.artist)}`
);
const youtubeInput = document.getElementById('suggestion-youtube-input');
const spotifyInput = document.getElementById('suggestion-spotify-input');
if (links.youtubeUrl && !youtubeInput.value) youtubeInput.value = links.youtubeUrl;
statusEl.textContent = links.youtubeUrl
? 'Lien YouTube trouvé automatiquement.'
: 'Aucun lien YouTube trouvé automatiquement — à saisir manuellement.';
if (links.spotifyUrl && !spotifyInput.value) spotifyInput.value = links.spotifyUrl;
if (links.youtubeUrl && links.spotifyUrl) statusEl.textContent = 'Liens YouTube et Spotify trouvés automatiquement.';
else if (links.youtubeUrl) statusEl.textContent = 'Lien YouTube trouvé automatiquement. Aucun lien Spotify trouvé — à saisir manuellement si besoin.';
else if (links.spotifyUrl) statusEl.textContent = 'Lien Spotify trouvé automatiquement. Aucun lien YouTube trouvé — à saisir manuellement si besoin.';
else statusEl.textContent = 'Aucun lien trouvé automatiquement — vous pouvez les saisir manuellement.';
} catch (err) {
statusEl.textContent = '';
}