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,
|
page_size: Annotated[int, Query(ge=1, le=100)] = 20,
|
||||||
pantry_items: Annotated[str | None, Query()] = None,
|
pantry_items: Annotated[str | None, Query()] = None,
|
||||||
subcategory: 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),
|
session: CloudUser = Depends(get_session),
|
||||||
) -> dict:
|
) -> dict:
|
||||||
"""Return a paginated list of recipes for a domain/category.
|
"""Return a paginated list of recipes for a domain/category.
|
||||||
|
|
||||||
Pass pantry_items as a comma-separated string to receive match_pct badges.
|
Pass pantry_items as a comma-separated string to receive match_pct badges.
|
||||||
Pass subcategory to narrow within a category that has subcategories.
|
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:
|
if domain not in DOMAINS:
|
||||||
raise HTTPException(status_code=404, detail=f"Unknown domain '{domain}'.")
|
raise HTTPException(status_code=404, detail=f"Unknown domain '{domain}'.")
|
||||||
|
|
@ -209,6 +212,8 @@ async def browse_recipes(
|
||||||
page=page,
|
page=page,
|
||||||
page_size=page_size,
|
page_size=page_size,
|
||||||
pantry_items=pantry_list,
|
pantry_items=pantry_list,
|
||||||
|
q=q or None,
|
||||||
|
sort=sort,
|
||||||
)
|
)
|
||||||
store.log_browser_telemetry(
|
store.log_browser_telemetry(
|
||||||
domain=domain,
|
domain=domain,
|
||||||
|
|
|
||||||
|
|
@ -1116,6 +1116,8 @@ class Store:
|
||||||
page: int,
|
page: int,
|
||||||
page_size: int,
|
page_size: int,
|
||||||
pantry_items: list[str] | None = None,
|
pantry_items: list[str] | None = None,
|
||||||
|
q: str | None = None,
|
||||||
|
sort: str = "default",
|
||||||
) -> dict:
|
) -> dict:
|
||||||
"""Return a page of recipes matching the keyword set.
|
"""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
|
Each recipe row includes match_pct (float | None) when pantry_items
|
||||||
is provided. match_pct is the fraction of ingredient_names covered by
|
is provided. match_pct is the fraction of ingredient_names covered by
|
||||||
the pantry set — computed deterministically, no LLM needed.
|
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:
|
if keywords is not None and not keywords:
|
||||||
return {"recipes": [], "total": 0, "page": page}
|
return {"recipes": [], "total": 0, "page": page}
|
||||||
|
|
@ -1130,37 +1135,52 @@ class Store:
|
||||||
offset = (page - 1) * page_size
|
offset = (page - 1) * page_size
|
||||||
c = self._cp
|
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:
|
if keywords is None:
|
||||||
# "All" browse — unfiltered paginated scan.
|
if q_param:
|
||||||
total = self.conn.execute(f"SELECT COUNT(*) FROM {c}recipes").fetchone()[0]
|
total = self.conn.execute(
|
||||||
rows = self._fetch_all(
|
f"SELECT COUNT(*) FROM {c}recipes WHERE LOWER(title) LIKE LOWER(?)",
|
||||||
f"""
|
(q_param,),
|
||||||
SELECT id, title, category, keywords, ingredient_names,
|
).fetchone()[0]
|
||||||
calories, fat_g, protein_g, sodium_mg
|
rows = self._fetch_all(
|
||||||
FROM {c}recipes
|
f"{cols} WHERE LOWER(title) LIKE LOWER(?) {order_clause} LIMIT ? OFFSET ?",
|
||||||
ORDER BY id ASC
|
(q_param, page_size, offset),
|
||||||
LIMIT ? OFFSET ?
|
)
|
||||||
""",
|
else:
|
||||||
(page_size, offset),
|
total = self.conn.execute(f"SELECT COUNT(*) FROM {c}recipes").fetchone()[0]
|
||||||
)
|
rows = self._fetch_all(
|
||||||
|
f"{cols} {order_clause} LIMIT ? OFFSET ?",
|
||||||
|
(page_size, offset),
|
||||||
|
)
|
||||||
else:
|
else:
|
||||||
match_expr = self._browser_fts_query(keywords)
|
match_expr = self._browser_fts_query(keywords)
|
||||||
# Reuse cached count — avoids a second index scan on every page turn.
|
fts_sub = f"id IN (SELECT rowid FROM {c}recipe_browser_fts WHERE recipe_browser_fts MATCH ?)"
|
||||||
total = self._count_recipes_for_keywords(keywords)
|
if q_param:
|
||||||
rows = self._fetch_all(
|
total = self.conn.execute(
|
||||||
f"""
|
f"SELECT COUNT(*) FROM {c}recipes WHERE {fts_sub} AND LOWER(title) LIKE LOWER(?)",
|
||||||
SELECT id, title, category, keywords, ingredient_names,
|
(match_expr, q_param),
|
||||||
calories, fat_g, protein_g, sodium_mg
|
).fetchone()[0]
|
||||||
FROM {c}recipes
|
rows = self._fetch_all(
|
||||||
WHERE id IN (
|
f"{cols} WHERE {fts_sub} AND LOWER(title) LIKE LOWER(?) {order_clause} LIMIT ? OFFSET ?",
|
||||||
SELECT rowid FROM {c}recipe_browser_fts
|
(match_expr, q_param, page_size, offset),
|
||||||
WHERE recipe_browser_fts MATCH ?
|
)
|
||||||
|
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"{cols} WHERE {fts_sub} {order_clause} LIMIT ? OFFSET ?",
|
||||||
|
(match_expr, page_size, offset),
|
||||||
)
|
)
|
||||||
ORDER BY id ASC
|
|
||||||
LIMIT ? OFFSET ?
|
|
||||||
""",
|
|
||||||
(match_expr, page_size, offset),
|
|
||||||
)
|
|
||||||
|
|
||||||
pantry_set = {p.lower() for p in pantry_items} if pantry_items else None
|
pantry_set = {p.lower() for p in pantry_items} if pantry_items else None
|
||||||
recipes = []
|
recipes = []
|
||||||
|
|
|
||||||
|
|
@ -78,6 +78,34 @@
|
||||||
<div v-if="loadingRecipes" class="text-secondary text-sm">Loading recipes…</div>
|
<div v-if="loadingRecipes" class="text-secondary text-sm">Loading recipes…</div>
|
||||||
|
|
||||||
<template v-else>
|
<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">
|
<div class="results-header flex-between mb-sm">
|
||||||
<span class="text-sm text-secondary">
|
<span class="text-sm text-secondary">
|
||||||
{{ total }} recipes
|
{{ total }} recipes
|
||||||
|
|
@ -183,6 +211,9 @@ const pageSize = 20
|
||||||
const loadingDomains = ref(false)
|
const loadingDomains = ref(false)
|
||||||
const loadingRecipes = ref(false)
|
const loadingRecipes = ref(false)
|
||||||
const savingRecipe = ref<BrowserRecipe | null>(null)
|
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 totalPages = computed(() => Math.max(1, Math.ceil(total.value / pageSize)))
|
||||||
const allCountsZero = computed(() =>
|
const allCountsZero = computed(() =>
|
||||||
|
|
@ -219,12 +250,29 @@ onMounted(async () => {
|
||||||
if (!savedStore.savedIds.size) savedStore.load()
|
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) {
|
async function selectDomain(domainId: string) {
|
||||||
activeDomain.value = domainId
|
activeDomain.value = domainId
|
||||||
activeCategory.value = null
|
activeCategory.value = null
|
||||||
recipes.value = []
|
recipes.value = []
|
||||||
total.value = 0
|
total.value = 0
|
||||||
page.value = 1
|
page.value = 1
|
||||||
|
searchQuery.value = ''
|
||||||
|
sortOrder.value = 'default'
|
||||||
categories.value = await browserAPI.listCategories(domainId)
|
categories.value = await browserAPI.listCategories(domainId)
|
||||||
// Auto-select the most-populated category so content appears immediately.
|
// 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.
|
// 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
|
activeSubcategory.value = null
|
||||||
subcategories.value = []
|
subcategories.value = []
|
||||||
page.value = 1
|
page.value = 1
|
||||||
|
searchQuery.value = ''
|
||||||
|
sortOrder.value = 'default'
|
||||||
|
|
||||||
// Fetch subcategories in the background when the category supports them,
|
// Fetch subcategories in the background when the category supports them,
|
||||||
// then immediately start loading recipes at the full-category level.
|
// then immediately start loading recipes at the full-category level.
|
||||||
|
|
@ -286,6 +336,8 @@ async function loadRecipes() {
|
||||||
? pantryItems.value.join(',')
|
? pantryItems.value.join(',')
|
||||||
: undefined,
|
: undefined,
|
||||||
subcategory: activeSubcategory.value ?? undefined,
|
subcategory: activeSubcategory.value ?? undefined,
|
||||||
|
q: searchQuery.value.trim() || undefined,
|
||||||
|
sort: sortOrder.value !== 'default' ? sortOrder.value : undefined,
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
recipes.value = result.recipes
|
recipes.value = result.recipes
|
||||||
|
|
@ -378,6 +430,38 @@ async function doUnsave(recipeId: number) {
|
||||||
color: white;
|
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 {
|
.recipe-grid {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
|
|
|
||||||
|
|
@ -924,6 +924,8 @@ export const browserAPI = {
|
||||||
page_size?: number
|
page_size?: number
|
||||||
pantry_items?: string
|
pantry_items?: string
|
||||||
subcategory?: string
|
subcategory?: string
|
||||||
|
q?: string
|
||||||
|
sort?: string
|
||||||
}): Promise<BrowserResult> {
|
}): Promise<BrowserResult> {
|
||||||
const response = await api.get(`/recipes/browse/${domain}/${encodeURIComponent(category)}`, { params })
|
const response = await api.get(`/recipes/browse/${domain}/${encodeURIComponent(category)}`, { params })
|
||||||
return response.data
|
return response.data
|
||||||
|
|
|
||||||
|
|
@ -111,6 +111,7 @@
|
||||||
justify-content: flex-end;
|
justify-content: flex-end;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
}
|
}
|
||||||
|
.align-center { align-items: center; }
|
||||||
|
|
||||||
.flex-responsive {
|
.flex-responsive {
|
||||||
display: flex;
|
display: flex;
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue