Coffee journaling on ATProto (alpha) alpha.arabica.social
coffee
17
fork

Configure Feed

Select the types of activity you want to include in your feed.

feat: server side masonry rendering

authored by

Patrick Dewey and committed by
Tangled
6345d9a0 8b73885c

+75 -90
+28 -7
internal/web/pages/feed.templ
··· 86 86 <!-- Feed items --> 87 87 <div id="feed-items" class="feed-grid"> 88 88 if len(items) > 0 { 89 - for _, item := range items { 90 - @FeedCardWithModeration(item, isAuthenticated, modCtx) 91 - } 89 + <div class="feed-masonry-col"> 90 + for i, item := range items { 91 + if i % 2 == 0 { 92 + @FeedCardWithModeration(item, isAuthenticated, modCtx) 93 + } 94 + } 95 + </div> 96 + <div class="feed-masonry-col"> 97 + for i, item := range items { 98 + if i % 2 == 1 { 99 + @FeedCardWithModeration(item, isAuthenticated, modCtx) 100 + } 101 + } 102 + </div> 92 103 <!-- Load more button --> 93 104 if qs.NextCursor != "" { 94 105 @FeedLoadMoreButton(qs) ··· 101 112 </div> 102 113 } 103 114 </div> 104 - <script src="/static/js/feed-masonry.js?v=0.1.0"></script> 105 115 </div> 106 116 } 107 117 ··· 167 177 168 178 // FeedMoreItems renders additional items for "load more" pagination (no filter bar) 169 179 templ FeedMoreItems(items []*feed.FeedItem, isAuthenticated bool, modCtx FeedModerationContext, qs FeedQueryState) { 170 - for _, item := range items { 171 - @FeedCardWithModeration(item, isAuthenticated, modCtx) 172 - } 180 + <div class="feed-masonry-col"> 181 + for i, item := range items { 182 + if i % 2 == 0 { 183 + @FeedCardWithModeration(item, isAuthenticated, modCtx) 184 + } 185 + } 186 + </div> 187 + <div class="feed-masonry-col"> 188 + for i, item := range items { 189 + if i % 2 == 1 { 190 + @FeedCardWithModeration(item, isAuthenticated, modCtx) 191 + } 192 + } 193 + </div> 173 194 if qs.NextCursor != "" { 174 195 @FeedLoadMoreButton(qs) 175 196 }
+22 -23
static/css/app.css
··· 847 847 box-shadow: var(--shadow-md); 848 848 } 849 849 850 + /* Sticky-note rotation only inside the feed pinboard */ 850 851 @media (min-width: 768px) { 851 - .feed-card { 852 + .feed-grid .feed-card { 852 853 transform: rotate(var(--card-rotate, 0deg)); 853 854 } 854 855 855 - .feed-card:hover { 856 + .feed-grid .feed-card:hover { 856 857 transform: rotate(0deg) translateY(-2px); 857 858 } 858 - } 859 859 860 - /* Subtle deterministic rotation for collage feel (desktop only) */ 861 - @media (min-width: 768px) { 862 - .feed-card:nth-child(5n+1) { --card-rotate: -0.8deg; } 863 - .feed-card:nth-child(5n+2) { --card-rotate: 0.5deg; } 864 - .feed-card:nth-child(5n+3) { --card-rotate: -0.3deg; } 865 - .feed-card:nth-child(5n+4) { --card-rotate: 0.7deg; } 866 - .feed-card:nth-child(5n+5) { --card-rotate: 0deg; } 860 + .feed-grid .feed-card:nth-child(5n+1) { --card-rotate: -0.8deg; } 861 + .feed-grid .feed-card:nth-child(5n+2) { --card-rotate: 0.5deg; } 862 + .feed-grid .feed-card:nth-child(5n+3) { --card-rotate: -0.3deg; } 863 + .feed-grid .feed-card:nth-child(5n+4) { --card-rotate: 0.7deg; } 864 + .feed-grid .feed-card:nth-child(5n+5) { --card-rotate: 0deg; } 867 865 } 868 866 869 867 /* Brew cards — the main sticky note, slightly larger presence */ ··· 926 924 row-gap: 1.25rem; 927 925 } 928 926 929 - /* JS adds this class after measuring heights */ 930 - .feed-grid.masonry-ready { 931 - grid-auto-rows: 8px; 932 - row-gap: 0; 927 + .feed-masonry-col { 928 + display: flex; 929 + flex-direction: column; 930 + gap: 1.25rem; 931 + min-width: 0; 933 932 } 934 933 } 935 934 ··· 1536 1535 Component-Specific Transitions 1537 1536 ======================================== */ 1538 1537 1539 - /* Feed cards appear with stagger effect */ 1540 - .feed-card { 1538 + /* Feed cards appear with stagger effect (only inside the pinboard) */ 1539 + .feed-grid .feed-card { 1541 1540 animation: fade-in-slide-up 300ms ease-out backwards; 1542 1541 } 1543 1542 1544 - .feed-card:nth-child(1) { animation-delay: 0ms; } 1545 - .feed-card:nth-child(2) { animation-delay: 60ms; } 1546 - .feed-card:nth-child(3) { animation-delay: 120ms; } 1547 - .feed-card:nth-child(4) { animation-delay: 160ms; } 1548 - .feed-card:nth-child(5) { animation-delay: 200ms; } 1549 - .feed-card:nth-child(6) { animation-delay: 230ms; } 1550 - .feed-card:nth-child(n + 7) { animation-delay: 260ms; } 1543 + .feed-grid .feed-card:nth-child(1) { animation-delay: 0ms; } 1544 + .feed-grid .feed-card:nth-child(2) { animation-delay: 60ms; } 1545 + .feed-grid .feed-card:nth-child(3) { animation-delay: 120ms; } 1546 + .feed-grid .feed-card:nth-child(4) { animation-delay: 160ms; } 1547 + .feed-grid .feed-card:nth-child(5) { animation-delay: 200ms; } 1548 + .feed-grid .feed-card:nth-child(6) { animation-delay: 230ms; } 1549 + .feed-grid .feed-card:nth-child(n + 7) { animation-delay: 260ms; } 1551 1550 1552 1551 /* Modal transitions (enhanced) */ 1553 1552 .modal-backdrop {
+25 -60
static/js/feed-masonry.js
··· 1 - // Feed masonry layout — measures card heights and sets grid-row spans 2 - // so items flow left-to-right while packing tightly (no vertical gaps). 3 - // Only activates on 2-column layout (md+ breakpoint). 1 + // Feed masonry — handles distributing dynamically-added cards (load-more) 2 + // into the server-rendered flex columns. Initial render needs no JS. 4 3 (function () { 5 - var ROW_HEIGHT = 8; 6 - var GAP = 20; // visual gap between cards (matches column-gap) 4 + function distributeLooseCards() { 5 + var container = document.getElementById('feed-items'); 6 + if (!container) return; 7 7 8 - function layoutFeed() { 9 - var grid = document.getElementById('feed-items'); 10 - if (!grid) return; 8 + var cols = container.querySelectorAll('.feed-masonry-col'); 9 + if (cols.length < 2) return; 11 10 12 - // Only apply masonry on 2-column layout 13 - var cols = getComputedStyle(grid).gridTemplateColumns.split(' ').length; 14 - if (cols < 2) { 15 - grid.classList.remove('masonry-ready'); 16 - var items = grid.querySelectorAll('.feed-card'); 17 - for (var i = 0; i < items.length; i++) { 18 - items[i].style.gridRowEnd = ''; 11 + // Find cards that are direct children of the container (not in columns) 12 + var loose = []; 13 + Array.from(container.children).forEach(function (child) { 14 + if (child.classList.contains('feed-card')) { 15 + loose.push(child); 19 16 } 20 - return; 21 - } 17 + }); 18 + if (loose.length === 0) return; 22 19 23 - // Step 1: remove masonry class so cards render at natural height 24 - grid.classList.remove('masonry-ready'); 25 - var items = grid.querySelectorAll('.feed-card'); 26 - for (var i = 0; i < items.length; i++) { 27 - items[i].style.gridRowEnd = ''; 28 - } 20 + // Measure current column heights 21 + var colHeights = Array.from(cols).map(function (col) { 22 + return col.offsetHeight; 23 + }); 29 24 30 - // Step 2: wait for reflow, measure natural heights, set spans 31 - requestAnimationFrame(function () { 32 - var items = grid.querySelectorAll('.feed-card'); 33 - if (items.length === 0) return; 34 - 35 - for (var i = 0; i < items.length; i++) { 36 - var height = items[i].getBoundingClientRect().height; 37 - var span = Math.ceil((height + GAP) / ROW_HEIGHT); 38 - items[i].style.gridRowEnd = 'span ' + span; 39 - } 40 - 41 - // Step 3: activate masonry grid now that spans are set 42 - grid.classList.add('masonry-ready'); 25 + // Distribute loose cards to shortest column 26 + loose.forEach(function (card) { 27 + var shortest = colHeights[0] <= colHeights[1] ? 0 : 1; 28 + cols[shortest].appendChild(card); 29 + colHeights[shortest] += card.offsetHeight + 20; 43 30 }); 44 31 } 45 32 46 - // Run on initial load (feed loads via HTMX, so script may run inline) 47 - if (document.readyState === 'loading') { 48 - document.addEventListener('DOMContentLoaded', layoutFeed); 49 - } else { 50 - // Small delay to ensure DOM is settled after HTMX swap 51 - setTimeout(layoutFeed, 50); 52 - } 53 - 54 - // Re-layout after HTMX swaps (load more, filter changes) 33 + // After HTMX swaps (load more adds loose cards) 55 34 document.addEventListener('htmx:afterSettle', function (e) { 56 35 if (e.detail.target && (e.detail.target.id === 'feed-items' || e.detail.target.closest('#feed-items'))) { 57 - setTimeout(layoutFeed, 50); 36 + distributeLooseCards(); 58 37 } 59 38 }); 60 - 61 - // Re-layout on window resize 62 - var resizeTimer; 63 - window.addEventListener('resize', function () { 64 - clearTimeout(resizeTimer); 65 - resizeTimer = setTimeout(layoutFeed, 150); 66 - }); 67 - 68 - // Re-layout after images load (avatars can shift height) 69 - document.addEventListener('load', function (e) { 70 - if (e.target.tagName === 'IMG' && e.target.closest('.feed-card')) { 71 - setTimeout(layoutFeed, 50); 72 - } 73 - }, true); 74 39 })();