feat: hierarchical subcategory navigation in recipe browser
Adds a two-level browse tree (domain → category → subcategory) to the
recipe browser, plus an "All" unfiltered option at the top of every
domain.
browser_domains.py:
- Category values now support list[str] (flat) or dict with "keywords"
and "subcategories" keys — backward compatible with all existing flat
categories
- Added subcategories to: Italian (Sicilian, Neapolitan, Tuscan, Roman,
Venetian, Ligurian), Mexican (Oaxacan, Yucatecan, Veracruz, Street
Food, Mole), Asian (Korean, Japanese, Chinese, Thai, Vietnamese,
Filipino, Indonesian), Indian (North, South, Bengali, Gujarati),
Mediterranean (Greek, Turkish, Moroccan, Lebanese, Israeli), American
(Southern, Cajun/Creole, BBQ, Tex-Mex, New England), European
(French, Spanish, German, British/Irish, Scandinavian), Latin American
(Peruvian, Brazilian, Colombian, Cuban, Caribbean), Dinner, Lunch,
Breakfast, Snack, Dessert, Chicken, Beef, Pork, Fish, Vegetables
- New helpers: category_has_subcategories, get_subcategory_names,
get_keywords_for_subcategory
store.py:
- get_browser_categories now accepts has_subcategories_by_category and
includes has_subcategories: bool in each result row
- New get_browser_subcategories method for subcategory count queries
recipes.py endpoints:
- GET /browse/{domain}/{category}/subcategories — returns subcategory
list with recipe counts (registered before /{subcategory} to avoid
path collision)
- GET /browse/{domain}/{category} gains optional ?subcategory=X param
to narrow results within a category
- GET /browse/{domain}/{category}/_all — unfiltered paginated browse
(landed in previous commit)
api.ts: BrowserCategory adds has_subcategories; new BrowserSubcategory
type; listSubcategories() call; browse() gains subcategory param
RecipeBrowserPanel.vue:
- Category pills show a › indicator when subcategories exist
- Selecting such a category fetches subcategories in the background
(non-blocking — recipes load immediately at the category level)
- Subcategory row appears below the category list with an
"All [Category]" pill + one pill per subcategory with count
- Active subcategory highlighted; clicking "All [Category]" resets
to the full category view
This commit is contained in:
parent
b2c546e86a
commit
e7ba305e63
5 changed files with 535 additions and 69 deletions
|
|
@ -28,9 +28,12 @@ from app.services.recipe.assembly_recipes import (
|
|||
)
|
||||
from app.services.recipe.browser_domains import (
|
||||
DOMAINS,
|
||||
category_has_subcategories,
|
||||
get_category_names,
|
||||
get_domain_labels,
|
||||
get_keywords_for_category,
|
||||
get_keywords_for_subcategory,
|
||||
get_subcategory_names,
|
||||
)
|
||||
from app.services.recipe.recipe_engine import RecipeEngine
|
||||
from app.services.heimdall_orch import check_orch_budget
|
||||
|
|
@ -115,15 +118,42 @@ async def list_browse_categories(
|
|||
if domain not in DOMAINS:
|
||||
raise HTTPException(status_code=404, detail=f"Unknown domain '{domain}'.")
|
||||
|
||||
keywords_by_category = {
|
||||
cat: get_keywords_for_category(domain, cat)
|
||||
for cat in get_category_names(domain)
|
||||
cat_names = get_category_names(domain)
|
||||
keywords_by_category = {cat: get_keywords_for_category(domain, cat) for cat in cat_names}
|
||||
has_subs = {cat: category_has_subcategories(domain, cat) for cat in cat_names}
|
||||
|
||||
def _get(db_path: Path) -> list[dict]:
|
||||
store = Store(db_path)
|
||||
try:
|
||||
return store.get_browser_categories(domain, keywords_by_category, has_subs)
|
||||
finally:
|
||||
store.close()
|
||||
|
||||
return await asyncio.to_thread(_get, session.db)
|
||||
|
||||
|
||||
@router.get("/browse/{domain}/{category}/subcategories")
|
||||
async def list_browse_subcategories(
|
||||
domain: str,
|
||||
category: str,
|
||||
session: CloudUser = Depends(get_session),
|
||||
) -> list[dict]:
|
||||
"""Return [{subcategory, recipe_count}] for a category that supports subcategories."""
|
||||
if domain not in DOMAINS:
|
||||
raise HTTPException(status_code=404, detail=f"Unknown domain '{domain}'.")
|
||||
if not category_has_subcategories(domain, category):
|
||||
return []
|
||||
|
||||
subcat_names = get_subcategory_names(domain, category)
|
||||
keywords_by_subcat = {
|
||||
sub: get_keywords_for_subcategory(domain, category, sub)
|
||||
for sub in subcat_names
|
||||
}
|
||||
|
||||
def _get(db_path: Path) -> list[dict]:
|
||||
store = Store(db_path)
|
||||
try:
|
||||
return store.get_browser_categories(domain, keywords_by_category)
|
||||
return store.get_browser_subcategories(domain, keywords_by_subcat)
|
||||
finally:
|
||||
store.close()
|
||||
|
||||
|
|
@ -137,16 +167,27 @@ async def browse_recipes(
|
|||
page: Annotated[int, Query(ge=1)] = 1,
|
||||
page_size: Annotated[int, Query(ge=1, le=100)] = 20,
|
||||
pantry_items: Annotated[str | None, Query()] = None,
|
||||
subcategory: Annotated[str | None, Query()] = None,
|
||||
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 on each result.
|
||||
Pass pantry_items as a comma-separated string to receive match_pct badges.
|
||||
Pass subcategory to narrow within a category that has subcategories.
|
||||
"""
|
||||
if domain not in DOMAINS:
|
||||
raise HTTPException(status_code=404, detail=f"Unknown domain '{domain}'.")
|
||||
|
||||
if category == "_all":
|
||||
keywords = None # unfiltered browse
|
||||
elif subcategory:
|
||||
keywords = get_keywords_for_subcategory(domain, category, subcategory)
|
||||
if not keywords:
|
||||
raise HTTPException(
|
||||
status_code=404,
|
||||
detail=f"Unknown subcategory '{subcategory}' in '{category}'.",
|
||||
)
|
||||
else:
|
||||
keywords = get_keywords_for_category(domain, category)
|
||||
if not keywords:
|
||||
raise HTTPException(
|
||||
|
|
|
|||
|
|
@ -1051,17 +1051,38 @@ class Store:
|
|||
# ── recipe browser ────────────────────────────────────────────────────
|
||||
|
||||
def get_browser_categories(
|
||||
self, domain: str, keywords_by_category: dict[str, list[str]]
|
||||
self,
|
||||
domain: str,
|
||||
keywords_by_category: dict[str, list[str]],
|
||||
has_subcategories_by_category: dict[str, bool] | None = None,
|
||||
) -> list[dict]:
|
||||
"""Return [{category, recipe_count}] for each category in the domain.
|
||||
"""Return [{category, recipe_count, has_subcategories}] for each category.
|
||||
|
||||
keywords_by_category maps category name to the keyword list used to
|
||||
match against recipes.category and recipes.keywords.
|
||||
keywords_by_category maps category name → keyword list for counting.
|
||||
has_subcategories_by_category maps category name → bool (optional;
|
||||
defaults to False for all categories when omitted).
|
||||
"""
|
||||
results = []
|
||||
for category, keywords in keywords_by_category.items():
|
||||
count = self._count_recipes_for_keywords(keywords)
|
||||
results.append({"category": category, "recipe_count": count})
|
||||
results.append({
|
||||
"category": category,
|
||||
"recipe_count": count,
|
||||
"has_subcategories": (has_subcategories_by_category or {}).get(category, False),
|
||||
})
|
||||
return results
|
||||
|
||||
def get_browser_subcategories(
|
||||
self, domain: str, keywords_by_subcategory: dict[str, list[str]]
|
||||
) -> list[dict]:
|
||||
"""Return [{subcategory, recipe_count}] for each subcategory.
|
||||
|
||||
Mirrors get_browser_categories but for the second level.
|
||||
"""
|
||||
results = []
|
||||
for subcat, keywords in keywords_by_subcategory.items():
|
||||
count = self._count_recipes_for_keywords(keywords)
|
||||
results.append({"subcategory": subcat, "recipe_count": count})
|
||||
return results
|
||||
|
||||
@staticmethod
|
||||
|
|
@ -1091,27 +1112,41 @@ class Store:
|
|||
|
||||
def browse_recipes(
|
||||
self,
|
||||
keywords: list[str],
|
||||
keywords: list[str] | None,
|
||||
page: int,
|
||||
page_size: int,
|
||||
pantry_items: list[str] | None = None,
|
||||
) -> dict:
|
||||
"""Return a page of recipes matching the keyword set.
|
||||
|
||||
Pass keywords=None to browse all recipes without category filtering.
|
||||
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.
|
||||
"""
|
||||
if not keywords:
|
||||
if keywords is not None and not keywords:
|
||||
return {"recipes": [], "total": 0, "page": page}
|
||||
|
||||
match_expr = self._browser_fts_query(keywords)
|
||||
offset = (page - 1) * page_size
|
||||
c = self._cp
|
||||
|
||||
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),
|
||||
)
|
||||
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)
|
||||
|
||||
c = self._cp
|
||||
rows = self._fetch_all(
|
||||
f"""
|
||||
SELECT id, title, category, keywords, ingredient_names,
|
||||
|
|
|
|||
|
|
@ -5,6 +5,12 @@ Each domain provides a two-level category hierarchy for browsing the recipe corp
|
|||
Keyword matching is case-insensitive against the recipes.category column and the
|
||||
recipes.keywords JSON array. A recipe may appear in multiple categories (correct).
|
||||
|
||||
Category values are either:
|
||||
- list[str] — flat keyword list (no subcategories)
|
||||
- dict — {"keywords": list[str], "subcategories": {name: list[str]}}
|
||||
keywords covers the whole category (used for "All X" browse);
|
||||
subcategories each have their own narrower keyword list.
|
||||
|
||||
These are starter mappings based on the food.com dataset structure. Run:
|
||||
|
||||
SELECT category, count(*) FROM recipes
|
||||
|
|
@ -19,24 +25,211 @@ DOMAINS: dict[str, dict] = {
|
|||
"cuisine": {
|
||||
"label": "Cuisine",
|
||||
"categories": {
|
||||
"Italian": ["italian", "pasta", "pizza", "risotto", "lasagna", "carbonara"],
|
||||
"Mexican": ["mexican", "tex-mex", "taco", "enchilada", "burrito", "salsa", "guacamole"],
|
||||
"Asian": ["asian", "chinese", "japanese", "thai", "korean", "vietnamese", "stir fry", "stir-fry", "ramen", "sushi"],
|
||||
"American": ["american", "southern", "bbq", "barbecue", "comfort food", "cajun", "creole"],
|
||||
"Mediterranean": ["mediterranean", "greek", "middle eastern", "turkish", "moroccan", "lebanese"],
|
||||
"Indian": ["indian", "curry", "lentil", "dal", "tikka", "masala", "biryani"],
|
||||
"European": ["french", "german", "spanish", "british", "irish", "scandinavian"],
|
||||
"Latin American": ["latin american", "peruvian", "argentinian", "colombian", "cuban", "caribbean"],
|
||||
"Italian": {
|
||||
"keywords": ["italian", "pasta", "pizza", "risotto", "lasagna", "carbonara"],
|
||||
"subcategories": {
|
||||
"Sicilian": ["sicilian", "sicily", "arancini", "caponata",
|
||||
"involtini", "cannoli"],
|
||||
"Neapolitan": ["neapolitan", "naples", "pizza napoletana",
|
||||
"sfogliatelle", "ragù"],
|
||||
"Tuscan": ["tuscan", "tuscany", "ribollita", "bistecca",
|
||||
"pappardelle", "crostini"],
|
||||
"Roman": ["roman", "rome", "cacio e pepe", "carbonara",
|
||||
"amatriciana", "gricia", "supplì"],
|
||||
"Venetian": ["venetian", "venice", "risotto", "bigoli",
|
||||
"baccalà", "sarde in saor"],
|
||||
"Ligurian": ["ligurian", "liguria", "pesto", "focaccia",
|
||||
"trofie", "farinata"],
|
||||
},
|
||||
},
|
||||
"Mexican": {
|
||||
"keywords": ["mexican", "tex-mex", "taco", "enchilada", "burrito",
|
||||
"salsa", "guacamole"],
|
||||
"subcategories": {
|
||||
"Oaxacan": ["oaxacan", "oaxaca", "mole negro", "tlayuda",
|
||||
"chapulines", "mezcal"],
|
||||
"Yucatecan": ["yucatecan", "yucatan", "cochinita pibil", "poc chuc",
|
||||
"sopa de lima", "panuchos"],
|
||||
"Veracruz": ["veracruz", "huachinango", "picadas", "enfrijoladas"],
|
||||
"Street Food": ["taco", "elote", "tlacoyos", "torta",
|
||||
"tamale", "quesadilla"],
|
||||
"Mole": ["mole", "mole negro", "mole rojo", "mole verde",
|
||||
"mole poblano"],
|
||||
},
|
||||
},
|
||||
"Asian": {
|
||||
"keywords": ["asian", "chinese", "japanese", "thai", "korean", "vietnamese",
|
||||
"stir fry", "stir-fry", "ramen", "sushi"],
|
||||
"subcategories": {
|
||||
"Korean": ["korean", "kimchi", "bibimbap", "bulgogi", "japchae",
|
||||
"doenjang", "gochujang"],
|
||||
"Japanese": ["japanese", "sushi", "ramen", "tempura", "miso",
|
||||
"teriyaki", "udon", "soba", "bento", "yakitori"],
|
||||
"Chinese": ["chinese", "dim sum", "fried rice", "dumplings", "wonton",
|
||||
"spring roll", "szechuan", "sichuan", "cantonese",
|
||||
"chow mein", "mapo", "lo mein"],
|
||||
"Thai": ["thai", "pad thai", "green curry", "red curry",
|
||||
"coconut milk", "lemongrass", "satay", "tom yum"],
|
||||
"Vietnamese": ["vietnamese", "pho", "banh mi", "spring rolls",
|
||||
"vermicelli", "nuoc cham", "bun bo"],
|
||||
"Filipino": ["filipino", "adobo", "sinigang", "pancit", "lumpia",
|
||||
"kare-kare", "lechon"],
|
||||
"Indonesian": ["indonesian", "rendang", "nasi goreng", "gado-gado",
|
||||
"tempeh", "sambal"],
|
||||
},
|
||||
},
|
||||
"Indian": {
|
||||
"keywords": ["indian", "curry", "lentil", "dal", "tikka", "masala",
|
||||
"biryani", "naan", "chutney"],
|
||||
"subcategories": {
|
||||
"North Indian": ["north indian", "punjabi", "mughal", "tikka masala",
|
||||
"naan", "tandoori", "butter chicken", "palak"],
|
||||
"South Indian": ["south indian", "tamil", "kerala", "dosa", "idli",
|
||||
"sambar", "rasam", "coconut chutney"],
|
||||
"Bengali": ["bengali", "mustard fish", "hilsa", "shorshe"],
|
||||
"Gujarati": ["gujarati", "dhokla", "thepla", "undhiyu"],
|
||||
},
|
||||
},
|
||||
"Mediterranean": {
|
||||
"keywords": ["mediterranean", "greek", "middle eastern", "turkish",
|
||||
"moroccan", "lebanese"],
|
||||
"subcategories": {
|
||||
"Greek": ["greek", "feta", "tzatziki", "moussaka", "spanakopita",
|
||||
"souvlaki", "dolmades"],
|
||||
"Turkish": ["turkish", "kebab", "borek", "meze", "baklava",
|
||||
"lahmacun"],
|
||||
"Moroccan": ["moroccan", "tagine", "couscous", "harissa",
|
||||
"chermoula", "preserved lemon"],
|
||||
"Lebanese": ["lebanese", "middle eastern", "hummus", "falafel",
|
||||
"tabbouleh", "kibbeh", "fattoush"],
|
||||
"Israeli": ["israeli", "shakshuka", "sabich", "za'atar",
|
||||
"tahini"],
|
||||
},
|
||||
},
|
||||
"American": {
|
||||
"keywords": ["american", "southern", "bbq", "barbecue", "comfort food",
|
||||
"cajun", "creole"],
|
||||
"subcategories": {
|
||||
"Southern": ["southern", "soul food", "fried chicken",
|
||||
"collard greens", "cornbread", "biscuits and gravy"],
|
||||
"Cajun/Creole": ["cajun", "creole", "new orleans", "gumbo",
|
||||
"jambalaya", "etouffee", "dirty rice"],
|
||||
"BBQ": ["bbq", "barbecue", "smoked", "brisket", "pulled pork",
|
||||
"ribs", "pit"],
|
||||
"Tex-Mex": ["tex-mex", "southwestern", "chili", "fajita",
|
||||
"queso"],
|
||||
"New England": ["new england", "chowder", "lobster", "clam",
|
||||
"maple", "yankee"],
|
||||
},
|
||||
},
|
||||
"European": {
|
||||
"keywords": ["french", "german", "spanish", "british", "irish",
|
||||
"scandinavian"],
|
||||
"subcategories": {
|
||||
"French": ["french", "provencal", "beurre", "crepe",
|
||||
"ratatouille", "cassoulet", "bouillabaisse"],
|
||||
"Spanish": ["spanish", "paella", "tapas", "gazpacho",
|
||||
"tortilla espanola", "chorizo"],
|
||||
"German": ["german", "bratwurst", "sauerkraut", "schnitzel",
|
||||
"pretzel", "strudel"],
|
||||
"British/Irish": ["british", "irish", "english", "pub food",
|
||||
"shepherd's pie", "bangers", "scones"],
|
||||
"Scandinavian": ["scandinavian", "nordic", "swedish", "norwegian",
|
||||
"danish", "gravlax", "meatballs"],
|
||||
},
|
||||
},
|
||||
"Latin American": {
|
||||
"keywords": ["latin american", "peruvian", "argentinian", "colombian",
|
||||
"cuban", "caribbean", "brazilian"],
|
||||
"subcategories": {
|
||||
"Peruvian": ["peruvian", "ceviche", "lomo saltado", "anticucho",
|
||||
"aji amarillo"],
|
||||
"Brazilian": ["brazilian", "churrasco", "feijoada", "pao de queijo",
|
||||
"brigadeiro"],
|
||||
"Colombian": ["colombian", "bandeja paisa", "arepas", "empanadas",
|
||||
"sancocho"],
|
||||
"Cuban": ["cuban", "ropa vieja", "moros y cristianos",
|
||||
"picadillo", "mojito"],
|
||||
"Caribbean": ["caribbean", "jamaican", "jerk", "trinidadian",
|
||||
"plantain", "roti"],
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
"meal_type": {
|
||||
"label": "Meal Type",
|
||||
"categories": {
|
||||
"Breakfast": ["breakfast", "brunch", "eggs", "pancakes", "waffles", "oatmeal", "muffin"],
|
||||
"Lunch": ["lunch", "sandwich", "wrap", "salad", "soup", "light meal"],
|
||||
"Dinner": ["dinner", "main dish", "entree", "main course", "supper"],
|
||||
"Snack": ["snack", "appetizer", "finger food", "dip", "bite", "starter"],
|
||||
"Dessert": ["dessert", "cake", "cookie", "pie", "sweet", "pudding", "ice cream", "brownie"],
|
||||
"Breakfast": {
|
||||
"keywords": ["breakfast", "brunch", "eggs", "pancakes", "waffles",
|
||||
"oatmeal", "muffin"],
|
||||
"subcategories": {
|
||||
"Eggs": ["egg", "omelette", "frittata", "quiche",
|
||||
"scrambled", "benedict", "shakshuka"],
|
||||
"Pancakes & Waffles": ["pancake", "waffle", "crepe", "french toast"],
|
||||
"Baked Goods": ["muffin", "scone", "biscuit", "quick bread",
|
||||
"coffee cake", "danish"],
|
||||
"Oats & Grains": ["oatmeal", "granola", "porridge", "muesli",
|
||||
"overnight oats"],
|
||||
},
|
||||
},
|
||||
"Lunch": {
|
||||
"keywords": ["lunch", "sandwich", "wrap", "salad", "soup", "light meal"],
|
||||
"subcategories": {
|
||||
"Sandwiches": ["sandwich", "sub", "hoagie", "panini", "club",
|
||||
"grilled cheese", "blt"],
|
||||
"Salads": ["salad", "grain bowl", "chopped", "caesar",
|
||||
"niçoise", "cobb"],
|
||||
"Soups": ["soup", "bisque", "chowder", "gazpacho",
|
||||
"minestrone", "lentil soup"],
|
||||
"Wraps": ["wrap", "burrito bowl", "pita", "lettuce wrap",
|
||||
"quesadilla"],
|
||||
},
|
||||
},
|
||||
"Dinner": {
|
||||
"keywords": ["dinner", "main dish", "entree", "main course", "supper"],
|
||||
"subcategories": {
|
||||
"Casseroles": ["casserole", "bake", "gratin", "lasagna",
|
||||
"sheperd's pie", "pot pie"],
|
||||
"Stews": ["stew", "braise", "slow cooker", "pot roast",
|
||||
"daube", "ragù"],
|
||||
"Grilled": ["grilled", "grill", "barbecue", "charred",
|
||||
"kebab", "skewer"],
|
||||
"Stir-Fries": ["stir fry", "stir-fry", "wok", "sauté",
|
||||
"sauteed"],
|
||||
"Roasts": ["roast", "roasted", "oven", "baked chicken",
|
||||
"pot roast"],
|
||||
},
|
||||
},
|
||||
"Snack": {
|
||||
"keywords": ["snack", "appetizer", "finger food", "dip", "bite",
|
||||
"starter"],
|
||||
"subcategories": {
|
||||
"Dips & Spreads": ["dip", "spread", "hummus", "guacamole",
|
||||
"salsa", "pate"],
|
||||
"Finger Foods": ["finger food", "bite", "skewer", "slider",
|
||||
"wing", "nugget"],
|
||||
"Chips & Crackers": ["chip", "cracker", "crisp", "popcorn",
|
||||
"pretzel"],
|
||||
},
|
||||
},
|
||||
"Dessert": {
|
||||
"keywords": ["dessert", "cake", "cookie", "pie", "sweet", "pudding",
|
||||
"ice cream", "brownie"],
|
||||
"subcategories": {
|
||||
"Cakes": ["cake", "cupcake", "layer cake", "bundt",
|
||||
"cheesecake", "torte"],
|
||||
"Cookies & Bars": ["cookie", "brownie", "blondie", "bar",
|
||||
"biscotti", "shortbread"],
|
||||
"Pies & Tarts": ["pie", "tart", "galette", "cobbler", "crisp",
|
||||
"crumble"],
|
||||
"Frozen": ["ice cream", "gelato", "sorbet", "frozen dessert",
|
||||
"popsicle", "granita"],
|
||||
"Puddings": ["pudding", "custard", "mousse", "panna cotta",
|
||||
"flan", "creme brulee"],
|
||||
"Candy": ["candy", "fudge", "truffle", "brittle",
|
||||
"caramel", "toffee"],
|
||||
},
|
||||
},
|
||||
"Beverage": ["drink", "smoothie", "cocktail", "beverage", "juice", "shake"],
|
||||
"Side Dish": ["side dish", "side", "accompaniment", "garnish"],
|
||||
},
|
||||
|
|
@ -56,14 +249,72 @@ DOMAINS: dict[str, dict] = {
|
|||
"main_ingredient": {
|
||||
"label": "Main Ingredient",
|
||||
"categories": {
|
||||
# These values match the inferred_tags written by tag_inferrer._MAIN_INGREDIENT_SIGNALS
|
||||
# and indexed into recipe_browser_fts — use exact tag strings.
|
||||
"Chicken": ["main:Chicken"],
|
||||
"Beef": ["main:Beef"],
|
||||
"Pork": ["main:Pork"],
|
||||
"Fish": ["main:Fish"],
|
||||
# keywords use exact inferred_tag strings (main:X) — indexed into recipe_browser_fts.
|
||||
"Chicken": {
|
||||
"keywords": ["main:Chicken"],
|
||||
"subcategories": {
|
||||
"Baked": ["baked chicken", "roast chicken", "chicken casserole",
|
||||
"chicken bake"],
|
||||
"Grilled": ["grilled chicken", "chicken kebab", "bbq chicken",
|
||||
"chicken skewer"],
|
||||
"Fried": ["fried chicken", "chicken cutlet", "chicken schnitzel",
|
||||
"crispy chicken"],
|
||||
"Stewed": ["chicken stew", "chicken soup", "coq au vin",
|
||||
"chicken curry", "chicken braise"],
|
||||
},
|
||||
},
|
||||
"Beef": {
|
||||
"keywords": ["main:Beef"],
|
||||
"subcategories": {
|
||||
"Ground Beef": ["ground beef", "hamburger", "meatball", "meatloaf",
|
||||
"bolognese", "burger"],
|
||||
"Steak": ["steak", "sirloin", "ribeye", "flank steak",
|
||||
"filet mignon", "t-bone"],
|
||||
"Roasts": ["beef roast", "pot roast", "brisket", "prime rib",
|
||||
"chuck roast"],
|
||||
"Stews": ["beef stew", "beef braise", "beef bourguignon",
|
||||
"short ribs"],
|
||||
},
|
||||
},
|
||||
"Pork": {
|
||||
"keywords": ["main:Pork"],
|
||||
"subcategories": {
|
||||
"Chops": ["pork chop", "pork loin", "pork cutlet"],
|
||||
"Pulled/Slow": ["pulled pork", "pork shoulder", "pork butt",
|
||||
"carnitas", "slow cooker pork"],
|
||||
"Sausage": ["sausage", "bratwurst", "chorizo", "andouille",
|
||||
"Italian sausage"],
|
||||
"Ribs": ["pork ribs", "baby back ribs", "spare ribs",
|
||||
"pork belly"],
|
||||
},
|
||||
},
|
||||
"Fish": {
|
||||
"keywords": ["main:Fish"],
|
||||
"subcategories": {
|
||||
"Salmon": ["salmon", "smoked salmon", "gravlax"],
|
||||
"Tuna": ["tuna", "albacore", "ahi"],
|
||||
"White Fish": ["cod", "tilapia", "halibut", "sole", "snapper",
|
||||
"flounder", "bass"],
|
||||
"Shellfish": ["shrimp", "prawn", "crab", "lobster", "scallop",
|
||||
"mussel", "clam", "oyster"],
|
||||
},
|
||||
},
|
||||
"Pasta": ["main:Pasta"],
|
||||
"Vegetables": ["main:Vegetables"],
|
||||
"Vegetables": {
|
||||
"keywords": ["main:Vegetables"],
|
||||
"subcategories": {
|
||||
"Root Veg": ["potato", "sweet potato", "carrot", "beet",
|
||||
"parsnip", "turnip"],
|
||||
"Leafy": ["spinach", "kale", "chard", "arugula",
|
||||
"collard greens", "lettuce"],
|
||||
"Brassicas": ["broccoli", "cauliflower", "brussels sprouts",
|
||||
"cabbage", "bok choy"],
|
||||
"Nightshades": ["tomato", "eggplant", "bell pepper", "zucchini",
|
||||
"squash"],
|
||||
"Mushrooms": ["mushroom", "portobello", "shiitake", "oyster mushroom",
|
||||
"chanterelle"],
|
||||
},
|
||||
},
|
||||
"Eggs": ["main:Eggs"],
|
||||
"Legumes": ["main:Legumes"],
|
||||
"Grains": ["main:Grains"],
|
||||
|
|
@ -73,16 +324,53 @@ DOMAINS: dict[str, dict] = {
|
|||
}
|
||||
|
||||
|
||||
def _get_category_def(domain: str, category: str) -> list[str] | dict | None:
|
||||
"""Return the raw category definition, or None if not found."""
|
||||
return DOMAINS.get(domain, {}).get("categories", {}).get(category)
|
||||
|
||||
|
||||
def get_domain_labels() -> list[dict]:
|
||||
"""Return [{id, label}] for all available domains."""
|
||||
return [{"id": k, "label": v["label"]} for k, v in DOMAINS.items()]
|
||||
|
||||
|
||||
def get_keywords_for_category(domain: str, category: str) -> list[str]:
|
||||
"""Return the keyword list for a domain/category pair, or [] if not found."""
|
||||
domain_data = DOMAINS.get(domain, {})
|
||||
categories = domain_data.get("categories", {})
|
||||
return categories.get(category, [])
|
||||
"""Return the keyword list for the category (top-level, covers all subcategories).
|
||||
|
||||
For flat categories returns the list directly.
|
||||
For nested categories returns the 'keywords' key.
|
||||
Returns [] if category or domain not found.
|
||||
"""
|
||||
cat_def = _get_category_def(domain, category)
|
||||
if cat_def is None:
|
||||
return []
|
||||
if isinstance(cat_def, list):
|
||||
return cat_def
|
||||
return cat_def.get("keywords", [])
|
||||
|
||||
|
||||
def category_has_subcategories(domain: str, category: str) -> bool:
|
||||
"""Return True when a category has a subcategory level."""
|
||||
cat_def = _get_category_def(domain, category)
|
||||
if not isinstance(cat_def, dict):
|
||||
return False
|
||||
return bool(cat_def.get("subcategories"))
|
||||
|
||||
|
||||
def get_subcategory_names(domain: str, category: str) -> list[str]:
|
||||
"""Return subcategory names for a category, or [] if none exist."""
|
||||
cat_def = _get_category_def(domain, category)
|
||||
if not isinstance(cat_def, dict):
|
||||
return []
|
||||
return list(cat_def.get("subcategories", {}).keys())
|
||||
|
||||
|
||||
def get_keywords_for_subcategory(domain: str, category: str, subcategory: str) -> list[str]:
|
||||
"""Return keyword list for a specific subcategory, or [] if not found."""
|
||||
cat_def = _get_category_def(domain, category)
|
||||
if not isinstance(cat_def, dict):
|
||||
return []
|
||||
return cat_def.get("subcategories", {}).get(subcategory, [])
|
||||
|
||||
|
||||
def get_category_names(domain: str) -> list[str]:
|
||||
|
|
|
|||
|
|
@ -21,7 +21,13 @@
|
|||
</div>
|
||||
|
||||
<!-- Category list + Surprise Me -->
|
||||
<div v-else class="category-list mb-md flex flex-wrap gap-xs">
|
||||
<div v-else class="category-list mb-sm flex flex-wrap gap-xs">
|
||||
<button
|
||||
:class="['btn', 'btn-secondary', 'cat-btn', { active: activeCategory === '_all' }]"
|
||||
@click="selectCategory('_all')"
|
||||
>
|
||||
All
|
||||
</button>
|
||||
<button
|
||||
v-for="cat in categories"
|
||||
:key="cat.category"
|
||||
|
|
@ -30,6 +36,7 @@
|
|||
>
|
||||
{{ cat.category }}
|
||||
<span class="cat-count">{{ cat.recipe_count }}</span>
|
||||
<span v-if="cat.has_subcategories" class="cat-drill-indicator" title="Has subcategories">›</span>
|
||||
</button>
|
||||
<button
|
||||
v-if="categories.length > 1"
|
||||
|
|
@ -41,6 +48,31 @@
|
|||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Subcategory row — shown when the active category has subcategories -->
|
||||
<div
|
||||
v-if="activeCategoryHasSubs && (subcategories.length > 0 || loadingSubcategories)"
|
||||
class="subcategory-list mb-md flex flex-wrap gap-xs"
|
||||
>
|
||||
<span v-if="loadingSubcategories" class="text-secondary text-xs">Loading…</span>
|
||||
<template v-else>
|
||||
<button
|
||||
:class="['btn', 'btn-secondary', 'subcat-btn', { active: activeSubcategory === null }]"
|
||||
@click="selectSubcategory(null)"
|
||||
>
|
||||
All {{ activeCategory }}
|
||||
</button>
|
||||
<button
|
||||
v-for="sub in subcategories"
|
||||
:key="sub.subcategory"
|
||||
:class="['btn', 'btn-secondary', 'subcat-btn', { active: activeSubcategory === sub.subcategory }]"
|
||||
@click="selectSubcategory(sub.subcategory)"
|
||||
>
|
||||
{{ sub.subcategory }}
|
||||
<span class="cat-count">{{ sub.recipe_count }}</span>
|
||||
</button>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<!-- Recipe grid -->
|
||||
<template v-if="activeCategory">
|
||||
<div v-if="loadingRecipes" class="text-secondary text-sm">Loading recipes…</div>
|
||||
|
|
@ -125,7 +157,7 @@
|
|||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, onMounted } from 'vue'
|
||||
import { browserAPI, type BrowserDomain, type BrowserCategory, type BrowserRecipe } from '../services/api'
|
||||
import { browserAPI, type BrowserDomain, type BrowserCategory, type BrowserSubcategory, type BrowserRecipe } from '../services/api'
|
||||
import { useSavedRecipesStore } from '../stores/savedRecipes'
|
||||
import { useInventoryStore } from '../stores/inventory'
|
||||
import SaveRecipeModal from './SaveRecipeModal.vue'
|
||||
|
|
@ -141,6 +173,9 @@ const domains = ref<BrowserDomain[]>([])
|
|||
const activeDomain = ref<string | null>(null)
|
||||
const categories = ref<BrowserCategory[]>([])
|
||||
const activeCategory = ref<string | null>(null)
|
||||
const subcategories = ref<BrowserSubcategory[]>([])
|
||||
const activeSubcategory = ref<string | null>(null)
|
||||
const loadingSubcategories = ref(false)
|
||||
const recipes = ref<BrowserRecipe[]>([])
|
||||
const total = ref(0)
|
||||
const page = ref(1)
|
||||
|
|
@ -153,6 +188,10 @@ const totalPages = computed(() => Math.max(1, Math.ceil(total.value / pageSize))
|
|||
const allCountsZero = computed(() =>
|
||||
categories.value.length > 0 && categories.value.every(c => c.recipe_count === 0)
|
||||
)
|
||||
const activeCategoryHasSubs = computed(() => {
|
||||
if (!activeCategory.value || activeCategory.value === '_all') return false
|
||||
return categories.value.find(c => c.category === activeCategory.value)?.has_subcategories ?? false
|
||||
})
|
||||
|
||||
const pantryItems = computed(() =>
|
||||
inventoryStore.items
|
||||
|
|
@ -205,6 +244,25 @@ function surpriseMe() {
|
|||
|
||||
async function selectCategory(category: string) {
|
||||
activeCategory.value = category
|
||||
activeSubcategory.value = null
|
||||
subcategories.value = []
|
||||
page.value = 1
|
||||
|
||||
// Fetch subcategories in the background when the category supports them,
|
||||
// then immediately start loading recipes at the full-category level.
|
||||
const catMeta = categories.value.find(c => c.category === category)
|
||||
if (catMeta?.has_subcategories) {
|
||||
loadingSubcategories.value = true
|
||||
browserAPI.listSubcategories(activeDomain.value!, category)
|
||||
.then(subs => { subcategories.value = subs })
|
||||
.finally(() => { loadingSubcategories.value = false })
|
||||
}
|
||||
|
||||
await loadRecipes()
|
||||
}
|
||||
|
||||
async function selectSubcategory(subcat: string | null) {
|
||||
activeSubcategory.value = subcat
|
||||
page.value = 1
|
||||
await loadRecipes()
|
||||
}
|
||||
|
|
@ -227,6 +285,7 @@ async function loadRecipes() {
|
|||
pantry_items: pantryItems.value.length > 0
|
||||
? pantryItems.value.join(',')
|
||||
: undefined,
|
||||
subcategory: activeSubcategory.value ?? undefined,
|
||||
}
|
||||
)
|
||||
recipes.value = result.recipes
|
||||
|
|
@ -289,6 +348,36 @@ async function doUnsave(recipeId: number) {
|
|||
opacity: 1;
|
||||
}
|
||||
|
||||
.cat-drill-indicator {
|
||||
margin-left: var(--spacing-xs);
|
||||
opacity: 0.5;
|
||||
font-size: var(--font-size-sm);
|
||||
}
|
||||
|
||||
.subcategory-list {
|
||||
padding-left: var(--spacing-sm);
|
||||
border-left: 2px solid var(--color-border);
|
||||
margin-left: var(--spacing-xs);
|
||||
}
|
||||
|
||||
.subcat-btn {
|
||||
font-size: var(--font-size-xs, 0.78rem);
|
||||
padding: var(--spacing-xs) var(--spacing-sm);
|
||||
opacity: 0.9;
|
||||
}
|
||||
|
||||
.subcat-btn.active {
|
||||
background: var(--color-primary);
|
||||
color: white;
|
||||
border-color: var(--color-primary);
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.subcat-btn.active .cat-count {
|
||||
background: rgba(255, 255, 255, 0.2);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.recipe-grid {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
|
|
|||
|
|
@ -881,6 +881,12 @@ export interface BrowserDomain {
|
|||
export interface BrowserCategory {
|
||||
category: string
|
||||
recipe_count: number
|
||||
has_subcategories: boolean
|
||||
}
|
||||
|
||||
export interface BrowserSubcategory {
|
||||
subcategory: string
|
||||
recipe_count: number
|
||||
}
|
||||
|
||||
export interface BrowserRecipe {
|
||||
|
|
@ -907,10 +913,17 @@ export const browserAPI = {
|
|||
const response = await api.get(`/recipes/browse/${domain}`)
|
||||
return response.data
|
||||
},
|
||||
async listSubcategories(domain: string, category: string): Promise<BrowserSubcategory[]> {
|
||||
const response = await api.get(
|
||||
`/recipes/browse/${domain}/${encodeURIComponent(category)}/subcategories`
|
||||
)
|
||||
return response.data
|
||||
},
|
||||
async browse(domain: string, category: string, params?: {
|
||||
page?: number
|
||||
page_size?: number
|
||||
pantry_items?: string
|
||||
subcategory?: string
|
||||
}): Promise<BrowserResult> {
|
||||
const response = await api.get(`/recipes/browse/${domain}/${encodeURIComponent(category)}`, { params })
|
||||
return response.data
|
||||
|
|
|
|||
Loading…
Reference in a new issue