diff --git a/app/api/endpoints/recipes.py b/app/api/endpoints/recipes.py index f2e221d..5900de3 100644 --- a/app/api/endpoints/recipes.py +++ b/app/api/endpoints/recipes.py @@ -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, diff --git a/app/db/store.py b/app/db/store.py index d491d03..a58884b 100644 --- a/app/db/store.py +++ b/app/db/store.py @@ -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,37 +1135,52 @@ 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. - 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 ? - """, - (page_size, offset), - ) + 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"{cols} {order_clause} LIMIT ? OFFSET ?", + (page_size, offset), + ) else: match_expr = self._browser_fts_query(keywords) - # 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 ? + 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"{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 recipes = [] diff --git a/frontend/src/components/RecipeBrowserPanel.vue b/frontend/src/components/RecipeBrowserPanel.vue index 1a94160..28aa39b 100644 --- a/frontend/src/components/RecipeBrowserPanel.vue +++ b/frontend/src/components/RecipeBrowserPanel.vue @@ -78,6 +78,34 @@
Loading recipes…