Compare commits

..

No commits in common. "041f0f2ea1521c7c220bccddee69599baef947d9" and "04be828e673527a4a17cf4cca5d984ea29fa3532" have entirely different histories.

20 changed files with 55 additions and 368 deletions

View File

@ -1 +0,0 @@
ALTER TABLE users ADD COLUMN disabled INTEGER NOT NULL DEFAULT 0;

View File

@ -240,7 +240,6 @@ button.linklike.danger:hover { color: #fff; text-shadow: 0 0 6px var(--danger);
}
.card-body { padding: 0.65rem 0.85rem; }
.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 ---------- */
.badge {
@ -293,31 +292,6 @@ 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-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 {
background: transparent;
color: var(--neon-magenta);
@ -484,17 +458,9 @@ 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 .actions { white-space: nowrap; font-size: 0.85rem; }
.data-table tr.row-disabled td { opacity: 0.45; }
.track-list { padding-left: 0; list-style: none; }
.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; }
.track-list { padding-left: 1.2rem; }
.track-list li { margin: 0.4rem 0; display: flex; justify-content: space-between; gap: 1rem; }
/* ---------- Player bar ---------- */
.player-bar {

View File

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

View File

@ -28,22 +28,14 @@
: '';
}
function highlightNowPlaying(id) {
document.querySelectorAll('.song-row').forEach(row => {
row.classList.toggle('now-playing', String(row.dataset.songId) === String(id));
});
}
function playCurrent() {
const cur = state.queue[state.index];
if (!cur) return;
window.briTunesNowPlaying = cur;
audio.src = `/stream/${cur.id}`;
audio.play()
.then(() => window.dispatchEvent(new CustomEvent('briTunes:play', { detail: cur })))
.catch((err) => console.warn('play failed', err));
render();
highlightNowPlaying(cur.id);
}
function playOne(song) {
@ -68,9 +60,6 @@
audio.addEventListener('ended', 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.
document.addEventListener('click', (e) => {
@ -88,18 +77,6 @@
const list = JSON.parse(listBtn.getAttribute('data-play-playlist'));
playList(list);
} 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

@ -71,7 +71,6 @@ app.use((req, _res, next) => {
app.use('/', require('./routes/public'));
app.use('/', require('./routes/auth')(csrfSynchronisedProtection));
app.use('/account', require('./routes/account')(csrfSynchronisedProtection));
app.use('/admin/users', require('./routes/users-admin')(csrfSynchronisedProtection));
app.use('/admin', require('./routes/admin')(csrfSynchronisedProtection));
// 404.

View File

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

View File

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

View File

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

View File

@ -1,71 +0,0 @@
const path = require('path');
const fs = require('fs');
const express = require('express');
const { requireAdmin } = require('../middleware/auth');
const users = require('../services/users');
const mediaDir = process.env.MEDIA_DIR || path.join(__dirname, '..', '..', 'media');
function unlinkQuiet(absPath) {
fs.unlink(absPath, () => {});
}
module.exports = function usersAdminRoutes(csrfProtection) {
const router = express.Router();
router.use(requireAdmin);
router.get('/', (req, res) => {
res.render('admin/users', { title: 'Users', userList: users.listAllUsers() });
});
router.post('/:id/disable', csrfProtection, (req, res, next) => {
try {
const id = Number(req.params.id);
if (id === req.session.user.id) {
req.flash('error', 'You cannot disable your own account.');
return res.redirect('/admin/users');
}
users.setDisabled(id, true);
req.flash('success', 'Account disabled.');
res.redirect('/admin/users');
} catch (err) { next(err); }
});
router.post('/:id/enable', csrfProtection, (req, res, next) => {
try {
users.setDisabled(Number(req.params.id), false);
req.flash('success', 'Account enabled.');
res.redirect('/admin/users');
} catch (err) { next(err); }
});
router.post('/:id/verify', csrfProtection, (req, res, next) => {
try {
users.markVerifiedAdmin(Number(req.params.id));
req.flash('success', 'Email marked as verified.');
res.redirect('/admin/users');
} catch (err) { next(err); }
});
router.post('/:id/delete', csrfProtection, (req, res, next) => {
try {
const id = Number(req.params.id);
if (id === req.session.user.id) {
req.flash('error', 'You cannot delete your own account.');
return res.redirect('/admin/users');
}
const { songs, playlists } = users.deleteUserWithContent(id);
for (const s of songs) {
if (s.audio_path) unlinkQuiet(path.join(mediaDir, s.audio_path));
if (s.cover_path) unlinkQuiet(path.join(mediaDir, s.cover_path));
}
for (const p of playlists) {
if (p.cover_path) unlinkQuiet(path.join(mediaDir, p.cover_path));
}
req.flash('success', 'User and all their content deleted.');
res.redirect('/admin/users');
} catch (err) { next(err); }
});
return router;
};

View File

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

View File

@ -21,13 +21,6 @@ const findVerifTokenStmt = db.prepare(
`SELECT * FROM email_verifications
WHERE token = ? AND expires_at > datetime('now')`
);
const listAllUsersStmt = db.prepare('SELECT * FROM users ORDER BY created_at ASC');
const setDisabledStmt = db.prepare('UPDATE users SET disabled = ? WHERE id = ?');
const deleteUserStmt = db.prepare('DELETE FROM users WHERE id = ?');
const songsByUserStmt = db.prepare('SELECT * FROM songs WHERE uploaded_by = ?');
const playlistsByUserStmt = db.prepare('SELECT * FROM playlists WHERE created_by = ?');
const deleteSongsByUserStmt = db.prepare('DELETE FROM songs WHERE uploaded_by = ?');
const deletePlaylistsByUserStmt = db.prepare('DELETE FROM playlists WHERE created_by = ?');
function publicView(row) {
if (!row) return null;
@ -35,7 +28,6 @@ function publicView(row) {
id: row.id, email: row.email,
displayName: row.display_name, role: row.role,
emailVerified: row.email_verified === 1,
disabled: row.disabled === 1,
};
}
@ -55,7 +47,6 @@ async function createUser({ email, displayName, password }) {
async function verifyCredentials(email, password) {
const row = findByEmailStmt.get(email.trim().toLowerCase());
if (!row) return null;
if (row.disabled) return { disabled: true };
const ok = await argon2.verify(row.password_hash, password);
return ok ? publicView(row) : null;
}
@ -100,32 +91,6 @@ function verifyEmailToken(token) {
return { ok: true, userId: row.user_id };
}
function listAllUsers() {
return listAllUsersStmt.all().map(publicView);
}
function setDisabled(id, disabled) {
setDisabledStmt.run(disabled ? 1 : 0, id);
}
function markVerifiedAdmin(id) {
db.transaction(() => {
markVerifiedStmt.run(id);
deleteVerifTokensStmt.run(id);
})();
}
function deleteUserWithContent(id) {
const userSongs = songsByUserStmt.all(id);
const userPlaylists = playlistsByUserStmt.all(id);
db.transaction(() => {
deleteSongsByUserStmt.run(id);
deletePlaylistsByUserStmt.run(id);
deleteUserStmt.run(id);
})();
return { songs: userSongs, playlists: userPlaylists };
}
module.exports = {
createUser,
verifyCredentials,
@ -135,8 +100,4 @@ module.exports = {
changePassword,
createVerificationToken,
verifyEmailToken,
listAllUsers,
setDisabled,
markVerifiedAdmin,
deleteUserWithContent,
};

View File

@ -45,17 +45,10 @@
<% if (tracks.length === 0) { %>
<p class="muted">No tracks yet.</p>
<% } else { %>
<ol class="track-list" id="track-list">
<ol class="track-list">
<% tracks.forEach((t) => { %>
<li draggable="true" data-song-id="<%= t.id %>">
<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>
<li>
<span><%= t.title %> — <span class="muted small"><%= t.artist %></span></span>
<form method="post" action="/admin/playlists/<%= playlist.id %>/remove-track" class="inline-form">
<input type="hidden" name="_csrf" value="<%= csrfToken %>">
<input type="hidden" name="song_id" value="<%= t.id %>">
@ -64,10 +57,6 @@
</li>
<% }) %>
</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">
@ -81,65 +70,4 @@
<button type="submit">Add</button>
</form>
</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,50 +0,0 @@
<h1>Users</h1>
<table class="data-table">
<thead>
<tr>
<th>Display Name</th>
<th>Email</th>
<th>Role</th>
<th>Verified</th>
<th>Status</th>
<th class="actions">Actions</th>
</tr>
</thead>
<tbody>
<% userList.forEach((u) => { %>
<tr class="<%= u.disabled ? 'row-disabled' : '' %>">
<td><%= u.displayName %></td>
<td class="muted small"><%= u.email %></td>
<td><%= u.role %></td>
<td><%= u.emailVerified ? '✓' : '✗' %></td>
<td><%= u.disabled ? 'Disabled' : 'Active' %></td>
<td class="actions">
<% if (!u.emailVerified) { %>
<form method="post" action="/admin/users/<%= u.id %>/verify" class="inline-form">
<input type="hidden" name="_csrf" value="<%= csrfToken %>">
<button type="submit" class="btn-small">Verify</button>
</form>
<% } %>
<% if (u.id !== user.id) { %>
<% if (u.disabled) { %>
<form method="post" action="/admin/users/<%= u.id %>/enable" class="inline-form">
<input type="hidden" name="_csrf" value="<%= csrfToken %>">
<button type="submit" class="btn-small">Enable</button>
</form>
<% } else { %>
<form method="post" action="/admin/users/<%= u.id %>/disable" class="inline-form">
<input type="hidden" name="_csrf" value="<%= csrfToken %>">
<button type="submit" class="btn-small">Disable</button>
</form>
<% } %>
<form method="post" action="/admin/users/<%= u.id %>/delete" class="inline-form"
onsubmit="return confirm('Delete <%= u.displayName.replace(/'/g, "\\'") %> and ALL their content? This cannot be undone.')">
<input type="hidden" name="_csrf" value="<%= csrfToken %>">
<button type="submit" class="btn-small danger">Delete</button>
</form>
<% } %>
</td>
</tr>
<% }) %>
</tbody>
</table>

View File

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

View File

@ -1,3 +1,12 @@
<% if (flash) { %>
<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

@ -5,10 +5,7 @@
<a href="/songs" class="<%= currentPath && currentPath.startsWith('/songs') ? 'active' : '' %>">Songs</a>
<a href="/playlists" class="<%= currentPath && currentPath.startsWith('/playlists') ? 'active' : '' %>">Playlists</a>
<% if (user) { %>
<a href="/admin/songs" class="<%= currentPath && currentPath.startsWith('/admin') && !currentPath.startsWith('/admin/users') ? 'active' : '' %>">My Music</a>
<% if (user.role === 'admin') { %>
<a href="/admin/users" class="<%= currentPath && currentPath.startsWith('/admin/users') ? 'active' : '' %>">Users</a>
<% } %>
<a href="/admin/songs" class="<%= currentPath && currentPath.startsWith('/admin') ? 'active' : '' %>">My Music</a>
<a href="/account">Account</a>
<form method="post" action="/logout" class="inline-form">
<input type="hidden" name="_csrf" value="<%= csrfToken %>">

View File

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

View File

@ -4,7 +4,7 @@
</section>
<section>
<h2>Featured playlists <a href="/playlists" class="muted small" style="font-weight:normal">(show all)</a></h2>
<h2>Featured playlists</h2>
<% if (featured.length === 0) { %>
<p class="muted">No playlists yet.</p>
<% } else { %>
@ -22,7 +22,6 @@
<% if (p.visibility === 'private') { %><span class="badge badge-private">Private</span><% } %>
</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>
</a>
<% }) %>
@ -31,7 +30,7 @@
</section>
<section>
<h2>Recently added <a href="/songs" class="muted small" style="font-weight:normal">(show all)</a></h2>
<h2>Recently added</h2>
<% 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>
<% } else { %>

View File

@ -6,7 +6,6 @@
<% } %>
<div>
<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><% } %>
<p class="muted small"><%= tracks.length %> track<%= tracks.length === 1 ? '' : 's' %></p>
<button type="button" class="btn-primary"
@ -14,10 +13,6 @@
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
</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>
</div>
</div>

View File

@ -16,7 +16,6 @@
<% if (p.visibility === 'private') { %><span class="badge badge-private">Private</span><% } %>
</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>
</a>
<% }) %>