mirror of
https://github.com/nfonteyne/octane-website.git
synced 2026-09-03 23:24:48 +02:00
update: autocomplete for suggestions too
This commit is contained in:
parent
6d8fe814b0
commit
b17715eaff
7 changed files with 141 additions and 93 deletions
|
|
@ -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 {
|
||||||
|
|
|
||||||
|
|
@ -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
91
public/js/autocomplete.js
Normal 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">♫</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 };
|
||||||
|
}
|
||||||
|
|
@ -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">♫</span> Octane</span>
|
<a class="brand" href="/index.html"><span class="brand-dot">♫</span> Octane</a>
|
||||||
<button type="button" class="nav-toggle" id="nav-toggle" aria-label="Menu">☰</button>
|
<button type="button" class="nav-toggle" id="nav-toggle" aria-label="Menu">☰</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>
|
||||||
|
|
|
||||||
|
|
@ -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">♫</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();
|
||||||
})();
|
})();
|
||||||
|
|
|
||||||
|
|
@ -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();
|
||||||
})();
|
})();
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue