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.

fix: actually use the feed-masonry.js file for client side feed columns

+119 -67
-12
internal/handlers/feed.go
··· 178 178 if len(typeFilters) > 0 { 179 179 typeFilterStr = "equipment" 180 180 } 181 - // Determine column count from query param or cookie 182 - cols := 2 // default to 2-column masonry 183 - if c := r.URL.Query().Get("cols"); c == "1" { 184 - cols = 1 185 - } else if c == "" { 186 - // No query param — check cookie (set by client JS on initial page load) 187 - if ck, err := r.Cookie("feed_cols"); err == nil && ck.Value == "1" { 188 - cols = 1 189 - } 190 - } 191 - 192 181 queryState := pages.FeedQueryState{ 193 182 TypeFilter: typeFilterStr, 194 183 Sort: string(sortBy), 195 184 NextCursor: nextCursor, 196 185 IsAuthenticated: isAuthenticated, 197 - Cols: cols, 198 186 } 199 187 200 188 // If this is a "load more" request (has cursor), render just the additional items
+1 -4
internal/web/components/layout.templ
··· 297 297 <script src="/static/js/combo-select.js?v=0.5.0"></script> 298 298 <!-- Load Alpine.js core with defer (will initialize after DOM loads) --> 299 299 <script src="/static/js/alpine.min.js?v=0.2.0" defer></script> 300 - <!-- Set feed column cookie before HTMX fires --> 301 - <script nonce={ data.CSPNonce }> 302 - (function(){var c=function(){document.cookie='feed_cols='+(window.innerWidth>=768?2:1)+';path=/;SameSite=Lax';};c();window.addEventListener('resize',c);})(); 303 - </script> 304 300 <!-- Load HTMX and other utilities --> 305 301 <script src="/static/js/htmx.min.js?v=0.2.0"></script> 306 302 <script src="/static/js/transitions.js?v=0.3.3"></script> 307 303 <script src="/static/js/entity-helpers.js?v=0.8.0"></script> 308 304 <script src="/static/js/data-cache.js?v=0.3.0"></script> 309 305 <script src="/static/js/sw-register.js?v=0.2.0"></script> 306 + <script src="/static/js/feed-masonry.js?v=0.1.0"></script> 310 307 </head> 311 308 if tp := bff.Traceparent(ctx); tp != "" { 312 309 <meta name="traceparent" content={ tp }/>
+12 -51
internal/web/pages/feed.templ
··· 22 22 Sort string // Current sort order 23 23 NextCursor string // Cursor for next page (empty = no more) 24 24 IsAuthenticated bool 25 - Cols int // Number of columns (1 = mobile flat, 2 = desktop masonry) 26 25 } 27 26 28 27 // feedFilterTab defines a filter tab option ··· 60 59 return url 61 60 } 62 61 63 - func buildFeedURLWithCursor(typeFilter, sort, cursor string, cols int) string { 62 + func buildFeedURLWithCursor(typeFilter, sort, cursor string) string { 64 63 url := buildFeedURL(typeFilter, sort) 65 - sep := "?" 66 - if len(url) > len("/api/feed") { 67 - sep = "&" 68 - } 69 - if cols > 0 { 70 - url += sep + fmt.Sprintf("cols=%d", cols) 71 - sep = "&" 72 - } 73 64 if cursor != "" { 74 - url += sep + "cursor=" + cursor 65 + if len(url) > len("/api/feed") { 66 + url += "&cursor=" + cursor 67 + } else { 68 + url += "?cursor=" + cursor 69 + } 75 70 } 76 71 return url 77 72 } ··· 91 86 <!-- Feed items --> 92 87 <div id="feed-items" class="feed-grid"> 93 88 if len(items) > 0 { 94 - if qs.Cols >= 2 { 95 - <div class="feed-masonry-col"> 96 - for i, item := range items { 97 - if i % 2 == 0 { 98 - @FeedCardWithModeration(item, isAuthenticated, modCtx) 99 - } 100 - } 101 - </div> 102 - <div class="feed-masonry-col"> 103 - for i, item := range items { 104 - if i % 2 == 1 { 105 - @FeedCardWithModeration(item, isAuthenticated, modCtx) 106 - } 107 - } 108 - </div> 109 - } else { 110 - for _, item := range items { 111 - @FeedCardWithModeration(item, isAuthenticated, modCtx) 112 - } 89 + for _, item := range items { 90 + @FeedCardWithModeration(item, isAuthenticated, modCtx) 113 91 } 114 92 <!-- Load more button --> 115 93 if qs.NextCursor != "" { ··· 132 110 class="mb-5" 133 111 data-type-filter={ qs.TypeFilter } 134 112 data-sort={ qs.Sort } 135 - x-data="{ typeFilter: $el.dataset.typeFilter, sort: $el.dataset.sort, feedCols() { return window.innerWidth >= 768 ? 2 : 1; }, pillClass(tab) { if (this.typeFilter !== tab) return 'filter-pill'; if (!tab) return 'filter-pill-active'; return 'filter-pill-' + tab; }, feedURL(t, s) { let u = '/api/feed', sep = '?'; if (t) { if (t === 'equipment') { u += sep + 'type=grinder&type=brewer'; } else { u += sep + 'type=' + t; } sep = '&'; } if (s) { if (s !== 'recent') { u += sep + 'sort=' + s; sep = '&'; } } u += sep + 'cols=' + this.feedCols(); return u; }, changeFilter(t) { this.typeFilter = t; htmx.ajax('GET', this.feedURL(t, this.sort), {target: '#feed-items', swap: 'outerHTML', select: '#feed-items'}); }, changeSort(s) { this.sort = s; htmx.ajax('GET', this.feedURL(this.typeFilter, s), {target: '#feed-items', swap: 'outerHTML', select: '#feed-items'}); } }" 113 + x-data="{ typeFilter: $el.dataset.typeFilter, sort: $el.dataset.sort, pillClass(tab) { if (this.typeFilter !== tab) return 'filter-pill'; if (!tab) return 'filter-pill-active'; return 'filter-pill-' + tab; }, feedURL(t, s) { let u = '/api/feed', sep = '?'; if (t) { if (t === 'equipment') { u += sep + 'type=grinder&type=brewer'; } else { u += sep + 'type=' + t; } sep = '&'; } if (s) { if (s !== 'recent') { u += sep + 'sort=' + s; } } return u; }, changeFilter(t) { this.typeFilter = t; htmx.ajax('GET', this.feedURL(t, this.sort), {target: '#feed-items', swap: 'outerHTML', select: '#feed-items'}); }, changeSort(s) { this.sort = s; htmx.ajax('GET', this.feedURL(this.typeFilter, s), {target: '#feed-items', swap: 'outerHTML', select: '#feed-items'}); } }" 136 114 > 137 115 <div class="flex flex-wrap items-center justify-between gap-2"> 138 116 <!-- Type filter pills --> ··· 174 152 <div class="text-center pt-2" style="grid-column: 1 / -1;" x-data="{ loading: false }"> 175 153 <button 176 154 class="btn-secondary text-sm disabled:opacity-50" 177 - hx-get={ buildFeedURLWithCursor(qs.TypeFilter, qs.Sort, qs.NextCursor, qs.Cols) } 155 + hx-get={ buildFeedURLWithCursor(qs.TypeFilter, qs.Sort, qs.NextCursor) } 178 156 hx-target="closest div" 179 157 hx-swap="outerHTML" 180 158 @htmx:before-request="loading = true" ··· 188 166 189 167 // FeedMoreItems renders additional items for "load more" pagination (no filter bar) 190 168 templ FeedMoreItems(items []*feed.FeedItem, isAuthenticated bool, modCtx FeedModerationContext, qs FeedQueryState) { 191 - if qs.Cols >= 2 { 192 - <div class="feed-masonry-col"> 193 - for i, item := range items { 194 - if i % 2 == 0 { 195 - @FeedCardWithModeration(item, isAuthenticated, modCtx) 196 - } 197 - } 198 - </div> 199 - <div class="feed-masonry-col"> 200 - for i, item := range items { 201 - if i % 2 == 1 { 202 - @FeedCardWithModeration(item, isAuthenticated, modCtx) 203 - } 204 - } 205 - </div> 206 - } else { 207 - for _, item := range items { 208 - @FeedCardWithModeration(item, isAuthenticated, modCtx) 209 - } 169 + for _, item := range items { 170 + @FeedCardWithModeration(item, isAuthenticated, modCtx) 210 171 } 211 172 if qs.NextCursor != "" { 212 173 @FeedLoadMoreButton(qs)
+106
static/js/feed-masonry.js
··· 1 + // Feed masonry — pure client-side two-column layout for desktop. 2 + // Server always renders cards flat in chronological order. 3 + // This script distributes them into two columns on desktop (768px+). 4 + (function () { 5 + var MQ = window.matchMedia('(min-width: 768px)'); 6 + 7 + function getContainer() { 8 + return document.getElementById('feed-items'); 9 + } 10 + 11 + // Get all .feed-card elements that are direct children of container 12 + function getLooseCards(container) { 13 + var cards = []; 14 + for (var i = 0; i < container.children.length; i++) { 15 + if (container.children[i].classList.contains('feed-card')) { 16 + cards.push(container.children[i]); 17 + } 18 + } 19 + return cards; 20 + } 21 + 22 + // Distribute loose cards into two masonry columns (shortest-first) 23 + function masonryLayout(container) { 24 + var cards = getLooseCards(container); 25 + if (cards.length === 0) return; 26 + 27 + // Get or create columns 28 + var cols = container.querySelectorAll(':scope > .feed-masonry-col'); 29 + if (cols.length < 2) { 30 + var ref = cards[0]; 31 + for (var i = cols.length; i < 2; i++) { 32 + var col = document.createElement('div'); 33 + col.className = 'feed-masonry-col'; 34 + container.insertBefore(col, ref); 35 + } 36 + cols = container.querySelectorAll(':scope > .feed-masonry-col'); 37 + } 38 + 39 + var heights = [cols[0].offsetHeight, cols[1].offsetHeight]; 40 + cards.forEach(function (card) { 41 + var idx = heights[0] <= heights[1] ? 0 : 1; 42 + cols[idx].appendChild(card); 43 + heights[idx] += card.offsetHeight + 20; 44 + }); 45 + } 46 + 47 + // Flatten columns back to chronological order 48 + function flattenLayout(container) { 49 + var cols = container.querySelectorAll(':scope > .feed-masonry-col'); 50 + if (cols.length === 0) return; 51 + 52 + // Interleave from both columns to restore chronological order 53 + var c0 = cols[0] ? Array.from(cols[0].children) : []; 54 + var c1 = cols[1] ? Array.from(cols[1].children) : []; 55 + var merged = []; 56 + var max = Math.max(c0.length, c1.length); 57 + for (var i = 0; i < max; i++) { 58 + if (i < c0.length) merged.push(c0[i]); 59 + if (i < c1.length) merged.push(c1[i]); 60 + } 61 + 62 + // Find insertion point (before first non-column, non-card child like load-more) 63 + var ref = null; 64 + for (var j = 0; j < container.children.length; j++) { 65 + var ch = container.children[j]; 66 + if (!ch.classList.contains('feed-masonry-col') && !ch.classList.contains('feed-card')) { 67 + ref = ch; 68 + break; 69 + } 70 + } 71 + 72 + merged.forEach(function (card) { 73 + container.insertBefore(card, ref); 74 + }); 75 + 76 + cols.forEach(function (col) { col.remove(); }); 77 + } 78 + 79 + function applyLayout() { 80 + var container = getContainer(); 81 + if (!container) return; 82 + if (MQ.matches) { 83 + masonryLayout(container); 84 + } else { 85 + flattenLayout(container); 86 + } 87 + } 88 + 89 + // Initial layout after DOM ready 90 + if (document.readyState === 'loading') { 91 + document.addEventListener('DOMContentLoaded', applyLayout); 92 + } else { 93 + applyLayout(); 94 + } 95 + 96 + // Viewport changes 97 + MQ.addEventListener('change', applyLayout); 98 + 99 + // After HTMX swaps (load-more, filter/sort changes) 100 + document.addEventListener('htmx:afterSettle', function (e) { 101 + var t = e.detail.target; 102 + if (t && (t.id === 'feed-items' || (t.closest && t.closest('#feed-items')))) { 103 + applyLayout(); 104 + } 105 + }); 106 + })();