Changeset 8afbdd6 in Klonkt


Ignore:
Timestamp:
06/14/2026 06:07:51 PM (3 months ago)
Author:
roboburr <roboburr@…>
Branches:
main
Children:
c869272
Parents:
b94c08e
Message:

feat: viewer role + artists directory + Klonkt Hub Beta rebrand

Viewer role (replaces the separate view-mode/readonly toggle):

  • 'kijker' is now a real role (VALID_ROLES) selectable in Admin. May view EVERYTHING including Admin, but cannot modify anything.
  • isViewer(user) (= role kijker or legacy readonly flag) is the source; requireGod/requireSiteManager(BySlug) let a viewer through (viewing), the global guard 403s every write. Existing readonly accounts are migrated on each role change (readonly=0).
  • Write leaks via GET patched: /prutter/new (INSERT) blocks for viewers, /prutter/:id skips markAsRead (UPDATE); WS upgrade rejects viewers (the HTTP guard doesn't cover WebSockets).
  • Clean "Viewer mode" page (viewer-blocked) instead of raw 403 text; styled sticky banner; account page shows read-only UI instead of an upload button that silently 403s. canMutate hides write buttons.

Scalability (>50 artists):

  • Hub home shows max 24 (most active first) + "All N artists ->".
  • New searchable, paginated /artiesten directory (hub only).
  • 'user' + 'artiesten' reserved as slugs.

Rebrand PrutFolio v1 -> Klonkt Hub Beta (footer, PWA manifest, account/
admin texts, default page title, startup, README; internal package +
PWA id 'prutfolio' remain for stability).

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

Files:
3 added
24 edited

Legend:

Unmodified
Added
Removed
  • README.md

    rb94c08e r8afbdd6  
    1 # PrutFolio v1
     1# Klonkt Hub Beta
    22
    33Persoonlijk multi-site platform met editorial-feel content + sociale community.
    44Forked van **PrutCMS v9** (PHP, file-based) naar **Node + SQLite + htmx**.
    55
    6 > **Naam-uitleg.** "PrutCMS v9" is en blijft het file-based PHP-product. PrutFolio
    7 > v1 is een nieuw product met andere DNA: het is geen *publishing tool* maar een
    8 > *persoonlijk canvas* — content, profiel, sociale interactie en realtime in één.
     6> **Naam-uitleg.** "PrutCMS v9" is en blijft het file-based PHP-product. Klonkt Hub
     7> (intern pakket `prutfolio`) is een nieuw product met andere DNA: het is geen
     8> *publishing tool* maar een *persoonlijk canvas* — content, profiel, sociale
     9> interactie en realtime in één.
    910
    1011## Filosofie
  • package.json

    rb94c08e r8afbdd6  
    22  "name": "prutfolio",
    33  "version": "1.0.0-alpha.1",
    4   "description": "PrutFolio v1 — persoonlijk multi-site platform met community, realtime, en (later) E2EE DMs. Forked van PrutCMS v9 (PHP) naar Node + SQLite + htmx.",
     4  "description": "Klonkt Hub Beta — persoonlijk multi-site platform met community, realtime, en (later) E2EE DMs. Forked van PrutCMS v9 (PHP) naar Node + SQLite + htmx.",
    55  "type": "module",
    66  "main": "src/server.js",
  • src/middleware/auth.js

    rb94c08e r8afbdd6  
    3737}
    3838
     39// Een 'kijker' mag ALLES bekijken (incl. Beheer) maar NIETS wijzigen. De
     40// schrijf-blokkade zit in de globale guard in server.js; deze helper bepaalt
     41// alleen "is dit een alleen-lezen account?". `readonly` is de legacy-vlag die
     42// we nog meenemen zodat niet-gemigreerde demo-accounts geblokkeerd blijven.
     43export function isViewer(user) {
     44  return !!user && (user.role === 'kijker' || !!user.readonly);
     45}
     46
    3947export function requireGod(req, res, next) {
    4048  if (!req.session?.user) return loginRedirect(req, res);
    41   if (req.session.user.role !== 'god') {
     49  const role = req.session.user.role;
     50  // god beheert; een kijker mág het Beheer-paneel zien (alleen-lezen) — de
     51  // globale guard 403't elke write, dus dit geeft enkel kijk-toegang.
     52  if (role !== 'god' && role !== 'kijker') {
    4253    return res.status(403).send('God role required');
    4354  }
     
    5263  if (!req.session?.user) return loginRedirect(req, res);
    5364  const u = req.session.user;
    54   if (u.role === 'god') return next();
     65  if (u.role === 'god' || u.role === 'kijker') return next(); // kijker = alleen-lezen kijk-toegang
    5566  const site = res.locals.site;
    5667  if (site && site.owner_id === u.id) return next();
     
    6273  if (!req.session?.user) return loginRedirect(req, res);
    6374  const u = req.session.user;
    64   if (u.role === 'god') return next();
     75  if (u.role === 'god' || u.role === 'kijker') return next(); // kijker = alleen-lezen kijk-toegang
    6576  const site = db.prepare('SELECT owner_id FROM sites WHERE slug = ?').get(req.params.slug);
    6677  if (site && site.owner_id === u.id) return next();
  • src/middleware/render.js

    rb94c08e r8afbdd6  
    1515import db from '../config/database.js';
    1616import PermissionsService from '../services/PermissionsService.js';
     17import { isViewer } from './auth.js';
    1718import { getSetting } from '../services/SettingsService.js';
    1819import { PLATFORMS as PLATFORMS_CATALOG } from '../services/PlatformIcons.js';
     
    5152    : null;
    5253
     54  // Kijker-modus: alles bekijken mag, niets wijzigen. Views gebruiken canMutate
     55  // om schrijf-knoppen (posten, opslaan, verwijderen) te verbergen/uit te zetten.
     56  const _isViewer = isViewer(_u);
     57
    5358  // Common locals
    5459  const locals = {
    5560    user: _u,
    5661    userOwnsSite,
     62    isViewer: _isViewer,
     63    canMutate: !_isViewer,
    5764    siteOwnerAvatar,
    5865    site: _site,
     
    6572    formatDate,
    6673    formatDateTime,
    67     pageTitle: data.pageTitle || (data.site && data.site.title) || 'PrutCMS',
     74    pageTitle: data.pageTitle || (data.site && data.site.title) || 'Klonkt Hub Beta',
    6875    bodyClass: data.bodyClass || 'on-home',
    6976    socialDescr: data.socialDescr || '',
  • src/routes/admin-sites.js

    rb94c08e r8afbdd6  
    9898  'auth', 'admin', 'login', 'register', 'logout', 'archive', 'search',
    9999  'account', 'sites', 'comments', 'posts', 'media', 'audio', 'prutter',
    100   'forum', 'tag', 'users', 'feed.xml', 'atom.xml', 'sitemap.xml',
     100  'forum', 'tag', 'user', 'users', 'artiesten', 'feed.xml', 'atom.xml', 'sitemap.xml',
    101101  'manifest.webmanifest', 'sw.js', 'favicon.ico', 'favicon.svg', 'assets',
    102102]);
  • src/routes/admin-users.js

    rb94c08e r8afbdd6  
    2020const router = express.Router();
    2121
    22 const VALID_ROLES = new Set(['member', 'admin', 'god']);
     22// 'kijker' = alleen-lezen demonstratie/audit-account: mag alles bekijken (incl.
     23// Beheer), maar de globale guard blokkeert elke wijziging. Vervangt de oude
     24// losse 'kijk-modus'-vlag (readonly), die nu door deze rol wordt afgedekt.
     25const VALID_ROLES = new Set(['kijker', 'member', 'admin', 'god']);
    2326
    2427function godCount() {
     
    6669  }
    6770
    68   db.prepare('UPDATE users SET role = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?')
     71  // readonly=0: de alleen-lezen-status zit nu volledig in de 'kijker'-rol, dus
     72  // bij elke rolwijziging ruimen we de legacy-vlag op (geen dubbele bron).
     73  db.prepare('UPDATE users SET role = ?, readonly = 0, updated_at = CURRENT_TIMESTAMP WHERE id = ?')
    6974    .run(newRole, userId);
    7075  res.redirect('/admin/users?success=' + encodeURIComponent('Role updated'));
    71 });
    72 
    73 // ==================== READ-ONLY (kijk-account) ====================
    74 router.post('/:id/readonly', requireGod, (req, res) => {
    75   const target = db.prepare('SELECT id FROM users WHERE id = ?').get(req.params.id);
    76   if (!target) return res.redirect('/admin/users?error=User+not+found');
    77   // Niet op jezelf — voorkomt dat een god zichzelf alleen-lezen maakt en vastloopt.
    78   if (target.id === req.session.user.id) {
    79     return res.redirect('/admin/users?error=' + encodeURIComponent('Kan jezelf niet op alleen-lezen zetten'));
    80   }
    81   const ro = req.body.readonly === '1' ? 1 : 0;
    82   db.prepare('UPDATE users SET readonly = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?')
    83     .run(ro, req.params.id);
    84   res.redirect('/admin/users?success=' + encodeURIComponent(ro ? 'Kijk-modus aan' : 'Kijk-modus uit'));
    8576});
    8677
  • src/routes/admin.js

    rb94c08e r8afbdd6  
    1616  const user = req.session.user;
    1717
    18   // Niet-god: een artiest die een eigen site bezit krijgt een "Mijn PrutFolio"-
    19   // dashboard, gescopet op z'n eigen site. Bezit 'ie geen site -> geen beheer.
    20   if (user.role !== 'god') {
     18  // Een kijker mag het volledige (god-)Beheer alleen-lezen inzien — net als god
     19  // dus, alleen schrijven is globaal geblokkeerd. Een gewone artiest die een
     20  // eigen site bezit krijgt een "Mijn Klonkt Hub"-dashboard, gescopet op z'n
     21  // eigen site. Bezit 'ie geen site -> geen beheer.
     22  if (user.role !== 'god' && user.role !== 'kijker') {
    2123    const mySite = db.prepare(
    2224      'SELECT * FROM sites WHERE owner_id = ? ORDER BY created_at ASC LIMIT 1'
     
    2931    };
    3032    return renderPage(req, res, 'pages/my-site', {
    31       pageTitle: 'Mijn PrutFolio',
     33      pageTitle: 'Mijn Klonkt Hub',
    3234      bodyClass: 'on-admin',
    3335      mySite,
  • src/routes/hub.js

    rb94c08e r8afbdd6  
    3535  `).all();
    3636
    37   // ALLE PrutFolio's (elke user, incl. de admin z'n eigen site) voor de roster.
     37  // Uitgelichte PrutFolio's voor de home-roster: meest-actief eerst (aantal
     38  // gepubliceerde posts), dan nieuwste. Beperkt tot HOME_ROSTER_LIMIT zodat de
     39  // home schaalt — de volledige, doorzoekbare lijst staat op /artiesten.
     40  const HOME_ROSTER_LIMIT = 24;
    3841  const artists = db.prepare(`
    3942    SELECT s.slug, s.title, s.tagline, s.profile_photo, s.accent,
     
    4245    FROM sites s
    4346    LEFT JOIN users u ON u.id = s.owner_id
    44     ORDER BY s.created_at ASC
    45   `).all();
     47    ORDER BY post_count DESC, s.created_at DESC
     48    LIMIT ?
     49  `).all(HOME_ROSTER_LIMIT);
     50  const totalArtists = db.prepare('SELECT COUNT(*) AS c FROM sites').get().c;
    4651
    4752  // De hub-pagina is GENERIEK (van geen enkele user) — branding komt uit globale
     
    6065    hub,
    6166    artists,
     67    totalArtists,
     68    rosterLimit: HOME_ROSTER_LIMIT,
    6269    posts,
    6370  });
  • src/routes/posts.js

    rb94c08e r8afbdd6  
    6262  'archive', 'search', 'account', 'sites', 'comments',
    6363  'posts', 'media', 'audio', 'prutter', 'forum',
    64   'tag', 'type', 'users', 'feed.xml', 'atom.xml', 'sitemap.xml',
     64  'tag', 'type', 'user', 'users', 'artiesten', 'feed.xml', 'atom.xml', 'sitemap.xml',
    6565  'manifest.webmanifest', 'sw.js', 'favicon.ico', 'favicon.svg', 'assets',
    6666]);
  • src/routes/prutter.js

    rb94c08e r8afbdd6  
    1818import db from '../config/database.js';
    1919import { renderPage } from '../middleware/render.js';
    20 import { requireAuth } from '../middleware/auth.js';
     20import { requireAuth, isViewer } from '../middleware/auth.js';
    2121import PermissionsService from '../services/PermissionsService.js';
    2222
     
    5555// ==================== START / RESUME CONVERSATION ====================
    5656router.get('/new', requirePrutter, (req, res) => {
     57  // Een gesprek starten is een schrijf-actie (INSERT) — een kijker mag dat niet.
     58  // De globale guard pakt dit niet omdat het een GET is, dus expliciet blokkeren.
     59  if (isViewer(req.session.user)) {
     60    res.status(403);
     61    return renderPage(req, res, 'pages/viewer-blocked', { pageTitle: 'Kijker-modus', bodyClass: 'on-special' });
     62  }
    5763  const targetUsername = (req.query.to || '').toString().trim();
    5864  if (!targetUsername) {
     
    9096  const messages = prutter.getMessages(conv.id, 200, 0).reverse();
    9197
    92   // Mark inbound messages as read
    93   prutter.markAsRead(conv.id, me);
     98  // Mark inbound messages as read — sla over voor kijkers (markAsRead is een
     99  // UPDATE; een GET valt buiten de globale guard, dus hier expliciet skippen).
     100  if (!isViewer(req.session.user)) prutter.markAsRead(conv.id, me);
    94101
    95102  renderPage(req, res, 'pages/prutter-conversation', {
  • src/server.js

    rb94c08e r8afbdd6  
    11/**
    2  * PrutFolio v1 — server bootstrap
     2 * Klonkt Hub Beta — server bootstrap
    33 *
    44 * Persoonlijk multi-site platform forked van PrutCMS v9 (PHP, file-based).
     
    2121
    2222import { resolveSite, loadAudioTracks, loadTheme } from './middleware/site.js';
     23import { isViewer } from './middleware/auth.js';
     24import { renderPage } from './middleware/render.js';
    2325import authRoutes from './routes/auth.js';
    2426import accountRoutes from './routes/account.js';
     
    3941import feedRoutes from './routes/feed.js';
    4042import hubRoutes from './routes/hub.js';
     43import artistsRoutes from './routes/artists.js';
    4144import postsRoutes from './routes/posts.js';
    4245
     
    164167});
    165168
    166 // Read-only/kijk-accounts: alles bekijken mag, niets wijzigen. Blokkeert elke
    167 // state-wijzigende methode (de login-POST zelf zet de sessie pas, dus die valt
    168 // hier nog niet onder).
     169// Kijker-accounts: alles bekijken mag (incl. Beheer), niets wijzigen. Dit is de
     170// ENIGE schrijf-blokkade — fail-closed, vóór alle route-handlers. Elke state-
     171// wijzigende methode wordt geweigerd (de login-POST zet de sessie pas ná deze
     172// guard, dus die valt er niet onder). I.p.v. rauwe 403-tekst tonen we een nette
     173// pagina (of, bij HTMX, een ingeswapte melding).
    169174app.use((req, res, next) => {
    170   if (req.session?.user?.readonly && req.method !== 'GET' && req.method !== 'HEAD' && req.method !== 'OPTIONS') {
    171     return res.status(403).send('Demo-account: alleen-lezen — wijzigen is uitgeschakeld.');
     175  const mutating = req.method !== 'GET' && req.method !== 'HEAD' && req.method !== 'OPTIONS';
     176  if (mutating && isViewer(req.session?.user)) {
     177    if (req.headers['hx-request'] === 'true') {
     178      // htmx swapt niet op 4xx; stuur 200 + retarget zodat de melding in #pcms-main verschijnt.
     179      res.setHeader('HX-Retarget', '#pcms-main');
     180      res.setHeader('HX-Reswap', 'innerHTML');
     181      res.status(200);
     182    } else {
     183      res.status(403);
     184    }
     185    return renderPage(req, res, 'pages/viewer-blocked', {
     186      pageTitle: 'Kijker-modus',
     187      bodyClass: 'on-special',
     188    });
    172189  }
    173190  next();
     
    192209// Feed/sitemap routes are mounted at root because they're at well-known paths
    193210app.use('/', feedRoutes);
     211app.use('/artiesten', artistsRoutes); // doorzoekbare artiesten-directory (alleen hub; solo: next())
    194212app.use('/', hubRoutes); // hub-overview op '/' (solo: next() -> postsRoutes)
    195213app.use('/', postsRoutes);
     
    215233  res.json({
    216234    id: idBase,
    217     name: site?.title || 'PrutFolio',
    218     short_name: (site?.title || 'PrutFolio').slice(0, 12),
     235    name: site?.title || 'Klonkt Hub Beta',
     236    short_name: (site?.title || 'Klonkt').slice(0, 12),
    219237    description: site?.description || site?.tagline || '',
    220238    scope,
     
    324342      return;
    325343    }
     344    // Kijker-accounts zijn alleen-lezen: weiger de WS-upgrade. De HTTP-guard
     345    // dekt geen WS, dus dit is de plek om schrijven via een (toekomstige)
     346    // message-handler te voorkomen.
     347    if (isViewer(req.session.user)) {
     348      socket.write('HTTP/1.1 403 Forbidden\r\n\r\n');
     349      socket.destroy();
     350      return;
     351    }
    326352    wss.handleUpgrade(req, socket, head, (ws) => {
    327353      ws.userId = req.session.user.id;
     
    350376server.listen(PORT, () => {
    351377  console.log('');
    352   console.log('🪶 PrutFolio v1 — alpha');
     378  console.log('🪶 Klonkt Hub Beta');
    353379  console.log(`   http://localhost:${PORT}`);
    354380  console.log('');
  • src/views/pages/account.ejs

    rb94c08e r8afbdd6  
    1212  <% if (error)   { %><div class="ax-flash ax-flash-err"><%= error %></div><% } %>
    1313
     14  <% if (isViewer) { %>
     15    <div class="ax-viewer-note" role="note">
     16      <span class="ax-viewer-ico" aria-hidden="true">👁️</span>
     17      <div>
     18        <strong>Kijker-modus</strong>
     19        <span>Dit is een demo-account. Je kunt alles bekijken, maar niets wijzigen — ook geen foto of bio.</span>
     20      </div>
     21    </div>
     22  <% } %>
     23
    1424  <%# ── PROFILE ────────────────────────────────────────────── %>
    1525  <section class="ax-card">
     
    1727
    1828    <div class="ax-profile-id">
    19       <form action="/account/avatar" method="post" enctype="multipart/form-data" class="ax-avatar-form">
    20         <label class="ax-avatar-wrap" title="Klik om je foto te wijzigen">
    21           <span class="ax-profile-avatar">
    22             <% if (account.avatar_url) { %>
    23               <img src="<%= account.avatar_url %>" alt="">
    24             <% } else { %>
    25               <span><%= account.username.charAt(0).toUpperCase() %></span>
    26             <% } %>
    27           </span>
    28           <span class="ax-avatar-edit" aria-hidden="true">
    29             <svg viewBox="0 0 24 24" width="15" height="15" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M23 19a2 2 0 0 1-2 2H3a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h4l2-3h6l2 3h4a2 2 0 0 1 2 2z"/><circle cx="12" cy="13" r="4"/></svg>
    30           </span>
    31           <input type="file" name="avatar" accept="image/jpeg,image/png,image/webp,image/gif" onchange="this.form.submit()" hidden>
    32         </label>
    33       </form>
     29      <% if (canMutate) { %>
     30        <form action="/account/avatar" method="post" enctype="multipart/form-data" class="ax-avatar-form">
     31          <label class="ax-avatar-wrap" title="Klik om je foto te wijzigen">
     32            <span class="ax-profile-avatar">
     33              <% if (account.avatar_url) { %>
     34                <img src="<%= account.avatar_url %>" alt="">
     35              <% } else { %>
     36                <span><%= account.username.charAt(0).toUpperCase() %></span>
     37              <% } %>
     38            </span>
     39            <span class="ax-avatar-edit" aria-hidden="true">
     40              <svg viewBox="0 0 24 24" width="15" height="15" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M23 19a2 2 0 0 1-2 2H3a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h4l2-3h6l2 3h4a2 2 0 0 1 2 2z"/><circle cx="12" cy="13" r="4"/></svg>
     41            </span>
     42            <input type="file" name="avatar" accept="image/jpeg,image/png,image/webp,image/gif" onchange="this.form.submit()" hidden>
     43          </label>
     44        </form>
     45      <% } else { %>
     46        <span class="ax-profile-avatar ax-profile-avatar-static">
     47          <% if (account.avatar_url) { %>
     48            <img src="<%= account.avatar_url %>" alt="">
     49          <% } else { %>
     50            <span><%= account.username.charAt(0).toUpperCase() %></span>
     51          <% } %>
     52        </span>
     53      <% } %>
    3454      <div class="ax-profile-meta">
    3555        <div class="ax-profile-name"><%= account.username %></div>
     
    3858          <div class="ax-profile-joined">Lid sinds <%= formatDate(account.created_at) %></div>
    3959        <% } %>
    40         <% if (account.avatar_url) { %>
     60        <% if (account.avatar_url && canMutate) { %>
    4161          <form action="/account/avatar/remove" method="post" class="ax-avatar-remove">
    4262            <button type="submit" class="ax-linkbtn">Foto verwijderen</button>
     
    4666    </div>
    4767
    48     <form action="/account/profile" method="post" class="ax-form ax-form-bio">
    49       <label class="ax-field">
    50         <span>Bio</span>
    51         <textarea name="bio" rows="3" maxlength="500" placeholder="Een korte regel over jezelf"><%= account.bio || '' %></textarea>
    52       </label>
    53       <div class="ax-form-actions">
    54         <button type="submit" class="ax-btn ax-btn-primary">Bio opslaan</button>
    55       </div>
    56     </form>
     68    <% if (canMutate) { %>
     69      <form action="/account/profile" method="post" class="ax-form ax-form-bio">
     70        <label class="ax-field">
     71          <span>Bio</span>
     72          <textarea name="bio" rows="3" maxlength="500" placeholder="Een korte regel over jezelf"><%= account.bio || '' %></textarea>
     73        </label>
     74        <div class="ax-form-actions">
     75          <button type="submit" class="ax-btn ax-btn-primary">Bio opslaan</button>
     76        </div>
     77      </form>
     78    <% } else { %>
     79      <div class="ax-form ax-form-bio">
     80        <label class="ax-field">
     81          <span>Bio</span>
     82          <textarea rows="3" disabled placeholder="Geen bio"><%= account.bio || '' %></textarea>
     83        </label>
     84      </div>
     85    <% } %>
    5786  </section>
    5887
    5988  <%# ── WACHTWOORD ────────────────────────────────────────── %>
     89  <%# In kijker-modus geen wachtwoord-sectie (niets te wijzigen). %>
     90  <% if (canMutate) { %>
    6091  <% if (hasPassword) { %>
    6192  <section class="ax-card">
     
    87118  </section>
    88119  <% } %>
     120  <% } %>
    89121
    90122</div>
     
    107139.ax-flash-ok  { background: rgba(40,160,90,.15); color: #2a9d5e; border: 1px solid rgba(40,160,90,.3); }
    108140.ax-flash-err { background: rgba(200,60,60,.15); color: #c33; border: 1px solid rgba(200,60,60,.3); }
     141
     142/* ─── Kijker-modus notice ──────────────────────────────────────── */
     143.ax-viewer-note {
     144  display: flex; align-items: flex-start; gap: 0.75rem;
     145  padding: 0.85rem 1rem; margin-bottom: 1rem;
     146  border: 1px solid color-mix(in srgb, var(--accent) 35%, var(--rule));
     147  border-left: 3px solid var(--accent);
     148  border-radius: 10px;
     149  background: color-mix(in srgb, var(--accent) 8%, var(--paper));
     150}
     151.ax-viewer-ico { font-size: 1.1rem; line-height: 1.4; }
     152.ax-viewer-note strong { display: block; font-size: 0.92rem; }
     153.ax-viewer-note span { color: var(--ink-muted, var(--ink-soft)); font-size: 0.85rem; }
     154.ax-profile-avatar-static { flex-shrink: 0; }
     155.ax-form-bio textarea:disabled { opacity: 0.7; cursor: default; }
    109156
    110157/* ─── Cards ────────────────────────────────────────────────────── */
  • src/views/pages/admin-settings.ejs

    rb94c08e r8afbdd6  
    2424        <span>
    2525          <strong>Hub</strong> — bedrijfssite met <code>/user/</code>.
    26           <small>Jij (admin) wijst gebruikers een eigen PrutFolio toe. Gesloten cirkel: geen open registratie.</small>
     26          <small>Jij (admin) wijst gebruikers een eigen Klonkt Hub toe. Gesloten cirkel: geen open registratie.</small>
    2727        </span>
    2828      </label>
     
    3636    <p class="set-help">
    3737      De openbare hub-pagina (<code>/</code>) is generiek — van geen enkele gebruiker.
    38       Hier stel je de naam en intro in. Jouw eigen PrutFolio blijft los bestaan onder <code>/user/</code>.
     38      Hier stel je de naam en intro in. Jouw eigen Klonkt Hub blijft los bestaan onder <code>/user/</code>.
    3939    </p>
    4040    <form method="post" action="/admin/settings" class="set-form">
  • src/views/pages/admin-users.ejs

    rb94c08e r8afbdd6  
    55    <div>
    66      <h1>Users</h1>
    7       <p class="ax-tagline">Beheer gebruikers, rollen, en verwijderingen.</p>
     7      <p class="ax-tagline">Beheer gebruikers, rollen, en verwijderingen. Rol <strong>kijker</strong> = alles bekijken (incl. Beheer), niets wijzigen — handig voor demo's.</p>
    88    </div>
    99  </header>
     
    5858              <label class="ax-role">
    5959                <span>Rol</span>
    60                 <select name="role" onchange="this.form.submit()" <% if (u.id === user.id) { %>disabled<% } %>>
     60                <select name="role" onchange="this.form.submit()" <% if (u.id === user.id || !canMutate) { %>disabled<% } %>>
     61                  <option value="kijker" <%= u.role === 'kijker' ? 'selected' : '' %>>kijker</option>
    6162                  <option value="member" <%= u.role === 'member' ? 'selected' : '' %>>member</option>
    6263                  <option value="admin"  <%= u.role === 'admin'  ? 'selected' : '' %>>admin</option>
     
    6667            </form>
    6768
    68             <form method="post" action="/admin/users/<%= u.id %>/readonly" class="ax-role-form">
    69               <label class="ax-role ax-ro" title="Alleen-lezen: kan alles bekijken, niets wijzigen">
    70                 <span>Kijk-modus</span>
    71                 <input type="checkbox" name="readonly" value="1" <%= u.readonly ? 'checked' : '' %>
    72                        onchange="this.form.submit()" <% if (u.id === user.id) { %>disabled<% } %>>
    73               </label>
    74             </form>
    75 
    76             <% if (u.id !== user.id) { %>
     69            <% if (u.id !== user.id && canMutate) { %>
    7770              <% var _warn = (u.post_count + u.site_count > 0)
    7871                   ? ('Dit verwijdert ook hun site + ' + u.post_count + ' post(s). ')
     
    8376              </form>
    8477            <% } else { %>
    85               <%# Lege plek zodat de kolommen (rol/kijk-modus) op elke rij uitlijnen. %>
     78              <%# Lege plek zodat de rol-kolom op elke rij uitlijnt. %>
    8679              <span class="ax-icon-btn-spacer" aria-hidden="true"></span>
    8780            <% } %>
  • src/views/pages/admin.ejs

    rb94c08e r8afbdd6  
    22  <h1>Beheer</h1>
    33  <% if (tenancy === 'hub') { %>
    4     <p class="admin-tagline">Hub-modus — bedrijfssite met gebruikers, elk hun eigen PrutFolio.</p>
     4    <p class="admin-tagline">Hub-modus — bedrijfssite met gebruikers, elk hun eigen Klonkt Hub.</p>
    55  <% } else { %>
    66    <p class="admin-tagline">Solo-modus — jouw site.</p>
  • src/views/pages/home.ejs

    rb94c08e r8afbdd6  
    2525      <h1>Hier is het nog stil.</h1>
    2626      <p>Nog geen posts. Spannend.</p>
    27       <% if (user && permissions.canCreatePost(user, site)) { %>
     27      <% if (user && canMutate && permissions.canCreatePost(user, site)) { %>
    2828        <p style="margin-top:2rem">
    2929          <a class="btn" href="<%= _base %>/posts/new"
  • src/views/pages/hub-home.ejs

    rb94c08e r8afbdd6  
    3333    <%# ── ARTIESTEN-ROSTER ─────────────────────────────────────────── %>
    3434    <% if (artists && artists.length) { %>
     35    <% var _total = (typeof totalArtists !== 'undefined') ? totalArtists : artists.length; %>
     36    <% var _hasMore = _total > artists.length; %>
    3537    <section class="hub-sec">
    36       <h2 class="hub-sec-title">Artiesten</h2>
     38      <h2 class="hub-sec-title">
     39        Artiesten
     40        <% if (_hasMore) { %><span class="hub-sec-count"><%= _total %></span><% } %>
     41      </h2>
    3742      <div class="roster">
    3843        <% artists.forEach(function(a){ %>
     
    4853        <% }); %>
    4954      </div>
     55      <% if (_hasMore) { %>
     56        <div class="hub-more">
     57          <a class="hub-more-link" href="/artiesten">Alle <%= _total %> artiesten <span aria-hidden="true">→</span></a>
     58        </div>
     59      <% } %>
    5060    </section>
    5161    <% } %>
     
    131141}
    132142.hub-sec-title::after { content: ''; flex: 1; height: 1px; background: var(--rule); }
     143.hub-sec-count {
     144  font-family: var(--font-ui, system-ui); font-size: 0.8rem; font-weight: 700;
     145  color: var(--accent); background: color-mix(in srgb, var(--accent) 12%, transparent);
     146  padding: 0.1rem 0.55rem; border-radius: 99px; order: 1;
     147}
     148.hub-more { margin-top: 1.25rem; text-align: center; }
     149.hub-more-link {
     150  display: inline-flex; align-items: center; gap: 0.4rem;
     151  padding: 0.6rem 1.3rem; border: 1px solid var(--rule); border-radius: 99px;
     152  background: var(--paper-2); color: var(--ink); text-decoration: none;
     153  font-weight: 600; font-size: 0.92rem; transition: border-color 140ms, background 140ms;
     154}
     155.hub-more-link:hover { border-color: var(--accent); background: color-mix(in srgb, var(--accent) 7%, var(--paper-2)); }
    133156
    134157/* ── Roster (artiesten) ───────────────────────────────────────────── */
  • src/views/pages/my-site.ejs

    rb94c08e r8afbdd6  
    11<div class="container admin-page">
    22  <p><a href="/" class="btn">&larr; Overzicht</a></p>
    3   <h1>Mijn PrutFolio</h1>
     3  <h1>Mijn Klonkt Hub</h1>
    44  <p class="admin-tagline"><%= mySite.title %></p>
    55
  • src/views/pages/post.ejs

    rb94c08e r8afbdd6  
    103103              <div class="comment-content"><%= c.content %></div>
    104104              <div class="comment-actions">
    105                 <% if (user) { %>
     105                <% if (user && canMutate) { %>
    106106                  <button type="button" class="comment-reply-btn" data-reply-to="<%= c.id %>">Reply</button>
    107107                <% } %>
     
    114114
    115115              <!-- Inline reply form (hidden by default) -->
    116               <% if (user) { %>
     116              <% if (user && canMutate) { %>
    117117                <form method="post" action="<%= _base %>/comments" class="comment-reply-form" hidden data-reply-form-for="<%= c.id %>">
    118118                  <input type="hidden" name="post_slug" value="<%= post.slug %>">
     
    163163
    164164    <!-- Top-level comment form (only for logged-in users for now) -->
    165     <% if (user) { %>
     165    <% if (user && canMutate) { %>
    166166      <form method="post" action="<%= _base %>/comments" class="comment-form">
    167167        <input type="hidden" name="post_slug" value="<%= post.slug %>">
     
    172172        <button type="submit" class="btn btn-primary">Post comment</button>
    173173      </form>
    174     <% } else { %>
     174    <% } else if (!user) { %>
    175175      <p class="comments-login-cta">
    176176        <a href="/auth/login?next=<%= encodeURIComponent(_base + '/' + post.slug + '#comments') %>" class="btn">Log in to comment</a>
    177177      </p>
     178    <% } else { %>
     179      <p class="comments-login-cta" style="color:var(--ink-muted)">👁️ Kijker-modus — reageren is uitgeschakeld.</p>
    178180    <% } %>
    179181  </section>
  • src/views/partials/bottom-tab.ejs

    rb94c08e r8afbdd6  
    1111const _siteUrlBase = (typeof siteUrlBase !== 'undefined' && siteUrlBase) ? siteUrlBase : '';
    1212const _path        = (typeof currentPath !== 'undefined' && currentPath) ? currentPath : '/';
    13 const _canPost     = !!(user && permissions && permissions.canCreatePost && permissions.canCreatePost(user, site));
     13const _canPost     = !!(user && (typeof canMutate === 'undefined' || canMutate)
     14                        && permissions && permissions.canCreatePost && permissions.canCreatePost(user, site));
    1415const _hasPrutter  = !!(user && site && site.enable_prutter && (typeof tenancy !== 'undefined' && tenancy === 'hub'));
    1516const _ownProfile  = user ? ('/users/' + user.username) : null;
  • src/views/partials/footer.ejs

    rb94c08e r8afbdd6  
    22  <div class="container site-footer-inner">
    33    <div>
    4       <span>© <%= new Date().getFullYear() %> <%= (site && site.title) || 'PrutFolio' %></span>
     4      <span>© <%= new Date().getFullYear() %> <%= (site && site.title) || 'Klonkt' %></span>
    55    </div>
    66    <div class="footer-meta">
    7       <span>PrutFolio v1</span>
     7      <span>Klonkt Hub Beta</span>
    88      <%# Mobile-only theme toggle. On desktop the topnav has its own toggle
    99          so we hide this one to avoid two visible toggles at once. The same
  • src/views/partials/profile-sheet.ejs

    rb94c08e r8afbdd6  
    1414
    1515const _isAdmin = (user.role === 'god' || user.role === 'admin');
    16 const _canPost = !!(typeof permissions !== 'undefined'
     16const _canPost = !!((typeof canMutate === 'undefined' || canMutate)
     17                    && typeof permissions !== 'undefined'
    1718                    && permissions
    1819                    && permissions.canCreatePost
  • src/views/partials/topnav.ejs

    rb94c08e r8afbdd6  
    1616const _hasSearch   = !site || site.show_search === undefined || site.show_search;
    1717const _hasPrutter  = user && site && site.enable_prutter && (typeof tenancy !== 'undefined' && tenancy === 'hub');
    18 const _canPost     = user && permissions && permissions.canCreatePost && permissions.canCreatePost(user, site);
     18const _canPost     = user && (typeof canMutate === 'undefined' || canMutate)
     19                     && permissions && permissions.canCreatePost && permissions.canCreatePost(user, site);
    1920%>
    2021
     
    3637           hx-push-url="<%= _siteUrlBase %>/" hx-indicator="#pcms-loading"><%= site.title %></a>
    3738      <% } else { %>
    38         <a class="site-title" href="/">PrutFolio</a>
     39        <a class="site-title" href="/">Klonkt</a>
    3940      <% } %>
    4041    </div>
  • src/views/shell.ejs

    rb94c08e r8afbdd6  
    239239<body class="<%= bodyClass || 'on-home' %> has-bottom-tab" data-feed-view="<%= _e(safeSite.feed_view_default || 'timeline') %>" data-grid-cols="3" data-site-base="<%= _e((typeof siteUrlBase !== 'undefined' && siteUrlBase) ? siteUrlBase : '') %>">
    240240
    241 <% if (user && user.readonly) { %>
    242   <div style="position:sticky;top:0;z-index:50;background:#e8b04b;color:#1a1a1a;text-align:center;font-size:0.85rem;font-weight:600;padding:0.4rem 1rem;">
    243     👁️ Demo — alleen-lezen: je kunt alles bekijken, maar niets wijzigen.
     241<% if (typeof isViewer !== 'undefined' && isViewer) { %>
     242  <div class="viewer-banner" role="status">
     243    <span class="viewer-banner-ico" aria-hidden="true">👁️</span>
     244    <span class="viewer-banner-text"><strong>Kijker-modus</strong> — je kunt alles bekijken, maar niets wijzigen.</span>
    244245  </div>
     246  <style>
     247    .viewer-banner {
     248      position: sticky; top: 0; z-index: 60;
     249      display: flex; align-items: center; justify-content: center; gap: 0.5rem;
     250      padding: 0.5rem 1rem;
     251      background: linear-gradient(90deg,
     252        color-mix(in srgb, var(--accent) 88%, #000) 0%,
     253        var(--accent) 100%);
     254      color: #fff;
     255      font-size: 0.85rem; line-height: 1.3;
     256      box-shadow: 0 1px 6px color-mix(in srgb, var(--accent) 45%, transparent);
     257    }
     258    .viewer-banner-ico { font-size: 1rem; }
     259    .viewer-banner-text strong { font-weight: 700; }
     260  </style>
    245261<% } %>
    246262
Note: See TracChangeset for help on using the changeset viewer.