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: occurence count for records (backlink count)

authored by

Patrick Dewey and committed by tangled.org 471707c9 33ec08f3

+324 -43
+62
internal/firehose/index.go
··· 1052 1052 return counts 1053 1053 } 1054 1054 1055 + // refCounts returns a map of ref AT-URI -> count of records in the given collection 1056 + // that reference it via the specified JSON field. If did is non-empty, only records 1057 + // owned by that DID are counted. 1058 + func (idx *FeedIndex) refCounts(collection, jsonField, did string) map[string]int { 1059 + counts := make(map[string]int) 1060 + var rows *sql.Rows 1061 + var err error 1062 + if did != "" { 1063 + rows, err = idx.db.Query(fmt.Sprintf(` 1064 + SELECT json_extract(record, '$.%s') as ref_uri, COUNT(*) as cnt 1065 + FROM records 1066 + WHERE collection = ? AND did = ? 1067 + AND ref_uri IS NOT NULL AND ref_uri != '' 1068 + GROUP BY ref_uri 1069 + `, jsonField), collection, did) 1070 + } else { 1071 + rows, err = idx.db.Query(fmt.Sprintf(` 1072 + SELECT json_extract(record, '$.%s') as ref_uri, COUNT(*) as cnt 1073 + FROM records 1074 + WHERE collection = ? 1075 + AND ref_uri IS NOT NULL AND ref_uri != '' 1076 + GROUP BY ref_uri 1077 + `, jsonField), collection) 1078 + } 1079 + if err != nil { 1080 + return counts 1081 + } 1082 + defer rows.Close() 1083 + for rows.Next() { 1084 + var uri string 1085 + var count int 1086 + if err := rows.Scan(&uri, &count); err == nil { 1087 + counts[uri] = count 1088 + } 1089 + } 1090 + return counts 1091 + } 1092 + 1093 + // BrewCountsByBeanURI returns a map of bean AT-URI -> number of brews referencing that bean. 1094 + // If did is non-empty, only brews owned by that DID are counted. 1095 + func (idx *FeedIndex) BrewCountsByBeanURI(did string) map[string]int { 1096 + return idx.refCounts("social.arabica.alpha.brew", "beanRef", did) 1097 + } 1098 + 1099 + // BrewCountsByGrinderURI returns a map of grinder AT-URI -> number of brews referencing that grinder. 1100 + // If did is non-empty, only brews owned by that DID are counted. 1101 + func (idx *FeedIndex) BrewCountsByGrinderURI(did string) map[string]int { 1102 + return idx.refCounts("social.arabica.alpha.brew", "grinderRef", did) 1103 + } 1104 + 1105 + // BrewCountsByBrewerURI returns a map of brewer AT-URI -> number of brews referencing that brewer. 1106 + // If did is non-empty, only brews owned by that DID are counted. 1107 + func (idx *FeedIndex) BrewCountsByBrewerURI(did string) map[string]int { 1108 + return idx.refCounts("social.arabica.alpha.brew", "brewerRef", did) 1109 + } 1110 + 1111 + // BeanCountsByRoasterURI returns a map of roaster AT-URI -> number of beans referencing that roaster. 1112 + // If did is non-empty, only beans owned by that DID are counted. 1113 + func (idx *FeedIndex) BeanCountsByRoasterURI(did string) map[string]int { 1114 + return idx.refCounts("social.arabica.alpha.bean", "roasterRef", did) 1115 + } 1116 + 1055 1117 func formatTimeAgo(t time.Time) string { 1056 1118 now := time.Now() 1057 1119 diff := now.Sub(t)
+36
internal/handlers/entity_views.go
··· 224 224 beanViewProps.IsRecordHidden = sd.IsRecordHidden 225 225 beanViewProps.AuthorDID = entityOwnerDID 226 226 227 + if h.feedIndex != nil && subjectURI != "" { 228 + ownerDID := entityOwnerDID 229 + if ownerDID == "" { 230 + ownerDID = didStr 231 + } 232 + counts := h.feedIndex.BrewCountsByBeanURI(ownerDID) 233 + beanViewProps.BrewCount = counts[subjectURI] 234 + } 235 + 227 236 if err := pages.BeanView(layoutData, beanViewProps).Render(r.Context(), w); err != nil { 228 237 http.Error(w, "Failed to render page", http.StatusInternalServerError) 229 238 log.Error().Err(err).Msg("Failed to render bean view") ··· 346 355 props.CanBlockUser = sd.CanBlockUser 347 356 props.IsRecordHidden = sd.IsRecordHidden 348 357 props.AuthorDID = entityOwnerDID 358 + 359 + if h.feedIndex != nil && subjectURI != "" { 360 + ownerDID := entityOwnerDID 361 + if ownerDID == "" { 362 + ownerDID = didStr 363 + } 364 + counts := h.feedIndex.BeanCountsByRoasterURI(ownerDID) 365 + props.BeanCount = counts[subjectURI] 366 + } 349 367 350 368 if err := pages.RoasterView(layoutData, props).Render(r.Context(), w); err != nil { 351 369 http.Error(w, "Failed to render page", http.StatusInternalServerError) ··· 470 488 props.IsRecordHidden = sd.IsRecordHidden 471 489 props.AuthorDID = entityOwnerDID 472 490 491 + if h.feedIndex != nil && subjectURI != "" { 492 + ownerDID := entityOwnerDID 493 + if ownerDID == "" { 494 + ownerDID = didStr 495 + } 496 + counts := h.feedIndex.BrewCountsByGrinderURI(ownerDID) 497 + props.BrewCount = counts[subjectURI] 498 + } 499 + 473 500 if err := pages.GrinderView(layoutData, props).Render(r.Context(), w); err != nil { 474 501 http.Error(w, "Failed to render page", http.StatusInternalServerError) 475 502 log.Error().Err(err).Msg("Failed to render grinder view") ··· 592 619 props.CanBlockUser = sd.CanBlockUser 593 620 props.IsRecordHidden = sd.IsRecordHidden 594 621 props.AuthorDID = entityOwnerDID 622 + 623 + if h.feedIndex != nil && subjectURI != "" { 624 + ownerDID := entityOwnerDID 625 + if ownerDID == "" { 626 + ownerDID = didStr 627 + } 628 + counts := h.feedIndex.BrewCountsByBrewerURI(ownerDID) 629 + props.BrewCount = counts[subjectURI] 630 + } 595 631 596 632 if err := pages.BrewerView(layoutData, props).Render(r.Context(), w); err != nil { 597 633 http.Error(w, "Failed to render page", http.StatusInternalServerError)
+23 -12
internal/handlers/profile.go
··· 575 575 brewLikeCounts := make(map[string]int) 576 576 brewLikedByUser := make(map[string]bool) 577 577 brewCIDs := make(map[string]string) 578 + var beanBrewCounts, grinderBrewCounts, brewerBrewCounts, roasterBeanCounts map[string]int 578 579 if h.feedIndex != nil && profile != nil { 579 580 for _, brew := range profileData.Brews { 580 581 subjectURI := atproto.BuildATURI(profile.DID, atproto.NSIDBrew, brew.RKey) ··· 587 588 brewCIDs[brew.RKey] = record.CID 588 589 } 589 590 } 591 + // Entity usage counts 592 + beanBrewCounts = h.feedIndex.BrewCountsByBeanURI(did) 593 + grinderBrewCounts = h.feedIndex.BrewCountsByGrinderURI(did) 594 + brewerBrewCounts = h.feedIndex.BrewCountsByBrewerURI(did) 595 + roasterBeanCounts = h.feedIndex.BeanCountsByRoasterURI(did) 590 596 } 591 597 592 598 if err := components.ProfileContentPartial(components.ProfileContentPartialProps{ 593 - Brews: profileData.Brews, 594 - Beans: profileData.Beans, 595 - Roasters: profileData.Roasters, 596 - Grinders: profileData.Grinders, 597 - Brewers: profileData.Brewers, 598 - IsOwnProfile: isOwnProfile, 599 - ProfileHandle: profileHandle, 600 - Profile: profile, 601 - BrewLikeCounts: brewLikeCounts, 602 - BrewLikedByUser: brewLikedByUser, 603 - BrewCIDs: brewCIDs, 604 - IsAuthenticated: isAuthenticated, 599 + Brews: profileData.Brews, 600 + Beans: profileData.Beans, 601 + Roasters: profileData.Roasters, 602 + Grinders: profileData.Grinders, 603 + Brewers: profileData.Brewers, 604 + IsOwnProfile: isOwnProfile, 605 + ProfileHandle: profileHandle, 606 + Profile: profile, 607 + BrewLikeCounts: brewLikeCounts, 608 + BrewLikedByUser: brewLikedByUser, 609 + BrewCIDs: brewCIDs, 610 + IsAuthenticated: isAuthenticated, 611 + BeanBrewCounts: beanBrewCounts, 612 + GrinderBrewCounts: grinderBrewCounts, 613 + BrewerBrewCounts: brewerBrewCounts, 614 + RoasterBeanCounts: roasterBeanCounts, 615 + ProfileDID: did, 605 616 }).Render(r.Context(), w); err != nil { 606 617 http.Error(w, "Failed to render content", http.StatusInternalServerError) 607 618 log.Error().Err(err).Msg("Failed to render profile partial")
+65 -8
internal/web/components/entity_tables.templ
··· 1 1 package components 2 2 3 3 import ( 4 + "arabica/internal/atproto" 4 5 "arabica/internal/models" 5 6 "arabica/internal/web/bff" 6 7 "fmt" 7 8 ) 8 9 10 + // entityCount looks up a count for an entity by building its AT-URI from the owner DID and rkey. 11 + func entityCount(counts map[string]int, ownerDID, nsid, rkey string) int { 12 + if counts == nil || ownerDID == "" { 13 + return 0 14 + } 15 + return counts[atproto.BuildATURI(ownerDID, nsid, rkey)] 16 + } 17 + 9 18 // BeanCardsProps defines props for the bean cards grid 10 19 type BeanCardsProps struct { 11 20 Beans []*models.Bean 12 21 ShowActions bool // Whether to show Edit/Delete actions 13 22 OwnerHandle string // If set, name links to view page with this owner 23 + BrewCounts map[string]int // bean AT-URI -> brew count (optional) 24 + OwnerDID string // DID of the entity owner (for count lookups) 14 25 } 15 26 16 27 // BeanCards renders a grid of bean cards ··· 20 31 } else { 21 32 <div class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-3"> 22 33 for _, bean := range props.Beans { 23 - @BeanCard(bean, props.ShowActions, props.OwnerHandle) 34 + @BeanCard(bean, props.ShowActions, props.OwnerHandle, entityCount(props.BrewCounts, props.OwnerDID, atproto.NSIDBean, bean.RKey)) 24 35 } 25 36 </div> 26 37 } 27 38 } 28 39 29 40 // BeanCard renders a single bean as a compact card 30 - templ BeanCard(bean *models.Bean, showActions bool, ownerHandle string) { 41 + templ BeanCard(bean *models.Bean, showActions bool, ownerHandle string, brewCount int) { 31 42 <div class="feed-card feed-card-bean"> 32 43 <div class="feed-content-box-sm"> 33 44 <div class="flex items-start justify-between gap-2 mb-2"> ··· 101 112 if bean.Description != "" { 102 113 <div class="mt-2 text-sm text-brown-800 italic line-clamp-2">"{ bean.Description }"</div> 103 114 } 115 + if brewCount > 0 { 116 + <div class="flex items-center gap-3 pt-2 mt-2 border-t border-brown-200/60 text-xs text-brown-500"> 117 + <span class="flex items-center gap-1"> 118 + @IconCoffee() 119 + { fmt.Sprintf("%d brew%s", brewCount, entityPluralS(brewCount)) } 120 + </span> 121 + </div> 122 + } 104 123 </div> 105 124 </div> 106 125 } 107 126 127 + // entityPluralS returns "s" for counts != 1 128 + func entityPluralS(n int) string { 129 + if n == 1 { 130 + return "" 131 + } 132 + return "s" 133 + } 134 + 108 135 // RoastersTableProps defines props for the shared roasters display 109 136 type RoastersTableProps struct { 110 137 Roasters []*models.Roaster 111 138 ShowActions bool // Whether to show Edit/Delete actions 112 139 OwnerHandle string // If set, name links to view page with this owner 140 + BeanCounts map[string]int // roaster AT-URI -> bean count (optional) 141 + OwnerDID string // DID of the entity owner (for count lookups) 113 142 } 114 143 115 144 // RoastersTable renders a grid of roaster cards ··· 119 148 } else { 120 149 <div class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-3"> 121 150 for _, roaster := range props.Roasters { 122 - @RoasterCard(roaster, props.ShowActions, props.OwnerHandle) 151 + @RoasterCard(roaster, props.ShowActions, props.OwnerHandle, entityCount(props.BeanCounts, props.OwnerDID, atproto.NSIDRoaster, roaster.RKey)) 123 152 } 124 153 </div> 125 154 } 126 155 } 127 156 128 157 // RoasterCard renders a single roaster as a compact card 129 - templ RoasterCard(roaster *models.Roaster, showActions bool, ownerHandle string) { 158 + templ RoasterCard(roaster *models.Roaster, showActions bool, ownerHandle string, beanCount int) { 130 159 <div class="feed-card feed-card-roaster"> 131 160 <div class="feed-content-box-sm"> 132 161 <div class="flex items-start justify-between gap-2 mb-2"> ··· 175 204 </span> 176 205 } 177 206 </div> 207 + if beanCount > 0 { 208 + <div class="flex items-center gap-3 pt-2 mt-2 border-t border-brown-200/60 text-xs text-brown-500"> 209 + <span class="flex items-center gap-1"> 210 + @IconLeaf() 211 + { fmt.Sprintf("%d bean%s", beanCount, entityPluralS(beanCount)) } 212 + </span> 213 + </div> 214 + } 178 215 </div> 179 216 </div> 180 217 } ··· 184 221 Grinders []*models.Grinder 185 222 ShowActions bool // Whether to show Edit/Delete actions 186 223 OwnerHandle string // If set, name links to view page with this owner 224 + BrewCounts map[string]int // grinder AT-URI -> brew count (optional) 225 + OwnerDID string // DID of the entity owner (for count lookups) 187 226 } 188 227 189 228 // GrindersTable renders a grid of grinder cards ··· 193 232 } else { 194 233 <div class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-3"> 195 234 for _, grinder := range props.Grinders { 196 - @GrinderCard(grinder, props.ShowActions, props.OwnerHandle) 235 + @GrinderCard(grinder, props.ShowActions, props.OwnerHandle, entityCount(props.BrewCounts, props.OwnerDID, atproto.NSIDGrinder, grinder.RKey)) 197 236 } 198 237 </div> 199 238 } 200 239 } 201 240 202 241 // GrinderCard renders a single grinder as a compact card 203 - templ GrinderCard(grinder *models.Grinder, showActions bool, ownerHandle string) { 242 + templ GrinderCard(grinder *models.Grinder, showActions bool, ownerHandle string, brewCount int) { 204 243 <div class="feed-card feed-card-grinder"> 205 244 <div class="feed-content-box-sm"> 206 245 <div class="flex items-start justify-between gap-2 mb-2"> ··· 248 287 if grinder.Notes != "" { 249 288 <div class="mt-2 text-sm text-brown-800 italic line-clamp-2">"{ grinder.Notes }"</div> 250 289 } 290 + if brewCount > 0 { 291 + <div class="flex items-center gap-3 pt-2 mt-2 border-t border-brown-200/60 text-xs text-brown-500"> 292 + <span class="flex items-center gap-1"> 293 + @IconCoffee() 294 + { fmt.Sprintf("%d brew%s", brewCount, entityPluralS(brewCount)) } 295 + </span> 296 + </div> 297 + } 251 298 </div> 252 299 </div> 253 300 } ··· 257 304 Brewers []*models.Brewer 258 305 ShowActions bool // Whether to show Edit/Delete actions 259 306 OwnerHandle string // If set, name links to view page with this owner 307 + BrewCounts map[string]int // brewer AT-URI -> brew count (optional) 308 + OwnerDID string // DID of the entity owner (for count lookups) 260 309 } 261 310 262 311 // BrewersTable renders a grid of brewer cards ··· 266 315 } else { 267 316 <div class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-3"> 268 317 for _, brewer := range props.Brewers { 269 - @BrewerCard(brewer, props.ShowActions, props.OwnerHandle) 318 + @BrewerCard(brewer, props.ShowActions, props.OwnerHandle, entityCount(props.BrewCounts, props.OwnerDID, atproto.NSIDBrewer, brewer.RKey)) 270 319 } 271 320 </div> 272 321 } 273 322 } 274 323 275 324 // BrewerCard renders a single brewer as a compact card 276 - templ BrewerCard(brewer *models.Brewer, showActions bool, ownerHandle string) { 325 + templ BrewerCard(brewer *models.Brewer, showActions bool, ownerHandle string, brewCount int) { 277 326 <div class="feed-card feed-card-brewer"> 278 327 <div class="feed-content-box-sm"> 279 328 <div class="flex items-start justify-between gap-2 mb-2"> ··· 314 363 } 315 364 if brewer.Description != "" { 316 365 <div class="mt-2 text-sm text-brown-800 italic line-clamp-2">"{ brewer.Description }"</div> 366 + } 367 + if brewCount > 0 { 368 + <div class="flex items-center gap-3 pt-2 mt-2 border-t border-brown-200/60 text-xs text-brown-500"> 369 + <span class="flex items-center gap-1"> 370 + @IconCoffee() 371 + { fmt.Sprintf("%d brew%s", brewCount, entityPluralS(brewCount)) } 372 + </span> 373 + </div> 317 374 } 318 375 </div> 319 376 </div>
+77 -23
internal/web/components/profile_partial.templ
··· 23 23 // Comment counts for brews (keyed by brew RKey) 24 24 BrewCommentCounts map[string]int 25 25 IsAuthenticated bool 26 + // Entity usage counts (keyed by AT-URI) 27 + BeanBrewCounts map[string]int // bean URI -> brew count 28 + GrinderBrewCounts map[string]int // grinder URI -> brew count 29 + BrewerBrewCounts map[string]int // brewer URI -> brew count 30 + RoasterBeanCounts map[string]int // roaster URI -> bean count 31 + ProfileDID string // DID of the profile being viewed 26 32 } 27 33 28 34 // ProfileContentPartial renders the profile tabs content (for HTMX loading) ··· 45 51 </div> 46 52 <!-- Beans Tab --> 47 53 <div x-show="activeTab === 'beans'"> 48 - @ProfileBeansTab(props.Beans, props.Roasters, props.IsOwnProfile, props.ProfileHandle) 54 + @ProfileBeansTab(ProfileBeansTabProps{ 55 + Beans: props.Beans, 56 + Roasters: props.Roasters, 57 + IsOwnProfile: props.IsOwnProfile, 58 + ProfileHandle: props.ProfileHandle, 59 + BeanBrewCounts: props.BeanBrewCounts, 60 + RoasterBeanCounts: props.RoasterBeanCounts, 61 + ProfileDID: props.ProfileDID, 62 + }) 49 63 </div> 50 64 <!-- Equipment Tab --> 51 65 <div x-show="activeTab === 'equipment'"> 52 - @ProfileEquipmentTab(props.Grinders, props.Brewers, props.IsOwnProfile, props.ProfileHandle) 66 + @ProfileEquipmentTab(ProfileEquipmentTabProps{ 67 + Grinders: props.Grinders, 68 + Brewers: props.Brewers, 69 + IsOwnProfile: props.IsOwnProfile, 70 + ProfileHandle: props.ProfileHandle, 71 + GrinderBrewCounts: props.GrinderBrewCounts, 72 + BrewerBrewCounts: props.BrewerBrewCounts, 73 + ProfileDID: props.ProfileDID, 74 + }) 53 75 </div> 54 76 } 55 77 78 + // ProfileBeansTabProps defines props for the beans tab 79 + type ProfileBeansTabProps struct { 80 + Beans []*models.Bean 81 + Roasters []*models.Roaster 82 + IsOwnProfile bool 83 + ProfileHandle string 84 + BeanBrewCounts map[string]int 85 + RoasterBeanCounts map[string]int 86 + ProfileDID string 87 + } 88 + 56 89 // ProfileBeansTab renders the beans and roasters tab for profile 57 - templ ProfileBeansTab(beans []*models.Bean, roasters []*models.Roaster, isOwnProfile bool, profileHandle string) { 90 + templ ProfileBeansTab(props ProfileBeansTabProps) { 58 91 <div class="space-y-6"> 59 92 <!-- Open Bags Section --> 60 93 <div> 61 94 <h4 class="text-lg font-semibold text-brown-900 mb-3">Open Bags</h4> 62 - if len(filterOpenBeans(beans)) == 0 { 63 - if isOwnProfile { 95 + if len(filterOpenBeans(props.Beans)) == 0 { 96 + if props.IsOwnProfile { 64 97 @EmptyState(EmptyStateProps{ 65 98 Message: "No open bags yet! Add your first bean.", 66 99 ActionURL: "/my-coffee", ··· 73 106 } 74 107 } else { 75 108 @BeanCards(BeanCardsProps{ 76 - Beans: filterOpenBeans(beans), 109 + Beans: filterOpenBeans(props.Beans), 77 110 ShowActions: false, 78 - OwnerHandle: profileHandle, 111 + OwnerHandle: props.ProfileHandle, 112 + BrewCounts: props.BeanBrewCounts, 113 + OwnerDID: props.ProfileDID, 79 114 }) 80 115 } 81 116 </div> 82 117 <!-- Roasters Section --> 83 118 <div> 84 119 <h4 class="text-lg font-semibold text-brown-900 mb-3">Roasters</h4> 85 - if len(roasters) == 0 { 86 - if isOwnProfile { 120 + if len(props.Roasters) == 0 { 121 + if props.IsOwnProfile { 87 122 @EmptyState(EmptyStateProps{Message: "No roasters added yet."}) 88 123 } else { 89 124 @EmptyState(EmptyStateProps{Message: "No roasters yet."}) 90 125 } 91 126 } else { 92 127 @RoastersTable(RoastersTableProps{ 93 - Roasters: roasters, 128 + Roasters: props.Roasters, 94 129 ShowActions: false, 95 - OwnerHandle: profileHandle, 130 + OwnerHandle: props.ProfileHandle, 131 + BeanCounts: props.RoasterBeanCounts, 132 + OwnerDID: props.ProfileDID, 96 133 }) 97 134 } 98 135 </div> 99 136 <!-- Closed Bags Section --> 100 137 <div> 101 138 <h4 class="text-lg font-semibold text-brown-900 mb-3">Closed Bags</h4> 102 - if len(filterClosedBeans(beans)) == 0 { 139 + if len(filterClosedBeans(props.Beans)) == 0 { 103 140 @EmptyState(EmptyStateProps{ 104 141 Message: "No closed bags.", 105 142 }) 106 143 } else { 107 144 @BeanCards(BeanCardsProps{ 108 - Beans: filterClosedBeans(beans), 145 + Beans: filterClosedBeans(props.Beans), 109 146 ShowActions: false, 110 - OwnerHandle: profileHandle, 147 + OwnerHandle: props.ProfileHandle, 148 + BrewCounts: props.BeanBrewCounts, 149 + OwnerDID: props.ProfileDID, 111 150 }) 112 151 } 113 152 </div> ··· 210 249 return counts[rkey] 211 250 } 212 251 252 + // ProfileEquipmentTabProps defines props for the equipment tab 253 + type ProfileEquipmentTabProps struct { 254 + Grinders []*models.Grinder 255 + Brewers []*models.Brewer 256 + IsOwnProfile bool 257 + ProfileHandle string 258 + GrinderBrewCounts map[string]int 259 + BrewerBrewCounts map[string]int 260 + ProfileDID string 261 + } 262 + 213 263 // ProfileEquipmentTab renders the equipment tab for profile (grinders and brewers only) 214 - templ ProfileEquipmentTab(grinders []*models.Grinder, brewers []*models.Brewer, isOwnProfile bool, profileHandle string) { 264 + templ ProfileEquipmentTab(props ProfileEquipmentTabProps) { 215 265 <div class="space-y-6"> 216 266 <!-- Grinders Section --> 217 267 <div> 218 268 <h4 class="text-lg font-semibold text-brown-900 mb-3">Grinders</h4> 219 - if len(grinders) == 0 { 220 - if isOwnProfile { 269 + if len(props.Grinders) == 0 { 270 + if props.IsOwnProfile { 221 271 @EmptyState(EmptyStateProps{Message: "No grinders added yet."}) 222 272 } else { 223 273 @EmptyState(EmptyStateProps{Message: "No grinders yet."}) 224 274 } 225 275 } else { 226 276 @GrindersTable(GrindersTableProps{ 227 - Grinders: grinders, 277 + Grinders: props.Grinders, 228 278 ShowActions: false, 229 - OwnerHandle: profileHandle, 279 + OwnerHandle: props.ProfileHandle, 280 + BrewCounts: props.GrinderBrewCounts, 281 + OwnerDID: props.ProfileDID, 230 282 }) 231 283 } 232 284 </div> 233 285 <!-- Brewers Section --> 234 286 <div> 235 287 <h4 class="text-lg font-semibold text-brown-900 mb-3">Brewers</h4> 236 - if len(brewers) == 0 { 237 - if isOwnProfile { 288 + if len(props.Brewers) == 0 { 289 + if props.IsOwnProfile { 238 290 @EmptyState(EmptyStateProps{Message: "No brewing devices added yet."}) 239 291 } else { 240 292 @EmptyState(EmptyStateProps{Message: "No brewing devices yet."}) 241 293 } 242 294 } else { 243 295 @BrewersTable(BrewersTableProps{ 244 - Brewers: brewers, 296 + Brewers: props.Brewers, 245 297 ShowActions: false, 246 - OwnerHandle: profileHandle, 298 + OwnerHandle: props.ProfileHandle, 299 + BrewCounts: props.BrewerBrewCounts, 300 + OwnerDID: props.ProfileDID, 247 301 }) 248 302 } 249 303 </div>
+16
internal/web/pages/bean_view.templ
··· 26 26 CanBlockUser bool 27 27 IsRecordHidden bool 28 28 AuthorDID string 29 + BrewCount int 29 30 } 30 31 31 32 templ BeanView(layout *components.LayoutData, props BeanViewProps) { ··· 76 77 <div class="section-box"> 77 78 <h3 class="text-sm font-medium text-brown-600 uppercase tracking-wider mb-2"><span class="inline-flex items-center gap-1">@components.IconFileText() Description</span></h3> 78 79 <div class="text-brown-900 whitespace-pre-wrap">{ props.Bean.Description }</div> 80 + </div> 81 + } 82 + if props.BrewCount > 0 { 83 + <div class="flex items-center gap-3 pt-3 border-t border-brown-200/60 text-xs text-brown-500"> 84 + <span class="flex items-center gap-1"> 85 + @components.IconCoffee() 86 + { fmt.Sprintf("%d brew%s", props.BrewCount, pluralS(props.BrewCount)) } 87 + </span> 79 88 </div> 80 89 } 81 90 <div class="flex flex-col sm:flex-row sm:justify-between sm:items-center gap-3"> ··· 324 333 "closed": %t 325 334 }`, bean.Name, bean.Origin, bean.Variety, bean.RoastLevel, 326 335 bean.Process, bean.Description, roasterRKey, ratingStr, bean.Closed) 336 + } 337 + 338 + func pluralS(n int) string { 339 + if n == 1 { 340 + return "" 341 + } 342 + return "s" 327 343 } 328 344 329 345 func getOwnerFromShareURL(shareURL string) string {
+11
internal/web/pages/brewer_view.templ
··· 1 1 package pages 2 2 3 3 import ( 4 + "fmt" 5 + 4 6 "arabica/internal/firehose" 5 7 "arabica/internal/models" 6 8 "arabica/internal/web/bff" ··· 24 26 CanBlockUser bool 25 27 IsRecordHidden bool 26 28 AuthorDID string 29 + BrewCount int 27 30 } 28 31 29 32 templ BrewerView(layout *components.LayoutData, props BrewerViewProps) { ··· 44 47 <div class="section-box"> 45 48 <h3 class="text-sm font-medium text-brown-600 uppercase tracking-wider mb-2"><span class="inline-flex items-center gap-1">@components.IconFileText() Description</span></h3> 46 49 <div class="text-brown-900 whitespace-pre-wrap">{ props.Brewer.Description }</div> 50 + </div> 51 + } 52 + if props.BrewCount > 0 { 53 + <div class="flex items-center gap-3 pt-3 border-t border-brown-200/60 text-xs text-brown-500"> 54 + <span class="flex items-center gap-1"> 55 + @components.IconCoffee() 56 + { fmt.Sprintf("%d brew%s", props.BrewCount, pluralS(props.BrewCount)) } 57 + </span> 47 58 </div> 48 59 } 49 60 <div class="flex justify-between items-center">
+11
internal/web/pages/grinder_view.templ
··· 1 1 package pages 2 2 3 3 import ( 4 + "fmt" 5 + 4 6 "arabica/internal/firehose" 5 7 "arabica/internal/models" 6 8 "arabica/internal/web/bff" ··· 24 26 CanBlockUser bool 25 27 IsRecordHidden bool 26 28 AuthorDID string 29 + BrewCount int 27 30 } 28 31 29 32 templ GrinderView(layout *components.LayoutData, props GrinderViewProps) { ··· 47 50 <div class="section-box"> 48 51 <h3 class="text-sm font-medium text-brown-600 uppercase tracking-wider mb-2"><span class="inline-flex items-center gap-1">@components.IconFileText() Notes</span></h3> 49 52 <div class="text-brown-900 whitespace-pre-wrap">{ props.Grinder.Notes }</div> 53 + </div> 54 + } 55 + if props.BrewCount > 0 { 56 + <div class="flex items-center gap-3 pt-3 border-t border-brown-200/60 text-xs text-brown-500"> 57 + <span class="flex items-center gap-1"> 58 + @components.IconCoffee() 59 + { fmt.Sprintf("%d brew%s", props.BrewCount, pluralS(props.BrewCount)) } 60 + </span> 50 61 </div> 51 62 } 52 63 <div class="flex justify-between items-center">
+12
internal/web/pages/recipe_explore.templ
··· 27 27 <div class="flex items-center gap-3 mb-6"> 28 28 @components.BackButton() 29 29 <h2 class="text-2xl font-semibold text-brown-900">Explore Recipes</h2> 30 + <div class="ml-auto"> 31 + <button 32 + hx-get="/api/modals/recipe/new" 33 + hx-target="#modal-container" 34 + hx-swap="innerHTML" 35 + class="btn-primary shadow-lg hover:shadow-xl" 36 + > 37 + + New Recipe 38 + </button> 39 + </div> 30 40 </div> 31 41 @components.RecipeAlphaWarning() 32 42 <!-- Search and filters --> ··· 513 523 </template> 514 524 </div> 515 525 </dialog> 526 + <!-- Modal container for recipe creation dialog --> 527 + <div id="modal-container"></div> 516 528 </div> 517 529 } 518 530
+11
internal/web/pages/roaster_view.templ
··· 1 1 package pages 2 2 3 3 import ( 4 + "fmt" 5 + 4 6 "arabica/internal/firehose" 5 7 "arabica/internal/models" 6 8 "arabica/internal/web/bff" ··· 24 26 CanBlockUser bool 25 27 IsRecordHidden bool 26 28 AuthorDID string 29 + BeanCount int 27 30 } 28 31 29 32 templ RoasterView(layout *components.LayoutData, props RoasterViewProps) { ··· 54 57 </div> 55 58 } 56 59 </div> 60 + if props.BeanCount > 0 { 61 + <div class="flex items-center gap-3 pt-3 border-t border-brown-200/60 text-xs text-brown-500"> 62 + <span class="flex items-center gap-1"> 63 + @components.IconLeaf() 64 + { fmt.Sprintf("%d bean%s", props.BeanCount, pluralS(props.BeanCount)) } 65 + </span> 66 + </div> 67 + } 57 68 <div class="flex justify-between items-center"> 58 69 @components.BackButton() 59 70 <div class="bg-brown-50 rounded-lg px-3 py-2 border border-brown-200 brew-view-actions">