- 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.
+320
-250
Diff
round #1
+22
internal/handlers/recipe.go
+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
+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">×</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">·</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 
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>
228
-
</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>
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>
252
415
</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>
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>
421
+
</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">×</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">·</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 
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
+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,
···
24
25
this.search();
25
26
},
26
27
28
+
setSort(sort) {
29
+
this.sortBy = sort;
30
+
this.search();
31
+
},
32
+
27
33
async search() {
28
34
this.loading = true;
29
35
try {
···
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) {
History
2 rounds
0 comments
pdewey.com
submitted
#1
1 commit
expand
collapse
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.
merge conflicts detected
expand
collapse
expand
collapse
expand 0 comments
pdewey.com
submitted
#0
1 commit
expand
collapse
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.