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

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

De chrome en vier schermen met servergegevens (shaer-bqr, stap 3e)

topnav en profile-sheet naar mod/chrome.js; admin-media, admin-videos, paid-gate,
paid-passkey en admin-playlists naar eigen modules.

TWEE MANIEREN om servergegevens bij een module te krijgen, en de keuze is niet
willekeurig:

OP HET ELEMENT waar de tekst bij EEN ding hoort. De zoekteksten staan nu op

#search-overlay, de twee themalabels op het label zelf. Dat is
ook robuuster: bij een htmx-navigatie wordt de chrome
vervangen, en dan komt de tekst gewoon mee.

VIA pageData() waar het om paginabrede gegevens gaat (csrf, slug, een blob,

een setje meldingen).

De chrome kon niet via pageData: die leeft langer dan een pagina.

DRIE FOUTEN ONDERWEG, alle drie gevangen voordat er iets stuk ging:

  1. Ik zocht de eerste </script> in het HELE bestand in plaats van die na het openende tag. paid-gate en paid-passkey hebben een <script src> erboven, dus het "blok" werd leeg en er werd een lege module geschreven. Teruggezet uit git en de zoekfunctie kreeg een offset plus een controle dat het blok niet leeg is.
  2. Een zoekterm sloot te vroeg af: de tekst van delete_failed loopt door in dezelfde JS-string (": " + err.message), dus het patroon met de sluitquote matchte niet.
  3. Een eerdere poging verving nul regels en schreef toch. Nu telt de helper de vervangingen en weigert als het er niet exact zoveel zijn als opgegeven.

Die controle op "geen enkele module bevat nog EJS" draait mee, en er is er nu een
bij die een verdacht lege module afvangt -- precies wat fout 1 opleverde.

Templates compileren, suite 565/565.

  • Property mode set to 100644
File size: 7.4 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 pageJs: 'admin-playlists',
65 pageTitleKey: 'admin.t_playlists',
66 playlists,
67 bodyClass: 'on-admin',
68 });
69});
70
71// ─── JSON API ─────────────────────────────────────────────────────────
72
73router.get('/api/list', requireGod, (req, res) => {
74 const site = res.locals.site;
75 if (!site) return res.status(404).json({ error: 'Site required' });
76 res.json({ ok: true, playlists: PlaylistService.list(site.id) });
77});
78
79/**
80 * List all audio tracks for picker. Includes a flag whether each track has
81 * a media file (only those are pickable).
82 */
83router.get('/api/tracks', requireGod, (req, res) => {
84 const site = res.locals.site;
85 if (!site) return res.status(404).json({ error: 'Site required' });
86
87 const tracks = db.prepare(`
88 SELECT t.id, t.title, t.artist, t.duration, t.cover_url,
89 t.link_spotify, t.link_youtube, t.link_soundcloud, m.filename
90 FROM audio_tracks t
91 LEFT JOIN media m ON m.id = t.media_id
92 WHERE t.site_id = ?
93 ORDER BY t.created_at DESC
94 `).all(site.id);
95
96 res.json({
97 ok: true,
98 tracks: tracks.map(t => ({
99 id: t.id,
100 title: t.title || 'Untitled',
101 artist: t.artist || '',
102 duration: t.duration || 0,
103 cover: t.cover_url || '',
104 // Insertable if it has a hosted file OR an external link — a link-only track ([[track:]])
105 // still renders its Spotify/YouTube card on the post, so it must not be disabled in the picker.
106 playable: !!t.filename || !!(t.link_spotify || t.link_youtube || t.link_soundcloud),
107 })),
108 });
109});
110
111router.get('/api/:id', requireGod, (req, res) => {
112 const site = res.locals.site;
113 if (!site) return res.status(404).json({ error: 'Site required' });
114
115 // Editor needs the raw track-id list (not stream URLs) — pass no urlFor.
116 const playlist = PlaylistService.get(site.id, req.params.id, null);
117 if (!playlist) return res.status(404).json({ error: 'Playlist niet gevonden' });
118 // Ship just the track ids in order so the editor can populate selection.
119 const trackIds = db.prepare(`
120 SELECT track_id FROM playlist_tracks
121 WHERE playlist_id = ? ORDER BY position ASC
122 `).all(playlist.id).map(r => r.track_id);
123 res.json({ ok: true, playlist: { ...playlist, track_ids: trackIds } });
124});
125
126router.post('/api', requireGod, express.json(), (req, res) => {
127 const site = res.locals.site;
128 if (!site) return res.status(404).json({ error: 'Site required' });
129
130 const id = PlaylistService.create(site.id, req.body || {});
131 if (!id) return res.status(400).json({ error: 'Aanmaken mislukt (titel verplicht)' });
132 res.json({ ok: true, id });
133});
134
135router.post('/api/:id', requireGod, express.json(), (req, res) => {
136 const site = res.locals.site;
137 if (!site) return res.status(404).json({ error: 'Site required' });
138
139 const ok = PlaylistService.update(site.id, req.params.id, req.body || {});
140 if (!ok) return res.status(400).json({ error: 'Bijwerken mislukt' });
141 res.json({ ok: true });
142});
143
144router.post('/api/:id/delete', requireGod, (req, res) => {
145 const site = res.locals.site;
146 if (!site) return res.status(404).json({ error: 'Site required' });
147
148 const ok = PlaylistService.delete(site.id, req.params.id);
149 if (!ok) return res.status(404).json({ error: 'Playlist niet gevonden' });
150 res.json({ ok: true });
151});
152
153/**
154 * POST /admin/playlists/api/:id/cover — upload a new cover image and set
155 * it on the playlist in one request. Returns { ok, url, cover_url } for
156 * the editor modal to preview. Mirrors the track-cover endpoint pattern.
157 */
158router.post('/api/:id/cover', requireGod, (req, res) => {
159 const site = res.locals.site;
160 if (!site) return res.status(404).json({ error: 'Site required' });
161
162 // Confirm ownership (and grab the previous cover for cleanup)
163 const existing = db.prepare(
164 'SELECT id, cover_url FROM playlists WHERE id = ? AND site_id = ?'
165 ).get(req.params.id, site.id);
166 if (!existing) return res.status(404).json({ error: 'Playlist niet gevonden' });
167
168 coverUpload.single('cover')(req, res, (err) => {
169 if (err) return res.status(400).json({ error: err.message });
170 const file = req.file;
171 if (!file) return res.status(400).json({ error: 'Geen bestand' });
172
173 const newUrl = `/media/audio-covers/${file.filename}`;
174 try {
175 db.prepare('UPDATE playlists SET cover_url = ? WHERE id = ? AND site_id = ?')
176 .run(newUrl, req.params.id, site.id);
177 } catch (dbErr) {
178 try { fs.unlinkSync(file.path); } catch {}
179 return res.status(500).json({ error: dbErr.message });
180 }
181
182 // Garbage-collect the previous cover if it was in our managed dir
183 if (existing.cover_url && existing.cover_url.startsWith('/media/audio-covers/')) {
184 const oldName = existing.cover_url.replace(/^\/media\/audio-covers\//, '');
185 try { fs.unlinkSync(path.join(COVER_DIR, oldName)); } catch {}
186 }
187
188 res.json({ ok: true, url: newUrl, cover_url: newUrl });
189 });
190});
191
192export default router;
Note: See TracBrowser for help on using the repository browser.