Changeset 72ec6a4 in Klonkt
- Timestamp:
- 07/31/2026 06:55:59 PM (6 weeks ago)
- Branches:
- main
- Children:
- 17546af
- Parents:
- bfa6fa1
- Location:
- src
- Files:
-
- 19 edited
-
config/database.js (modified) (1 diff)
-
middleware/render.js (modified) (2 diffs)
-
middleware/site.js (modified) (2 diffs)
-
routes/admin-settings.js (modified) (6 diffs)
-
routes/admin.js (modified) (5 diffs)
-
routes/audio.js (modified) (2 diffs)
-
routes/download.js (modified) (1 diff)
-
routes/guardian.js (modified) (2 diffs)
-
routes/posts.js (modified) (4 diffs)
-
routes/search.js (modified) (2 diffs)
-
services/ActivityPubService.js (modified) (2 diffs)
-
services/SettingsService.js (modified) (2 diffs)
-
views/pages/admin-site-edit.ejs (modified) (2 diffs)
-
views/pages/admin-users.ejs (modified) (1 diff)
-
views/pages/admin.ejs (modified) (5 diffs)
-
views/pages/guardian.ejs (modified) (1 diff)
-
views/partials/bottom-tab.ejs (modified) (2 diffs)
-
views/partials/chrome.ejs (modified) (1 diff)
-
views/partials/topnav.ejs (modified) (1 diff)
Legend:
- Unmodified
- Added
- Removed
-
src/config/database.js
rbfa6fa1 r72ec6a4 38 38 // SQLite throws if the column already exists; we swallow that. 39 39 ensureColumn('sites', 'enable_audio_player', 'INTEGER DEFAULT 1'); 40 // Guardian 2: losse guardians. Een guardian-only account is user + minimale 41 // site (alleen de actor telt); de vlag houdt CMS/listings erbuiten. 42 ensureColumn('sites', 'guardian_only', 'INTEGER DEFAULT 0'); 43 db.exec(`CREATE TABLE IF NOT EXISTS ap_guardian_invites ( 44 token TEXT PRIMARY KEY, 45 created_by TEXT NOT NULL, 46 created_at TEXT DEFAULT CURRENT_TIMESTAMP, 47 used_by TEXT, 48 used_at TEXT 49 )`); 40 // (Verwijderd 31-7-2026: sites.guardian_only en ap_guardian_invites hoorden 41 // bij de guardian-lite accounts. Bestaande installaties houden kolom en tabel 42 // ongebruikt; nieuwe krijgen ze niet meer.) 50 43 // FEP-633c §5.3: follows targeting a ward are held pending until its 51 44 // guardians approve (Guardian 2). Gating applies only to ward-actors. -
src/middleware/render.js
rbfa6fa1 r72ec6a4 200 200 canManageFedi, 201 201 apEnabled: apEnabled(), 202 // Cirkel = the artists you feature (auto-boost) . Shown when AP is on and you203 // auto-boost ≥1 account, or (legacy) on a circle-tenancy site.202 // Cirkel = the artists you feature (auto-boost): shown when AP is on and 203 // you auto-boost at least one account. 204 204 hasCirkel: !!(_site && apEnabled() && (ActivityPubService.autoBoostCount(_site.slug) > 0 || ActivityPubService.boostedCount(_site.slug) > 0)), 205 205 isViewer: _isViewer, … … 213 213 audioTracks: data.audioTracks || res.locals.audioTracks || [], 214 214 siteUrlBase: res.locals.siteUrlBase || '', 215 tenancy: res.locals.tenancy || 'solo',216 hubTitle: getSetting('hub_title') || '',217 215 footerNewsletter: getSetting('footer_newsletter') === '1', // newsletter sign-up in footer (premium) 218 216 agendaEnabled: getSetting('agenda_enabled') === '1', // show agenda/events in the pill (premium, opt-in) -
src/middleware/site.js
rbfa6fa1 r72ec6a4 10 10 11 11 import db from '../config/database.js'; 12 import { getTenancy } from '../services/SettingsService.js';13 12 import { audioUrl } from '../services/AudioStreamService.js'; 14 13 import { audioEnabled } from '../config/features.js'; … … 27 26 28 27 export function resolveSite(req, res, next) { 29 const tenancy = getTenancy(); 30 res.locals.tenancy = tenancy; // also available in views 31 32 // In HUB mode /user/:slug maps to a specific site. In SOLO mode there is only 33 // one site: we skip that routing and pin to the primary site. 34 if (tenancy === 'hub') { 35 // A Klonkt site is canonically reachable via /user/:slug. /sites/:slug is a 36 // legacy alias → 301 to the canonical form so one URL scheme remains 37 // (preserves path + query string; does NOT touch /admin/sites, which starts with /admin/). 38 const m = req.path.match(/^\/(sites|user)\/([a-zA-Z0-9_-]+)(\/.*)?$/); 39 if (m) { 40 if (m[1] === 'sites') { 41 return res.redirect(301, req.originalUrl.replace(/^\/sites\//, '/user/')); 42 } 43 const site = db.prepare('SELECT * FROM sites WHERE slug = ?').get(m[2]); 44 if (site) { 45 res.locals.site = site; 46 req.url = (m[3] || '/'); // strip /user/:slug zodat downstream de rest ziet 47 res.locals.siteUrlBase = `/user/${m[2]}`; 48 return next(); 49 } 50 } 51 // (Removed: a dead "slug == hostname" subdomain hack. Slugs may not contain 52 // dots, so it could never match. Real subdomain routing would match the 53 // subdomain LABEL against the slug — a separate feature, not this.) 54 } 55 56 // Solo (or hub without a match): pin to the primary/main site. 28 // One instance is one owner (Robins besluit, 31-7): there is one site tree, 29 // pinned to the primary site. The /user/:slug routing that hub mode needed 30 // is gone with it. 57 31 const defaultSite = getPrimarySite(); 58 32 if (defaultSite) { -
src/routes/admin-settings.js
rbfa6fa1 r72ec6a4 1 1 /** 2 2 * Admin: global settings. 3 * - tenancy mode (Solo/Hub)4 3 * - hub branding (name/tagline/intro/hero of the generic hub home page) 5 4 * … … 20 19 import { renderPage } from '../middleware/render.js'; 21 20 import { requireGod } from '../middleware/auth.js'; 22 import { get Tenancy, setTenancy, getSetting, setSetting } from '../services/SettingsService.js';21 import { getSetting, setSetting } from '../services/SettingsService.js'; 23 22 import { SUPPORTED } from '../services/i18n.js'; 24 23 import { mailerStatus, sendMail } from '../config/mailer.js'; … … 69 68 pageTitleKey: 'admin.t_settings', 70 69 bodyClass: 'on-admin', 71 tenancy: getTenancy(),72 hubTitle: getSetting('hub_title') || '',73 70 hubTagline: getSetting('hub_tagline') || '', 74 71 hubIntro: getSetting('hub_intro') || '', … … 87 84 router.post('/', requireGod, (req, res) => { 88 85 // multer.single processes multipart (hub branding form). For a plain 89 // urlencoded POST (tenancy form)multer does nothing and req.body stays intact.86 // urlencoded POST: multer does nothing and req.body stays intact. 90 87 heroUpload.single('hub_hero_file')(req, res, (err) => { 91 88 if (err) { … … 93 90 } 94 91 95 if (typeof req.body.tenancy !== 'undefined') {96 // Hub mode is removed → setTenancy only accepts solo | circle (coerces the rest).97 setTenancy(req.body.tenancy);98 }99 92 if (typeof req.body.default_lang !== 'undefined') { 100 93 // Default language for visitors (empty = follow env/browser). Validated against NL/EN/DE. … … 109 102 if (tz) { try { Intl.DateTimeFormat('en-US', { timeZone: tz }); valid = tz; } catch { valid = ''; } } 110 103 setSetting('timezone', valid); 111 }112 if (typeof req.body.hub_title !== 'undefined') {113 setSetting('hub_title', (req.body.hub_title || '').toString().slice(0, 80).trim());114 104 } 115 105 if (typeof req.body.hub_tagline !== 'undefined') { -
src/routes/admin.js
rbfa6fa1 r72ec6a4 9 9 import { renderPage } from '../middleware/render.js'; 10 10 import { requireAuth } from '../middleware/auth.js'; 11 import { getTenancy,apEnabled } from '../services/SettingsService.js';11 import { apEnabled } from '../services/SettingsService.js'; 12 12 import { getPrimarySite } from '../middleware/site.js'; 13 13 … … 17 17 // Solves the problem that drafts (status != published) were not findable anywhere: 18 18 // the timeline shows only published posts. 19 function sitePosts(siteId, siteSlug, tenancy,limit = 60) {20 const base = tenancy === 'hub' ? `/user/${siteSlug}` :'';19 function sitePosts(siteId, siteSlug, limit = 60) { 20 const base = ''; 21 21 return db.prepare(` 22 22 SELECT slug, title, status, published_at, created_at, updated_at … … 53 53 mySite, 54 54 mine, 55 posts: sitePosts(mySite.id, mySite.slug , 'hub'), // my-site is hub-only55 posts: sitePosts(mySite.id, mySite.slug), 56 56 }); 57 57 } 58 58 59 const tenancy = getTenancy(); 60 61 // The primary/main site — in solo THE site, in hub the main site. Provides the 62 // "Appearance" tile with its edit link + the posts/drafts list. 59 // THE site: it provides the "Appearance" tile with its edit link and the 60 // posts/drafts list. 63 61 const primarySite = getPrimarySite(); 64 62 … … 72 70 }; 73 71 74 // Sites/users tables are only relevant in hub mode; in solo we skip the query. 75 const sites = tenancy === 'hub' ? db.prepare(` 76 SELECT s.slug, s.title, s.created_at, u.username AS owner_username 77 FROM sites s 78 LEFT JOIN users u ON u.id = s.owner_id 79 ORDER BY s.created_at DESC 80 LIMIT 50 81 `).all() : []; 82 83 const users = tenancy === 'hub' ? db.prepare(` 84 SELECT username, email, role, created_at 85 FROM users 86 ORDER BY created_at DESC 87 LIMIT 50 88 `).all() : []; 89 90 // Posts/drafts of the primary site (in solo = the site; in hub = the admin's 91 // main site). Drafts are listed first so they are easy to find. 92 const posts = primarySite ? sitePosts(primarySite.id, primarySite.slug, tenancy) : []; 72 // Posts/drafts of the site. Drafts are listed first so they are easy to find. 73 const posts = primarySite ? sitePosts(primarySite.id, primarySite.slug) : []; 93 74 94 75 renderPage(req, res, 'pages/admin', { 95 76 pageTitleKey: 'admin.t_admin', 96 77 bodyClass: 'on-admin', 97 tenancy, 98 circlesOn: apEnabled(), // solo vs cirkels tagline (federatie aan/uit) 78 circlesOn: apEnabled(), // federatie aan/uit in de tagline 99 79 primarySite, 100 80 stats, 101 sites,102 81 posts, 103 users,104 82 }); 105 83 }); … … 111 89 pageTitleKey: 'admin.t_manual', 112 90 bodyClass: 'on-admin', 113 tenancy: getTenancy(),114 91 }); 115 92 }); -
src/routes/audio.js
rbfa6fa1 r72ec6a4 156 156 const id = String(req.params.id || ''); 157 157 if (!/^[A-Za-z0-9_-]+$/.test(id)) return res.status(400).json({ error: 'bad id' }); 158 const isHub = res.locals.tenancy === 'hub';159 158 const row = db.prepare(` 160 159 SELECT p.slug, s.slug AS site_slug … … 164 163 `).get('%[[track:' + id + ']]%'); 165 164 if (!row) return res.status(404).json({ error: 'not found' }); 166 const url = isHub ? `/user/${row.site_slug}/${row.slug}` :`/${row.slug}`;165 const url = `/${row.slug}`; 167 166 res.json({ url }); 168 167 }); -
src/routes/download.js
rbfa6fa1 r72ec6a4 34 34 ).get(site.id); 35 35 if (!post) return {}; 36 try { return postNeighbors(site, post , res.locals.tenancy === 'hub'); } catch (e) { return {}; }36 try { return postNeighbors(site, post); } catch (e) { return {}; } 37 37 } 38 38 const __dirname = path.dirname(fileURLToPath(import.meta.url)); -
src/routes/guardian.js
rbfa6fa1 r72ec6a4 10 10 */ 11 11 import express from 'express'; 12 import crypto from 'crypto';13 import bcrypt from 'bcryptjs';14 12 import path from 'path'; 15 13 import { fileURLToPath } from 'url'; … … 595 593 }); 596 594 597 // ── Losse guardians (Guardian 2): uitnodigen en aansluiten ─────────────── 598 // De familie nodigt oma uit; zij kiest naam + wachtwoord en heeft daarmee een 599 // guardian-only account: user + minimale site (guardian_only=1). Alles wat al 600 // per slug werkt (actor, inbox, offers, push, deze PWA) werkt dan meteen. 601 602 router.post('/invite', requireAuth, (req, res) => { 603 const token = crypto.randomBytes(16).toString('base64url'); 604 db.prepare('INSERT INTO ap_guardian_invites (token, created_by) VALUES (?,?)') 605 .run(token, req.session.user.id); 606 const base = (process.env.PUBLIC_BASE_URL || '').replace(/\/+$/, ''); 607 const url = `${base}/guardian/join/${token}`; 608 res.send(`<!doctype html><meta charset="utf-8"><body style="font-family:sans-serif;max-width:480px;margin:40px auto"> 609 <h2>Invite a guardian</h2> 610 <p>Share this link. It lets one person create a guardian account here:</p> 611 <p><a href="${url}">${url}</a></p> 612 <p><a href="/guardian">Back</a></p></body>`); 613 }); 614 615 function joinForm(token, error) { 616 return `<!doctype html><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1"> 617 <body style="font-family:sans-serif;max-width:420px;margin:40px auto"> 618 <h2>Become a guardian</h2> 619 <p>Watch over someone you care about. Pick a name and a password; that is all.</p> 620 ${error ? `<p style="color:#b00">${error}</p>` : ''} 621 <form method="post" action="/guardian/join/${token}"> 622 <p><input name="name" placeholder="your name (grandma)" required pattern="[a-z0-9_-]{1,32}" 623 style="width:100%;padding:10px" autocapitalize="none"></p> 624 <p><input name="password" type="password" placeholder="password" required minlength="8" 625 style="width:100%;padding:10px"></p> 626 <p><button style="width:100%;padding:12px">Create my guardian account</button></p> 627 </form></body>`; 628 } 629 630 router.get('/join/:token', (req, res) => { 631 const inv = db.prepare('SELECT * FROM ap_guardian_invites WHERE token = ? AND used_at IS NULL') 632 .get(req.params.token); 633 if (!inv) return res.status(404).send('This invite is no longer valid.'); 634 res.send(joinForm(req.params.token)); 635 }); 636 637 router.post('/join/:token', express.urlencoded({ extended: false }), (req, res) => { 638 const inv = db.prepare('SELECT * FROM ap_guardian_invites WHERE token = ? AND used_at IS NULL') 639 .get(req.params.token); 640 if (!inv) return res.status(404).send('This invite is no longer valid.'); 641 const name = String(req.body.name || '').trim().toLowerCase(); 642 const password = String(req.body.password || ''); 643 if (!/^[a-z0-9_-]{1,32}$/.test(name)) return res.status(400).send(joinForm(req.params.token, 'Only lowercase letters, digits, - and _.')); 644 if (password.length < 8) return res.status(400).send(joinForm(req.params.token, 'Password: at least 8 characters.')); 645 if (db.prepare('SELECT 1 FROM sites WHERE slug = ?').get(name) || db.prepare('SELECT 1 FROM users WHERE username = ?').get(name)) { 646 return res.status(409).send(joinForm(req.params.token, 'That name is taken, pick another.')); 647 } 648 const userId = crypto.randomUUID(); 649 db.prepare('INSERT INTO users (id, username, email, password_hash, role) VALUES (?,?,?,?,?)') 650 .run(userId, name, `${name}@guardian.invalid`, bcrypt.hashSync(password, 10), 'member'); 651 db.prepare('INSERT INTO sites (id, slug, title, owner_id, is_primary, guardian_only) VALUES (?,?,?,?,0,1)') 652 .run(crypto.randomUUID(), name, name, userId); 653 db.prepare('UPDATE ap_guardian_invites SET used_by = ?, used_at = CURRENT_TIMESTAMP WHERE token = ?') 654 .run(userId, req.params.token); 655 req.session.user = { id: userId, username: name, role: 'member' }; 656 res.redirect('/guardian'); 657 }); 595 // Losse guardian-accounts (guardian-lite: /invite + /join, user + site met 596 // guardian_only=1) zijn verwijderd op 31-7-2026. Een instance is een eigenaar; 597 // zo'n account was de laatste multi-user-rest en zette bovendien andermans 598 // wachtwoordhash, sessie en PRIVATE actor-sleutel in jouw database, wat een 599 // verhuizing (shaer-qw6q) onmogelijk netjes maakte. Een guardian hoort een 600 // eigen Klonkt te hebben; de adoptie loopt dan gewoon over de federatie. 658 601 659 602 export default router; -
src/routes/posts.js
rbfa6fa1 r72ec6a4 763 763 } 764 764 765 function postNeighbors(site, post, isHub) { 766 const urlBaseFor = (p) => (isHub && p && p.site_slug) ? `/user/${p.site_slug}` : ''; 767 const ordered = isHub 768 ? db.prepare(` 769 SELECT p.id, p.slug, p.title, p.pinned, s.slug AS site_slug 770 FROM posts p JOIN sites s ON s.id = p.site_id 771 WHERE p.status = 'published' 772 ORDER BY p.published_at DESC 773 `).all() 774 : db.prepare(` 775 SELECT id, slug, title, pinned FROM posts 776 WHERE site_id = ? AND status = 'published' 777 ORDER BY (pinned = 0) ASC, pinned ASC, published_at DESC 778 `).all(site.id); 765 function postNeighbors(site, post) { 766 const ordered = db.prepare(` 767 SELECT id, slug, title, pinned FROM posts 768 WHERE site_id = ? AND status = 'published' 769 ORDER BY (pinned = 0) ASC, pinned ASC, published_at DESC 770 `).all(site.id); 779 771 const idx = ordered.findIndex((p) => p.id === post.id); 780 772 const newerPost = idx > 0 ? ordered[idx - 1] : null; 781 773 const olderPost = (idx >= 0 && idx < ordered.length - 1) ? ordered[idx + 1] : null; 782 if (newerPost) newerPost._urlBase = urlBaseFor(newerPost);783 if (olderPost) olderPost._urlBase = urlBaseFor(olderPost);774 if (newerPost) newerPost._urlBase = ''; 775 if (olderPost) olderPost._urlBase = ''; 784 776 return { newerPost, olderPost }; 785 777 } … … 1344 1336 const _unlocked = _u && _u.purpose === 'unlocked' && _u.siteId === site.id && String(_u.post) === String(post.slug); 1345 1337 if (post.paid && !canEditThis && !_unlocked) { 1346 const { newerPost, olderPost } = postNeighbors(site, post , res.locals.tenancy === 'hub');1338 const { newerPost, olderPost } = postNeighbors(site, post); 1347 1339 return renderPage(req, res, 'pages/paid-gate', { 1348 1340 pageTitle: post.title || 'Voor supporters', … … 1364 1356 // Same Newer/Older navigation as on a normal post, so the visitor doesn't get 1365 1357 // stuck on the fan gate but can keep browsing. 1366 const { newerPost, olderPost } = postNeighbors(site, post , res.locals.tenancy === 'hub');1358 const { newerPost, olderPost } = postNeighbors(site, post); 1367 1359 return renderPage(req, res, 'pages/fan-gate', { 1368 1360 pageTitle: post.title || 'Alleen voor fans', … … 1396 1388 // Prev / next chronological (kept for back-compat — "post-nav" feature 1397 1389 // below the article still uses these as a simple linear navigation). 1398 // Hub mode: Related posts + Newer/Older pull from ALL users (all sites), 1399 // newest first. Solo mode: within the current site (old behaviour). 1400 const isHub = res.locals.tenancy === 'hub'; 1401 // Per-post URL base: in hub a link points to /user/<site-slug>/<post-slug>. 1402 const urlBaseFor = (p) => (isHub && p && p.site_slug) ? `/user/${p.site_slug}` : ''; 1390 const urlBaseFor = () => ''; 1403 1391 1404 1392 // Newer/Older across ALL posts (shared helper — also used by the fan gate). 1405 const { newerPost, olderPost } = postNeighbors(site, post , isHub);1393 const { newerPost, olderPost } = postNeighbors(site, post); 1406 1394 1407 1395 // ── Related posts: same-tag matching with recency fallback ───── 1408 1396 // Fetch ~50 candidates, score by tag overlap, take top 3. 1409 1397 // Excluding self via `id != ?`. 1410 const candidates = isHub 1411 ? db.prepare(` 1412 SELECT p.id, p.slug, p.title, p.cover_image_url, p.cover_video_url, p.published_at, p.tags, p.nsfw, p.content_warning, s.slug AS site_slug 1413 FROM posts p JOIN sites s ON s.id = p.site_id 1414 WHERE p.status = 'published' AND p.id != ? 1415 ORDER BY p.published_at DESC LIMIT 50 1416 `).all(post.id) 1417 : db.prepare(` 1418 SELECT id, slug, title, cover_image_url, cover_video_url, published_at, tags, nsfw, content_warning 1419 FROM posts 1420 WHERE site_id = ? AND status = 'published' AND id != ? 1421 ORDER BY published_at DESC LIMIT 50 1422 `).all(site.id, post.id); 1398 const candidates = db.prepare(` 1399 SELECT id, slug, title, cover_image_url, cover_video_url, published_at, tags, nsfw, content_warning 1400 FROM posts 1401 WHERE site_id = ? AND status = 'published' AND id != ? 1402 ORDER BY published_at DESC LIMIT 50 1403 `).all(site.id, post.id); 1423 1404 1424 1405 // Parse tags JSON safely; missing/malformed → empty array. -
src/routes/search.js
rbfa6fa1 r72ec6a4 51 51 function searchSite(req, res, rawQ, lim) { 52 52 const site = res.locals.site; 53 const isHub = res.locals.tenancy === 'hub';54 53 const base = res.locals.siteUrlBase || ''; 55 const urlFor = (slug) => (isHub ? `/user/${site.slug}/${slug}` : `/${slug}`);54 const urlFor = (slug) => `/${slug}`; 56 55 // Eigen vertaler (werkt ook in de JSON-route, waar res.locals.t niet bestaat). 57 56 const lang = resolveLang(req); … … 172 171 if (rawQ.length < 2) return res.json({ posts: [], tracks: [], events: [], pages: [] }); 173 172 174 const isHub = res.locals.tenancy === 'hub'; 175 const urlFor = (slug) => (isHub ? `/user/${site.slug}/${slug}` : `/${slug}`); 173 const urlFor = (slug) => `/${slug}`; 176 174 const r = searchSite(req, res, rawQ, { posts: 5, tracks: 4, events: 3, pages: 4 }); 177 175 res.json({ -
src/services/ActivityPubService.js
rbfa6fa1 r72ec6a4 26 26 import EmbedResolver from './EmbedResolver.js'; 27 27 import Push from './PushService.js'; 28 import { getTenancy } from './SettingsService.js';29 28 import { t as i18nT } from './i18n.js'; 30 29 import Blocklist from './BlocklistService.js'; … … 1418 1417 for (const cb of cbs) { try { cb(); } catch { /* a waiter must never break the rest */ } } 1419 1418 } 1420 // Hub-aware path prefix for a site's pages ('' in solo).1421 function pushPrefix(slug) { 1422 try { return getTenancy() === 'hub' ? `/user/${slug}` : ''; } catch { return ''; } 1423 }1419 // Path prefix for a site's pages. One instance is one owner, so the site 1420 // lives at the root; kept as a function because the push URLs read like 1421 // `${pushPrefix(slug)}/messages` all over this file. 1422 function pushPrefix() { return ''; } 1424 1423 // Notification language: the site's content language (fallback: instance default). 1425 1424 function pushLang(slug) { -
src/services/SettingsService.js
rbfa6fa1 r72ec6a4 1 // Global app settings (key/value, cached). Primarily used for the tenancy mode.1 // Global app settings (key/value, cached). 2 2 // 3 // tenancy = 'solo' -> exactly one site (the primary/owner site) 4 // tenancy = 'circle' -> solo site that federates with other solo Klonkt sites 5 // 6 // HUB MODE IS REMOVED (2026-06-24): multi-artist-per-domain was dropped in favour 7 // of solo + Cirkels. getTenancy() coerces any legacy 'hub' value to 'solo' so all 8 // the old `tenancy === 'hub'` branches are unreachable; the hub code is being 9 // deleted incrementally. 3 // One instance is one owner (Robins besluit, 31-7-2026). The old tenancy modes 4 // (hub = many artists on one domain, circle) are gone, code and all: the 5 // branches were already unreachable and have now been deleted. 10 6 // 11 7 // The cache is updated immediately on setSetting, so a toggle in admin … … 39 35 } 40 36 41 export function getTenancy() {42 // Tenancy is retired: 'hub' and 'circle' were both removed. Every site is43 // 'solo'. Cirkels are now an ActivityPub feature (auto-boost), not a mode.44 return 'solo';45 }46 47 export function setTenancy() {48 setSetting('tenancy', 'solo');49 }50 51 37 // ActivityPub / fediverse federation. ON by default. '0' = off: the site does 52 38 // not federate, /ap/* is gone, and the "from the fediverse" reactions disappear -
src/views/pages/admin-site-edit.ejs
rbfa6fa1 r72ec6a4 14 14 <span class="slug-url"> 15 15 <span class="slug-url-host" id="slug-host" 16 data-prefix=" <%= (typeof tenancy !== 'undefined' && tenancy === 'hub') ? '/user/' : '/' %>">website.com/</span><input16 data-prefix="/">website.com/</span><input 17 17 type="text" name="slug" id="slug-input" class="slug-url-input" value="<%= site.slug %>" required <% if (!isNew) { %>readonly<% } %> 18 18 pattern="[a-z0-9_-]{2,40}" placeholder="<%= t('asite.slug_placeholder') %>" … … 24 24 <input type="text" name="title" value="<%= site.title || '' %>" required maxlength="200"> 25 25 </label> 26 <%# Assign (or reassign) owner — ONLY god AND only in hub mode. In hub this27 gives each user their own self-managed Klonkt. In solo (one site,28 one owner) this is pointless/confusing, so it's hidden. %>29 <% if (user && user.role === 'god' && (typeof tenancy !== 'undefined' && tenancy === 'hub')) { %>30 <label>31 <span><%= t('asite.owner') %> <small class="form-hint-inline"><%= t('asite.owner_hint') %></small></span>32 <% var _godSuffix = t('asite.owner_god_suffix'); %>33 <select name="owner_id">34 <% (typeof users !== 'undefined' ? users : []).forEach(function(u){ %>35 <option value="<%= u.id %>" <%= (site.owner_id === u.id) ? 'selected' : '' %>><%= u.username %><%= u.role === 'god' ? _godSuffix : '' %></option>36 <% }); %>37 </select>38 </label>39 <% } %>40 26 <label> 41 27 <span><%= t('asite.tagline') %> <small class="form-hint-inline"><%= t('asite.tagline_hint') %></small></span> -
src/views/pages/admin-users.ejs
rbfa6fa1 r72ec6a4 71 71 72 72 <div class="ax-user-controls"> 73 <%# Hub: give this user their own self-managed Klonkt — opens the74 new-site form with this user already pre-selected as owner. %>75 <% if (typeof tenancy !== 'undefined' && tenancy === 'hub' && canMutate) { %>76 <a href="/admin/sites/new?owner=<%= u.id %>" class="ax-icon-btn"77 aria-label="<%= t('ausr.new_klonkt_for', { name: u.username }) %>" title="<%= _u_newklonkt %>">+🌐</a>78 <% } %>79 73 <form method="post" action="/admin/users/<%= u.id %>/role" class="ax-role-form"> 80 74 <label class="ax-role"> -
src/views/pages/admin.ejs
rbfa6fa1 r72ec6a4 17 17 .prem-badge:hover{filter:brightness(1.08)} 18 18 </style> 19 <% if (tenancy === 'hub') { %> 20 <p class="admin-tagline"><%= t('admin.tagline_hub') %></p> 21 <% } else if (typeof circlesOn !== 'undefined' && circlesOn) { %> 19 <% if (typeof circlesOn !== 'undefined' && circlesOn) { %> 22 20 <p class="admin-tagline"><%= t('admin.tagline_cirkels') %></p> 23 21 <% } else { %> … … 26 24 27 25 <nav class="admin-quick-links" aria-label="<%= t('nav.admin') %>"> 28 <% if (tenancy === 'hub') { %>29 <a href="/admin/sites" class="btn"><%= t('admin.b_sites') %></a>30 <a href="/admin/users" class="btn"><%= t('admin.b_users') %></a>31 <a href="/admin/media" class="btn"><%= t('admin.b_media') %></a>32 <% if (typeof audioEnabled === 'undefined' || audioEnabled) { %>33 <a href="/admin/playlists" class="btn"><%= t('admin.b_playlists') %></a>34 <% } %>35 <% if (primarySite) { %><a href="/admin/seo" class="btn"><%= t('admin.b_seo') %></a><% } %>36 <a href="/admin/settings" class="btn"><%= t('admin.b_settings') %></a>37 <% } else { %>38 26 <a href="/posts/new" class="btn"><%= t('admin.b_newpost') %></a> 39 27 <a href="/admin/media" class="btn"><%= t('admin.b_media') %></a> … … 45 33 <% if (primarySite) { %><a href="/admin/seo" class="btn"><%= t('admin.b_seo') %></a><% } %> 46 34 <a href="/admin/settings" class="btn"><%= t('admin.b_settings') %></a> 47 <% } %>48 35 <% if (typeof premiumUnlocked === 'undefined' || premiumUnlocked) { %> 49 36 <a href="/admin/stats" class="btn"><%= t('admin.b_stats') %></a> 50 37 <a href="/admin/paid" class="btn"><%= t('admin.b_paid') %></a> 51 38 <a href="/admin/newsletter" class="btn"><%= t('admin.b_newsletter') %></a> 52 <% if ( tenancy !== 'hub' &&primarySite) { %><a href="/pers" class="btn" target="_blank"><%= t('admin.b_perskit') %></a><% } %>53 <% if ( tenancy !== 'hub' &&primarySite) { %><a href="/downloads" class="btn" target="_blank"><%= t('admin.b_downloads') %></a><% } %>54 <% if ( tenancy !== 'hub' &&primarySite) { %><a href="/links" class="btn" target="_blank"><%= t('admin.b_linkbio') %></a><% } %>39 <% if (primarySite) { %><a href="/pers" class="btn" target="_blank"><%= t('admin.b_perskit') %></a><% } %> 40 <% if (primarySite) { %><a href="/downloads" class="btn" target="_blank"><%= t('admin.b_downloads') %></a><% } %> 41 <% if (primarySite) { %><a href="/links" class="btn" target="_blank"><%= t('admin.b_linkbio') %></a><% } %> 55 42 <a href="/admin/shows" class="btn"><%= t('admin.b_agenda') %></a> 56 43 <% } %> … … 63 50 64 51 <div class="admin-stats"> 65 <% if (tenancy === 'hub') { %>66 <div class="stat-card"><div class="stat-num"><%= stats.users %></div><div class="stat-label"><%= t('admin.st_users') %></div></div>67 <div class="stat-card"><div class="stat-num"><%= stats.sites %></div><div class="stat-label"><%= t('admin.st_sites') %></div></div>68 <% } %>69 52 <div class="stat-card"><div class="stat-num"><%= stats.posts %></div><div class="stat-label"><%= t('admin.st_posts') %></div></div> 70 53 <div class="stat-card"><div class="stat-num"><%= stats.published %></div><div class="stat-label"><%= t('admin.st_published') %></div></div> … … 90 73 <% } %> 91 74 92 <% if (sites && sites.length) { %>93 <h2><%= t('admin.sec_sites') %></h2>94 <table class="admin-table">95 <thead>96 <tr><th><%= t('admin.th_slug') %></th><th><%= t('admin.th_title') %></th><th><%= t('admin.th_owner') %></th><th><%= t('admin.th_created') %></th></tr>97 </thead>98 <tbody>99 <% sites.forEach(function(s) { %>100 <tr>101 <td><code><%= s.slug %></code></td>102 <td><%= s.title %></td>103 <td><%= s.owner_username || '—' %></td>104 <td><%= formatDate(s.created_at) %></td>105 </tr>106 <% }); %>107 </tbody>108 </table>109 <% } %>110 111 <% if (users && users.length) { %>112 <h2><%= t('admin.sec_users') %></h2>113 <table class="admin-table">114 <thead>115 <tr><th><%= t('admin.th_username') %></th><th><%= t('admin.th_email') %></th><th><%= t('admin.th_role') %></th><th><%= t('admin.th_joined') %></th></tr>116 </thead>117 <tbody>118 <% users.forEach(function(u) { %>119 <tr>120 <td><%= u.username %></td>121 <td><%= u.email %></td>122 <td><span class="role-pill role-<%= u.role %>"><%= u.role %></span></td>123 <td><%= formatDate(u.created_at) %></td>124 </tr>125 <% }); %>126 </tbody>127 </table>128 <% } %>129 130 75 </div> 131 76 -
src/views/pages/guardian.ejs
rbfa6fa1 r72ec6a4 26 26 <span class="g-me" title="<%= t('guardian.acting_as') %>">@<%= state.site %></span> 27 27 <% } %> 28 <form method="post" action="/guardian/invite" style="display:inline">29 <button class="quiet small" type="submit">Invite a guardian</button>30 </form>31 28 </header> 32 29 -
src/views/partials/bottom-tab.ejs
rbfa6fa1 r72ec6a4 13 13 && permissions && permissions.canCreatePost && permissions.canCreatePost(user, site)); 14 14 const _ownProfile = user ? ('/users/' + user.username) : null; 15 const _isHub = (typeof tenancy !== 'undefined' && tenancy === 'hub'); 16 // Home in hub mode ALWAYS goes to the hub root (/), not to an artist's sub-home. 17 // In solo/circle _siteUrlBase is empty, so it stays '/'. 18 const _homeHref = (_isHub ? '' : _siteUrlBase) + '/'; 15 const _homeHref = _siteUrlBase + '/'; 19 16 20 17 // Strip site prefix for cleaner matching … … 26 23 27 24 let _active = null; 28 // In hub, Home is only active on the real hub root, not on an artist sub-home. 29 if (_isHub ? (_path === '/') : (_p === '/' || _p === '')) _active = 'home'; 25 if (_p === '/' || _p === '') _active = 'home'; 30 26 else if (_p.indexOf('/search') === 0 || _p.indexOf('/tag/') === 0 || _p.indexOf('/type/') === 0) _active = 'search'; 31 27 else if (_p === '/posts/new' || /^\/posts\/[^/]+\/edit$/.test(_p)) _active = 'create'; -
src/views/partials/chrome.ejs
rbfa6fa1 r72ec6a4 17 17 var _isAuth = typeof bodyClass === 'string' && bodyClass.indexOf('on-auth') >= 0; 18 18 var _isChat = typeof bodyClass === 'string' && bodyClass.indexOf('on-chat') >= 0; 19 var hubLanding = (typeof bodyClass === 'string' && bodyClass.indexOf('on-hub') >= 0) 20 || ((typeof tenancy !== 'undefined' && tenancy === 'hub') && !siteUrlBase && !_isAdmin); 19 var hubLanding = (typeof bodyClass === 'string' && bodyClass.indexOf('on-hub') >= 0); 21 20 // Zelfstandige premium-feature-pagina's hebben hun eigen kop → geen profielkop/ 22 21 // switcher, wél de topnav (Robin 2026-06-18: "bovenste weg, behoud de nav"). -
src/views/partials/topnav.ejs
rbfa6fa1 r72ec6a4 28 28 <% if (_isAdmin || _isSpecial) { %> 29 29 <a href="<%= _siteUrlBase %>/" class="nav-link nav-link-admin"><%= t('nav.back_to_site') %></a> 30 <% } else if (typeof tenancy !== 'undefined' && tenancy === 'hub') { %>31 <%# Hub: the brand navigates to the hub home via HTMX. The chrome is always32 rendered in hub mode and hidden on the landing via body.on-hub (CSS), so htmx33 can switch without a full reload → the audio player stays alive. %>34 <a class="nav-hub-home" href="/" aria-label="Home" title="Home"35 hx-get="/?partial=1" hx-target="#pcms-main" hx-swap="innerHTML" hx-push-url="/" hx-indicator="#pcms-loading">36 <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M3 9l9-7 9 7v11a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z"/><polyline points="9 22 9 12 15 12 15 22"/></svg>37 </a>38 <% } else if (site) { %>39 <a class="site-title" href="<%= _siteUrlBase %>/"40 hx-get="<%= _siteUrlBase %>/?partial=1" hx-target="#pcms-main" hx-swap="innerHTML"41 hx-push-url="<%= _siteUrlBase %>/" hx-indicator="#pcms-loading"><%= site.title %></a>42 30 <% } else { %> 43 31 <a class="site-title" href="/">Klonkt</a>
Note:
See TracChangeset
for help on using the changeset viewer.
![(please configure the [header_logo] section in trac.ini)](/chrome/site/your_project_logo.png)