mirror of
https://github.com/nfonteyne/octane-website.git
synced 2026-09-03 23:24:48 +02:00
update: approve or disaprove suggrestions by sweeping
This commit is contained in:
parent
061df3b1c6
commit
e368a8681b
5 changed files with 566 additions and 54 deletions
|
|
@ -1136,3 +1136,161 @@ a.back-link:hover { color: var(--accent); }
|
|||
.timeline-embed,
|
||||
.timeline-no-embed { width: 100%; max-width: none; }
|
||||
}
|
||||
|
||||
/* ---------- View toggle (suggestions) ---------- */
|
||||
|
||||
.view-toggle {
|
||||
display: flex;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-sm);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.view-toggle-btn {
|
||||
background: var(--surface);
|
||||
color: var(--text);
|
||||
border: none;
|
||||
border-radius: 0;
|
||||
padding: 0.45rem 0.9rem;
|
||||
font-size: 0.88rem;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.view-toggle-btn + .view-toggle-btn { border-left: 1px solid var(--border); }
|
||||
|
||||
.view-toggle-btn.active {
|
||||
background: var(--accent);
|
||||
color: var(--accent-contrast);
|
||||
}
|
||||
|
||||
/* ---------- Swipe / "Vote rapide" view ---------- */
|
||||
|
||||
.swipe-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin: 0 0 1rem;
|
||||
color: var(--muted);
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.swipe-stack {
|
||||
position: relative;
|
||||
max-width: 380px;
|
||||
height: 420px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.swipe-card {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius);
|
||||
box-shadow: var(--shadow);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
touch-action: none;
|
||||
transform: scale(calc(1 - var(--depth, 0) * 0.04)) translateY(calc(var(--depth, 0) * 10px));
|
||||
transition: transform 0.2s ease;
|
||||
}
|
||||
|
||||
.swipe-card-media {
|
||||
position: relative;
|
||||
flex: 1 1 auto;
|
||||
background: var(--surface-alt);
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.swipe-card-media img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.swipe-card-media .song-thumb.placeholder {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
font-size: 3rem;
|
||||
}
|
||||
|
||||
.swipe-card-media .play-overlay {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: rgba(0, 0, 0, 0.15);
|
||||
color: #fff;
|
||||
font-size: 2rem;
|
||||
text-decoration: none;
|
||||
opacity: 0;
|
||||
transition: opacity 0.15s ease;
|
||||
}
|
||||
|
||||
.swipe-card-media:hover .play-overlay { opacity: 1; }
|
||||
|
||||
.swipe-stamp {
|
||||
position: absolute;
|
||||
top: 1.2rem;
|
||||
padding: 0.3rem 0.7rem;
|
||||
border: 3px solid;
|
||||
border-radius: var(--radius-sm);
|
||||
font-weight: 700;
|
||||
font-size: 1.1rem;
|
||||
letter-spacing: 0.04em;
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
transform: rotate(-12deg);
|
||||
}
|
||||
|
||||
.swipe-stamp.approve { left: 1.2rem; color: var(--success); border-color: var(--success); }
|
||||
.swipe-stamp.reject { right: 1.2rem; color: var(--danger); border-color: var(--danger); transform: rotate(12deg); }
|
||||
|
||||
.swipe-card-body {
|
||||
flex: 0 0 auto;
|
||||
padding: 0.9rem 1.1rem;
|
||||
}
|
||||
|
||||
.swipe-controls {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
gap: 1.5rem;
|
||||
margin: 1.25rem 0;
|
||||
}
|
||||
|
||||
.swipe-btn {
|
||||
width: 56px;
|
||||
height: 56px;
|
||||
border-radius: 50%;
|
||||
border: 1px solid var(--border);
|
||||
background: var(--surface);
|
||||
font-size: 1.4rem;
|
||||
cursor: pointer;
|
||||
box-shadow: var(--shadow);
|
||||
}
|
||||
|
||||
.swipe-btn.approve { color: var(--success); }
|
||||
.swipe-btn.reject { color: var(--danger); }
|
||||
|
||||
/* ---------- "Mes votes" review modal ---------- */
|
||||
|
||||
.modal-wide { max-width: 520px; max-height: 80vh; overflow-y: auto; }
|
||||
|
||||
.vote-review-row {
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-sm);
|
||||
padding: 0.75rem 0.9rem;
|
||||
margin-bottom: 0.6rem;
|
||||
}
|
||||
|
||||
.vote-review-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.vote-review-detail { margin-top: 0.75rem; }
|
||||
|
|
|
|||
|
|
@ -5,12 +5,17 @@ const expanded = new Set();
|
|||
async function loadSuggestions() {
|
||||
suggestions = await api.get('/api/suggestions');
|
||||
renderSuggestions();
|
||||
refreshSwipeView();
|
||||
}
|
||||
|
||||
function statusLabel(status) {
|
||||
return { pending: 'En attente', approved: 'Approuvé', rejected: 'Rejeté' }[status] || status;
|
||||
}
|
||||
|
||||
function findSuggestion(id) {
|
||||
return suggestions.find((s) => s.id === id);
|
||||
}
|
||||
|
||||
function suggestionThumbTemplate(s) {
|
||||
const thumb = youtubeThumbnailUrl(s.youtube_url);
|
||||
if (thumb) {
|
||||
|
|
@ -51,14 +56,22 @@ function suggestionTemplate(s) {
|
|||
`;
|
||||
}
|
||||
|
||||
function detailTemplate(s) {
|
||||
// Rendered once when a detail panel opens — never re-rendered afterward, so the
|
||||
// iframe embed isn't destroyed/reloaded just because some vote count changed.
|
||||
function mediaTemplate(s) {
|
||||
const embed = youtubeEmbedUrl(s.youtube_url);
|
||||
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">♫ Spotify</a></div>` : ''}
|
||||
${s.description ? `<div class="suggestion-note">${escapeHtml(s.description)}</div>` : ''}
|
||||
`;
|
||||
}
|
||||
|
||||
// Re-rendered after any vote/promote/reject change — deliberately excludes the
|
||||
// media block above so the embed stays mounted.
|
||||
function voteSectionTemplate(s) {
|
||||
const myVote = s.votes.find((v) => v.user_id === me.id);
|
||||
return `
|
||||
<form class="inline-form vote-form" data-id="${s.id}">
|
||||
<label>Mon vote
|
||||
<select name="vote">
|
||||
|
|
@ -85,6 +98,13 @@ function detailTemplate(s) {
|
|||
`;
|
||||
}
|
||||
|
||||
function detailTemplate(s) {
|
||||
return `
|
||||
<div class="media-wrap">${mediaTemplate(s)}</div>
|
||||
<div class="vote-section">${voteSectionTemplate(s)}</div>
|
||||
`;
|
||||
}
|
||||
|
||||
function voteItemTemplate(v) {
|
||||
const icon = v.vote === 'approve' ? '✔' : '✘';
|
||||
return `
|
||||
|
|
@ -103,6 +123,33 @@ function renderSuggestions() {
|
|||
});
|
||||
}
|
||||
|
||||
// Updates a card's status/tally text in place, without touching its detail panel/iframe.
|
||||
function patchCardSummary(s) {
|
||||
const card = document.querySelector(`.card[data-suggestion-id="${s.id}"]`);
|
||||
if (!card) return;
|
||||
const subtitle = card.querySelector('.card-subtitle');
|
||||
if (subtitle) subtitle.textContent = `Proposé par ${s.suggested_by_name} · ${statusLabel(s.status)}`;
|
||||
const approveEl = card.querySelector('.vote-tally .approve');
|
||||
const rejectEl = card.querySelector('.vote-tally .reject');
|
||||
if (approveEl) approveEl.textContent = `✔ ${s.approve_count}`;
|
||||
if (rejectEl) rejectEl.textContent = `✘ ${s.reject_count}`;
|
||||
const promoteBtn = card.querySelector('.promote-btn');
|
||||
if (promoteBtn) {
|
||||
promoteBtn.disabled = !!s.promoted_song_id;
|
||||
promoteBtn.textContent = s.promoted_song_id ? 'Déjà au répertoire' : 'Ajouter au répertoire';
|
||||
}
|
||||
}
|
||||
|
||||
function bindVoteSectionListeners(container, id) {
|
||||
container.querySelector('.vote-form').addEventListener('submit', (e) => onVote(e, id));
|
||||
const promoteBtn = container.querySelector('.promote-btn');
|
||||
if (promoteBtn) promoteBtn.addEventListener('click', () => onPromote(id));
|
||||
const rejectBtn = container.querySelector('.reject-btn');
|
||||
if (rejectBtn) rejectBtn.addEventListener('click', () => onReject(id));
|
||||
const deleteBtn = container.querySelector('.delete-btn');
|
||||
if (deleteBtn) deleteBtn.addEventListener('click', () => onDelete(id));
|
||||
}
|
||||
|
||||
async function onToggleDetail(id) {
|
||||
if (expanded.has(id)) {
|
||||
expanded.delete(id);
|
||||
|
|
@ -121,18 +168,53 @@ async function loadDetail(id) {
|
|||
const container = document.querySelector(`[data-detail-for="${id}"]`);
|
||||
if (!container) return;
|
||||
container.innerHTML = detailTemplate(detail);
|
||||
container.querySelector('.vote-form').addEventListener('submit', (e) => onVote(e, id));
|
||||
const promoteBtn = container.querySelector('.promote-btn');
|
||||
if (promoteBtn) promoteBtn.addEventListener('click', () => onPromote(id));
|
||||
const rejectBtn = container.querySelector('.reject-btn');
|
||||
if (rejectBtn) rejectBtn.addEventListener('click', () => onReject(id));
|
||||
const deleteBtn = container.querySelector('.delete-btn');
|
||||
if (deleteBtn) deleteBtn.addEventListener('click', () => onDelete(id));
|
||||
bindVoteSectionListeners(container.querySelector('.vote-section'), id);
|
||||
} catch (err) {
|
||||
showError(err.message);
|
||||
}
|
||||
}
|
||||
|
||||
// Re-fetches one suggestion's detail and refreshes only its vote-section (and
|
||||
// card summary), leaving any mounted iframe untouched — used after voting,
|
||||
// promoting or rejecting so the YouTube embed never reloads for unrelated changes.
|
||||
async function refreshVoteSection(id) {
|
||||
const detail = await api.get(`/api/suggestions/${id}`);
|
||||
const myVote = detail.votes.find((v) => v.user_id === me.id);
|
||||
detail.my_vote = myVote?.vote || null;
|
||||
detail.my_vote_comment = myVote?.comment || null;
|
||||
const idx = suggestions.findIndex((s) => s.id === id);
|
||||
if (idx !== -1) suggestions[idx] = detail;
|
||||
patchCardSummary(detail);
|
||||
const container = document.querySelector(`[data-detail-for="${id}"] .vote-section`);
|
||||
if (container) {
|
||||
container.innerHTML = voteSectionTemplate(detail);
|
||||
bindVoteSectionListeners(container, id);
|
||||
}
|
||||
refreshSwipeView();
|
||||
return detail;
|
||||
}
|
||||
|
||||
function removeCard(id) {
|
||||
const card = document.querySelector(`.card[data-suggestion-id="${id}"]`);
|
||||
if (card) card.remove();
|
||||
const idx = suggestions.findIndex((s) => s.id === id);
|
||||
if (idx !== -1) suggestions.splice(idx, 1);
|
||||
expanded.delete(id);
|
||||
if (!suggestions.length) renderSuggestions();
|
||||
refreshSwipeView();
|
||||
}
|
||||
|
||||
function appendCard(s) {
|
||||
suggestions.push(s);
|
||||
const container = document.getElementById('suggestions-list');
|
||||
const emptyMsg = container.querySelector('.empty');
|
||||
if (emptyMsg) container.innerHTML = '';
|
||||
container.insertAdjacentHTML('beforeend', suggestionTemplate(s));
|
||||
const btn = container.querySelector(`.toggle-detail[data-id="${s.id}"]`);
|
||||
if (btn) btn.addEventListener('click', () => onToggleDetail(s.id));
|
||||
refreshSwipeView();
|
||||
}
|
||||
|
||||
async function onVote(e, id) {
|
||||
e.preventDefault();
|
||||
const form = e.target;
|
||||
|
|
@ -140,10 +222,7 @@ async function onVote(e, id) {
|
|||
const comment = form.comment.value.trim();
|
||||
try {
|
||||
await api.post(`/api/suggestions/${id}/vote`, { vote, comment });
|
||||
await loadSuggestions();
|
||||
expanded.add(id);
|
||||
renderSuggestions();
|
||||
await loadDetail(id);
|
||||
await refreshVoteSection(id);
|
||||
} catch (err) {
|
||||
showError(err.message);
|
||||
}
|
||||
|
|
@ -152,10 +231,7 @@ async function onVote(e, id) {
|
|||
async function onPromote(id) {
|
||||
try {
|
||||
await api.post(`/api/suggestions/${id}/promote`, {});
|
||||
await loadSuggestions();
|
||||
expanded.add(id);
|
||||
renderSuggestions();
|
||||
await loadDetail(id);
|
||||
await refreshVoteSection(id);
|
||||
} catch (err) {
|
||||
showError(err.message);
|
||||
}
|
||||
|
|
@ -164,10 +240,7 @@ async function onPromote(id) {
|
|||
async function onReject(id) {
|
||||
try {
|
||||
await api.patch(`/api/suggestions/${id}`, { status: 'rejected' });
|
||||
await loadSuggestions();
|
||||
expanded.add(id);
|
||||
renderSuggestions();
|
||||
await loadDetail(id);
|
||||
await refreshVoteSection(id);
|
||||
} catch (err) {
|
||||
showError(err.message);
|
||||
}
|
||||
|
|
@ -176,8 +249,7 @@ async function onReject(id) {
|
|||
async function onDelete(id) {
|
||||
try {
|
||||
await api.del(`/api/suggestions/${id}`);
|
||||
expanded.delete(id);
|
||||
await loadSuggestions();
|
||||
removeCard(id);
|
||||
} catch (err) {
|
||||
showError(err.message);
|
||||
}
|
||||
|
|
@ -192,12 +264,19 @@ async function onAddSuggestion(e) {
|
|||
const spotifyUrl = form.spotifyUrl.value.trim();
|
||||
const description = form.description.value.trim();
|
||||
try {
|
||||
await api.post('/api/suggestions', { title, artist, youtubeUrl, spotifyUrl, description });
|
||||
const created = await api.post('/api/suggestions', { title, artist, youtubeUrl, spotifyUrl, description });
|
||||
form.reset();
|
||||
document.getElementById('suggestion-link-status').textContent = '';
|
||||
if (suggestionAutocomplete) suggestionAutocomplete.close();
|
||||
setAddSuggestionPanelOpen(false);
|
||||
await loadSuggestions();
|
||||
appendCard({
|
||||
...created,
|
||||
suggested_by_name: me.name,
|
||||
approve_count: 0,
|
||||
reject_count: 0,
|
||||
my_vote: null,
|
||||
my_vote_comment: null,
|
||||
});
|
||||
} catch (err) {
|
||||
showError(err.message);
|
||||
}
|
||||
|
|
@ -238,6 +317,224 @@ async function onSuggestionCandidateSelected(candidate) {
|
|||
}
|
||||
}
|
||||
|
||||
// ---------- Swipe ("Vote rapide") view ----------
|
||||
|
||||
const VIEW_STORAGE_KEY = 'octane-suggestions-view';
|
||||
let currentView = localStorage.getItem(VIEW_STORAGE_KEY) === 'swipe' ? 'swipe' : 'list';
|
||||
let swipeQueue = [];
|
||||
let dragState = null;
|
||||
|
||||
function pendingSwipeQueue() {
|
||||
return suggestions.filter((s) => s.status === 'pending' && !s.my_vote);
|
||||
}
|
||||
|
||||
function setView(view) {
|
||||
currentView = view;
|
||||
localStorage.setItem(VIEW_STORAGE_KEY, view);
|
||||
document.getElementById('suggestions-list').style.display = view === 'list' ? '' : 'none';
|
||||
document.getElementById('add-suggestion-panel-wrap').style.display = view === 'list' ? '' : 'none';
|
||||
document.getElementById('swipe-view').style.display = view === 'swipe' ? '' : 'none';
|
||||
document.getElementById('view-toggle-list').classList.toggle('active', view === 'list');
|
||||
document.getElementById('view-toggle-swipe').classList.toggle('active', view === 'swipe');
|
||||
if (view === 'swipe') refreshSwipeView();
|
||||
}
|
||||
|
||||
function refreshSwipeView() {
|
||||
if (currentView !== 'swipe') return;
|
||||
swipeQueue = pendingSwipeQueue();
|
||||
renderSwipeStack();
|
||||
}
|
||||
|
||||
function swipeCardTemplate(s, depth) {
|
||||
const thumb = youtubeThumbnailUrl(s.youtube_url);
|
||||
return `
|
||||
<div class="swipe-card" data-id="${s.id}" style="--depth:${depth}">
|
||||
<div class="swipe-card-media">
|
||||
${thumb ? `<img src="${thumb}" alt="${escapeHtml(s.title)}" loading="lazy">` : `<div class="song-thumb placeholder">♫</div>`}
|
||||
<a class="play-overlay" href="${escapeHtml(s.youtube_url)}" target="_blank" rel="noopener" title="Écouter sur YouTube">►</a>
|
||||
<div class="swipe-stamp approve">J'APPROUVE</div>
|
||||
<div class="swipe-stamp reject">JE REJETTE</div>
|
||||
</div>
|
||||
<div class="swipe-card-body">
|
||||
<div class="card-title">${escapeHtml(s.title)}${s.artist ? ` — ${escapeHtml(s.artist)}` : ''}</div>
|
||||
<div class="card-subtitle">Proposé par ${escapeHtml(s.suggested_by_name)}</div>
|
||||
<div class="vote-tally">
|
||||
<span class="approve">✔ ${s.approve_count}</span>
|
||||
<span class="reject">✘ ${s.reject_count}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
function renderSwipeStack() {
|
||||
const stack = document.getElementById('swipe-stack');
|
||||
const counter = document.getElementById('swipe-counter');
|
||||
const empty = document.getElementById('swipe-empty');
|
||||
counter.textContent = swipeQueue.length ? `${swipeQueue.length} restante${swipeQueue.length > 1 ? 's' : ''}` : '';
|
||||
|
||||
if (!swipeQueue.length) {
|
||||
stack.innerHTML = '';
|
||||
empty.style.display = 'block';
|
||||
return;
|
||||
}
|
||||
empty.style.display = 'none';
|
||||
|
||||
const visible = swipeQueue.slice(0, 3);
|
||||
stack.innerHTML = visible.map((s, i) => swipeCardTemplate(s, i)).reverse().join('');
|
||||
|
||||
const topCard = stack.querySelector(`.swipe-card[data-id="${visible[0].id}"]`);
|
||||
if (topCard) bindSwipeCardEvents(topCard, visible[0]);
|
||||
}
|
||||
|
||||
function bindSwipeCardEvents(card, suggestion) {
|
||||
card.addEventListener('pointerdown', (e) => {
|
||||
if (e.pointerType === 'mouse') return;
|
||||
dragState = { id: card.dataset.id, startX: e.clientX, startY: e.clientY, dx: 0, dy: 0 };
|
||||
card.setPointerCapture(e.pointerId);
|
||||
});
|
||||
card.addEventListener('pointermove', (e) => {
|
||||
if (!dragState || dragState.id !== card.dataset.id) return;
|
||||
dragState.dx = e.clientX - dragState.startX;
|
||||
dragState.dy = e.clientY - dragState.startY;
|
||||
applyDragTransform(card, dragState.dx, dragState.dy);
|
||||
});
|
||||
const endDrag = (e) => {
|
||||
if (!dragState || dragState.id !== card.dataset.id) return;
|
||||
const { dx } = dragState;
|
||||
dragState = null;
|
||||
const threshold = 110;
|
||||
if (dx > threshold) {
|
||||
resolveCard(suggestion, 'approve', card);
|
||||
} else if (dx < -threshold) {
|
||||
resolveCard(suggestion, 'reject', card);
|
||||
} else {
|
||||
card.style.transition = 'transform 0.25s ease';
|
||||
card.style.transform = '';
|
||||
setTimeout(() => { card.style.transition = ''; }, 250);
|
||||
card.querySelectorAll('.swipe-stamp').forEach((el) => (el.style.opacity = 0));
|
||||
}
|
||||
};
|
||||
card.addEventListener('pointerup', endDrag);
|
||||
card.addEventListener('pointercancel', endDrag);
|
||||
}
|
||||
|
||||
function applyDragTransform(card, dx, dy) {
|
||||
const rotate = dx / 12;
|
||||
card.style.transform = `translate(${dx}px, ${dy}px) rotate(${rotate}deg)`;
|
||||
const approveStamp = card.querySelector('.swipe-stamp.approve');
|
||||
const rejectStamp = card.querySelector('.swipe-stamp.reject');
|
||||
const intensity = Math.min(Math.abs(dx) / 100, 1);
|
||||
if (dx > 0) {
|
||||
approveStamp.style.opacity = intensity;
|
||||
rejectStamp.style.opacity = 0;
|
||||
} else {
|
||||
rejectStamp.style.opacity = intensity;
|
||||
approveStamp.style.opacity = 0;
|
||||
}
|
||||
}
|
||||
|
||||
async function resolveCard(suggestion, vote, cardEl) {
|
||||
const card = cardEl || document.querySelector(`.swipe-card[data-id="${suggestion.id}"]`);
|
||||
if (card) {
|
||||
card.style.transition = 'transform 0.3s ease, opacity 0.3s ease';
|
||||
card.style.transform = `translate(${vote === 'approve' ? '' : '-'}600px, -40px) rotate(${vote === 'approve' ? '' : '-'}25deg)`;
|
||||
card.style.opacity = '0';
|
||||
card.style.pointerEvents = 'none';
|
||||
}
|
||||
try {
|
||||
await api.post(`/api/suggestions/${suggestion.id}/vote`, { vote, comment: '' });
|
||||
const idx = suggestions.findIndex((s) => s.id === suggestion.id);
|
||||
if (idx !== -1) {
|
||||
suggestions[idx].my_vote = vote;
|
||||
if (vote === 'approve') suggestions[idx].approve_count += 1;
|
||||
else suggestions[idx].reject_count += 1;
|
||||
}
|
||||
patchCardSummary(suggestions[idx]);
|
||||
} catch (err) {
|
||||
showError(err.message);
|
||||
}
|
||||
swipeQueue = swipeQueue.filter((s) => s.id !== suggestion.id);
|
||||
setTimeout(() => renderSwipeStack(), card ? 260 : 0);
|
||||
}
|
||||
|
||||
function onSwipeButtonClick(vote) {
|
||||
if (!swipeQueue.length) return;
|
||||
const top = swipeQueue[0];
|
||||
resolveCard(top, vote);
|
||||
}
|
||||
|
||||
// ---------- "Mes votes" review modal ----------
|
||||
|
||||
function openVotesModal() {
|
||||
renderVotesModalList();
|
||||
document.getElementById('votes-modal-overlay').classList.remove('hidden');
|
||||
}
|
||||
|
||||
function closeVotesModal() {
|
||||
document.getElementById('votes-modal-overlay').classList.add('hidden');
|
||||
}
|
||||
|
||||
function votesModalRowTemplate(s) {
|
||||
const isOpen = expanded.has(`modal-${s.id}`);
|
||||
return `
|
||||
<div class="vote-review-row" data-id="${s.id}">
|
||||
<div class="vote-review-header">
|
||||
<div>
|
||||
<div class="card-title">${escapeHtml(s.title)}${s.artist ? ` — ${escapeHtml(s.artist)}` : ''}</div>
|
||||
<div class="card-subtitle">Mon vote : ${s.my_vote === 'approve' ? '✔ Approuvé' : '✘ Rejeté'}</div>
|
||||
</div>
|
||||
<button class="secondary icon-btn toggle-vote-review" data-id="${s.id}">${isOpen ? 'Masquer' : 'Modifier'}</button>
|
||||
</div>
|
||||
<div class="vote-review-detail" style="${isOpen ? '' : 'display:none'}" data-review-for="${s.id}">
|
||||
<p class="empty">Chargement…</p>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
function renderVotesModalList() {
|
||||
const container = document.getElementById('votes-modal-list');
|
||||
const voted = suggestions.filter((s) => s.my_vote);
|
||||
renderList(container, voted, votesModalRowTemplate, "Vous n'avez pas encore voté.");
|
||||
container.querySelectorAll('.toggle-vote-review').forEach((btn) => {
|
||||
btn.addEventListener('click', () => onToggleVoteReview(parseInt(btn.dataset.id, 10)));
|
||||
});
|
||||
}
|
||||
|
||||
async function onToggleVoteReview(id) {
|
||||
const key = `modal-${id}`;
|
||||
if (expanded.has(key)) {
|
||||
expanded.delete(key);
|
||||
renderVotesModalList();
|
||||
return;
|
||||
}
|
||||
expanded.add(key);
|
||||
renderVotesModalList();
|
||||
try {
|
||||
const detail = await api.get(`/api/suggestions/${id}`);
|
||||
const container = document.querySelector(`[data-review-for="${id}"]`);
|
||||
if (!container) return;
|
||||
container.innerHTML = voteSectionTemplate(detail);
|
||||
container.querySelector('.vote-form').addEventListener('submit', async (e) => {
|
||||
e.preventDefault();
|
||||
const form = e.target;
|
||||
try {
|
||||
await api.post(`/api/suggestions/${id}/vote`, {
|
||||
vote: form.vote.value,
|
||||
comment: form.comment.value.trim(),
|
||||
});
|
||||
await refreshVoteSection(id);
|
||||
renderVotesModalList();
|
||||
} catch (err) {
|
||||
showError(err.message);
|
||||
}
|
||||
});
|
||||
} catch (err) {
|
||||
showError(err.message);
|
||||
}
|
||||
}
|
||||
|
||||
(async function init() {
|
||||
me = await initNav('suggestions');
|
||||
document.getElementById('add-suggestion-form').addEventListener('submit', onAddSuggestion);
|
||||
|
|
@ -250,5 +547,28 @@ async function onSuggestionCandidateSelected(candidate) {
|
|||
dropdownId: 'suggestion-title-dropdown',
|
||||
onSelect: onSuggestionCandidateSelected,
|
||||
});
|
||||
|
||||
document.getElementById('view-toggle-list').addEventListener('click', () => setView('list'));
|
||||
document.getElementById('view-toggle-swipe').addEventListener('click', () => setView('swipe'));
|
||||
document.getElementById('swipe-approve-btn').addEventListener('click', () => onSwipeButtonClick('approve'));
|
||||
document.getElementById('swipe-reject-btn').addEventListener('click', () => onSwipeButtonClick('reject'));
|
||||
document.getElementById('open-votes-modal').addEventListener('click', openVotesModal);
|
||||
document.getElementById('swipe-empty-review-btn').addEventListener('click', openVotesModal);
|
||||
document.getElementById('votes-modal-close').addEventListener('click', closeVotesModal);
|
||||
document.getElementById('votes-modal-overlay').addEventListener('click', (e) => {
|
||||
if (e.target === e.currentTarget) closeVotesModal();
|
||||
});
|
||||
document.addEventListener('keydown', (e) => {
|
||||
const modalOpen = !document.getElementById('votes-modal-overlay').classList.contains('hidden');
|
||||
if (e.key === 'Escape' && modalOpen) {
|
||||
closeVotesModal();
|
||||
return;
|
||||
}
|
||||
if (modalOpen || currentView !== 'swipe') return;
|
||||
if (e.key === 'ArrowRight') onSwipeButtonClick('approve');
|
||||
if (e.key === 'ArrowLeft') onSwipeButtonClick('reject');
|
||||
});
|
||||
|
||||
setView(currentView);
|
||||
await loadSuggestions();
|
||||
})();
|
||||
|
|
|
|||
|
|
@ -18,27 +18,58 @@
|
|||
|
||||
<div class="section-header">
|
||||
<h2 style="margin:0">Toutes les suggestions</h2>
|
||||
<button type="button" class="secondary" id="toggle-add-suggestion">+ Proposer un morceau</button>
|
||||
<div class="view-toggle">
|
||||
<button type="button" class="view-toggle-btn active" id="view-toggle-list">Liste</button>
|
||||
<button type="button" class="view-toggle-btn" id="view-toggle-swipe">Vote rapide</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="panel" id="add-suggestion-panel" style="display:none">
|
||||
<form id="add-suggestion-form" class="stacked-form">
|
||||
<div class="autocomplete-wrapper">
|
||||
<label>Titre
|
||||
<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>Lien Spotify <input name="spotifyUrl" id="suggestion-spotify-input" type="url" placeholder="https://open.spotify.com/track/..."></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>
|
||||
</form>
|
||||
<div id="add-suggestion-panel-wrap">
|
||||
<div class="section-header">
|
||||
<div></div>
|
||||
<button type="button" class="secondary" id="toggle-add-suggestion">+ Proposer un morceau</button>
|
||||
</div>
|
||||
|
||||
<div class="panel" id="add-suggestion-panel" style="display:none">
|
||||
<form id="add-suggestion-form" class="stacked-form">
|
||||
<div class="autocomplete-wrapper">
|
||||
<label>Titre
|
||||
<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>Lien Spotify <input name="spotifyUrl" id="suggestion-spotify-input" type="url" placeholder="https://open.spotify.com/track/..."></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>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="suggestions-list" class="card-grid"></div>
|
||||
|
||||
<div id="swipe-view" style="display:none">
|
||||
<div class="swipe-header">
|
||||
<span id="swipe-counter"></span>
|
||||
<button type="button" class="secondary icon-btn" id="open-votes-modal">Voir mes votes</button>
|
||||
</div>
|
||||
<div class="swipe-stack" id="swipe-stack"></div>
|
||||
<div class="swipe-controls">
|
||||
<button type="button" class="swipe-btn reject" id="swipe-reject-btn" title="Rejeter">✘</button>
|
||||
<button type="button" class="swipe-btn approve" id="swipe-approve-btn" title="Approuver">✔</button>
|
||||
</div>
|
||||
<p class="empty" id="swipe-empty" style="display:none">Tout est voté ! <button type="button" class="secondary icon-btn" id="swipe-empty-review-btn">Voir mes votes</button></p>
|
||||
</div>
|
||||
|
||||
<div class="modal-overlay hidden" id="votes-modal-overlay">
|
||||
<div class="modal card modal-wide">
|
||||
<button type="button" class="modal-close icon-btn secondary" id="votes-modal-close">×</button>
|
||||
<h2 style="margin-top:0">Mes votes</h2>
|
||||
<div id="votes-modal-list"></div>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<script src="/js/api.js"></script>
|
||||
|
|
|
|||
|
|
@ -1,17 +1,20 @@
|
|||
const pool = require('../db/pool');
|
||||
|
||||
async function findAll() {
|
||||
const { rows } = await pool.query(`
|
||||
SELECT sg.id, sg.title, sg.artist, sg.youtube_url, sg.spotify_url, sg.description, sg.status, sg.promoted_song_id,
|
||||
sg.created_at, sg.suggested_by, u.name AS suggested_by_name,
|
||||
COUNT(*) FILTER (WHERE v.vote = 'approve')::int AS approve_count,
|
||||
COUNT(*) FILTER (WHERE v.vote = 'reject')::int AS reject_count
|
||||
FROM suggestions sg
|
||||
JOIN users u ON u.id = sg.suggested_by
|
||||
LEFT JOIN suggestion_votes v ON v.suggestion_id = sg.id
|
||||
GROUP BY sg.id, u.name
|
||||
ORDER BY sg.created_at DESC
|
||||
`);
|
||||
async function findAll(userId) {
|
||||
const { rows } = await pool.query(
|
||||
`SELECT sg.id, sg.title, sg.artist, sg.youtube_url, sg.spotify_url, sg.description, sg.status, sg.promoted_song_id,
|
||||
sg.created_at, sg.suggested_by, u.name AS suggested_by_name,
|
||||
COUNT(*) FILTER (WHERE v.vote = 'approve')::int AS approve_count,
|
||||
COUNT(*) FILTER (WHERE v.vote = 'reject')::int AS reject_count,
|
||||
mv.vote AS my_vote, mv.comment AS my_vote_comment
|
||||
FROM suggestions sg
|
||||
JOIN users u ON u.id = sg.suggested_by
|
||||
LEFT JOIN suggestion_votes v ON v.suggestion_id = sg.id
|
||||
LEFT JOIN suggestion_votes mv ON mv.suggestion_id = sg.id AND mv.user_id = $1
|
||||
GROUP BY sg.id, u.name, mv.vote, mv.comment
|
||||
ORDER BY sg.created_at DESC`,
|
||||
[userId]
|
||||
);
|
||||
return rows;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -10,7 +10,7 @@ const router = express.Router();
|
|||
router.get(
|
||||
'/',
|
||||
asyncHandler(async (req, res) => {
|
||||
res.json(await suggestionsRepo.findAll());
|
||||
res.json(await suggestionsRepo.findAll(req.user.id));
|
||||
})
|
||||
);
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue