feat: implement genre selection with new-genre fallback in song forms

- Add client-side genre dropdown populated from existing genres + option to add new genre via text input
- Introduce resolveGenre() helper to prioritize new-genre input over selection, handling the "__new__" sentinel value
- Update admin routes and form rendering to pass available genres and preserve user selections on validation errors
- Add listGenres() service method and CSS styling for genre field layout

This replaces the simple text input with a more user-friendly dropdown experience while still supporting custom genres.
This commit is contained in:
Brian Fertig 2026-04-12 15:13:41 -06:00
parent 31edb59583
commit dfeedb1acd
4 changed files with 62 additions and 11 deletions

View File

@ -1434,6 +1434,15 @@ a.detail-creator-card:hover {
font-size: 0.8rem; font-size: 0.8rem;
} }
/* Genre select + new-genre sub-field */
.genre-field {
display: flex;
flex-direction: column;
gap: 0.5rem;
flex: 1;
}
.genre-field label { flex: unset; }
.preselect-notice { .preselect-notice {
padding: 0.6rem 0.9rem; padding: 0.6rem 0.9rem;
margin-bottom: 0.5rem; margin-bottom: 0.5rem;

View File

@ -145,6 +145,14 @@ function forbidden(next) {
next(err); next(err);
} }
// Resolve the genre field from the genre select + optional "add new" text input.
// genre_new (non-empty) wins; else use the select value unless it's the __new__ sentinel.
function resolveGenre(body) {
const newVal = (body.genre_new || '').trim();
const selVal = body.genre === '__new__' ? '' : (body.genre || '');
return { ...body, genre: newVal || selVal };
}
module.exports = function adminRoutes(csrfProtection) { module.exports = function adminRoutes(csrfProtection) {
const router = express.Router(); const router = express.Router();
router.use(requireUser); router.use(requireUser);
@ -179,19 +187,20 @@ module.exports = function adminRoutes(csrfProtection) {
}); });
router.get('/songs/new', (req, res) => { router.get('/songs/new', (req, res) => {
res.render('mymusic/song_form', { title: 'New song', song: null, errors: {}, values: { visibility: 'logged_in' } }); res.render('mymusic/song_form', { title: 'New song', song: null, errors: {}, values: { visibility: 'logged_in' }, genres: songs.listGenres() });
}); });
router.post('/songs', songFields, csrfProtection, async (req, res, next) => { router.post('/songs', songFields, csrfProtection, async (req, res, next) => {
try { try {
const parsed = songUploadSchema.safeParse(req.body); const body = resolveGenre(req.body);
const parsed = songUploadSchema.safeParse(body);
if (!parsed.success || !req.files || !req.files.audio || !req.files.audio[0]) { if (!parsed.success || !req.files || !req.files.audio || !req.files.audio[0]) {
const errors = {}; const errors = {};
if (!parsed.success) for (const i of parsed.error.issues) errors[i.path[0]] = i.message; if (!parsed.success) for (const i of parsed.error.issues) errors[i.path[0]] = i.message;
if (!req.files || !req.files.audio || !req.files.audio[0]) errors.audio = 'Audio file is required.'; if (!req.files || !req.files.audio || !req.files.audio[0]) errors.audio = 'Audio file is required.';
if (req.files?.audio?.[0]) unlinkQuiet(req.files.audio[0].path); if (req.files?.audio?.[0]) unlinkQuiet(req.files.audio[0].path);
if (req.files?.cover?.[0]) unlinkQuiet(req.files.cover[0].path); if (req.files?.cover?.[0]) unlinkQuiet(req.files.cover[0].path);
return res.status(400).render('mymusic/song_form', { title: 'New song', song: null, errors, values: req.body }); return res.status(400).render('mymusic/song_form', { title: 'New song', song: null, errors, values: body, genres: songs.listGenres() });
} }
const audioFile = req.files.audio[0]; const audioFile = req.files.audio[0];
const coverFile = req.files.cover?.[0]; const coverFile = req.files.cover?.[0];
@ -243,7 +252,7 @@ module.exports = function adminRoutes(csrfProtection) {
if (!canManage(song, req.session.user)) return forbidden(next); if (!canManage(song, req.session.user)) return forbidden(next);
res.render('mymusic/song_form', { res.render('mymusic/song_form', {
title: 'Edit song', song, errors: {}, title: 'Edit song', song, errors: {},
values: { ...song }, values: { ...song }, genres: songs.listGenres(),
}); });
}); });
@ -253,12 +262,13 @@ module.exports = function adminRoutes(csrfProtection) {
const existing = songs.findById(id); const existing = songs.findById(id);
if (!existing) return next(); if (!existing) return next();
if (!canManage(existing, req.session.user)) return forbidden(next); if (!canManage(existing, req.session.user)) return forbidden(next);
const parsed = songEditSchema.safeParse(req.body); const body = resolveGenre(req.body);
const parsed = songEditSchema.safeParse(body);
if (!parsed.success) { if (!parsed.success) {
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);
return res.status(400).render('mymusic/song_form', { title: 'Edit song', song: existing, errors, values: req.body }); return res.status(400).render('mymusic/song_form', { title: 'Edit song', song: existing, errors, values: body, genres: songs.listGenres() });
} }
songs.update(id, { songs.update(id, {
title: parsed.data.title, title: parsed.data.title,

View File

@ -32,6 +32,7 @@ const listForManagementStmt = db.prepare(`
WHERE visibility IN ('public', 'logged_in') OR uploaded_by = ? WHERE visibility IN ('public', 'logged_in') OR uploaded_by = ?
ORDER BY title ASC ORDER BY title ASC
`); `);
const listGenresStmt = db.prepare(`SELECT DISTINCT genre FROM songs WHERE genre IS NOT NULL AND genre != '' ORDER BY genre ASC`);
const recentGuestStmt = db.prepare(`SELECT * FROM songs WHERE visibility = 'public' ORDER BY created_at DESC LIMIT ?`); const recentGuestStmt = db.prepare(`SELECT * FROM songs WHERE visibility = 'public' ORDER BY created_at DESC LIMIT ?`);
const recentUserStmt = db.prepare(`SELECT * FROM songs WHERE visibility IN ('public', 'logged_in') ORDER BY created_at DESC LIMIT ?`); const recentUserStmt = db.prepare(`SELECT * FROM songs WHERE visibility IN ('public', 'logged_in') ORDER BY created_at DESC LIMIT ?`);
const uploaderNameStmt = db.prepare('SELECT display_name FROM users WHERE id = ?'); const uploaderNameStmt = db.prepare('SELECT display_name FROM users WHERE id = ?');
@ -182,4 +183,8 @@ function listPublicByUser(userId, loggedIn = false) {
return (loggedIn ? listPublicByUserLoggedStmt : listPublicByUserGuestStmt).all(userId).map(toView); return (loggedIn ? listPublicByUserLoggedStmt : listPublicByUserGuestStmt).all(userId).map(toView);
} }
module.exports = { create, update, setCover, remove, findById, findBySlug, list, listAll, listByUser, listPublicByUser, listForManagement, recent }; function listGenres() {
return listGenresStmt.all().map(r => r.genre);
}
module.exports = { create, update, setCover, remove, findById, findBySlug, list, listAll, listByUser, listPublicByUser, listForManagement, recent, listGenres };

View File

@ -25,15 +25,42 @@
<input type="text" name="album" value="<%= values.album || '' %>"> <input type="text" name="album" value="<%= values.album || '' %>">
</label> </label>
<div class="row"> <div class="row">
<label> <div class="genre-field">
Genre <label>
<input type="text" name="genre" value="<%= values.genre || '' %>"> Genre
</label> <select name="genre" id="genre-select">
<option value="">— No genre —<% if (!song) { %> (read from tags)<% } %></option>
<% genres.forEach(function(g) { %>
<option value="<%= g %>"
<%= (values.genre === g) ? 'selected' : '' %>
><%= g %></option>
<% }) %>
<option value="__new__"
<%= (values.genre && genres.indexOf(values.genre) === -1) ? 'selected' : '' %>
>+ Add new genre…</option>
</select>
</label>
<label id="genre-new-wrap">
New genre
<input type="text" name="genre_new" id="genre-new-input" maxlength="80"
value="<%= (values.genre && genres.indexOf(values.genre) === -1) ? values.genre : '' %>">
</label>
</div>
<label> <label>
Year Year
<input type="number" name="year" min="1" max="9999" value="<%= values.year || '' %>"> <input type="number" name="year" min="1" max="9999" value="<%= values.year || '' %>">
</label> </label>
</div> </div>
<script>
(function () {
var sel = document.getElementById('genre-select');
var wrap = document.getElementById('genre-new-wrap');
if (!sel || !wrap) return;
function sync() { wrap.style.display = sel.value === '__new__' ? '' : 'none'; }
sel.addEventListener('change', sync);
sync();
})();
</script>
<% if (!song) { %> <% if (!song) { %>
<label> <label>