Index: src/routes/admin-sites.js
===================================================================
--- src/routes/admin-sites.js	(revision 0c2228a9c4489c1c67d7c50a257243dee4569320)
+++ src/routes/admin-sites.js	(revision 8afbdd6572a947bce4726d5aa0d0aa0d6c7e5067)
@@ -98,5 +98,5 @@
   'auth', 'admin', 'login', 'register', 'logout', 'archive', 'search',
   'account', 'sites', 'comments', 'posts', 'media', 'audio', 'prutter',
-  'forum', 'tag', 'users', 'feed.xml', 'atom.xml', 'sitemap.xml',
+  'forum', 'tag', 'user', 'users', 'artiesten', 'feed.xml', 'atom.xml', 'sitemap.xml',
   'manifest.webmanifest', 'sw.js', 'favicon.ico', 'favicon.svg', 'assets',
 ]);
Index: src/routes/admin-users.js
===================================================================
--- src/routes/admin-users.js	(revision 0c2228a9c4489c1c67d7c50a257243dee4569320)
+++ src/routes/admin-users.js	(revision 8afbdd6572a947bce4726d5aa0d0aa0d6c7e5067)
@@ -20,5 +20,8 @@
 const router = express.Router();
 
-const VALID_ROLES = new Set(['member', 'admin', 'god']);
+// 'kijker' = alleen-lezen demonstratie/audit-account: mag alles bekijken (incl.
+// Beheer), maar de globale guard blokkeert elke wijziging. Vervangt de oude
+// losse 'kijk-modus'-vlag (readonly), die nu door deze rol wordt afgedekt.
+const VALID_ROLES = new Set(['kijker', 'member', 'admin', 'god']);
 
 function godCount() {
@@ -66,21 +69,9 @@
   }
 
-  db.prepare('UPDATE users SET role = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?')
+  // readonly=0: de alleen-lezen-status zit nu volledig in de 'kijker'-rol, dus
+  // bij elke rolwijziging ruimen we de legacy-vlag op (geen dubbele bron).
+  db.prepare('UPDATE users SET role = ?, readonly = 0, updated_at = CURRENT_TIMESTAMP WHERE id = ?')
     .run(newRole, userId);
   res.redirect('/admin/users?success=' + encodeURIComponent('Role updated'));
-});
-
-// ==================== READ-ONLY (kijk-account) ====================
-router.post('/:id/readonly', requireGod, (req, res) => {
-  const target = db.prepare('SELECT id FROM users WHERE id = ?').get(req.params.id);
-  if (!target) return res.redirect('/admin/users?error=User+not+found');
-  // Niet op jezelf — voorkomt dat een god zichzelf alleen-lezen maakt en vastloopt.
-  if (target.id === req.session.user.id) {
-    return res.redirect('/admin/users?error=' + encodeURIComponent('Kan jezelf niet op alleen-lezen zetten'));
-  }
-  const ro = req.body.readonly === '1' ? 1 : 0;
-  db.prepare('UPDATE users SET readonly = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?')
-    .run(ro, req.params.id);
-  res.redirect('/admin/users?success=' + encodeURIComponent(ro ? 'Kijk-modus aan' : 'Kijk-modus uit'));
 });
 
Index: src/routes/admin.js
===================================================================
--- src/routes/admin.js	(revision 0c2228a9c4489c1c67d7c50a257243dee4569320)
+++ src/routes/admin.js	(revision 8afbdd6572a947bce4726d5aa0d0aa0d6c7e5067)
@@ -16,7 +16,9 @@
   const user = req.session.user;
 
-  // Niet-god: een artiest die een eigen site bezit krijgt een "Mijn PrutFolio"-
-  // dashboard, gescopet op z'n eigen site. Bezit 'ie geen site -> geen beheer.
-  if (user.role !== 'god') {
+  // Een kijker mag het volledige (god-)Beheer alleen-lezen inzien — net als god
+  // dus, alleen schrijven is globaal geblokkeerd. Een gewone artiest die een
+  // eigen site bezit krijgt een "Mijn Klonkt Hub"-dashboard, gescopet op z'n
+  // eigen site. Bezit 'ie geen site -> geen beheer.
+  if (user.role !== 'god' && user.role !== 'kijker') {
     const mySite = db.prepare(
       'SELECT * FROM sites WHERE owner_id = ? ORDER BY created_at ASC LIMIT 1'
@@ -29,5 +31,5 @@
     };
     return renderPage(req, res, 'pages/my-site', {
-      pageTitle: 'Mijn PrutFolio',
+      pageTitle: 'Mijn Klonkt Hub',
       bodyClass: 'on-admin',
       mySite,
Index: src/routes/artists.js
===================================================================
--- src/routes/artists.js	(revision 8afbdd6572a947bce4726d5aa0d0aa0d6c7e5067)
+++ src/routes/artists.js	(revision 8afbdd6572a947bce4726d5aa0d0aa0d6c7e5067)
@@ -0,0 +1,65 @@
+/**
+ * Artiesten-directory — alleen in hub-modus.
+ *
+ * GET /artiesten?q=&page=  -> doorzoekbare, gepagineerde lijst van ALLE
+ * PrutFolio's. De hub-home toont maar een beperkte selectie; deze pagina
+ * schaalt naar honderden/duizenden artiesten via zoeken + paginering.
+ *
+ * In solo-modus bestaat er maar één site -> next() (valt door naar postsRoutes,
+ * die 'artiesten' als onbekende slug afhandelt).
+ */
+
+import express from 'express';
+import db from '../config/database.js';
+import { renderPage } from '../middleware/render.js';
+import { getTenancy } from '../services/SettingsService.js';
+
+const router = express.Router();
+
+const PER_PAGE = 24;
+
+router.get('/', (req, res, next) => {
+  if (getTenancy() !== 'hub') return next();
+
+  const q = (req.query.q || '').toString().trim().slice(0, 80);
+  let page = parseInt(req.query.page, 10);
+  if (!Number.isFinite(page) || page < 1) page = 1;
+
+  // Zoekterm tegen titel/slug/tagline (case-insensitive via LIKE; SQLite LIKE is
+  // standaard ongevoelig voor ASCII-hoofdletters). De ESCAPE '\' maakt %, _ en \
+  // in de zoekterm letterlijk (anders zouden ze als wildcards werken).
+  const where = q
+    ? "WHERE (s.title LIKE @like ESCAPE '\\' OR s.slug LIKE @like ESCAPE '\\' OR s.tagline LIKE @like ESCAPE '\\')"
+    : '';
+  const like = '%' + q.replace(/[\\%_]/g, (m) => '\\' + m) + '%';
+  const params = q ? { like } : {};
+
+  const total = db.prepare(`SELECT COUNT(*) AS c FROM sites s ${where}`)
+    .get(params).c;
+  const pages = Math.max(1, Math.ceil(total / PER_PAGE));
+  if (page > pages) page = pages;
+  const offset = (page - 1) * PER_PAGE;
+
+  const artists = db.prepare(`
+    SELECT s.slug, s.title, s.tagline, s.profile_photo, s.accent,
+           u.avatar_url AS owner_avatar,
+           (SELECT COUNT(*) FROM posts WHERE site_id = s.id AND status = 'published') AS post_count
+    FROM sites s
+    LEFT JOIN users u ON u.id = s.owner_id
+    ${where}
+    ORDER BY s.title COLLATE NOCASE ASC
+    LIMIT @limit OFFSET @offset
+  `).all({ ...params, limit: PER_PAGE, offset });
+
+  renderPage(req, res, 'pages/artists-directory', {
+    pageTitle: 'Artiesten',
+    bodyClass: 'on-hub',
+    q,
+    artists,
+    total,
+    page,
+    pages,
+  });
+});
+
+export default router;
Index: src/routes/hub.js
===================================================================
--- src/routes/hub.js	(revision 0c2228a9c4489c1c67d7c50a257243dee4569320)
+++ src/routes/hub.js	(revision 8afbdd6572a947bce4726d5aa0d0aa0d6c7e5067)
@@ -35,5 +35,8 @@
   `).all();
 
-  // ALLE PrutFolio's (elke user, incl. de admin z'n eigen site) voor de roster.
+  // Uitgelichte PrutFolio's voor de home-roster: meest-actief eerst (aantal
+  // gepubliceerde posts), dan nieuwste. Beperkt tot HOME_ROSTER_LIMIT zodat de
+  // home schaalt — de volledige, doorzoekbare lijst staat op /artiesten.
+  const HOME_ROSTER_LIMIT = 24;
   const artists = db.prepare(`
     SELECT s.slug, s.title, s.tagline, s.profile_photo, s.accent,
@@ -42,6 +45,8 @@
     FROM sites s
     LEFT JOIN users u ON u.id = s.owner_id
-    ORDER BY s.created_at ASC
-  `).all();
+    ORDER BY post_count DESC, s.created_at DESC
+    LIMIT ?
+  `).all(HOME_ROSTER_LIMIT);
+  const totalArtists = db.prepare('SELECT COUNT(*) AS c FROM sites').get().c;
 
   // De hub-pagina is GENERIEK (van geen enkele user) — branding komt uit globale
@@ -60,4 +65,6 @@
     hub,
     artists,
+    totalArtists,
+    rosterLimit: HOME_ROSTER_LIMIT,
     posts,
   });
Index: src/routes/posts.js
===================================================================
--- src/routes/posts.js	(revision 0c2228a9c4489c1c67d7c50a257243dee4569320)
+++ src/routes/posts.js	(revision 8afbdd6572a947bce4726d5aa0d0aa0d6c7e5067)
@@ -62,5 +62,5 @@
   'archive', 'search', 'account', 'sites', 'comments',
   'posts', 'media', 'audio', 'prutter', 'forum',
-  'tag', 'type', 'users', 'feed.xml', 'atom.xml', 'sitemap.xml',
+  'tag', 'type', 'user', 'users', 'artiesten', 'feed.xml', 'atom.xml', 'sitemap.xml',
   'manifest.webmanifest', 'sw.js', 'favicon.ico', 'favicon.svg', 'assets',
 ]);
Index: src/routes/prutter.js
===================================================================
--- src/routes/prutter.js	(revision 0c2228a9c4489c1c67d7c50a257243dee4569320)
+++ src/routes/prutter.js	(revision 8afbdd6572a947bce4726d5aa0d0aa0d6c7e5067)
@@ -18,5 +18,5 @@
 import db from '../config/database.js';
 import { renderPage } from '../middleware/render.js';
-import { requireAuth } from '../middleware/auth.js';
+import { requireAuth, isViewer } from '../middleware/auth.js';
 import PermissionsService from '../services/PermissionsService.js';
 
@@ -55,4 +55,10 @@
 // ==================== START / RESUME CONVERSATION ====================
 router.get('/new', requirePrutter, (req, res) => {
+  // Een gesprek starten is een schrijf-actie (INSERT) — een kijker mag dat niet.
+  // De globale guard pakt dit niet omdat het een GET is, dus expliciet blokkeren.
+  if (isViewer(req.session.user)) {
+    res.status(403);
+    return renderPage(req, res, 'pages/viewer-blocked', { pageTitle: 'Kijker-modus', bodyClass: 'on-special' });
+  }
   const targetUsername = (req.query.to || '').toString().trim();
   if (!targetUsername) {
@@ -90,6 +96,7 @@
   const messages = prutter.getMessages(conv.id, 200, 0).reverse();
 
-  // Mark inbound messages as read
-  prutter.markAsRead(conv.id, me);
+  // Mark inbound messages as read — sla over voor kijkers (markAsRead is een
+  // UPDATE; een GET valt buiten de globale guard, dus hier expliciet skippen).
+  if (!isViewer(req.session.user)) prutter.markAsRead(conv.id, me);
 
   renderPage(req, res, 'pages/prutter-conversation', {
