mirror of
https://github.com/nfonteyne/octane-website.git
synced 2026-09-03 23:24:48 +02:00
update: listen setlist as youtube playlist
This commit is contained in:
parent
09b7b0bc10
commit
1b792eecfb
8 changed files with 65 additions and 5 deletions
12
README.md
12
README.md
|
|
@ -237,10 +237,10 @@ erDiagram
|
|||
|
||||
| Page | Accès | Description |
|
||||
|---|---|---|
|
||||
| `/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. Ajout avec autocomplete titre/artiste + liens auto-trouvés ([détails](#recherche-automatique-de-morceaux)) |
|
||||
| `/index.html` | Tous (lecture et écriture) | Répertoire des morceaux travaillés, avec recherche instantanée (titre/artiste), liens/vignettes YouTube et Spotify, tutos embarqués par morceau et par instrument. Ajout avec autocomplete titre/artiste + liens auto-trouvés ([détails](#recherche-automatique-de-morceaux)) |
|
||||
| `/suggestions.html` | Tous | Proposer un morceau (liens YouTube et Spotify + note libre), voter approuver/rejeter avec commentaire (attribué nominativement), ajouter une suggestion au répertoire |
|
||||
| `/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 et écriture) | Historique des concerts passés, modifiable (date, morceaux, ordre, rappel) et supprimable. `/history.html` propose deux vues : chronologique (par défaut, tous les concerts détaillés avec YouTube embarqué par morceau, du plus récent au plus ancien) et réduite (liste compacte, comme avant) |
|
||||
| `/setlist.html` | Tous (lecture et écriture) | Setlist du prochain concert : choix des morceaux du répertoire, ordre, notes, section rappel, lien "Écouter la setlist sur YouTube" |
|
||||
| `/history.html`, `/history-detail.html` | Tous (lecture et écriture) | Historique des concerts passés, modifiable (date, morceaux, ordre, rappel) et supprimable, avec le même lien playlist YouTube. `/history.html` propose deux vues : chronologique (par défaut, tous les concerts détaillés avec YouTube embarqué par morceau, du plus récent au plus ancien) et réduite (liste compacte, comme avant) |
|
||||
| `/profile.html` | Chacun voit le sien | Profil issu d'Authentik (nom, avatar, groupes) + votre activité (morceaux ajoutés, suggestions, votes) |
|
||||
| `/calendar.html` | Tous (lecture et écriture) | Disponibilités du groupe pour les 3 prochaines semaines (calendrier, filtres par personne, modale par jour) — [détails](#disponibilités-calendrier) |
|
||||
|
||||
|
|
@ -270,6 +270,12 @@ Si aucune des deux n'est configurée, l'autocomplete titre/artiste marche quand
|
|||
|
||||
Si la recherche ne trouve rien (morceau trop obscur, faute de frappe...), rien ne bloque : titre, artiste et liens restent modifiables à la main comme avant.
|
||||
|
||||
## Setlist en playlist YouTube
|
||||
|
||||
Sur la page d'un concert (prochain concert ou historique), un lien **"Écouter la setlist sur YouTube"** ouvre tous les morceaux ayant un lien YouTube à la suite, dans l'ordre du programme puis du rappel — via le lecteur "mix" temporaire de YouTube (`watch_videos?video_ids=...`), sans authentification ni appel API. Le lien n'apparaît que si au moins un morceau de la setlist a un lien YouTube renseigné.
|
||||
|
||||
Une vraie **playlist Spotify** (persistante, sur un compte Spotify) nécessiterait une autorisation OAuth d'un compte Spotify précis — le Client ID/Secret déjà utilisé pour la recherche automatique ([détails](#recherche-automatique-de-morceaux)) ne permet que la recherche, pas la création de playlists. Pas encore implémenté.
|
||||
|
||||
## Disponibilités (calendrier)
|
||||
|
||||
La page `/calendar.html` montre, pour les 3 prochaines semaines, les créneaux de répétition (lun–ven 18h30–21h, sam–dim 15h–19h) où chaque membre est disponible — calculé par un workflow **n8n** externe qui interroge Google Calendar (FreeBusy API) de chaque personne. Cette fonctionnalité est une fusion complète de l'ancien projet séparé [octane-calendar](https://github.com/nfonteyne/octane-calendar) dans cette application (même thème, même authentification, un seul déploiement).
|
||||
|
|
|
|||
|
|
@ -301,6 +301,26 @@ h3 { font-size: 1rem; margin: 0 0 0.5rem; }
|
|||
|
||||
.section-header h2 { margin: 0; }
|
||||
|
||||
.search-bar {
|
||||
position: relative;
|
||||
max-width: 360px;
|
||||
margin: 0 0 1rem;
|
||||
}
|
||||
|
||||
.search-bar input {
|
||||
padding-left: 2.2rem;
|
||||
}
|
||||
|
||||
.search-icon {
|
||||
position: absolute;
|
||||
left: 0.65rem;
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
color: var(--muted);
|
||||
display: flex;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
/* ---------- Cards / grids ---------- */
|
||||
|
||||
.card-grid {
|
||||
|
|
|
|||
|
|
@ -38,6 +38,11 @@
|
|||
</form>
|
||||
</div>
|
||||
|
||||
<div class="search-bar">
|
||||
<span class="search-icon" aria-hidden="true"><svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="11" cy="11" r="7"></circle><path d="M21 21l-4.35-4.35"></path></svg></span>
|
||||
<input type="search" id="song-search-input" placeholder="Rechercher un morceau ou un artiste...">
|
||||
</div>
|
||||
|
||||
<div id="songs-list" class="card-grid"></div>
|
||||
</main>
|
||||
|
||||
|
|
|
|||
|
|
@ -32,10 +32,12 @@ function timelineSongRow(song) {
|
|||
function timelineConcertBlock(concert) {
|
||||
const main = concert.songs.filter((s) => !s.is_encore);
|
||||
const encore = concert.songs.filter((s) => s.is_encore);
|
||||
const playlistUrl = youtubePlaylistUrl(concert.songs.map((s) => s.youtube_url).filter(Boolean));
|
||||
return `
|
||||
<div class="card timeline-concert">
|
||||
<div class="card-title">${escapeHtml(concert.name || 'Concert')}</div>
|
||||
<div class="card-subtitle">${escapeHtml(concert.venue || '')} · ${formatDate(concert.concert_date)}</div>
|
||||
${playlistUrl ? `<div class="song-links"><a class="pill-link youtube" href="${playlistUrl}" target="_blank" rel="noopener">► Écouter la setlist sur YouTube</a></div>` : ''}
|
||||
|
||||
<div class="setlist-section">
|
||||
<h3>Setlist</h3>
|
||||
|
|
|
|||
|
|
@ -40,6 +40,15 @@ function youtubeThumbnailUrl(url) {
|
|||
return videoId ? `https://img.youtube.com/vi/${videoId}/mqdefault.jpg` : null;
|
||||
}
|
||||
|
||||
// Builds a link that opens several videos back-to-back in YouTube's
|
||||
// temporary "mix" player — no API call, no auth, just the video IDs we
|
||||
// already have from each song's youtube_url. Returns null if none of the
|
||||
// given URLs have a recognizable YouTube video ID.
|
||||
function youtubePlaylistUrl(urls) {
|
||||
const ids = urls.map(youtubeVideoId).filter(Boolean);
|
||||
return ids.length ? `https://www.youtube.com/watch_videos?video_ids=${ids.join(',')}` : null;
|
||||
}
|
||||
|
||||
function initials(name) {
|
||||
return String(name || '?')
|
||||
.trim()
|
||||
|
|
|
|||
|
|
@ -1,9 +1,16 @@
|
|||
let me = null;
|
||||
let instruments = [];
|
||||
let songs = [];
|
||||
let searchQuery = '';
|
||||
const expanded = new Set();
|
||||
const editing = new Set();
|
||||
|
||||
function matchesSearch(song, query) {
|
||||
const q = query.trim().toLowerCase();
|
||||
if (!q) return true;
|
||||
return song.title.toLowerCase().includes(q) || song.artist.toLowerCase().includes(q);
|
||||
}
|
||||
|
||||
async function loadInstruments() {
|
||||
instruments = await api.get('/api/instruments');
|
||||
}
|
||||
|
|
@ -131,7 +138,11 @@ function tutorialCardTemplate(t) {
|
|||
|
||||
function renderSongs() {
|
||||
const container = document.getElementById('songs-list');
|
||||
renderList(container, songs, songCardTemplate, 'Aucun morceau au répertoire pour le moment.');
|
||||
const visibleSongs = songs.filter((s) => matchesSearch(s, searchQuery));
|
||||
const emptyMessage = searchQuery.trim()
|
||||
? 'Aucun morceau ne correspond à la recherche.'
|
||||
: 'Aucun morceau au répertoire pour le moment.';
|
||||
renderList(container, visibleSongs, songCardTemplate, emptyMessage);
|
||||
|
||||
document.querySelectorAll('.toggle-tutorials').forEach((btn) => {
|
||||
btn.addEventListener('click', () => onToggleTutorials(parseInt(btn.dataset.id, 10)));
|
||||
|
|
@ -316,5 +327,9 @@ async function onSongCandidateSelected(candidate) {
|
|||
dropdownId: 'song-title-dropdown',
|
||||
onSelect: onSongCandidateSelected,
|
||||
});
|
||||
document.getElementById('song-search-input').addEventListener('input', (e) => {
|
||||
searchQuery = e.target.value;
|
||||
renderSongs();
|
||||
});
|
||||
await loadSongs();
|
||||
})();
|
||||
|
|
|
|||
|
|
@ -32,10 +32,12 @@ function createSetlistEditor({
|
|||
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>`;
|
||||
const playlistUrl = youtubePlaylistUrl([...main, ...encore].map((s) => s.youtube_url).filter(Boolean));
|
||||
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>
|
||||
${playlistUrl ? `<div class="song-links"><a class="pill-link youtube" href="${playlistUrl}" target="_blank" rel="noopener">► Écouter la setlist sur YouTube</a></div>` : ''}
|
||||
</div>
|
||||
<div class="setlist-section">
|
||||
<h3>Setlist</h3>
|
||||
|
|
|
|||
|
|
@ -57,7 +57,8 @@ async function findById(id) {
|
|||
|
||||
async function findSongs(setlistId) {
|
||||
const { rows } = await pool.query(
|
||||
`SELECT ss.id, ss.setlist_id, ss.song_id, s.title, s.artist, ss.position, ss.note, ss.is_encore
|
||||
`SELECT ss.id, ss.setlist_id, ss.song_id, s.title, s.artist, s.youtube_url, s.spotify_url,
|
||||
ss.position, ss.note, ss.is_encore
|
||||
FROM setlist_songs ss
|
||||
JOIN songs s ON s.id = ss.song_id
|
||||
WHERE ss.setlist_id = $1
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue