| [79bba6e] | 1 | <div class="prutter-conv-page" data-conversation-id="<%= conversation.id %>" data-me="<%= user.id %>">
|
|---|
| [7bc636b] | 2 | <header class="prutter-conv-header">
|
|---|
| [79bba6e] | 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 | <% } %>
|
|---|
| [7bc636b] | 18 | </div>
|
|---|
| 19 | </header>
|
|---|
| 20 |
|
|---|
| 21 | <ol class="prutter-thread" id="prutter-thread">
|
|---|
| 22 | <% messages.forEach(function(m) { %>
|
|---|
| 23 | <li class="prutter-msg <%= m.author_id === user.id ? 'prutter-msg--mine' : 'prutter-msg--theirs' %>" data-msg-id="<%= m.id %>">
|
|---|
| 24 | <div class="prutter-msg-bubble"><%= m.content %></div>
|
|---|
| 25 | <div class="prutter-msg-time" title="<%= m.created_at %>"><%= formatDateTime(m.created_at) %></div>
|
|---|
| 26 | </li>
|
|---|
| 27 | <% }); %>
|
|---|
| 28 | </ol>
|
|---|
| 29 |
|
|---|
| [8f6a61d] | 30 | <form class="prutter-composer" id="prutter-composer"
|
|---|
| [7bc636b] | 31 | method="post"
|
|---|
| 32 | action="<%= siteUrlBase %>/prutter/<%= conversation.id %>/send"
|
|---|
| 33 | hx-post="<%= siteUrlBase %>/prutter/<%= conversation.id %>/send"
|
|---|
| 34 | hx-target="#prutter-thread"
|
|---|
| [8f6a61d] | 35 | hx-swap="beforeend">
|
|---|
| [79bba6e] | 36 | <textarea name="content" rows="1" maxlength="2000" placeholder="Bericht…" required></textarea>
|
|---|
| 37 | <button type="submit" class="btn btn-primary">Stuur</button>
|
|---|
| [7bc636b] | 38 | </form>
|
|---|
| 39 | </div>
|
|---|
| 40 |
|
|---|
| 41 | <script>
|
|---|
| 42 | (function() {
|
|---|
| 43 | // Auto-scroll to bottom on load and after appends.
|
|---|
| 44 | var thread = document.getElementById('prutter-thread');
|
|---|
| 45 | function scrollToBottom() { thread.scrollTop = thread.scrollHeight; }
|
|---|
| 46 | scrollToBottom();
|
|---|
| 47 | window.__prutterScroll = scrollToBottom;
|
|---|
| 48 |
|
|---|
| 49 | // Live updates via WebSocket.
|
|---|
| 50 | // Browser auto-resolves protocol/host; same origin as the page.
|
|---|
| 51 | try {
|
|---|
| 52 | var page = document.querySelector('.prutter-conv-page');
|
|---|
| 53 | var convId = page.dataset.conversationId;
|
|---|
| 54 | var me = page.dataset.me;
|
|---|
| 55 | var proto = location.protocol === 'https:' ? 'wss:' : 'ws:';
|
|---|
| 56 | var ws = new WebSocket(proto + '//' + location.host + '/ws/prutter');
|
|---|
| 57 |
|
|---|
| 58 | ws.addEventListener('message', function(ev) {
|
|---|
| 59 | try {
|
|---|
| 60 | var data = JSON.parse(ev.data);
|
|---|
| 61 | if (data.type !== 'new_message' || data.conversationId !== convId) return;
|
|---|
| 62 | var m = data.message;
|
|---|
| 63 | // Don't double-render our own messages (already added via HTMX response)
|
|---|
| 64 | if (m.author_id === me) return;
|
|---|
| 65 |
|
|---|
| 66 | var li = document.createElement('li');
|
|---|
| 67 | li.className = 'prutter-msg prutter-msg--theirs';
|
|---|
| 68 | li.dataset.msgId = m.id;
|
|---|
| 69 | var bubble = document.createElement('div');
|
|---|
| 70 | bubble.className = 'prutter-msg-bubble';
|
|---|
| 71 | bubble.textContent = m.content; // text content = automatic escaping
|
|---|
| 72 | var time = document.createElement('div');
|
|---|
| 73 | time.className = 'prutter-msg-time';
|
|---|
| 74 | time.textContent = new Date(m.created_at).toLocaleString();
|
|---|
| 75 | li.appendChild(bubble);
|
|---|
| 76 | li.appendChild(time);
|
|---|
| 77 | thread.appendChild(li);
|
|---|
| 78 | scrollToBottom();
|
|---|
| 79 | } catch (e) { /* ignore malformed */ }
|
|---|
| 80 | });
|
|---|
| 81 | } catch (e) {
|
|---|
| 82 | console.warn('Prutter WS unavailable:', e);
|
|---|
| 83 | }
|
|---|
| [8f6a61d] | 84 |
|
|---|
| 85 | // ── Composer: invoer-gedrag + auto-groei + wissen na versturen ──────────
|
|---|
| 86 | var form = document.getElementById('prutter-composer');
|
|---|
| 87 | var ta = form ? form.querySelector('textarea') : null;
|
|---|
| 88 | if (form && ta) {
|
|---|
| 89 | var MAXH = function () { return Math.round(window.innerHeight * 0.4); };
|
|---|
| 90 | function autoGrow() {
|
|---|
| 91 | ta.style.height = 'auto';
|
|---|
| 92 | ta.style.height = Math.min(ta.scrollHeight, MAXH()) + 'px';
|
|---|
| 93 | }
|
|---|
| 94 | function isTouch() {
|
|---|
| 95 | return window.matchMedia && window.matchMedia('(pointer: coarse)').matches;
|
|---|
| 96 | }
|
|---|
| 97 | function submit() {
|
|---|
| 98 | if (!ta.value.trim()) return;
|
|---|
| 99 | if (form.requestSubmit) form.requestSubmit();
|
|---|
| 100 | else form.dispatchEvent(new Event('submit', { bubbles: true, cancelable: true }));
|
|---|
| 101 | }
|
|---|
| 102 |
|
|---|
| 103 | ta.addEventListener('input', autoGrow);
|
|---|
| 104 | ta.addEventListener('keydown', function (e) {
|
|---|
| 105 | if (e.key !== 'Enter' || e.isComposing) return; // IME-invoer met rust laten
|
|---|
| 106 | // Touch (mobiel/tablet): Enter = nieuwe regel, versturen via de Stuur-knop.
|
|---|
| 107 | if (isTouch()) return;
|
|---|
| 108 | // Desktop: Shift+Enter = nieuwe regel, gewone Enter = versturen.
|
|---|
| 109 | if (e.shiftKey) return;
|
|---|
| 110 | e.preventDefault();
|
|---|
| 111 | submit();
|
|---|
| 112 | });
|
|---|
| 113 |
|
|---|
| 114 | // Wissen + terug naar één regel ná een geslaagde verzending.
|
|---|
| 115 | form.addEventListener('htmx:afterRequest', function (e) {
|
|---|
| 116 | if (e.detail && e.detail.successful) {
|
|---|
| 117 | ta.value = '';
|
|---|
| 118 | autoGrow();
|
|---|
| 119 | scrollToBottom();
|
|---|
| 120 | ta.focus();
|
|---|
| 121 | }
|
|---|
| 122 | });
|
|---|
| 123 |
|
|---|
| 124 | autoGrow();
|
|---|
| 125 | }
|
|---|
| [7bc636b] | 126 | })();
|
|---|
| 127 | </script>
|
|---|
| 128 |
|
|---|
| 129 | <style>
|
|---|
| [9611f31] | 130 | /* De chat is position:fixed (los uit de flow) → de site-footer (met copyright,
|
|---|
| 131 | versie én de "Installeer app"-knop) schemerde erdoorheen in het lege
|
|---|
| 132 | berichtgebied. In de chat hoort die footer niet thuis: verbergen. */
|
|---|
| 133 | body:has(.prutter-conv-page) .site-footer { display: none; }
|
|---|
| 134 |
|
|---|
| [79bba6e] | 135 | /* ── Full-height chat-layout (mobile-first) ──────────────────────────────
|
|---|
| 136 | De pagina is FIXED gepind tussen de topnav (desktop) en de onderste balken
|
|---|
| 137 | (bottom-tab + audiospeler), zodat ALLEEN de thread scrollt — niet de hele
|
|---|
| 138 | pagina. De offsets volgen de body-classes has-bottom-tab/has-audio-player,
|
|---|
| 139 | dus de composer valt nooit achter een balk. */
|
|---|
| 140 | .prutter-conv-page {
|
|---|
| 141 | position: fixed;
|
|---|
| 142 | left: 0; right: 0;
|
|---|
| 143 | top: 0; /* mobiel: masthead is verborgen → vanaf de top */
|
|---|
| 144 | bottom: 0;
|
|---|
| 145 | max-width: 760px; margin: 0 auto;
|
|---|
| 146 | display: flex; flex-direction: column;
|
|---|
| 147 | padding: 0.5rem 0.75rem;
|
|---|
| 148 | z-index: 1;
|
|---|
| 149 | }
|
|---|
| 150 | /* Desktop: de sticky masthead staat erboven → begin eronder. */
|
|---|
| 151 | @media (min-width: 768px) {
|
|---|
| 152 | .prutter-conv-page { top: 64px; padding: 0.75rem 1rem; }
|
|---|
| [7bc636b] | 153 | }
|
|---|
| [79bba6e] | 154 | /* Onderste vrije ruimte = de balken die de viewport-bodem overlappen. */
|
|---|
| 155 | @media (max-width: 767px) {
|
|---|
| 156 | body.has-bottom-tab .prutter-conv-page { bottom: calc(56px + env(safe-area-inset-bottom, 0)); }
|
|---|
| 157 | }
|
|---|
| 158 | body.has-audio-player .prutter-conv-page { bottom: calc(76px + env(safe-area-inset-bottom, 0)); }
|
|---|
| 159 | @media (max-width: 767px) {
|
|---|
| 160 | body.has-bottom-tab.has-audio-player .prutter-conv-page { bottom: calc(56px + 76px + env(safe-area-inset-bottom, 0)); }
|
|---|
| [7bc636b] | 161 | }
|
|---|
| 162 |
|
|---|
| [79bba6e] | 163 | /* Compacte chat-kop: terug-pijl + avatar + naam op één regel. */
|
|---|
| 164 | .prutter-conv-header {
|
|---|
| 165 | flex: 0 0 auto;
|
|---|
| 166 | display: flex; align-items: center; gap: 0.65rem;
|
|---|
| 167 | padding-bottom: 0.6rem; margin-bottom: 0.5rem;
|
|---|
| 168 | border-bottom: 1px solid var(--rule);
|
|---|
| 169 | }
|
|---|
| 170 | .prutter-back {
|
|---|
| 171 | flex: 0 0 auto;
|
|---|
| 172 | display: inline-flex; align-items: center; justify-content: center;
|
|---|
| 173 | width: 40px; height: 40px; border-radius: 8px;
|
|---|
| 174 | color: var(--ink); text-decoration: none;
|
|---|
| 175 | }
|
|---|
| 176 | .prutter-back:hover { color: var(--accent); background: var(--paper-2); }
|
|---|
| 177 | .prutter-back svg { width: 20px; height: 20px; }
|
|---|
| 178 | .prutter-conv-avatar {
|
|---|
| 179 | flex: 0 0 auto;
|
|---|
| 180 | width: 40px; height: 40px; border-radius: 50%;
|
|---|
| 181 | overflow: hidden; background: var(--paper-2);
|
|---|
| 182 | display: inline-flex; align-items: center; justify-content: center;
|
|---|
| 183 | color: var(--accent); font-weight: 700;
|
|---|
| 184 | }
|
|---|
| 185 | .prutter-conv-avatar img { width: 100%; height: 100%; object-fit: cover; }
|
|---|
| 186 | .prutter-conv-id { display: flex; flex-direction: column; min-width: 0; line-height: 1.2; }
|
|---|
| 187 | .prutter-conv-name {
|
|---|
| 188 | font-family: var(--font-display, serif); font-size: 1.15rem; color: var(--ink);
|
|---|
| 189 | white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
|
|---|
| 190 | }
|
|---|
| 191 | .prutter-conv-sub { font-size: 0.78rem; color: var(--ink-muted, var(--ink-soft)); text-decoration: none; }
|
|---|
| 192 | .prutter-conv-sub:hover { color: var(--accent); }
|
|---|
| [7bc636b] | 193 |
|
|---|
| [79bba6e] | 194 | /* De ENIGE scroller. min-height:0 is cruciaal in een flex-kolom, anders
|
|---|
| 195 | groeit de lijst i.p.v. te scrollen. */
|
|---|
| [7bc636b] | 196 | .prutter-thread {
|
|---|
| [79bba6e] | 197 | flex: 1 1 auto; min-height: 0;
|
|---|
| 198 | list-style: none; margin: 0;
|
|---|
| 199 | display: flex; flex-direction: column; gap: 0.4rem;
|
|---|
| 200 | padding: 0.25rem 0.1rem 0.5rem;
|
|---|
| 201 | overflow-y: auto; overscroll-behavior: contain;
|
|---|
| 202 | -webkit-overflow-scrolling: touch;
|
|---|
| [7bc636b] | 203 | }
|
|---|
| 204 |
|
|---|
| [79bba6e] | 205 | .prutter-msg { display: flex; flex-direction: column; max-width: 82%; }
|
|---|
| [7bc636b] | 206 | .prutter-msg-bubble {
|
|---|
| 207 | padding: 0.55rem 0.85rem;
|
|---|
| 208 | border-radius: 14px;
|
|---|
| 209 | white-space: pre-wrap;
|
|---|
| 210 | word-wrap: break-word;
|
|---|
| 211 | line-height: 1.4;
|
|---|
| 212 | font-size: 0.95rem;
|
|---|
| 213 | }
|
|---|
| 214 | .prutter-msg-time {
|
|---|
| 215 | font-size: 0.7rem;
|
|---|
| 216 | color: var(--ink-muted, var(--ink-soft));
|
|---|
| 217 | margin-top: 0.15rem;
|
|---|
| 218 | }
|
|---|
| 219 |
|
|---|
| 220 | .prutter-msg--theirs { align-self: flex-start; }
|
|---|
| 221 | .prutter-msg--theirs .prutter-msg-bubble {
|
|---|
| 222 | background: var(--paper);
|
|---|
| 223 | border: 1px solid var(--rule);
|
|---|
| 224 | border-bottom-left-radius: 4px;
|
|---|
| 225 | }
|
|---|
| 226 | .prutter-msg--theirs .prutter-msg-time { padding-left: 0.5rem; }
|
|---|
| 227 |
|
|---|
| 228 | .prutter-msg--mine { align-self: flex-end; align-items: flex-end; }
|
|---|
| 229 | .prutter-msg--mine .prutter-msg-bubble {
|
|---|
| 230 | background: var(--accent);
|
|---|
| 231 | color: white;
|
|---|
| 232 | border-bottom-right-radius: 4px;
|
|---|
| 233 | }
|
|---|
| 234 | .prutter-msg--mine .prutter-msg-time { padding-right: 0.5rem; }
|
|---|
| 235 |
|
|---|
| 236 | .prutter-composer {
|
|---|
| [79bba6e] | 237 | flex: 0 0 auto;
|
|---|
| [7bc636b] | 238 | display: flex; gap: 0.5rem; align-items: flex-end;
|
|---|
| [79bba6e] | 239 | padding-top: 0.6rem; margin-top: 0.25rem;
|
|---|
| 240 | border-top: 1px solid var(--rule);
|
|---|
| [7bc636b] | 241 | }
|
|---|
| 242 | .prutter-composer textarea {
|
|---|
| 243 | flex: 1;
|
|---|
| [79bba6e] | 244 | padding: 0.65rem 0.85rem;
|
|---|
| [7bc636b] | 245 | border: 1px solid var(--rule);
|
|---|
| [79bba6e] | 246 | border-radius: 12px;
|
|---|
| [7bc636b] | 247 | background: var(--paper);
|
|---|
| 248 | color: var(--ink);
|
|---|
| 249 | font-family: inherit;
|
|---|
| [79bba6e] | 250 | font-size: 1rem; /* ≥16px → iOS zoomt niet in bij focus */
|
|---|
| 251 | line-height: 1.35;
|
|---|
| 252 | resize: none;
|
|---|
| 253 | min-height: 44px; max-height: 40vh;
|
|---|
| [8f6a61d] | 254 | overflow-y: auto; /* scrollt intern zodra de auto-groei de max raakt */
|
|---|
| [7bc636b] | 255 | }
|
|---|
| [79bba6e] | 256 | .prutter-composer .btn { flex: 0 0 auto; min-height: 44px; }
|
|---|
| [7bc636b] | 257 | </style>
|
|---|