feat: recipe management page — browse, search, edit, delete recipes

New /recipes page with backend switching (Mealie/Tandoor), full-text
search, tag filtering, multi-select bulk delete, per-recipe edit/delete
buttons, and a full recipe editor reusing the import preview form.

New API client methods: list_recipes, get_recipe, update_recipe,
delete_recipe for both MealieClient and TandoorClient.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-02-26 08:30:26 +01:00
parent 98d1e3b45a
commit 19cbd505d4
8 changed files with 1618 additions and 1 deletions
+117
View File
@@ -97,6 +97,123 @@ class MealieClient:
return [{"name": t["name"], "slug": t["slug"], "id": t["id"]}
for t in r.json().get("items", [])]
def list_recipes(self, search: str = "", tag_ids: list[str] | None = None,
page: int = 1, per_page: int = 50) -> dict:
"""List recipes with optional search and tag filtering.
Returns {"items": [...], "page": int, "per_page": int, "total": int}.
"""
params: dict = {
"page": page, "perPage": per_page,
"orderBy": "name", "orderDirection": "asc",
}
if search:
params["search"] = search
if tag_ids:
# Mealie accepts repeated ?tags=<id>&tags=<id> params.
# requests encodes list values as repeated keys automatically.
params["tags"] = tag_ids
r = self.session.get(f"{self.api_url}/api/recipes", params=params,
timeout=15)
r.raise_for_status()
data = r.json()
items = []
for item in data.get("items", []):
image_url = ""
if item.get("image"):
image_url = f"{self.base_url}/api/media/recipes/{item['id']}/images/min-original.webp"
items.append({
"id": item["slug"],
"name": item.get("name", ""),
"image": image_url,
"tags": [t["name"] for t in item.get("tags", [])],
"url": f"{self.base_url}/g/home/r/{item['slug']}",
})
return {
"items": items,
"page": data.get("page", page),
"per_page": data.get("perPage", per_page),
"total": data.get("total", 0),
}
def get_recipe(self, slug: str) -> dict:
"""Fetch a single recipe and return it in the common format."""
r = self.session.get(f"{self.api_url}/api/recipes/{slug}", timeout=15)
r.raise_for_status()
data = r.json()
# Parse ingredients
ingredients = []
current_group = None
for ing in data.get("recipeIngredient", []):
group_title = ing.get("title", "")
if group_title and group_title != current_group:
ingredients.append({"group": group_title})
current_group = group_title
food_name = ""
if ing.get("food"):
food_name = ing["food"].get("name", "")
unit_name = ""
if ing.get("unit"):
unit_name = ing["unit"].get("name", "")
qty = ing.get("quantity", 0)
qty_str = ""
if qty:
qty_str = str(int(qty)) if qty == int(qty) else str(qty)
note = ing.get("note", "")
if food_name or qty_str or unit_name:
ingredients.append({
"quantity": qty_str,
"unit": unit_name,
"food": food_name,
"extra": note,
})
# Parse instructions
instructions = []
for step in data.get("recipeInstructions", []):
text = step.get("text", "").strip()
if text:
instructions.append(text)
# Parse tags
tags = [t["name"] for t in data.get("tags", [])]
# Image URL
image_url = ""
if data.get("image"):
image_url = f"{self.base_url}/api/media/recipes/{data['id']}/images/original.webp"
return {
"title": data.get("name", ""),
"description": data.get("description", ""),
"image_url": image_url,
"ingredients": ingredients,
"instructions": instructions,
"tags": tags,
"original_url": data.get("orgURL", ""),
}
def update_recipe(self, slug: str, recipe: dict) -> None:
"""Update an existing recipe from a common-format dict."""
payload = self._build_payload(recipe)
tag_names = recipe.get("tags", [])
if tag_names:
payload["tags"] = self._ensure_tags(tag_names)
else:
payload["tags"] = []
r = self.session.patch(
f"{self.api_url}/api/recipes/{slug}",
json=payload,
timeout=15,
)
r.raise_for_status()
def delete_recipe(self, slug: str) -> None:
"""Delete a recipe by slug."""
r = self.session.delete(f"{self.api_url}/api/recipes/{slug}", timeout=10)
r.raise_for_status()
def create_recipe(self, recipe: dict) -> str:
"""Create a recipe in Mealie from a scraper result dict.