Changeset 1ecbf71 in Klonkt


Ignore:
Timestamp:
06/26/2026 02:01:26 PM (2 months ago)
Author:
Robin Genis <roboburr@…>
Branches:
main
Children:
297c77d
Parents:
5e3be97
Message:

feat(tijdlijn): graphical redesign + inline embeds (others' media)

  • Redesigned timeline cards: rounded avatars with accent ring, cleaner meta, hover lift, image grid (Mastodon-style), nicer actions, empty state.
  • Render others' media: images (grid), video (<video>), audio (<audio>) — not just images.
  • Inline embeds: detect the first YouTube/Spotify/SoundCloud/Vimeo link in a remote post's content and render a direct iframe (CSP already allows these frame-srcs).
Location:
src
Files:
2 edited

Legend:

Unmodified
Added
Removed
  • src/routes/posts.js

    r5e3be97 r1ecbf71  
    580580
    581581// ==================== FEDIVERSE CLIENT: home timeline + following ====================
     582// Build a direct embed iframe for the first embeddable link (YouTube/Spotify/
     583// SoundCloud/Vimeo) in a remote post's content, so others' media plays inline.
     584function timelineEmbedHtml(html) {
     585  if (!html) return null;
     586  const re = /href=["']([^"']+)["']/gi; let m; const seen = new Set();
     587  while ((m = re.exec(html))) {
     588    const u = m[1]; if (seen.has(u)) continue; seen.add(u);
     589    let p; try { p = AudioEmbedService.detectProvider(u); } catch { p = null; }
     590    if (!p) continue;
     591    if (p.provider === 'youtube') return `<iframe class="tl-embed-frame" src="https://www.youtube-nocookie.com/embed/${p.id}" title="YouTube" loading="lazy" frameborder="0" allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture" allowfullscreen></iframe>`;
     592    if (p.provider === 'spotify') return `<iframe class="tl-embed-frame tl-embed-spotify" src="https://open.spotify.com/embed/${p.type}/${p.id}" title="Spotify" loading="lazy" frameborder="0" allow="encrypted-media"></iframe>`;
     593    if (p.provider === 'soundcloud') return `<iframe class="tl-embed-frame tl-embed-sc" src="https://w.soundcloud.com/player/?url=${encodeURIComponent(p.url)}&color=%23ff5500&visual=false" title="SoundCloud" loading="lazy" frameborder="0" allow="autoplay" scrolling="no"></iframe>`;
     594    if (p.provider === 'vimeo') return `<iframe class="tl-embed-frame" src="https://player.vimeo.com/video/${p.id}" title="Vimeo" loading="lazy" frameborder="0" allow="autoplay; fullscreen; picture-in-picture" allowfullscreen></iframe>`;
     595  }
     596  return null;
     597}
     598
    582599router.get('/tijdlijn', requireSiteManager, (req, res) => {
    583600  const site = res.locals.site;
    584   const timeline = site ? ActivityPubService.getTimeline(site.slug, 60) : [];
     601  const timeline = (site ? ActivityPubService.getTimeline(site.slug, 60) : [])
     602    .map((p) => ({ ...p, embedHtml: timelineEmbedHtml(p.content) }));
    585603  renderPage(req, res, 'pages/timeline', {
    586604    pageTitle: 'Tijdlijn', bodyClass: 'on-special',
  • src/views/pages/timeline.ejs

    r5e3be97 r1ecbf71  
    77
    88  <% if (!timeline || !timeline.length) { %>
    9     <p class="tl-empty"><%= t('tl.empty') %></p>
     9    <div class="tl-empty">
     10      <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><circle cx="18" cy="5" r="3"/><circle cx="6" cy="12" r="3"/><circle cx="18" cy="19" r="3"/><line x1="8.59" y1="13.51" x2="15.42" y2="17.49"/><line x1="15.41" y1="6.51" x2="8.59" y2="10.49"/></svg>
     11      <p><%= t('tl.empty') %></p>
     12    </div>
    1013  <% } else { %>
    1114    <ol class="tl-feed">
    1215      <% timeline.forEach(function(p){ %>
    1316        <li class="tl-item">
    14           <span class="tl-avatar"><% if (p.author_icon) { %><img src="<%= p.author_icon %>" alt="" loading="lazy"><% } else { %><%= (p.author_name || '?').charAt(0).toUpperCase() %><% } %></span>
    15           <div class="tl-itembody">
    16             <div class="tl-meta">
     17          <div class="tl-head">
     18            <span class="tl-avatar"><% if (p.author_icon) { %><img src="<%= p.author_icon %>" alt="" loading="lazy"><% } else { %><%= (p.author_name || '?').charAt(0).toUpperCase() %><% } %></span>
     19            <span class="tl-id">
    1720              <a class="tl-author" href="<%= p.author_url || p.author_uri %>" target="_blank" rel="nofollow noopener"><%= p.author_name %></a>
    1821              <span class="tl-handle"><%= p.author_handle %></span>
    19               <% if (p.published || p.created_at) { %><span class="tl-time"><%= formatDateTime(p.published || p.created_at) %></span><% } %>
     22            </span>
     23            <% if (p.published || p.created_at) { %><time class="tl-time"><%= formatDateTime(p.published || p.created_at) %></time><% } %>
     24          </div>
     25
     26          <div class="tl-content"><%- p.content %></div>
     27
     28          <%
     29            var media = []; try { media = JSON.parse(p.media_json || '[]'); } catch (e) { media = []; }
     30            var imgs = media.filter(function(m){ return m && m.url && (!m.type || /^image\//.test(m.type)); });
     31            var vids = media.filter(function(m){ return m && m.url && m.type && /^video\//.test(m.type); });
     32            var auds = media.filter(function(m){ return m && m.url && m.type && /^audio\//.test(m.type); });
     33          %>
     34          <% if (imgs.length) { %>
     35            <div class="tl-media-grid<%= imgs.length === 1 ? ' is-single' : '' %>" data-n="<%= imgs.length %>">
     36              <% imgs.slice(0, 4).forEach(function(m){ %>
     37                <a class="tl-media-img" href="<%= m.url %>" target="_blank" rel="noopener"><img src="<%= m.url %>" alt="" loading="lazy" decoding="async"></a>
     38              <% }); %>
    2039            </div>
    21             <div class="tl-content"><%- p.content %></div>
    22             <% var media = []; try { media = JSON.parse(p.media_json || '[]'); } catch (e) { media = []; } %>
    23             <% media.filter(function(m){ return !m.type || /^image\//.test(m.type); }).forEach(function(m){ %><img class="tl-media" src="<%= m.url %>" alt="" loading="lazy"><% }); %>
    24             <% if (p.url) { %><p class="tl-orig"><a href="<%= p.url %>" target="_blank" rel="nofollow noopener"><%= t('tl.view_original') %></a></p><% } %>
    25             <div class="tl-actions">
    26               <form method="post" action="/tijdlijn/like" class="tl-act-form"><input type="hidden" name="note" value="<%= p.id %>"><input type="hidden" name="author" value="<%= p.author_uri %>"><button type="submit" class="tl-act" title="<%= t('fedi.likes') %>">⭐</button></form>
    27               <% if (p.boosted) { %>
    28               <form method="post" action="/tijdlijn/unboost" class="tl-act-form"><input type="hidden" name="note" value="<%= p.id %>"><input type="hidden" name="author" value="<%= p.author_uri %>"><button type="submit" class="tl-act tl-act-on" title="<%= t('tl.unboost') %>">🔁</button></form>
    29               <% } else { %>
    30               <form method="post" action="/tijdlijn/boost" class="tl-act-form"><input type="hidden" name="note" value="<%= p.id %>"><input type="hidden" name="author" value="<%= p.author_uri %>"><button type="submit" class="tl-act" title="<%= t('fedi.boosts') %>">🔁</button></form>
    31               <% } %>
    32               <button type="button" class="tl-act fedi-remote-reply-btn" data-fedi-uri="<%= p.id %>" data-fedi-ph="<%= t('fedi.remote_ph') %>" title="<%= t('fedi.remote_reply') %>">↩</button>
    33               <form method="post" action="/blokkeren/add" class="tl-act-form" onsubmit="return confirm('<%= t('tl.block') %>?');"><input type="hidden" name="target" value="<%= p.author_uri %>"><button type="submit" class="tl-act" title="<%= t('tl.block') %>">🚫</button></form>
    34             </div>
     40          <% } %>
     41          <% vids.forEach(function(m){ %><video class="tl-media-video" src="<%= m.url %>" controls preload="metadata" playsinline></video><% }); %>
     42          <% auds.forEach(function(m){ %><audio class="tl-media-audio" src="<%= m.url %>" controls preload="none"></audio><% }); %>
     43
     44          <% if (p.embedHtml) { %><div class="tl-embed"><%- p.embedHtml %></div><% } %>
     45
     46          <% if (p.url) { %><a class="tl-orig" href="<%= p.url %>" target="_blank" rel="nofollow noopener"><%= t('tl.view_original') %></a><% } %>
     47
     48          <div class="tl-actions">
     49            <form method="post" action="/tijdlijn/like" class="tl-act-form"><input type="hidden" name="note" value="<%= p.id %>"><input type="hidden" name="author" value="<%= p.author_uri %>"><button type="submit" class="tl-act" title="<%= t('fedi.likes') %>">⭐</button></form>
     50            <% if (p.boosted) { %>
     51            <form method="post" action="/tijdlijn/unboost" class="tl-act-form"><input type="hidden" name="note" value="<%= p.id %>"><input type="hidden" name="author" value="<%= p.author_uri %>"><button type="submit" class="tl-act tl-act-on" title="<%= t('tl.unboost') %>">🔁</button></form>
     52            <% } else { %>
     53            <form method="post" action="/tijdlijn/boost" class="tl-act-form"><input type="hidden" name="note" value="<%= p.id %>"><input type="hidden" name="author" value="<%= p.author_uri %>"><button type="submit" class="tl-act" title="<%= t('fedi.boosts') %>">🔁</button></form>
     54            <% } %>
     55            <button type="button" class="tl-act fedi-remote-reply-btn" data-fedi-uri="<%= p.id %>" data-fedi-ph="<%= t('fedi.remote_ph') %>" title="<%= t('fedi.remote_reply') %>">↩</button>
     56            <form method="post" action="/blokkeren/add" class="tl-act-form" onsubmit="return confirm('<%= t('tl.block') %>?');"><input type="hidden" name="target" value="<%= p.author_uri %>"><button type="submit" class="tl-act tl-act-block" title="<%= t('tl.block') %>">🚫</button></form>
    3557          </div>
    3658        </li>
     
    4163
    4264<style>
    43   .tl-wrap { max-width: 640px; margin: 2rem auto; padding: 0 1rem; }
    44   .fedi-back { margin: 0 0 1.25rem; }
    45   .tl-title { margin: 0 0 .25rem; }
    46   .tl-lead { color: var(--ink-soft, #888); margin: 0 0 1.25rem; }
    47   .tl-sub { font-size: 1.1rem; margin: 1.75rem 0 .75rem; }
    48   .tl-follow { display: flex; gap: .5rem; margin: 0 0 1rem; flex-wrap: wrap; }
    49   .tl-follow input { flex: 1; min-width: 200px; height: 2.4rem; padding: 0 .9rem; border-radius: 999px; font: inherit;
    50     border: 1px solid color-mix(in srgb, var(--ink, #000) 18%, transparent); background: color-mix(in srgb, var(--ink, #000) 4%, transparent); color: var(--ink, #000); }
    51   .tl-foll-list { list-style: none; padding: 0; margin: 0; display: flex; flex-direction: column; gap: .4rem; }
    52   .tl-foll { display: flex; align-items: center; gap: .6rem; padding: .45rem .6rem; border-radius: 12px; background: color-mix(in srgb, var(--ink, #000) 4%, transparent); }
    53   .tl-foll-av, .tl-avatar { flex: 0 0 36px; width: 36px; height: 36px; border-radius: 50%; overflow: hidden; display: inline-flex; align-items: center; justify-content: center; font-weight: 700; background: color-mix(in srgb, var(--accent, #888) 20%, transparent); color: var(--accent, #555); }
    54   .tl-foll-av img, .tl-avatar img { width: 100%; height: 100%; object-fit: cover; }
    55   .tl-foll-meta { flex: 1; min-width: 0; display: flex; flex-direction: column; line-height: 1.25; }
    56   .tl-foll-handle, .tl-handle { color: var(--ink-soft, #888); font-size: .82rem; }
    57   .tl-pending { font-size: .72rem; color: var(--ink-soft, #999); }
    58   .tl-unfollow { background: none; border: 1px solid color-mix(in srgb, var(--ink, #000) 18%, transparent); border-radius: 8px; padding: .25rem .6rem; font-size: .8rem; color: var(--ink-soft, #888); cursor: pointer; }
    59   .tl-unfollow:hover { color: var(--ink, #000); }
    60   .tl-empty { color: var(--ink-soft, #888); }
    61   .tl-feed { list-style: none; padding: 0; margin: 0; display: flex; flex-direction: column; gap: .9rem; }
    62   .tl-item { display: flex; gap: .75rem; padding: .9rem 1rem; border-radius: 14px; border: 1px solid color-mix(in srgb, var(--ink, #000) 9%, transparent); background: color-mix(in srgb, var(--ink, #000) 3%, transparent); }
    63   .tl-itembody { flex: 1; min-width: 0; }
    64   .tl-meta { display: flex; align-items: baseline; gap: .4rem; flex-wrap: wrap; }
    65   .tl-author { font-weight: 600; color: var(--ink, inherit); text-decoration: none; }
     65  .tl-wrap { max-width: 600px; margin: 1.5rem auto 3rem; padding: 0 1rem; }
     66  .tl-title { margin: 0 0 .15rem; font-size: clamp(1.5rem, 4vw, 1.8rem); }
     67  .tl-lead { color: var(--ink-soft, #888); margin: 0 0 1.5rem; }
     68  .tl-empty { text-align: center; color: var(--ink-soft, #888); margin: 3rem auto; }
     69  .tl-empty svg { width: 44px; height: 44px; color: color-mix(in srgb, var(--accent, #888) 60%, transparent); margin: 0 auto .75rem; display: block; }
     70
     71  .tl-feed { list-style: none; padding: 0; margin: 0; display: flex; flex-direction: column; gap: 1rem; }
     72  .tl-item { padding: 1.1rem 1.15rem; border-radius: 16px;
     73    border: 1px solid color-mix(in srgb, var(--ink, #000) 8%, transparent);
     74    background: color-mix(in srgb, var(--ink, #000) 2.5%, transparent);
     75    transition: border-color .15s ease, box-shadow .15s ease; }
     76  .tl-item:hover { border-color: color-mix(in srgb, var(--accent, #888) 32%, transparent);
     77    box-shadow: 0 2px 14px color-mix(in srgb, var(--ink, #000) 6%, transparent); }
     78
     79  .tl-head { display: flex; align-items: center; gap: .65rem; margin: 0 0 .65rem; }
     80  .tl-avatar { flex: 0 0 44px; width: 44px; height: 44px; border-radius: 50%; overflow: hidden;
     81    display: inline-flex; align-items: center; justify-content: center; font-weight: 700; font-size: 1.1rem;
     82    background: color-mix(in srgb, var(--accent, #888) 22%, transparent); color: var(--accent, #555);
     83    box-shadow: 0 0 0 2px color-mix(in srgb, var(--accent, #888) 18%, transparent); }
     84  .tl-avatar img { width: 100%; height: 100%; object-fit: cover; }
     85  .tl-id { flex: 1; min-width: 0; display: flex; flex-direction: column; line-height: 1.25; }
     86  .tl-author { font-weight: 700; color: var(--ink, inherit); text-decoration: none; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
    6687  .tl-author:hover { text-decoration: underline; }
    67   .tl-time { color: var(--ink-soft, #999); font-size: .8rem; margin-left: auto; }
    68   .tl-content { margin: .3rem 0 0; line-height: 1.5; overflow-wrap: anywhere; }
    69   .tl-content p { margin: .25rem 0; } .tl-content p:first-child { margin-top: 0; }
    70   .tl-media { max-width: 100%; height: auto; border-radius: 10px; margin: .5rem 0 0; display: block; }
    71   .tl-orig { margin: .5rem 0 0; font-size: .82rem; }
    72   .tl-orig a { color: var(--accent, #06c); }
    73   .tl-actions { display: flex; gap: .35rem; margin-top: .55rem; }
     88  .tl-handle { color: var(--ink-soft, #888); font-size: .82rem; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
     89  .tl-time { color: var(--ink-soft, #999); font-size: .78rem; flex: 0 0 auto; align-self: flex-start; white-space: nowrap; }
     90
     91  .tl-content { line-height: 1.55; overflow-wrap: anywhere; }
     92  .tl-content p { margin: .4rem 0; } .tl-content p:first-child { margin-top: 0; } .tl-content p:last-child { margin-bottom: 0; }
     93  .tl-content a { color: var(--accent, #06c); }
     94  .tl-content img { max-width: 100%; height: auto; border-radius: 10px; }
     95
     96  .tl-media-grid { display: grid; gap: .3rem; margin: .75rem 0 0; grid-template-columns: 1fr 1fr;
     97    border-radius: 14px; overflow: hidden; }
     98  .tl-media-grid.is-single { grid-template-columns: 1fr; }
     99  .tl-media-grid[data-n="3"] .tl-media-img:first-child { grid-row: span 2; }
     100  .tl-media-img { display: block; aspect-ratio: 16/11; background: var(--paper-2, #111); overflow: hidden; }
     101  .tl-media-grid.is-single .tl-media-img { aspect-ratio: auto; max-height: 480px; background: #000; }
     102  .tl-media-img img { width: 100%; height: 100%; object-fit: cover; display: block; transition: transform .25s ease; }
     103  .tl-media-grid.is-single .tl-media-img img { object-fit: contain; max-height: 480px; }
     104  .tl-media-img:hover img { transform: scale(1.03); }
     105
     106  .tl-media-video, .tl-media-audio { width: 100%; margin: .75rem 0 0; border-radius: 12px; display: block; }
     107  .tl-media-video { max-height: 480px; background: #000; }
     108
     109  .tl-embed { margin: .75rem 0 0; border-radius: 12px; overflow: hidden; background: var(--paper-2, #111); }
     110  .tl-embed-frame { width: 100%; border: 0; display: block; aspect-ratio: 16 / 9; }
     111  .tl-embed-spotify { aspect-ratio: auto; height: 152px; }
     112  .tl-embed-sc { aspect-ratio: auto; height: 166px; }
     113
     114  .tl-orig { display: inline-block; margin: .7rem 0 0; font-size: .82rem; color: var(--accent, #06c); text-decoration: none; }
     115  .tl-orig:hover { text-decoration: underline; }
     116
     117  .tl-actions { display: flex; gap: .4rem; margin-top: .85rem; padding-top: .15rem; }
    74118  .tl-act-form { margin: 0; }
    75   .tl-act { background: none; border: 1px solid color-mix(in srgb, var(--ink, #000) 14%, transparent); border-radius: 999px; padding: .2rem .6rem; cursor: pointer; font-size: .9rem; color: var(--ink-soft, #888); }
    76   .tl-act:hover { color: var(--ink, #000); border-color: color-mix(in srgb, var(--accent, #888) 50%, transparent); }
    77   .tl-act-on { color: var(--accent, #06c); border-color: color-mix(in srgb, var(--accent, #888) 60%, transparent); background: color-mix(in srgb, var(--accent, #888) 14%, transparent); }
     119  .tl-act { background: none; border: 1px solid color-mix(in srgb, var(--ink, #000) 12%, transparent);
     120    border-radius: 999px; padding: .25rem .65rem; cursor: pointer; font-size: .92rem; line-height: 1;
     121    color: var(--ink-soft, #888); transition: color .12s, border-color .12s, background .12s; }
     122  .tl-act:hover { color: var(--ink, #000); border-color: color-mix(in srgb, var(--accent, #888) 50%, transparent);
     123    background: color-mix(in srgb, var(--accent, #888) 8%, transparent); }
     124  .tl-act-on { color: var(--accent, #06c); border-color: color-mix(in srgb, var(--accent, #888) 60%, transparent);
     125    background: color-mix(in srgb, var(--accent, #888) 14%, transparent); }
     126  .tl-act-block { margin-left: auto; }
     127  .tl-act-block:hover { color: #e06b66; border-color: color-mix(in srgb, #d9534f 50%, transparent); background: color-mix(in srgb, #d9534f 12%, transparent); }
    78128</style>
    79129
Note: See TracChangeset for help on using the changeset viewer.