source: Klonkt/src/views/partials/post-card.ejs@ c581e5e

main
Last change on this file since c581e5e was 6dd26e5, checked in by Robin <roboburr@…>, 6 weeks ago

De audio-tegel opgevrolijkt: golfvorm als thumbnail en een echte titel

Een audiopost uit Shaer was op Klonkt een kale gradient met (untitled)
(Robins schermafdruk, 30-7). Twee dingen tegelijk gefixt.

De thumbnail: de upload-leg tekent bij audio nu een golfvorm met de
gebundelde ffmpeg (showwavespic, wit op transparant) naar
<naam>.poster.png, precies zoals video zijn stilstaand beeld krijgt. Die
golfvorm rijdt mee als data-poster op de audio-tag (voor de tegels), in
c2s_attachments en als AS2-icon op het gefedereerde Audio-attachment. De
tegel toont hem OVER de eigen hue-gradient, dus elke audiopost houdt
zijn kleur en krijgt zijn vorm.

De titel: C2S-posts hebben geen titel, dus elke Shaer-post toonde
(untitled). Tegels en kaarten leiden de titel nu af: eerst de eigen
woorden van de post (afgekapt op 64), anders een vriendelijk typelabel
(muzieknoot audio, pijl video), en pas als er echt niets is (untitled).
Een tegel die zijn beeld al toont forceert geen label meer.

Changed files:
src/routes/activitypub.js

  • uploadMedia: audio/* -> showwavespic-golfvorm naar .poster.png (best-effort, zelfde patroon als de videoposter)

src/services/ActivityPubService.js

  • c2sCreatePost: posterExt per type (video .jpg, audio .png); de audio-tag krijgt data-poster

src/views/partials/post-tile.ejs

  • audio-detectie + golfvorm over de gradient; titelcascade (titel -> tekst -> typelabel)

src/views/partials/post-card.ejs

  • zelfde spiegel: golfvorm als cover met eigen accent-gradient, zelfde titelcascade

src/assets/css/style.css

  • .grid-tile-wave: contain, padding, transparant op de gradient

test/c2s-compose.test.js

  • audio-golfvormketen: data-poster op de tag, poster in de store, icon op het gefedereerde attachment

remarks: bestaande audioposts kregen hun upload voor deze code en hebben
dus nog geen golfvorm-bestand; die tonen nu wel het muzieknoot-label. Een
verse audiopost laat het hele effect zien. ffmpeg-commando en beide
templates hier lokaal gesmoke-test (RGBA-png, render ok), 345 tests
groen.

-robo
Co-Authored-By: Claude Opus 4.8 <noreply@…>

  • Property mode set to 100644
File size: 12.2 KB
Line 
1<%
2// Post list item — MOBILE-FIRST.
3//
4// <768px (default):
5// Vertical stack — full-bleed 16:9 cover on top, content below.
6// Whole card is tappable (one big <a> wrapping everything).
7// Active state: scale(0.985) + opacity dim.
8//
9// ≥768px (desktop):
10// v9 pattern — 120px square cover left, content right.
11// Hover state on cover. Tap-target stays large.
12
13const _base = (typeof siteUrlBase !== 'undefined' && siteUrlBase) ? siteUrlBase : '';
14const _external = post.external_url || null;
15const _href = _external || (_base + '/' + post.slug);
16const _ext = !!_external;
17const _src = post.source_name || '';
18const _realCover = !!post.cover_image_url || !!post.cover_video_url;
19// Content-derived fallback (see post-tile.ejs): a C2S post's media lives in
20// its content, and the card fronts it from there.
21let _cVideo = null, _cPoster = null, _cImg = null, _cAudio = false, _cWave = null;
22if (!_realCover && post.content) {
23 const vm = String(post.content).match(/<video[^>]*\ssrc="([^"]+)"[^>]*>/i);
24 if (vm) { _cVideo = vm[1]; const pm = vm[0].match(/poster="([^"]+)"/i); if (pm) _cPoster = pm[1]; }
25 else {
26 const im = String(post.content).match(/<img[^>]*\ssrc="([^"]+)"/i);
27 if (im) _cImg = im[1];
28 else {
29 // An audio post (Robins vraag, 30-7): the upload leg drew a waveform
30 // (data-poster on the tag); the card fronts it like a cover.
31 const am = String(post.content).match(/<audio[^>]*>/i);
32 if (am) { _cAudio = true; const wm = am[0].match(/data-poster="([^"]+)"/i); if (wm) _cWave = wm[1]; }
33 }
34 }
35}
36const _hasCover = _realCover || !!_cVideo || !!_cImg || !!_cWave;
37// A C2S post has no title: fall back to the post's own words, then to a
38// friendly type label instead of "(zonder titel)".
39let _title = post.title || '';
40if (!_title && post.content) {
41 const _txt = String(post.content).replace(/<[^>]*>/g, ' ').replace(/&[a-zA-Z#0-9]+;/g, ' ').replace(/\s+/g, ' ').trim();
42 if (_txt) _title = _txt.length > 64 ? _txt.slice(0, 63).replace(/\s+\S*$/, '') + '…' : _txt;
43}
44if (!_title) _title = _cAudio ? '♪ audio' : _cVideo ? '▶ video' : '(zonder titel)';
45const _typeLabel = (post.type && post.type !== 'overig' && post.type !== 'post') ? post.type : '';
46const _isPinned = !!post.pinned;
47const _isBoost = !!post.isBoost;
48const _isDraft = post.status && post.status !== 'published';
49
50function _ddmmyyyy(iso) {
51 if (!iso) return '';
52 const d = new Date(iso);
53 if (isNaN(d.getTime())) return '';
54 const pad = n => String(n).padStart(2, '0');
55 return pad(d.getDate()) + '-' + pad(d.getMonth() + 1) + '-' + d.getFullYear();
56}
57
58let _tags = [];
59if (post.tags) {
60 if (Array.isArray(post.tags)) _tags = post.tags;
61 else if (typeof post.tags === 'string') {
62 try {
63 const parsed = JSON.parse(post.tags);
64 _tags = Array.isArray(parsed) ? parsed : post.tags.split(',').map(t => t.trim()).filter(Boolean);
65 } catch (e) {
66 _tags = post.tags.split(',').map(t => t.trim()).filter(Boolean);
67 }
68 }
69}
70%>
71<li class="post-list-item<%= (_hasCover || post.nsfw) ? ' has-cover' : '' %><%= (_isPinned || _isBoost) ? ' is-pinned' : '' %>">
72
73 <% if (_hasCover) { %>
74 <a class="post-list-cover<%= post.nsfw ? ' nsfw-media' : '' %>" href="<%= _href %>"
75 <% if (_ext) { %>target="_blank" rel="noopener"<% } else { %>hx-get="<%= _href %>?partial=1" hx-target="#pcms-main" hx-swap="innerHTML" hx-push-url="<%= _href %>" hx-indicator="#pcms-loading"<% } %>
76 aria-label="<%= _title %>" tabindex="-1">
77 <% if (post.cover_image_url) { %><img src="<%= thumb(post.cover_image_url, 480) %>"
78 srcset="<%= thumb(post.cover_image_url, 320) %> 320w, <%= thumb(post.cover_image_url, 640) %> 640w, <%= thumb(post.cover_image_url, 1280) %> 1280w"
79 sizes="(min-width: 768px) 120px, 100vw"
80 alt="" loading="lazy" decoding="async"<% if (post.cover_video_url) { %> data-ios-mp4="<%= post.cover_video_url %>"<% } %>>
81 <% } else if (post.cover_video_url) { %><video src="<%= post.cover_video_url %>" poster="<%= thumb(post.cover_video_url, 480) %>" autoplay loop muted playsinline></video><% } else if (_cVideo) { %><video src="<%= _cVideo %>"<% if (_cPoster) { %> poster="<%= _cPoster %>"<% } %> autoplay loop muted playsinline preload="metadata"></video><% } else if (_cImg) { %><img src="<%= _cImg %>" alt="" loading="lazy" decoding="async"><% } else if (_cWave) { %><img class="post-cover-wave" src="<%= _cWave %>" alt="" loading="lazy" decoding="async"><% } %>
82 <% if (post.nsfw) { %><span class="nsfw-veil"><%- include('nsfw-veil', { cw: post.content_warning }) %></span><% } %>
83 </a>
84 <% } else if (post.nsfw) { %>
85 <a class="post-list-cover nsfw-media" href="<%= _href %>"
86 <% if (_ext) { %>target="_blank" rel="noopener"<% } else { %>hx-get="<%= _href %>?partial=1" hx-target="#pcms-main" hx-swap="innerHTML" hx-push-url="<%= _href %>" hx-indicator="#pcms-loading"<% } %>
87 aria-label="<%= _title %>" tabindex="-1">
88 <span class="nsfw-veil"><%- include('nsfw-veil', { cw: post.content_warning }) %></span>
89 </a>
90 <% } %>
91
92 <div class="post-list-body">
93 <div class="post-list-meta mono">
94 <% if (_isBoost) { %><span class="post-list-pin post-list-boost" aria-label="Boost"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><polyline points="17 1 21 5 17 9"/><path d="M3 11V9a4 4 0 0 1 4-4h14"/><polyline points="7 23 3 19 7 15"/><path d="M21 13v2a4 4 0 0 1-4 4H3"/></svg></span><% } else if (_isPinned) { %><span class="post-list-pin" aria-label="Vastgepind"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.1" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><line x1="12" y1="17" x2="12" y2="22"/><path d="M5 17h14v-1.76a2 2 0 0 0-1.11-1.79l-1.78-.9A2 2 0 0 1 15 10.76V6h1a2 2 0 0 0 0-4H8a2 2 0 0 0 0 4h1v4.76a2 2 0 0 1-1.11 1.79l-1.78.9A2 2 0 0 0 5 15.24z"/></svg></span><% } %>
95 <time datetime="<%= post.published_at || post.created_at %>"><%= _ddmmyyyy(post.published_at || post.created_at) %></time>
96 <% if (_src) { %><span class="post-list-type">&middot; via <%= _src %></span><% } %>
97 <% if (_typeLabel) { %>
98 <span class="post-list-type">· <%= _typeLabel.charAt(0).toUpperCase() + _typeLabel.slice(1) %></span>
99 <% } %>
100 <% if (_isDraft) { %>
101 <span class="post-list-type post-list-draft">· concept</span>
102 <% } %>
103 </div>
104
105 <h3 class="post-list-title">
106 <a href="<%= _href %>"
107 <% if (_ext) { %>target="_blank" rel="noopener"<% } else { %>hx-get="<%= _href %>?partial=1" hx-target="#pcms-main" hx-swap="innerHTML" hx-push-url="<%= _href %>" hx-indicator="#pcms-loading"<% } %>>
108 <%= _title %>
109 </a>
110 </h3>
111
112 <% if (post.excerpt) { %>
113 <p class="post-list-excerpt"><%= post.excerpt %></p>
114 <% } %>
115
116 <% if (_tags.length) { %>
117 <div class="post-list-tags">
118 <% _tags.forEach(function(t) { %>
119 <a href="<%= _base %>/tag/<%= encodeURIComponent(t) %>" class="tag-chip"
120 hx-get="<%= _base %>/tag/<%= encodeURIComponent(t) %>?partial=1" hx-target="#pcms-main"
121 hx-swap="innerHTML" hx-push-url="<%= _base %>/tag/<%= encodeURIComponent(t) %>"
122 hx-indicator="#pcms-loading">#<%= t %></a>
123 <% }); %>
124 </div>
125 <% } %>
126 </div>
127</li>
128
129<style>
130/* ============================================================
131 POST-LIST-ITEM — MOBILE-FIRST OVERRIDE
132 The base v9 styles in /assets/css/style.css use a desktop
133 120×120 grid by default. We flip that: stacked on mobile,
134 side-by-side on ≥768px.
135 ============================================================ */
136
137/* Reset v9's grid on mobile and stack vertically */
138@media (max-width: 767px) {
139 .post-list {
140 gap: 2rem;
141 }
142 .post-list-item {
143 display: flex !important;
144 flex-direction: column;
145 grid-template-columns: none !important;
146 gap: .85rem !important;
147 padding-bottom: 2rem;
148 /* Active feedback for the whole card row */
149 transition: transform 120ms ease, opacity 120ms ease;
150 }
151 .post-list-item:active {
152 transform: scale(0.985);
153 opacity: 0.85;
154 }
155 .post-list-item:last-child {
156 border-bottom: none;
157 padding-bottom: 0;
158 }
159
160 /* Cover: full-bleed of the container, 16:9 ratio, no border-radius
161 on small phones (edge-to-edge feels app-native). 8px radius from 480px+. */
162 .post-list-cover {
163 aspect-ratio: 16 / 9 !important;
164 width: 100% !important;
165 border-radius: 0 !important;
166 /* Negative margin to break out of .container's padding */
167 margin-left: calc(-1 * clamp(1.25rem, 4vw, 2rem));
168 margin-right: calc(-1 * clamp(1.25rem, 4vw, 2rem));
169 width: calc(100% + 2 * clamp(1.25rem, 4vw, 2rem)) !important;
170 /* Accent-gradient placeholder shown while the cover loads (the image fades in over it). */
171 background-image: linear-gradient(135deg,
172 color-mix(in srgb, var(--accent, #888) 22%, var(--paper-2)) 0%,
173 color-mix(in srgb, var(--accent, #888) 6%, var(--paper-2)) 100%);
174 }
175 .post-list-cover img, .post-list-cover video {
176 width: 100%; height: 100%;
177 object-fit: cover;
178 display: block;
179 transition: opacity .4s ease;
180 }
181 .post-list-cover img.is-loading { opacity: 0; }
182
183 /* Re-apply rounded corners on slightly larger phones */
184 @media (min-width: 480px) {
185 .post-list-cover {
186 border-radius: var(--radius) !important;
187 margin-left: 0 !important;
188 margin-right: 0 !important;
189 width: 100% !important;
190 }
191 }
192
193 /* Larger title for mobile readability */
194 .post-list-title {
195 font-size: 1.45rem !important;
196 line-height: 1.2 !important;
197 }
198 .post-list-excerpt {
199 font-size: 1rem !important;
200 color: var(--ink-soft) !important;
201 line-height: 1.55 !important;
202 }
203 .post-list-meta {
204 font-size: .8rem !important;
205 gap: .5rem !important;
206 }
207
208 /* Audio cover (Shaer): white-on-transparent waveform on its own accent
209 gradient, contained so the shape reads at 120px and full-bleed alike. */
210 /* (see also .grid-tile-wave in style.css for the grid tiles) */
211 /* Pinned indicator */
212 .post-list-item.is-pinned .post-list-title a {
213 /* No visual change on title — the ★ in meta is enough */
214 }
215
216 /* Tap targets for tags */
217 .post-list-tags { gap: .4rem; }
218 .tag-chip {
219 min-height: 32px;
220 display: inline-flex;
221 align-items: center;
222 padding: .2rem .65rem;
223 }
224}
225
226/* ============================================================
227 DESKTOP — keep v9's 120px-square pattern (style.css does this)
228 We just enforce the layout here for clarity + override safety.
229 ============================================================ */
230@media (min-width: 768px) {
231 .post-list-item.has-cover {
232 display: grid !important;
233 grid-template-columns: 120px 1fr !important;
234 gap: 1.25rem !important;
235 }
236 .post-list-item:not(.has-cover) {
237 grid-template-columns: 1fr !important;
238 }
239 .post-list-cover {
240 aspect-ratio: 1 !important;
241 border-radius: var(--radius) !important;
242 }
243 /* Hover lift on desktop (touch devices use :active above) */
244 @media (hover: hover) {
245 .post-list-cover {
246 transition: transform 200ms ease;
247 }
248 .post-list-item:hover .post-list-cover {
249 transform: scale(1.02);
250 }
251 }
252}
253
254/* ============================================================
255 AUDIO COVER (shared) — Shaer audio post (Robins vraag, 30-7):
256 the waveform PNG is white on transparent; give it its own accent
257 gradient and contain it so the shape reads at 120px and full-bleed.
258 ============================================================ */
259.post-list-cover .post-cover-wave {
260 width: 100%; height: 100%;
261 object-fit: contain !important;
262 padding: 14% 8%;
263 background-image: linear-gradient(135deg,
264 color-mix(in srgb, var(--accent, #888) 26%, var(--paper-2, var(--paper))) 0%,
265 color-mix(in srgb, var(--accent, #888) 8%, var(--paper-2, var(--paper))) 100%);
266}
267
268/* ============================================================
269 PIN + DRAFT INDICATORS (shared)
270 ============================================================ */
271.post-list-pin {
272 color: var(--accent);
273 margin-right: .35rem;
274 display: inline-flex;
275 align-items: center;
276 vertical-align: middle;
277}
278.post-list-pin svg { width: 13px; height: 13px; display: block; }
279.post-list-boost { color: #2fa85a; }
280.post-list-draft {
281 color: var(--accent);
282 font-style: italic;
283}
284</style>
Note: See TracBrowser for help on using the repository browser.