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;
|
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;
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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 };
|
||||||
|
|
|
||||||
|
|
@ -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">
|
||||||
|
<div class="genre-field">
|
||||||
<label>
|
<label>
|
||||||
Genre
|
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>
|
||||||
|
<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>
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue