feat: title search and sort controls in recipe browser
Adds minimal sort/search to the recipe browser for cognitive access diversity — linear scanners, alphabet browsers, and keyword diggers each get a different way in without duplicating the full search tab. - browse_recipes: q (LIKE title filter) + sort (default/alpha/alpha_desc) - API endpoint: q/sort query params with validation - Frontend: debounced search input (350ms) + sort pills (Default/A→Z/Z→A) - Search and sort reset on domain/category change - _all path supports q+sort; keyword-FTS path adds AND filter on top
This commit is contained in:
parent
e7ba305e63
commit
5385adc52a
5 changed files with 139 additions and 27 deletions
|
|
@ -168,12 +168,15 @@ async def browse_recipes(
|
|||
page_size: Annotated[int, Query(ge=1, le=100)] = 20,
|
||||
pantry_items: Annotated[str | None, Query()] = None,
|
||||
subcategory: Annotated[str | None, Query()] = None,
|
||||
q: Annotated[str | None, Query(max_length=200)] = None,
|
||||
sort: Annotated[str, Query(pattern="^(default|alpha|alpha_desc)$")] = "default",
|
||||
session: CloudUser = Depends(get_session),
|
||||
) -> dict:
|
||||
"""Return a paginated list of recipes for a domain/category.
|
||||
|
||||
Pass pantry_items as a comma-separated string to receive match_pct badges.
|
||||
Pass subcategory to narrow within a category that has subcategories.
|
||||
Pass q to filter by title substring. Pass sort for ordering (default/alpha/alpha_desc).
|
||||
"""
|
||||
if domain not in DOMAINS:
|
||||
raise HTTPException(status_code=404, detail=f"Unknown domain '{domain}'.")
|
||||
|
|
@ -209,6 +212,8 @@ async def browse_recipes(
|
|||
page=page,
|
||||
page_size=page_size,
|
||||
pantry_items=pantry_list,
|
||||
q=q or None,
|
||||
sort=sort,
|
||||
)
|
||||
store.log_browser_telemetry(
|
||||
domain=domain,
|
||||
|
|
|
|||
|
|
@ -1116,6 +1116,8 @@ class Store:
|
|||
page: int,
|
||||
page_size: int,
|
||||
pantry_items: list[str] | None = None,
|
||||
q: str | None = None,
|
||||
sort: str = "default",
|
||||
) -> dict:
|
||||
"""Return a page of recipes matching the keyword set.
|
||||
|
||||
|
|
@ -1123,6 +1125,9 @@ class Store:
|
|||
Each recipe row includes match_pct (float | None) when pantry_items
|
||||
is provided. match_pct is the fraction of ingredient_names covered by
|
||||
the pantry set — computed deterministically, no LLM needed.
|
||||
|
||||
q: optional title substring filter (case-insensitive LIKE).
|
||||
sort: "default" (corpus order) | "alpha" (A→Z) | "alpha_desc" (Z→A).
|
||||
"""
|
||||
if keywords is not None and not keywords:
|
||||
return {"recipes": [], "total": 0, "page": page}
|
||||
|
|
@ -1130,35 +1135,50 @@ class Store:
|
|||
offset = (page - 1) * page_size
|
||||
c = self._cp
|
||||
|
||||
order_clause = {
|
||||
"alpha": "ORDER BY title ASC",
|
||||
"alpha_desc": "ORDER BY title DESC",
|
||||
}.get(sort, "ORDER BY id ASC")
|
||||
|
||||
q_param = f"%{q.strip()}%" if q and q.strip() else None
|
||||
cols = (
|
||||
f"SELECT id, title, category, keywords, ingredient_names,"
|
||||
f" calories, fat_g, protein_g, sodium_mg FROM {c}recipes"
|
||||
)
|
||||
|
||||
if keywords is None:
|
||||
# "All" browse — unfiltered paginated scan.
|
||||
if q_param:
|
||||
total = self.conn.execute(
|
||||
f"SELECT COUNT(*) FROM {c}recipes WHERE LOWER(title) LIKE LOWER(?)",
|
||||
(q_param,),
|
||||
).fetchone()[0]
|
||||
rows = self._fetch_all(
|
||||
f"{cols} WHERE LOWER(title) LIKE LOWER(?) {order_clause} LIMIT ? OFFSET ?",
|
||||
(q_param, page_size, offset),
|
||||
)
|
||||
else:
|
||||
total = self.conn.execute(f"SELECT COUNT(*) FROM {c}recipes").fetchone()[0]
|
||||
rows = self._fetch_all(
|
||||
f"""
|
||||
SELECT id, title, category, keywords, ingredient_names,
|
||||
calories, fat_g, protein_g, sodium_mg
|
||||
FROM {c}recipes
|
||||
ORDER BY id ASC
|
||||
LIMIT ? OFFSET ?
|
||||
""",
|
||||
f"{cols} {order_clause} LIMIT ? OFFSET ?",
|
||||
(page_size, offset),
|
||||
)
|
||||
else:
|
||||
match_expr = self._browser_fts_query(keywords)
|
||||
fts_sub = f"id IN (SELECT rowid FROM {c}recipe_browser_fts WHERE recipe_browser_fts MATCH ?)"
|
||||
if q_param:
|
||||
total = self.conn.execute(
|
||||
f"SELECT COUNT(*) FROM {c}recipes WHERE {fts_sub} AND LOWER(title) LIKE LOWER(?)",
|
||||
(match_expr, q_param),
|
||||
).fetchone()[0]
|
||||
rows = self._fetch_all(
|
||||
f"{cols} WHERE {fts_sub} AND LOWER(title) LIKE LOWER(?) {order_clause} LIMIT ? OFFSET ?",
|
||||
(match_expr, q_param, page_size, offset),
|
||||
)
|
||||
else:
|
||||
# Reuse cached count — avoids a second index scan on every page turn.
|
||||
total = self._count_recipes_for_keywords(keywords)
|
||||
rows = self._fetch_all(
|
||||
f"""
|
||||
SELECT id, title, category, keywords, ingredient_names,
|
||||
calories, fat_g, protein_g, sodium_mg
|
||||
FROM {c}recipes
|
||||
WHERE id IN (
|
||||
SELECT rowid FROM {c}recipe_browser_fts
|
||||
WHERE recipe_browser_fts MATCH ?
|
||||
)
|
||||
ORDER BY id ASC
|
||||
LIMIT ? OFFSET ?
|
||||
""",
|
||||
f"{cols} WHERE {fts_sub} {order_clause} LIMIT ? OFFSET ?",
|
||||
(match_expr, page_size, offset),
|
||||
)
|
||||
|
||||
|
|
|
|||
|
|
@ -78,6 +78,34 @@
|
|||
<div v-if="loadingRecipes" class="text-secondary text-sm">Loading recipes…</div>
|
||||
|
||||
<template v-else>
|
||||
<!-- Search + sort controls -->
|
||||
<div class="browser-controls flex gap-sm mb-sm flex-wrap align-center">
|
||||
<input
|
||||
v-model="searchQuery"
|
||||
@input="onSearchInput"
|
||||
type="search"
|
||||
placeholder="Filter by title…"
|
||||
class="browser-search"
|
||||
/>
|
||||
<div class="sort-btns flex gap-xs">
|
||||
<button
|
||||
:class="['btn', 'btn-secondary', 'sort-btn', { active: sortOrder === 'default' }]"
|
||||
@click="setSort('default')"
|
||||
title="Corpus order"
|
||||
>Default</button>
|
||||
<button
|
||||
:class="['btn', 'btn-secondary', 'sort-btn', { active: sortOrder === 'alpha' }]"
|
||||
@click="setSort('alpha')"
|
||||
title="Alphabetical A→Z"
|
||||
>A→Z</button>
|
||||
<button
|
||||
:class="['btn', 'btn-secondary', 'sort-btn', { active: sortOrder === 'alpha_desc' }]"
|
||||
@click="setSort('alpha_desc')"
|
||||
title="Alphabetical Z→A"
|
||||
>Z→A</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="results-header flex-between mb-sm">
|
||||
<span class="text-sm text-secondary">
|
||||
{{ total }} recipes
|
||||
|
|
@ -183,6 +211,9 @@ const pageSize = 20
|
|||
const loadingDomains = ref(false)
|
||||
const loadingRecipes = ref(false)
|
||||
const savingRecipe = ref<BrowserRecipe | null>(null)
|
||||
const searchQuery = ref('')
|
||||
const sortOrder = ref<'default' | 'alpha' | 'alpha_desc'>('default')
|
||||
let searchDebounce: ReturnType<typeof setTimeout> | null = null
|
||||
|
||||
const totalPages = computed(() => Math.max(1, Math.ceil(total.value / pageSize)))
|
||||
const allCountsZero = computed(() =>
|
||||
|
|
@ -219,12 +250,29 @@ onMounted(async () => {
|
|||
if (!savedStore.savedIds.size) savedStore.load()
|
||||
})
|
||||
|
||||
function onSearchInput() {
|
||||
if (searchDebounce) clearTimeout(searchDebounce)
|
||||
searchDebounce = setTimeout(() => {
|
||||
page.value = 1
|
||||
loadRecipes()
|
||||
}, 350)
|
||||
}
|
||||
|
||||
function setSort(s: 'default' | 'alpha' | 'alpha_desc') {
|
||||
if (sortOrder.value === s) return
|
||||
sortOrder.value = s
|
||||
page.value = 1
|
||||
loadRecipes()
|
||||
}
|
||||
|
||||
async function selectDomain(domainId: string) {
|
||||
activeDomain.value = domainId
|
||||
activeCategory.value = null
|
||||
recipes.value = []
|
||||
total.value = 0
|
||||
page.value = 1
|
||||
searchQuery.value = ''
|
||||
sortOrder.value = 'default'
|
||||
categories.value = await browserAPI.listCategories(domainId)
|
||||
// Auto-select the most-populated category so content appears immediately.
|
||||
// Skip when all counts are 0 (corpus not seeded) — no point loading an empty result.
|
||||
|
|
@ -247,6 +295,8 @@ async function selectCategory(category: string) {
|
|||
activeSubcategory.value = null
|
||||
subcategories.value = []
|
||||
page.value = 1
|
||||
searchQuery.value = ''
|
||||
sortOrder.value = 'default'
|
||||
|
||||
// Fetch subcategories in the background when the category supports them,
|
||||
// then immediately start loading recipes at the full-category level.
|
||||
|
|
@ -286,6 +336,8 @@ async function loadRecipes() {
|
|||
? pantryItems.value.join(',')
|
||||
: undefined,
|
||||
subcategory: activeSubcategory.value ?? undefined,
|
||||
q: searchQuery.value.trim() || undefined,
|
||||
sort: sortOrder.value !== 'default' ? sortOrder.value : undefined,
|
||||
}
|
||||
)
|
||||
recipes.value = result.recipes
|
||||
|
|
@ -378,6 +430,38 @@ async function doUnsave(recipeId: number) {
|
|||
color: white;
|
||||
}
|
||||
|
||||
.browser-controls {
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.browser-search {
|
||||
flex: 1;
|
||||
min-width: 120px;
|
||||
max-width: 260px;
|
||||
padding: var(--spacing-xs) var(--spacing-sm);
|
||||
font-size: var(--font-size-sm);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-sm);
|
||||
background: var(--color-bg);
|
||||
color: var(--color-text);
|
||||
}
|
||||
|
||||
.browser-search:focus {
|
||||
outline: none;
|
||||
border-color: var(--color-primary);
|
||||
}
|
||||
|
||||
.sort-btn {
|
||||
font-size: var(--font-size-xs, 0.75rem);
|
||||
padding: 2px var(--spacing-sm);
|
||||
}
|
||||
|
||||
.sort-btn.active {
|
||||
background: var(--color-primary);
|
||||
color: white;
|
||||
border-color: var(--color-primary);
|
||||
}
|
||||
|
||||
.recipe-grid {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
|
|
|||
|
|
@ -924,6 +924,8 @@ export const browserAPI = {
|
|||
page_size?: number
|
||||
pantry_items?: string
|
||||
subcategory?: string
|
||||
q?: string
|
||||
sort?: string
|
||||
}): Promise<BrowserResult> {
|
||||
const response = await api.get(`/recipes/browse/${domain}/${encodeURIComponent(category)}`, { params })
|
||||
return response.data
|
||||
|
|
|
|||
|
|
@ -111,6 +111,7 @@
|
|||
justify-content: flex-end;
|
||||
align-items: center;
|
||||
}
|
||||
.align-center { align-items: center; }
|
||||
|
||||
.flex-responsive {
|
||||
display: flex;
|
||||
|
|
|
|||
Loading…
Reference in a new issue