- 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.
+365
-207
Diff
round #0
+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")
+330
-207
internal/web/pages/recipe_explore.templ
+330
-207
internal/web/pages/recipe_explore.templ
···
133
133
</div>
134
134
</div>
135
135
</div>
136
-
<!-- Results -->
137
-
<div>
138
-
<template x-if="loading">
139
-
<div class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4">
140
-
<div class="feed-card animate-pulse">
141
-
<div class="h-4 bg-brown-200 rounded w-1/3 mb-3"></div>
142
-
<div class="h-5 bg-brown-200 rounded w-2/3 mb-2"></div>
143
-
<div class="h-4 bg-brown-200 rounded w-1/2 mb-3"></div>
144
-
<div class="grid grid-cols-3 gap-2">
145
-
<div class="h-12 bg-brown-100 rounded"></div>
146
-
<div class="h-12 bg-brown-100 rounded"></div>
147
-
<div class="h-12 bg-brown-100 rounded"></div>
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
+
<!-- Results + detail side panel -->
175
+
<div class="flex gap-6 items-start">
176
+
<!-- Recipe grid (shrinks when detail panel is open on desktop) -->
177
+
<div class="min-w-0" :class="selectedRecipe ? 'flex-1 lg:w-3/5' : 'w-full'">
178
+
<template x-if="loading">
179
+
<div class="grid gap-4" :class="selectedRecipe ? 'grid-cols-1 sm:grid-cols-2' : 'grid-cols-1 sm:grid-cols-2 lg:grid-cols-3'">
180
+
<div class="feed-card animate-pulse">
181
+
<div class="h-4 bg-brown-200 rounded w-1/3 mb-3"></div>
182
+
<div class="h-5 bg-brown-200 rounded w-2/3 mb-2"></div>
183
+
<div class="h-4 bg-brown-200 rounded w-1/2 mb-3"></div>
184
+
<div class="grid grid-cols-3 gap-2">
185
+
<div class="h-12 bg-brown-100 rounded"></div>
186
+
<div class="h-12 bg-brown-100 rounded"></div>
187
+
<div class="h-12 bg-brown-100 rounded"></div>
188
+
</div>
148
189
</div>
149
-
</div>
150
-
<div class="feed-card animate-pulse hidden sm:block">
151
-
<div class="h-4 bg-brown-200 rounded w-1/3 mb-3"></div>
152
-
<div class="h-5 bg-brown-200 rounded w-2/3 mb-2"></div>
153
-
<div class="h-4 bg-brown-200 rounded w-1/2 mb-3"></div>
154
-
<div class="grid grid-cols-3 gap-2">
155
-
<div class="h-12 bg-brown-100 rounded"></div>
156
-
<div class="h-12 bg-brown-100 rounded"></div>
157
-
<div class="h-12 bg-brown-100 rounded"></div>
190
+
<div class="feed-card animate-pulse hidden sm:block">
191
+
<div class="h-4 bg-brown-200 rounded w-1/3 mb-3"></div>
192
+
<div class="h-5 bg-brown-200 rounded w-2/3 mb-2"></div>
193
+
<div class="h-4 bg-brown-200 rounded w-1/2 mb-3"></div>
194
+
<div class="grid grid-cols-3 gap-2">
195
+
<div class="h-12 bg-brown-100 rounded"></div>
196
+
<div class="h-12 bg-brown-100 rounded"></div>
197
+
<div class="h-12 bg-brown-100 rounded"></div>
198
+
</div>
158
199
</div>
159
200
</div>
160
-
<div class="feed-card animate-pulse hidden lg:block">
161
-
<div class="h-4 bg-brown-200 rounded w-1/3 mb-3"></div>
162
-
<div class="h-5 bg-brown-200 rounded w-2/3 mb-2"></div>
163
-
<div class="h-4 bg-brown-200 rounded w-1/2 mb-3"></div>
164
-
<div class="grid grid-cols-3 gap-2">
165
-
<div class="h-12 bg-brown-100 rounded"></div>
166
-
<div class="h-12 bg-brown-100 rounded"></div>
167
-
<div class="h-12 bg-brown-100 rounded"></div>
168
-
</div>
201
+
</template>
202
+
<template x-if="!loading && recipes.length === 0">
203
+
<div class="card card-inner text-center py-8">
204
+
<p class="text-brown-700 text-lg font-medium">No recipes found</p>
205
+
<p class="text-sm text-brown-600 mt-2">Try adjusting your filters or search terms</p>
169
206
</div>
170
-
</div>
171
-
</template>
172
-
<template x-if="!loading && recipes.length === 0">
173
-
<div class="card card-inner text-center py-8">
174
-
<p class="text-brown-700 text-lg font-medium">No recipes found</p>
175
-
<p class="text-sm text-brown-600 mt-2">Try adjusting your filters or search terms</p>
176
-
</div>
177
-
</template>
178
-
<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">
207
+
</template>
208
+
<template x-if="!loading && recipes.length > 0">
209
+
<div class="grid gap-4" :class="selectedRecipe ? 'grid-cols-1 sm:grid-cols-2' : 'grid-cols-1 sm:grid-cols-2 lg:grid-cols-3'">
184
210
<template x-for="recipe in recipes" :key="recipe.rkey">
185
211
<div
186
-
class="feed-card feed-card-recipe cursor-pointer"
212
+
class="feed-card feed-card-recipe cursor-pointer transition-shadow"
213
+
:class="selectedRecipe && selectedRecipe.rkey === recipe.rkey ? 'ring-2 ring-brown-400' : ''"
187
214
@click="selectRecipe(recipe)"
188
215
>
189
216
<!-- Author row -->
···
253
280
</div>
254
281
</template>
255
282
</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>
274
-
</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)">
283
+
</template>
284
+
</div>
285
+
<!-- Recipe detail side panel (sticky on desktop, below grid on mobile) -->
286
+
<template x-if="selectedRecipe">
287
+
<div class="hidden lg:block lg:w-2/5 lg:sticky lg:top-4">
288
+
<div class="card card-inner">
289
+
<div class="flex justify-between items-start mb-4">
290
+
<div class="min-w-0 flex-1">
291
+
<h3 class="text-xl font-bold text-brown-900 break-words" x-text="selectedRecipe.name"></h3>
292
+
<a
293
+
:href="'/profile/' + selectedRecipe.author_did"
294
+
class="flex items-center gap-2 mt-1 group/author"
295
+
>
296
+
<template x-if="selectedRecipe.author_avatar">
297
+
<img :src="selectedRecipe.author_avatar" class="w-6 h-6 rounded-full object-cover" :alt="selectedRecipe.author_display || ''" loading="lazy" width="24" height="24"/>
298
+
</template>
299
+
<template x-if="!selectedRecipe.author_avatar">
300
+
<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>
301
+
</template>
317
302
<div>
318
-
<div class="action-menu-divider"></div>
319
-
<button type="button" @click="openReport(); actionsOpen = false" class="action-menu-item">
303
+
<template x-if="selectedRecipe.author_display">
304
+
<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>
305
+
</template>
306
+
<span class="block text-xs text-brown-600 group-hover/author:text-brown-800 transition-colors" x-text="selectedRecipe.author_handle || ''"></span>
307
+
</div>
308
+
</a>
309
+
</div>
310
+
<div class="flex items-center gap-2 shrink-0">
311
+
<!-- Actions dropdown -->
312
+
<div class="relative" x-data="{ actionsOpen: false }">
313
+
<button
314
+
type="button"
315
+
@click="actionsOpen = !actionsOpen"
316
+
@click.away="actionsOpen = false"
317
+
class="action-btn"
318
+
aria-label="More options"
319
+
>
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="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>
322
+
</svg>
323
+
</button>
324
+
<div
325
+
x-show="actionsOpen"
326
+
x-transition:enter="transition ease-out duration-100"
327
+
x-transition:enter-start="transform opacity-0 scale-95"
328
+
x-transition:enter-end="transform opacity-100 scale-100"
329
+
x-transition:leave="transition ease-in duration-75"
330
+
x-transition:leave-start="transform opacity-100 scale-100"
331
+
x-transition:leave-end="transform opacity-0 scale-95"
332
+
class="action-menu bottom-full mb-1"
333
+
x-cloak
334
+
>
335
+
<!-- Share -->
336
+
<button type="button" @click="shareRecipe(); actionsOpen = false" class="action-menu-item">
320
337
<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>
338
+
<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>
322
339
</svg>
323
-
Report
340
+
Share
324
341
</button>
342
+
<!-- Report (only for authenticated non-owners) -->
343
+
<template x-if="isAuthenticated && !isOwner(selectedRecipe)">
344
+
<div>
345
+
<div class="action-menu-divider"></div>
346
+
<button type="button" @click="openReport(); actionsOpen = false" class="action-menu-item">
347
+
<svg class="w-4 h-4" fill="none" stroke="currentColor" stroke-width="1.5" viewBox="0 0 24 24" aria-hidden="true">
348
+
<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>
349
+
</svg>
350
+
Report
351
+
</button>
352
+
</div>
353
+
</template>
325
354
</div>
326
-
</template>
355
+
</div>
356
+
<button @click="selectedRecipe = null" class="text-brown-500 hover:text-brown-700 text-lg font-bold" aria-label="Close recipe details">×</button>
327
357
</div>
328
358
</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>
363
-
</template>
359
+
<template x-if="getBrewerDisplay(selectedRecipe) !== '-'">
360
+
<div class="mb-4">
361
+
<span class="text-xs text-brown-600 uppercase">Brewer</span>
362
+
<p class="font-medium text-brown-900" x-text="getBrewerDisplay(selectedRecipe)"></p>
363
+
</div>
364
+
</template>
365
+
<div class="grid grid-cols-3 gap-4 mb-4">
366
+
<div>
367
+
<span class="text-xs text-brown-600 uppercase">Coffee</span>
368
+
<p class="font-medium text-brown-900" x-text="selectedRecipe.coffee_amount > 0 ? selectedRecipe.coffee_amount.toFixed(1) + 'g' : '-'"></p>
369
+
</div>
370
+
<div>
371
+
<span class="text-xs text-brown-600 uppercase">Water</span>
372
+
<p class="font-medium text-brown-900" x-text="selectedRecipe.water_amount > 0 ? selectedRecipe.water_amount.toFixed(1) + 'g' : '-'"></p>
373
+
</div>
374
+
<div>
375
+
<span class="text-xs text-brown-600 uppercase">Ratio</span>
376
+
<p class="font-medium text-brown-900" x-text="formatRatio(selectedRecipe)"></p>
377
+
</div>
364
378
</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>
379
+
<template x-if="selectedRecipe.pours && selectedRecipe.pours.length > 0">
380
+
<div class="mb-4">
381
+
<span class="text-xs text-brown-600 uppercase">Pours</span>
382
+
<div class="flex flex-wrap gap-2 mt-1">
383
+
<template x-for="(pour, i) in selectedRecipe.pours" :key="i">
384
+
<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">
385
+
<span class="font-medium text-brown-800" x-text="(i+1)"></span>
386
+
<span class="text-brown-700" x-text="pour.water_amount + 'g'"></span>
387
+
<span class="text-brown-400">·</span>
388
+
<span class="text-brown-600" x-text="pour.time_seconds + 's'"></span>
389
+
</span>
390
+
</template>
391
+
</div>
392
+
</div>
380
393
</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"/>
394
+
<template x-if="selectedRecipe.notes">
395
+
<div class="mb-4">
396
+
<span class="text-xs text-brown-600 uppercase">Notes</span>
397
+
<p class="text-sm text-brown-800 mt-1 whitespace-pre-wrap" x-text="selectedRecipe.notes"></p>
398
+
</div>
399
+
</template>
400
+
<template x-if="selectedRecipe.brew_count > 0 || selectedRecipe.fork_count > 0">
401
+
<div class="flex items-center gap-4 mb-4 text-sm text-brown-600">
402
+
<template x-if="selectedRecipe.brew_count > 0">
403
+
<span class="flex items-center gap-1.5">
404
+
<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>
405
+
<span x-text="selectedRecipe.brew_count + ' brew' + (selectedRecipe.brew_count !== 1 ? 's' : '')"></span>
406
+
</span>
407
+
</template>
408
+
<template x-if="selectedRecipe.fork_count > 0">
409
+
<span class="flex items-center gap-1.5">
410
+
<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>
411
+
<span x-text="selectedRecipe.fork_count + ' fork' + (selectedRecipe.fork_count !== 1 ? 's' : '')"></span>
412
+
<template x-if="selectedRecipe.forker_avatars && selectedRecipe.forker_avatars.length > 0">
413
+
<div class="flex -space-x-1.5 ml-1">
414
+
<template x-for="(avatar, i) in selectedRecipe.forker_avatars.slice(0, 5)" :key="i">
415
+
<img :src="avatar" alt="" class="w-5 h-5 rounded-full object-cover border border-white" loading="lazy" width="20" height="20"/>
416
+
</template>
417
+
</div>
389
418
</template>
390
-
</div>
419
+
</span>
391
420
</template>
392
-
</span>
421
+
</div>
393
422
</template>
423
+
<template x-if="selectedRecipe.source_ref">
424
+
<p class="text-sm text-brown-500 mb-3">
425
+
Forked from 
426
+
<template x-if="selectedRecipe.source_author_display || selectedRecipe.source_author_handle">
427
+
<a
428
+
:href="getSourceRecipeURL(selectedRecipe)"
429
+
class="text-brown-700 underline hover:text-brown-900"
430
+
@click.stop
431
+
x-text="(selectedRecipe.source_author_display || selectedRecipe.source_author_handle) + '\'s recipe'"
432
+
></a>
433
+
</template>
434
+
<template x-if="!selectedRecipe.source_author_display && !selectedRecipe.source_author_handle">
435
+
<span>another recipe</span>
436
+
</template>
437
+
</p>
438
+
</template>
439
+
<div class="flex flex-col gap-2">
440
+
<a
441
+
:href="'/brews/new?recipe=' + selectedRecipe.rkey + '&recipe_owner=' + (selectedRecipe.author_did || '')"
442
+
class="btn-primary text-sm text-center"
443
+
>
444
+
Use in Brew
445
+
</a>
446
+
<template x-if="isAuthenticated && !isOwner(selectedRecipe)">
447
+
<button
448
+
type="button"
449
+
@click="forkRecipe()"
450
+
class="btn-secondary text-sm"
451
+
>
452
+
Copy Recipe
453
+
</button>
454
+
</template>
455
+
<a
456
+
:href="'/recipes/' + selectedRecipe.rkey + '?owner=' + encodeURIComponent(selectedRecipe.author_handle || selectedRecipe.author_did)"
457
+
class="btn-secondary text-sm text-center"
458
+
>
459
+
View Recipe
460
+
</a>
461
+
</div>
394
462
</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">
463
+
</div>
464
+
</template>
465
+
</div>
466
+
<!-- Mobile detail panel (shown below grid on small screens) -->
467
+
<template x-if="selectedRecipe">
468
+
<div class="lg:hidden mt-4" x-ref="mobileDetail">
469
+
<div class="card card-inner">
470
+
<div class="flex justify-between items-start mb-4">
471
+
<div class="min-w-0 flex-1">
472
+
<h3 class="text-xl font-bold text-brown-900 break-words" x-text="selectedRecipe.name"></h3>
400
473
<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>
474
+
:href="'/profile/' + selectedRecipe.author_did"
475
+
class="flex items-center gap-2 mt-1 group/author"
476
+
>
477
+
<template x-if="selectedRecipe.author_avatar">
478
+
<img :src="selectedRecipe.author_avatar" class="w-6 h-6 rounded-full object-cover" :alt="selectedRecipe.author_display || ''" loading="lazy" width="24" height="24"/>
479
+
</template>
480
+
<template x-if="!selectedRecipe.author_avatar">
481
+
<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>
482
+
</template>
483
+
<div>
484
+
<template x-if="selectedRecipe.author_display">
485
+
<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>
486
+
</template>
487
+
<span class="block text-xs text-brown-600 group-hover/author:text-brown-800 transition-colors" x-text="selectedRecipe.author_handle || ''"></span>
488
+
</div>
489
+
</a>
490
+
</div>
491
+
<button @click="selectedRecipe = null" class="text-brown-500 hover:text-brown-700 text-lg font-bold shrink-0" aria-label="Close recipe details">×</button>
492
+
</div>
493
+
<template x-if="getBrewerDisplay(selectedRecipe) !== '-'">
494
+
<div class="mb-4">
495
+
<span class="text-xs text-brown-600 uppercase">Brewer</span>
496
+
<p class="font-medium text-brown-900" x-text="getBrewerDisplay(selectedRecipe)"></p>
497
+
</div>
498
+
</template>
499
+
<div class="grid grid-cols-3 gap-4 mb-4">
500
+
<div>
501
+
<span class="text-xs text-brown-600 uppercase">Coffee</span>
502
+
<p class="font-medium text-brown-900" x-text="selectedRecipe.coffee_amount > 0 ? selectedRecipe.coffee_amount.toFixed(1) + 'g' : '-'"></p>
503
+
</div>
504
+
<div>
505
+
<span class="text-xs text-brown-600 uppercase">Water</span>
506
+
<p class="font-medium text-brown-900" x-text="selectedRecipe.water_amount > 0 ? selectedRecipe.water_amount.toFixed(1) + 'g' : '-'"></p>
507
+
</div>
508
+
<div>
509
+
<span class="text-xs text-brown-600 uppercase">Ratio</span>
510
+
<p class="font-medium text-brown-900" x-text="formatRatio(selectedRecipe)"></p>
511
+
</div>
512
+
</div>
513
+
<template x-if="selectedRecipe.pours && selectedRecipe.pours.length > 0">
514
+
<div class="mb-4">
515
+
<span class="text-xs text-brown-600 uppercase">Pours</span>
516
+
<div class="flex flex-wrap gap-2 mt-1">
517
+
<template x-for="(pour, i) in selectedRecipe.pours" :key="i">
518
+
<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">
519
+
<span class="font-medium text-brown-800" x-text="(i+1)"></span>
520
+
<span class="text-brown-700" x-text="pour.water_amount + 'g'"></span>
521
+
<span class="text-brown-400">·</span>
522
+
<span class="text-brown-600" x-text="pour.time_seconds + 's'"></span>
523
+
</span>
524
+
</template>
525
+
</div>
526
+
</div>
527
+
</template>
528
+
<template x-if="selectedRecipe.notes">
529
+
<div class="mb-4">
530
+
<span class="text-xs text-brown-600 uppercase">Notes</span>
531
+
<p class="text-sm text-brown-800 mt-1 whitespace-pre-wrap" x-text="selectedRecipe.notes"></p>
532
+
</div>
533
+
</template>
534
+
<div class="flex flex-col gap-2">
535
+
<a
536
+
:href="'/brews/new?recipe=' + selectedRecipe.rkey + '&recipe_owner=' + (selectedRecipe.author_did || '')"
537
+
class="btn-primary text-sm text-center"
538
+
>
539
+
Use in Brew
540
+
</a>
541
+
<template x-if="isAuthenticated && !isOwner(selectedRecipe)">
542
+
<button
543
+
type="button"
544
+
@click="forkRecipe()"
545
+
class="btn-secondary text-sm"
546
+
>
547
+
Copy Recipe
548
+
</button>
409
549
</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"
550
+
<a
551
+
:href="'/recipes/' + selectedRecipe.rkey + '?owner=' + encodeURIComponent(selectedRecipe.author_handle || selectedRecipe.author_did)"
552
+
class="btn-secondary text-sm text-center"
424
553
>
425
-
Copy Recipe
426
-
</button>
427
-
</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>
554
+
View Recipe
555
+
</a>
556
+
</div>
434
557
</div>
435
558
</div>
436
559
</template>
+13
static/js/recipe-explore.js
+13
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
+
// On mobile (no side panel), scroll to the detail panel
62
+
this.$nextTick(() => {
63
+
if (window.innerWidth < 1024 && this.$refs.mobileDetail) {
64
+
this.$refs.mobileDetail.scrollIntoView({ behavior: "smooth", block: "start" });
65
+
}
66
+
});
54
67
},
55
68
56
69
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.