source: Klonkt/src/views/pages/post.ejs@ f2fff7e

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

style(activitypub): reply field replaces the button in place (no layout jump)

The address form now hides the button and takes its spot (restored on cancel),
instead of appearing as an extra element that reflows the page.

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

  • Property mode set to 100644
File size: 19.5 KB
Line 
1<%
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 = ''.
5const _base = (typeof siteUrlBase !== 'undefined' && siteUrlBase) ? siteUrlBase : '';
6%>
7<article class="container post-page">
8
9 <%# Navigation across ALL posts — ABOVE the post (shared partial). %>
10 <%- include('../partials/post-nav', { newerPost: newerPost, olderPost: olderPost }) %>
11
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>
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 <% } %>
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 => { %>
47 <a class="tag" href="<%= _base %>/tag/<%= encodeURIComponent(t) %>"
48 hx-get="<%= _base %>/tag/<%= encodeURIComponent(t) %>?partial=1"
49 hx-target="#pcms-main" hx-swap="innerHTML"
50 hx-push-url="<%= _base %>/tag/<%= encodeURIComponent(t) %>"
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)) { %>
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">
63 ✏️ Edit
64 </a>
65 <% } %>
66 <% if (permissions.canDeletePost(user, post, site)) { %>
67 <form method="post" action="<%= _base %>/posts/<%= post.slug %>/delete" style="display:inline" onsubmit="return confirm('Delete this post?')">
68 <button type="submit" class="btn btn-danger">🗑 Delete</button>
69 </form>
70 <% } %>
71 </aside>
72 <% } %>
73
74 <!-- Fediverse interactions (threaded: inbound replies/likes/boosts + our replies) -->
75 <% if (typeof fediverse !== 'undefined' && fediverse && fediverse.total > 0) { %>
76 <section class="post-fediverse post-comments" id="fediverse">
77 <h2 class="comments-heading"><%= t('fedi.heading') %></h2>
78 <p class="fedi-stats">
79 <span title="<%= t('fedi.likes') %>">⭐ <%= fediverse.likeCount %></span>
80 <span title="<%= t('fedi.boosts') %>">🔁 <%= fediverse.announceCount %></span>
81 <span title="<%= t('fedi.replies') %>">💬 <%= (fediverse.thread || []).length %></span>
82 </p>
83 <% if (fediverse.thread && fediverse.thread.length) { %>
84 <ol class="comments-list">
85 <% fediverse.thread.forEach(function(n){ %>
86 <li class="comment"><%- include('../partials/fedi-node', { n: n, t: t, canManageSite: (typeof canManageSite !== 'undefined' ? canManageSite : false), _base: _base, siteAvatar: (typeof siteAvatar !== 'undefined' ? siteAvatar : null), formatDateTime: formatDateTime }) %></li>
87 <% }); %>
88 </ol>
89 <% } %>
90 </section>
91 <% } %>
92
93 <!-- Reply via the fediverse (remote interaction: bounces to the visitor's own server) -->
94 <% var _postAbsUrl = (typeof ogOrigin !== 'undefined' && ogOrigin ? ogOrigin : '') + (typeof _base !== 'undefined' && _base ? _base : '') + '/' + post.slug; %>
95 <div class="post-fediverse-cta">
96 <button type="button" class="btn fedi-remote-reply-btn" data-fedi-uri="<%= _postAbsUrl %>" data-fedi-ph="<%= t('fedi.remote_ph') %>">
97 <svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><polyline points="9 17 4 12 9 7"/><path d="M20 18v-2a4 4 0 0 0-4-4H4"/></svg>
98 <%= t('fedi.remote_reply') %>
99 </button>
100 </div>
101 <script>
102 (function(){
103 if (window.__fediRemoteWired) return; window.__fediRemoteWired = true;
104 function go(raw, uri){
105 var d = (raw||'').trim().replace(/^@?[^@\s]*@/, '').replace(/^https?:\/\//i, '').replace(/\/.*$/, '').trim();
106 if (d) location.href = 'https://' + d + '/authorize_interaction?uri=' + encodeURIComponent(uri||'');
107 }
108 function closeForm(f){ if (!f) return; if (f._btn) f._btn.style.display = ''; f.remove(); }
109 document.addEventListener('click', function(e){
110 var cancel = e.target.closest && e.target.closest('.fedi-remote-cancel');
111 if (cancel) { closeForm(cancel.closest('.fedi-remote-form')); return; }
112 var b = e.target.closest && e.target.closest('.fedi-remote-reply-btn');
113 if (!b) return;
114 e.preventDefault();
115 closeForm(document.querySelector('.fedi-remote-form')); // close any open one (restores its button)
116 var f = document.createElement('form');
117 f.className = 'fedi-remote-form';
118 f.dataset.uri = b.getAttribute('data-fedi-uri') || '';
119 f._btn = b;
120 f.innerHTML = '<input type="text" autocomplete="off" spellcheck="false">'
121 + '<button type="submit" class="btn btn-primary fedi-remote-go" aria-label="ok">&rarr;</button>'
122 + '<button type="button" class="fedi-remote-cancel" aria-label="x">&times;</button>';
123 var inp = f.querySelector('input');
124 inp.placeholder = b.getAttribute('data-fedi-ph') || '@name@server.social';
125 b.style.display = 'none'; // replace the button in place → no layout jump
126 b.parentNode.insertBefore(f, b.nextSibling);
127 inp.focus();
128 });
129 document.addEventListener('submit', function(e){
130 var f = e.target.closest && e.target.closest('.fedi-remote-form');
131 if (!f) return;
132 e.preventDefault();
133 go(f.querySelector('input').value, f.dataset.uri);
134 });
135 })();
136 </script>
137
138
139 <%# ── Related posts (3-card grid) ─────────────────────────────
140 Always shown if at least one related post exists. Selection logic
141 lives server-side: same-tag posts ranked by overlap, falling back
142 to most-recent if there's no tag overlap. %>
143 <% if (relatedPosts && relatedPosts.length > 0) { %>
144 <section class="post-related" aria-labelledby="related-heading">
145 <h2 class="post-related-heading" id="related-heading"><%= t('related.title') %></h2>
146 <ul class="post-related-grid">
147 <% relatedPosts.forEach(function(rp) { %>
148 <li class="post-related-card">
149 <a href="<%= rp._urlBase || '' %>/<%= rp.slug %>"<% if (!rp._urlBase) { %>
150 hx-get="/<%= rp.slug %>?partial=1" hx-target="#pcms-main"
151 hx-push-url="/<%= rp.slug %>" hx-indicator="#pcms-loading"<% } %>>
152 <% if (rp.cover_image_url) { %>
153 <span class="post-related-cover"
154 style="background-image: url('<%= rp.cover_image_url %>')"
155 aria-hidden="true"></span>
156 <% } else { %>
157 <span class="post-related-cover post-related-cover--empty" aria-hidden="true"></span>
158 <% } %>
159 <span class="post-related-meta">
160 <span class="post-related-date"><%= formatDate(rp.published_at) %></span>
161 <span class="post-related-title"><%= rp.title %></span>
162 </span>
163 </a>
164 </li>
165 <% }); %>
166 </ul>
167 </section>
168 <% } %>
169
170</article>
171<style>
172.post-page { max-width: 720px; margin: 0.5rem auto 2rem; padding: 0 1rem; }
173.post-cover { margin: 0 0 2rem; border-radius: 8px; overflow: hidden; }
174.post-cover img { width: 100%; height: auto; display: block; }
175.post-header { margin-bottom: 2rem; }
176.post-title { font-family: var(--font-display, serif); font-size: 2.5rem; line-height: 1.1; margin: 0 0 1rem; }
177@media (max-width: 600px) { .post-title { font-size: 1.85rem; } }
178.post-meta { font-size: 0.9rem; color: var(--ink-muted); }
179.author { font-weight: 500; color: var(--ink-soft); text-decoration: none; }
180.author:hover { color: var(--accent); }
181.post-content { font-family: var(--font-body, serif); font-size: 1.1rem; line-height: 1.7; color: var(--ink); }
182.post-content p { margin: 1.2em 0; }
183.post-like-row { margin-top: 1rem; }
184.like-btn {
185 display: inline-flex; align-items: center; gap: 0.45rem;
186 padding: 0.4rem 0.85rem; border: 1px solid var(--rule); border-radius: 999px;
187 background: var(--paper-2); color: var(--ink); font: inherit; font-size: 0.95rem;
188 cursor: pointer; text-decoration: none; transition: border-color 120ms, color 120ms, background 120ms;
189}
190.like-btn:hover { border-color: var(--accent); }
191.like-btn .like-heart { font-size: 1.1rem; line-height: 1; }
192.like-btn.is-liked { border-color: var(--accent); color: var(--accent); }
193.like-btn.is-liked .like-heart { color: var(--accent); }
194.like-btn .like-count { font-variant-numeric: tabular-nums; }
195.post-content h2 { font-family: var(--font-display, serif); font-size: 1.6rem; margin: 2rem 0 1rem; }
196.post-content h3 { font-family: var(--font-display, serif); font-size: 1.3rem; margin: 1.5rem 0 0.75rem; }
197.post-content a { color: var(--accent); }
198.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; }
199.post-content pre { background: var(--paper-2); padding: 1rem; border-radius: 6px; overflow-x: auto; }
200.post-content blockquote { border-left: 3px solid var(--accent); padding-left: 1rem; margin: 1.5rem 0; color: var(--ink-soft); font-style: italic; }
201.post-content img { max-width: 100%; height: auto; border-radius: 6px; }
202.post-tags { display: flex; gap: 0.5rem; flex-wrap: wrap; margin: 2rem 0; }
203.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; }
204.tag:hover { background: var(--accent); color: white; }
205.post-actions { display: flex; gap: 0.5rem; margin: 2rem 0; padding: 1rem; background: var(--paper-2); border-radius: 6px; flex-wrap: wrap; }
206
207/* ============================================================
208 Comments
209 ============================================================ */
210.post-comments {
211 margin-top: 3rem;
212 padding-top: 2rem;
213 border-top: 1px solid var(--rule);
214}
215.comments-heading {
216 font-family: var(--font-display, serif);
217 font-size: 1.5rem;
218 margin: 0 0 1.5rem;
219}
220.comments-empty { color: var(--ink-muted, var(--ink-soft)); margin: 0 0 1.5rem; }
221.comments-pending-flash {
222 background: rgba(40, 160, 90, 0.15);
223 color: #2a9d5e;
224 border: 1px solid rgba(40, 160, 90, 0.3);
225 padding: 0.75rem 1rem;
226 border-radius: 6px;
227 margin-bottom: 1rem;
228 font-size: 0.9rem;
229}
230.comments-empty a { color: var(--accent); }
231
232.comments-list, .comment-replies {
233 list-style: none;
234 padding: 0;
235 margin: 0 0 2rem;
236}
237
238.comment {
239 display: flex;
240 gap: 0.85rem;
241 padding: 1rem 0;
242 border-top: 1px solid var(--rule);
243}
244.comments-list > .comment:first-child { border-top: 0; padding-top: 0; }
245
246.comment-avatar {
247 flex-shrink: 0;
248 width: 40px;
249 height: 40px;
250 border-radius: 50%;
251 background: var(--paper-2);
252 border: 1px solid var(--rule);
253 overflow: hidden;
254 display: inline-flex;
255 align-items: center;
256 justify-content: center;
257}
258.comment-avatar img { width: 100%; height: 100%; object-fit: cover; }
259.comment-avatar span {
260 font-family: var(--font-display, serif);
261 font-weight: 700;
262 color: var(--accent);
263}
264
265.comment-body { flex: 1; min-width: 0; }
266.comment-meta {
267 display: flex;
268 gap: 0.5rem;
269 align-items: baseline;
270 font-size: 0.85rem;
271 margin-bottom: 0.3rem;
272}
273.comment-author { color: var(--ink); font-weight: 600; text-decoration: none; }
274.comment-author:hover { color: var(--accent); }
275.comment-time { color: var(--ink-muted, var(--ink-soft)); }
276.comment-content {
277 white-space: pre-wrap;
278 word-wrap: break-word;
279 color: var(--ink);
280 line-height: 1.5;
281 font-size: 0.95rem;
282}
283
284.comment-actions {
285 display: flex;
286 gap: 0.5rem;
287 margin-top: 0.4rem;
288}
289.comment-reply-btn, .comment-delete-btn {
290 background: none;
291 border: 0;
292 color: var(--ink-muted, var(--ink-soft));
293 font-size: 0.8rem;
294 cursor: pointer;
295 padding: 0.2rem 0;
296 font-family: inherit;
297 text-transform: uppercase;
298 letter-spacing: 0.04em;
299}
300.comment-reply-btn:hover { color: var(--accent); }
301.comment-delete-btn:hover { color: #c33; }
302
303.comment-replies {
304 margin-top: 1rem;
305 margin-left: 0;
306 border-left: 2px solid var(--rule);
307 padding-left: 1rem;
308}
309.comment-reply { padding: 0.75rem 0; }
310.comment-replies > .comment-reply:first-child { border-top: 0; padding-top: 0; }
311.comment-reply .comment-avatar { width: 32px; height: 32px; }
312.comment-reply .comment-avatar span { font-size: 0.85rem; }
313
314.comment-form, .comment-reply-form {
315 display: flex;
316 flex-direction: column;
317 gap: 0.6rem;
318 background: var(--paper-2);
319 border: 1px solid var(--rule);
320 border-radius: 8px;
321 padding: 1rem;
322 margin-top: 1rem;
323}
324.comment-form-label { display: flex; flex-direction: column; gap: 0.4rem; }
325.comment-form-label > span { font-size: 0.85rem; color: var(--ink-muted, var(--ink-soft)); }
326.comment-form textarea, .comment-reply-form textarea {
327 width: 100%;
328 resize: vertical;
329 min-height: 70px;
330 padding: 0.65rem 0.85rem;
331 border: 1px solid var(--rule);
332 border-radius: 5px;
333 background: var(--paper);
334 color: var(--ink);
335 font-family: inherit;
336 font-size: 0.95rem;
337 line-height: 1.4;
338 box-sizing: border-box;
339}
340.comment-form button { align-self: flex-start; }
341.comment-reply-form-actions { display: flex; gap: 0.5rem; }
342/* Show the reply form only AFTER clicking REPLY. The display:flex above would
343 otherwise override the [hidden] attribute → form was always open. */
344.comment-reply-form[hidden] { display: none; }
345/* DELETE button is inside a <form>; display:contents makes it a direct flex-sibling
346 of REPLY → pixel-perfect alignment + equal gap. */
347.comment-actions form { display: contents; }
348
349.comments-login-cta { margin-top: 1.5rem; text-align: center; }
350
351/* ─── Related posts (3-card grid above pinned-nav) ─────────────── */
352.post-related {
353 margin: 3rem auto 1.5rem;
354 /* Break out of the 720px article column so the cards have room to breathe.
355 Cap at 1100 to match other wide content. */
356 max-width: 1100px;
357 padding: 0 1rem;
358}
359.post-related-heading {
360 font-family: var(--font-ui, system-ui), sans-serif;
361 font-size: 0.75rem;
362 font-weight: 600;
363 text-transform: uppercase;
364 letter-spacing: 0.15em;
365 color: var(--ink-muted, var(--ink-soft));
366 margin: 0 0 1rem;
367}
368.post-related-grid {
369 list-style: none;
370 padding: 0; margin: 0;
371 display: grid;
372 grid-template-columns: repeat(3, minmax(0, 1fr));
373 gap: 1rem;
374}
375@media (max-width: 880px) { .post-related-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); } }
376@media (max-width: 540px) { .post-related-grid { grid-template-columns: 1fr; } }
377
378.post-related-card {
379 background: var(--paper);
380 border: 1px solid var(--rule);
381 border-radius: 10px;
382 overflow: hidden;
383 transition: border-color 150ms, transform 150ms;
384}
385.post-related-card:hover { border-color: var(--accent); transform: translateY(-2px); }
386.post-related-card a {
387 display: block;
388 color: inherit;
389 text-decoration: none;
390}
391.post-related-cover {
392 display: block;
393 aspect-ratio: 16 / 10;
394 background: var(--paper-2) center/cover no-repeat;
395}
396.post-related-cover--empty {
397 background-image: linear-gradient(135deg,
398 color-mix(in srgb, var(--accent) 12%, var(--paper-2)),
399 var(--paper-2));
400}
401.post-related-meta {
402 display: flex; flex-direction: column; gap: 0.4rem;
403 padding: 0.85rem 1rem 1.1rem;
404}
405.post-related-date {
406 font-size: 0.78rem;
407 color: var(--ink-muted, var(--ink-soft));
408 font-variant-numeric: tabular-nums;
409}
410.post-related-title {
411 font-family: var(--font-display, serif);
412 font-size: 1.1rem;
413 font-weight: 600;
414 color: var(--ink);
415 line-height: 1.25;
416}
417
418/* ─── Pinned navigation cards (v9 style) ───────────────────────── */
419.post-pinned-nav {
420 display: grid;
421 grid-template-columns: 1fr 1fr;
422 gap: 1rem;
423 margin: 1.5rem auto 2rem;
424 max-width: 1100px;
425 padding: 0 1rem;
426}
427@media (max-width: 600px) {
428 .post-pinned-nav { grid-template-columns: 1fr; }
429}
430
431.post-pinned-card {
432 display: flex; align-items: center; gap: 1rem;
433 padding: 1rem 1.25rem;
434 border: 1.5px solid color-mix(in srgb, var(--accent) 50%, transparent);
435 background: color-mix(in srgb, var(--accent) 6%, var(--paper));
436 border-radius: 10px;
437 text-decoration: none;
438 color: var(--ink);
439 transition: border-color 150ms, background 150ms, transform 150ms;
440 min-height: 88px;
441}
442.post-pinned-card:hover {
443 border-color: var(--accent);
444 background: color-mix(in srgb, var(--accent) 11%, var(--paper));
445 transform: translateY(-2px);
446}
447.post-pinned-arrow {
448 font-size: 1.6rem;
449 color: var(--accent);
450 flex-shrink: 0;
451 line-height: 1;
452}
453.post-pinned-card--next {
454 /* "Volgende" card aligns text to the right with the arrow on the right
455 side, mirroring v9 — the arrow is on the OPPOSITE end from the body. */
456 flex-direction: row-reverse;
457 text-align: right;
458}
459.post-pinned-body {
460 display: flex; flex-direction: column; gap: 0.25rem;
461 flex: 1; min-width: 0;
462}
463.post-pinned-label {
464 font-family: var(--font-ui, system-ui), sans-serif;
465 font-size: 0.7rem; font-weight: 600;
466 text-transform: uppercase;
467 letter-spacing: 0.1em;
468 color: var(--accent);
469}
470.post-pinned-title {
471 font-family: var(--font-display, serif);
472 font-size: 1.05rem; font-weight: 600;
473 color: var(--ink);
474 line-height: 1.25;
475 overflow: hidden;
476 text-overflow: ellipsis;
477 display: -webkit-box;
478 -webkit-line-clamp: 2;
479 -webkit-box-orient: vertical;
480}
481.post-pinned-cta {
482 font-size: 0.78rem;
483 color: var(--accent);
484 font-weight: 500;
485}
486
487/* Edge-of-stack indicator: dashed border, muted styling. */
488.post-pinned-card--edge {
489 flex-direction: column;
490 align-items: flex-start;
491 justify-content: center;
492 gap: 0.35rem;
493 border-style: dashed;
494 background: transparent;
495 color: var(--ink-muted, var(--ink-soft));
496 cursor: default;
497}
498.post-pinned-card--edge:hover {
499 /* Edge cards aren't clickable — undo the lift effect. */
500 transform: none;
501 border-color: color-mix(in srgb, var(--accent) 50%, transparent);
502 background: transparent;
503}
504.post-pinned-edge-label {
505 font-family: var(--font-ui, system-ui), sans-serif;
506 font-size: 0.72rem;
507 font-weight: 600;
508 text-transform: uppercase;
509 letter-spacing: 0.15em;
510 color: var(--accent);
511}
512.post-pinned-edge-title {
513 font-family: var(--font-display, serif);
514 font-size: 1.05rem;
515 font-weight: 600;
516 color: var(--ink);
517}
518</style>
Note: See TracBrowser for help on using the repository browser.