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: slightly improve experience of clicking recipe on explore page

- This is mostly a stop-gap until I dedicate more time into making
this page better. The side-drawer thing on desktop might go away.

authored by

Patrick Dewey and committed by
Tangled
267de09e e3455224

+320 -250
+22
internal/handlers/recipe.go
··· 476 476 477 477 filtered := models.FilterRecipes(recipes, filter) 478 478 479 + // Sort results (default: popular) 480 + sortBy := r.URL.Query().Get("sort") 481 + if sortBy == "" { 482 + sortBy = "popular" 483 + } 484 + switch sortBy { 485 + case "popular": 486 + sort.Slice(filtered, func(i, j int) bool { 487 + si := filtered[i].BrewCount + filtered[i].ForkCount 488 + sj := filtered[j].BrewCount + filtered[j].ForkCount 489 + return si > sj 490 + }) 491 + case "newest": 492 + sort.Slice(filtered, func(i, j int) bool { 493 + return filtered[i].CreatedAt.After(filtered[j].CreatedAt) 494 + }) 495 + case "most_forked": 496 + sort.Slice(filtered, func(i, j int) bool { 497 + return filtered[i].ForkCount > filtered[j].ForkCount 498 + }) 499 + } 500 + 479 501 w.Header().Set("Content-Type", "application/json") 480 502 if err := json.NewEncoder(w).Encode(filtered); err != nil { 481 503 log.Error().Err(err).Msg("Failed to encode recipe suggestions response")
+286 -250
internal/web/pages/recipe_explore.templ
··· 133 133 </div> 134 134 </div> 135 135 </div> 136 - <!-- Results --> 136 + <!-- Sort + results count bar --> 137 + <div class="flex items-center justify-between mb-3"> 138 + <template x-if="!loading && recipes.length > 0"> 139 + <p class="text-sm text-brown-600"> 140 + <span x-text="recipes.length"></span> recipe(s) found 141 + </p> 142 + </template> 143 + <template x-if="loading || recipes.length === 0"> 144 + <div></div> 145 + </template> 146 + <div class="flex items-center gap-1 text-sm"> 147 + <span class="text-brown-500 mr-1">Sort:</span> 148 + <button 149 + type="button" 150 + class="filter-pill text-xs" 151 + @click="setSort('popular')" 152 + :class="sortBy === 'popular' ? 'filter-pill-active' : 'filter-pill'" 153 + > 154 + Popular 155 + </button> 156 + <button 157 + type="button" 158 + class="filter-pill text-xs" 159 + @click="setSort('newest')" 160 + :class="sortBy === 'newest' ? 'filter-pill-active' : 'filter-pill'" 161 + > 162 + Newest 163 + </button> 164 + <button 165 + type="button" 166 + class="filter-pill text-xs" 167 + @click="setSort('most_forked')" 168 + :class="sortBy === 'most_forked' ? 'filter-pill-active' : 'filter-pill'" 169 + > 170 + Most Forked 171 + </button> 172 + </div> 173 + </div> 174 + <!-- Selected recipe detail panel (above grid) --> 175 + <template x-if="selectedRecipe"> 176 + <div class="mb-4" x-ref="recipeDetail"> 177 + <div class="card card-inner"> 178 + <div class="flex justify-between items-start mb-4"> 179 + <div class="min-w-0 flex-1"> 180 + <h3 class="text-xl font-bold text-brown-900 break-words" x-text="selectedRecipe.name"></h3> 181 + <a 182 + :href="'/profile/' + selectedRecipe.author_did" 183 + class="flex items-center gap-2 mt-1 group/author" 184 + > 185 + <template x-if="selectedRecipe.author_avatar"> 186 + <img :src="selectedRecipe.author_avatar" class="w-6 h-6 rounded-full object-cover" :alt="selectedRecipe.author_display || ''" loading="lazy" width="24" height="24"/> 187 + </template> 188 + <template x-if="!selectedRecipe.author_avatar"> 189 + <div class="w-6 h-6 rounded-full bg-brown-200 flex items-center justify-center text-brown-600 text-xs font-bold" x-text="(selectedRecipe.author_display || selectedRecipe.author_handle || '?')[0].toUpperCase()"></div> 190 + </template> 191 + <div> 192 + <template x-if="selectedRecipe.author_display"> 193 + <span class="block text-sm font-medium text-brown-700 group-hover/author:text-brown-900 group-hover/author:underline transition-colors" x-text="selectedRecipe.author_display"></span> 194 + </template> 195 + <span class="block text-xs text-brown-600 group-hover/author:text-brown-800 transition-colors" x-text="selectedRecipe.author_handle || ''"></span> 196 + </div> 197 + </a> 198 + </div> 199 + <div class="flex items-center gap-2 shrink-0"> 200 + <!-- Actions dropdown --> 201 + <div class="relative" x-data="{ actionsOpen: false }"> 202 + <button 203 + type="button" 204 + @click="actionsOpen = !actionsOpen" 205 + @click.away="actionsOpen = false" 206 + class="action-btn" 207 + aria-label="More options" 208 + > 209 + <svg class="w-4 h-4" fill="none" stroke="currentColor" stroke-width="1.5" viewBox="0 0 24 24" aria-hidden="true"> 210 + <path stroke-linecap="round" stroke-linejoin="round" d="M6.75 12a.75.75 0 1 1-1.5 0 .75.75 0 0 1 1.5 0ZM12.75 12a.75.75 0 1 1-1.5 0 .75.75 0 0 1 1.5 0ZM18.75 12a.75.75 0 1 1-1.5 0 .75.75 0 0 1 1.5 0Z"></path> 211 + </svg> 212 + </button> 213 + <div 214 + x-show="actionsOpen" 215 + x-transition:enter="transition ease-out duration-100" 216 + x-transition:enter-start="transform opacity-0 scale-95" 217 + x-transition:enter-end="transform opacity-100 scale-100" 218 + x-transition:leave="transition ease-in duration-75" 219 + x-transition:leave-start="transform opacity-100 scale-100" 220 + x-transition:leave-end="transform opacity-0 scale-95" 221 + class="action-menu bottom-full mb-1" 222 + x-cloak 223 + > 224 + <!-- Share --> 225 + <button type="button" @click="shareRecipe(); actionsOpen = false" class="action-menu-item"> 226 + <svg class="w-4 h-4" fill="none" stroke="currentColor" stroke-width="1.5" viewBox="0 0 24 24" aria-hidden="true"> 227 + <path stroke-linecap="round" stroke-linejoin="round" d="M7.217 10.907a2.25 2.25 0 1 0 0 2.186m0-2.186c.18.324.283.696.283 1.093s-.103.77-.283 1.093m0-2.186 9.566-5.314m-9.566 7.5 9.566 5.314m0 0a2.25 2.25 0 1 0 3.935 2.186 2.25 2.25 0 0 0-3.935-2.186Zm0-12.814a2.25 2.25 0 1 0 3.933-2.185 2.25 2.25 0 0 0-3.933 2.185Z"></path> 228 + </svg> 229 + Share 230 + </button> 231 + <!-- Report (only for authenticated non-owners) --> 232 + <template x-if="isAuthenticated && !isOwner(selectedRecipe)"> 233 + <div> 234 + <div class="action-menu-divider"></div> 235 + <button type="button" @click="openReport(); actionsOpen = false" class="action-menu-item"> 236 + <svg class="w-4 h-4" fill="none" stroke="currentColor" stroke-width="1.5" viewBox="0 0 24 24" aria-hidden="true"> 237 + <path stroke-linecap="round" stroke-linejoin="round" d="M3 3v1.5M3 21v-6m0 0 2.77-.693a9 9 0 0 1 6.208.682l.108.054a9 9 0 0 0 6.086.71l3.114-.732a48.524 48.524 0 0 1-.005-10.499l-3.11.732a9 9 0 0 1-6.085-.711l-.108-.054a9 9 0 0 0-6.208-.682L3 4.5M3 15V4.5"></path> 238 + </svg> 239 + Report 240 + </button> 241 + </div> 242 + </template> 243 + </div> 244 + </div> 245 + <button @click="selectedRecipe = null" class="text-brown-500 hover:text-brown-700 text-lg font-bold" aria-label="Close recipe details">&times;</button> 246 + </div> 247 + </div> 248 + <template x-if="getBrewerDisplay(selectedRecipe) !== '-'"> 249 + <div class="mb-4"> 250 + <span class="text-xs text-brown-600 uppercase">Brewer</span> 251 + <p class="font-medium text-brown-900" x-text="getBrewerDisplay(selectedRecipe)"></p> 252 + </div> 253 + </template> 254 + <div class="grid grid-cols-3 gap-4 mb-4"> 255 + <div> 256 + <span class="text-xs text-brown-600 uppercase">Coffee</span> 257 + <p class="font-medium text-brown-900" x-text="selectedRecipe.coffee_amount > 0 ? selectedRecipe.coffee_amount.toFixed(1) + 'g' : '-'"></p> 258 + </div> 259 + <div> 260 + <span class="text-xs text-brown-600 uppercase">Water</span> 261 + <p class="font-medium text-brown-900" x-text="selectedRecipe.water_amount > 0 ? selectedRecipe.water_amount.toFixed(1) + 'g' : '-'"></p> 262 + </div> 263 + <div> 264 + <span class="text-xs text-brown-600 uppercase">Ratio</span> 265 + <p class="font-medium text-brown-900" x-text="formatRatio(selectedRecipe)"></p> 266 + </div> 267 + </div> 268 + <template x-if="selectedRecipe.pours && selectedRecipe.pours.length > 0"> 269 + <div class="mb-4"> 270 + <span class="text-xs text-brown-600 uppercase">Pours</span> 271 + <div class="flex flex-wrap gap-2 mt-1"> 272 + <template x-for="(pour, i) in selectedRecipe.pours" :key="i"> 273 + <span class="inline-flex items-center gap-1.5 text-xs bg-brown-50 px-2.5 py-1 rounded-full border border-brown-200"> 274 + <span class="font-medium text-brown-800" x-text="(i+1)"></span> 275 + <span class="text-brown-700" x-text="pour.water_amount + 'g'"></span> 276 + <span class="text-brown-400">&middot;</span> 277 + <span class="text-brown-600" x-text="pour.time_seconds + 's'"></span> 278 + </span> 279 + </template> 280 + </div> 281 + </div> 282 + </template> 283 + <template x-if="selectedRecipe.notes"> 284 + <div class="mb-4"> 285 + <span class="text-xs text-brown-600 uppercase">Notes</span> 286 + <p class="text-sm text-brown-800 mt-1 whitespace-pre-wrap" x-text="selectedRecipe.notes"></p> 287 + </div> 288 + </template> 289 + <template x-if="selectedRecipe.brew_count > 0 || selectedRecipe.fork_count > 0"> 290 + <div class="flex items-center gap-4 mb-4 text-sm text-brown-600"> 291 + <template x-if="selectedRecipe.brew_count > 0"> 292 + <span class="flex items-center gap-1.5"> 293 + <svg class="w-4 h-4" fill="none" stroke="currentColor" stroke-width="1.5" viewBox="0 0 24 24" aria-hidden="true"><path stroke-linecap="round" stroke-linejoin="round" d="M15.362 5.214A8.252 8.252 0 0 1 12 21 8.25 8.25 0 0 1 6.038 7.047 8.287 8.287 0 0 0 9 9.601a8.983 8.983 0 0 1 3.361-6.867 8.21 8.21 0 0 0 3 2.48Z"></path></svg> 294 + <span x-text="selectedRecipe.brew_count + ' brew' + (selectedRecipe.brew_count !== 1 ? 's' : '')"></span> 295 + </span> 296 + </template> 297 + <template x-if="selectedRecipe.fork_count > 0"> 298 + <span class="flex items-center gap-1.5"> 299 + <svg class="w-4 h-4" fill="none" stroke="currentColor" stroke-width="1.5" viewBox="0 0 24 24" aria-hidden="true"><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></svg> 300 + <span x-text="selectedRecipe.fork_count + ' fork' + (selectedRecipe.fork_count !== 1 ? 's' : '')"></span> 301 + <template x-if="selectedRecipe.forker_avatars && selectedRecipe.forker_avatars.length > 0"> 302 + <div class="flex -space-x-1.5 ml-1"> 303 + <template x-for="(avatar, i) in selectedRecipe.forker_avatars.slice(0, 5)" :key="i"> 304 + <img :src="avatar" alt="" class="w-5 h-5 rounded-full object-cover border border-white" loading="lazy" width="20" height="20"/> 305 + </template> 306 + </div> 307 + </template> 308 + </span> 309 + </template> 310 + </div> 311 + </template> 312 + <template x-if="selectedRecipe.source_ref"> 313 + <p class="text-sm text-brown-500 mb-3"> 314 + Forked from&#32; 315 + <template x-if="selectedRecipe.source_author_display || selectedRecipe.source_author_handle"> 316 + <a 317 + :href="getSourceRecipeURL(selectedRecipe)" 318 + class="text-brown-700 underline hover:text-brown-900" 319 + @click.stop 320 + x-text="(selectedRecipe.source_author_display || selectedRecipe.source_author_handle) + '\'s recipe'" 321 + ></a> 322 + </template> 323 + <template x-if="!selectedRecipe.source_author_display && !selectedRecipe.source_author_handle"> 324 + <span>another recipe</span> 325 + </template> 326 + </p> 327 + </template> 328 + <div class="flex flex-col sm:flex-row gap-2 sm:gap-3"> 329 + <a 330 + :href="'/brews/new?recipe=' + selectedRecipe.rkey + '&recipe_owner=' + (selectedRecipe.author_did || '')" 331 + class="btn-primary text-sm text-center" 332 + > 333 + Use in Brew 334 + </a> 335 + <template x-if="isAuthenticated && !isOwner(selectedRecipe)"> 336 + <button 337 + type="button" 338 + @click="forkRecipe()" 339 + class="btn-secondary text-sm" 340 + > 341 + Copy Recipe 342 + </button> 343 + </template> 344 + <a 345 + :href="'/recipes/' + selectedRecipe.rkey + '?owner=' + encodeURIComponent(selectedRecipe.author_handle || selectedRecipe.author_did)" 346 + class="btn-secondary text-sm text-center" 347 + > 348 + View Recipe 349 + </a> 350 + </div> 351 + </div> 352 + </div> 353 + </template> 354 + <!-- Recipe grid --> 137 355 <div> 138 356 <template x-if="loading"> 139 357 <div class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4"> ··· 176 394 </div> 177 395 </template> 178 396 <template x-if="!loading && recipes.length > 0"> 179 - <div> 180 - <p class="text-sm text-brown-600 mb-3"> 181 - <span x-text="recipes.length"></span> recipe(s) found 182 - </p> 183 - <div class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4"> 184 - <template x-for="recipe in recipes" :key="recipe.rkey"> 185 - <div 186 - class="feed-card feed-card-recipe cursor-pointer" 187 - @click="selectRecipe(recipe)" 397 + <div class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4"> 398 + <template x-for="recipe in recipes" :key="recipe.rkey"> 399 + <div 400 + class="feed-card feed-card-recipe cursor-pointer transition-shadow" 401 + :class="selectedRecipe && selectedRecipe.rkey === recipe.rkey ? 'ring-2 ring-brown-400' : ''" 402 + @click="selectRecipe(recipe)" 403 + > 404 + <!-- Author row --> 405 + <a 406 + :href="'/profile/' + recipe.author_did" 407 + class="flex items-center gap-2 mb-3 group/author" 408 + @click.stop 188 409 > 189 - <!-- Author row --> 190 - <a 191 - :href="'/profile/' + recipe.author_did" 192 - class="flex items-center gap-2 mb-3 group/author" 193 - @click.stop 194 - > 195 - <template x-if="recipe.author_avatar"> 196 - <img :src="recipe.author_avatar" class="w-7 h-7 rounded-full object-cover" :alt="recipe.author_display || recipe.author_handle || ''" loading="lazy" width="28" height="28"/> 197 - </template> 198 - <template x-if="!recipe.author_avatar"> 199 - <div class="w-7 h-7 rounded-full bg-brown-200 flex items-center justify-center text-brown-600 text-xs font-bold" x-text="(recipe.author_display || recipe.author_handle || '?')[0].toUpperCase()"></div> 200 - </template> 201 - <div class="min-w-0 flex-1"> 202 - <template x-if="recipe.author_display"> 203 - <span class="block truncate text-sm font-medium text-brown-700 group-hover/author:text-brown-900 group-hover/author:underline transition-colors" x-text="recipe.author_display"></span> 204 - </template> 205 - <span class="block truncate text-xs text-brown-600 group-hover/author:text-brown-800 transition-colors" x-text="recipe.author_handle || ''"></span> 206 - </div> 207 - </a> 208 - <!-- Recipe name --> 209 - <h3 class="font-semibold text-brown-900 mb-2 truncate" x-text="recipe.name"></h3> 210 - <!-- Brewer --> 211 - <template x-if="getBrewerDisplay(recipe) !== '-'"> 212 - <p class="text-sm text-brown-600 mb-3" x-text="getBrewerDisplay(recipe)"></p> 410 + <template x-if="recipe.author_avatar"> 411 + <img :src="recipe.author_avatar" class="w-7 h-7 rounded-full object-cover" :alt="recipe.author_display || recipe.author_handle || ''" loading="lazy" width="28" height="28"/> 213 412 </template> 214 - <!-- Stats grid --> 215 - <div class="grid grid-cols-3 gap-2 mb-3"> 216 - <div class="text-center bg-brown-50/60 rounded-md py-1.5"> 217 - <span class="block text-[10px] text-brown-500 uppercase tracking-wide">Coffee</span> 218 - <span class="block text-sm font-medium text-brown-900" x-text="recipe.coffee_amount > 0 ? recipe.coffee_amount.toFixed(1) + 'g' : '-'"></span> 219 - </div> 220 - <div class="text-center bg-brown-50/60 rounded-md py-1.5"> 221 - <span class="block text-[10px] text-brown-500 uppercase tracking-wide">Water</span> 222 - <span class="block text-sm font-medium text-brown-900" x-text="recipe.water_amount > 0 ? recipe.water_amount.toFixed(1) + 'g' : '-'"></span> 223 - </div> 224 - <div class="text-center bg-brown-50/60 rounded-md py-1.5"> 225 - <span class="block text-[10px] text-brown-500 uppercase tracking-wide">Ratio</span> 226 - <span class="block text-sm font-medium text-brown-900" x-text="formatRatio(recipe)"></span> 227 - </div> 413 + <template x-if="!recipe.author_avatar"> 414 + <div class="w-7 h-7 rounded-full bg-brown-200 flex items-center justify-center text-brown-600 text-xs font-bold" x-text="(recipe.author_display || recipe.author_handle || '?')[0].toUpperCase()"></div> 415 + </template> 416 + <div class="min-w-0 flex-1"> 417 + <template x-if="recipe.author_display"> 418 + <span class="block truncate text-sm font-medium text-brown-700 group-hover/author:text-brown-900 group-hover/author:underline transition-colors" x-text="recipe.author_display"></span> 419 + </template> 420 + <span class="block truncate text-xs text-brown-600 group-hover/author:text-brown-800 transition-colors" x-text="recipe.author_handle || ''"></span> 228 421 </div> 229 - <!-- Counts row --> 230 - <template x-if="recipe.brew_count > 0 || recipe.fork_count > 0"> 231 - <div class="flex items-center gap-3 pt-2 border-t border-brown-200/60 text-xs text-brown-500"> 232 - <template x-if="recipe.brew_count > 0"> 233 - <span class="flex items-center gap-1" :title="recipe.brew_count + ' brews'"> 234 - <svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" stroke-width="1.5" viewBox="0 0 24 24" aria-hidden="true"><path stroke-linecap="round" stroke-linejoin="round" d="M15.362 5.214A8.252 8.252 0 0 1 12 21 8.25 8.25 0 0 1 6.038 7.047 8.287 8.287 0 0 0 9 9.601a8.983 8.983 0 0 1 3.361-6.867 8.21 8.21 0 0 0 3 2.48Z"></path></svg> 235 - <span x-text="recipe.brew_count + ' brew' + (recipe.brew_count !== 1 ? 's' : '')"></span> 236 - </span> 237 - </template> 238 - <template x-if="recipe.fork_count > 0"> 239 - <span class="flex items-center gap-1" :title="recipe.fork_count + ' forks'"> 240 - <svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" stroke-width="1.5" viewBox="0 0 24 24" aria-hidden="true"><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></svg> 241 - <span x-text="recipe.fork_count + ' fork' + (recipe.fork_count !== 1 ? 's' : '')"></span> 242 - </span> 243 - </template> 244 - <template x-if="recipe.forker_avatars && recipe.forker_avatars.length > 0"> 245 - <div class="flex -space-x-1.5 ml-auto"> 246 - <template x-for="(avatar, i) in recipe.forker_avatars.slice(0, 3)" :key="i"> 247 - <img :src="avatar" alt="" class="w-5 h-5 rounded-full object-cover border border-white" loading="lazy" width="20" height="20"/> 248 - </template> 249 - </div> 250 - </template> 251 - </div> 252 - </template> 253 - </div> 254 - </template> 255 - </div> 256 - </div> 257 - </template> 258 - </div> 259 - <!-- Recipe detail panel --> 260 - <template x-if="selectedRecipe"> 261 - <div class="card card-inner mt-4"> 262 - <div class="flex justify-between items-start mb-4"> 263 - <div> 264 - <h3 class="text-xl font-bold text-brown-900" x-text="selectedRecipe.name"></h3> 265 - <a 266 - :href="'/profile/' + selectedRecipe.author_did" 267 - class="flex items-center gap-2 mt-1 group/author" 268 - > 269 - <template x-if="selectedRecipe.author_avatar"> 270 - <img :src="selectedRecipe.author_avatar" class="w-6 h-6 rounded-full object-cover" :alt="selectedRecipe.author_display || ''" loading="lazy" width="24" height="24"/> 271 - </template> 272 - <template x-if="!selectedRecipe.author_avatar"> 273 - <div class="w-6 h-6 rounded-full bg-brown-200 flex items-center justify-center text-brown-600 text-xs font-bold" x-text="(selectedRecipe.author_display || selectedRecipe.author_handle || '?')[0].toUpperCase()"></div> 422 + </a> 423 + <!-- Recipe name --> 424 + <h3 class="font-semibold text-brown-900 mb-2 truncate" x-text="recipe.name"></h3> 425 + <!-- Brewer --> 426 + <template x-if="getBrewerDisplay(recipe) !== '-'"> 427 + <p class="text-sm text-brown-600 mb-3" x-text="getBrewerDisplay(recipe)"></p> 274 428 </template> 275 - <div> 276 - <template x-if="selectedRecipe.author_display"> 277 - <span class="block text-sm font-medium text-brown-700 group-hover/author:text-brown-900 group-hover/author:underline transition-colors" x-text="selectedRecipe.author_display"></span> 278 - </template> 279 - <span class="block text-xs text-brown-600 group-hover/author:text-brown-800 transition-colors" x-text="selectedRecipe.author_handle || ''"></span> 280 - </div> 281 - </a> 282 - </div> 283 - <div class="flex items-center gap-2"> 284 - <!-- Actions dropdown --> 285 - <div class="relative" x-data="{ actionsOpen: false }"> 286 - <button 287 - type="button" 288 - @click="actionsOpen = !actionsOpen" 289 - @click.away="actionsOpen = false" 290 - class="action-btn" 291 - aria-label="More options" 292 - > 293 - <svg class="w-4 h-4" fill="none" stroke="currentColor" stroke-width="1.5" viewBox="0 0 24 24" aria-hidden="true"> 294 - <path stroke-linecap="round" stroke-linejoin="round" d="M6.75 12a.75.75 0 1 1-1.5 0 .75.75 0 0 1 1.5 0ZM12.75 12a.75.75 0 1 1-1.5 0 .75.75 0 0 1 1.5 0ZM18.75 12a.75.75 0 1 1-1.5 0 .75.75 0 0 1 1.5 0Z"></path> 295 - </svg> 296 - </button> 297 - <div 298 - x-show="actionsOpen" 299 - x-transition:enter="transition ease-out duration-100" 300 - x-transition:enter-start="transform opacity-0 scale-95" 301 - x-transition:enter-end="transform opacity-100 scale-100" 302 - x-transition:leave="transition ease-in duration-75" 303 - x-transition:leave-start="transform opacity-100 scale-100" 304 - x-transition:leave-end="transform opacity-0 scale-95" 305 - class="action-menu bottom-full mb-1" 306 - x-cloak 307 - > 308 - <!-- Share --> 309 - <button type="button" @click="shareRecipe(); actionsOpen = false" class="action-menu-item"> 310 - <svg class="w-4 h-4" fill="none" stroke="currentColor" stroke-width="1.5" viewBox="0 0 24 24" aria-hidden="true"> 311 - <path stroke-linecap="round" stroke-linejoin="round" d="M7.217 10.907a2.25 2.25 0 1 0 0 2.186m0-2.186c.18.324.283.696.283 1.093s-.103.77-.283 1.093m0-2.186 9.566-5.314m-9.566 7.5 9.566 5.314m0 0a2.25 2.25 0 1 0 3.935 2.186 2.25 2.25 0 0 0-3.935-2.186Zm0-12.814a2.25 2.25 0 1 0 3.933-2.185 2.25 2.25 0 0 0-3.933 2.185Z"></path> 312 - </svg> 313 - Share 314 - </button> 315 - <!-- Report (only for authenticated non-owners) --> 316 - <template x-if="isAuthenticated && !isOwner(selectedRecipe)"> 317 - <div> 318 - <div class="action-menu-divider"></div> 319 - <button type="button" @click="openReport(); actionsOpen = false" class="action-menu-item"> 320 - <svg class="w-4 h-4" fill="none" stroke="currentColor" stroke-width="1.5" viewBox="0 0 24 24" aria-hidden="true"> 321 - <path stroke-linecap="round" stroke-linejoin="round" d="M3 3v1.5M3 21v-6m0 0 2.77-.693a9 9 0 0 1 6.208.682l.108.054a9 9 0 0 0 6.086.71l3.114-.732a48.524 48.524 0 0 1-.005-10.499l-3.11.732a9 9 0 0 1-6.085-.711l-.108-.054a9 9 0 0 0-6.208-.682L3 4.5M3 15V4.5"></path> 322 - </svg> 323 - Report 324 - </button> 325 - </div> 326 - </template> 429 + <!-- Stats grid --> 430 + <div class="grid grid-cols-3 gap-2 mb-3"> 431 + <div class="text-center bg-brown-50/60 rounded-md py-1.5"> 432 + <span class="block text-[10px] text-brown-500 uppercase tracking-wide">Coffee</span> 433 + <span class="block text-sm font-medium text-brown-900" x-text="recipe.coffee_amount > 0 ? recipe.coffee_amount.toFixed(1) + 'g' : '-'"></span> 434 + </div> 435 + <div class="text-center bg-brown-50/60 rounded-md py-1.5"> 436 + <span class="block text-[10px] text-brown-500 uppercase tracking-wide">Water</span> 437 + <span class="block text-sm font-medium text-brown-900" x-text="recipe.water_amount > 0 ? recipe.water_amount.toFixed(1) + 'g' : '-'"></span> 438 + </div> 439 + <div class="text-center bg-brown-50/60 rounded-md py-1.5"> 440 + <span class="block text-[10px] text-brown-500 uppercase tracking-wide">Ratio</span> 441 + <span class="block text-sm font-medium text-brown-900" x-text="formatRatio(recipe)"></span> 442 + </div> 327 443 </div> 328 - </div> 329 - <button @click="selectedRecipe = null" class="text-brown-500 hover:text-brown-700 text-lg font-bold" aria-label="Close recipe details">&times;</button> 330 - </div> 331 - </div> 332 - <template x-if="getBrewerDisplay(selectedRecipe) !== '-'"> 333 - <div class="mb-4"> 334 - <span class="text-xs text-brown-600 uppercase">Brewer</span> 335 - <p class="font-medium text-brown-900" x-text="getBrewerDisplay(selectedRecipe)"></p> 336 - </div> 337 - </template> 338 - <div class="grid grid-cols-3 gap-4 mb-4"> 339 - <div> 340 - <span class="text-xs text-brown-600 uppercase">Coffee</span> 341 - <p class="font-medium text-brown-900" x-text="selectedRecipe.coffee_amount > 0 ? selectedRecipe.coffee_amount.toFixed(1) + 'g' : '-'"></p> 342 - </div> 343 - <div> 344 - <span class="text-xs text-brown-600 uppercase">Water</span> 345 - <p class="font-medium text-brown-900" x-text="selectedRecipe.water_amount > 0 ? selectedRecipe.water_amount.toFixed(1) + 'g' : '-'"></p> 346 - </div> 347 - <div> 348 - <span class="text-xs text-brown-600 uppercase">Ratio</span> 349 - <p class="font-medium text-brown-900" x-text="formatRatio(selectedRecipe)"></p> 350 - </div> 351 - </div> 352 - <template x-if="selectedRecipe.pours && selectedRecipe.pours.length > 0"> 353 - <div class="mb-4"> 354 - <span class="text-xs text-brown-600 uppercase">Pours</span> 355 - <div class="flex flex-wrap gap-2 mt-1"> 356 - <template x-for="(pour, i) in selectedRecipe.pours" :key="i"> 357 - <span class="inline-flex items-center gap-1.5 text-xs bg-brown-50 px-2.5 py-1 rounded-full border border-brown-200"> 358 - <span class="font-medium text-brown-800" x-text="(i+1)"></span> 359 - <span class="text-brown-700" x-text="pour.water_amount + 'g'"></span> 360 - <span class="text-brown-400">&middot;</span> 361 - <span class="text-brown-600" x-text="pour.time_seconds + 's'"></span> 362 - </span> 444 + <!-- Counts row --> 445 + <template x-if="recipe.brew_count > 0 || recipe.fork_count > 0"> 446 + <div class="flex items-center gap-3 pt-2 border-t border-brown-200/60 text-xs text-brown-500"> 447 + <template x-if="recipe.brew_count > 0"> 448 + <span class="flex items-center gap-1" :title="recipe.brew_count + ' brews'"> 449 + <svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" stroke-width="1.5" viewBox="0 0 24 24" aria-hidden="true"><path stroke-linecap="round" stroke-linejoin="round" d="M15.362 5.214A8.252 8.252 0 0 1 12 21 8.25 8.25 0 0 1 6.038 7.047 8.287 8.287 0 0 0 9 9.601a8.983 8.983 0 0 1 3.361-6.867 8.21 8.21 0 0 0 3 2.48Z"></path></svg> 450 + <span x-text="recipe.brew_count + ' brew' + (recipe.brew_count !== 1 ? 's' : '')"></span> 451 + </span> 452 + </template> 453 + <template x-if="recipe.fork_count > 0"> 454 + <span class="flex items-center gap-1" :title="recipe.fork_count + ' forks'"> 455 + <svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" stroke-width="1.5" viewBox="0 0 24 24" aria-hidden="true"><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></svg> 456 + <span x-text="recipe.fork_count + ' fork' + (recipe.fork_count !== 1 ? 's' : '')"></span> 457 + </span> 458 + </template> 459 + <template x-if="recipe.forker_avatars && recipe.forker_avatars.length > 0"> 460 + <div class="flex -space-x-1.5 ml-auto"> 461 + <template x-for="(avatar, i) in recipe.forker_avatars.slice(0, 3)" :key="i"> 462 + <img :src="avatar" alt="" class="w-5 h-5 rounded-full object-cover border border-white" loading="lazy" width="20" height="20"/> 463 + </template> 464 + </div> 465 + </template> 466 + </div> 363 467 </template> 364 468 </div> 365 - </div> 366 - </template> 367 - <template x-if="selectedRecipe.notes"> 368 - <div class="mb-4"> 369 - <span class="text-xs text-brown-600 uppercase">Notes</span> 370 - <p class="text-sm text-brown-800 mt-1 whitespace-pre-wrap" x-text="selectedRecipe.notes"></p> 371 - </div> 372 - </template> 373 - <template x-if="selectedRecipe.brew_count > 0 || selectedRecipe.fork_count > 0"> 374 - <div class="flex items-center gap-4 mb-4 text-sm text-brown-600"> 375 - <template x-if="selectedRecipe.brew_count > 0"> 376 - <span class="flex items-center gap-1.5"> 377 - <svg class="w-4 h-4" fill="none" stroke="currentColor" stroke-width="1.5" viewBox="0 0 24 24" aria-hidden="true"><path stroke-linecap="round" stroke-linejoin="round" d="M15.362 5.214A8.252 8.252 0 0 1 12 21 8.25 8.25 0 0 1 6.038 7.047 8.287 8.287 0 0 0 9 9.601a8.983 8.983 0 0 1 3.361-6.867 8.21 8.21 0 0 0 3 2.48Z"></path></svg> 378 - <span x-text="selectedRecipe.brew_count + ' brew' + (selectedRecipe.brew_count !== 1 ? 's' : '')"></span> 379 - </span> 380 - </template> 381 - <template x-if="selectedRecipe.fork_count > 0"> 382 - <span class="flex items-center gap-1.5"> 383 - <svg class="w-4 h-4" fill="none" stroke="currentColor" stroke-width="1.5" viewBox="0 0 24 24" aria-hidden="true"><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></svg> 384 - <span x-text="selectedRecipe.fork_count + ' fork' + (selectedRecipe.fork_count !== 1 ? 's' : '')"></span> 385 - <template x-if="selectedRecipe.forker_avatars && selectedRecipe.forker_avatars.length > 0"> 386 - <div class="flex -space-x-1.5 ml-1"> 387 - <template x-for="(avatar, i) in selectedRecipe.forker_avatars.slice(0, 5)" :key="i"> 388 - <img :src="avatar" alt="" class="w-5 h-5 rounded-full object-cover border border-white" loading="lazy" width="20" height="20"/> 389 - </template> 390 - </div> 391 - </template> 392 - </span> 393 - </template> 394 - </div> 395 - </template> 396 - <template x-if="selectedRecipe.source_ref"> 397 - <p class="text-sm text-brown-500 mb-3"> 398 - Forked from&#32; 399 - <template x-if="selectedRecipe.source_author_display || selectedRecipe.source_author_handle"> 400 - <a 401 - :href="getSourceRecipeURL(selectedRecipe)" 402 - class="text-brown-700 underline hover:text-brown-900" 403 - @click.stop 404 - x-text="(selectedRecipe.source_author_display || selectedRecipe.source_author_handle) + '\'s recipe'" 405 - ></a> 406 - </template> 407 - <template x-if="!selectedRecipe.source_author_display && !selectedRecipe.source_author_handle"> 408 - <span>another recipe</span> 409 - </template> 410 - </p> 411 - </template> 412 - <div class="flex flex-col sm:flex-row gap-2 sm:gap-3"> 413 - <a 414 - :href="'/brews/new?recipe=' + selectedRecipe.rkey + '&recipe_owner=' + (selectedRecipe.author_did || '')" 415 - class="btn-primary text-sm text-center" 416 - > 417 - Use in Brew 418 - </a> 419 - <template x-if="isAuthenticated && !isOwner(selectedRecipe)"> 420 - <button 421 - type="button" 422 - @click="forkRecipe()" 423 - class="btn-secondary text-sm" 424 - > 425 - Copy Recipe 426 - </button> 427 469 </template> 428 - <a 429 - :href="'/recipes/' + selectedRecipe.rkey + '?owner=' + encodeURIComponent(selectedRecipe.author_handle || selectedRecipe.author_did)" 430 - class="btn-secondary text-sm text-center" 431 - > 432 - View Recipe 433 - </a> 434 470 </div> 435 - </div> 436 - </template> 471 + </template> 472 + </div> 437 473 <!-- Report dialog --> 438 474 <dialog id="recipe-report-modal" class="modal-dialog" x-data="{ reason: '', charCount: 0, submitting: false, error: '', success: false }"> 439 475 <div class="modal-content">
+12
static/js/recipe-explore.js
··· 9 9 brewerType: "", 10 10 minCoffee: "", 11 11 maxCoffee: "", 12 + sortBy: "popular", 12 13 loading: false, 13 14 recipes: [], 14 15 selectedRecipe: null, ··· 21 22 22 23 setCategory(cat) { 23 24 this.category = cat; 25 + this.search(); 26 + }, 27 + 28 + setSort(sort) { 29 + this.sortBy = sort; 24 30 this.search(); 25 31 }, 26 32 ··· 33 39 if (this.brewerType) params.set("brewer_type", this.brewerType); 34 40 if (this.minCoffee) params.set("min_coffee", this.minCoffee); 35 41 if (this.maxCoffee) params.set("max_coffee", this.maxCoffee); 42 + if (this.sortBy) params.set("sort", this.sortBy); 36 43 37 44 const resp = await fetch(`/api/recipes/suggestions?${params}`, { 38 45 credentials: "same-origin", ··· 51 58 52 59 selectRecipe(recipe) { 53 60 this.selectedRecipe = recipe; 61 + this.$nextTick(() => { 62 + if (this.$refs.recipeDetail) { 63 + this.$refs.recipeDetail.scrollIntoView({ behavior: "smooth", block: "start" }); 64 + } 65 + }); 54 66 }, 55 67 56 68 formatRatio(recipe) {