feat: add playlist creation credit display, shuffle playback, drag-and-drop reordering, and email verification flow improvements

- Display playlist creator name on public and admin playlist views
- Add shuffle playback button for playlists in player UI
- Implement drag-and-drop reordering for playlist tracks with visual feedback
- Improve email verification UX: redirect pending users to verify page with resend option
- Simplify admin routes by removing redundant role checks (user-based filtering already handled)
- Adjust featured playlist count and add "show all" links on home page
This commit is contained in:
Brian Fertig 2026-04-10 20:14:54 -06:00
parent 04be828e67
commit 9438330f22
14 changed files with 195 additions and 52 deletions

View File

@ -240,6 +240,7 @@ button.linklike.danger:hover { color: #fff; text-shadow: 0 0 6px var(--danger);
} }
.card-body { padding: 0.65rem 0.85rem; } .card-body { padding: 0.65rem 0.85rem; }
.card-title { font-size: 0.85rem; color: var(--fg); } .card-title { font-size: 0.85rem; color: var(--fg); }
.card-creator { font-size: 0.75rem; font-style: italic; color: var(--muted); margin-top: 0.5rem; opacity: 0.75; }
/* ---------- Visibility badges ---------- */ /* ---------- Visibility badges ---------- */
.badge { .badge {
@ -292,6 +293,31 @@ button.linklike.danger:hover { color: #fff; text-shadow: 0 0 6px var(--danger);
.song-row:hover .song-title { color: var(--neon-cyan); text-shadow: var(--glow-cyan); } .song-row:hover .song-title { color: var(--neon-cyan); text-shadow: var(--glow-cyan); }
.song-meta { width: 60px; text-align: right; font-family: 'Orbitron', monospace; font-size: 0.75rem; color: var(--muted); } .song-meta { width: 60px; text-align: right; font-family: 'Orbitron', monospace; font-size: 0.75rem; color: var(--muted); }
/* ── Now-playing highlight ── */
@keyframes now-playing-pulse {
0%, 100% { background: rgba(0,255,255,0.05); box-shadow: inset 3px 0 0 var(--neon-cyan), inset 4px 0 12px rgba(0,255,255,0.15); }
50% { background: rgba(0,255,255,0.10); box-shadow: inset 3px 0 0 var(--neon-cyan), inset 4px 0 22px rgba(0,255,255,0.28); }
}
@keyframes eq-bar {
0%, 100% { height: 3px; }
50% { height: 14px; }
}
.song-row.now-playing {
animation: now-playing-pulse 2s ease-in-out infinite;
}
.song-row.now-playing .song-title { color: var(--neon-cyan); text-shadow: var(--glow-cyan); }
.eq-bars { display: none; align-items: flex-end; gap: 2px; height: 16px; flex-shrink: 0; }
.song-row.now-playing .eq-bars { display: flex; }
.eq-bars span {
display: block; width: 3px; border-radius: 1px;
background: var(--neon-cyan);
box-shadow: 0 0 4px var(--neon-cyan);
animation: eq-bar 0.7s ease-in-out infinite alternate;
}
.eq-bars span:nth-child(1) { animation-duration: 0.6s; animation-delay: 0s; }
.eq-bars span:nth-child(2) { animation-duration: 0.8s; animation-delay: 0.15s; }
.eq-bars span:nth-child(3) { animation-duration: 0.5s; animation-delay: 0.3s; }
.btn-play { .btn-play {
background: transparent; background: transparent;
color: var(--neon-magenta); color: var(--neon-magenta);
@ -459,8 +485,15 @@ button.linklike.danger:hover { color: #fff; text-shadow: 0 0 6px var(--danger);
.data-table tr:hover td { background: rgba(0, 229, 255, 0.04); } .data-table tr:hover td { background: rgba(0, 229, 255, 0.04); }
.data-table .actions { white-space: nowrap; font-size: 0.85rem; } .data-table .actions { white-space: nowrap; font-size: 0.85rem; }
.track-list { padding-left: 1.2rem; } .track-list { padding-left: 0; list-style: none; }
.track-list li { margin: 0.4rem 0; display: flex; justify-content: space-between; gap: 1rem; } .track-list li { margin: 0; display: flex; align-items: center; justify-content: space-between; gap: 0.75rem; padding: 0.35rem 0.5rem; border-radius: 4px; border-top: 2px solid transparent; border-bottom: 2px solid transparent; transition: background 0.15s; }
.track-list li:hover { background: rgba(255,255,255,0.04); }
.track-list li.dragging { opacity: 0.35; }
.track-list li.drop-above { border-top-color: var(--neon-cyan); }
.track-list li.drop-below { border-bottom-color: var(--neon-cyan); }
.track-label { flex: 1; min-width: 0; }
.drag-handle { flex-shrink: 0; color: var(--muted); cursor: grab; padding: 0 2px; line-height: 0; user-select: none; }
.drag-handle:active { cursor: grabbing; }
/* ---------- Player bar ---------- */ /* ---------- Player bar ---------- */
.player-bar { .player-bar {

View File

@ -17,6 +17,7 @@
document.title = doc.title; document.title = doc.title;
if (pushState) history.pushState({ btiNav: true }, doc.title, finalUrl); if (pushState) history.pushState({ btiNav: true }, doc.title, finalUrl);
window.scrollTo(0, 0); window.scrollTo(0, 0);
window.dispatchEvent(new CustomEvent('briTunes:navigate'));
} }
async function navigate(url, pushState) { async function navigate(url, pushState) {

View File

@ -28,14 +28,22 @@
: ''; : '';
} }
function highlightNowPlaying(id) {
document.querySelectorAll('.song-row').forEach(row => {
row.classList.toggle('now-playing', String(row.dataset.songId) === String(id));
});
}
function playCurrent() { function playCurrent() {
const cur = state.queue[state.index]; const cur = state.queue[state.index];
if (!cur) return; if (!cur) return;
window.briTunesNowPlaying = cur;
audio.src = `/stream/${cur.id}`; audio.src = `/stream/${cur.id}`;
audio.play() audio.play()
.then(() => window.dispatchEvent(new CustomEvent('briTunes:play', { detail: cur }))) .then(() => window.dispatchEvent(new CustomEvent('briTunes:play', { detail: cur })))
.catch((err) => console.warn('play failed', err)); .catch((err) => console.warn('play failed', err));
render(); render();
highlightNowPlaying(cur.id);
} }
function playOne(song) { function playOne(song) {
@ -60,6 +68,9 @@
audio.addEventListener('ended', next); audio.addEventListener('ended', next);
nextBtn.addEventListener('click', next); nextBtn.addEventListener('click', next);
window.addEventListener('briTunes:navigate', () => {
if (window.briTunesNowPlaying) highlightNowPlaying(window.briTunesNowPlaying.id);
});
// Delegated click handler for any [data-play-song] / [data-play-playlist] button. // Delegated click handler for any [data-play-song] / [data-play-playlist] button.
document.addEventListener('click', (e) => { document.addEventListener('click', (e) => {
@ -77,6 +88,18 @@
const list = JSON.parse(listBtn.getAttribute('data-play-playlist')); const list = JSON.parse(listBtn.getAttribute('data-play-playlist'));
playList(list); playList(list);
} catch (err) { console.error(err); } } catch (err) { console.error(err); }
return;
}
const shuffleBtn = e.target.closest('[data-shuffle-playlist]');
if (shuffleBtn) {
try {
const list = JSON.parse(shuffleBtn.getAttribute('data-shuffle-playlist'));
for (let i = list.length - 1; i > 0; i--) {
const j = Math.floor(Math.random() * (i + 1));
[list[i], list[j]] = [list[j], list[i]];
}
playList(list);
} catch (err) { console.error(err); }
} }
}); });

View File

@ -153,8 +153,7 @@ module.exports = function adminRoutes(csrfProtection) {
router.get('/', (req, res) => res.redirect('/admin/songs')); router.get('/', (req, res) => res.redirect('/admin/songs'));
router.get('/songs', (req, res) => { router.get('/songs', (req, res) => {
const user = req.session.user; const songList = songs.listByUser(req.session.user.id);
const songList = user.role === 'admin' ? songs.listAll() : songs.listByUser(user.id);
res.render('admin/songs', { title: 'My Music · Songs', songs: songList }); res.render('admin/songs', { title: 'My Music · Songs', songs: songList });
}); });
@ -269,14 +268,12 @@ module.exports = function adminRoutes(csrfProtection) {
// ---------- Playlists ---------- // ---------- Playlists ----------
router.get('/playlists', (req, res) => { router.get('/playlists', (req, res) => {
const user = req.session.user; const playlistList = playlists.listByUser(req.session.user.id);
const playlistList = user.role === 'admin' ? playlists.listAll() : playlists.listByUser(user.id);
res.render('admin/playlists', { title: 'My Music · Playlists', playlists: playlistList }); res.render('admin/playlists', { title: 'My Music · Playlists', playlists: playlistList });
}); });
router.get('/playlists/new', (req, res) => { router.get('/playlists/new', (req, res) => {
const user = req.session.user; const allSongs = songs.listForManagement(req.session.user.id);
const allSongs = user.role === 'admin' ? songs.listAll() : songs.listForManagement(user.id);
res.render('admin/playlist_form', { title: 'New playlist', playlist: null, tracks: [], allSongs, errors: {}, values: { visibility: 'logged_in' } }); res.render('admin/playlist_form', { title: 'New playlist', playlist: null, tracks: [], allSongs, errors: {}, values: { visibility: 'logged_in' } });
}); });
@ -288,7 +285,7 @@ module.exports = function adminRoutes(csrfProtection) {
const errors = {}; const errors = {};
for (const i of parsed.error.issues) errors[i.path[0]] = i.message; for (const i of parsed.error.issues) errors[i.path[0]] = i.message;
if (req.files?.cover?.[0]) unlinkQuiet(req.files.cover[0].path); if (req.files?.cover?.[0]) unlinkQuiet(req.files.cover[0].path);
const allSongs = user.role === 'admin' ? songs.listAll() : songs.listForManagement(user.id); const allSongs = songs.listForManagement(user.id);
return res.status(400).render('admin/playlist_form', { title: 'New playlist', playlist: null, tracks: [], allSongs, errors, values: req.body }); return res.status(400).render('admin/playlist_form', { title: 'New playlist', playlist: null, tracks: [], allSongs, errors, values: req.body });
} }
const coverFile = req.files?.cover?.[0]; const coverFile = req.files?.cover?.[0];
@ -309,8 +306,7 @@ module.exports = function adminRoutes(csrfProtection) {
const playlist = playlists.findById(id); const playlist = playlists.findById(id);
if (!playlist) return next(); if (!playlist) return next();
if (!canManage(playlist, req.session.user)) return forbidden(next); if (!canManage(playlist, req.session.user)) return forbidden(next);
const user = req.session.user; const allSongs = songs.listForManagement(req.session.user.id);
const allSongs = user.role === 'admin' ? songs.listAll() : songs.listForManagement(user.id);
res.render('admin/playlist_form', { res.render('admin/playlist_form', {
title: 'Edit playlist', title: 'Edit playlist',
playlist, playlist,
@ -333,7 +329,7 @@ module.exports = function adminRoutes(csrfProtection) {
const errors = {}; const errors = {};
for (const i of parsed.error.issues) errors[i.path[0]] = i.message; for (const i of parsed.error.issues) errors[i.path[0]] = i.message;
if (req.files?.cover?.[0]) unlinkQuiet(req.files.cover[0].path); if (req.files?.cover?.[0]) unlinkQuiet(req.files.cover[0].path);
const allSongs = user.role === 'admin' ? songs.listAll() : songs.listForManagement(user.id); const allSongs = songs.listForManagement(user.id);
return res.status(400).render('admin/playlist_form', { return res.status(400).render('admin/playlist_form', {
title: 'Edit playlist', playlist: existing, title: 'Edit playlist', playlist: existing,
tracks: playlists.getTracks(id), allSongs, tracks: playlists.getTracks(id), allSongs,

View File

@ -2,7 +2,6 @@ const express = require('express');
const { z } = require('zod'); const { z } = require('zod');
const users = require('../services/users'); const users = require('../services/users');
const mailer = require('../services/mailer'); const mailer = require('../services/mailer');
const { requireUser } = require('../middleware/auth');
const registerSchema = z.object({ const registerSchema = z.object({
email: z.string().email().max(200), email: z.string().email().max(200),
@ -41,15 +40,14 @@ module.exports = function authRoutes(csrfProtection) {
displayName: parsed.data.display_name, displayName: parsed.data.display_name,
password: parsed.data.password, password: parsed.data.password,
}); });
req.session.user = user;
req.session.pendingVerificationEmail = user.email;
const token = users.createVerificationToken(user.id); const token = users.createVerificationToken(user.id);
const base = process.env.APP_BASE_URL || 'http://localhost:3000'; const base = process.env.APP_BASE_URL || 'http://localhost:3000';
mailer.sendVerificationEmail(user.email, `${base}/verify-email?token=${token}`) mailer.sendVerificationEmail(user.email, `${base}/verify-email?token=${token}`)
.catch(err => console.error('[mailer] send failed:', err)); .catch(err => console.error('[mailer] send failed:', err));
req.flash('success', `Welcome, ${user.displayName}! Check your email to verify your account.`); res.redirect('/verify-email?pending=1');
res.redirect('/');
} catch (err) { } catch (err) {
next(err); next(err);
} }
@ -70,6 +68,10 @@ module.exports = function authRoutes(csrfProtection) {
if (!user) { if (!user) {
return res.status(401).render('auth/login', { title: 'Log in', values: req.body, error: 'Invalid email or password.' }); return res.status(401).render('auth/login', { title: 'Log in', values: req.body, error: 'Invalid email or password.' });
} }
if (!user.emailVerified) {
req.session.pendingVerificationEmail = user.email;
return res.redirect('/verify-email?pending=1');
}
req.session.user = user; req.session.user = user;
const target = req.session.returnTo || '/'; const target = req.session.returnTo || '/';
delete req.session.returnTo; delete req.session.returnTo;
@ -86,33 +88,40 @@ module.exports = function authRoutes(csrfProtection) {
router.get('/verify-email', (req, res, next) => { router.get('/verify-email', (req, res, next) => {
try { try {
const pendingEmail = req.session.pendingVerificationEmail || null;
if (req.query.pending === '1') {
return res.render('auth/verify-email', { title: 'Verify Email', state: 'pending', pendingEmail });
}
const { token } = req.query; const { token } = req.query;
if (!token || typeof token !== 'string') { if (!token || typeof token !== 'string') {
return res.render('auth/verify-email', { title: 'Verify Email', state: 'invalid' }); return res.render('auth/verify-email', { title: 'Verify Email', state: 'invalid', pendingEmail });
} }
const result = users.verifyEmailToken(token); const result = users.verifyEmailToken(token);
if (!result.ok) { if (!result.ok) {
return res.render('auth/verify-email', { title: 'Verify Email', state: 'invalid' }); return res.render('auth/verify-email', { title: 'Verify Email', state: 'invalid', pendingEmail });
} }
if (req.session.user && req.session.user.id === result.userId) { if (req.session.user && req.session.user.id === result.userId) {
req.session.user = { ...req.session.user, emailVerified: true }; req.session.user = { ...req.session.user, emailVerified: true };
} }
res.render('auth/verify-email', { title: 'Verify Email', state: 'success' }); delete req.session.pendingVerificationEmail;
res.render('auth/verify-email', { title: 'Verify Email', state: 'success', pendingEmail: null });
} catch (err) { next(err); } } catch (err) { next(err); }
}); });
router.post('/resend-verification', requireUser, csrfProtection, (req, res, next) => { router.post('/resend-verification', csrfProtection, (req, res, next) => {
try { try {
if (req.session.user.emailVerified) { const email = req.session.pendingVerificationEmail;
req.flash('success', 'Your email is already verified.'); if (!email) {
return res.redirect('/'); return res.redirect('/login');
} }
const token = users.createVerificationToken(req.session.user.id); const user = users.findByEmail(email);
if (user && !user.emailVerified) {
const token = users.createVerificationToken(user.id);
const base = process.env.APP_BASE_URL || 'http://localhost:3000'; const base = process.env.APP_BASE_URL || 'http://localhost:3000';
mailer.sendVerificationEmail(req.session.user.email, `${base}/verify-email?token=${token}`) mailer.sendVerificationEmail(email, `${base}/verify-email?token=${token}`)
.catch(err => console.error('[mailer] resend failed:', err)); .catch(err => console.error('[mailer] resend failed:', err));
req.flash('success', 'Verification email sent. Check your inbox.'); }
res.redirect('/'); res.redirect('/verify-email?pending=1');
} catch (err) { next(err); } } catch (err) { next(err); }
}); });

View File

@ -38,7 +38,7 @@ router.get('/', (req, res) => {
const loggedIn = !!req.session.user; const loggedIn = !!req.session.user;
res.render('public/home', { res.render('public/home', {
title: 'Tunes', title: 'Tunes',
featured: playlists.featured(6, loggedIn), featured: playlists.featured(5, loggedIn),
recent: songs.recent(8, loggedIn), recent: songs.recent(8, loggedIn),
}); });
}); });

View File

@ -46,7 +46,7 @@ const featuredGuestStmt = db.prepare(`
JOIN songs s ON s.id = ps.song_id JOIN songs s ON s.id = ps.song_id
WHERE ps.playlist_id = p.id AND s.visibility IN ('logged_in', 'private') WHERE ps.playlist_id = p.id AND s.visibility IN ('logged_in', 'private')
) )
ORDER BY p.created_at DESC LIMIT ? ORDER BY RANDOM() LIMIT ?
`); `);
const featuredUserStmt = db.prepare(` const featuredUserStmt = db.prepare(`
SELECT p.* FROM playlists p SELECT p.* FROM playlists p
@ -56,7 +56,7 @@ const featuredUserStmt = db.prepare(`
JOIN songs s ON s.id = ps.song_id JOIN songs s ON s.id = ps.song_id
WHERE ps.playlist_id = p.id AND s.visibility = 'private' WHERE ps.playlist_id = p.id AND s.visibility = 'private'
) )
ORDER BY p.created_at DESC LIMIT ? ORDER BY RANDOM() LIMIT ?
`); `);
// Compute the single most-restrictive visibility for one playlist (stored + tracks). // Compute the single most-restrictive visibility for one playlist (stored + tracks).
@ -85,6 +85,7 @@ const insertTrackStmt = db.prepare('INSERT OR IGNORE INTO playlist_songs (playli
const removeTrackStmt = db.prepare('DELETE FROM playlist_songs WHERE playlist_id = ? AND song_id = ?'); const removeTrackStmt = db.prepare('DELETE FROM playlist_songs WHERE playlist_id = ? AND song_id = ?');
const clearTracksStmt = db.prepare('DELETE FROM playlist_songs WHERE playlist_id = ?'); const clearTracksStmt = db.prepare('DELETE FROM playlist_songs WHERE playlist_id = ?');
const countStmt = db.prepare('SELECT COUNT(*) AS c FROM playlist_songs WHERE playlist_id = ?'); const countStmt = db.prepare('SELECT COUNT(*) AS c FROM playlist_songs WHERE playlist_id = ?');
const creatorNameStmt = db.prepare('SELECT display_name FROM users WHERE id = ?');
function toView(row) { function toView(row) {
if (!row) return null; if (!row) return null;
@ -95,6 +96,7 @@ function toView(row) {
coverPath: row.cover_path, coverPath: row.cover_path,
visibility: row.visibility || 'logged_in', visibility: row.visibility || 'logged_in',
createdBy: row.created_by, createdBy: row.created_by,
createdByName: row.created_by ? (creatorNameStmt.get(row.created_by)?.display_name || null) : null,
createdAt: row.created_at, createdAt: row.created_at,
}; };
} }

View File

@ -45,10 +45,17 @@
<% if (tracks.length === 0) { %> <% if (tracks.length === 0) { %>
<p class="muted">No tracks yet.</p> <p class="muted">No tracks yet.</p>
<% } else { %> <% } else { %>
<ol class="track-list"> <ol class="track-list" id="track-list">
<% tracks.forEach((t) => { %> <% tracks.forEach((t) => { %>
<li> <li draggable="true" data-song-id="<%= t.id %>">
<span><%= t.title %> — <span class="muted small"><%= t.artist %></span></span> <span class="drag-handle" title="Drag to reorder">
<svg width="12" height="16" viewBox="0 0 12 16" fill="currentColor" aria-hidden="true">
<circle cx="4" cy="4" r="1.5"/><circle cx="8" cy="4" r="1.5"/>
<circle cx="4" cy="8" r="1.5"/><circle cx="8" cy="8" r="1.5"/>
<circle cx="4" cy="12" r="1.5"/><circle cx="8" cy="12" r="1.5"/>
</svg>
</span>
<span class="track-label"><%= t.title %> — <span class="muted small"><%= t.artist %></span></span>
<form method="post" action="/admin/playlists/<%= playlist.id %>/remove-track" class="inline-form"> <form method="post" action="/admin/playlists/<%= playlist.id %>/remove-track" class="inline-form">
<input type="hidden" name="_csrf" value="<%= csrfToken %>"> <input type="hidden" name="_csrf" value="<%= csrfToken %>">
<input type="hidden" name="song_id" value="<%= t.id %>"> <input type="hidden" name="song_id" value="<%= t.id %>">
@ -57,6 +64,10 @@
</li> </li>
<% }) %> <% }) %>
</ol> </ol>
<form id="reorder-form" method="post" action="/admin/playlists/<%= playlist.id %>/reorder" style="display:none">
<input type="hidden" name="_csrf" value="<%= csrfToken %>">
<input type="hidden" name="order" id="reorder-order">
</form>
<% } %> <% } %>
<form method="post" action="/admin/playlists/<%= playlist.id %>/add-track" class="inline-row"> <form method="post" action="/admin/playlists/<%= playlist.id %>/add-track" class="inline-row">
@ -70,4 +81,65 @@
<button type="submit">Add</button> <button type="submit">Add</button>
</form> </form>
</section> </section>
<script>
(function () {
const list = document.getElementById('track-list');
if (!list) return;
let dragged = null;
let overItem = null;
let overBefore = true;
function clearIndicators() {
list.querySelectorAll('li').forEach(li => li.classList.remove('drop-above', 'drop-below'));
}
list.addEventListener('dragstart', (e) => {
dragged = e.target.closest('li');
if (!dragged) return;
e.dataTransfer.effectAllowed = 'move';
e.dataTransfer.setData('text/plain', dragged.dataset.songId);
setTimeout(() => dragged.classList.add('dragging'), 0);
});
list.addEventListener('dragover', (e) => {
e.preventDefault();
e.dataTransfer.dropEffect = 'move';
const target = e.target.closest('li');
if (!target || target === dragged) return;
clearIndicators();
const rect = target.getBoundingClientRect();
overBefore = e.clientY < rect.top + rect.height / 2;
overItem = target;
target.classList.add(overBefore ? 'drop-above' : 'drop-below');
});
list.addEventListener('dragleave', (e) => {
if (!list.contains(e.relatedTarget)) clearIndicators();
});
list.addEventListener('drop', (e) => {
e.preventDefault();
clearIndicators();
if (!dragged || !overItem) return;
if (overBefore) {
list.insertBefore(dragged, overItem);
} else {
list.insertBefore(dragged, overItem.nextSibling);
}
const form = document.getElementById('reorder-form');
document.getElementById('reorder-order').value =
[...list.querySelectorAll('li')].map(li => li.dataset.songId).join(',');
fetch(form.action, { method: 'POST', body: new FormData(form) })
.catch(err => console.error('[reorder] failed:', err));
});
list.addEventListener('dragend', () => {
clearIndicators();
if (dragged) dragged.classList.remove('dragging');
dragged = null;
overItem = null;
});
})();
</script>
<% } %> <% } %>

View File

@ -1,13 +1,21 @@
<div class="form-card"> <div class="form-card">
<% if (state === 'success') { %> <% if (state === 'success') { %>
<h1>Email verified</h1> <h1>Email verified!</h1>
<p>Your email address is confirmed.</p> <p>Your email address is confirmed. You can now log in.</p>
<p><a href="/" class="btn-primary">Go to home</a></p> <p><a href="/login" class="btn-primary">Log in</a></p>
<% } else if (state === 'pending') { %>
<h1>Check your inbox</h1>
<p>A verification link has been sent to <strong><%= pendingEmail %></strong>.</p>
<p>Click the link in the email to activate your account.</p>
<form method="post" action="/resend-verification" class="stacked" style="margin-top:1.5rem">
<input type="hidden" name="_csrf" value="<%= csrfToken %>">
<button type="submit" class="btn-primary">Resend verification email</button>
</form>
<% } else { %> <% } else { %>
<h1>Link invalid or expired</h1> <h1>Link invalid or expired</h1>
<p>Verification links expire after 24 hours.</p> <p>Verification links expire after 24 hours.</p>
<% if (user) { %> <% if (pendingEmail) { %>
<form method="post" action="/resend-verification"> <form method="post" action="/resend-verification" class="stacked" style="margin-top:1.5rem">
<input type="hidden" name="_csrf" value="<%= csrfToken %>"> <input type="hidden" name="_csrf" value="<%= csrfToken %>">
<button type="submit" class="btn-primary">Send a new link</button> <button type="submit" class="btn-primary">Send a new link</button>
</form> </form>

View File

@ -1,12 +1,3 @@
<% if (flash) { %> <% if (flash) { %>
<div class="flash flash-<%= flash.type %>"><%= flash.message %></div> <div class="flash flash-<%= flash.type %>"><%= flash.message %></div>
<% } %> <% } %>
<% if (user && !user.emailVerified) { %>
<div class="flash flash-warning">
Please verify your email address.
<form method="post" action="/resend-verification" class="inline-form">
<input type="hidden" name="_csrf" value="<%= csrfToken %>">
<button type="submit" class="linklike">Resend link</button>
</form>
</div>
<% } %>

View File

@ -1,6 +1,6 @@
<ul class="song-list"> <ul class="song-list">
<% songs.forEach((s) => { %> <% songs.forEach((s) => { %>
<li class="song-row"> <li class="song-row" data-song-id="<%= s.id %>">
<% if (s.coverPath) { %> <% if (s.coverPath) { %>
<img src="/media/<%= s.coverPath %>" alt="" class="song-cover" /> <img src="/media/<%= s.coverPath %>" alt="" class="song-cover" />
<% } else { %> <% } else { %>
@ -15,6 +15,7 @@
<div class="song-meta muted small"> <div class="song-meta muted small">
<% if (s.durationSeconds) { %><%= Math.floor(s.durationSeconds/60) %>:<%= String(s.durationSeconds % 60).padStart(2,'0') %><% } %> <% if (s.durationSeconds) { %><%= Math.floor(s.durationSeconds/60) %>:<%= String(s.durationSeconds % 60).padStart(2,'0') %><% } %>
</div> </div>
<span class="eq-bars" aria-hidden="true"><span></span><span></span><span></span></span>
<button type="button" class="btn-play" <button type="button" class="btn-play"
data-play-song="<%= JSON.stringify({ id: s.id, title: s.title, artist: s.artist, cover: s.coverPath ? '/media/' + s.coverPath : null }) %>"> data-play-song="<%= JSON.stringify({ id: s.id, title: s.title, artist: s.artist, cover: s.coverPath ? '/media/' + s.coverPath : null }) %>">
▶ Play ▶ Play

View File

@ -4,7 +4,7 @@
</section> </section>
<section> <section>
<h2>Featured playlists</h2> <h2>Featured playlists <a href="/playlists" class="muted small" style="font-weight:normal">(show all)</a></h2>
<% if (featured.length === 0) { %> <% if (featured.length === 0) { %>
<p class="muted">No playlists yet.</p> <p class="muted">No playlists yet.</p>
<% } else { %> <% } else { %>
@ -22,6 +22,7 @@
<% if (p.visibility === 'private') { %><span class="badge badge-private">Private</span><% } %> <% if (p.visibility === 'private') { %><span class="badge badge-private">Private</span><% } %>
</div> </div>
<% if (p.description) { %><div class="muted small"><%= p.description %></div><% } %> <% if (p.description) { %><div class="muted small"><%= p.description %></div><% } %>
<% if (p.createdByName) { %><div class="card-creator">Created by:<br><%= p.createdByName %></div><% } %>
</div> </div>
</a> </a>
<% }) %> <% }) %>
@ -30,7 +31,7 @@
</section> </section>
<section> <section>
<h2>Recently added</h2> <h2>Recently added <a href="/songs" class="muted small" style="font-weight:normal">(show all)</a></h2>
<% if (recent.length === 0) { %> <% if (recent.length === 0) { %>
<p class="muted">No songs yet. <% if (user && user.role === 'admin') { %><a href="/admin/songs/new">Upload the first one →</a><% } %></p> <p class="muted">No songs yet. <% if (user && user.role === 'admin') { %><a href="/admin/songs/new">Upload the first one →</a><% } %></p>
<% } else { %> <% } else { %>

View File

@ -6,6 +6,7 @@
<% } %> <% } %>
<div> <div>
<h1><%= playlist.title %></h1> <h1><%= playlist.title %></h1>
<% if (playlist.createdByName) { %><p class="card-creator">Created by: <%= playlist.createdByName %></p><% } %>
<% if (playlist.description) { %><p class="muted"><%= playlist.description %></p><% } %> <% if (playlist.description) { %><p class="muted"><%= playlist.description %></p><% } %>
<p class="muted small"><%= tracks.length %> track<%= tracks.length === 1 ? '' : 's' %></p> <p class="muted small"><%= tracks.length %> track<%= tracks.length === 1 ? '' : 's' %></p>
<button type="button" class="btn-primary" <button type="button" class="btn-primary"
@ -13,6 +14,10 @@
data-play-playlist="<%= JSON.stringify(tracks.map((t) => ({ id: t.id, title: t.title, artist: t.artist, cover: t.coverPath ? '/media/' + t.coverPath : null }))) %>"> data-play-playlist="<%= JSON.stringify(tracks.map((t) => ({ id: t.id, title: t.title, artist: t.artist, cover: t.coverPath ? '/media/' + t.coverPath : null }))) %>">
▶ Play all ▶ Play all
</button> </button>
<button type="button" class="btn-primary"
data-shuffle-playlist="<%= JSON.stringify(tracks.map((t) => ({ id: t.id, title: t.title, artist: t.artist, cover: t.coverPath ? '/media/' + t.coverPath : null }))) %>">
⇄ Shuffle all
</button>
<a href="/playlists" class="btn-link">← Back</a> <a href="/playlists" class="btn-link">← Back</a>
</div> </div>
</div> </div>

View File

@ -16,6 +16,7 @@
<% if (p.visibility === 'private') { %><span class="badge badge-private">Private</span><% } %> <% if (p.visibility === 'private') { %><span class="badge badge-private">Private</span><% } %>
</div> </div>
<% if (p.description) { %><div class="muted small"><%= p.description %></div><% } %> <% if (p.description) { %><div class="muted small"><%= p.description %></div><% } %>
<% if (p.createdByName) { %><div class="card-creator">Created by:<br><%= p.createdByName %></div><% } %>
</div> </div>
</a> </a>
<% }) %> <% }) %>