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:
parent
31edb59583
commit
dfeedb1acd
|
|
@ -1434,6 +1434,15 @@ a.detail-creator-card:hover {
|
|||
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 {
|
||||
padding: 0.6rem 0.9rem;
|
||||
margin-bottom: 0.5rem;
|
||||
|
|
|
|||
|
|
@ -145,6 +145,14 @@ function forbidden(next) {
|
|||
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) {
|
||||
const router = express.Router();
|
||||
router.use(requireUser);
|
||||
|
|
@ -179,19 +187,20 @@ module.exports = function adminRoutes(csrfProtection) {
|
|||
});
|
||||
|
||||
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) => {
|
||||
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]) {
|
||||
const errors = {};
|
||||
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?.audio?.[0]) unlinkQuiet(req.files.audio[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 coverFile = req.files.cover?.[0];
|
||||
|
|
@ -243,7 +252,7 @@ module.exports = function adminRoutes(csrfProtection) {
|
|||
if (!canManage(song, req.session.user)) return forbidden(next);
|
||||
res.render('mymusic/song_form', {
|
||||
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);
|
||||
if (!existing) return 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) {
|
||||
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);
|
||||
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, {
|
||||
title: parsed.data.title,
|
||||
|
|
|
|||
|
|
@ -32,6 +32,7 @@ const listForManagementStmt = db.prepare(`
|
|||
WHERE visibility IN ('public', 'logged_in') OR uploaded_by = ?
|
||||
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 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 = ?');
|
||||
|
|
@ -182,4 +183,8 @@ function listPublicByUser(userId, loggedIn = false) {
|
|||
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 };
|
||||
|
|
|
|||
|
|
@ -25,15 +25,42 @@
|
|||
<input type="text" name="album" value="<%= values.album || '' %>">
|
||||
</label>
|
||||
<div class="row">
|
||||
<div class="genre-field">
|
||||
<label>
|
||||
Genre
|
||||
<input type="text" name="genre" value="<%= values.genre || '' %>">
|
||||
<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>
|
||||
Year
|
||||
<input type="number" name="year" min="1" max="9999" value="<%= values.year || '' %>">
|
||||
</label>
|
||||
</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) { %>
|
||||
<label>
|
||||
|
|
|
|||
Loading…
Reference in New Issue