| [495b019] | 1 | import fs from 'fs';
|
|---|
| 2 | import path from 'path';
|
|---|
| 3 | import { fileURLToPath } from 'url';
|
|---|
| 4 |
|
|---|
| [7bc636b] | 5 | /**
|
|---|
| 6 | * ThemeService — Palette and theme management
|
|---|
| 7 | *
|
|---|
| [c9c70cc] | 8 | * 8 Built-in Palettes — 1 neutral default + 7 real colours:
|
|---|
| 9 | * - Klonkt (DEFAULT, clean white/black neutral + gold accent; was 'Paper')
|
|---|
| 10 | * - Forest (green) · Ocean (blue) · Teal · Lilac (purple)
|
|---|
| 11 | * - Sunset (pink) · Candy (red) · Amber (warm)
|
|---|
| 12 | * Colour palettes are tinted (like forest/lilac), not navy-with-accent.
|
|---|
| [434c2e9] | 13 | *
|
|---|
| [7bc636b] | 14 | * Dark/Light mode toggle stored per user
|
|---|
| 15 | */
|
|---|
| 16 |
|
|---|
| 17 | class ThemeService {
|
|---|
| [834bcc3] | 18 | // Paper/ink values map 1-to-1 from the [data-palette] CSS in style.css (= what
|
|---|
| 19 | // is ACTUALLY applied); the accent dot is a representative color per palette.
|
|---|
| [7bc636b] | 20 | static PALETTES = {
|
|---|
| [c9c70cc] | 21 | // DEFAULT — clean neutral (formerly 'Paper'), renamed to the brand 'Klonkt'.
|
|---|
| 22 | // White → near-black, with the brand gold as accent. The old navy 'Klonkt' is gone.
|
|---|
| [dd7e2a2] | 23 | klonkt: {
|
|---|
| 24 | name: 'Klonkt',
|
|---|
| [c9c70cc] | 25 | light: { paper: '#ffffff', ink: '#09090b', accent: '#c98a2a' },
|
|---|
| 26 | dark: { paper: '#0a0a0a', ink: '#fafafa', accent: '#e8b04b' }
|
|---|
| [7bc636b] | 27 | },
|
|---|
| [c9c70cc] | 28 | // 7 real-colour palettes (tinted paper, like forest/lilac — NOT navy-with-accent).
|
|---|
| [7bc636b] | 29 | forest: {
|
|---|
| 30 | name: 'Forest',
|
|---|
| [999aad2] | 31 | light: { paper: '#f2f6ed', ink: '#1a2e15', accent: '#4d7c2a' },
|
|---|
| 32 | dark: { paper: '#0d1f12', ink: '#dcf2d0', accent: '#6fae3f' }
|
|---|
| [7bc636b] | 33 | },
|
|---|
| [19fbe03] | 34 | ocean: {
|
|---|
| 35 | name: 'Ocean',
|
|---|
| [c9c70cc] | 36 | light: { paper: '#eef4fb', ink: '#0f2942', accent: '#1d6fe0' },
|
|---|
| 37 | dark: { paper: '#081726', ink: '#d6e8fb', accent: '#5ba0f5' }
|
|---|
| [999aad2] | 38 | },
|
|---|
| [c9c70cc] | 39 | teal: {
|
|---|
| 40 | name: 'Teal',
|
|---|
| 41 | light: { paper: '#ecf7f5', ink: '#0c2e2a', accent: '#0d9488' },
|
|---|
| 42 | dark: { paper: '#06201d', ink: '#d4f2ec', accent: '#2dd4bf' }
|
|---|
| [999aad2] | 43 | },
|
|---|
| 44 | lilac: {
|
|---|
| 45 | name: 'Lilac',
|
|---|
| 46 | light: { paper: '#faf4fb', ink: '#2a1830', accent: '#a855f7' },
|
|---|
| 47 | dark: { paper: '#170a1c', ink: '#f3e2f7', accent: '#c084fc' }
|
|---|
| [c9c70cc] | 48 | },
|
|---|
| 49 | sunset: {
|
|---|
| 50 | name: 'Sunset',
|
|---|
| 51 | light: { paper: '#fdf4f3', ink: '#2e1618', accent: '#d6477f' },
|
|---|
| 52 | dark: { paper: '#1f0a14', ink: '#fce7f3', accent: '#f06fa3' }
|
|---|
| 53 | },
|
|---|
| 54 | candy: {
|
|---|
| 55 | name: 'Candy',
|
|---|
| 56 | light: { paper: '#fdf1f3', ink: '#3a1018', accent: '#e11d48' },
|
|---|
| 57 | dark: { paper: '#220810', ink: '#fde0e6', accent: '#fb6f8b' }
|
|---|
| 58 | },
|
|---|
| 59 | amber: {
|
|---|
| 60 | name: 'Amber',
|
|---|
| 61 | light: { paper: '#fdf6e9', ink: '#3a2a0c', accent: '#d97706' },
|
|---|
| 62 | dark: { paper: '#221a08', ink: '#fdeecb', accent: '#f0a93a' }
|
|---|
| [7bc636b] | 63 | }
|
|---|
| 64 | };
|
|---|
| 65 |
|
|---|
| 66 | /**
|
|---|
| 67 | * Curated accent palette — admins pick one of these instead of a free-form
|
|---|
| 68 | * hex picker. Keeps the brand consistent and avoids unreadable combinations.
|
|---|
| 69 | * Each color works against both light and dark themes.
|
|---|
| 70 | */
|
|---|
| [834bcc3] | 71 | // Balanced across the color wheel — fewer greens/blues (4 of 12),
|
|---|
| 72 | // more warm + purple/pink variation. All readable on both light and dark.
|
|---|
| [7bc636b] | 73 | static ACCENTS = [
|
|---|
| [dd7e2a2] | 74 | { key: 'klonkt', name: 'Klonkt-geel', color: '#e8b04b' },
|
|---|
| [19fbe03] | 75 | { key: 'red', name: 'Candy-rood', color: '#e11d48' },
|
|---|
| [0251bae] | 76 | { key: 'amber', name: 'Amber', color: '#d97706' },
|
|---|
| 77 | { key: 'forest', name: 'Groen', color: '#16a34a' },
|
|---|
| [999aad2] | 78 | { key: 'teal', name: 'Turquoise', color: '#0d9488' },
|
|---|
| [0251bae] | 79 | { key: 'ocean', name: 'Blauw', color: '#2563eb' },
|
|---|
| 80 | { key: 'indigo', name: 'Indigo', color: '#4f46e5' },
|
|---|
| 81 | { key: 'violet', name: 'Violet', color: '#7c3aed' },
|
|---|
| 82 | { key: 'plum', name: 'Magenta', color: '#c026d3' },
|
|---|
| 83 | { key: 'pink', name: 'Roze', color: '#db2777' },
|
|---|
| [7bc636b] | 84 | ];
|
|---|
| 85 |
|
|---|
| 86 | static listAccents() {
|
|---|
| 87 | return this.ACCENTS;
|
|---|
| 88 | }
|
|---|
| 89 |
|
|---|
| 90 | /**
|
|---|
| 91 | * Validate an accent color. Returns the canonical hex if it's in the curated
|
|---|
| 92 | * set (case-insensitive match), or null if it's not. Server-side validation
|
|---|
| 93 | * uses this so we never persist arbitrary hex from the form.
|
|---|
| 94 | */
|
|---|
| 95 | static validateAccent(hex) {
|
|---|
| 96 | if (!hex || typeof hex !== 'string') return null;
|
|---|
| 97 | const target = hex.trim().toLowerCase();
|
|---|
| 98 | const found = this.ACCENTS.find(a => a.color.toLowerCase() === target);
|
|---|
| 99 | return found ? found.color : null;
|
|---|
| 100 | }
|
|---|
| 101 |
|
|---|
| 102 | /**
|
|---|
| 103 | * Get palette data
|
|---|
| 104 | */
|
|---|
| 105 | static getPalette(paletteKey) {
|
|---|
| [dd7e2a2] | 106 | return this.PALETTES[paletteKey] || this.PALETTES.klonkt;
|
|---|
| [7bc636b] | 107 | }
|
|---|
| 108 |
|
|---|
| 109 | /**
|
|---|
| 110 | * Get all available palettes (with full color data so a picker UI can
|
|---|
| 111 | * render true previews of paper/ink/accent for both light and dark).
|
|---|
| 112 | */
|
|---|
| 113 | static listPalettes() {
|
|---|
| 114 | return Object.entries(this.PALETTES).map(([key, data]) => ({
|
|---|
| 115 | key,
|
|---|
| 116 | name: data.name,
|
|---|
| 117 | light: data.light,
|
|---|
| 118 | dark: data.dark,
|
|---|
| 119 | }));
|
|---|
| 120 | }
|
|---|
| 121 |
|
|---|
| 122 | /**
|
|---|
| 123 | * Generate CSS variables for palette + theme
|
|---|
| 124 | */
|
|---|
| 125 | static generateCSSVars(paletteKey, theme = 'dark', accentColor = null) {
|
|---|
| 126 | const palette = this.getPalette(paletteKey);
|
|---|
| 127 | const colors = theme === 'dark' ? palette.dark : palette.light;
|
|---|
| 128 | const accent = accentColor || colors.accent;
|
|---|
| 129 |
|
|---|
| 130 | return `
|
|---|
| 131 | :root {
|
|---|
| 132 | --palette: ${paletteKey};
|
|---|
| 133 | --theme: ${theme};
|
|---|
| 134 | --paper: ${colors.paper};
|
|---|
| 135 | --ink: ${colors.ink};
|
|---|
| 136 | --accent: ${accent};
|
|---|
| 137 | }
|
|---|
| 138 | `.trim();
|
|---|
| 139 | }
|
|---|
| 140 |
|
|---|
| 141 | /**
|
|---|
| 142 | * Update user's theme preference
|
|---|
| 143 | */
|
|---|
| 144 | static updateUserTheme(db, userId, theme, palette) {
|
|---|
| 145 | if (!['dark', 'light'].includes(theme)) {
|
|---|
| 146 | throw new Error('Invalid theme. Must be "dark" or "light"');
|
|---|
| 147 | }
|
|---|
| 148 | if (!this.PALETTES[palette]) {
|
|---|
| 149 | throw new Error('Invalid palette');
|
|---|
| 150 | }
|
|---|
| 151 |
|
|---|
| 152 | db.prepare(`
|
|---|
| 153 | UPDATE users SET theme = ?, palette = ? WHERE id = ?
|
|---|
| 154 | `).run(theme, palette, userId);
|
|---|
| 155 | }
|
|---|
| 156 |
|
|---|
| 157 | /**
|
|---|
| 158 | * Update site's palette + accent
|
|---|
| 159 | */
|
|---|
| 160 | static updateSitePalette(db, siteId, paletteKey, accentColor) {
|
|---|
| 161 | if (!this.PALETTES[paletteKey]) {
|
|---|
| 162 | throw new Error('Invalid palette');
|
|---|
| 163 | }
|
|---|
| 164 | if (!/^#[0-9a-f]{6}$/i.test(accentColor)) {
|
|---|
| 165 | throw new Error('Invalid accent color. Must be hex #RRGGBB');
|
|---|
| 166 | }
|
|---|
| 167 |
|
|---|
| 168 | db.prepare(`
|
|---|
| 169 | UPDATE sites SET palette = ?, accent = ? WHERE id = ?
|
|---|
| 170 | `).run(paletteKey, accentColor, siteId);
|
|---|
| 171 | }
|
|---|
| 172 |
|
|---|
| 173 | /**
|
|---|
| 174 | * Generate full HTML theme meta tags
|
|---|
| 175 | */
|
|---|
| 176 | static generateThemeMeta(userTheme, userPalette, siteTheme, siteAccent) {
|
|---|
| 177 | const theme = userTheme || siteTheme || 'dark';
|
|---|
| [dd7e2a2] | 178 | const palette = userPalette || 'klonkt';
|
|---|
| 179 | const accent = siteAccent || '#e8b04b';
|
|---|
| [7bc636b] | 180 | const paletteData = this.getPalette(palette);
|
|---|
| 181 | const colors = theme === 'dark' ? paletteData.dark : paletteData.light;
|
|---|
| 182 |
|
|---|
| 183 | return {
|
|---|
| 184 | colorScheme: 'dark light',
|
|---|
| 185 | themeColor: accent,
|
|---|
| 186 | appleMobileWebAppStatusBarStyle: 'black-translucent',
|
|---|
| 187 | cssVars: this.generateCSSVars(palette, theme, accent),
|
|---|
| 188 | inline: `
|
|---|
| 189 | <style>
|
|---|
| 190 | html {
|
|---|
| 191 | color-scheme: ${theme === 'dark' ? 'dark light' : 'light dark'};
|
|---|
| 192 | }
|
|---|
| 193 | :root {
|
|---|
| 194 | --paper: ${colors.paper};
|
|---|
| 195 | --ink: ${colors.ink};
|
|---|
| 196 | --accent: ${accent};
|
|---|
| 197 | }
|
|---|
| 198 | </style>
|
|---|
| 199 | <script>
|
|---|
| 200 | // Apply theme ASAP (before paint) to avoid flash
|
|---|
| 201 | (function() {
|
|---|
| 202 | try {
|
|---|
| 203 | const t = localStorage.getItem('pcms-theme') || '${theme}';
|
|---|
| 204 | const p = localStorage.getItem('pcms-palette') || '${palette}';
|
|---|
| 205 | document.documentElement.setAttribute('data-theme', t);
|
|---|
| 206 | document.documentElement.setAttribute('data-palette', p);
|
|---|
| 207 | } catch(e) {}
|
|---|
| 208 | })();
|
|---|
| 209 | </script>
|
|---|
| 210 | `.trim()
|
|---|
| 211 | };
|
|---|
| 212 | }
|
|---|
| [495b019] | 213 |
|
|---|
| 214 | // ── Lay-outthema's (experimental/theme, op weg naar 2.0) ─────────────
|
|---|
| 215 | //
|
|---|
| 216 | // Een lay-out is een PAKKET: een map met een theme.json en een stylesheet.
|
|---|
| 217 | // De ingebouwde staan in src/themes/<id>/; een thema van buiten installeren
|
|---|
| 218 | // komt later en gaat door dezelfde lader, zodat er één vorm is.
|
|---|
| 219 | //
|
|---|
| 220 | // Wie kiest: de BEHEERDER van een site (sites.layout). Een bezoeker niet --
|
|---|
| 221 | // `?layout=` is alleen een voorproefje voor wie de site beheert.
|
|---|
| 222 |
|
|---|
| 223 | /** Het DOM-contract dat thema's mogen aanspreken. Gaat omhoog zodra de
|
|---|
| 224 | * markup verandert op een manier die thema's breekt; een thema op een ander
|
|---|
| 225 | * contract valt dan terug op de standaardlay-out in plaats van half te
|
|---|
| 226 | * werken. */
|
|---|
| 227 | static LAYOUT_CONTRACT = 1;
|
|---|
| 228 |
|
|---|
| 229 | static THEMES_DIR = path.join(path.dirname(fileURLToPath(import.meta.url)), '..', 'themes');
|
|---|
| 230 |
|
|---|
| 231 | /** Een id is een naam en geen pad. Kort, kleine letters, punten en
|
|---|
| 232 | * streepjes, zodat er niets anders van te maken is dan een mapnaam. */
|
|---|
| 233 | static isLayoutId(id) {
|
|---|
| 234 | return typeof id === 'string' && /^[a-z0-9][a-z0-9.-]{0,63}$/.test(id) && !id.includes('..');
|
|---|
| 235 | }
|
|---|
| 236 |
|
|---|
| 237 | /** Een theme.json die klopt, of null -- met de reden in de log, want een
|
|---|
| 238 | * thema dat stil wegvalt is een raadsel voor wie het installeerde. */
|
|---|
| 239 | static validateLayoutManifest(m, dirName) {
|
|---|
| 240 | const fout = (waarom) => { console.warn(`[theme] ${dirName || '?'}: ${waarom}`); return null; };
|
|---|
| 241 | if (!m || typeof m !== 'object') return fout('geen geldige theme.json');
|
|---|
| 242 | if (!ThemeService.isLayoutId(m.id)) return fout(`ongeldig id ${JSON.stringify(m.id)}`);
|
|---|
| 243 | if (dirName && m.id !== dirName) return fout(`id ${m.id} hoort in een map met die naam`);
|
|---|
| 244 | if (typeof m.name !== 'string' || !m.name.trim()) return fout('naam ontbreekt');
|
|---|
| 245 | if (typeof m.version !== 'string' || !/^\d+\.\d+\.\d+$/.test(m.version)) return fout('versie is geen x.y.z');
|
|---|
| 246 | if (m.contract !== ThemeService.LAYOUT_CONTRACT) return fout(`contract ${m.contract}, deze Klonkt kent ${ThemeService.LAYOUT_CONTRACT}`);
|
|---|
| 247 | if (typeof m.stylesheet !== 'string' || !/^[a-z0-9][a-z0-9._-]*\.css$/i.test(m.stylesheet)) return fout('stylesheet ontbreekt of is geen bestandsnaam');
|
|---|
| 248 | return {
|
|---|
| 249 | id: m.id,
|
|---|
| 250 | name: m.name.trim().slice(0, 80),
|
|---|
| 251 | description: typeof m.description === 'string' ? m.description.slice(0, 500) : '',
|
|---|
| 252 | version: m.version,
|
|---|
| 253 | contract: m.contract,
|
|---|
| 254 | stylesheet: m.stylesheet,
|
|---|
| 255 | author: typeof m.author === 'string' ? m.author.slice(0, 200) : '',
|
|---|
| 256 | license: typeof m.license === 'string' ? m.license.slice(0, 64) : '',
|
|---|
| 257 | builtIn: true,
|
|---|
| 258 | };
|
|---|
| 259 | }
|
|---|
| 260 |
|
|---|
| 261 | static _layouts = null;
|
|---|
| 262 |
|
|---|
| 263 | /** Alle lay-outs die deze Klonkt kent, op naam. Eén keer gelezen per proces:
|
|---|
| 264 | * een pakket verandert niet terwijl de server draait, en de shell vraagt
|
|---|
| 265 | * hier bij elk verzoek om. */
|
|---|
| 266 | static listLayouts() {
|
|---|
| 267 | if (ThemeService._layouts) return ThemeService._layouts;
|
|---|
| 268 | const uit = [];
|
|---|
| 269 | let mappen = [];
|
|---|
| 270 | try { mappen = fs.readdirSync(ThemeService.THEMES_DIR, { withFileTypes: true }); } catch { mappen = []; }
|
|---|
| 271 | for (const d of mappen) {
|
|---|
| 272 | if (!d.isDirectory() || !ThemeService.isLayoutId(d.name)) continue;
|
|---|
| 273 | const dir = path.join(ThemeService.THEMES_DIR, d.name);
|
|---|
| 274 | let ruw;
|
|---|
| 275 | try { ruw = JSON.parse(fs.readFileSync(path.join(dir, 'theme.json'), 'utf8')); } catch { console.warn(`[theme] ${d.name}: theme.json onleesbaar`); continue; }
|
|---|
| 276 | const m = ThemeService.validateLayoutManifest(ruw, d.name);
|
|---|
| 277 | if (!m) continue;
|
|---|
| 278 | if (!fs.existsSync(path.join(dir, m.stylesheet))) { console.warn(`[theme] ${d.name}: ${m.stylesheet} ontbreekt`); continue; }
|
|---|
| 279 | uit.push(m);
|
|---|
| 280 | }
|
|---|
| 281 | uit.sort((a, b) => a.name.localeCompare(b.name));
|
|---|
| 282 | ThemeService._layouts = uit;
|
|---|
| 283 | return uit;
|
|---|
| 284 | }
|
|---|
| 285 |
|
|---|
| 286 | static getLayout(id) {
|
|---|
| 287 | if (!ThemeService.isLayoutId(id)) return null;
|
|---|
| 288 | return ThemeService.listLayouts().find((m) => m.id === id) || null;
|
|---|
| 289 | }
|
|---|
| 290 |
|
|---|
| 291 | /** Waar een thema zijn bestanden laat zien. De versie zit in het pad, dus
|
|---|
| 292 | * een nieuwe versie is een nieuwe URL en de cache van een jaar kan blijven. */
|
|---|
| 293 | static layoutBaseUrl(m) { return `/themes/${m.id}/${m.version}`; }
|
|---|
| 294 | static layoutStylesheetUrl(m) { return `${ThemeService.layoutBaseUrl(m)}/${m.stylesheet}`; }
|
|---|
| 295 | static layoutManifestUrl(m) { return `${ThemeService.layoutBaseUrl(m)}/theme.json`; }
|
|---|
| 296 |
|
|---|
| 297 | /**
|
|---|
| 298 | * Welke lay-out deze pagina draagt, of null voor de gewone kop.
|
|---|
| 299 | *
|
|---|
| 300 | * - Beheerpagina's ALTIJD de gewone kop: een kapot thema mag de beheerder
|
|---|
| 301 | * nooit buitensluiten van de instelling waarmee hij het uitzet.
|
|---|
| 302 | * - `preview` (uit ?layout=) alleen voor wie de site beheert. Een bezoeker
|
|---|
| 303 | * kiest niet; de site kiest.
|
|---|
| 304 | * - Anders wat de site gekozen heeft.
|
|---|
| 305 | */
|
|---|
| 306 | static resolveLayout(site, { isAdminPage = false, canManage = false, preview = null } = {}) {
|
|---|
| 307 | if (isAdminPage) return null;
|
|---|
| 308 | if (canManage && typeof preview === 'string') {
|
|---|
| 309 | if (preview === 'top') return null;
|
|---|
| 310 | const p = ThemeService.getLayout(preview);
|
|---|
| 311 | if (p) return p;
|
|---|
| 312 | }
|
|---|
| 313 | return site && site.layout ? ThemeService.getLayout(site.layout) : null;
|
|---|
| 314 | }
|
|---|
| 315 |
|
|---|
| 316 | /** Het bestand achter /themes/<id>/<versie>/<naam>, of null. Alleen wat het
|
|---|
| 317 | * pakket zelf noemt -- de stylesheet en de theme.json -- en alleen van de
|
|---|
| 318 | * versie die er nu is. Geen pad uit de URL komt ooit in een join terecht. */
|
|---|
| 319 | static layoutFile(id, version, file) {
|
|---|
| 320 | const m = ThemeService.getLayout(id);
|
|---|
| 321 | if (!m || version !== m.version) return null;
|
|---|
| 322 | if (file !== m.stylesheet && file !== 'theme.json') return null;
|
|---|
| 323 | return path.join(ThemeService.THEMES_DIR, m.id, file);
|
|---|
| 324 | }
|
|---|
| 325 |
|
|---|
| 326 | /** Wat de actor erover zegt (shaer:theme), zodat Shaer het thema van zijn
|
|---|
| 327 | * eigen site kan ophalen in plaats van er zelf een te kiezen. */
|
|---|
| 328 | static layoutActorProp(base, site) {
|
|---|
| 329 | const m = site && site.layout ? ThemeService.getLayout(site.layout) : null;
|
|---|
| 330 | if (!m) return {};
|
|---|
| 331 | return {
|
|---|
| 332 | 'shaer:theme': {
|
|---|
| 333 | type: 'shaer:Theme',
|
|---|
| 334 | id: `${base}${ThemeService.layoutManifestUrl(m)}`,
|
|---|
| 335 | name: m.name,
|
|---|
| 336 | 'shaer:version': m.version,
|
|---|
| 337 | 'shaer:contract': m.contract,
|
|---|
| 338 | },
|
|---|
| 339 | };
|
|---|
| 340 | }
|
|---|
| [7bc636b] | 341 | }
|
|---|
| 342 |
|
|---|
| 343 | export default ThemeService;
|
|---|