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: popular recipes and button in feed

authored by

Patrick Dewey and committed by tangled.org 33ec08f3 f8f324a7

+196 -8
+28 -1
internal/handlers/recipe.go
··· 5 5 "encoding/json" 6 6 "fmt" 7 7 "net/http" 8 + "sort" 8 9 "strconv" 9 10 10 11 "arabica/internal/atproto" ··· 12 13 "arabica/internal/models" 13 14 "arabica/internal/web/components" 14 15 "arabica/internal/web/pages" 15 - 16 16 17 17 "github.com/rs/zerolog/log" 18 18 ) ··· 746 746 log.Error().Err(err).Msg("Failed to render recipe explore page") 747 747 } 748 748 } 749 + 750 + // HandlePopularRecipesPartial returns an HTML fragment of popular recipes for the home page. 751 + func (h *Handler) HandlePopularRecipesPartial(w http.ResponseWriter, r *http.Request) { 752 + recipes, err := h.listAllRecipesFromIndex(r.Context()) 753 + if err != nil { 754 + log.Error().Err(err).Msg("Failed to fetch recipes for popular section") 755 + return 756 + } 757 + 758 + // Sort by popularity: brew_count + fork_count, descending 759 + sort.Slice(recipes, func(i, j int) bool { 760 + si := recipes[i].BrewCount + recipes[i].ForkCount 761 + sj := recipes[j].BrewCount + recipes[j].ForkCount 762 + return si > sj 763 + }) 764 + 765 + // Take top 6 766 + if len(recipes) > 6 { 767 + recipes = recipes[:6] 768 + } 769 + 770 + if err := components.PopularRecipes(components.PopularRecipesProps{ 771 + Recipes: recipes, 772 + }).Render(r.Context(), w); err != nil { 773 + log.Error().Err(err).Msg("Failed to render popular recipes") 774 + } 775 + }
+1
internal/routing/routing.go
··· 62 62 mux.Handle("GET /api/brews", middleware.RequireHTMXMiddleware(http.HandlerFunc(h.HandleBrewListPartial))) 63 63 mux.Handle("GET /api/manage", middleware.RequireHTMXMiddleware(http.HandlerFunc(h.HandleManagePartial))) 64 64 mux.Handle("GET /api/incomplete-records", middleware.RequireHTMXMiddleware(http.HandlerFunc(h.HandleIncompleteRecordsPartial))) 65 + mux.Handle("GET /api/popular-recipes", middleware.RequireHTMXMiddleware(http.HandlerFunc(h.HandlePopularRecipesPartial))) 65 66 mux.Handle("POST /api/manage/refresh", cop.Handler(http.HandlerFunc(h.HandleManageRefresh))) 66 67 mux.Handle("GET /api/profile/{actor}", middleware.RequireHTMXMiddleware(http.HandlerFunc(h.HandleProfilePartial))) 67 68
+6
internal/web/components/icons.templ
··· 207 207 } 208 208 209 209 // IconTag renders a tag icon (for type/category metadata) 210 + templ IconFork() { 211 + <svg class="inline-block w-3.5 h-3.5 flex-shrink-0" fill="none" stroke="currentColor" stroke-width="1.5" viewBox="0 0 24 24"> 212 + <path stroke-linecap="round" stroke-linejoin="round" d="M7.5 21 3 16.5m0 0L7.5 12M3 16.5h13.5m0-13.5L21 7.5m0 0L16.5 12M21 7.5H7.5"></path> 213 + </svg> 214 + } 215 + 210 216 templ IconTag() { 211 217 <svg class="inline-block w-4 h-4 flex-shrink-0 text-brown-400" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"> 212 218 <path d="M12.586 2.586A2 2 0 0 0 11.172 2H4a2 2 0 0 0-2 2v7.172a2 2 0 0 0 .586 1.414l8.704 8.704a2.426 2.426 0 0 0 3.42 0l6.58-6.58a2.426 2.426 0 0 0 0-3.42z"></path>
+143
internal/web/components/popular_recipes.templ
··· 1 + package components 2 + 3 + import ( 4 + "arabica/internal/models" 5 + "fmt" 6 + ) 7 + 8 + // PopularRecipesProps defines the data for the popular recipes section 9 + type PopularRecipesProps struct { 10 + Recipes []*models.Recipe 11 + } 12 + 13 + // PopularRecipes renders the popular recipes section for the home page 14 + templ PopularRecipes(props PopularRecipesProps) { 15 + if len(props.Recipes) > 0 { 16 + <div class="card p-4 sm:p-6 mb-6"> 17 + <div class="flex items-center justify-between mb-4"> 18 + <h3 class="text-lg font-semibold text-brown-900">Popular Recipes</h3> 19 + <a href="/recipes" class="text-sm text-brown-600 hover:text-brown-800 font-medium"> 20 + Explore all 21 + </a> 22 + </div> 23 + <div class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-3"> 24 + for _, recipe := range props.Recipes { 25 + @popularRecipeCard(recipe) 26 + } 27 + </div> 28 + </div> 29 + } 30 + } 31 + 32 + templ popularRecipeCard(recipe *models.Recipe) { 33 + <a 34 + href={ templ.SafeURL(fmt.Sprintf("/recipes/%s?owner=%s", recipe.RKey, recipe.AuthorDID)) } 35 + class="feed-card feed-card-recipe block hover:shadow-md transition-shadow" 36 + > 37 + <!-- Author --> 38 + <div class="flex items-center gap-2 mb-2"> 39 + if recipe.AuthorAvatar != "" { 40 + <img src={ recipe.AuthorAvatar } class="w-6 h-6 rounded-full object-cover" alt=""/> 41 + } else { 42 + <div class="w-6 h-6 rounded-full bg-brown-200 flex items-center justify-center text-brown-600 text-xs font-bold"> 43 + { authorInitial(recipe) } 44 + </div> 45 + } 46 + <span class="text-xs text-brown-600 truncate"> 47 + if recipe.AuthorDisplay != "" { 48 + { recipe.AuthorDisplay } 49 + } else if recipe.AuthorHandle != "" { 50 + { recipe.AuthorHandle } 51 + } 52 + </span> 53 + </div> 54 + <!-- Name + brewer --> 55 + <h4 class="font-semibold text-brown-900 truncate mb-1">{ recipe.Name }</h4> 56 + if recipe.BrewerType != "" { 57 + <p class="text-xs text-brown-600 mb-2">{ formatBrewerType(recipe.BrewerType) }</p> 58 + } 59 + <!-- Stats --> 60 + <div class="grid grid-cols-3 gap-1.5 mb-2"> 61 + <div class="text-center bg-brown-50/60 rounded py-1"> 62 + <span class="block text-[10px] text-brown-500 uppercase tracking-wide">Coffee</span> 63 + <span class="block text-xs font-medium text-brown-900">{ formatAmount(recipe.CoffeeAmount) }</span> 64 + </div> 65 + <div class="text-center bg-brown-50/60 rounded py-1"> 66 + <span class="block text-[10px] text-brown-500 uppercase tracking-wide">Water</span> 67 + <span class="block text-xs font-medium text-brown-900">{ formatAmount(recipe.WaterAmount) }</span> 68 + </div> 69 + <div class="text-center bg-brown-50/60 rounded py-1"> 70 + <span class="block text-[10px] text-brown-500 uppercase tracking-wide">Ratio</span> 71 + <span class="block text-xs font-medium text-brown-900">{ formatRatio(recipe) }</span> 72 + </div> 73 + </div> 74 + <!-- Brew/Fork counts --> 75 + if recipe.BrewCount > 0 || recipe.ForkCount > 0 { 76 + <div class="flex items-center gap-3 pt-2 border-t border-brown-200/60 text-xs text-brown-500"> 77 + if recipe.BrewCount > 0 { 78 + <span class="flex items-center gap-1"> 79 + @IconFlame() 80 + { fmt.Sprintf("%d brew%s", recipe.BrewCount, plural(recipe.BrewCount)) } 81 + </span> 82 + } 83 + if recipe.ForkCount > 0 { 84 + <span class="flex items-center gap-1"> 85 + @IconFork() 86 + { fmt.Sprintf("%d fork%s", recipe.ForkCount, plural(recipe.ForkCount)) } 87 + </span> 88 + } 89 + if len(recipe.ForkerAvatars) > 0 { 90 + <div class="flex -space-x-1.5 ml-auto"> 91 + for _, avatar := range recipe.ForkerAvatars[:min(3, len(recipe.ForkerAvatars))] { 92 + <img src={ avatar } class="w-5 h-5 rounded-full object-cover border border-white"/> 93 + } 94 + </div> 95 + } 96 + </div> 97 + } 98 + </a> 99 + } 100 + 101 + func authorInitial(recipe *models.Recipe) string { 102 + if recipe.AuthorDisplay != "" { 103 + return string([]rune(recipe.AuthorDisplay)[:1]) 104 + } 105 + if recipe.AuthorHandle != "" { 106 + return string([]rune(recipe.AuthorHandle)[:1]) 107 + } 108 + return "?" 109 + } 110 + 111 + func formatBrewerType(bt string) string { 112 + if label, ok := models.BrewerTypeLabels[bt]; ok { 113 + return label 114 + } 115 + return bt 116 + } 117 + 118 + func formatAmount(amount float64) string { 119 + if amount <= 0 { 120 + return "-" 121 + } 122 + if amount == float64(int(amount)) { 123 + return fmt.Sprintf("%dg", int(amount)) 124 + } 125 + return fmt.Sprintf("%.1fg", amount) 126 + } 127 + 128 + func formatRatio(recipe *models.Recipe) string { 129 + if recipe.Ratio > 0 { 130 + return fmt.Sprintf("1:%.1f", recipe.Ratio) 131 + } 132 + if recipe.CoffeeAmount > 0 && recipe.WaterAmount > 0 { 133 + return fmt.Sprintf("1:%.1f", recipe.WaterAmount/recipe.CoffeeAmount) 134 + } 135 + return "-" 136 + } 137 + 138 + func plural(n int) string { 139 + if n == 1 { 140 + return "" 141 + } 142 + return "s" 143 + }
+13 -7
internal/web/components/shared.templ
··· 223 223 <a href="/atproto" class="text-brown-700 hover:text-brown-900 transition-colors">(What is this?)</a> 224 224 </p> 225 225 </div> 226 - <div class="grid grid-cols-1 sm:grid-cols-3 gap-4"> 226 + <div class="grid grid-cols-2 sm:grid-cols-4 gap-3"> 227 227 <a 228 228 href="/brews/new" 229 - class="home-action-primary block text-center py-4 px-6 rounded-xl" 229 + class="home-action-primary block text-center py-4 px-4 rounded-xl" 230 230 > 231 - <span class="text-lg font-semibold">Log Brew</span> 231 + <span class="text-base font-semibold">Log Brew</span> 232 232 </a> 233 233 <a 234 234 href="/my-coffee" 235 - class="home-action-secondary block text-center py-4 px-6 rounded-xl" 235 + class="home-action-secondary block text-center py-4 px-4 rounded-xl" 236 236 > 237 - <span class="text-lg font-semibold">My Coffee</span> 237 + <span class="text-base font-semibold">My Coffee</span> 238 + </a> 239 + <a 240 + href="/recipes" 241 + class="home-action-secondary block text-center py-4 px-4 rounded-xl" 242 + > 243 + <span class="text-base font-semibold">Recipes</span> 238 244 </a> 239 245 <a 240 246 href={ templ.SafeURL("/profile/" + userDID) } 241 - class="home-action-secondary block text-center py-4 px-6 rounded-xl" 247 + class="home-action-secondary block text-center py-4 px-4 rounded-xl" 242 248 > 243 - <span class="text-lg font-semibold">Profile</span> 249 + <span class="text-base font-semibold">Profile</span> 244 250 </a> 245 251 </div> 246 252 }
+5
internal/web/pages/home.templ
··· 27 27 if !props.IsAuthenticated { 28 28 @components.AboutInfoCard() 29 29 } 30 + if props.IsAuthenticated { 31 + <!-- Popular recipes loaded async --> 32 + <div hx-get="/api/popular-recipes" hx-trigger="load" hx-swap="innerHTML"> 33 + </div> 34 + } 30 35 @CommunityFeedSection(props.IsAuthenticated) 31 36 if props.IsAuthenticated { 32 37 @components.AboutInfoCard()