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: band-aid fix of feed ordering on mobile

This fix leaves stuff to be desired, there is a gross inline script and
the feed breaks if the page is resized from small<->big (probably not
that big of a deal, but not ideal)

+63 -69
+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 + 181 192 queryState := pages.FeedQueryState{ 182 193 TypeFilter: typeFilterStr, 183 194 Sort: string(sortBy), 184 195 NextCursor: nextCursor, 185 196 IsAuthenticated: isAuthenticated, 197 + Cols: cols, 186 198 } 187 199 188 200 // If this is a "load more" request (has cursor), render just the additional items
+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> 300 304 <!-- Load HTMX and other utilities --> 301 305 <script src="/static/js/htmx.min.js?v=0.2.0"></script> 302 306 <script src="/static/js/transitions.js?v=0.3.3"></script>
+47 -30
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) 25 26 } 26 27 27 28 // feedFilterTab defines a filter tab option ··· 59 60 return url 60 61 } 61 62 62 - func buildFeedURLWithCursor(typeFilter, sort, cursor string) string { 63 + func buildFeedURLWithCursor(typeFilter, sort, cursor string, cols int) string { 63 64 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 + } 64 73 if cursor != "" { 65 - if len(url) > len("/api/feed") { 66 - url += "&cursor=" + cursor 67 - } else { 68 - url += "?cursor=" + cursor 69 - } 74 + url += sep + "cursor=" + cursor 70 75 } 71 76 return url 72 77 } ··· 86 91 <!-- Feed items --> 87 92 <div id="feed-items" class="feed-grid"> 88 93 if len(items) > 0 { 89 - <div class="feed-masonry-col"> 90 - for i, item := range items { 91 - if i % 2 == 0 { 92 - @FeedCardWithModeration(item, isAuthenticated, modCtx) 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 + } 93 100 } 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) 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 + } 100 107 } 108 + </div> 109 + } else { 110 + for _, item := range items { 111 + @FeedCardWithModeration(item, isAuthenticated, modCtx) 101 112 } 102 - </div> 113 + } 103 114 <!-- Load more button --> 104 115 if qs.NextCursor != "" { 105 116 @FeedLoadMoreButton(qs) ··· 121 132 class="mb-5" 122 133 data-type-filter={ qs.TypeFilter } 123 134 data-sort={ qs.Sort } 124 - 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'}); } }" 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'}); } }" 125 136 > 126 137 <div class="flex flex-wrap items-center justify-between gap-2"> 127 138 <!-- Type filter pills --> ··· 163 174 <div class="text-center pt-2" style="grid-column: 1 / -1;" x-data="{ loading: false }"> 164 175 <button 165 176 class="btn-secondary text-sm disabled:opacity-50" 166 - hx-get={ buildFeedURLWithCursor(qs.TypeFilter, qs.Sort, qs.NextCursor) } 177 + hx-get={ buildFeedURLWithCursor(qs.TypeFilter, qs.Sort, qs.NextCursor, qs.Cols) } 167 178 hx-target="closest div" 168 179 hx-swap="outerHTML" 169 180 @htmx:before-request="loading = true" ··· 177 188 178 189 // FeedMoreItems renders additional items for "load more" pagination (no filter bar) 179 190 templ FeedMoreItems(items []*feed.FeedItem, isAuthenticated bool, modCtx FeedModerationContext, qs FeedQueryState) { 180 - <div class="feed-masonry-col"> 181 - for i, item := range items { 182 - if i % 2 == 0 { 183 - @FeedCardWithModeration(item, isAuthenticated, modCtx) 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 + } 184 197 } 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) 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 + } 191 204 } 205 + </div> 206 + } else { 207 + for _, item := range items { 208 + @FeedCardWithModeration(item, isAuthenticated, modCtx) 192 209 } 193 - </div> 210 + } 194 211 if qs.NextCursor != "" { 195 212 @FeedLoadMoreButton(qs) 196 213 }
-39
static/js/feed-masonry.js
··· 1 - // Feed masonry — handles distributing dynamically-added cards (load-more) 2 - // into the server-rendered flex columns. Initial render needs no JS. 3 - (function () { 4 - function distributeLooseCards() { 5 - var container = document.getElementById('feed-items'); 6 - if (!container) return; 7 - 8 - var cols = container.querySelectorAll('.feed-masonry-col'); 9 - if (cols.length < 2) return; 10 - 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); 16 - } 17 - }); 18 - if (loose.length === 0) return; 19 - 20 - // Measure current column heights 21 - var colHeights = Array.from(cols).map(function (col) { 22 - return col.offsetHeight; 23 - }); 24 - 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; 30 - }); 31 - } 32 - 33 - // After HTMX swaps (load more adds loose cards) 34 - document.addEventListener('htmx:afterSettle', function (e) { 35 - if (e.detail.target && (e.detail.target.id === 'feed-items' || e.detail.target.closest('#feed-items'))) { 36 - distributeLooseCards(); 37 - } 38 - }); 39 - })();