source: Klonkt/src/views/pages/post.ejs@ 3d7312a

main
Last change on this file since 3d7312a was 3d7312a, checked in by Robin Genis <roboburr@…>, 3 months ago

feat(activitypub): remote interaction — reply to any fediverse post from your own server

Standard 'reply from your own server' flow: every post page has a 'Reply via the
fediverse' button that bounces the visitor to their home server's
/authorize_interaction?uri=. Klonkt implements that endpoint: the site owner
composes a reply that's resolved (resolveRemoteNote) + federated back to the
remote post as the site actor. Works Klonkt<->Klonkt and with Mastodon.

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

  • Property mode set to 100644
File size: 28.4 KB
RevLine 
[b94c08e]1<%
[834bcc3]2// In hub mode the site is accessible under /user/<slug>; all site-scoped links
3// (tag, edit, delete, comments) must carry that prefix otherwise they fall back
4// to the primary site → 404 / wrong site. In solo mode _base = ''.
[b94c08e]5const _base = (typeof siteUrlBase !== 'undefined' && siteUrlBase) ? siteUrlBase : '';
6%>
[7bc636b]7<article class="container post-page">
[74544fb]8
[834bcc3]9 <%# Navigation across ALL posts — ABOVE the post (shared partial). %>
[1e2e9e7]10 <%- include('../partials/post-nav', { newerPost: newerPost, olderPost: olderPost }) %>
[74544fb]11
[7bc636b]12 <% if (post.cover_image_url) { %>
13 <figure class="post-cover">
14 <img src="<%= post.cover_image_url %>" alt="">
15 </figure>
16 <% } %>
17
18 <header class="post-header">
19 <h1 class="post-title"><%= post.title %></h1>
20 <div class="post-meta">
21 <a class="author" href="/users/<%= encodeURIComponent(post.author_username) %>"
22 hx-get="/users/<%= encodeURIComponent(post.author_username) %>?partial=1"
23 hx-target="#pcms-main" hx-swap="innerHTML"
24 hx-push-url="/users/<%= encodeURIComponent(post.author_username) %>"
25 hx-indicator="#pcms-loading"><%= post.author_username %></a>
26 <span class="meta-sep">·</span>
27 <time datetime="<%= post.published_at || post.created_at %>"><%= formatDate(post.published_at || post.created_at) %></time>
28 <% if (post.status !== 'published') { %>
29 <span class="meta-sep">·</span>
30 <span class="status-badge status-<%= post.status %>"><%= post.status %></span>
31 <% } %>
32 </div>
[535f955]33 <% if (post.status === 'published') { %>
34 <div class="post-like-row">
35 <%- include('../partials/like-button', { post: post, likedByMe: (typeof likedByMe !== 'undefined' && likedByMe), likeCount: (typeof likeCount !== 'undefined' ? likeCount : 0), loggedIn: !!user, loginNext: _base + '/' + post.slug }) %>
36 </div>
37 <% } %>
[7bc636b]38 </header>
39
40 <div class="post-content">
41 <%- post.content_html %>
42 </div>
43
44 <% if (post.tags && post.tags.length > 0) { %>
45 <div class="post-tags">
46 <% post.tags.forEach(t => { %>
[b94c08e]47 <a class="tag" href="<%= _base %>/tag/<%= encodeURIComponent(t) %>"
48 hx-get="<%= _base %>/tag/<%= encodeURIComponent(t) %>?partial=1"
[7bc636b]49 hx-target="#pcms-main" hx-swap="innerHTML"
[b94c08e]50 hx-push-url="<%= _base %>/tag/<%= encodeURIComponent(t) %>"
[7bc636b]51 hx-indicator="#pcms-loading">#<%= t %></a>
52 <% }); %>
53 </div>
54 <% } %>
55
56 <!-- Inline admin actions: only visible if user has permission -->
57 <% if (user && (permissions.canEditPost(user, post, site) || permissions.canDeletePost(user, post, site))) { %>
58 <aside class="post-actions">
59 <% if (permissions.canEditPost(user, post, site)) { %>
[b94c08e]60 <a href="<%= _base %>/posts/<%= post.slug %>/edit" class="btn"
61 hx-get="<%= _base %>/posts/<%= post.slug %>/edit?partial=1" hx-target="#pcms-main"
62 hx-push-url="<%= _base %>/posts/<%= post.slug %>/edit" hx-indicator="#pcms-loading">
[7bc636b]63 ✏️ Edit
64 </a>
65 <% } %>
66 <% if (permissions.canDeletePost(user, post, site)) { %>
[b94c08e]67 <form method="post" action="<%= _base %>/posts/<%= post.slug %>/delete" style="display:inline" onsubmit="return confirm('Delete this post?')">
[7bc636b]68 <button type="submit" class="btn btn-danger">🗑 Delete</button>
69 </form>
70 <% } %>
71 </aside>
72 <% } %>
73
[c16e0a5]74 <!-- Fediverse interactions (inbound replies / likes / boosts via ActivityPub) -->
[3a2c9d3]75 <!-- Mirrors the native comment markup/classes so it looks identical. -->
[c16e0a5]76 <% if (typeof fediverse !== 'undefined' && fediverse && fediverse.total > 0) { %>
[3a2c9d3]77 <section class="post-fediverse post-comments" id="fediverse">
78 <h2 class="comments-heading"><%= t('fedi.heading') %></h2>
[c16e0a5]79 <p class="fedi-stats">
80 <span title="<%= t('fedi.likes') %>">⭐ <%= fediverse.likeCount %></span>
81 <span title="<%= t('fedi.boosts') %>">🔁 <%= fediverse.announceCount %></span>
82 <span title="<%= t('fedi.replies') %>">💬 <%= fediverse.replies.length %></span>
83 </p>
84 <% if (fediverse.replies.length) { %>
[3a2c9d3]85 <ol class="comments-list">
[c16e0a5]86 <% fediverse.replies.forEach(function(r) { %>
[3a2c9d3]87 <li class="comment">
88 <div class="comment-avatar">
[c16e0a5]89 <% if (r.actor_icon) { %><img src="<%= r.actor_icon %>" alt="" loading="lazy">
[3a2c9d3]90 <% } else { %><span><%= (r.actor_name || '?').charAt(0).toUpperCase() %></span><% } %>
[c16e0a5]91 </div>
[3a2c9d3]92 <div class="comment-body">
93 <div class="comment-meta">
94 <a class="comment-author" href="<%= r.actor_url %>" rel="nofollow noopener" target="_blank"><%= r.actor_name %></a>
[c16e0a5]95 <span class="fedi-handle"><%= r.actor_handle %></span>
[3a2c9d3]96 <% if (r.published || r.created_at) { %><span class="comment-time"><%= formatDateTime(r.published || r.created_at) %></span><% } %>
[c16e0a5]97 </div>
[3a2c9d3]98 <div class="comment-content"><%- r.content %></div>
99
[55bc7f9]100 <% if (typeof canManageSite !== 'undefined' && canManageSite) { %>
101 <details class="fedi-replybox">
[3a2c9d3]102 <summary class="comment-reply-btn fedi-reply-toggle"><%= t('fedi.reply') %></summary>
103 <form method="post" action="<%= _base %>/posts/<%= post.slug %>/fedi-reply" class="comment-reply-form">
[55bc7f9]104 <input type="hidden" name="interaction_id" value="<%= r.id %>">
[3a2c9d3]105 <textarea name="text" rows="3" required placeholder="<%= t('fedi.reply_ph') %>"></textarea>
106 <div class="comment-reply-form-actions">
107 <button type="submit" class="btn btn-primary"><%= t('fedi.send') %></button>
108 </div>
[55bc7f9]109 </form>
110 </details>
111 <% } %>
[3a2c9d3]112
113 <% var mine = (typeof fediverse.outReplies !== 'undefined' ? fediverse.outReplies : []).filter(function(o){ return o.in_reply_to && o.in_reply_to === r.object_uri; }); %>
114 <% if (mine.length) { %>
115 <ol class="comment-replies">
116 <% mine.forEach(function(o){ %>
117 <li class="comment comment-reply">
[52ea6df]118 <div class="comment-avatar">
119 <% if (typeof siteAvatar !== 'undefined' && siteAvatar) { %><img src="<%= siteAvatar %>" alt="">
120 <% } else { %><span><%= t('fedi.you').charAt(0).toUpperCase() %></span><% } %>
121 </div>
[3a2c9d3]122 <div class="comment-body">
123 <div class="comment-meta"><span class="comment-author"><%= t('fedi.you') %></span></div>
124 <div class="comment-content"><%- o.content %></div>
125 </div>
126 </li>
127 <% }); %>
128 </ol>
129 <% } %>
[c16e0a5]130 </div>
131 </li>
132 <% }); %>
[3a2c9d3]133 </ol>
[c16e0a5]134 <% } %>
135 </section>
136 <% } %>
137
[3d7312a]138 <!-- Reply via the fediverse (remote interaction: bounces to the visitor's own server) -->
139 <% var _postAbsUrl = (typeof ogOrigin !== 'undefined' && ogOrigin ? ogOrigin : '') + (typeof _base !== 'undefined' && _base ? _base : '') + '/' + post.slug; %>
140 <div class="post-fediverse-cta">
141 <button type="button" class="btn fedi-remote-reply-btn"
142 onclick="(function(){var d=prompt(<%- JSON.stringify(t('fedi.remote_prompt')) %>);if(!d)return;d=d.trim().replace(/^@?[^@\s]*@/,'').replace(/^https?:\/\//i,'').replace(/\/.*$/,'').trim();if(d)location.href='https://'+d+'/authorize_interaction?uri='+encodeURIComponent(<%- JSON.stringify(_postAbsUrl) %>);})()">
143 🐘 <%= t('fedi.remote_reply') %>
144 </button>
145 </div>
146
[7bc636b]147 <!-- Comments -->
148 <section class="post-comments" id="comments">
149 <h2 class="comments-heading">
[fe86013]150 <%= t(totalComments === 1 ? 'comments.heading_one' : 'comments.heading_other', { n: totalComments }) %>
[7bc636b]151 </h2>
152
153 <% if (typeof currentPath === 'string' && currentPath && currentPath.indexOf('/' + post.slug) === 0) { %>
[834bcc3]154 <% /* If the URL has ?pending=1, the previous submit landed in the moderation queue */ %>
[7bc636b]155 <% } %>
156 <script>
157 (function() {
158 if (location.search.indexOf('pending=1') !== -1) {
159 var s = document.createElement('div');
160 s.className = 'comments-pending-flash';
[fe86013]161 s.textContent = <%- JSON.stringify(t('comments.pending')) %>;
[7bc636b]162 document.currentScript.parentNode.insertBefore(s, document.currentScript);
163 }
164 })();
165 </script>
166
167 <% if (!comments || !comments.length) { %>
[fe86013]168 <p class="comments-empty"><%= t('comments.empty') %><% if (!user) { %> <a href="/auth/login?next=<%= encodeURIComponent(_base + '/' + post.slug + '#comments') %>"><%= t('nav.login') %></a> <%= t('comments.to_start') %><% } %></p>
[7bc636b]169 <% } else { %>
170 <ol class="comments-list">
171 <% comments.forEach(function(c) { %>
172 <li class="comment" id="comment-<%= c.id %>">
173 <div class="comment-avatar">
174 <% if (c.author_avatar) { %>
175 <img src="<%= c.author_avatar %>" alt="">
176 <% } else { %>
177 <span><%= c.author_username.charAt(0).toUpperCase() %></span>
178 <% } %>
179 </div>
180 <div class="comment-body">
181 <div class="comment-meta">
182 <a class="comment-author" href="/users/<%= encodeURIComponent(c.author_username) %>"><%= c.author_username %></a>
183 <span class="comment-time" title="<%= c.created_at %>"><%= formatDateTime(c.created_at) %></span>
184 </div>
185 <div class="comment-content"><%= c.content %></div>
186 <div class="comment-actions">
[8afbdd6]187 <% if (user && canMutate) { %>
[fe86013]188 <button type="button" class="comment-reply-btn" data-reply-to="<%= c.id %>"><%= t('comments.reply') %></button>
[7bc636b]189 <% } %>
190 <% if (user && permissions.canDeleteComment(user, c, site)) { %>
[e25438a]191 <form method="post" action="<%= _base %>/comments/<%= c.id %>/delete" onsubmit="return confirm('<%= t('comments.delete_confirm') %>')">
[fe86013]192 <button type="submit" class="comment-delete-btn"><%= t('comments.delete') %></button>
[7bc636b]193 </form>
194 <% } %>
195 </div>
196
197 <!-- Inline reply form (hidden by default) -->
[8afbdd6]198 <% if (user && canMutate) { %>
[b94c08e]199 <form method="post" action="<%= _base %>/comments" class="comment-reply-form" hidden data-reply-form-for="<%= c.id %>">
[7bc636b]200 <input type="hidden" name="post_slug" value="<%= post.slug %>">
201 <input type="hidden" name="parent_comment_id" value="<%= c.id %>">
[fe86013]202 <textarea name="content" rows="3" maxlength="4000" placeholder="<%= t('comments.reply_to', { name: c.author_username }) %>" required></textarea>
[7bc636b]203 <div class="comment-reply-form-actions">
[fe86013]204 <button type="submit" class="btn btn-primary"><%= t('comments.reply') %></button>
205 <button type="button" class="btn comment-cancel-reply"><%= t('comments.cancel') %></button>
[7bc636b]206 </div>
207 </form>
208 <% } %>
209
210 <!-- Replies (1 level deep) -->
211 <% if (c.replies && c.replies.length) { %>
212 <ol class="comment-replies">
213 <% c.replies.forEach(function(r) { %>
214 <li class="comment comment-reply" id="comment-<%= r.id %>">
215 <div class="comment-avatar">
216 <% if (r.author_avatar) { %>
217 <img src="<%= r.author_avatar %>" alt="">
218 <% } else { %>
219 <span><%= r.author_username.charAt(0).toUpperCase() %></span>
220 <% } %>
221 </div>
222 <div class="comment-body">
223 <div class="comment-meta">
224 <a class="comment-author" href="/users/<%= encodeURIComponent(r.author_username) %>"><%= r.author_username %></a>
225 <span class="comment-time"><%= formatDateTime(r.created_at) %></span>
226 </div>
227 <div class="comment-content"><%= r.content %></div>
228 <div class="comment-actions">
[d41f4be]229 <% if (user && canMutate) { %>
230 <button type="button" class="comment-reply-btn" data-reply-to="<%= r.id %>"><%= t('comments.reply') %></button>
231 <% } %>
[7bc636b]232 <% if (user && permissions.canDeleteComment(user, r, site)) { %>
[e25438a]233 <form method="post" action="<%= _base %>/comments/<%= r.id %>/delete" onsubmit="return confirm('<%= t('comments.delete_confirm') %>')">
[fe86013]234 <button type="submit" class="comment-delete-btn"><%= t('comments.delete') %></button>
[7bc636b]235 </form>
236 <% } %>
237 </div>
[d41f4be]238
[834bcc3]239 <%# Replying to a reply — lands (via resolvedParent on the
240 server) in the same thread, so the 1-level depth is preserved. %>
[d41f4be]241 <% if (user && canMutate) { %>
242 <form method="post" action="<%= _base %>/comments" class="comment-reply-form" hidden data-reply-form-for="<%= r.id %>">
243 <input type="hidden" name="post_slug" value="<%= post.slug %>">
244 <input type="hidden" name="parent_comment_id" value="<%= r.id %>">
245 <textarea name="content" rows="3" maxlength="4000" placeholder="<%= t('comments.reply_to', { name: r.author_username }) %>" required></textarea>
246 <div class="comment-reply-form-actions">
247 <button type="submit" class="btn btn-primary"><%= t('comments.reply') %></button>
248 <button type="button" class="btn comment-cancel-reply"><%= t('comments.cancel') %></button>
249 </div>
250 </form>
251 <% } %>
[7bc636b]252 </div>
253 </li>
254 <% }); %>
255 </ol>
256 <% } %>
257 </div>
258 </li>
259 <% }); %>
260 </ol>
261 <% } %>
262
263 <!-- Top-level comment form (only for logged-in users for now) -->
[8afbdd6]264 <% if (user && canMutate) { %>
[b94c08e]265 <form method="post" action="<%= _base %>/comments" class="comment-form">
[7bc636b]266 <input type="hidden" name="post_slug" value="<%= post.slug %>">
267 <label class="comment-form-label">
[fe86013]268 <span><%= t('comments.add_as') %> <strong><%= user.username %></strong></span>
269 <textarea name="content" rows="3" maxlength="4000" placeholder="<%= t('comments.placeholder') %>" required></textarea>
[7bc636b]270 </label>
[fe86013]271 <button type="submit" class="btn btn-primary"><%= t('comments.post') %></button>
[7bc636b]272 </form>
[8afbdd6]273 <% } else if (!user) { %>
[7bc636b]274 <p class="comments-login-cta">
[fe86013]275 <a href="/auth/login?next=<%= encodeURIComponent(_base + '/' + post.slug + '#comments') %>" class="btn"><%= t('comments.login_to_comment') %></a>
[7bc636b]276 </p>
[8afbdd6]277 <% } else { %>
278 <p class="comments-login-cta" style="color:var(--ink-muted)">👁️ Kijker-modus — reageren is uitgeschakeld.</p>
[7bc636b]279 <% } %>
280 </section>
281
282 <!-- Reply form toggle -->
283 <script>
284 (function() {
285 document.querySelectorAll('.comment-reply-btn').forEach(function(btn) {
286 btn.addEventListener('click', function() {
287 var id = btn.dataset.replyTo;
288 var form = document.querySelector('[data-reply-form-for="' + id + '"]');
289 if (form) {
290 form.hidden = !form.hidden;
291 if (!form.hidden) form.querySelector('textarea').focus();
292 }
293 });
294 });
295 document.querySelectorAll('.comment-cancel-reply').forEach(function(btn) {
296 btn.addEventListener('click', function() {
297 btn.closest('.comment-reply-form').hidden = true;
298 });
299 });
300 })();
301 </script>
302
303 <%# ── Related posts (3-card grid) ─────────────────────────────
304 Always shown if at least one related post exists. Selection logic
305 lives server-side: same-tag posts ranked by overlap, falling back
306 to most-recent if there's no tag overlap. %>
307 <% if (relatedPosts && relatedPosts.length > 0) { %>
308 <section class="post-related" aria-labelledby="related-heading">
[fe86013]309 <h2 class="post-related-heading" id="related-heading"><%= t('related.title') %></h2>
[7bc636b]310 <ul class="post-related-grid">
311 <% relatedPosts.forEach(function(rp) { %>
312 <li class="post-related-card">
[d54dade]313 <a href="<%= rp._urlBase || '' %>/<%= rp.slug %>"<% if (!rp._urlBase) { %>
[7bc636b]314 hx-get="/<%= rp.slug %>?partial=1" hx-target="#pcms-main"
[d54dade]315 hx-push-url="/<%= rp.slug %>" hx-indicator="#pcms-loading"<% } %>>
[7bc636b]316 <% if (rp.cover_image_url) { %>
317 <span class="post-related-cover"
318 style="background-image: url('<%= rp.cover_image_url %>')"
319 aria-hidden="true"></span>
320 <% } else { %>
321 <span class="post-related-cover post-related-cover--empty" aria-hidden="true"></span>
322 <% } %>
323 <span class="post-related-meta">
324 <span class="post-related-date"><%= formatDate(rp.published_at) %></span>
325 <span class="post-related-title"><%= rp.title %></span>
326 </span>
327 </a>
328 </li>
329 <% }); %>
330 </ul>
331 </section>
332 <% } %>
333
334</article>
335<style>
[8e29f77]336.post-page { max-width: 720px; margin: 0.5rem auto 2rem; padding: 0 1rem; }
[7bc636b]337.post-cover { margin: 0 0 2rem; border-radius: 8px; overflow: hidden; }
338.post-cover img { width: 100%; height: auto; display: block; }
339.post-header { margin-bottom: 2rem; }
340.post-title { font-family: var(--font-display, serif); font-size: 2.5rem; line-height: 1.1; margin: 0 0 1rem; }
341@media (max-width: 600px) { .post-title { font-size: 1.85rem; } }
342.post-meta { font-size: 0.9rem; color: var(--ink-muted); }
343.author { font-weight: 500; color: var(--ink-soft); text-decoration: none; }
344.author:hover { color: var(--accent); }
345.post-content { font-family: var(--font-body, serif); font-size: 1.1rem; line-height: 1.7; color: var(--ink); }
346.post-content p { margin: 1.2em 0; }
[535f955]347.post-like-row { margin-top: 1rem; }
348.like-btn {
349 display: inline-flex; align-items: center; gap: 0.45rem;
350 padding: 0.4rem 0.85rem; border: 1px solid var(--rule); border-radius: 999px;
351 background: var(--paper-2); color: var(--ink); font: inherit; font-size: 0.95rem;
352 cursor: pointer; text-decoration: none; transition: border-color 120ms, color 120ms, background 120ms;
353}
354.like-btn:hover { border-color: var(--accent); }
355.like-btn .like-heart { font-size: 1.1rem; line-height: 1; }
356.like-btn.is-liked { border-color: var(--accent); color: var(--accent); }
357.like-btn.is-liked .like-heart { color: var(--accent); }
358.like-btn .like-count { font-variant-numeric: tabular-nums; }
[7bc636b]359.post-content h2 { font-family: var(--font-display, serif); font-size: 1.6rem; margin: 2rem 0 1rem; }
360.post-content h3 { font-family: var(--font-display, serif); font-size: 1.3rem; margin: 1.5rem 0 0.75rem; }
361.post-content a { color: var(--accent); }
362.post-content code { background: var(--paper-2); padding: 0.1em 0.4em; border-radius: 3px; font-family: var(--font-mono, monospace); font-size: 0.9em; }
363.post-content pre { background: var(--paper-2); padding: 1rem; border-radius: 6px; overflow-x: auto; }
364.post-content blockquote { border-left: 3px solid var(--accent); padding-left: 1rem; margin: 1.5rem 0; color: var(--ink-soft); font-style: italic; }
365.post-content img { max-width: 100%; height: auto; border-radius: 6px; }
366.post-tags { display: flex; gap: 0.5rem; flex-wrap: wrap; margin: 2rem 0; }
367.tag { background: var(--paper-2); color: var(--ink-soft); padding: 0.2rem 0.6rem; border-radius: 999px; font-size: 0.85rem; text-decoration: none; transition: background 120ms, color 120ms; }
368.tag:hover { background: var(--accent); color: white; }
369.post-actions { display: flex; gap: 0.5rem; margin: 2rem 0; padding: 1rem; background: var(--paper-2); border-radius: 6px; flex-wrap: wrap; }
370
371/* ============================================================
372 Comments
373 ============================================================ */
374.post-comments {
375 margin-top: 3rem;
376 padding-top: 2rem;
377 border-top: 1px solid var(--rule);
378}
379.comments-heading {
380 font-family: var(--font-display, serif);
381 font-size: 1.5rem;
382 margin: 0 0 1.5rem;
383}
384.comments-empty { color: var(--ink-muted, var(--ink-soft)); margin: 0 0 1.5rem; }
385.comments-pending-flash {
386 background: rgba(40, 160, 90, 0.15);
387 color: #2a9d5e;
388 border: 1px solid rgba(40, 160, 90, 0.3);
389 padding: 0.75rem 1rem;
390 border-radius: 6px;
391 margin-bottom: 1rem;
392 font-size: 0.9rem;
393}
394.comments-empty a { color: var(--accent); }
395
396.comments-list, .comment-replies {
397 list-style: none;
398 padding: 0;
399 margin: 0 0 2rem;
400}
401
402.comment {
403 display: flex;
404 gap: 0.85rem;
405 padding: 1rem 0;
406 border-top: 1px solid var(--rule);
407}
408.comments-list > .comment:first-child { border-top: 0; padding-top: 0; }
409
410.comment-avatar {
411 flex-shrink: 0;
412 width: 40px;
413 height: 40px;
414 border-radius: 50%;
415 background: var(--paper-2);
416 border: 1px solid var(--rule);
417 overflow: hidden;
418 display: inline-flex;
419 align-items: center;
420 justify-content: center;
421}
422.comment-avatar img { width: 100%; height: 100%; object-fit: cover; }
423.comment-avatar span {
424 font-family: var(--font-display, serif);
425 font-weight: 700;
426 color: var(--accent);
427}
428
429.comment-body { flex: 1; min-width: 0; }
430.comment-meta {
431 display: flex;
432 gap: 0.5rem;
433 align-items: baseline;
434 font-size: 0.85rem;
435 margin-bottom: 0.3rem;
436}
437.comment-author { color: var(--ink); font-weight: 600; text-decoration: none; }
438.comment-author:hover { color: var(--accent); }
439.comment-time { color: var(--ink-muted, var(--ink-soft)); }
440.comment-content {
441 white-space: pre-wrap;
442 word-wrap: break-word;
443 color: var(--ink);
444 line-height: 1.5;
445 font-size: 0.95rem;
446}
447
448.comment-actions {
449 display: flex;
450 gap: 0.5rem;
451 margin-top: 0.4rem;
452}
453.comment-reply-btn, .comment-delete-btn {
454 background: none;
455 border: 0;
456 color: var(--ink-muted, var(--ink-soft));
457 font-size: 0.8rem;
458 cursor: pointer;
459 padding: 0.2rem 0;
460 font-family: inherit;
461 text-transform: uppercase;
462 letter-spacing: 0.04em;
463}
464.comment-reply-btn:hover { color: var(--accent); }
465.comment-delete-btn:hover { color: #c33; }
466
467.comment-replies {
468 margin-top: 1rem;
469 margin-left: 0;
470 border-left: 2px solid var(--rule);
471 padding-left: 1rem;
472}
473.comment-reply { padding: 0.75rem 0; }
474.comment-replies > .comment-reply:first-child { border-top: 0; padding-top: 0; }
475.comment-reply .comment-avatar { width: 32px; height: 32px; }
476.comment-reply .comment-avatar span { font-size: 0.85rem; }
477
478.comment-form, .comment-reply-form {
479 display: flex;
480 flex-direction: column;
481 gap: 0.6rem;
482 background: var(--paper-2);
483 border: 1px solid var(--rule);
484 border-radius: 8px;
485 padding: 1rem;
486 margin-top: 1rem;
487}
488.comment-form-label { display: flex; flex-direction: column; gap: 0.4rem; }
489.comment-form-label > span { font-size: 0.85rem; color: var(--ink-muted, var(--ink-soft)); }
490.comment-form textarea, .comment-reply-form textarea {
491 width: 100%;
492 resize: vertical;
493 min-height: 70px;
494 padding: 0.65rem 0.85rem;
495 border: 1px solid var(--rule);
496 border-radius: 5px;
497 background: var(--paper);
498 color: var(--ink);
499 font-family: inherit;
500 font-size: 0.95rem;
501 line-height: 1.4;
502 box-sizing: border-box;
503}
504.comment-form button { align-self: flex-start; }
505.comment-reply-form-actions { display: flex; gap: 0.5rem; }
[834bcc3]506/* Show the reply form only AFTER clicking REPLY. The display:flex above would
507 otherwise override the [hidden] attribute → form was always open. */
[e25438a]508.comment-reply-form[hidden] { display: none; }
[834bcc3]509/* DELETE button is inside a <form>; display:contents makes it a direct flex-sibling
510 of REPLY → pixel-perfect alignment + equal gap. */
[e25438a]511.comment-actions form { display: contents; }
[7bc636b]512
513.comments-login-cta { margin-top: 1.5rem; text-align: center; }
514
515/* ─── Related posts (3-card grid above pinned-nav) ─────────────── */
516.post-related {
517 margin: 3rem auto 1.5rem;
518 /* Break out of the 720px article column so the cards have room to breathe.
519 Cap at 1100 to match other wide content. */
520 max-width: 1100px;
521 padding: 0 1rem;
522}
523.post-related-heading {
524 font-family: var(--font-ui, system-ui), sans-serif;
525 font-size: 0.75rem;
526 font-weight: 600;
527 text-transform: uppercase;
528 letter-spacing: 0.15em;
529 color: var(--ink-muted, var(--ink-soft));
530 margin: 0 0 1rem;
531}
532.post-related-grid {
533 list-style: none;
534 padding: 0; margin: 0;
535 display: grid;
536 grid-template-columns: repeat(3, minmax(0, 1fr));
537 gap: 1rem;
538}
539@media (max-width: 880px) { .post-related-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); } }
540@media (max-width: 540px) { .post-related-grid { grid-template-columns: 1fr; } }
541
542.post-related-card {
543 background: var(--paper);
544 border: 1px solid var(--rule);
545 border-radius: 10px;
546 overflow: hidden;
547 transition: border-color 150ms, transform 150ms;
548}
549.post-related-card:hover { border-color: var(--accent); transform: translateY(-2px); }
550.post-related-card a {
551 display: block;
552 color: inherit;
553 text-decoration: none;
554}
555.post-related-cover {
556 display: block;
557 aspect-ratio: 16 / 10;
558 background: var(--paper-2) center/cover no-repeat;
559}
560.post-related-cover--empty {
561 background-image: linear-gradient(135deg,
562 color-mix(in srgb, var(--accent) 12%, var(--paper-2)),
563 var(--paper-2));
564}
565.post-related-meta {
566 display: flex; flex-direction: column; gap: 0.4rem;
567 padding: 0.85rem 1rem 1.1rem;
568}
569.post-related-date {
570 font-size: 0.78rem;
571 color: var(--ink-muted, var(--ink-soft));
572 font-variant-numeric: tabular-nums;
573}
574.post-related-title {
575 font-family: var(--font-display, serif);
576 font-size: 1.1rem;
577 font-weight: 600;
578 color: var(--ink);
579 line-height: 1.25;
580}
581
582/* ─── Pinned navigation cards (v9 style) ───────────────────────── */
583.post-pinned-nav {
584 display: grid;
585 grid-template-columns: 1fr 1fr;
586 gap: 1rem;
587 margin: 1.5rem auto 2rem;
588 max-width: 1100px;
589 padding: 0 1rem;
590}
591@media (max-width: 600px) {
592 .post-pinned-nav { grid-template-columns: 1fr; }
593}
594
595.post-pinned-card {
596 display: flex; align-items: center; gap: 1rem;
597 padding: 1rem 1.25rem;
598 border: 1.5px solid color-mix(in srgb, var(--accent) 50%, transparent);
599 background: color-mix(in srgb, var(--accent) 6%, var(--paper));
600 border-radius: 10px;
601 text-decoration: none;
602 color: var(--ink);
603 transition: border-color 150ms, background 150ms, transform 150ms;
604 min-height: 88px;
605}
606.post-pinned-card:hover {
607 border-color: var(--accent);
608 background: color-mix(in srgb, var(--accent) 11%, var(--paper));
609 transform: translateY(-2px);
610}
611.post-pinned-arrow {
612 font-size: 1.6rem;
613 color: var(--accent);
614 flex-shrink: 0;
615 line-height: 1;
616}
617.post-pinned-card--next {
618 /* "Volgende" card aligns text to the right with the arrow on the right
619 side, mirroring v9 — the arrow is on the OPPOSITE end from the body. */
620 flex-direction: row-reverse;
621 text-align: right;
622}
623.post-pinned-body {
624 display: flex; flex-direction: column; gap: 0.25rem;
625 flex: 1; min-width: 0;
626}
627.post-pinned-label {
628 font-family: var(--font-ui, system-ui), sans-serif;
629 font-size: 0.7rem; font-weight: 600;
630 text-transform: uppercase;
631 letter-spacing: 0.1em;
632 color: var(--accent);
633}
634.post-pinned-title {
635 font-family: var(--font-display, serif);
636 font-size: 1.05rem; font-weight: 600;
637 color: var(--ink);
638 line-height: 1.25;
639 overflow: hidden;
640 text-overflow: ellipsis;
641 display: -webkit-box;
642 -webkit-line-clamp: 2;
643 -webkit-box-orient: vertical;
644}
645.post-pinned-cta {
646 font-size: 0.78rem;
647 color: var(--accent);
648 font-weight: 500;
649}
650
651/* Edge-of-stack indicator: dashed border, muted styling. */
652.post-pinned-card--edge {
653 flex-direction: column;
654 align-items: flex-start;
655 justify-content: center;
656 gap: 0.35rem;
657 border-style: dashed;
658 background: transparent;
659 color: var(--ink-muted, var(--ink-soft));
660 cursor: default;
661}
662.post-pinned-card--edge:hover {
663 /* Edge cards aren't clickable — undo the lift effect. */
664 transform: none;
665 border-color: color-mix(in srgb, var(--accent) 50%, transparent);
666 background: transparent;
667}
668.post-pinned-edge-label {
669 font-family: var(--font-ui, system-ui), sans-serif;
670 font-size: 0.72rem;
671 font-weight: 600;
672 text-transform: uppercase;
673 letter-spacing: 0.15em;
674 color: var(--accent);
675}
676.post-pinned-edge-title {
677 font-family: var(--font-display, serif);
678 font-size: 1.05rem;
679 font-weight: 600;
680 color: var(--ink);
681}
682</style>
Note: See TracBrowser for help on using the repository browser.