source: Klonkt/src/routes/admin-playlists.js@ 3487567

main
Last change on this file since 3487567 was 3487567, checked in by Robin Genis <roboburr@…>, 2 months ago

fix(i18n): translate admin page titles (pageTitleKey instead of hardcoded strings)

Admin page/tab titles were hardcoded (mostly Dutch: Beheer, Instellingen, Nieuwsbrief, …).
renderPage now accepts pageTitleKey (+ pageTitleVars) and translates it with the resolved
language; the 16 admin routes pass keys. Adds admin.t_* keys in nl/en/de.

  • middleware/render.js — pageTitleKey support
  • services/i18n.js — admin.t_* title keys (nl/en/de)
  • routes/admin*.js — pageTitle string -> pageTitleKey
  • Property mode set to 100644
File size: 7.2 KB
Line 
1/**
2 * Admin: Playlists management — first-class playlist entity (v9 feature).
3 *
4 * GET /admin/playlists -> list page (server-rendered)
5 * GET /admin/playlists/api/list -> JSON list (used by post-editor picker)
6 * GET /admin/playlists/api/tracks -> JSON list of all audio tracks for picker
7 * GET /admin/playlists/api/:id -> JSON get
8 * POST /admin/playlists/api -> create (id assigned, returned in body)
9 * POST /admin/playlists/api/:id -> update
10 * POST /admin/playlists/api/:id/delete -> delete
11 *
12 * All endpoints require god/admin role. CSRF enforced for write ops via the
13 * shared csrf middleware mounted in server.js.
14 */
15
16import express from 'express';
17import path from 'path';
18import fs from 'fs';
19import multer from 'multer';
20import { randomUUID } from 'crypto';
21import db from '../config/database.js';
22import { renderPage } from '../middleware/render.js';
23import { requireGod } from '../middleware/auth.js';
24import PlaylistService from '../services/PlaylistService.js';
25
26// Cover storage — same convention as track covers so a single physical
27// directory holds all album/track artwork. Existing covers in the DB
28// already point at /media/audio-covers/<filename> so we reuse the path.
29const __dirname = path.dirname(new URL(import.meta.url).pathname);
30const COVER_DIR = path.resolve(
31 process.env.COVER_PATH || path.join(__dirname, '..', '..', 'storage', 'media', 'audio-covers')
32);
33fs.mkdirSync(COVER_DIR, { recursive: true });
34
35const MAX_COVER_BYTES = 5 * 1024 * 1024;
36const COVER_MIMES = new Set(['image/jpeg', 'image/png', 'image/webp', 'image/gif']);
37
38const coverUpload = multer({
39 storage: multer.diskStorage({
40 destination: (req, file, cb) => cb(null, COVER_DIR),
41 filename: (req, file, cb) => {
42 // <uuid>.<ext> — keep extension so MIME detection works downstream
43 const ext = (path.extname(file.originalname) || '.jpg').toLowerCase();
44 cb(null, `${randomUUID()}${ext}`);
45 },
46 }),
47 limits: { fileSize: MAX_COVER_BYTES },
48 fileFilter: (req, file, cb) => {
49 if (!COVER_MIMES.has(file.mimetype)) {
50 return cb(new Error('Alleen JPEG/PNG/WebP/GIF toegestaan'));
51 }
52 cb(null, true);
53 },
54});
55
56const router = express.Router();
57
58// ─── Page render ──────────────────────────────────────────────────────
59
60router.get('/', requireGod, (req, res) => {
61 const site = res.locals.site;
62 if (!site) return res.status(404).send('Site required');
63
64 const playlists = PlaylistService.list(site.id);
65 renderPage(req, res, 'pages/admin-playlists', {
66 pageTitleKey: 'admin.t_playlists',
67 playlists,
68 bodyClass: 'on-admin',
69 });
70});
71
72// ─── JSON API ─────────────────────────────────────────────────────────
73
74router.get('/api/list', requireGod, (req, res) => {
75 const site = res.locals.site;
76 if (!site) return res.status(404).json({ error: 'Site required' });
77 res.json({ ok: true, playlists: PlaylistService.list(site.id) });
78});
79
80/**
81 * List all audio tracks for picker. Includes a flag whether each track has
82 * a media file (only those are pickable).
83 */
84router.get('/api/tracks', requireGod, (req, res) => {
85 const site = res.locals.site;
86 if (!site) return res.status(404).json({ error: 'Site required' });
87
88 const tracks = db.prepare(`
89 SELECT t.id, t.title, t.artist, t.duration, t.cover_url,
90 m.filename
91 FROM audio_tracks t
92 LEFT JOIN media m ON m.id = t.media_id
93 WHERE t.site_id = ?
94 ORDER BY t.created_at DESC
95 `).all(site.id);
96
97 res.json({
98 ok: true,
99 tracks: tracks.map(t => ({
100 id: t.id,
101 title: t.title || 'Untitled',
102 artist: t.artist || '',
103 duration: t.duration || 0,
104 cover: t.cover_url || '',
105 playable: !!t.filename,
106 })),
107 });
108});
109
110router.get('/api/:id', requireGod, (req, res) => {
111 const site = res.locals.site;
112 if (!site) return res.status(404).json({ error: 'Site required' });
113
114 // Editor needs the raw track-id list (not stream URLs) — pass no urlFor.
115 const playlist = PlaylistService.get(site.id, req.params.id, null);
116 if (!playlist) return res.status(404).json({ error: 'Playlist niet gevonden' });
117 // Ship just the track ids in order so the editor can populate selection.
118 const trackIds = db.prepare(`
119 SELECT track_id FROM playlist_tracks
120 WHERE playlist_id = ? ORDER BY position ASC
121 `).all(playlist.id).map(r => r.track_id);
122 res.json({ ok: true, playlist: { ...playlist, track_ids: trackIds } });
123});
124
125router.post('/api', requireGod, express.json(), (req, res) => {
126 const site = res.locals.site;
127 if (!site) return res.status(404).json({ error: 'Site required' });
128
129 const id = PlaylistService.create(site.id, req.body || {});
130 if (!id) return res.status(400).json({ error: 'Aanmaken mislukt (titel verplicht)' });
131 res.json({ ok: true, id });
132});
133
134router.post('/api/:id', requireGod, express.json(), (req, res) => {
135 const site = res.locals.site;
136 if (!site) return res.status(404).json({ error: 'Site required' });
137
138 const ok = PlaylistService.update(site.id, req.params.id, req.body || {});
139 if (!ok) return res.status(400).json({ error: 'Bijwerken mislukt' });
140 res.json({ ok: true });
141});
142
143router.post('/api/:id/delete', requireGod, (req, res) => {
144 const site = res.locals.site;
145 if (!site) return res.status(404).json({ error: 'Site required' });
146
147 const ok = PlaylistService.delete(site.id, req.params.id);
148 if (!ok) return res.status(404).json({ error: 'Playlist niet gevonden' });
149 res.json({ ok: true });
150});
151
152/**
153 * POST /admin/playlists/api/:id/cover — upload a new cover image and set
154 * it on the playlist in one request. Returns { ok, url, cover_url } for
155 * the editor modal to preview. Mirrors the track-cover endpoint pattern.
156 */
157router.post('/api/:id/cover', requireGod, (req, res) => {
158 const site = res.locals.site;
159 if (!site) return res.status(404).json({ error: 'Site required' });
160
161 // Confirm ownership (and grab the previous cover for cleanup)
162 const existing = db.prepare(
163 'SELECT id, cover_url FROM playlists WHERE id = ? AND site_id = ?'
164 ).get(req.params.id, site.id);
165 if (!existing) return res.status(404).json({ error: 'Playlist niet gevonden' });
166
167 coverUpload.single('cover')(req, res, (err) => {
168 if (err) return res.status(400).json({ error: err.message });
169 const file = req.file;
170 if (!file) return res.status(400).json({ error: 'Geen bestand' });
171
172 const newUrl = `/media/audio-covers/${file.filename}`;
173 try {
174 db.prepare('UPDATE playlists SET cover_url = ? WHERE id = ? AND site_id = ?')
175 .run(newUrl, req.params.id, site.id);
176 } catch (dbErr) {
177 try { fs.unlinkSync(file.path); } catch {}
178 return res.status(500).json({ error: dbErr.message });
179 }
180
181 // Garbage-collect the previous cover if it was in our managed dir
182 if (existing.cover_url && existing.cover_url.startsWith('/media/audio-covers/')) {
183 const oldName = existing.cover_url.replace(/^\/media\/audio-covers\//, '');
184 try { fs.unlinkSync(path.join(COVER_DIR, oldName)); } catch {}
185 }
186
187 res.json({ ok: true, url: newUrl, cover_url: newUrl });
188 });
189});
190
191export default router;
Note: See TracBrowser for help on using the repository browser.