Changeset 79bba6e in Klonkt


Ignore:
Timestamp:
06/17/2026 12:29:16 AM (3 months ago)
Author:
roboburr <roboburr@…>
Branches:
main
Children:
cbd1761
Parents:
44cf44d
Message:

Audio never jumps + hub topnav + Prutter chat mobile-first

  • Global link boost in shell.ejs: all internal navigation links go through htmx into #pcms-main (session/auth/downloads/assets excluded), so the audio player keeps playing everywhere — including clicks within Admin. Back/forward re-fetches (historyCacheSize=0) so the OOB header comes along.
  • Hub-home: the mini topnav bar now shows on the hub landing (no large profile header — the hero is the header); duplicate .hub-nav removed.
  • Prutter conversation: full-height mobile-first chat (bodyClass on-chat → no artist profile header). Page pinned fixed between topnav and the bottom bars; only the thread scrolls. Compact header + clean composer.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@…>

Location:
src
Files:
5 edited

Legend:

Unmodified
Added
Removed
  • src/routes/prutter.js

    r44cf44d r79bba6e  
    112112  renderPage(req, res, 'pages/prutter-conversation', {
    113113    pageTitle: 'Prutter — ' + (other?.username || ''),
    114     bodyClass: 'on-special',
     114    // on-chat → full-height chat-view (geen artiest-profielkop, alleen de thread
     115    // scrollt). Zie chrome.ejs (_headerless) + de page-CSS hieronder.
     116    bodyClass: 'on-special on-chat',
    115117    conversation: conv,
    116118    other,
  • src/views/pages/hub-home.ejs

    r44cf44d r79bba6e  
    11<div class="hub<%= hub.heroImage ? ' hub--img' : '' %>">
    22
    3   <%# ── Minimale nav (alleen auth) — over de hero, geen brand/switcher ── %>
    4   <nav class="hub-nav">
    5     <div class="hub-nav-inner container">
    6       <% if (user) { %>
    7         <% if (typeof canSeeBeheer !== 'undefined' && canSeeBeheer) { %>
    8           <a class="hub-nav-link" href="/admin"
    9              hx-get="/admin?partial=1" hx-target="#pcms-main" hx-swap="innerHTML"
    10              hx-push-url="/admin" hx-indicator="#pcms-loading">Beheer</a>
    11         <% } %>
    12         <a class="hub-nav-link" href="/account"
    13            hx-get="/account?partial=1" hx-target="#pcms-main" hx-swap="innerHTML"
    14            hx-push-url="/account" hx-indicator="#pcms-loading"><%= user.username %></a>
    15         <a class="hub-nav-link" href="/auth/logout">Uitloggen</a>
    16       <% } else { %>
    17         <a class="hub-nav-link" href="/auth/login">Inloggen</a>
    18       <% } %>
    19     </div>
    20   </nav>
    21 
    22   <%# ── HERO (label) — de pagina begint hier, geen header erboven ────── %>
     3  <%# ── HERO (label) — de mini topnav-balk staat erboven (chrome.ejs);
     4        de hero zelf is de "grote" header van de hub-home ──────────────── %>
    235  <header class="hub-hero<%= hub.heroImage ? ' has-img' : '' %>"<% if (hub.heroImage) { %> style="--hero-img:url('<%= hub.heroImage %>'); --hero-ov:<%= ((hub.heroOverlay == null ? 45 : hub.heroOverlay)/100).toFixed(2) %>"<% } %>>
    246    <div class="hub-hero-inner container">
  • src/views/pages/prutter-conversation.ejs

    r44cf44d r79bba6e  
    1 <div class="container prutter-conv-page" data-conversation-id="<%= conversation.id %>" data-me="<%= user.id %>">
     1<div class="prutter-conv-page" data-conversation-id="<%= conversation.id %>" data-me="<%= user.id %>">
    22  <header class="prutter-conv-header">
    3     <p class="prutter-back"><a href="<%= siteUrlBase %>/prutter">&larr; Inbox</a></p>
    4     <div class="prutter-conv-headline">
    5       <div class="prutter-conv-avatar prutter-conv-avatar--lg">
    6         <% if (other && other.avatar_url) { %>
    7           <img src="<%= other.avatar_url %>" alt="">
    8         <% } else { %>
    9           <span><%= (other && other.username || '?').charAt(0).toUpperCase() %></span>
    10         <% } %>
    11       </div>
    12       <div>
    13         <h1><%= other ? other.username : 'Unknown' %></h1>
    14         <% if (other) { %>
    15           <p class="prutter-conv-sub"><a href="/users/<%= encodeURIComponent(other.username) %>">View profile</a></p>
    16         <% } %>
    17       </div>
     3    <a class="prutter-back" href="<%= siteUrlBase %>/prutter" aria-label="Terug naar inbox" title="Inbox">
     4      <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><line x1="19" y1="12" x2="5" y2="12"/><polyline points="12 19 5 12 12 5"/></svg>
     5    </a>
     6    <div class="prutter-conv-avatar">
     7      <% if (other && other.avatar_url) { %>
     8        <img src="<%= other.avatar_url %>" alt="">
     9      <% } else { %>
     10        <span><%= (other && other.username || '?').charAt(0).toUpperCase() %></span>
     11      <% } %>
     12    </div>
     13    <div class="prutter-conv-id">
     14      <span class="prutter-conv-name"><%= other ? other.username : 'Onbekend' %></span>
     15      <% if (other) { %>
     16        <a class="prutter-conv-sub" href="/users/<%= encodeURIComponent(other.username) %>">Bekijk profiel</a>
     17      <% } %>
    1818    </div>
    1919  </header>
     
    3535        hx-swap="beforeend"
    3636        hx-on::after-request="this.querySelector('textarea').value=''; window.__prutterScroll && window.__prutterScroll();">
    37     <textarea name="content" rows="2" maxlength="2000" placeholder="Type a message…" required></textarea>
    38     <button type="submit" class="btn btn-primary">Send</button>
     37    <textarea name="content" rows="1" maxlength="2000" placeholder="Bericht…" required></textarea>
     38    <button type="submit" class="btn btn-primary">Stuur</button>
    3939  </form>
    4040</div>
     
    8787
    8888<style>
    89 .prutter-conv-page { max-width: 720px; margin: 2rem auto; padding: 0 1rem; display: flex; flex-direction: column; gap: 1rem; }
    90 .prutter-back { margin: 0; }
    91 .prutter-back a { color: var(--accent); text-decoration: none; }
    92 
    93 .prutter-conv-headline {
    94   display: flex; align-items: center; gap: 0.85rem;
    95   padding-bottom: 1rem; border-bottom: 1px solid var(--rule);
    96 }
    97 .prutter-conv-headline h1 {
    98   font-family: var(--font-display, serif);
    99   font-size: 1.5rem; margin: 0;
    100 }
    101 .prutter-conv-sub { margin: 0; font-size: 0.85rem; color: var(--ink-muted, var(--ink-soft)); }
    102 .prutter-conv-sub a { color: var(--accent); }
    103 
    104 .prutter-conv-avatar--lg { width: 56px; height: 56px; }
    105 .prutter-conv-avatar--lg span { font-size: 1.2rem; }
    106 
     89/* ── Full-height chat-layout (mobile-first) ──────────────────────────────
     90   De pagina is FIXED gepind tussen de topnav (desktop) en de onderste balken
     91   (bottom-tab + audiospeler), zodat ALLEEN de thread scrollt — niet de hele
     92   pagina. De offsets volgen de body-classes has-bottom-tab/has-audio-player,
     93   dus de composer valt nooit achter een balk. */
     94.prutter-conv-page {
     95  position: fixed;
     96  left: 0; right: 0;
     97  top: 0;                 /* mobiel: masthead is verborgen → vanaf de top */
     98  bottom: 0;
     99  max-width: 760px; margin: 0 auto;
     100  display: flex; flex-direction: column;
     101  padding: 0.5rem 0.75rem;
     102  z-index: 1;
     103}
     104/* Desktop: de sticky masthead staat erboven → begin eronder. */
     105@media (min-width: 768px) {
     106  .prutter-conv-page { top: 64px; padding: 0.75rem 1rem; }
     107}
     108/* Onderste vrije ruimte = de balken die de viewport-bodem overlappen. */
     109@media (max-width: 767px) {
     110  body.has-bottom-tab .prutter-conv-page { bottom: calc(56px + env(safe-area-inset-bottom, 0)); }
     111}
     112body.has-audio-player .prutter-conv-page { bottom: calc(76px + env(safe-area-inset-bottom, 0)); }
     113@media (max-width: 767px) {
     114  body.has-bottom-tab.has-audio-player .prutter-conv-page { bottom: calc(56px + 76px + env(safe-area-inset-bottom, 0)); }
     115}
     116
     117/* Compacte chat-kop: terug-pijl + avatar + naam op één regel. */
     118.prutter-conv-header {
     119  flex: 0 0 auto;
     120  display: flex; align-items: center; gap: 0.65rem;
     121  padding-bottom: 0.6rem; margin-bottom: 0.5rem;
     122  border-bottom: 1px solid var(--rule);
     123}
     124.prutter-back {
     125  flex: 0 0 auto;
     126  display: inline-flex; align-items: center; justify-content: center;
     127  width: 40px; height: 40px; border-radius: 8px;
     128  color: var(--ink); text-decoration: none;
     129}
     130.prutter-back:hover { color: var(--accent); background: var(--paper-2); }
     131.prutter-back svg { width: 20px; height: 20px; }
     132.prutter-conv-avatar {
     133  flex: 0 0 auto;
     134  width: 40px; height: 40px; border-radius: 50%;
     135  overflow: hidden; background: var(--paper-2);
     136  display: inline-flex; align-items: center; justify-content: center;
     137  color: var(--accent); font-weight: 700;
     138}
     139.prutter-conv-avatar img { width: 100%; height: 100%; object-fit: cover; }
     140.prutter-conv-id { display: flex; flex-direction: column; min-width: 0; line-height: 1.2; }
     141.prutter-conv-name {
     142  font-family: var(--font-display, serif); font-size: 1.15rem; color: var(--ink);
     143  white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
     144}
     145.prutter-conv-sub { font-size: 0.78rem; color: var(--ink-muted, var(--ink-soft)); text-decoration: none; }
     146.prutter-conv-sub:hover { color: var(--accent); }
     147
     148/* De ENIGE scroller. min-height:0 is cruciaal in een flex-kolom, anders
     149   groeit de lijst i.p.v. te scrollen. */
    107150.prutter-thread {
    108   list-style: none; padding: 0; margin: 0;
    109   display: flex; flex-direction: column; gap: 0.5rem;
    110   background: var(--paper-2);
    111   border: 1px solid var(--rule);
    112   border-radius: 8px;
    113   padding: 1rem;
    114   height: 60vh;
    115   overflow-y: auto;
    116 }
    117 
    118 .prutter-msg { display: flex; flex-direction: column; max-width: 80%; }
     151  flex: 1 1 auto; min-height: 0;
     152  list-style: none; margin: 0;
     153  display: flex; flex-direction: column; gap: 0.4rem;
     154  padding: 0.25rem 0.1rem 0.5rem;
     155  overflow-y: auto; overscroll-behavior: contain;
     156  -webkit-overflow-scrolling: touch;
     157}
     158
     159.prutter-msg { display: flex; flex-direction: column; max-width: 82%; }
    119160.prutter-msg-bubble {
    120161  padding: 0.55rem 0.85rem;
     
    148189
    149190.prutter-composer {
     191  flex: 0 0 auto;
    150192  display: flex; gap: 0.5rem; align-items: flex-end;
    151   padding-bottom: env(safe-area-inset-bottom, 0);
     193  padding-top: 0.6rem; margin-top: 0.25rem;
     194  border-top: 1px solid var(--rule);
    152195}
    153196.prutter-composer textarea {
    154197  flex: 1;
    155   padding: 0.6rem 0.85rem;
     198  padding: 0.65rem 0.85rem;
    156199  border: 1px solid var(--rule);
    157   border-radius: 8px;
     200  border-radius: 12px;
    158201  background: var(--paper);
    159202  color: var(--ink);
    160203  font-family: inherit;
    161   font-size: 0.95rem;
    162   resize: vertical;
    163   min-height: 44px;
    164 }
     204  font-size: 1rem;            /* ≥16px → iOS zoomt niet in bij focus */
     205  line-height: 1.35;
     206  resize: none;
     207  min-height: 44px; max-height: 40vh;
     208}
     209.prutter-composer .btn { flex: 0 0 auto; min-height: 44px; }
    165210</style>
  • src/views/partials/chrome.ejs

    r44cf44d r79bba6e  
    88//
    99// Wordt zowel door shell.ejs (oob niet gezet) als door render.js bij partials
    10 // (oob=true) gerenderd. bareChrome-logica staat hier zodat beide identiek zijn.
    11 var _isAdmin = typeof bodyClass === 'string' && bodyClass.indexOf('on-admin') >= 0;
    12 var bareChrome = (typeof bodyClass === 'string' && (bodyClass.indexOf('on-hub') >= 0 || bodyClass.indexOf('on-auth') >= 0))
     10// (oob=true) gerenderd. De logica staat hier zodat beide identiek zijn.
     11//
     12//   _isAuth     → focus-scherm (in/uitloggen): helemaal geen chrome.
     13//   _headerless → hub-homepagina én de Prutter-chat: WÉL de mini topnav-balk,
     14//                 GEEN profielkop (hub: hero = header; chat: full-height view).
     15//   anders      → topnav + profielkop (+ view-switcher buiten admin).
     16var _isAdmin   = typeof bodyClass === 'string' && bodyClass.indexOf('on-admin') >= 0;
     17var _isAuth    = typeof bodyClass === 'string' && bodyClass.indexOf('on-auth') >= 0;
     18var _isChat    = typeof bodyClass === 'string' && bodyClass.indexOf('on-chat') >= 0;
     19var hubLanding = (typeof bodyClass === 'string' && bodyClass.indexOf('on-hub') >= 0)
    1320   || ((typeof tenancy !== 'undefined' && tenancy === 'hub') && !siteUrlBase && !_isAdmin);
     21var _headerless = hubLanding || _isChat;
    1422%>
    1523<div id="pcms-chrome"<% if (typeof oob !== 'undefined' && oob) { %> hx-swap-oob="true"<% } %>>
    16 <% if (!bareChrome) { %>
     24<% if (!_isAuth) { %>
    1725  <%- include('topnav') %>
    18   <%- include('profile-header') %>
    19   <% if (!_isAdmin) { %><%- include('view-switcher') %><% } %>
     26  <% if (!_headerless) { %>
     27    <%- include('profile-header') %>
     28    <% if (!_isAdmin) { %><%- include('view-switcher') %><% } %>
     29  <% } %>
    2030<% } %>
    2131</div>
  • src/views/shell.ejs

    r44cf44d r79bba6e  
    419419</script>
    420420
     421<!-- Globale link-boost: alle interne navigatie-links lopen via htmx in #pcms-main,
     422     zodat de audioplayer (los in document.body) blijft spelen i.p.v. te verspringen
     423     bij een full page-load. Werkt overal — Beheer, Account, posts, sites — zonder
     424     elke link los htmx te maken. Links die écht een volledige load nodig hebben
     425     (uitloggen/auth, downloads, feeds, media, assets, bestanden) worden overgeslagen,
     426     net als links die al hun eigen hx-* hebben. -->
     427<script>
     428(function () {
     429  if (!window.htmx) return;
     430  // Back/forward moet her-ophalen, zodat de OOB-site-chrome (kop) meekomt met de
     431  // herstelde pagina — htmx' snapshot-cache zou anders een stale kop tonen.
     432  try { window.htmx.config.historyCacheSize = 0; } catch (_) {}
     433
     434  function fullLoad(a, url) {
     435    if (a.hasAttribute('download') || a.hasAttribute('data-full-load')) return true;
     436    if (a.hasAttribute('hx-get') || a.hasAttribute('hx-post') || a.hasAttribute('hx-boost')) return true;
     437    if (a.target && a.target !== '_self') return true;
     438    if (a.getAttribute('rel') === 'external') return true;
     439    var p = url.pathname;
     440    // Sessie/security/oauth → volledige navigatie (cookies, redirects).
     441    if (/^\/(logout|login|auth|oauth)(?:\/|$)/.test(p)) return true;
     442    // Feeds, PWA, service-worker, statics, media-streams, downloads.
     443    if (/^\/(feed|atom|sitemap|manifest|robots|sw\.js|assets|media|audio|uploads)(?:\/|\.|$)/.test(p)) return true;
     444    if (/\.[a-z0-9]{2,5}$/i.test(p)) return true; // bestandsextensie → laat de browser 't halen
     445    return false;
     446  }
     447
     448  var lastPath = location.pathname + location.search;
     449
     450  document.addEventListener('click', function (e) {
     451    if (e.defaultPrevented || e.button !== 0 || e.metaKey || e.ctrlKey || e.shiftKey || e.altKey) return;
     452    var a = e.target.closest('a[href]');
     453    if (!a) return;
     454    var href = a.getAttribute('href');
     455    if (!href || href.charAt(0) === '#') return;
     456    var url; try { url = new URL(a.href, location.href); } catch (_) { return; }
     457    if (url.origin !== location.origin) return;
     458    if (fullLoad(a, url)) return;
     459    e.preventDefault();
     460    var dest = url.pathname + url.search;
     461    if (dest !== lastPath) history.pushState({ b: 1 }, '', dest);
     462    lastPath = dest;
     463    window.htmx.ajax('GET', dest, { target: '#pcms-main', swap: 'innerHTML' });
     464    try { window.scrollTo(0, 0); } catch (_) {}
     465  });
     466
     467  window.addEventListener('popstate', function () {
     468    var here = location.pathname + location.search;
     469    if (here === lastPath) return;
     470    lastPath = here;
     471    window.htmx.ajax('GET', here, { target: '#pcms-main', swap: 'innerHTML' });
     472  });
     473})();
     474</script>
     475
    421476<!-- PWA install prompt — show button when browser fires beforeinstallprompt -->
    422477<script>
Note: See TracChangeset for help on using the changeset viewer.