Changeset 7cf8144 in Klonkt


Ignore:
Timestamp:
08/18/2026 06:47:58 PM (3 weeks ago)
Author:
Bart <bart@…>
Branches:
main
Children:
9da8027
Parents:
2d2fd8c
git-author:
Bart <bart@…> (08/18/2026 05:28:31 AM)
git-committer:
Bart <bart@…> (08/18/2026 06:47:58 PM)
Message:

Lezen is een weergave van de feed, geen eigen plek

/read was een route met een eigen shell: eigen pagina, eigen chrome-gedrag, en
op een site zonder berichten een 404 -- een dode knop in de switcher. Nu is het
wat het altijd had moeten zijn: een derde waarde van body[data-feed-view], naast
timeline en grid. Dezelfde berichten, andere vorm.

De feed stuurt ze alledrie mee en CSS kiest, precies zoals timeline/grid dat al
deden. Het lijf loopt door PostAccessService, dus een gesloten poort levert ook
hier geen tekst op. "Meer laden" vult de leesstroom OOB mee aan, net als de
tegels.

DE LEGE STAAT zit in de :has(.feed-reader) op de verbergregel. Zonder berichten
rendert de leessectie niet, en dan blijft de tijdlijn staan -- want daar staat
"nog niets geschreven" met de knop om te beginnen. Zonder die voorwaarde was
Lezen op een verse site een wit scherm. Feeds zonder leessectie (cirkel,
archief) houden om dezelfde reden gewoon hun tijdlijn.

Wat weg kon, en dat is de winst:

  • de route, pages/read.ejs, en 'on-read' als page-class;
  • mod/read.js van 222 naar 51 regels. Het ophalen van buren, het corrigeren van de scrollpositie bij invoegen en het onderbreken van het snappen zijn niet opgelost maar OVERBODIG: de feed levert de berichten al. Wat blijft is de tik.
  • read.end en read.load_error: het script plaatst geen zinnen meer.

De balken schuiven niet meer weg. Dat was mooi op een eigen scherm, maar hier
zou het de switcher verbergen waarmee je net koos -- en dan kun je niet terug.

MOD_V naar 4, style.css naar v70.

Co-Authored-By: Claude Opus 5 <claude@…>

Location:
src
Files:
1 deleted
9 edited

Legend:

Unmodified
Added
Removed
  • src/assets/css/style.css

    r2d2fd8c r7cf8144  
    23532353    padding: .35rem 0;
    23542354}
    2355 .view-switch-btn,
    2356 .view-switch-link {
     2355.view-switch-btn {
    23572356    display: inline-flex;
    23582357    align-items: center;
     
    23692368    transition: all .15s;
    23702369}
    2371 .view-switch-btn:hover,
    2372 .view-switch-link:hover {
     2370.view-switch-btn:hover {
    23732371    color: var(--ink);
    23742372    border-color: var(--ink-muted);
     
    24002398body.on-home[data-feed-view="grid"]    .feed-grid,
    24012399body.on-cirkel[data-feed-view="grid"]  .feed-grid { display: block; }
     2400
     2401/* -------- LEESWEERGAVE (data-feed-view="reader") --------
     2402   Dezelfde berichten als de tijdlijn, maar heel: één per scherm, grenzen door
     2403   scroll-snapping. GEEN eigen route en geen eigen shell -- de balken blijven
     2404   staan, want je bent nog op de feed en moet terug kunnen schakelen.
     2405   Pagina's zonder .feed-reader (cirkel, archief, post) negeren de keuze en
     2406   houden hun tijdlijn: er is hieronder simpelweg geen regel die die daar
     2407   verbergt. Zo is Lezen nooit een leeg scherm. */
     2408.feed-reader { display: none; }
     2409body.on-home[data-feed-view="reader"] .feed-reader { display: block; }
     2410/* :has(.feed-reader) is hier de LEGE STAAT, niet een truc. Zonder berichten
     2411   rendert de leessectie niet, en dan mag de tijdlijn niet verborgen worden --
     2412   want dáár staat "nog niets geschreven" met de knop om te beginnen. Zonder deze
     2413   voorwaarde is Lezen op een verse site een wit scherm. Hetzelfde geldt voor
     2414   feeds die (nog) geen leessectie hebben. */
     2415body.on-home[data-feed-view="reader"]:has(.feed-reader) .feed-timeline,
     2416body.on-home[data-feed-view="reader"]:has(.feed-reader) .feed-grid,
     2417body.on-home[data-feed-view="reader"]:has(.feed-reader) .pagination,
     2418body.on-home[data-feed-view="reader"]:has(.feed-reader) .related-posts,
     2419body.on-home[data-feed-view="reader"]:has(.feed-reader) .article-footer {
     2420    display: none !important;
     2421}
     2422
     2423/* Snappen doet de SCROLLCONTAINER, en dat is het document -- niet .read-stream,
     2424   die heeft overflow: visible en scrollt zelf niet. Proximity en geen mandatory:
     2425   in een bericht dat langer is dan het scherm moet je rustig door kunnen lezen. */
     2426html:has(body.on-home[data-feed-view="reader"]) { scroll-snap-type: y proximity; }
     2427/* En de browser niet óók laten compenseren als Load more er berichten bij zet:
     2428   Chrome en Firefox schuiven bij invoegen zelf mee (scroll anchoring), en dat
     2429   vecht met het snappen. */
     2430html:has(body.on-home[data-feed-view="reader"]),
     2431.feed-reader .read-stream { overflow-anchor: none; }
     2432
     2433.feed-reader .read-post {
     2434    scroll-snap-align: start;
     2435    scroll-snap-stop: normal;
     2436    min-height: 100svh;
     2437    max-width: 46rem;
     2438    margin: 0 auto;
     2439    /* De onderrand loopt achter de vaste tabbalk door; deze padding houdt de
     2440       TEKST erboven. */
     2441    padding: 2rem 1.2rem 4rem;
     2442    box-sizing: border-box;
     2443}
     2444/* Een lijn tussen twee berichten: zonder grens is een stroom een lange brij. */
     2445.feed-reader .read-post + .read-post { border-top: 1px solid color-mix(in srgb, currentColor 12%, transparent); }
     2446
     2447.feed-reader .read-head { padding-bottom: .5rem; }
     2448.feed-reader .read-title { font-size: clamp(1.6rem, 4vw, 2.4rem); line-height: 1.15; margin: 0 0 .3rem; }
     2449/* De titel is een link, maar mag er niet als een link uitzien: dit is een
     2450   leesscherm, geen index. */
     2451.feed-reader .read-title a { color: inherit; text-decoration: none; }
     2452.feed-reader .read-title a:hover,
     2453.feed-reader .read-title a:focus-visible { text-decoration: underline; }
     2454.feed-reader .read-when { font-size: .85rem; opacity: .6; margin: 0; }
     2455
     2456.feed-reader .read-foot { margin-top: 2rem; }
     2457.feed-reader .read-open {
     2458    display: inline-block; font-size: .9rem; opacity: .75;
     2459    text-decoration: none; border-bottom: 1px solid currentColor; padding-bottom: 1px;
     2460}
     2461.feed-reader .read-open:hover,
     2462.feed-reader .read-open:focus-visible { opacity: 1; }
     2463
     2464@media (prefers-reduced-motion: reduce) {
     2465    html:has(body.on-home[data-feed-view="reader"]) { scroll-snap-type: none; }
     2466}
    24022467
    24032468/* -------- GRID VIEW TILES (Instagram-stijl) -------- */
  • src/assets/js/mod/read.js

    r2d2fd8c r7cf8144  
    11/**
    2  * De leesweergave: een stroom van hele berichten, één per beeld.
     2 * De leesweergave: tikken op een bericht opent dat bericht.
    33 *
    4  * NIET de truc van bartoverkamp.nl. Daar springt de pagina bij de rand naar de
    5  * andere kant en wordt het nieuwe artikel "vloeiend" in beeld gezet -- een naad
    6  * die verborgen wordt. Hier groeit de stroom in de richting waarin je leest:
    7  * bij de onderrand komt de oudere buur eronder te staan, bij de bovenrand de
    8  * nieuwere erboven. De browser snapt naar de berichtgrenzen (CSS, proximity).
    9  * Er is dan geen naad, dus ook niets te verbergen.
     4 * Dit was een heel scherm met een eigen route, dat zijn buren zelf ophaalde, de
     5 * scrollpositie corrigeerde bij invoegen en de balken wegschoof. Dat is allemaal
     6 * weg, en dat is winst: Lezen is nu een DERDE WEERGAVE van de feed
     7 * (body[data-feed-view="reader"]), naast Tijdlijn en Grid. De feed levert de
     8 * berichten al, "meer laden" vult al aan, en het snappen naar berichtgrenzen
     9 * doet CSS. Wat overblijft is dit ene gemak.
    1010 *
    11  * Elk artikel draagt zijn eigen buren in data-attributen. Wat er nog te halen
    12  * valt staat dus altijd op het stuk dat er al is, en deze module hoeft geen
    13  * lijst bij te houden die uit de pas kan lopen.
     11 * De titel en de voetlink in read-article.ejs zijn echte <a>'s en doen het werk
     12 * voor toetsenbord en schermlezer; dit hier is er voor een duim.
    1413 *
    15  * De namen hieronder zijn Engels, zoals overal in assets/js/mod. Het commentaar
    16  * blijft Nederlands, zoals overal in deze repo.
     14 * Vier uitzonderingen, want een tik die je niet bedoelde is erger dan geen tik:
     15 * iets dat zelf al een doel heeft (link, knop, veld) houdt zijn eigen werking,
     16 * een geselecteerde tekst is geen tik, een verschoven vinger is scrollen, en
     17 * cmd/ctrl-klik hoort de browser zelf af te handelen.
    1718 */
    1819
    19 // Hoeveel schermen vooruit we alvast halen. Eén is genoeg: je leest niet
    20 // sneller dan je scrolt, en meer betekent alleen meer verkeer.
    21 const ROOT_MARGIN = '1px 0px 100% 0px';
    22 
    23 // De teksten die dit script plaatst. Ze staan in data-i18n op de stroom, want
    24 // een zin die hier staat, staat in één taal -- en dat is nooit de taal van de
    25 // lezer. Zelfde afspraak als de topnav (mod/chrome.js).
    26 let strings = {};
    27 function str(key) { return strings[key] || ''; }
    28 
    29 const stream = () => document.getElementById('read-stream');
    30 const loaded = new Set();       // slugs die al in de stroom staan
    31 let busyBelow = false;
    32 let busyAbove = false;
    33 let observer = null;
    34 let lastY = 0;
    35 
    36 // Niet `chrome`: dat is in deze browser ook een global, en een module-scope die
    37 // window.chrome overschaduwt is een val voor de volgende lezer.
    38 function toggleChrome(hide) {
    39   document.body.classList.toggle('read-chrome-hidden', !!hide);
    40 }
    41 
    42 /** Het artikel dat nu het meest in beeld is; die bepaalt titel en URL. */
    43 function inView() {
    44   const posts = [...document.querySelectorAll('.read-post')];
    45   const middle = window.innerHeight / 2;
    46   return posts.find((p) => {
    47     const r = p.getBoundingClientRect();
    48     return r.top <= middle && r.bottom >= middle;
    49   }) || posts[0];
    50 }
    51 
    52 /** Titel en adres volgen wat je leest. replaceState: je bladert, je stapelt niet. */
    53 function syncAddress() {
    54   const p = inView();
    55   if (!p) return;
    56   const slug = p.dataset.slug;
    57   const base = p.dataset.base || '';
    58   if (location.pathname === `${base}/read/${slug}`) return;
    59   history.replaceState(null, '', `${base}/read/${slug}`);
    60   if (p.dataset.title) document.title = p.dataset.title;
    61 }
    62 
    63 async function fetchArticle(slug, base) {
    64   const r = await fetch(`${base}/read/${encodeURIComponent(slug)}?fragment=1`, {
    65     headers: { 'HX-Request': 'true' },
    66     credentials: 'same-origin',
    67   });
    68   if (!r.ok) throw new Error('HTTP ' + r.status);
    69   const box = document.createElement('div');
    70   box.innerHTML = await r.text();
    71   const art = box.querySelector('.read-post');
    72   if (!art) throw new Error('no post in the response');
    73   return art;
    74 }
    75 
    76 /** Onderaan aanvullen: gewoon erbij zetten, de scrollpositie verandert niet. */
    77 async function appendOlder() {
    78   const s = stream();
    79   if (!s || busyBelow) return;
    80   const last = s.querySelector('.read-post:last-of-type');
    81   const slug = last && last.dataset.older;
    82   if (!slug) return markEnd(s);
    83   if (loaded.has(slug)) return;
    84   busyBelow = true;
    85   const pending = notice(s, 'read-loading', '…');
    86   try {
    87     const art = await fetchArticle(slug, last.dataset.base || '');
    88     loaded.add(slug);
    89     pending.remove();
    90     s.appendChild(art);
    91     watch();
    92   } catch (e) {
    93     pending.textContent = str('load_error');
    94     console.warn('[read] below:', e && e.message);
    95   } finally { busyBelow = false; }
    96 }
    97 
    98 /**
    99  * Bovenaan aanvullen. Dit is het enige plek waar we de scrollpositie WEL
    100  * moeten bijstellen: er komt hoogte bij bóven het beeld, en zonder correctie
    101  * schuift alles wat je las naar beneden weg. We meten de hoogte na het invoegen
    102  * en tellen die bij de scrollpositie op -- geen animatie, geen sprong.
    103  */
    104 async function prependNewer() {
    105   const s = stream();
    106   if (!s || busyAbove) return;
    107   const first = s.querySelector('.read-post');
    108   const slug = first && first.dataset.newer;
    109   if (!slug || loaded.has(slug)) return;
    110   busyAbove = true;
    111   const root = document.documentElement;
    112   try {
    113     const art = await fetchArticle(slug, first.dataset.base || '');
    114     loaded.add(slug);
    115     // Snappen even UIT. De correctie hieronder zet de scrollpositie precies
    116     // goed, maar een proximity-snap trekt hem daarna alsnog naar de grens van
    117     // het bericht dat er net bij kwam -- gemeten: een sprong ter grootte van
    118     // dat hele bericht, precies de ruk die deze opzet moet vermijden.
    119     root.style.scrollSnapType = 'none';
    120     const before = s.scrollHeight;
    121     s.insertBefore(art, first);
    122     const added = s.scrollHeight - before;
    123     window.scrollBy(0, added);
    124     watch();
    125     // Twee frames: één om de correctie te laten landen, één om te snappen pas
    126     // weer toe te staan als de browser klaar is met deze scroll.
    127     requestAnimationFrame(() => requestAnimationFrame(() => { root.style.scrollSnapType = ''; }));
    128   } catch (e) {
    129     root.style.scrollSnapType = '';
    130     console.warn('[read] above:', e && e.message);
    131   } finally { busyAbove = false; }
    132 }
    133 
    134 function notice(s, className, text) {
    135   const d = document.createElement('div');
    136   d.className = className;
    137   d.textContent = text;
    138   s.appendChild(d);
    139   return d;
    140 }
    141 
    142 function markEnd(s) {
    143   if (s.querySelector('.read-end')) return;
    144   notice(s, 'read-end', str('end'));
    145 }
    146 
    147 /** (Her)richt de waarnemer op de huidige eerste en laatste post. */
    148 function watch() {
    149   if (observer) observer.disconnect();
    150   observer = new IntersectionObserver((entries) => {
    151     for (const e of entries) {
    152       if (!e.isIntersecting) continue;
    153       // GEEN else-if. Bij het laden staat er één artikel in de stroom, en dat is
    154       // tegelijk het eerste én het laatste. Met een else-if wint de eerste tak
    155       // altijd, en dan draait prependNewer() nooit: wie op het OUDSTE bericht
    156       // binnenkomt, krijgt "einde archief" te zien terwijl er zes nieuwere
    157       // boven hem staan die hij nooit te pakken krijgt.
    158       if (e.target.matches('.read-post:last-of-type')) appendOlder();
    159       if (e.target.matches('.read-post:first-of-type')) prependNewer();
    160     }
    161   }, { rootMargin: ROOT_MARGIN });
    162   const first = stream() && stream().querySelector('.read-post');
    163   const last = stream() && stream().querySelector('.read-post:last-of-type');
    164   if (first) observer.observe(first);
    165   if (last && last !== first) observer.observe(last);
    166 }
    167 
    168 /**
    169  * Tikken op een bericht opent dat bericht.
    170  *
    171  * De stroom toont de tekst; het gesprek -- reacties, waarderingen, boosts --
    172  * staat op de berichtpagina. De titel en de voetlink zijn echte <a>'s en doen
    173  * het werk voor toetsenbord en schermlezer; dit hier is het gemak voor een duim.
    174  *
    175  * Daarom vier uitzonderingen, want een tik die je niet bedoelde is erger dan
    176  * geen tik: iets dat zelf al een doel heeft (link, knop, veld) houdt zijn eigen
    177  * werking, een geselecteerde tekst is geen tik, een verschoven vinger is
    178  * scrollen, en cmd/ctrl-klik hoort de browser zelf af te handelen.
    179  */
    18020let tapX = 0, tapY = 0;
    18121function onPointerDown(e) { tapX = e.clientX; tapY = e.clientY; }
     
    19737}
    19838
    199 function onScroll() {
    200   const y = window.scrollY || 0;
    201   if (y < 8) toggleChrome(false);
    202   else if (y > lastY + 4) toggleChrome(true);
    203   else if (y < lastY - 24) toggleChrome(false);
    204   lastY = y;
    205   syncAddress();
    206 }
    207 
    20839export function init() {
    209   const s = stream();
     40  const s = document.getElementById('read-stream');
    21041  if (!s) return;
    211   try { strings = JSON.parse(s.getAttribute('data-i18n') || '{}'); } catch (e) { strings = {}; }
    212   s.querySelectorAll('.read-post').forEach((p) => loaded.add(p.dataset.slug));
    213   lastY = window.scrollY || 0;
    214   window.addEventListener('scroll', onScroll, { passive: true });
    215   window.addEventListener('resize', watch, { passive: true });
    216   // Op de stroom, niet per artikel: aangevulde berichten doen zo vanzelf mee.
     42  // Op de stroom, niet per artikel: wat "meer laden" erbij zet doet vanzelf mee.
     43  // init() draait bij ELKE paginawissel, dus eerst losmaken -- anders stapelt
     44  // dezelfde afhandelaar zich op en vuurt hij twee keer.
     45  s.removeEventListener('pointerdown', onPointerDown);
     46  s.removeEventListener('click', onTap);
    21747  s.addEventListener('pointerdown', onPointerDown, { passive: true });
    21848  s.addEventListener('click', onTap);
    219   watch();
    22049}
    22150
  • src/routes/posts.js

    r2d2fd8c r7cf8144  
    232232
    233233  if (append) {
    234     return renderPage(req, res, 'partials/home-append', { posts, hasMore, nextOffset: offset + FEED_PAGE, moreBase });
     234    return renderPage(req, res, 'partials/home-append', {
     235      posts, hasMore, nextOffset: offset + FEED_PAGE, moreBase,
     236      readerItems: readerItems(site, posts, req),
     237    });
    235238  }
    236239
     
    243246    pinnedPosts,
    244247    posts,
     248    readerItems: readerItems(site, [...pinnedPosts, ...posts], req),
    245249    hasMore, nextOffset: offset + FEED_PAGE, moreBase,
    246250    movedTo,
     
    249253    socialDescr: site.description || site.tagline || '',
    250254    bodyClass: 'on-home',
     255    // mod/read.js: alleen nog de tik-op-een-bericht in de leesweergave.
     256    pageJs: 'read',
    251257  });
    252 });
    253 
    254 // ==================== LEES (meeslepende tijdlijn) ====================
    255 //
    256 // Eén bericht vult het scherm, de chrome schuift weg, en je scrollt voorbij de
    257 // rand naar het vorige of volgende. Naar bartoverkamp.nl, waar prev/next
    258 // schildwachten boven en onder het artikel staan en het overscrollen zelf de
    259 // navigatie is.
    260 //
    261 // De buren komen uit postNeighbors(), dezelfde die de gewone postpagina al
    262 // gebruikt -- dus "vorige" en "volgende" betekenen hier precies hetzelfde als
    263 // daar, ook voor gepinde berichten.
    264 //
    265 // ?partial=1 levert alleen het artikel, want dat is wat de schildwacht inruilt.
    266 router.get('/read/:slug?', (req, res, next) => {
    267   const site = res.locals.site;
    268   if (!site) return next();
    269 
    270   // Zonder slug: het nieuwste bericht, zodat /read een ingang is en niet een
    271   // fout. Gepind eerst, net als op de voorpagina.
    272   const post = req.params.slug
    273     ? db.prepare(`SELECT p.*, u.username as author_username FROM posts p JOIN users u ON p.author_id = u.id
    274                   WHERE p.site_id = ? AND p.slug = ?`).get(site.id, req.params.slug)
    275     : db.prepare(`SELECT p.*, u.username as author_username FROM posts p JOIN users u ON p.author_id = u.id
    276                   WHERE p.site_id = ? AND p.status = 'published'
    277                   ORDER BY (p.pinned = 0) ASC, p.pinned ASC, p.published_at DESC LIMIT 1`).get(site.id);
    278   if (!post) return next();
    279 
    280   // Het besluit en het lijf komen uit één plek (PostAccessService). Een dichte
    281   // poort levert hier GEEN tekst op: die wordt niet eens gerenderd.
    282   const _u = req.query.u ? verifyBlob(String(req.query.u)) : null;
    283   const unlockedSlug = (_u && _u.purpose === 'unlocked' && _u.siteId === site.id) ? String(_u.post) : null;
    284   const entry = postEntry(post, { user: req.session?.user || null, site, unlockedSlug },
    285     { renderBody: (p) => renderPostBodyHtml(site, p, req) });
    286 
    287   if (entry.access === 'forbidden') return next();
    288 
    289   const { newerPost, olderPost } = postNeighbors(site, post);
    290   const model = {
    291     post, entry, newerPost, olderPost,
    292     pageTitle: post.title || site.title,
    293     // on-read zet de chrome weg; de mini-topnav blijft (chrome.ejs, _headerless).
    294     bodyClass: 'on-read on-special',
    295     pageJs: 'read',
    296   };
    297   // ALLEEN de leesmodule krijgt één artikel; ?fragment=1 is haar eigen teken.
    298   //
    299   // Hier stond `?partial=1 || hx-request`, en dat waren twee dingen door elkaar.
    300   // Op deze site betekent ?partial=1 al iets anders -- "de pagina zonder de
    301   // schil", voor htmx -- dus een gebooste link naar /read kreeg één kaal
    302   // artikel in #pcms-main geschoven: geen stroom, geen on-read, geen script.
    303   // Onzichtbaar zolang je /read alleen intikt; zichtbaar zodra er een link naar
    304   // toe bestaat (de Lezen-knop in de view-switcher).
    305   //
    306   // Alles wat GEEN fragment vraagt gaat naar pages/read, en renderPage bepaalt
    307   // daar zelf of de schil eromheen moet -- net als bij elke andere pagina.
    308   if (req.query.fragment === '1') {
    309     return renderPage(req, res, 'partials/read-article', model);
    310   }
    311   recordPageview(site.id, req);
    312   return renderPage(req, res, 'pages/read', model);
    313258});
    314259
     
    748693// Renders a post's display HTML: baked content + the dynamic audio/embed layer.
    749694// Extracted so the paid unlock (slice 4) serves the exact same body as the page.
     695// Dezelfde berichten, klaar voor de leesweergave.
     696//
     697// Tijdlijn en Grid tonen kaartjes; Lezen toont het hele stuk. Het is dus geen
     698// andere PAGINA maar een andere vorm van dezelfde rijen -- vandaar dat de feed
     699// ze alledrie meestuurt en CSS kiest, precies zoals timeline/grid dat al deden.
     700//
     701// Het lijf loopt door PostAccessService: een gesloten poort levert hier GEEN
     702// tekst op, want wat niet gerenderd wordt kan ook niet lekken.
     703function readerItems(site, rows, req) {
     704  const viewer = { user: req.session?.user || null, site, unlockedSlug: null };
     705  return rows.map((post) => ({
     706    post,
     707    entry: postEntry(post, viewer, { renderBody: (p) => renderPostBodyHtml(site, p, req) }),
     708  }));
     709}
     710
    750711export function renderPostBodyHtml(site, post, req) {
    751712  let html = (post.content_rendered != null && post.content_rendered !== '')
  • src/services/i18n.js

    r2d2fd8c r7cf8144  
    891891    'fgate.title': 'Alleen voor vrienden',
    892892    'fgate.sub': 'Dit is exclusief voor ingelogde vrienden. Log in om het te bekijken.',
    893     'fgate.login': 'Inloggen / aanmelden', 'read.open': 'Reacties en waarderingen', 'read.end': 'Je bent bij het begin van het archief.', 'read.load_error': 'Kon het volgende bericht niet laden.',
     893    'fgate.login': 'Inloggen / aanmelden', 'read.open': 'Reacties en waarderingen',
    894894    'lbio.empty': 'Nog geen links ingesteld.',
    895895    'lbio.back_to_site': 'naar de site',
     
    18971897    'fgate.title': 'Friends only',
    18981898    'fgate.sub': 'This is exclusive to logged-in friends. Log in to view it.',
    1899     'fgate.login': 'Log in / sign up', 'read.open': 'Replies and reactions', 'read.end': 'You have reached the beginning of the archive.', 'read.load_error': 'Could not load the next post.',
     1899    'fgate.login': 'Log in / sign up', 'read.open': 'Replies and reactions',
    19001900    'lbio.empty': 'No links set up yet.',
    19011901    'lbio.back_to_site': 'back to the site',
     
    29032903    'fgate.title': 'Nur für Freunde',
    29042904    'fgate.sub': 'Dies ist exklusiv für eingeloggte Freunde. Melde dich an, um es anzusehen.',
    2905     'fgate.login': 'Anmelden / Registrieren', 'read.open': 'Antworten und Reaktionen', 'read.end': 'Du bist am Anfang des Archivs angekommen.', 'read.load_error': 'Der nächste Beitrag konnte nicht geladen werden.',
     2905    'fgate.login': 'Anmelden / Registrieren', 'read.open': 'Antworten und Reaktionen',
    29062906    'lbio.empty': 'Noch keine Links eingerichtet.',
    29072907    'lbio.back_to_site': 'zurück zur Seite',
  • src/views/pages/home.ejs

    r2d2fd8c r7cf8144  
    5959</div>
    6060
     61<!-- ========== LEZEN (alleen zichtbaar bij body[data-feed-view="reader"]) ==========
     62     Hetzelfde stel berichten, maar heel: één per scherm, met de grenzen door
     63     CSS-snapping. Geen eigen route en geen eigen shell -- de balken blijven
     64     staan, want je bent nog gewoon op de feed en moet terug kunnen schakelen.
     65     Leeg? Dan valt de lege staat van de tijdlijn hierboven in beeld; deze
     66     sectie rendert dan niet. -->
     67<% if (typeof readerItems !== 'undefined' && readerItems && readerItems.length) { %>
     68<section class="feed-reader" aria-label="<%= t('switch.reader') %>">
     69  <div class="read-stream" id="read-stream" data-base="<%= _base %>">
     70    <% readerItems.forEach(function(it) { %>
     71      <%- include('../partials/read-article', { post: it.post, entry: it.entry }) %>
     72    <% }); %>
     73  </div>
     74</section>
     75<% } %>
     76
    6177<!-- ========== GRID (only visible when body[data-feed-view="grid"]) ========== -->
    6278<% if (allPosts.length > 0) { %>
  • src/views/partials/home-append.ejs

    r2d2fd8c r7cf8144  
    44    items instead of nesting under an extra box. %>
    55<div hx-swap-oob="beforeend:#grid-tiles" style="display:contents"><% posts.forEach(function(post){ %><%- include('post-tile', { post: post }) %><% }); %></div>
     6<%# Lezen: dezelfde berichten OOB achteraan de leesstroom, net als de tegels
     7    hierboven. Zonder dit zou "meer laden" in de leesweergave niets doen. %>
     8<% if (typeof readerItems !== 'undefined' && readerItems && readerItems.length) { %><div hx-swap-oob="beforeend:#read-stream" style="display:contents"><% readerItems.forEach(function(it){ %><%- include('read-article', { post: it.post, entry: it.entry }) %><% }); %></div><% } %>
    69<%- include('load-more', { hasMore: hasMore, nextOffset: nextOffset, moreBase: moreBase, moreTarget: '#post-list', morePath: (typeof morePath !== 'undefined' ? morePath : '/'), oob: true }) %>
  • src/views/partials/shared-styles.ejs

    r2d2fd8c r7cf8144  
    2727  padding: 0.15rem;
    2828}
    29 .view-switch-btn,
    30 .view-switch-link {
     29.view-switch-btn {
    3130  display: inline-flex; align-items: center; gap: 0.35rem;
    3231  padding: 0.35rem 0.85rem;
     
    3635  transition: background 120ms, color 120ms;
    3736}
    38 .view-switch-btn:hover,
    39 .view-switch-link:hover { color: var(--ink); }
    40 .view-switch-link { text-decoration: none; }
     37.view-switch-btn:hover { color: var(--ink); }
    4138/* Active = white (light background, dark text). Via aria-selected (element
    4239   attribute, set by syncAria + on htmx:afterSettle) → htmx-robust. On non-
    4340   feed pages syncAria sets aria-selected=false → both grey. */
    44 .view-switch-btn[aria-selected="true"],
    45 .view-switch-link[aria-selected="true"] { background: var(--ink); color: var(--paper); border-color: var(--ink); }
     41.view-switch-btn[aria-selected="true"] { background: var(--ink); color: var(--paper); border-color: var(--ink); }
    4642
    4743/* Agenda = its OWN pill (separate from Timeline/Grid, so the feed-toggle is never
  • src/views/partials/view-switcher.ejs

    r2d2fd8c r7cf8144  
    88  // Pagina's zonder actieve feed-weergave → niets in de pill is 'actief' (Solo/Cirkel
    99  // + Tijdlijn/Grid grijs). Alleen Agenda krijgt z'n eigen is-active op de agenda.
    10   var _neutral = ['on-shows','on-downloads','on-post','on-epk','on-linkbio','on-newsletter','on-archive','on-read']
     10  var _neutral = ['on-shows','on-downloads','on-post','on-epk','on-linkbio','on-newsletter','on-archive']
    1111                   .some(function(c){ return _bc.indexOf(c) >= 0; });
    12   var _onRead = _bc.indexOf('on-read') >= 0;
    13   var _sbR = (typeof siteUrlBase !== 'undefined' && siteUrlBase) ? siteUrlBase : '';
    1412%>
    1513<div class="view-switcher-wrap">
     
    4745        <span><%= t('switch.grid') %></span>
    4846      </button>
    49       <%# Lezen is GEEN in-place omschakeling maar een andere route (/read), dus
    50           een <a> en niet een <button>. En bewust NIET de klasse view-switch-btn:
    51           de gedelegeerde klik in shell.ejs pakt alles met die klasse op, zet
    52           body[data-feed-view] erop en springt op niet-feedpagina's terug naar
    53           home -- dat zou deze link opeten. Andere klasse, zelfde uiterlijk.
    54           aria-selected staat hier vast: syncAria() raakt alleen .view-switch-btn
    55           aan, dus de actieve staat op /read blijft staan na een htmx-navigatie. %>
    56       <a class="view-switch-link" href="<%= _sbR %>/read" role="tab"
    57          aria-selected="<%= _onRead ? 'true' : 'false' %>">
     47      <%# Lezen is een gewone derde weergave: dezelfde berichten, andere vorm.
     48          Dus een <button> met data-view, net als de andere twee -- dan doen de
     49          gedelegeerde klik, localStorage en syncAria in shell.ejs hun werk al. %>
     50      <button type="button" class="view-switch-btn" data-view="reader" role="tab"
     51              aria-selected="false">
    5852        <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M2 3h6a4 4 0 0 1 4 4v14a3 3 0 0 0-3-3H2z"/><path d="M22 3h-6a4 4 0 0 0-4 4v14a3 3 0 0 1 3-3h7z"/></svg>
    5953        <span><%= t('switch.reader') %></span>
    60       </a>
     54      </button>
    6155    </div>
    6256    <% if (!_neutral) { %>
  • src/views/shell.ejs

    r2d2fd8c r7cf8144  
    222222
    223223<!-- v9 stylesheet (full palette system) -->
    224 <link rel="stylesheet" href="/assets/css/style.css?v=68">
     224<link rel="stylesheet" href="/assets/css/style.css?v=70">
    225225<script>
    226226/* iOS safe-area, built by hand. env(safe-area-inset-top) resolves to 0 on this iOS in
     
    435435  // focusscherm óók via htmx z'n styling krijgt (geen chrome, geen tab, audio op 0).
    436436  // ALLES wat de server als page-class kan sturen moet hier staan: wat er niet in
    437   // staat wordt bij een htmx-navigatie stilletjes weggefilterd. 'on-read' ontbrak,
    438   // en dan kwam je via de Lezen-knop op een leesweergave waarvan ELKE regel
    439   // (.on-read ...) niet aansloeg: geen schermhoge berichten, geen snappen, geen
    440   // wegschuivende balken. Bij intikken van /read ging het wel goed, want dan
    441   // rendert de shell de class zelf -- vandaar dat het lang onzichtbaar bleef.
     437  // staat wordt bij een htmx-navigatie stilletjes weggefilterd.
    442438  var PAGE_CLASSES = ['on-home','on-post','on-special','on-archive','on-search','on-admin','on-auth',
    443439    'on-shows','on-downloads','on-download','on-epk','on-linkbio','on-newsletter',
    444     'on-tag','on-type','on-user','on-cirkel','on-hub','on-chat','on-read'];
     440    'on-tag','on-type','on-user','on-cirkel','on-hub','on-chat'];
    445441
    446442  document.body.addEventListener('pcmsNav', function(ev) {
     
    509505  // Eén nummer voor de hele map. Te vaak bumpen kost één download; te weinig
    510506  // bumpen kost een bugfix die nooit aankomt.
    511   var MOD_V = 3;
     507  var MOD_V = 4;
    512508
    513509  // name -> 1 (aan het laden) of de module-namespace (geladen). Een module
     
    554550  try {
    555551    var v = localStorage.getItem('pcms-feed-view');
    556     if (v === 'timeline' || v === 'grid') body.dataset.feedView = v;
     552    if (v === 'timeline' || v === 'grid' || v === 'reader') body.dataset.feedView = v;
    557553    var c = parseInt(localStorage.getItem('pcms-grid-cols'), 10);
    558554    if (c === 2 || c === 3 || c === 4) body.dataset.gridCols = String(c);
Note: See TracChangeset for help on using the changeset viewer.