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: pinboard style feed

authored by

Patrick Dewey and committed by
Tangled
a36c723b 850bae69

+355 -181
+15 -12
internal/feed/service.go
··· 86 86 87 87 // FeedQuery specifies filtering, sorting, and pagination for feed queries 88 88 type FeedQuery struct { 89 - Limit int 90 - Cursor string 91 - TypeFilter lexicons.RecordType 92 - Sort FeedSort 89 + Limit int 90 + Cursor string 91 + TypeFilter lexicons.RecordType 92 + TypeFilters []lexicons.RecordType 93 + Sort FeedSort 93 94 } 94 95 95 96 // FeedResult contains feed items plus pagination info ··· 108 109 109 110 // FirehoseFeedQuery mirrors FeedQuery for the firehose layer 110 111 type FirehoseFeedQuery struct { 111 - Limit int 112 - Cursor string 113 - TypeFilter lexicons.RecordType 114 - Sort string // "recent" or "popular" 112 + Limit int 113 + Cursor string 114 + TypeFilter lexicons.RecordType 115 + TypeFilters []lexicons.RecordType 116 + Sort string // "recent" or "popular" 115 117 } 116 118 117 119 // FirehoseFeedResult mirrors FeedResult for the firehose layer ··· 349 351 } 350 352 351 353 firehoseResult, err := s.firehoseIndex.GetFeedWithQuery(ctx, FirehoseFeedQuery{ 352 - Limit: fetchLimit, 353 - Cursor: q.Cursor, 354 - TypeFilter: q.TypeFilter, 355 - Sort: string(q.Sort), 354 + Limit: fetchLimit, 355 + Cursor: q.Cursor, 356 + TypeFilter: q.TypeFilter, 357 + TypeFilters: q.TypeFilters, 358 + Sort: string(q.Sort), 356 359 }) 357 360 if err != nil { 358 361 return nil, err
+5 -4
internal/firehose/adapter.go
··· 36 36 // GetFeedWithQuery returns feed items matching query parameters 37 37 func (a *FeedIndexAdapter) GetFeedWithQuery(ctx context.Context, q feed.FirehoseFeedQuery) (*feed.FirehoseFeedResult, error) { 38 38 result, err := a.index.GetFeedWithQuery(ctx, FeedQuery{ 39 - Limit: q.Limit, 40 - Cursor: q.Cursor, 41 - TypeFilter: q.TypeFilter, 42 - Sort: FeedSort(q.Sort), 39 + Limit: q.Limit, 40 + Cursor: q.Cursor, 41 + TypeFilter: q.TypeFilter, 42 + TypeFilters: q.TypeFilters, 43 + Sort: FeedSort(q.Sort), 43 44 }) 44 45 if err != nil { 45 46 return nil, err
+29 -12
internal/firehose/index.go
··· 81 81 82 82 // FeedQuery specifies filtering, sorting, and pagination for feed queries 83 83 type FeedQuery struct { 84 - Limit int // Max items to return 85 - Cursor string // Opaque cursor for pagination (created_at|uri) 86 - TypeFilter lexicons.RecordType // Filter to a specific record type (empty = all) 87 - Sort FeedSort // Sort order (default: recent) 84 + Limit int // Max items to return 85 + Cursor string // Opaque cursor for pagination (created_at|uri) 86 + TypeFilter lexicons.RecordType // Filter to a specific record type (empty = all) 87 + TypeFilters []lexicons.RecordType // Filter to multiple record types (takes precedence over TypeFilter) 88 + Sort FeedSort // Sort order (default: recent) 88 89 } 89 90 90 91 // FeedResult contains feed items plus pagination info ··· 596 597 597 598 // GetRecentFeed returns recent feed items from the index 598 599 func (idx *FeedIndex) GetRecentFeed(ctx context.Context, limit int) ([]*FeedItem, error) { 599 - return idx.getFeedItems(ctx, "", limit, "") 600 + return idx.getFeedItems(ctx, nil, limit, "") 600 601 } 601 602 602 603 // recordTypeToNSID maps a lexicons.RecordType to its NSID collection string ··· 627 628 q.Sort = FeedSortRecent 628 629 } 629 630 630 - collectionFilter := "" 631 - if q.TypeFilter != "" { 631 + // Determine collection filters 632 + var collectionFilters []string 633 + if len(q.TypeFilters) > 0 { 634 + for _, tf := range q.TypeFilters { 635 + nsid, ok := recordTypeToNSID[tf] 636 + if !ok { 637 + return nil, fmt.Errorf("unknown record type: %s", tf) 638 + } 639 + collectionFilters = append(collectionFilters, nsid) 640 + } 641 + } else if q.TypeFilter != "" { 632 642 nsid, ok := recordTypeToNSID[q.TypeFilter] 633 643 if !ok { 634 644 return nil, fmt.Errorf("unknown record type: %s", q.TypeFilter) 635 645 } 636 - collectionFilter = nsid 646 + collectionFilters = []string{nsid} 637 647 } 638 648 639 649 // For popular sort, fetch more candidates to re-rank by score ··· 642 652 fetchLimit = q.Limit * 5 643 653 } 644 654 645 - items, err := idx.getFeedItems(ctx, collectionFilter, fetchLimit, q.Cursor) 655 + items, err := idx.getFeedItems(ctx, collectionFilters, fetchLimit, q.Cursor) 646 656 if err != nil { 647 657 return nil, err 648 658 } ··· 669 679 } 670 680 671 681 // getFeedItems fetches records from SQLite, resolves references, and returns FeedItems. 672 - func (idx *FeedIndex) getFeedItems(ctx context.Context, collectionFilter string, limit int, cursor string) ([]*FeedItem, error) { 682 + func (idx *FeedIndex) getFeedItems(ctx context.Context, collectionFilters []string, limit int, cursor string) ([]*FeedItem, error) { 673 683 // Build query for feedable records 674 684 var args []any 675 685 query := `SELECT uri, did, collection, rkey, record, cid, indexed_at, created_at FROM records WHERE ` 676 686 677 - if collectionFilter != "" { 687 + if len(collectionFilters) == 1 { 678 688 query += `collection = ? ` 679 - args = append(args, collectionFilter) 689 + args = append(args, collectionFilters[0]) 690 + } else if len(collectionFilters) > 1 { 691 + placeholders := make([]string, len(collectionFilters)) 692 + for i, c := range collectionFilters { 693 + placeholders[i] = "?" 694 + args = append(args, c) 695 + } 696 + query += `collection IN (` + strings.Join(placeholders, ",") + `) ` 680 697 } else { 681 698 // Only feedable collections 682 699 placeholders := make([]string, len(feedableCollections))
+17 -6
internal/handlers/feed.go
··· 107 107 isAuthenticated := err == nil 108 108 109 109 // Parse query parameters 110 - typeFilter := lexicons.ParseRecordType(r.URL.Query().Get("type")) 110 + typeParam := r.URL.Query().Get("type") 111 + typeFilter := lexicons.ParseRecordType(typeParam) 112 + var typeFilters []lexicons.RecordType 113 + if typeParam == "equipment" { 114 + typeFilters = []lexicons.RecordType{lexicons.RecordTypeGrinder, lexicons.RecordTypeBrewer} 115 + typeFilter = "" // Clear single filter when using multi 116 + } 111 117 sortBy := feed.FeedSort(r.URL.Query().Get("sort")) 112 118 cursor := r.URL.Query().Get("cursor") 113 119 ··· 118 124 if h.feedService != nil { 119 125 if isAuthenticated { 120 126 result, err := h.feedService.GetFeedWithQuery(r.Context(), feed.FeedQuery{ 121 - Limit: feed.FeedLimit, 122 - Cursor: cursor, 123 - TypeFilter: typeFilter, 124 - Sort: sortBy, 127 + Limit: feed.FeedLimit, 128 + Cursor: cursor, 129 + TypeFilter: typeFilter, 130 + TypeFilters: typeFilters, 131 + Sort: sortBy, 125 132 }) 126 133 if err != nil { 127 134 log.Error().Err(err).Str("sort", string(sortBy)).Str("type", string(typeFilter)).Msg("Failed to query feed") ··· 167 174 modCtx := h.buildModerationContext(r.Context(), viewerDID, feedItems) 168 175 169 176 // Build query state for template 177 + typeFilterStr := string(typeFilter) 178 + if len(typeFilters) > 0 { 179 + typeFilterStr = "equipment" 180 + } 170 181 queryState := pages.FeedQueryState{ 171 - TypeFilter: string(typeFilter), 182 + TypeFilter: typeFilterStr, 172 183 Sort: string(sortBy), 173 184 NextCursor: nextCursor, 174 185 IsAuthenticated: isAuthenticated,
+1 -1
internal/web/components/layout.templ
··· 115 115 <link rel="icon" href="/static/favicon.svg" type="image/svg+xml"/> 116 116 <link rel="icon" href="/static/favicon-32.svg" type="image/svg+xml" sizes="32x32"/> 117 117 <link rel="apple-touch-icon" href="/static/icon-192.svg"/> 118 - <link rel="stylesheet" href="/static/css/output.css?v=0.8.4"/> 118 + <link rel="stylesheet" href="/static/css/output.css?v=0.9.0"/> 119 119 <style> 120 120 [x-cloak] { display: none !important; } 121 121 </style>
+46 -38
internal/web/pages/feed.templ
··· 35 35 {Label: "All", Value: ""}, 36 36 {Label: "Brews", Value: "brew"}, 37 37 {Label: "Beans", Value: "bean"}, 38 - {Label: "Roasters", Value: "roaster"}, 39 - {Label: "Grinders", Value: "grinder"}, 40 - {Label: "Brewers", Value: "brewer"}, 41 38 {Label: "Recipes", Value: "recipe"}, 39 + {Label: "Equipment", Value: "equipment"}, 42 40 } 43 41 } 44 42 ··· 46 44 url := "/api/feed" 47 45 sep := "?" 48 46 if typeFilter != "" { 49 - url += sep + "type=" + typeFilter 50 - sep = "&" 47 + if typeFilter == "equipment" { 48 + url += sep + "type=grinder&type=brewer" 49 + sep = "&" 50 + } else { 51 + url += sep + "type=" + typeFilter 52 + sep = "&" 53 + } 51 54 } 52 55 if sort != "" && sort != "recent" { 53 56 url += sep + "sort=" + sort ··· 80 83 @FeedFilterBar(qs) 81 84 } 82 85 <!-- Feed items --> 83 - <div id="feed-items" class="space-y-4"> 86 + <div id="feed-items" class="feed-grid"> 84 87 if len(items) > 0 { 85 88 for _, item := range items { 86 89 @FeedCardWithModeration(item, isAuthenticated, modCtx) ··· 90 93 @FeedLoadMoreButton(qs) 91 94 } 92 95 } else { 93 - <div class="bg-brown-100 rounded-lg p-6 text-center text-brown-700 border border-brown-200"> 94 - <p class="mb-2 font-medium">No activity in the feed yet.</p> 95 - <p class="text-sm">Be the first to add something!</p> 96 + <div class="rounded-xl p-8 text-center" style="background: var(--surface-bg); border: 2px dashed var(--card-border); grid-column: 1 / -1;"> 97 + <div class="text-4xl mb-3">☕</div> 98 + <p class="text-brown-700 font-medium mb-1">The board's quiet today</p> 99 + <p class="text-sm text-brown-500">Follow some brewers to fill it up, or log your first brew to get started.</p> 96 100 </div> 97 101 } 98 102 </div> 103 + <script src="/static/js/feed-masonry.js?v=0.1.0"></script> 99 104 </div> 100 105 } 101 106 102 107 // FeedFilterBar renders the type filter tabs and sort selector 103 108 templ FeedFilterBar(qs FeedQueryState) { 104 109 <div 105 - class="flex flex-wrap items-center justify-between gap-2 mb-4" 110 + class="mb-5" 106 111 data-type-filter={ qs.TypeFilter } 107 112 data-sort={ qs.Sort } 108 - 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) { 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'}); } }" 113 + x-data="{ typeFilter: $el.dataset.typeFilter, sort: $el.dataset.sort, tabClass(tab) { if (this.typeFilter !== tab) return 'feed-tab'; if (!tab) return 'feed-tab-active'; return 'feed-tab-' + 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'}); } }" 109 114 > 110 - <!-- Type filter tabs --> 111 - <div class="flex flex-wrap gap-1"> 112 - for _, tab := range feedFilterTabs() { 115 + <div class="flex items-center justify-between gap-4"> 116 + <!-- Type filter tabs --> 117 + <nav class="feed-tabs flex-1" aria-label="Feed filters"> 118 + for _, tab := range feedFilterTabs() { 119 + <button 120 + class="feed-tab" 121 + :class="tabClass($el.dataset.tab)" 122 + data-tab={ tab.Value } 123 + @click="changeFilter($el.dataset.tab)" 124 + > 125 + { tab.Label } 126 + </button> 127 + } 128 + </nav> 129 + <!-- Sort selector --> 130 + <div class="flex items-center gap-1 flex-shrink-0"> 131 + <button 132 + class="feed-sort-btn" 133 + :class="(sort === '' || sort === 'recent') ? 'feed-sort-btn-active' : 'feed-sort-btn'" 134 + @click="changeSort('recent')" 135 + > 136 + New 137 + </button> 113 138 <button 114 - class="filter-pill" 115 - :class="pillClass($el.dataset.tab)" 116 - data-tab={ tab.Value } 117 - @click="changeFilter($el.dataset.tab)" 139 + class="feed-sort-btn" 140 + :class="sort === 'popular' ? 'feed-sort-btn-active' : 'feed-sort-btn'" 141 + @click="changeSort('popular')" 118 142 > 119 - { tab.Label } 143 + Popular 120 144 </button> 121 - } 122 - </div> 123 - <!-- Sort selector --> 124 - <div class="flex items-center gap-1"> 125 - <button 126 - class="filter-pill" 127 - :class="(sort === '' || sort === 'recent') ? 'filter-pill-active' : 'filter-pill'" 128 - @click="changeSort('recent')" 129 - > 130 - New 131 - </button> 132 - <button 133 - class="filter-pill" 134 - :class="sort === 'popular' ? 'filter-pill-active' : 'filter-pill'" 135 - @click="changeSort('popular')" 136 - > 137 - Popular 138 - </button> 145 + </div> 139 146 </div> 140 147 </div> 141 148 } 142 149 143 150 // FeedLoadMoreButton renders a "Load more" button for pagination 144 151 templ FeedLoadMoreButton(qs FeedQueryState) { 145 - <div class="text-center pt-2" x-data="{ loading: false }"> 152 + <div class="text-center pt-2" style="grid-column: 1 / -1;" x-data="{ loading: false }"> 146 153 <button 147 154 class="btn-secondary text-sm disabled:opacity-50" 148 155 hx-get={ buildFeedURLWithCursor(qs.TypeFilter, qs.Sort, qs.NextCursor) } ··· 183 190 templ.KV("feed-card-roaster", item.RecordType == lexicons.RecordTypeRoaster), 184 191 templ.KV("feed-card-grinder", item.RecordType == lexicons.RecordTypeGrinder), 185 192 templ.KV("feed-card-brewer", item.RecordType == lexicons.RecordTypeBrewer), 193 + templ.KV("feed-card-compact", item.RecordType == lexicons.RecordTypeGrinder || item.RecordType == lexicons.RecordTypeBrewer || item.RecordType == lexicons.RecordTypeRoaster), 186 194 ) } 187 195 > 188 196 <!-- Author row -->
+168 -108
static/css/app.css
··· 23 23 } 24 24 25 25 /* ======================================== 26 + Texture Patterns (inline SVG noise) 27 + ======================================== */ 28 + 29 + /* Corkboard — organic, fine-grained, warm */ 30 + :root { 31 + --texture-cork: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='200' height='200'%3E%3Cfilter id='c'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.9' numOctaves='5' stitchTiles='stitch'/%3E%3CfeColorMatrix type='matrix' values='0.3 0 0 0 0.15 0.2 0 0 0 0.08 0.1 0 0 0 0.02 0 0 0 0.15 0'/%3E%3C/filter%3E%3Crect width='200' height='200' filter='url(%23c)'/%3E%3C/svg%3E"); 32 + /* Feed board — warm cork brown */ 33 + --feed-board-bg: #C4A882; 34 + --feed-board-border: #B09470; 35 + /* Sticky note base — warm off-white */ 36 + --feed-card-bg: #FFFDF5; 37 + --texture-paper: none; 38 + } 39 + 40 + /* ======================================== 26 41 Design Tokens (CSS Custom Properties) 27 42 Light theme (default) 28 43 ======================================== */ ··· 91 106 --type-brewer: #5b6e4e; 92 107 93 108 /* Type background tints */ 94 - --type-brew-tint: rgba(107, 68, 35, 0.08); 95 - --type-bean-tint: rgba(180, 83, 9, 0.07); 96 - --type-recipe-tint: rgba(160, 82, 45, 0.07); 97 - --type-roaster-tint: rgba(146, 64, 14, 0.07); 98 - --type-grinder-tint: rgba(120, 113, 108, 0.06); 99 - --type-brewer-tint: rgba(91, 110, 78, 0.07); 109 + --type-brew-tint: rgba(107, 68, 35, 0.14); 110 + --type-bean-tint: rgba(180, 83, 9, 0.12); 111 + --type-recipe-tint: rgba(160, 82, 45, 0.12); 112 + --type-roaster-tint: rgba(146, 64, 14, 0.12); 113 + --type-grinder-tint: rgba(120, 113, 108, 0.10); 114 + --type-brewer-tint: rgba(91, 110, 78, 0.12); 100 115 101 116 /* Rating badges */ 102 117 --rating-bg: #fef3c7; ··· 139 154 --surface-bg: rgba(36, 26, 22, 0.6); 140 155 --surface-border: #2E211B; 141 156 157 + /* Feed board — noticeably lighter than cards for contrast */ 158 + --feed-board-bg: #3D2D22; 159 + --feed-board-border: #4A3828; 160 + /* Sticky notes — dark, sitting on the lighter board */ 161 + --feed-card-bg: #1A1210; 162 + 142 163 /* Header */ 143 164 --header-bg-from: #0F0A08; 144 165 --header-bg-to: #0F0A08; ··· 189 210 --type-brewer: #8ba37a; 190 211 191 212 /* Type background tints */ 192 - --type-brew-tint: rgba(166, 124, 82, 0.10); 193 - --type-bean-tint: rgba(217, 119, 6, 0.09); 194 - --type-recipe-tint: rgba(196, 114, 74, 0.09); 195 - --type-roaster-tint: rgba(194, 97, 14, 0.09); 196 - --type-grinder-tint: rgba(168, 162, 158, 0.08); 197 - --type-brewer-tint: rgba(139, 163, 122, 0.09); 213 + --type-brew-tint: rgba(166, 124, 82, 0.18); 214 + --type-bean-tint: rgba(217, 119, 6, 0.15); 215 + --type-recipe-tint: rgba(196, 114, 74, 0.15); 216 + --type-roaster-tint: rgba(194, 97, 14, 0.15); 217 + --type-grinder-tint: rgba(168, 162, 158, 0.12); 218 + --type-brewer-tint: rgba(139, 163, 122, 0.15); 198 219 199 220 /* Rating badges */ 200 221 --rating-bg: rgba(251, 191, 36, 0.15); ··· 292 313 --card-shadow-hover: rgba(0, 0, 0, 0.4); 293 314 --surface-bg: rgba(36, 26, 22, 0.6); 294 315 --surface-border: #2E211B; 316 + --feed-board-bg: #3D2D22; 317 + --feed-board-border: #4A3828; 318 + --feed-card-bg: #1A1210; 295 319 --header-bg-from: #0F0A08; 296 320 --header-bg-to: #0F0A08; 297 321 --header-border: #2E211B; ··· 327 351 --type-roaster: #c2610e; 328 352 --type-grinder: #a8a29e; 329 353 --type-brewer: #8ba37a; 330 - --type-brew-tint: rgba(166, 124, 82, 0.10); 331 - --type-bean-tint: rgba(217, 119, 6, 0.09); 332 - --type-recipe-tint: rgba(196, 114, 74, 0.09); 333 - --type-roaster-tint: rgba(194, 97, 14, 0.09); 334 - --type-grinder-tint: rgba(168, 162, 158, 0.08); 335 - --type-brewer-tint: rgba(139, 163, 122, 0.09); 354 + --type-brew-tint: rgba(166, 124, 82, 0.18); 355 + --type-bean-tint: rgba(217, 119, 6, 0.15); 356 + --type-recipe-tint: rgba(196, 114, 74, 0.15); 357 + --type-roaster-tint: rgba(194, 97, 14, 0.15); 358 + --type-grinder-tint: rgba(168, 162, 158, 0.12); 359 + --type-brewer-tint: rgba(139, 163, 122, 0.15); 336 360 --rating-bg: rgba(251, 191, 36, 0.15); 337 361 --rating-text: #fbbf24; 338 362 --alert-warning-bg: rgba(251, 191, 36, 0.08); ··· 809 833 color: var(--text-primary); 810 834 } 811 835 812 - /* Feed Components */ 836 + /* Feed Components — Sticky Notes on Corkboard */ 813 837 .feed-card { 814 - background: var(--card-bg); 815 - border: 1px solid var(--card-border); 816 - @apply rounded-lg p-3 sm:p-4 transition-shadow; 817 - box-shadow: var(--shadow-sm); 838 + background-color: var(--feed-card-bg); 839 + border: none; 840 + @apply rounded p-3 sm:p-4 transition-all duration-200; 841 + box-shadow: 1px 2px 4px rgba(0,0,0,0.1), 0 1px 2px rgba(0,0,0,0.06); 842 + break-inside: avoid; 843 + overflow: hidden; 818 844 } 819 845 820 846 .feed-card:hover { 821 847 box-shadow: var(--shadow-md); 822 848 } 823 849 824 - /* Feed card type tints — outer card gets full tint */ 850 + @media (min-width: 768px) { 851 + .feed-card { 852 + transform: rotate(var(--card-rotate, 0deg)); 853 + } 854 + 855 + .feed-card:hover { 856 + transform: rotate(0deg) translateY(-2px); 857 + } 858 + } 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; } 867 + } 868 + 869 + /* Brew cards — the main sticky note, slightly larger presence */ 825 870 .feed-card-brew { 826 - background: linear-gradient(var(--type-brew-tint), var(--type-brew-tint)), var(--card-bg); 871 + background-color: var(--feed-card-bg); 872 + background-image: linear-gradient(var(--type-brew-tint), var(--type-brew-tint)); 873 + @apply rounded; 874 + box-shadow: 2px 3px 6px rgba(0,0,0,0.12), 0 1px 3px rgba(0,0,0,0.08); 827 875 } 876 + 877 + .feed-card-brew:hover { 878 + box-shadow: 3px 4px 10px rgba(0,0,0,0.15), 0 2px 4px rgba(0,0,0,0.1); 879 + } 880 + 881 + /* Compact cards — equipment and roasters */ 882 + .feed-card-compact { 883 + @apply p-2.5 sm:p-3 rounded-lg; 884 + } 885 + 886 + /* Feed card type tints — colored sticky notes */ 828 887 .feed-card-bean { 829 - background: linear-gradient(var(--type-bean-tint), var(--type-bean-tint)), var(--card-bg); 888 + background-color: var(--feed-card-bg); 889 + background-image: linear-gradient(var(--type-bean-tint), var(--type-bean-tint)); 830 890 } 831 891 .feed-card-recipe { 832 - background: linear-gradient(var(--type-recipe-tint), var(--type-recipe-tint)), var(--card-bg); 892 + background-color: var(--feed-card-bg); 893 + background-image: linear-gradient(var(--type-recipe-tint), var(--type-recipe-tint)); 833 894 } 834 895 .feed-card-roaster { 835 - background: linear-gradient(var(--type-roaster-tint), var(--type-roaster-tint)), var(--card-bg); 896 + background-color: var(--feed-card-bg); 897 + background-image: linear-gradient(var(--type-roaster-tint), var(--type-roaster-tint)); 836 898 } 837 899 .feed-card-grinder { 838 - background: linear-gradient(var(--type-grinder-tint), var(--type-grinder-tint)), var(--card-bg); 900 + background-color: var(--feed-card-bg); 901 + background-image: linear-gradient(var(--type-grinder-tint), var(--type-grinder-tint)); 839 902 } 840 903 .feed-card-brewer { 841 - background: linear-gradient(var(--type-brewer-tint), var(--type-brewer-tint)), var(--card-bg); 904 + background-color: var(--feed-card-bg); 905 + background-image: linear-gradient(var(--type-brewer-tint), var(--type-brewer-tint)); 906 + } 907 + 908 + /* Feed grid layout — starts as normal grid, JS adds masonry class for tight packing */ 909 + .feed-grid { 910 + display: grid; 911 + grid-template-columns: 1fr; 912 + gap: 1rem; 913 + align-items: start; 914 + overflow: hidden; 915 + background-image: var(--texture-cork); 916 + background-color: var(--feed-board-bg); 917 + @apply rounded-xl p-3 sm:p-5; 918 + border: 1px solid var(--feed-board-border); 919 + box-shadow: inset 0 2px 8px rgba(0,0,0,0.12); 920 + } 921 + 922 + @media (min-width: 768px) { 923 + .feed-grid { 924 + grid-template-columns: repeat(2, 1fr); 925 + column-gap: 1.25rem; 926 + row-gap: 1.25rem; 927 + } 928 + 929 + /* JS adds this class after measuring heights */ 930 + .feed-grid.masonry-ready { 931 + grid-auto-rows: 8px; 932 + row-gap: 0; 933 + } 934 + } 935 + 936 + /* Brew cards get extra presence without spanning columns */ 937 + .feed-card-brew { 938 + @apply p-4 sm:p-5; 842 939 } 843 940 844 941 /* Inner content boxes inside tinted cards — blend with outer card */ ··· 968 1065 color: var(--rating-text); 969 1066 } 970 1067 971 - /* Filter Pills */ 972 - .filter-pill { 973 - @apply inline-flex items-center gap-1.5 px-3 py-1 text-xs font-medium rounded-full transition-all cursor-pointer; 974 - color: var(--text-muted); 975 - background: transparent; 976 - border: 1px solid var(--card-border); 977 - } 978 - 979 - .filter-pill:hover { 980 - color: var(--text-secondary); 981 - border-color: var(--input-border-focus); 982 - background: var(--surface-bg); 1068 + /* Feed Tab Bar */ 1069 + .feed-tabs { 1070 + @apply flex items-center gap-1 overflow-x-auto; 1071 + border-bottom: 2px solid var(--card-border); 983 1072 } 984 1073 985 - /* Type-aware hover: preview the active color on hover */ 986 - .filter-pill[data-tab="brew"]:hover { border-color: var(--type-brew); color: var(--type-brew); } 987 - .filter-pill[data-tab="bean"]:hover { border-color: var(--type-bean); color: var(--type-bean); } 988 - .filter-pill[data-tab="recipe"]:hover { border-color: var(--type-recipe); color: var(--type-recipe); } 989 - .filter-pill[data-tab="roaster"]:hover { border-color: var(--type-roaster); color: var(--type-roaster); } 990 - .filter-pill[data-tab="grinder"]:hover { border-color: var(--type-grinder); color: var(--type-grinder); } 991 - .filter-pill[data-tab="brewer"]:hover { border-color: var(--type-brewer); color: var(--type-brewer); } 992 - 993 - .filter-pill-active { 994 - @apply inline-flex items-center gap-1.5 px-3 py-1 text-xs font-medium rounded-full transition-all cursor-pointer; 995 - color: var(--btn-primary-text); 996 - background: var(--btn-primary-bg); 997 - border: 1px solid var(--btn-primary-bg); 998 - } 999 - 1000 - .filter-pill-active:hover { 1001 - background: var(--btn-primary-bg-hover); 1002 - border-color: var(--btn-primary-bg-hover); 1003 - } 1004 - 1005 - /* Type-colored filter pills (active state matches feed card left border) */ 1006 - .filter-pill-brew, 1007 - .filter-pill-bean, 1008 - .filter-pill-recipe, 1009 - .filter-pill-roaster, 1010 - .filter-pill-grinder, 1011 - .filter-pill-brewer { 1012 - @apply inline-flex items-center gap-1.5 px-3 py-1 text-xs font-medium rounded-full transition-all cursor-pointer; 1013 - color: var(--btn-primary-text); 1074 + .feed-tab { 1075 + @apply px-4 py-2.5 text-sm font-medium whitespace-nowrap transition-colors cursor-pointer; 1076 + color: var(--text-muted); 1077 + border-bottom: 2px solid transparent; 1078 + margin-bottom: -2px; 1014 1079 } 1015 1080 1016 - .filter-pill-brew { 1017 - background: var(--type-brew); 1018 - border: 1px solid var(--type-brew); 1081 + .feed-tab:hover { 1082 + color: var(--text-secondary); 1019 1083 } 1020 1084 1021 - .filter-pill-bean { 1022 - background: var(--type-bean); 1023 - border: 1px solid var(--type-bean); 1085 + .feed-tab-active { 1086 + @apply px-4 py-2.5 text-sm font-medium whitespace-nowrap transition-colors cursor-pointer; 1087 + color: var(--text-primary); 1088 + border-bottom: 2px solid var(--btn-primary-bg); 1089 + margin-bottom: -2px; 1024 1090 } 1025 1091 1026 - .filter-pill-recipe { 1027 - background: var(--type-recipe); 1028 - border: 1px solid var(--type-recipe); 1029 - } 1092 + /* Type-colored active tabs */ 1093 + .feed-tab-brew { color: var(--type-brew); border-bottom-color: var(--type-brew); } 1094 + .feed-tab-bean { color: var(--type-bean); border-bottom-color: var(--type-bean); } 1095 + .feed-tab-recipe { color: var(--type-recipe); border-bottom-color: var(--type-recipe); } 1096 + .feed-tab-equipment { color: var(--type-grinder); border-bottom-color: var(--type-grinder); } 1030 1097 1031 - .filter-pill-roaster { 1032 - background: var(--type-roaster); 1033 - border: 1px solid var(--type-roaster); 1098 + .feed-sort-btn { 1099 + @apply px-3 py-1.5 text-xs font-medium rounded-full transition-all cursor-pointer; 1100 + color: var(--text-muted); 1101 + background: transparent; 1102 + border: 1px solid var(--card-border); 1034 1103 } 1035 1104 1036 - .filter-pill-grinder { 1037 - background: var(--type-grinder); 1038 - border: 1px solid var(--type-grinder); 1105 + .feed-sort-btn:hover { 1106 + color: var(--text-secondary); 1107 + border-color: var(--input-border-focus); 1039 1108 } 1040 1109 1041 - .filter-pill-brewer { 1042 - background: var(--type-brewer); 1043 - border: 1px solid var(--type-brewer); 1110 + .feed-sort-btn-active { 1111 + @apply px-3 py-1.5 text-xs font-medium rounded-full transition-all cursor-pointer; 1112 + color: var(--btn-primary-text); 1113 + background: var(--btn-primary-bg); 1114 + border: 1px solid var(--btn-primary-bg); 1044 1115 } 1045 1116 1046 1117 /* Links */ ··· 1432 1503 animation: fade-in-slide-up 300ms ease-out backwards; 1433 1504 } 1434 1505 1435 - .feed-card:nth-child(1) { 1436 - animation-delay: 0ms; 1437 - } 1438 - .feed-card:nth-child(2) { 1439 - animation-delay: 50ms; 1440 - } 1441 - .feed-card:nth-child(3) { 1442 - animation-delay: 100ms; 1443 - } 1444 - .feed-card:nth-child(4) { 1445 - animation-delay: 150ms; 1446 - } 1447 - .feed-card:nth-child(5) { 1448 - animation-delay: 200ms; 1449 - } 1450 - .feed-card:nth-child(n + 6) { 1451 - animation-delay: 250ms; 1452 - } 1506 + .feed-card:nth-child(1) { animation-delay: 0ms; } 1507 + .feed-card:nth-child(2) { animation-delay: 60ms; } 1508 + .feed-card:nth-child(3) { animation-delay: 120ms; } 1509 + .feed-card:nth-child(4) { animation-delay: 160ms; } 1510 + .feed-card:nth-child(5) { animation-delay: 200ms; } 1511 + .feed-card:nth-child(6) { animation-delay: 230ms; } 1512 + .feed-card:nth-child(n + 7) { animation-delay: 260ms; } 1453 1513 1454 1514 /* Modal transitions (enhanced) */ 1455 1515 .modal-backdrop {
+74
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). 4 + (function () { 5 + var ROW_HEIGHT = 8; 6 + var GAP = 20; // visual gap between cards (matches column-gap) 7 + 8 + function layoutFeed() { 9 + var grid = document.getElementById('feed-items'); 10 + if (!grid) return; 11 + 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 = ''; 19 + } 20 + return; 21 + } 22 + 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 + } 29 + 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'); 43 + }); 44 + } 45 + 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) 55 + document.addEventListener('htmx:afterSettle', function (e) { 56 + if (e.detail.target && (e.detail.target.id === 'feed-items' || e.detail.target.closest('#feed-items'))) { 57 + setTimeout(layoutFeed, 50); 58 + } 59 + }); 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 + })();