diff --git a/README.md b/README.md index f97815b..d5e768b 100644 --- a/README.md +++ b/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). diff --git a/public/css/style.css b/public/css/style.css index 4a04656..b3c1209 100644 --- a/public/css/style.css +++ b/public/css/style.css @@ -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 { diff --git a/public/index.html b/public/index.html index a09dd8e..ce5de19 100644 --- a/public/index.html +++ b/public/index.html @@ -38,6 +38,11 @@ + +
diff --git a/public/js/history.js b/public/js/history.js index 19bb598..3aa3eb2 100644 --- a/public/js/history.js +++ b/public/js/history.js @@ -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 `
${escapeHtml(concert.name || 'Concert')}
${escapeHtml(concert.venue || '')} · ${formatDate(concert.concert_date)}
+ ${playlistUrl ? `` : ''}

Setlist

diff --git a/public/js/render.js b/public/js/render.js index 498b83d..3d20c18 100644 --- a/public/js/render.js +++ b/public/js/render.js @@ -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() diff --git a/public/js/repertoire.js b/public/js/repertoire.js index b3fa57e..413cee4 100644 --- a/public/js/repertoire.js +++ b/public/js/repertoire.js @@ -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(); })(); diff --git a/public/js/setlistEditor.js b/public/js/setlistEditor.js index c1afc8a..4798ceb 100644 --- a/public/js/setlistEditor.js +++ b/public/js/setlistEditor.js @@ -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) => `
  • ${escapeHtml(s.title)} — ${escapeHtml(s.artist)}${s.note ? `${escapeHtml(s.note)}` : ''}
  • `; + const playlistUrl = youtubePlaylistUrl([...main, ...encore].map((s) => s.youtube_url).filter(Boolean)); return `
    ${escapeHtml(setlist.name || 'Concert')}
    ${escapeHtml(setlist.venue || '')} · ${formatDate(setlist.concert_date)}
    + ${playlistUrl ? `` : ''}

    Setlist

    diff --git a/src/repositories/setlistsRepo.js b/src/repositories/setlistsRepo.js index be1bd35..53b5450 100644 --- a/src/repositories/setlistsRepo.js +++ b/src/repositories/setlistsRepo.js @@ -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