source: Klonkt/src/routes/admin-sites.js@ e46ebaa

main
Last change on this file since e46ebaa was e46ebaa, checked in by Robin <roboburr@…>, 3 weeks ago

Lezen: snappen dat je laat lezen, en geen lege schermen meer

Barts meldingen van 20 augustus, in een stuk of zes rondes bijgesteld. De
uitkomst is korter dan de weg ernaartoe, en die weg is het waard om vast te
leggen omdat ik er twee keer overheen ben geschoten.

SNAPPEN. De regel is nu in drie zinnen te zeggen:

omhoog scrollen nooit snappen -- je zoekt iets terug en bepaalt zelf

waar je stopt

omlaag, ver weg vrij scrollen
omlaag, laatste
150px van een
bericht vangt, en je landt op de bovenkant van het volgende

Er is geen top-zone: de bovenkant van het bericht waar je IN zit is geen
doelwit meer. Dat was de oorspronkelijke klacht -- je las een paar regels
verder en proximity trok je terug naar de bovenrand, waardoor het leek of
het scrollen vastliep. De grens die wel vangt is de onderkant van je huidige
bericht, en dat is hetzelfde punt als de bovenkant van het volgende.

Onderweg twee doodlopende wegen, allebei door Robin gecorrigeerd:

  • Eerst zette ik de snap UIT voor berichten langer dan het scherm. Op een telefoon is bijna elk bericht net iets langer dan de viewport, dus daar verdween het snappen overal: op 812px hoog kregen berichten van 1009 en zelfs 839 al geen snap meer.
  • Toen probeerde ik de vangzone aan de VOET van het bericht te hangen ("ben je de reacties voorbij"). Bij een kort bericht staat die voet middenin de lege ruimte die min-height maakte, dus de zone begon op een willekeurige plek. Gelukkig ving Robin dat voordat het uitgerold stond.

Het moest dus een getal in pixels zijn, en 150 is dat getal.

GEEN LEGE SCHERMEN MEER. min-height:100svh rekte elk bericht op tot een vol
scherm. Bij een kort bericht gaf dat een halve pagina leegte onder "Replies
and reactions" -- je keek naar niets en moest doorscrollen om te ontdekken
dat er nog iets kwam. Weg dus: berichten hebben hun eigen hoogte (gemeten:
360/1009/839/250 waar het eerst 888/888/888/888 was) en de stroom is gewoon
bericht na bericht. Het snappen hangt aan scroll-snap-align en niet aan die
hoogte, dus dat werkt onveranderd.

VLOEIEND: scroll-behavior:smooth, want zonder dat springt een snap er in een
frame naartoe en voelt het als een hik. Uit bij prefers-reduced-motion.

TERUG NAAR BOVEN, rechtsonder, in twee stappen: eerst naar het begin van DIT
bericht, daarna pas naar de kop van de pagina. Die knop is de enige die naar
een bovenkant mag springen, en hij heeft het snappen daar niet voor nodig --
scrollIntoView stuurt zelf. Nagemeten, juist omdat omhoog-snappen uit staat:
1706 -> 1206, exact de bovenkant.

COVERS nemen de volle kolombreedte met de verhouding intact. De ronde ervoor
haalde het opblazen eruit maar liet kleine afbeeldingen op ware grootte
staan, en dat was als postzegel in een brede kolom te klein.

DE CIRKEL. Lezen blijft daar dood (berichten van anderen), maar een tijdlijn
werkt er juist bij uitstek. Die dwong ik eerst af zodra de site geen
Lezen-site was, en dan kon je in de cirkel geen Grid meer kiezen. Nu is Grid
de landing en staat Tijdlijn ernaast: bij feed_alt_view=timeline altijd, bij
auto op desktop. De cirkel krijgt zijn eigen geheugen terug, met een eigen
sleutel zodat een keuze daar je thuisweergave niet aanraakt.

style.css naar v77 en MOD_V naar 13.

Suite 1165/1165.

Co-Authored-By: Claude Opus 5 <noreply@…>

  • Property mode set to 100644
File size: 18.3 KB
Line 
1/**
2 * Admin: Site management — Phase E.
3 *
4 * GET /admin/sites -> list all sites
5 * GET /admin/sites/new -> create form
6 * POST /admin/sites/create -> insert + redirect to edit
7 * GET /admin/sites/:slug/edit -> edit form
8 * POST /admin/sites/:slug/save -> update
9 * POST /admin/sites/:slug/delete-> delete (refuses if site has posts)
10 *
11 * God-only (requireGod middleware). Slug is immutable after create — too
12 * many things hang off it (URLs, manifest scope, federation). If you really
13 * need to rename: delete + re-create.
14 */
15
16import express from 'express';
17import path from 'path';
18import fs from 'fs';
19import multer from 'multer';
20import { v4 as uuid } from 'uuid';
21import db from '../config/database.js';
22import { renderPage } from '../middleware/render.js';
23import { requireGod, requireAuth, requireSiteManagerBySlug } from '../middleware/auth.js';
24import ThemeService from '../services/ThemeService.js';
25import { listPlatforms, PLATFORMS } from '../services/PlatformIcons.js';
26import { toWebp } from '../services/ImageWebpService.js';
27import { mediaDir } from '../config/paths.js';
28import AP from '../services/ActivityPubService.js';
29
30
31// Profile photos share the avatar directory with user avatars — same physical
32// folder, same URL prefix. Filenames are uuid-prefixed so site photos and
33// user avatars never collide.
34const PHOTO_DIR = mediaDir('AVATAR_PATH', 'avatars');
35fs.mkdirSync(PHOTO_DIR, { recursive: true });
36
37const ALLOWED_PHOTO_EXT = new Set(['.jpg', '.jpeg', '.png', '.webp', '.gif']);
38const MAX_PHOTO_BYTES = 5 * 1024 * 1024;
39const photoUpload = multer({
40 storage: multer.diskStorage({
41 destination: (req, file, cb) => cb(null, PHOTO_DIR),
42 filename: (req, file, cb) => {
43 const ext = path.extname(file.originalname || '').toLowerCase() || '.jpg';
44 cb(null, `site-${uuid()}${ext}`);
45 },
46 }),
47 limits: { fileSize: MAX_PHOTO_BYTES },
48 fileFilter: (req, file, cb) => {
49 const ext = path.extname(file.originalname || '').toLowerCase();
50 if (!ALLOWED_PHOTO_EXT.has(ext)) {
51 return cb(new Error('Alleen JPG/PNG/WebP/GIF toegestaan'));
52 }
53 cb(null, true);
54 },
55});
56
57/** Coerce req.body fields into the JSON profile_links array. */
58function buildProfileLinks(body) {
59 const platforms = body.profile_link_platform || [];
60 const urls = body.profile_link_url || [];
61 const arr = [];
62 const platformsArr = Array.isArray(platforms) ? platforms : [platforms];
63 const urlsArr = Array.isArray(urls) ? urls : [urls];
64 for (let i = 0; i < platformsArr.length; i++) {
65 const p = (platformsArr[i] || '').toString().trim();
66 const u = (urlsArr[i] || '').toString().trim();
67 if (!p || !u) continue;
68 if (!PLATFORMS[p]) continue;
69 if (!/^https?:\/\//i.test(u) && p !== 'email') continue;
70 if (p === 'email' && !/^mailto:|^[^\s@]+@[^\s@]+$/i.test(u)) continue;
71 arr.push({ platform: p, url: u });
72 }
73 return arr.length ? JSON.stringify(arr) : null;
74}
75
76/**
77 * FEP-7628 aliases (alsoKnownAs): one former identity per line, as an actor
78 * URL or an @user@host handle. Handles resolve via WebFinger AT SAVE TIME on
79 * purpose — a typo'd alias that silently lands on the actor would make a later
80 * Move fail at the old server with no hint why. Throws the offending line.
81 */
82export async function parseApAliases(raw, ownActorUri) {
83 const lines = String(raw || '').split(/\r?\n/).map((s) => s.trim()).filter(Boolean);
84 if (lines.length > 5) throw new Error(lines[5] + ' (max 5)');
85 const out = [];
86 for (const line of lines) {
87 let uri = null;
88 if (/^https?:\/\//i.test(line)) uri = line;
89 else if (line.includes('@')) uri = await AP.webfingerResolve(line).catch(() => null);
90 if (!uri) throw new Error(line);
91 if (uri === ownActorUri) continue; // claiming yourself adds nothing
92 if (!out.includes(uri)) out.push(uri);
93 }
94 return out;
95}
96
97const router = express.Router();
98
99// ==================== UPLOAD PROFILE PHOTO (JSON) ====================
100// POST /admin/sites/upload-photo → { ok: true, url: '/media/avatars/<filename>' }
101// Used by the admin-site-edit form's photo picker. The form itself still
102// holds the URL string in `profile_photo` — this endpoint just stores the
103// file and hands back a URL that the form can paste into the input field.
104router.post('/upload-photo', requireAuth, (req, res) => {
105 photoUpload.single('photo')(req, res, (err) => {
106 if (err) return res.status(400).json({ ok: false, error: err.message });
107 if (!req.file) return res.status(400).json({ ok: false, error: 'Geen bestand ontvangen' });
108 res.json({
109 ok: true,
110 url: `/media/avatars/${toWebp(req.file)}`,
111 size: req.file.size,
112 mime: req.file.mimetype,
113 });
114 });
115});
116
117const RESERVED_SITE_SLUGS = new Set([
118 'auth', 'admin', 'login', 'register', 'logout', 'archive', 'search',
119 'account', 'sites', 'comments', 'posts', 'media', 'audio',
120 'forum', 'tag', 'user', 'users', 'artiesten', 'leden', 'feed.xml', 'atom.xml', 'sitemap.xml',
121 'manifest.webmanifest', 'sw.js', 'favicon.ico', 'favicon.svg', 'assets',
122 'paid', 'push', 'guardian',
123]);
124
125function siteEditableFields() {
126 return {
127 title: '',
128 description: '',
129 tagline: '',
130 language: 'nl',
131 palette: 'klonkt',
132 accent: '#e8b04b',
133 profile_photo: '',
134 profile_enabled: 1,
135 profile_name: '',
136 profile_bio: '',
137 is_public: 1,
138 robots_index: 1,
139 require_login_to_comment: 1,
140 enable_audio_player: 1,
141 comments_moderation_mode: 'moderate',
142 feed_view_default: 'grid',
143 feed_view_switch: 1,
144 show_search: 1,
145 show_archive_link: 1,
146 title_template: '{title} — {site}',
147 twitter: '',
148 canonical: '',
149 google_verification: '',
150 bing_verification: '',
151 pinterest_verification: '',
152 yandex_verification: '',
153 custom_css: '',
154 custom_head_html: '',
155 custom_foot_html: '',
156 };
157}
158
159/** Valid user-id for owner assignment, or null if empty/unknown. */
160function validOwnerId(raw) {
161 const id = (raw || '').toString().trim();
162 if (!id) return null;
163 return db.prepare('SELECT 1 FROM users WHERE id = ?').get(id) ? id : null;
164}
165
166/** Grant a user admin rights on a site (idempotent upsert). */
167function grantSiteAdmin(siteId, userId) {
168 db.prepare(`
169 INSERT INTO site_members (site_id, user_id, role) VALUES (?, ?, 'admin')
170 ON CONFLICT(site_id, user_id) DO UPDATE SET role = 'admin'
171 `).run(siteId, userId);
172}
173
174/** Candidate owners for the owner selector field (god-only). */
175function listOwnerCandidates() {
176 return db.prepare('SELECT id, username, role FROM users ORDER BY username').all();
177}
178
179// ==================== LIST ====================
180router.get('/', requireGod, (req, res) => {
181 const sites = db.prepare(`
182 SELECT s.id, s.slug, s.title, s.description, s.created_at,
183 s.is_public, s.robots_index, s.is_primary,
184 u.username AS owner_username,
185 (SELECT COUNT(*) FROM posts WHERE site_id = s.id) AS post_count
186 FROM sites s LEFT JOIN users u ON u.id = s.owner_id
187 ORDER BY s.is_primary DESC, s.created_at DESC
188 `).all();
189
190 renderPage(req, res, 'pages/admin-sites', {
191 pageTitleKey: 'admin.t_sites',
192 bodyClass: 'on-admin',
193 sites,
194 success: req.query.success || null,
195 error: req.query.error || null,
196 });
197});
198
199// ==================== NEW (form) ====================
200router.get('/new', requireGod, (req, res) => {
201 renderPage(req, res, 'pages/admin-site-edit', {
202 pageJs: 'admin-site-edit',
203 pageTitleKey: 'admin.t_newsite',
204 bodyClass: 'on-admin',
205 isNew: true,
206 // ?owner=<id> (from the users page: "give this user a Klonkt") is
207 // pre-selected; otherwise defaults to the creating god.
208 site: { slug: '', owner_id: validOwnerId(req.query.owner) || req.session.user.id, ...siteEditableFields() },
209 users: listOwnerCandidates(),
210 palettes: ThemeService.listPalettes(),
211 accents: ThemeService.listAccents(),
212 platforms: listPlatforms(),
213 parsedLinks: [],
214 apAliases: '',
215 error: null,
216 });
217});
218
219// ==================== CREATE ====================
220router.post('/create', requireGod, (req, res) => {
221 const slug = (req.body.slug || '').toString().toLowerCase().trim();
222 if (!/^[a-z0-9_-]{2,40}$/.test(slug)) {
223 return res.redirect('/admin/sites/new?error=' + encodeURIComponent('Slug: 2-40 chars, letters/numbers/underscore/dash'));
224 }
225 if (RESERVED_SITE_SLUGS.has(slug)) {
226 return res.redirect('/admin/sites/new?error=' + encodeURIComponent('That slug is reserved'));
227 }
228 const existing = db.prepare('SELECT id FROM sites WHERE slug = ?').get(slug);
229 if (existing) {
230 return res.redirect('/admin/sites/new?error=' + encodeURIComponent('Slug already taken'));
231 }
232
233 const f = { ...siteEditableFields(), ...req.body };
234
235 // Owner: god may assign the site to a DIFFERENT user — this is the core of
236 // hub mode (each user their own self-managed Klonkt). Empty or invalid → the
237 // creating god themselves.
238 const ownerId = validOwnerId(req.body.owner_id) || req.session.user.id;
239
240 const siteId = uuid();
241 db.prepare(`
242 INSERT INTO sites (
243 id, slug, title, description, tagline, owner_id,
244 language, palette, accent, profile_photo,
245 is_public, robots_index, require_login_to_comment, enable_audio_player,
246 feed_view_default
247 ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
248 `).run(
249 siteId, slug,
250 (f.title || slug).slice(0, 200),
251 (f.description || '').slice(0, 500),
252 (f.tagline || '').slice(0, 200),
253 ownerId,
254 f.language || 'nl',
255 f.palette || 'klonkt',
256 ThemeService.validateAccent(f.accent) || '#e8b04b',
257 f.profile_photo || null,
258 f.is_public ? 1 : 0,
259 f.robots_index ? 1 : 0,
260 f.require_login_to_comment ? 1 : 0,
261 (f.enable_audio_player !== undefined ? (f.enable_audio_player ? 1 : 0) : 1),
262 f.feed_view_default === 'grid' ? 'grid' : 'reader',
263 );
264
265 // The OWNER (not necessarily the creator) gets a site_members admin row → this
266 // lets them pass canAdminSite + requireSiteManager gates to manage their site.
267 grantSiteAdmin(siteId, ownerId);
268
269 res.redirect(`/admin/sites/${slug}/edit?success=` + encodeURIComponent('Site aangemaakt'));
270});
271
272// ==================== EDIT (form) ====================
273router.get('/:slug/edit', requireSiteManagerBySlug, (req, res) => {
274 const site = db.prepare('SELECT * FROM sites WHERE slug = ?').get(req.params.slug);
275 if (!site) return res.redirect('/admin/sites?error=Not+found');
276
277 let parsedLinks = [];
278 if (site.profile_links) {
279 try { parsedLinks = JSON.parse(site.profile_links) || []; } catch {}
280 }
281
282 let apAliases = '';
283 try { apAliases = (JSON.parse(site.ap_aliases || '[]') || []).join('\n'); } catch { /* show empty on malformed */ }
284
285 renderPage(req, res, 'pages/admin-site-edit', {
286 pageJs: 'admin-site-edit',
287 pageTitleKey: 'admin.t_editsite', pageTitleVars: { title: site.title },
288 bodyClass: 'on-admin',
289 isNew: false,
290 site,
291 users: listOwnerCandidates(),
292 palettes: ThemeService.listPalettes(),
293 accents: ThemeService.listAccents(),
294 platforms: listPlatforms(),
295 parsedLinks,
296 apAliases,
297 success: req.query.success || null,
298 error: req.query.error || null,
299 });
300});
301
302// ==================== MOVE (FEP-7628, slice 2) ====================
303// The explicit departure: announce to every follower that this account now
304// lives elsewhere. Deliberately its own POST with its own button, never a
305// side effect of Save: a Move is a door you close behind you.
306router.post('/:slug/move', requireSiteManagerBySlug, async (req, res) => {
307 const site = db.prepare('SELECT * FROM sites WHERE slug = ?').get(req.params.slug);
308 if (!site) return res.redirect('/admin/sites?error=Not+found');
309 const r = await AP.moveAccount(site, req.body.move_target || '');
310 if (r && r.ok) {
311 return res.redirect(`/admin/sites/${req.params.slug}/edit?success=` + encodeURIComponent(`Verhuizing aangekondigd naar ${r.target} (${r.inboxes} inboxen).`));
312 }
313 const msg = {
314 guarded_account: 'Dit account heeft guardians; verhuizen kan pas als de guardianship mee kan (shaer-tge).',
315 no_backreference: 'Het nieuwe profiel claimt dit account niet in zijn aliassen. Zet daar eerst dit adres als alias.',
316 not_found: 'Nieuw adres niet gevonden. Gebruik @naam@server of een actor-URL.',
317 unreachable: 'Het nieuwe profiel is niet bereikbaar.',
318 self: 'Dat is dit account zelf.',
319 }[r && r.error] || 'Verhuizen mislukte; probeer het opnieuw.';
320 res.redirect(`/admin/sites/${req.params.slug}/edit?error=` + encodeURIComponent(msg));
321});
322
323// ==================== SAVE ====================
324router.post('/:slug/save', requireSiteManagerBySlug, async (req, res) => {
325 const site = db.prepare('SELECT id, ap_aliases FROM sites WHERE slug = ?').get(req.params.slug);
326 if (!site) return res.redirect('/admin/sites?error=Not+found');
327
328 const f = req.body;
329 // Twee vragen, en ze hingen scheef: dit pad schreef 'timeline' terwijl het
330 // AANMAAKpad 'reader' schreef, voor precies dezelfde keuze. Elke site die ooit
331 // is opgeslagen droeg dus 'timeline', en de client vertaalde dat stil terug.
332 // Nu betekent de waarde weer wat er staat.
333 const feedAlt = ['timeline', 'auto'].includes(f.feed_alt_view) ? f.feed_alt_view : 'reader';
334 const feedViewDef = f.feed_view_default === 'grid' ? 'grid' : feedAlt;
335 const profileLinksJson = buildProfileLinks(f);
336
337 // FEP-7628 aliases — validated/resolved before anything is written.
338 const base = (process.env.PUBLIC_BASE_URL || '').replace(/\/+$/, '');
339 // Het INVOERVELD staat hier sinds 14-8 niet meer: aliassen horen bij
340 // Migreren. Dit formulier mag ze dus niet aanraken, en al helemaal niet
341 // leegmaken omdat het veld ontbreekt. Anders verlies je je claim op je oude
342 // account door je kleuren aan te passen, en weigert de Move daarna met
343 // no_backreference. Alleen verwerken als het veld ECHT is meegestuurd, zodat
344 // een oude gecachte pagina die hem nog wel heeft blijft werken.
345 let apAliasesJson = site.ap_aliases || null;
346 if (Object.prototype.hasOwnProperty.call(f, 'ap_aliases')) {
347 try {
348 const arr = await parseApAliases(f.ap_aliases, AP.actorId(base, req.params.slug));
349 apAliasesJson = arr.length ? JSON.stringify(arr) : null;
350 } catch (e) {
351 return res.redirect(`/admin/sites/${req.params.slug}/edit?error=` + encodeURIComponent(`Alias niet herkend of niet vindbaar: ${e.message}`));
352 }
353 }
354
355 // theme_override: only accept the three legal values. Empty string means
356 // "Auto" — defer to user's prefers-color-scheme on first paint.
357 const themeOverride = ['light', 'dark'].includes(f.theme_override) ? f.theme_override : '';
358
359 // accent: only accept colors from the curated ACCENTS list. Falls back to
360 // the orange default if the submitted value isn't recognised.
361 const accent = ThemeService.validateAccent(f.accent) || '#e8b04b';
362
363 db.prepare(`
364 UPDATE sites SET
365 title = ?, description = ?, tagline = ?, language = ?,
366 palette = ?, accent = ?, theme_override = ?, profile_photo = ?,
367 profile_enabled = ?,
368 profile_links = ?,
369 ap_aliases = ?,
370 is_public = ?, robots_index = ?, require_login_to_comment = ?,
371 enable_audio_player = ?,
372 approve_followers = ?,
373 feed_view_default = ?, feed_view_switch = ?, feed_alt_view = ?,
374 show_search = ?, show_archive_link = ?,
375 custom_css = ?, custom_head_html = ?, custom_foot_html = ?,
376 updated_at = CURRENT_TIMESTAMP
377 WHERE id = ?
378 `).run(
379 (f.title || '').slice(0, 200),
380 (f.description || '').slice(0, 500),
381 (f.tagline || '').slice(0, 200),
382 f.language || 'nl',
383 f.palette || 'klonkt',
384 accent,
385 themeOverride,
386 f.profile_photo || null,
387 f.profile_enabled ? 1 : 0,
388 profileLinksJson,
389 apAliasesJson,
390 f.is_public ? 1 : 0,
391 f.robots_index ? 1 : 0,
392 f.require_login_to_comment ? 1 : 0,
393 f.enable_audio_player ? 1 : 0,
394 f.approve_followers ? 1 : 0,
395 feedViewDef,
396 f.feed_view_switch ? 1 : 0,
397 feedAlt,
398 f.show_search ? 1 : 0,
399 f.show_archive_link ? 1 : 0,
400 f.custom_css || null,
401 f.custom_head_html || null,
402 f.custom_foot_html || null,
403 site.id,
404 );
405
406 // (Re)assign owner — god ONLY. A site-owner editing their own site cannot
407 // change the owner (the field is not shown to non-god users either).
408 if (req.session.user.role === 'god') {
409 const newOwner = validOwnerId(req.body.owner_id);
410 if (newOwner) {
411 db.prepare('UPDATE sites SET owner_id = ? WHERE id = ?').run(newOwner, site.id);
412 grantSiteAdmin(site.id, newOwner);
413 }
414 }
415
416 // Alias change → broadcast an actor Update so remote caches refresh. The old
417 // server re-fetches the actor live during a Move anyway; this is freshness,
418 // not correctness, hence best-effort.
419 if ((site.ap_aliases || null) !== apAliasesJson) {
420 try {
421 const fresh = db.prepare('SELECT * FROM sites WHERE id = ?').get(site.id);
422 AP.deliverActorUpdate(fresh).catch(() => {});
423 } catch { /* never blocks the save */ }
424 }
425
426 res.redirect(`/admin/sites/${req.params.slug}/edit?success=` + encodeURIComponent('Opgeslagen'));
427});
428
429// ==================== MAKE PRIMARY ====================
430// God chooses which site is the primary/main site (the label/company site in hub;
431// in solo mode: the one site). Exactly one site is primary → clear all, then set this one.
432router.post('/:slug/make-primary', requireGod, (req, res) => {
433 const site = db.prepare('SELECT id FROM sites WHERE slug = ?').get(req.params.slug);
434 if (!site) return res.redirect('/admin/sites?error=Niet+gevonden');
435 db.transaction(() => {
436 db.prepare('UPDATE sites SET is_primary = 0').run();
437 db.prepare('UPDATE sites SET is_primary = 1 WHERE id = ?').run(site.id);
438 })();
439 res.redirect('/admin/sites?success=' + encodeURIComponent('Primaire site bijgewerkt'));
440});
441
442// ==================== DELETE ====================
443router.post('/:slug/delete', requireGod, (req, res) => {
444 const site = db.prepare('SELECT id FROM sites WHERE slug = ?').get(req.params.slug);
445 if (!site) return res.redirect('/admin/sites?error=Not+found');
446
447 const postCount = db.prepare('SELECT COUNT(*) AS c FROM posts WHERE site_id = ?').get(site.id).c;
448 if (postCount > 0) {
449 return res.redirect('/admin/sites?error=' + encodeURIComponent(`Cannot delete: site has ${postCount} post(s). Delete posts first.`));
450 }
451
452 // Clean up site_members and audio_tracks (no posts to worry about).
453 db.prepare('DELETE FROM site_members WHERE site_id = ?').run(site.id);
454 db.prepare('DELETE FROM audio_tracks WHERE site_id = ?').run(site.id);
455 db.prepare('DELETE FROM sites WHERE id = ?').run(site.id);
456
457 res.redirect('/admin/sites?success=' + encodeURIComponent('Site deleted'));
458});
459
460export default router;
Note: See TracBrowser for help on using the repository browser.