Changeset 5e61b17 in Klonkt


Ignore:
Timestamp:
06/21/2026 03:16:51 PM (3 months ago)
Author:
roboburr <roboburr@…>
Branches:
main
Children:
11b3ba5
Parents:
4885eab
Message:

feat(i18n): admin-configurable language — personal + default for visitors

  • users.lang column: personal interface language per account (follows across devices/sessions). Switcher (/lang/:code) and Account select both write it.
  • app_setting default_lang: admin picks the default language for visitors in Admin → Settings (overrides env/browser; empty = automatic).
  • resolveLang priority: session choice → users.lang → default_lang (DB) → env KLONKT_DEFAULT_LANG → browser → nl.
  • Account: language select; admin-settings: default language select. NL/EN/DE keys.

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

Location:
src
Files:
8 edited

Legend:

Unmodified
Added
Removed
  • src/config/database.js

    r4885eab r5e61b17  
    4343  // Read-only/kijk-account: kan alles bekijken maar geen wijzigingen doen.
    4444  ensureColumn('users', 'readonly', 'INTEGER DEFAULT 0');
     45  // Persoonlijke interface-taal (nl|en|de). Null = volg de standaard (site/env/browser).
     46  ensureColumn('users', 'lang', 'TEXT');
    4547  // Site-level moderation toggle. 'trust' = auto-approve, 'moderate' = pending until reviewed.
    4648  ensureColumn('sites', 'comments_moderation_mode', "TEXT DEFAULT 'moderate'");
  • src/middleware/render.js

    r4885eab r5e61b17  
    5454  // avatar-wijziging of rolwissel) zichzelf herstelt zonder opnieuw inloggen.
    5555  if (_u && _u.id) {
    56     const _fresh = db.prepare('SELECT avatar_url, role FROM users WHERE id = ?').get(_u.id);
    57     if (_fresh) _u = { ..._u, avatar_url: _fresh.avatar_url, role: _fresh.role };
     56    const _fresh = db.prepare('SELECT avatar_url, role, lang FROM users WHERE id = ?').get(_u.id);
     57    if (_fresh) _u = { ..._u, avatar_url: _fresh.avatar_url, role: _fresh.role, lang: _fresh.lang };
    5858  }
    5959  const userOwnsSite = !!(_u && _u.role !== 'god' &&
     
    7878  const canSeeBeheer = !!(_u && (_role === 'god' || _role === 'admin' || _role === 'kijker' || userOwnsSite));
    7979
    80   // Interface-taal (bezoeker-instelbaar): sessie-keuze → browser-taal → nl.
    81   const _lang = resolveLang(req);
     80  // Interface-taal: sessie-keuze (deze sessie) → eigen voorkeur van de ingelogde
     81  // gebruiker (users.lang) → admin-ingestelde standaard (Beheer) → env → browser → nl.
     82  const _lang = resolveLang(req, {
     83    userLang: _u && _u.lang,
     84    defaultLang: getSetting('default_lang'),
     85  });
    8286
    8387  // Common locals
  • src/routes/account.js

    r4885eab r5e61b17  
    2626import { googleConfigured } from '../config/google.js';
    2727import { toWebp } from '../services/ImageWebpService.js';
     28import { SUPPORTED } from '../services/i18n.js';
    2829
    2930const __dirname = path.dirname(fileURLToPath(import.meta.url));
     
    6061router.get('/', requireAuth, (req, res) => {
    6162  const account = db.prepare(`
    62     SELECT id, username, email, role, bio, avatar_url, created_at, password_hash, google_sub
     63    SELECT id, username, email, role, bio, avatar_url, created_at, password_hash, google_sub, lang
    6364    FROM users WHERE id = ?
    6465  `).get(req.session.user.id);
     
    8081});
    8182
     83// ==================== PERSOONLIJKE INTERFACE-TAAL ====================
     84// Slaat de taalkeuze op het account op (reist mee over apparaten/sessies) én
     85// zet 'm meteen in de sessie zodat 't direct effect heeft.
     86router.post('/lang', requireAuth, (req, res) => {
     87  const code = SUPPORTED.includes(req.body.lang) ? req.body.lang : null;
     88  if (code) {
     89    db.prepare('UPDATE users SET lang = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?').run(code, req.session.user.id);
     90    req.session.user.lang = code;
     91    req.session.lang = code;
     92  }
     93  res.redirect('/account?success=' + encodeURIComponent('Taal opgeslagen'));
     94});
     95
    8296// De site die deze gebruiker mag bewerken vanuit z'n account: z'n eigen site
    8397// (owner_id), of voor een god de primaire site. Null als er niets is.
  • src/routes/admin-settings.js

    r4885eab r5e61b17  
    2222import { requireGod } from '../middleware/auth.js';
    2323import { getTenancy, setTenancy, getSetting, setSetting } from '../services/SettingsService.js';
     24import { SUPPORTED } from '../services/i18n.js';
    2425import { mailerStatus, sendMail } from '../config/mailer.js';
    2526import { entitlementStatus, premiumUnlocked } from '../services/PatreonService.js';
     
    7879    hubHeroImage: getSetting('hub_hero_image') || '',
    7980    hubHeroOverlay: clampOverlay(getSetting('hub_hero_overlay')),
     81    defaultLang: getSetting('default_lang') || '',
    8082    premium: entitlementStatus(),
    8183    google: {
     
    108110      }
    109111      setTenancy(req.body.tenancy); // valideert naar solo | hub | circle
     112    }
     113    if (typeof req.body.default_lang !== 'undefined') {
     114      // Standaardtaal voor bezoekers (leeg = volg env/browser). Valideert tegen NL/EN/DE.
     115      const dl = (req.body.default_lang || '').toString().toLowerCase();
     116      setSetting('default_lang', SUPPORTED.includes(dl) ? dl : '');
    110117    }
    111118    if (typeof req.body.hub_title !== 'undefined') {
  • src/routes/lang.js

    r4885eab r5e61b17  
    33import express from 'express';
    44import { SUPPORTED } from '../services/i18n.js';
     5import db from '../config/database.js';
    56
    67const router = express.Router();
     
    910  const code = SUPPORTED.includes(req.params.code) ? req.params.code : 'nl';
    1011  if (req.session) req.session.lang = code;
     12  // Ingelogd? Bewaar de keuze ook op het account zodat 'ie meereist over
     13  // apparaten/sessies (niet alleen deze sessie-cookie).
     14  if (req.session && req.session.user && req.session.user.id) {
     15    try {
     16      db.prepare('UPDATE users SET lang = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?').run(code, req.session.user.id);
     17      req.session.user.lang = code;
     18    } catch { /* lang-kolom ontbreekt op een oude DB → sessie-only, geen breuk */ }
     19  }
    1120  // Veilige terug-URL: alleen een intern pad (geen open redirect).
    1221  let back = (typeof req.query.r === 'string') ? req.query.r : '';
  • src/services/i18n.js

    r4885eab r5e61b17  
    964964    'prcv.placeholder': 'Bericht…',
    965965    'prcv.send': 'Stuur',
     966    'acct.lang_label': 'Taal',
     967    'acct.lang_hint': '— jouw persoonlijke interface-taal; reist mee over apparaten en sessies.',
     968    'aset.default_lang': 'Standaardtaal voor bezoekers',
     969    'aset.default_lang_hint': 'Wat nieuwe bezoekers zien voordat ze zelf een taal kiezen. Een ingelogde gebruiker met een eigen voorkeur ziet díe.',
     970    'aset.default_lang_auto': 'Automatisch (browser-taal)',
    966971  },
    967972  en: {
     
    19101915    'prcv.placeholder': 'Message…',
    19111916    'prcv.send': 'Send',
     1917    'acct.lang_label': 'Language',
     1918    'acct.lang_hint': '— your personal interface language; travels with you across devices and sessions.',
     1919    'aset.default_lang': 'Default language for visitors',
     1920    'aset.default_lang_hint': 'What new visitors see before they pick a language themselves. A logged-in user with their own preference sees that instead.',
     1921    'aset.default_lang_auto': 'Automatic (browser language)',
    19121922  },
    19131923  de: {
     
    28562866    'prcv.placeholder': 'Nachricht…',
    28572867    'prcv.send': 'Senden',
     2868    'acct.lang_label': 'Sprache',
     2869    'acct.lang_hint': '— deine persönliche Interface-Sprache; reist mit dir über Geräte und Sitzungen.',
     2870    'aset.default_lang': 'Standardsprache für Besucher',
     2871    'aset.default_lang_hint': 'Was neue Besucher sehen, bevor sie selbst eine Sprache wählen. Ein angemeldeter Benutzer mit eigener Voreinstellung sieht diese.',
     2872    'aset.default_lang_auto': 'Automatisch (Browsersprache)',
    28582873  },
    28592874};
     
    28692884// Bepaal de taal voor dit request: expliciete sessie-keuze → instance-standaard
    28702885// (env KLONKT_DEFAULT_LANG, bv. 'de' voor een Duitse site) → browser-taal → nl.
    2871 export function resolveLang(req) {
     2886export function resolveLang(req, opts = {}) {
    28722887  const s = req && req.session && req.session.lang;
    2873   if (s && SUPPORTED.includes(s)) return s;            // bezoeker koos zelf
     2888  if (s && SUPPORTED.includes(s)) return s;             // bezoeker koos zelf (deze sessie)
     2889  const u = (opts.userLang || '').toLowerCase();
     2890  if (SUPPORTED.includes(u)) return u;                  // ingelogde gebruiker: eigen voorkeur
     2891  const d = (opts.defaultLang || '').toLowerCase();
     2892  if (SUPPORTED.includes(d)) return d;                  // admin-ingestelde standaard (Beheer/DB)
    28742893  const envDefault = (process.env.KLONKT_DEFAULT_LANG || '').toLowerCase();
    2875   if (SUPPORTED.includes(envDefault)) return envDefault; // per-instance standaard
     2894  if (SUPPORTED.includes(envDefault)) return envDefault; // per-instance standaard (env)
    28762895  const al = ((req && req.headers && req.headers['accept-language']) || '').toLowerCase();
    28772896  const first = al.split(',')[0].trim().slice(0, 2);
  • src/views/pages/account.ejs

    r4885eab r5e61b17  
    9090  </section>
    9191
     92  <%# ── PERSOONLIJKE TAAL ───────────────────────────────────── %>
     93  <% if (canMutate) { %>
     94  <section class="ax-card">
     95    <div class="ax-card-title"><%= t('acct.lang_label') %></div>
     96    <form action="/account/lang" method="post" class="ax-form">
     97      <label class="ax-field">
     98        <span><%= t('acct.lang_label') %> <small><%= t('acct.lang_hint') %></small></span>
     99        <select name="lang">
     100          <% langs.forEach(function(l){ var sel = (account && account.lang) ? (account.lang === l.code) : l.active; %>
     101            <option value="<%= l.code %>"<%= sel ? ' selected' : '' %>><%= l.name %></option>
     102          <% }); %>
     103        </select>
     104      </label>
     105      <div class="ax-form-actions">
     106        <button type="submit" class="ax-btn ax-btn-primary"><%= t('acct.save') %></button>
     107      </div>
     108    </form>
     109  </section>
     110  <% } %>
     111
    92112  <%# ── SITE (eigenaar mag de naam van z'n site aanpassen) ── %>
    93113  <% if (typeof editableSite !== 'undefined' && editableSite && canMutate) { %>
  • src/views/pages/admin-settings.ejs

    r4885eab r5e61b17  
    3535          <small><%= t('aset.circle_desc') %></small>
    3636        </span>
     37      </label>
     38      <button type="submit" class="btn btn-primary"><%= t('aset.save') %></button>
     39    </form>
     40  </section>
     41
     42  <%# ── Standaardtaal voor bezoekers ── %>
     43  <section class="set-card" style="margin-top:1rem">
     44    <h2><%= t('aset.default_lang') %></h2>
     45    <p class="set-help"><%= t('aset.default_lang_hint') %></p>
     46    <form method="post" action="/admin/settings" class="set-form">
     47      <label class="set-field">
     48        <span><%= t('aset.default_lang') %></span>
     49        <select name="default_lang">
     50          <option value=""<%= !defaultLang ? ' selected' : '' %>><%= t('aset.default_lang_auto') %></option>
     51          <% langs.forEach(function(l){ %>
     52            <option value="<%= l.code %>"<%= defaultLang === l.code ? ' selected' : '' %>><%= l.name %></option>
     53          <% }); %>
     54        </select>
    3755      </label>
    3856      <button type="submit" class="btn btn-primary"><%= t('aset.save') %></button>
Note: See TracChangeset for help on using the changeset viewer.