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
+104
View File
@@ -80,6 +80,110 @@ class TandoorClient:
params = {} # next URL already has params
return results
def list_recipes(self, search: str = "", keyword_ids: list[int] | None = None,
page: int = 1, per_page: int = 50) -> dict:
"""List recipes with optional search and keyword filtering.
Returns {"items": [...], "page": int, "per_page": int, "total": int}.
"""
params: dict = {
"limit": per_page,
"offset": (page - 1) * per_page,
"format": "json",
}
if search:
params["query"] = search
if keyword_ids:
params["keywords"] = keyword_ids
r = self.session.get(f"{self.api_url}/api/recipe/", params=params,
timeout=15)
r.raise_for_status()
data = r.json()
items = []
for item in data.get("results", []):
items.append({
"id": item["id"],
"name": item.get("name", ""),
"image": item.get("image") or "",
"tags": [k["name"] for k in item.get("keywords", [])],
"url": f"{self.base_url}/view/recipe/{item['id']}",
})
return {
"items": items,
"page": page,
"per_page": per_page,
"total": data.get("count", 0),
}
def get_recipe(self, recipe_id: int) -> dict:
"""Fetch a single recipe and return it in the common format."""
r = self.session.get(f"{self.api_url}/api/recipe/{recipe_id}/",
timeout=15)
r.raise_for_status()
data = r.json()
# Parse ingredients from all steps
ingredients = []
for step in data.get("steps", []):
for ing in step.get("ingredients", []):
if ing.get("is_header"):
ingredients.append({"group": ing.get("note", "")})
continue
food_name = ""
if ing.get("food"):
food_name = ing["food"].get("name", "")
unit_name = ""
if ing.get("unit"):
unit_name = ing["unit"].get("name", "")
amount = ing.get("amount", 0)
qty_str = ""
if amount:
qty_str = str(int(amount)) if amount == int(amount) else str(amount)
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("steps", []):
text = step.get("instruction", "").strip()
if text:
instructions.append(text)
# Parse tags
tags = [k["name"] for k in data.get("keywords", [])]
return {
"title": data.get("name", ""),
"description": data.get("description", ""),
"image_url": data.get("image") or "",
"ingredients": ingredients,
"instructions": instructions,
"tags": tags,
"original_url": data.get("source_url", ""),
}
def update_recipe(self, recipe_id: int, recipe: dict) -> None:
"""Update an existing recipe from a common-format dict."""
payload = self._build_payload(recipe)
r = self.session.put(
f"{self.api_url}/api/recipe/{recipe_id}/",
json=payload,
timeout=15,
)
r.raise_for_status()
def delete_recipe(self, recipe_id: int) -> None:
"""Delete a recipe by ID."""
r = self.session.delete(f"{self.api_url}/api/recipe/{recipe_id}/",
timeout=10)
r.raise_for_status()
def find_duplicate(self, url: str, title: str = "") -> dict | None:
"""Check if a recipe with this source URL already exists."""
if not url and not title: