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

main
Last change on this file since 6ee289a was 6ee289a, checked in by roboburr <roboburr@…>, 5 weeks ago

De audio- en afspeellijst-editors (shaer-bqr, stap 3f)

admin-audio (435 regels, 18 vertaalsleutels) en playlist-editor (406 regels, het
csrf-token). Daarmee zijn 23 van de 24 bestanden om; alleen post-edit rest.

De achttien vertalingen van admin-audio stonden verspreid door de code, midden in
stringconcatenaties. Ze gaan nu als een tabel via pageData en worden opgezocht in
plaats van geinterpoleerd. Steekproefsgewijs nagelopen op de plekken waar de tekst
de HELE string was: daar bleef anders een leeg begin of eind over.

admin-playlists neemt de playlist-editor op, dus die route vraagt nu om
'admin-playlists playlist-editor' -- een module hoort bij het onderdeel, niet bij
de pagina die het toevallig opneemt.

Templates compileren, 22 modules schoon, suite 565/565.

  • Property mode set to 100644
File size: 7.5 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';
25import { mediaDir } from '../config/paths.js';
26
27// Cover storage — same convention as track covers so a single physical
28// directory holds all album/track artwork. Existing covers in the DB
29// already point at /media/audio-covers/<filename> so we reuse the path.
30const COVER_DIR = mediaDir('COVER_PATH', 'audio-covers');
31fs.mkdirSync(COVER_DIR, { recursive: true });
32
33const MAX_COVER_BYTES = 5 * 1024 * 1024;
34const COVER_MIMES = new Set(['image/jpeg', 'image/png', 'image/webp', 'image/gif']);
35
36const coverUpload = multer({
37 storage: multer.diskStorage({
38 destination: (req, file, cb) => cb(null, COVER_DIR),
39 filename: (req, file, cb) => {
40 // <uuid>.<ext> — keep extension so MIME detection works downstream
41 const ext = (path.extname(file.originalname) || '.jpg').toLowerCase();
42 cb(null, `${randomUUID()}${ext}`);
43 },
44 }),
45 limits: { fileSize: MAX_COVER_BYTES },
46 fileFilter: (req, file, cb) => {
47 if (!COVER_MIMES.has(file.mimetype)) {
48 return cb(new Error('Alleen JPEG/PNG/WebP/GIF toegestaan'));
49 }
50 cb(null, true);
51 },
52});
53
54const router = express.Router();
55
56// ─── Page render ──────────────────────────────────────────────────────
57
58router.get('/', requireGod, (req, res) => {
59 const site = res.locals.site;
60 if (!site) return res.status(404).send('Site required');
61
62 const playlists = PlaylistService.list(site.id);
63 renderPage(req, res, 'pages/admin-playlists', {
64 // admin-playlists neemt de playlist-editor op, dus die module hoort erbij.
65 pageJs: 'admin-playlists playlist-editor',
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 t.link_spotify, t.link_youtube, t.link_soundcloud, 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 // Insertable if it has a hosted file OR an external link — a link-only track ([[track:]])
106 // still renders its Spotify/YouTube card on the post, so it must not be disabled in the picker.
107 playable: !!t.filename || !!(t.link_spotify || t.link_youtube || t.link_soundcloud),
108 })),
109 });
110});
111
112router.get('/api/:id', requireGod, (req, res) => {
113 const site = res.locals.site;
114 if (!site) return res.status(404).json({ error: 'Site required' });
115
116 // Editor needs the raw track-id list (not stream URLs) — pass no urlFor.
117 const playlist = PlaylistService.get(site.id, req.params.id, null);
118 if (!playlist) return res.status(404).json({ error: 'Playlist niet gevonden' });
119 // Ship just the track ids in order so the editor can populate selection.
120 const trackIds = db.prepare(`
121 SELECT track_id FROM playlist_tracks
122 WHERE playlist_id = ? ORDER BY position ASC
123 `).all(playlist.id).map(r => r.track_id);
124 res.json({ ok: true, playlist: { ...playlist, track_ids: trackIds } });
125});
126
127router.post('/api', requireGod, express.json(), (req, res) => {
128 const site = res.locals.site;
129 if (!site) return res.status(404).json({ error: 'Site required' });
130
131 const id = PlaylistService.create(site.id, req.body || {});
132 if (!id) return res.status(400).json({ error: 'Aanmaken mislukt (titel verplicht)' });
133 res.json({ ok: true, id });
134});
135
136router.post('/api/:id', requireGod, express.json(), (req, res) => {
137 const site = res.locals.site;
138 if (!site) return res.status(404).json({ error: 'Site required' });
139
140 const ok = PlaylistService.update(site.id, req.params.id, req.body || {});
141 if (!ok) return res.status(400).json({ error: 'Bijwerken mislukt' });
142 res.json({ ok: true });
143});
144
145router.post('/api/:id/delete', requireGod, (req, res) => {
146 const site = res.locals.site;
147 if (!site) return res.status(404).json({ error: 'Site required' });
148
149 const ok = PlaylistService.delete(site.id, req.params.id);
150 if (!ok) return res.status(404).json({ error: 'Playlist niet gevonden' });
151 res.json({ ok: true });
152});
153
154/**
155 * POST /admin/playlists/api/:id/cover — upload a new cover image and set
156 * it on the playlist in one request. Returns { ok, url, cover_url } for
157 * the editor modal to preview. Mirrors the track-cover endpoint pattern.
158 */
159router.post('/api/:id/cover', requireGod, (req, res) => {
160 const site = res.locals.site;
161 if (!site) return res.status(404).json({ error: 'Site required' });
162
163 // Confirm ownership (and grab the previous cover for cleanup)
164 const existing = db.prepare(
165 'SELECT id, cover_url FROM playlists WHERE id = ? AND site_id = ?'
166 ).get(req.params.id, site.id);
167 if (!existing) return res.status(404).json({ error: 'Playlist niet gevonden' });
168
169 coverUpload.single('cover')(req, res, (err) => {
170 if (err) return res.status(400).json({ error: err.message });
171 const file = req.file;
172 if (!file) return res.status(400).json({ error: 'Geen bestand' });
173
174 const newUrl = `/media/audio-covers/${file.filename}`;
175 try {
176 db.prepare('UPDATE playlists SET cover_url = ? WHERE id = ? AND site_id = ?')
177 .run(newUrl, req.params.id, site.id);
178 } catch (dbErr) {
179 try { fs.unlinkSync(file.path); } catch {}
180 return res.status(500).json({ error: dbErr.message });
181 }
182
183 // Garbage-collect the previous cover if it was in our managed dir
184 if (existing.cover_url && existing.cover_url.startsWith('/media/audio-covers/')) {
185 const oldName = existing.cover_url.replace(/^\/media\/audio-covers\//, '');
186 try { fs.unlinkSync(path.join(COVER_DIR, oldName)); } catch {}
187 }
188
189 res.json({ ok: true, url: newUrl, cover_url: newUrl });
190 });
191});
192
193export default router;
Note: See TracBrowser for help on using the repository browser.