update: autocomplete for suggestions too

This commit is contained in:
Nathan FONTEYNE 2026-07-08 15:27:34 +02:00
parent 6d8fe814b0
commit b17715eaff
7 changed files with 141 additions and 93 deletions

View file

@ -112,6 +112,7 @@ nav#main-nav {
align-items: center; align-items: center;
gap: 0.4rem; gap: 0.4rem;
margin-right: auto; margin-right: auto;
text-decoration: none;
} }
.brand .brand-dot { .brand .brand-dot {

View file

@ -44,6 +44,7 @@
<script src="/js/api.js"></script> <script src="/js/api.js"></script>
<script src="/js/render.js"></script> <script src="/js/render.js"></script>
<script src="/js/nav.js"></script> <script src="/js/nav.js"></script>
<script src="/js/autocomplete.js"></script>
<script src="/js/repertoire.js"></script> <script src="/js/repertoire.js"></script>
</body> </body>
</html> </html>

91
public/js/autocomplete.js Normal file
View file

@ -0,0 +1,91 @@
function createTitleAutocomplete({ inputId, dropdownId, onSelect }) {
const input = document.getElementById(inputId);
const dropdown = document.getElementById(dropdownId);
let candidates = [];
let highlight = -1;
let timer = null;
function itemTemplate(c, index) {
const img = c.artworkUrl
? `<img src="${escapeHtml(c.artworkUrl)}" alt="">`
: `<span class="autocomplete-thumb-placeholder">&#9835;</span>`;
return `
<div class="autocomplete-item${index === highlight ? ' active' : ''}" data-index="${index}">
${img}
<div>
<div>${escapeHtml(c.title)}</div>
<div class="card-subtitle">${escapeHtml(c.artist)}</div>
</div>
</div>
`;
}
function render() {
if (!candidates.length) {
dropdown.style.display = 'none';
dropdown.innerHTML = '';
return;
}
dropdown.innerHTML = candidates.map(itemTemplate).join('');
dropdown.style.display = 'block';
dropdown.querySelectorAll('.autocomplete-item').forEach((el) => {
el.addEventListener('mousedown', (e) => {
e.preventDefault();
select(candidates[parseInt(el.dataset.index, 10)]);
});
});
}
function close() {
candidates = [];
highlight = -1;
render();
}
function select(candidate) {
close();
onSelect(candidate);
}
input.addEventListener('input', () => {
const query = input.value.trim();
clearTimeout(timer);
if (query.length < 2) {
close();
return;
}
timer = setTimeout(async () => {
try {
candidates = await api.get(`/api/music-search?q=${encodeURIComponent(query)}`);
highlight = -1;
render();
} catch (err) {
close();
}
}, 300);
});
input.addEventListener('keydown', (e) => {
if (!candidates.length) return;
if (e.key === 'ArrowDown') {
e.preventDefault();
highlight = Math.min(highlight + 1, candidates.length - 1);
render();
} else if (e.key === 'ArrowUp') {
e.preventDefault();
highlight = Math.max(highlight - 1, 0);
render();
} else if (e.key === 'Enter' && highlight >= 0) {
e.preventDefault();
select(candidates[highlight]);
} else if (e.key === 'Escape') {
close();
}
});
input.addEventListener('blur', () => {
setTimeout(close, 150);
});
return { close };
}

View file

@ -23,7 +23,7 @@ async function initNav(activePage) {
const me = await api.get('/api/users/me'); const me = await api.get('/api/users/me');
const nav = document.getElementById('main-nav'); const nav = document.getElementById('main-nav');
nav.innerHTML = ` nav.innerHTML = `
<span class="brand"><span class="brand-dot">&#9835;</span> Octane</span> <a class="brand" href="/index.html"><span class="brand-dot">&#9835;</span> Octane</a>
<button type="button" class="nav-toggle" id="nav-toggle" aria-label="Menu">&#9776;</button> <button type="button" class="nav-toggle" id="nav-toggle" aria-label="Menu">&#9776;</button>
<div class="nav-links" id="nav-links"> <div class="nav-links" id="nav-links">
<a href="/index.html" class="${activePage === 'repertoire' ? 'active' : ''}">Répertoire</a> <a href="/index.html" class="${activePage === 'repertoire' ? 'active' : ''}">Répertoire</a>

View file

@ -232,7 +232,7 @@ async function onAddSong(e) {
await api.post('/api/songs', { title, artist, notes, youtubeUrl, spotifyUrl }); await api.post('/api/songs', { title, artist, notes, youtubeUrl, spotifyUrl });
form.reset(); form.reset();
document.getElementById('song-links-status').textContent = ''; document.getElementById('song-links-status').textContent = '';
closeAutocompleteDropdown(); songAutocomplete.close();
setAddSongPanelOpen(false); setAddSongPanelOpen(false);
await loadSongs(); await loadSongs();
} catch (err) { } catch (err) {
@ -277,52 +277,11 @@ function showError(message) {
document.getElementById('error').innerHTML = `<div class="error-banner">${escapeHtml(message)}</div>`; document.getElementById('error').innerHTML = `<div class="error-banner">${escapeHtml(message)}</div>`;
} }
let autocompleteTimer = null; let songAutocomplete = null;
let autocompleteCandidates = [];
let autocompleteHighlight = -1;
function candidateItemTemplate(c, index) { async function onSongCandidateSelected(candidate) {
const img = c.artworkUrl
? `<img src="${escapeHtml(c.artworkUrl)}" alt="">`
: `<span class="autocomplete-thumb-placeholder">&#9835;</span>`;
return `
<div class="autocomplete-item${index === autocompleteHighlight ? ' active' : ''}" data-index="${index}">
${img}
<div>
<div>${escapeHtml(c.title)}</div>
<div class="card-subtitle">${escapeHtml(c.artist)}</div>
</div>
</div>
`;
}
function renderAutocompleteDropdown() {
const dropdown = document.getElementById('song-title-dropdown');
if (!autocompleteCandidates.length) {
dropdown.style.display = 'none';
dropdown.innerHTML = '';
return;
}
dropdown.innerHTML = autocompleteCandidates.map(candidateItemTemplate).join('');
dropdown.style.display = 'block';
dropdown.querySelectorAll('.autocomplete-item').forEach((el) => {
el.addEventListener('mousedown', (e) => {
e.preventDefault();
selectCandidate(autocompleteCandidates[parseInt(el.dataset.index, 10)]);
});
});
}
function closeAutocompleteDropdown() {
autocompleteCandidates = [];
autocompleteHighlight = -1;
renderAutocompleteDropdown();
}
async function selectCandidate(candidate) {
document.getElementById('song-title-input').value = candidate.title; document.getElementById('song-title-input').value = candidate.title;
document.getElementById('song-artist-input').value = candidate.artist; document.getElementById('song-artist-input').value = candidate.artist;
closeAutocompleteDropdown();
const statusEl = document.getElementById('song-links-status'); const statusEl = document.getElementById('song-links-status');
statusEl.textContent = 'Recherche des liens YouTube / Spotify…'; statusEl.textContent = 'Recherche des liens YouTube / Spotify…';
@ -344,50 +303,6 @@ async function selectCandidate(candidate) {
} }
} }
function initTitleAutocomplete() {
const input = document.getElementById('song-title-input');
input.addEventListener('input', () => {
const query = input.value.trim();
clearTimeout(autocompleteTimer);
if (query.length < 2) {
closeAutocompleteDropdown();
return;
}
autocompleteTimer = setTimeout(async () => {
try {
autocompleteCandidates = await api.get(`/api/music-search?q=${encodeURIComponent(query)}`);
autocompleteHighlight = -1;
renderAutocompleteDropdown();
} catch (err) {
closeAutocompleteDropdown();
}
}, 300);
});
input.addEventListener('keydown', (e) => {
if (!autocompleteCandidates.length) return;
if (e.key === 'ArrowDown') {
e.preventDefault();
autocompleteHighlight = Math.min(autocompleteHighlight + 1, autocompleteCandidates.length - 1);
renderAutocompleteDropdown();
} else if (e.key === 'ArrowUp') {
e.preventDefault();
autocompleteHighlight = Math.max(autocompleteHighlight - 1, 0);
renderAutocompleteDropdown();
} else if (e.key === 'Enter' && autocompleteHighlight >= 0) {
e.preventDefault();
selectCandidate(autocompleteCandidates[autocompleteHighlight]);
} else if (e.key === 'Escape') {
closeAutocompleteDropdown();
}
});
input.addEventListener('blur', () => {
setTimeout(closeAutocompleteDropdown, 150);
});
}
(async function init() { (async function init() {
me = await initNav('repertoire'); me = await initNav('repertoire');
await loadInstruments(); await loadInstruments();
@ -396,6 +311,10 @@ function initTitleAutocomplete() {
const isOpen = document.getElementById('add-song-panel').style.display !== 'none'; const isOpen = document.getElementById('add-song-panel').style.display !== 'none';
setAddSongPanelOpen(!isOpen); setAddSongPanelOpen(!isOpen);
}); });
initTitleAutocomplete(); songAutocomplete = createTitleAutocomplete({
inputId: 'song-title-input',
dropdownId: 'song-title-dropdown',
onSelect: onSongCandidateSelected,
});
await loadSongs(); await loadSongs();
})(); })();

View file

@ -192,6 +192,8 @@ async function onAddSuggestion(e) {
try { try {
await api.post('/api/suggestions', { title, artist, youtubeUrl, description }); await api.post('/api/suggestions', { title, artist, youtubeUrl, description });
form.reset(); form.reset();
document.getElementById('suggestion-link-status').textContent = '';
if (suggestionAutocomplete) suggestionAutocomplete.close();
setAddSuggestionPanelOpen(false); setAddSuggestionPanelOpen(false);
await loadSuggestions(); await loadSuggestions();
} catch (err) { } catch (err) {
@ -208,6 +210,28 @@ function showError(message) {
document.getElementById('error').innerHTML = `<div class="error-banner">${escapeHtml(message)}</div>`; document.getElementById('error').innerHTML = `<div class="error-banner">${escapeHtml(message)}</div>`;
} }
let suggestionAutocomplete = null;
async function onSuggestionCandidateSelected(candidate) {
document.getElementById('suggestion-title-input').value = candidate.title;
document.getElementById('suggestion-artist-input').value = candidate.artist;
const statusEl = document.getElementById('suggestion-link-status');
statusEl.textContent = 'Recherche du lien YouTube…';
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');
if (links.youtubeUrl && !youtubeInput.value) youtubeInput.value = links.youtubeUrl;
statusEl.textContent = links.youtubeUrl
? 'Lien YouTube trouvé automatiquement.'
: 'Aucun lien YouTube trouvé automatiquement — à saisir manuellement.';
} catch (err) {
statusEl.textContent = '';
}
}
(async function init() { (async function init() {
me = await initNav('suggestions'); me = await initNav('suggestions');
document.getElementById('add-suggestion-form').addEventListener('submit', onAddSuggestion); document.getElementById('add-suggestion-form').addEventListener('submit', onAddSuggestion);
@ -215,5 +239,10 @@ function showError(message) {
const isOpen = document.getElementById('add-suggestion-panel').style.display !== 'none'; const isOpen = document.getElementById('add-suggestion-panel').style.display !== 'none';
setAddSuggestionPanelOpen(!isOpen); setAddSuggestionPanelOpen(!isOpen);
}); });
suggestionAutocomplete = createTitleAutocomplete({
inputId: 'suggestion-title-input',
dropdownId: 'suggestion-title-dropdown',
onSelect: onSuggestionCandidateSelected,
});
await loadSuggestions(); await loadSuggestions();
})(); })();

View file

@ -23,10 +23,16 @@
<div class="panel" id="add-suggestion-panel" style="display:none"> <div class="panel" id="add-suggestion-panel" style="display:none">
<form id="add-suggestion-form" class="stacked-form"> <form id="add-suggestion-form" class="stacked-form">
<label>Titre <input name="title" required></label> <div class="autocomplete-wrapper">
<label>Artiste <input name="artist"></label> <label>Titre
<label>Lien YouTube <input name="youtubeUrl" type="url" required placeholder="https://youtube.com/watch?v=..."></label> <input name="title" id="suggestion-title-input" autocomplete="off" required>
</label>
<div class="autocomplete-dropdown" id="suggestion-title-dropdown" style="display:none"></div>
</div>
<label>Artiste <input name="artist" id="suggestion-artist-input"></label>
<label>Lien YouTube <input name="youtubeUrl" id="suggestion-youtube-input" type="url" required placeholder="https://youtube.com/watch?v=..."></label>
<label>Note / détails <textarea name="description" placeholder="Pourquoi ce morceau, difficulté, arrangement envisagé..."></textarea></label> <label>Note / détails <textarea name="description" placeholder="Pourquoi ce morceau, difficulté, arrangement envisagé..."></textarea></label>
<p class="note" id="suggestion-link-status"></p>
<button type="submit">Proposer</button> <button type="submit">Proposer</button>
</form> </form>
</div> </div>
@ -37,6 +43,7 @@
<script src="/js/api.js"></script> <script src="/js/api.js"></script>
<script src="/js/render.js"></script> <script src="/js/render.js"></script>
<script src="/js/nav.js"></script> <script src="/js/nav.js"></script>
<script src="/js/autocomplete.js"></script>
<script src="/js/suggestions.js"></script> <script src="/js/suggestions.js"></script>
</body> </body>
</html> </html>