feat: saved recipes, recipe browser, and recipe detail panel
- Saved recipes: save/unsave, star rating, notes, tags, collections (migrations 018-020) - Recipe browser: domain/category browsing with pantry match badges, pagination - Recipe detail panel: full directions, ingredient checklist, swap candidates, prep notes - Grocery links: affiliate links for missing ingredients - Nutrition filters and display chips on recipe cards - Bookmark toggle persisted to saved_recipes table - Tier gates on saved recipes (paid) and collections (premium) - Browser telemetry for domain/category click tracking - Cloud compose: CLOUD_DATA_ROOT volume mount for per-user SQLite trees - manage.sh: cf-orch agent sidecar in local stack - README: updated feature list and stack description
This commit is contained in:
parent
c064933b14
commit
793df1b5cf
32 changed files with 3111 additions and 83 deletions
|
|
@ -75,3 +75,11 @@ DEMO_MODE=false
|
||||||
# FORGEJO_API_TOKEN=
|
# FORGEJO_API_TOKEN=
|
||||||
# FORGEJO_REPO=Circuit-Forge/kiwi
|
# FORGEJO_REPO=Circuit-Forge/kiwi
|
||||||
# FORGEJO_API_URL=https://git.opensourcesolarpunk.com/api/v1
|
# FORGEJO_API_URL=https://git.opensourcesolarpunk.com/api/v1
|
||||||
|
|
||||||
|
# Affiliate links (optional — plain URLs are shown if unset)
|
||||||
|
# Amazon Associates tag (circuitforge_core.affiliates, retailer="amazon")
|
||||||
|
# AMAZON_ASSOCIATES_TAG=circuitforge-20
|
||||||
|
# Instacart affiliate ID (circuitforge_core.affiliates, retailer="instacart")
|
||||||
|
# INSTACART_AFFILIATE_ID=circuitforge
|
||||||
|
# Walmart Impact network affiliate ID (inline, path-based redirect)
|
||||||
|
# WALMART_AFFILIATE_ID=
|
||||||
|
|
|
||||||
3
.gitignore
vendored
3
.gitignore
vendored
|
|
@ -25,3 +25,6 @@ data/
|
||||||
|
|
||||||
# Test artifacts (MagicMock sqlite files from pytest)
|
# Test artifacts (MagicMock sqlite files from pytest)
|
||||||
<MagicMock*
|
<MagicMock*
|
||||||
|
|
||||||
|
# Playwright / debug screenshots
|
||||||
|
debug-screenshots/
|
||||||
|
|
|
||||||
20
README.md
20
README.md
|
|
@ -6,7 +6,9 @@
|
||||||
|
|
||||||
Scan barcodes, photograph receipts, and get recipe ideas based on what you already have — before it expires.
|
Scan barcodes, photograph receipts, and get recipe ideas based on what you already have — before it expires.
|
||||||
|
|
||||||
**Status:** Pre-alpha · CircuitForge LLC
|
**LLM support is optional.** Inventory tracking, barcode scanning, expiry alerts, CSV export, and receipt upload all work without any LLM configured. AI features (receipt OCR, recipe suggestions, meal planning) activate when a backend is available and are BYOK-unlockable at any tier.
|
||||||
|
|
||||||
|
**Status:** Beta · CircuitForge LLC
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|
@ -14,9 +16,14 @@ Scan barcodes, photograph receipts, and get recipe ideas based on what you alrea
|
||||||
|
|
||||||
- **Inventory tracking** — add items by barcode scan, receipt upload, or manually
|
- **Inventory tracking** — add items by barcode scan, receipt upload, or manually
|
||||||
- **Expiry alerts** — know what's about to go bad
|
- **Expiry alerts** — know what's about to go bad
|
||||||
- **Receipt OCR** — extract line items from receipt photos automatically (Paid tier)
|
- **Recipe browser** — browse the full recipe corpus by cuisine, meal type, dietary preference, or main ingredient; pantry match percentage shown inline (Free)
|
||||||
- **Recipe suggestions** — LLM-powered ideas based on what's expiring (Paid tier, BYOK-unlockable)
|
- **Saved recipes** — bookmark any recipe with notes, a 0–5 star rating, and free-text style tags (Free); organize into named collections (Paid)
|
||||||
|
- **Receipt OCR** — extract line items from receipt photos automatically (Paid tier, BYOK-unlockable)
|
||||||
|
- **Recipe suggestions** — four levels from pantry-match to full LLM generation (Paid tier, BYOK-unlockable)
|
||||||
|
- **Style auto-classifier** — LLM suggests style tags (comforting, hands-off, quick, etc.) for saved recipes (Paid tier, BYOK-unlockable)
|
||||||
- **Leftover mode** — prioritize nearly-expired items in recipe ranking (Premium tier)
|
- **Leftover mode** — prioritize nearly-expired items in recipe ranking (Premium tier)
|
||||||
|
- **LLM backend config** — configure inference via `circuitforge-core` env-var system; BYOK unlocks Paid AI features at any tier
|
||||||
|
- **Feedback FAB** — in-app feedback button; status probed on load, hidden if CF feedback endpoint unreachable
|
||||||
|
|
||||||
## Stack
|
## Stack
|
||||||
|
|
||||||
|
|
@ -52,8 +59,13 @@ cp .env.example .env
|
||||||
| Receipt upload | ✓ | ✓ | ✓ |
|
| Receipt upload | ✓ | ✓ | ✓ |
|
||||||
| Expiry alerts | ✓ | ✓ | ✓ |
|
| Expiry alerts | ✓ | ✓ | ✓ |
|
||||||
| CSV export | ✓ | ✓ | ✓ |
|
| CSV export | ✓ | ✓ | ✓ |
|
||||||
|
| Recipe browser (domain/category) | ✓ | ✓ | ✓ |
|
||||||
|
| Save recipes + notes + star rating | ✓ | ✓ | ✓ |
|
||||||
|
| Style tags (manual, free-text) | ✓ | ✓ | ✓ |
|
||||||
| Receipt OCR | BYOK | ✓ | ✓ |
|
| Receipt OCR | BYOK | ✓ | ✓ |
|
||||||
| Recipe suggestions | BYOK | ✓ | ✓ |
|
| Recipe suggestions (L1–L4) | BYOK | ✓ | ✓ |
|
||||||
|
| Named recipe collections | — | ✓ | ✓ |
|
||||||
|
| LLM style auto-classifier | — | BYOK | ✓ |
|
||||||
| Meal planning | — | ✓ | ✓ |
|
| Meal planning | — | ✓ | ✓ |
|
||||||
| Multi-household | — | — | ✓ |
|
| Multi-household | — | — | ✓ |
|
||||||
| Leftover mode | — | — | ✓ |
|
| Leftover mode | — | — | ✓ |
|
||||||
|
|
|
||||||
|
|
@ -16,6 +16,9 @@ from app.db.session import get_store
|
||||||
from app.db.store import Store
|
from app.db.store import Store
|
||||||
from app.models.schemas.inventory import (
|
from app.models.schemas.inventory import (
|
||||||
BarcodeScanResponse,
|
BarcodeScanResponse,
|
||||||
|
BulkAddByNameRequest,
|
||||||
|
BulkAddByNameResponse,
|
||||||
|
BulkAddItemResult,
|
||||||
InventoryItemCreate,
|
InventoryItemCreate,
|
||||||
InventoryItemResponse,
|
InventoryItemResponse,
|
||||||
InventoryItemUpdate,
|
InventoryItemUpdate,
|
||||||
|
|
@ -130,6 +133,34 @@ async def create_inventory_item(body: InventoryItemCreate, store: Store = Depend
|
||||||
return InventoryItemResponse.model_validate(item)
|
return InventoryItemResponse.model_validate(item)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/items/bulk-add-by-name", response_model=BulkAddByNameResponse)
|
||||||
|
async def bulk_add_items_by_name(body: BulkAddByNameRequest, store: Store = Depends(get_store)):
|
||||||
|
"""Create pantry items from a list of ingredient names (no barcode required).
|
||||||
|
|
||||||
|
Uses get_or_create_product so re-adding an existing product is idempotent.
|
||||||
|
"""
|
||||||
|
results: list[BulkAddItemResult] = []
|
||||||
|
for entry in body.items:
|
||||||
|
try:
|
||||||
|
product, _ = await asyncio.to_thread(
|
||||||
|
store.get_or_create_product, entry.name, None, source="shopping"
|
||||||
|
)
|
||||||
|
item = await asyncio.to_thread(
|
||||||
|
store.add_inventory_item,
|
||||||
|
product["id"],
|
||||||
|
entry.location,
|
||||||
|
quantity=entry.quantity,
|
||||||
|
unit=entry.unit,
|
||||||
|
source="shopping",
|
||||||
|
)
|
||||||
|
results.append(BulkAddItemResult(name=entry.name, ok=True, item_id=item["id"]))
|
||||||
|
except Exception as exc:
|
||||||
|
results.append(BulkAddItemResult(name=entry.name, ok=False, error=str(exc)))
|
||||||
|
|
||||||
|
added = sum(1 for r in results if r.ok)
|
||||||
|
return BulkAddByNameResponse(added=added, failed=len(results) - added, results=results)
|
||||||
|
|
||||||
|
|
||||||
@router.get("/items", response_model=List[InventoryItemResponse])
|
@router.get("/items", response_model=List[InventoryItemResponse])
|
||||||
async def list_inventory_items(
|
async def list_inventory_items(
|
||||||
location: Optional[str] = None,
|
location: Optional[str] = None,
|
||||||
|
|
|
||||||
|
|
@ -1,14 +1,21 @@
|
||||||
"""Recipe suggestion endpoints."""
|
"""Recipe suggestion and browser endpoints."""
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import asyncio
|
import asyncio
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
from typing import Annotated
|
||||||
|
|
||||||
from fastapi import APIRouter, Depends, HTTPException
|
from fastapi import APIRouter, Depends, HTTPException, Query
|
||||||
|
|
||||||
from app.cloud_session import CloudUser, get_session
|
from app.cloud_session import CloudUser, get_session
|
||||||
from app.db.store import Store
|
from app.db.store import Store
|
||||||
from app.models.schemas.recipe import RecipeRequest, RecipeResult
|
from app.models.schemas.recipe import RecipeRequest, RecipeResult
|
||||||
|
from app.services.recipe.browser_domains import (
|
||||||
|
DOMAINS,
|
||||||
|
get_category_names,
|
||||||
|
get_domain_labels,
|
||||||
|
get_keywords_for_category,
|
||||||
|
)
|
||||||
from app.services.recipe.recipe_engine import RecipeEngine
|
from app.services.recipe.recipe_engine import RecipeEngine
|
||||||
from app.tiers import can_use
|
from app.tiers import can_use
|
||||||
|
|
||||||
|
|
@ -52,6 +59,90 @@ async def suggest_recipes(
|
||||||
return await asyncio.to_thread(_suggest_in_thread, session.db, req)
|
return await asyncio.to_thread(_suggest_in_thread, session.db, req)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/browse/domains")
|
||||||
|
async def list_browse_domains(
|
||||||
|
session: CloudUser = Depends(get_session),
|
||||||
|
) -> list[dict]:
|
||||||
|
"""Return available domain schemas for the recipe browser."""
|
||||||
|
return get_domain_labels()
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/browse/{domain}")
|
||||||
|
async def list_browse_categories(
|
||||||
|
domain: str,
|
||||||
|
session: CloudUser = Depends(get_session),
|
||||||
|
) -> list[dict]:
|
||||||
|
"""Return categories with recipe counts for a given domain."""
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
|
||||||
|
def _get(db_path: Path) -> list[dict]:
|
||||||
|
store = Store(db_path)
|
||||||
|
try:
|
||||||
|
return store.get_browser_categories(domain, keywords_by_category)
|
||||||
|
finally:
|
||||||
|
store.close()
|
||||||
|
|
||||||
|
return await asyncio.to_thread(_get, session.db)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/browse/{domain}/{category}")
|
||||||
|
async def browse_recipes(
|
||||||
|
domain: str,
|
||||||
|
category: str,
|
||||||
|
page: Annotated[int, Query(ge=1)] = 1,
|
||||||
|
page_size: Annotated[int, Query(ge=1, le=100)] = 20,
|
||||||
|
pantry_items: 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.
|
||||||
|
"""
|
||||||
|
if domain not in DOMAINS:
|
||||||
|
raise HTTPException(status_code=404, detail=f"Unknown domain '{domain}'.")
|
||||||
|
|
||||||
|
keywords = get_keywords_for_category(domain, category)
|
||||||
|
if not keywords:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=404,
|
||||||
|
detail=f"Unknown category '{category}' in domain '{domain}'.",
|
||||||
|
)
|
||||||
|
|
||||||
|
pantry_list = (
|
||||||
|
[p.strip() for p in pantry_items.split(",") if p.strip()]
|
||||||
|
if pantry_items
|
||||||
|
else None
|
||||||
|
)
|
||||||
|
|
||||||
|
def _browse(db_path: Path) -> dict:
|
||||||
|
store = Store(db_path)
|
||||||
|
try:
|
||||||
|
result = store.browse_recipes(
|
||||||
|
keywords=keywords,
|
||||||
|
page=page,
|
||||||
|
page_size=page_size,
|
||||||
|
pantry_items=pantry_list,
|
||||||
|
)
|
||||||
|
store.log_browser_telemetry(
|
||||||
|
domain=domain,
|
||||||
|
category=category,
|
||||||
|
page=page,
|
||||||
|
result_count=result["total"],
|
||||||
|
)
|
||||||
|
return result
|
||||||
|
finally:
|
||||||
|
store.close()
|
||||||
|
|
||||||
|
return await asyncio.to_thread(_browse, session.db)
|
||||||
|
|
||||||
|
|
||||||
@router.get("/{recipe_id}")
|
@router.get("/{recipe_id}")
|
||||||
async def get_recipe(recipe_id: int, session: CloudUser = Depends(get_session)) -> dict:
|
async def get_recipe(recipe_id: int, session: CloudUser = Depends(get_session)) -> dict:
|
||||||
def _get(db_path: Path, rid: int) -> dict | None:
|
def _get(db_path: Path, rid: int) -> dict | None:
|
||||||
|
|
|
||||||
186
app/api/endpoints/saved_recipes.py
Normal file
186
app/api/endpoints/saved_recipes.py
Normal file
|
|
@ -0,0 +1,186 @@
|
||||||
|
"""Saved recipe bookmark endpoints."""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Depends, HTTPException
|
||||||
|
|
||||||
|
from app.cloud_session import CloudUser, get_session
|
||||||
|
from app.db.store import Store
|
||||||
|
from app.models.schemas.saved_recipe import (
|
||||||
|
CollectionMemberRequest,
|
||||||
|
CollectionRequest,
|
||||||
|
CollectionSummary,
|
||||||
|
SavedRecipeSummary,
|
||||||
|
SaveRecipeRequest,
|
||||||
|
UpdateSavedRecipeRequest,
|
||||||
|
)
|
||||||
|
from app.tiers import can_use
|
||||||
|
|
||||||
|
router = APIRouter()
|
||||||
|
|
||||||
|
|
||||||
|
def _in_thread(db_path: Path, fn):
|
||||||
|
"""Run a Store operation in a worker thread with its own connection."""
|
||||||
|
store = Store(db_path)
|
||||||
|
try:
|
||||||
|
return fn(store)
|
||||||
|
finally:
|
||||||
|
store.close()
|
||||||
|
|
||||||
|
|
||||||
|
def _to_summary(row: dict, store: Store) -> SavedRecipeSummary:
|
||||||
|
collection_ids = store.get_saved_recipe_collection_ids(row["id"])
|
||||||
|
return SavedRecipeSummary(
|
||||||
|
id=row["id"],
|
||||||
|
recipe_id=row["recipe_id"],
|
||||||
|
title=row.get("title", ""),
|
||||||
|
saved_at=row["saved_at"],
|
||||||
|
notes=row.get("notes"),
|
||||||
|
rating=row.get("rating"),
|
||||||
|
style_tags=row.get("style_tags") or [],
|
||||||
|
collection_ids=collection_ids,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# ── save / unsave ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
@router.post("", response_model=SavedRecipeSummary)
|
||||||
|
async def save_recipe(
|
||||||
|
req: SaveRecipeRequest,
|
||||||
|
session: CloudUser = Depends(get_session),
|
||||||
|
) -> SavedRecipeSummary:
|
||||||
|
def _run(store: Store) -> SavedRecipeSummary:
|
||||||
|
row = store.save_recipe(req.recipe_id, req.notes, req.rating)
|
||||||
|
return _to_summary(row, store)
|
||||||
|
|
||||||
|
return await asyncio.to_thread(_in_thread, session.db, _run)
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete("/{recipe_id}", status_code=204)
|
||||||
|
async def unsave_recipe(
|
||||||
|
recipe_id: int,
|
||||||
|
session: CloudUser = Depends(get_session),
|
||||||
|
) -> None:
|
||||||
|
await asyncio.to_thread(
|
||||||
|
_in_thread, session.db, lambda s: s.unsave_recipe(recipe_id)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.patch("/{recipe_id}", response_model=SavedRecipeSummary)
|
||||||
|
async def update_saved_recipe(
|
||||||
|
recipe_id: int,
|
||||||
|
req: UpdateSavedRecipeRequest,
|
||||||
|
session: CloudUser = Depends(get_session),
|
||||||
|
) -> SavedRecipeSummary:
|
||||||
|
def _run(store: Store) -> SavedRecipeSummary:
|
||||||
|
if not store.is_recipe_saved(recipe_id):
|
||||||
|
raise HTTPException(status_code=404, detail="Recipe not saved.")
|
||||||
|
row = store.update_saved_recipe(
|
||||||
|
recipe_id, req.notes, req.rating, req.style_tags
|
||||||
|
)
|
||||||
|
return _to_summary(row, store)
|
||||||
|
|
||||||
|
return await asyncio.to_thread(_in_thread, session.db, _run)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("", response_model=list[SavedRecipeSummary])
|
||||||
|
async def list_saved_recipes(
|
||||||
|
sort_by: str = "saved_at",
|
||||||
|
collection_id: int | None = None,
|
||||||
|
session: CloudUser = Depends(get_session),
|
||||||
|
) -> list[SavedRecipeSummary]:
|
||||||
|
def _run(store: Store) -> list[SavedRecipeSummary]:
|
||||||
|
rows = store.get_saved_recipes(sort_by=sort_by, collection_id=collection_id)
|
||||||
|
return [_to_summary(r, store) for r in rows]
|
||||||
|
|
||||||
|
return await asyncio.to_thread(_in_thread, session.db, _run)
|
||||||
|
|
||||||
|
|
||||||
|
# ── collections (Paid) ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
@router.get("/collections", response_model=list[CollectionSummary])
|
||||||
|
async def list_collections(
|
||||||
|
session: CloudUser = Depends(get_session),
|
||||||
|
) -> list[CollectionSummary]:
|
||||||
|
rows = await asyncio.to_thread(
|
||||||
|
_in_thread, session.db, lambda s: s.get_collections()
|
||||||
|
)
|
||||||
|
return [CollectionSummary(**r) for r in rows]
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/collections", response_model=CollectionSummary)
|
||||||
|
async def create_collection(
|
||||||
|
req: CollectionRequest,
|
||||||
|
session: CloudUser = Depends(get_session),
|
||||||
|
) -> CollectionSummary:
|
||||||
|
if not can_use("recipe_collections", session.tier):
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=403,
|
||||||
|
detail="Collections require Paid tier.",
|
||||||
|
)
|
||||||
|
row = await asyncio.to_thread(
|
||||||
|
_in_thread, session.db,
|
||||||
|
lambda s: s.create_collection(req.name, req.description),
|
||||||
|
)
|
||||||
|
return CollectionSummary(**row)
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete("/collections/{collection_id}", status_code=204)
|
||||||
|
async def delete_collection(
|
||||||
|
collection_id: int,
|
||||||
|
session: CloudUser = Depends(get_session),
|
||||||
|
) -> None:
|
||||||
|
if not can_use("recipe_collections", session.tier):
|
||||||
|
raise HTTPException(status_code=403, detail="Collections require Paid tier.")
|
||||||
|
await asyncio.to_thread(
|
||||||
|
_in_thread, session.db, lambda s: s.delete_collection(collection_id)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.patch("/collections/{collection_id}", response_model=CollectionSummary)
|
||||||
|
async def rename_collection(
|
||||||
|
collection_id: int,
|
||||||
|
req: CollectionRequest,
|
||||||
|
session: CloudUser = Depends(get_session),
|
||||||
|
) -> CollectionSummary:
|
||||||
|
if not can_use("recipe_collections", session.tier):
|
||||||
|
raise HTTPException(status_code=403, detail="Collections require Paid tier.")
|
||||||
|
row = await asyncio.to_thread(
|
||||||
|
_in_thread, session.db,
|
||||||
|
lambda s: s.rename_collection(collection_id, req.name, req.description),
|
||||||
|
)
|
||||||
|
if not row:
|
||||||
|
raise HTTPException(status_code=404, detail="Collection not found.")
|
||||||
|
return CollectionSummary(**row)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/collections/{collection_id}/members", status_code=204)
|
||||||
|
async def add_to_collection(
|
||||||
|
collection_id: int,
|
||||||
|
req: CollectionMemberRequest,
|
||||||
|
session: CloudUser = Depends(get_session),
|
||||||
|
) -> None:
|
||||||
|
if not can_use("recipe_collections", session.tier):
|
||||||
|
raise HTTPException(status_code=403, detail="Collections require Paid tier.")
|
||||||
|
await asyncio.to_thread(
|
||||||
|
_in_thread, session.db,
|
||||||
|
lambda s: s.add_to_collection(collection_id, req.saved_recipe_id),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete(
|
||||||
|
"/collections/{collection_id}/members/{saved_recipe_id}", status_code=204
|
||||||
|
)
|
||||||
|
async def remove_from_collection(
|
||||||
|
collection_id: int,
|
||||||
|
saved_recipe_id: int,
|
||||||
|
session: CloudUser = Depends(get_session),
|
||||||
|
) -> None:
|
||||||
|
if not can_use("recipe_collections", session.tier):
|
||||||
|
raise HTTPException(status_code=403, detail="Collections require Paid tier.")
|
||||||
|
await asyncio.to_thread(
|
||||||
|
_in_thread, session.db,
|
||||||
|
lambda s: s.remove_from_collection(collection_id, saved_recipe_id),
|
||||||
|
)
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
from fastapi import APIRouter
|
from fastapi import APIRouter
|
||||||
from app.api.endpoints import health, receipts, export, inventory, ocr, recipes, settings, staples, feedback, household
|
from app.api.endpoints import health, receipts, export, inventory, ocr, recipes, settings, staples, feedback, household, saved_recipes
|
||||||
|
|
||||||
api_router = APIRouter()
|
api_router = APIRouter()
|
||||||
|
|
||||||
|
|
@ -13,3 +13,4 @@ api_router.include_router(settings.router, prefix="/settings", tags=["setting
|
||||||
api_router.include_router(staples.router, prefix="/staples", tags=["staples"])
|
api_router.include_router(staples.router, prefix="/staples", tags=["staples"])
|
||||||
api_router.include_router(feedback.router, prefix="/feedback", tags=["feedback"])
|
api_router.include_router(feedback.router, prefix="/feedback", tags=["feedback"])
|
||||||
api_router.include_router(household.router, prefix="/household", tags=["household"])
|
api_router.include_router(household.router, prefix="/household", tags=["household"])
|
||||||
|
api_router.include_router(saved_recipes.router, prefix="/recipes/saved", tags=["saved-recipes"])
|
||||||
14
app/db/migrations/018_saved_recipes.sql
Normal file
14
app/db/migrations/018_saved_recipes.sql
Normal file
|
|
@ -0,0 +1,14 @@
|
||||||
|
-- Migration 018: saved recipes bookmarks.
|
||||||
|
|
||||||
|
CREATE TABLE saved_recipes (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
recipe_id INTEGER NOT NULL REFERENCES recipes(id) ON DELETE CASCADE,
|
||||||
|
saved_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||||
|
notes TEXT,
|
||||||
|
rating INTEGER CHECK (rating IS NULL OR (rating >= 0 AND rating <= 5)),
|
||||||
|
style_tags TEXT NOT NULL DEFAULT '[]',
|
||||||
|
UNIQUE (recipe_id)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX idx_saved_recipes_saved_at ON saved_recipes (saved_at DESC);
|
||||||
|
CREATE INDEX idx_saved_recipes_rating ON saved_recipes (rating);
|
||||||
16
app/db/migrations/019_recipe_collections.sql
Normal file
16
app/db/migrations/019_recipe_collections.sql
Normal file
|
|
@ -0,0 +1,16 @@
|
||||||
|
-- Migration 019: recipe collections (Paid tier organisation).
|
||||||
|
|
||||||
|
CREATE TABLE recipe_collections (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
name TEXT NOT NULL,
|
||||||
|
description TEXT,
|
||||||
|
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||||
|
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE recipe_collection_members (
|
||||||
|
collection_id INTEGER NOT NULL REFERENCES recipe_collections(id) ON DELETE CASCADE,
|
||||||
|
saved_recipe_id INTEGER NOT NULL REFERENCES saved_recipes(id) ON DELETE CASCADE,
|
||||||
|
added_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||||
|
PRIMARY KEY (collection_id, saved_recipe_id)
|
||||||
|
);
|
||||||
13
app/db/migrations/020_browser_telemetry.sql
Normal file
13
app/db/migrations/020_browser_telemetry.sql
Normal file
|
|
@ -0,0 +1,13 @@
|
||||||
|
-- Migration 020: recipe browser navigation telemetry.
|
||||||
|
-- Used to determine whether category nesting depth needs increasing.
|
||||||
|
-- Review: if any category has page > 5 and result_count > 100 consistently,
|
||||||
|
-- consider adding a third nesting level for that category.
|
||||||
|
|
||||||
|
CREATE TABLE browser_telemetry (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
domain TEXT NOT NULL,
|
||||||
|
category TEXT NOT NULL,
|
||||||
|
page INTEGER NOT NULL,
|
||||||
|
result_count INTEGER NOT NULL,
|
||||||
|
recorded_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||||
|
);
|
||||||
266
app/db/store.py
266
app/db/store.py
|
|
@ -35,7 +35,9 @@ class Store:
|
||||||
"warnings",
|
"warnings",
|
||||||
# recipe columns
|
# recipe columns
|
||||||
"ingredients", "ingredient_names", "directions",
|
"ingredients", "ingredient_names", "directions",
|
||||||
"keywords", "element_coverage"):
|
"keywords", "element_coverage",
|
||||||
|
# saved recipe columns
|
||||||
|
"style_tags"):
|
||||||
if key in d and isinstance(d[key], str):
|
if key in d and isinstance(d[key], str):
|
||||||
try:
|
try:
|
||||||
d[key] = json.loads(d[key])
|
d[key] = json.loads(d[key])
|
||||||
|
|
@ -735,3 +737,265 @@ class Store:
|
||||||
int(approved), int(opted_in),
|
int(approved), int(opted_in),
|
||||||
))
|
))
|
||||||
self.conn.commit()
|
self.conn.commit()
|
||||||
|
|
||||||
|
# ── saved recipes ─────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def save_recipe(
|
||||||
|
self,
|
||||||
|
recipe_id: int,
|
||||||
|
notes: str | None,
|
||||||
|
rating: int | None,
|
||||||
|
) -> dict:
|
||||||
|
return self._insert_returning(
|
||||||
|
"""
|
||||||
|
INSERT INTO saved_recipes (recipe_id, notes, rating)
|
||||||
|
VALUES (?, ?, ?)
|
||||||
|
ON CONFLICT(recipe_id) DO UPDATE SET
|
||||||
|
notes = excluded.notes,
|
||||||
|
rating = excluded.rating
|
||||||
|
RETURNING *
|
||||||
|
""",
|
||||||
|
(recipe_id, notes, rating),
|
||||||
|
)
|
||||||
|
|
||||||
|
def unsave_recipe(self, recipe_id: int) -> None:
|
||||||
|
self.conn.execute(
|
||||||
|
"DELETE FROM saved_recipes WHERE recipe_id = ?", (recipe_id,)
|
||||||
|
)
|
||||||
|
self.conn.commit()
|
||||||
|
|
||||||
|
def is_recipe_saved(self, recipe_id: int) -> bool:
|
||||||
|
row = self._fetch_one(
|
||||||
|
"SELECT id FROM saved_recipes WHERE recipe_id = ?", (recipe_id,)
|
||||||
|
)
|
||||||
|
return row is not None
|
||||||
|
|
||||||
|
def update_saved_recipe(
|
||||||
|
self,
|
||||||
|
recipe_id: int,
|
||||||
|
notes: str | None,
|
||||||
|
rating: int | None,
|
||||||
|
style_tags: list[str],
|
||||||
|
) -> dict:
|
||||||
|
self.conn.execute(
|
||||||
|
"""
|
||||||
|
UPDATE saved_recipes
|
||||||
|
SET notes = ?, rating = ?, style_tags = ?
|
||||||
|
WHERE recipe_id = ?
|
||||||
|
""",
|
||||||
|
(notes, rating, self._dump(style_tags), recipe_id),
|
||||||
|
)
|
||||||
|
self.conn.commit()
|
||||||
|
row = self._fetch_one(
|
||||||
|
"SELECT * FROM saved_recipes WHERE recipe_id = ?", (recipe_id,)
|
||||||
|
)
|
||||||
|
return row # type: ignore[return-value]
|
||||||
|
|
||||||
|
def get_saved_recipes(
|
||||||
|
self,
|
||||||
|
sort_by: str = "saved_at",
|
||||||
|
collection_id: int | None = None,
|
||||||
|
) -> list[dict]:
|
||||||
|
order = {
|
||||||
|
"saved_at": "sr.saved_at DESC",
|
||||||
|
"rating": "sr.rating DESC",
|
||||||
|
"title": "r.title ASC",
|
||||||
|
}.get(sort_by, "sr.saved_at DESC")
|
||||||
|
|
||||||
|
if collection_id is not None:
|
||||||
|
return self._fetch_all(
|
||||||
|
f"""
|
||||||
|
SELECT sr.*, r.title
|
||||||
|
FROM saved_recipes sr
|
||||||
|
JOIN recipes r ON r.id = sr.recipe_id
|
||||||
|
JOIN recipe_collection_members rcm ON rcm.saved_recipe_id = sr.id
|
||||||
|
WHERE rcm.collection_id = ?
|
||||||
|
ORDER BY {order}
|
||||||
|
""",
|
||||||
|
(collection_id,),
|
||||||
|
)
|
||||||
|
return self._fetch_all(
|
||||||
|
f"""
|
||||||
|
SELECT sr.*, r.title
|
||||||
|
FROM saved_recipes sr
|
||||||
|
JOIN recipes r ON r.id = sr.recipe_id
|
||||||
|
ORDER BY {order}
|
||||||
|
""",
|
||||||
|
)
|
||||||
|
|
||||||
|
def get_saved_recipe_collection_ids(self, saved_recipe_id: int) -> list[int]:
|
||||||
|
rows = self._fetch_all(
|
||||||
|
"SELECT collection_id FROM recipe_collection_members WHERE saved_recipe_id = ?",
|
||||||
|
(saved_recipe_id,),
|
||||||
|
)
|
||||||
|
return [r["collection_id"] for r in rows]
|
||||||
|
|
||||||
|
# ── recipe collections ────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def create_collection(self, name: str, description: str | None) -> dict:
|
||||||
|
return self._insert_returning(
|
||||||
|
"INSERT INTO recipe_collections (name, description) VALUES (?, ?) RETURNING *",
|
||||||
|
(name, description),
|
||||||
|
)
|
||||||
|
|
||||||
|
def delete_collection(self, collection_id: int) -> None:
|
||||||
|
self.conn.execute(
|
||||||
|
"DELETE FROM recipe_collections WHERE id = ?", (collection_id,)
|
||||||
|
)
|
||||||
|
self.conn.commit()
|
||||||
|
|
||||||
|
def rename_collection(
|
||||||
|
self, collection_id: int, name: str, description: str | None
|
||||||
|
) -> dict:
|
||||||
|
self.conn.execute(
|
||||||
|
"""
|
||||||
|
UPDATE recipe_collections
|
||||||
|
SET name = ?, description = ?, updated_at = datetime('now')
|
||||||
|
WHERE id = ?
|
||||||
|
""",
|
||||||
|
(name, description, collection_id),
|
||||||
|
)
|
||||||
|
self.conn.commit()
|
||||||
|
row = self._fetch_one(
|
||||||
|
"SELECT * FROM recipe_collections WHERE id = ?", (collection_id,)
|
||||||
|
)
|
||||||
|
return row # type: ignore[return-value]
|
||||||
|
|
||||||
|
def get_collections(self) -> list[dict]:
|
||||||
|
return self._fetch_all(
|
||||||
|
"""
|
||||||
|
SELECT rc.*,
|
||||||
|
COUNT(rcm.saved_recipe_id) AS member_count
|
||||||
|
FROM recipe_collections rc
|
||||||
|
LEFT JOIN recipe_collection_members rcm ON rcm.collection_id = rc.id
|
||||||
|
GROUP BY rc.id
|
||||||
|
ORDER BY rc.created_at ASC
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
|
||||||
|
def add_to_collection(self, collection_id: int, saved_recipe_id: int) -> None:
|
||||||
|
self.conn.execute(
|
||||||
|
"""
|
||||||
|
INSERT OR IGNORE INTO recipe_collection_members (collection_id, saved_recipe_id)
|
||||||
|
VALUES (?, ?)
|
||||||
|
""",
|
||||||
|
(collection_id, saved_recipe_id),
|
||||||
|
)
|
||||||
|
self.conn.commit()
|
||||||
|
|
||||||
|
def remove_from_collection(
|
||||||
|
self, collection_id: int, saved_recipe_id: int
|
||||||
|
) -> None:
|
||||||
|
self.conn.execute(
|
||||||
|
"""
|
||||||
|
DELETE FROM recipe_collection_members
|
||||||
|
WHERE collection_id = ? AND saved_recipe_id = ?
|
||||||
|
""",
|
||||||
|
(collection_id, saved_recipe_id),
|
||||||
|
)
|
||||||
|
self.conn.commit()
|
||||||
|
|
||||||
|
# ── recipe browser ────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def get_browser_categories(
|
||||||
|
self, domain: str, keywords_by_category: dict[str, list[str]]
|
||||||
|
) -> list[dict]:
|
||||||
|
"""Return [{category, recipe_count}] for each category in the domain.
|
||||||
|
|
||||||
|
keywords_by_category maps category name to the keyword list used to
|
||||||
|
match against recipes.category and recipes.keywords.
|
||||||
|
"""
|
||||||
|
results = []
|
||||||
|
for category, keywords in keywords_by_category.items():
|
||||||
|
count = self._count_recipes_for_keywords(keywords)
|
||||||
|
results.append({"category": category, "recipe_count": count})
|
||||||
|
return results
|
||||||
|
|
||||||
|
def _count_recipes_for_keywords(self, keywords: list[str]) -> int:
|
||||||
|
if not keywords:
|
||||||
|
return 0
|
||||||
|
conditions = " OR ".join(
|
||||||
|
["lower(category) LIKE ?"] * len(keywords)
|
||||||
|
+ ["lower(keywords) LIKE ?"] * len(keywords)
|
||||||
|
)
|
||||||
|
params = tuple(f"%{kw.lower()}%" for kw in keywords) * 2
|
||||||
|
row = self.conn.execute(
|
||||||
|
f"SELECT count(*) AS n FROM recipes WHERE {conditions}", params
|
||||||
|
).fetchone()
|
||||||
|
return row[0] if row else 0
|
||||||
|
|
||||||
|
def browse_recipes(
|
||||||
|
self,
|
||||||
|
keywords: list[str],
|
||||||
|
page: int,
|
||||||
|
page_size: int,
|
||||||
|
pantry_items: list[str] | None = None,
|
||||||
|
) -> dict:
|
||||||
|
"""Return a page of recipes matching the keyword set.
|
||||||
|
|
||||||
|
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:
|
||||||
|
return {"recipes": [], "total": 0, "page": page}
|
||||||
|
|
||||||
|
conditions = " OR ".join(
|
||||||
|
["lower(category) LIKE ?"] * len(keywords)
|
||||||
|
+ ["lower(keywords) LIKE ?"] * len(keywords)
|
||||||
|
)
|
||||||
|
like_params = tuple(f"%{kw.lower()}%" for kw in keywords) * 2
|
||||||
|
offset = (page - 1) * page_size
|
||||||
|
|
||||||
|
total_row = self.conn.execute(
|
||||||
|
f"SELECT count(*) AS n FROM recipes WHERE {conditions}", like_params
|
||||||
|
).fetchone()
|
||||||
|
total = total_row[0] if total_row else 0
|
||||||
|
|
||||||
|
rows = self._fetch_all(
|
||||||
|
f"""
|
||||||
|
SELECT id, title, category, keywords, ingredient_names,
|
||||||
|
calories, fat_g, protein_g, sodium_mg, source_url
|
||||||
|
FROM recipes
|
||||||
|
WHERE {conditions}
|
||||||
|
ORDER BY title ASC
|
||||||
|
LIMIT ? OFFSET ?
|
||||||
|
""",
|
||||||
|
like_params + (page_size, offset),
|
||||||
|
)
|
||||||
|
|
||||||
|
pantry_set = {p.lower() for p in pantry_items} if pantry_items else None
|
||||||
|
recipes = []
|
||||||
|
for r in rows:
|
||||||
|
entry = {
|
||||||
|
"id": r["id"],
|
||||||
|
"title": r["title"],
|
||||||
|
"category": r["category"],
|
||||||
|
"match_pct": None,
|
||||||
|
}
|
||||||
|
if pantry_set:
|
||||||
|
names = r.get("ingredient_names") or []
|
||||||
|
if names:
|
||||||
|
matched = sum(
|
||||||
|
1 for n in names if n.lower() in pantry_set
|
||||||
|
)
|
||||||
|
entry["match_pct"] = round(matched / len(names), 3)
|
||||||
|
recipes.append(entry)
|
||||||
|
|
||||||
|
return {"recipes": recipes, "total": total, "page": page}
|
||||||
|
|
||||||
|
def log_browser_telemetry(
|
||||||
|
self,
|
||||||
|
domain: str,
|
||||||
|
category: str,
|
||||||
|
page: int,
|
||||||
|
result_count: int,
|
||||||
|
) -> None:
|
||||||
|
self.conn.execute(
|
||||||
|
"""
|
||||||
|
INSERT INTO browser_telemetry (domain, category, page, result_count)
|
||||||
|
VALUES (?, ?, ?, ?)
|
||||||
|
""",
|
||||||
|
(domain, category, page, result_count),
|
||||||
|
)
|
||||||
|
self.conn.commit()
|
||||||
|
|
|
||||||
|
|
@ -133,6 +133,32 @@ class BarcodeScanResponse(BaseModel):
|
||||||
message: str
|
message: str
|
||||||
|
|
||||||
|
|
||||||
|
# ── Bulk add by name ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
class BulkAddItem(BaseModel):
|
||||||
|
name: str = Field(..., min_length=1, max_length=200)
|
||||||
|
quantity: float = Field(default=1.0, gt=0)
|
||||||
|
unit: str = "count"
|
||||||
|
location: str = "pantry"
|
||||||
|
|
||||||
|
|
||||||
|
class BulkAddByNameRequest(BaseModel):
|
||||||
|
items: List[BulkAddItem] = Field(..., min_length=1)
|
||||||
|
|
||||||
|
|
||||||
|
class BulkAddItemResult(BaseModel):
|
||||||
|
name: str
|
||||||
|
ok: bool
|
||||||
|
item_id: Optional[int] = None
|
||||||
|
error: Optional[str] = None
|
||||||
|
|
||||||
|
|
||||||
|
class BulkAddByNameResponse(BaseModel):
|
||||||
|
added: int
|
||||||
|
failed: int
|
||||||
|
results: List[BulkAddItemResult]
|
||||||
|
|
||||||
|
|
||||||
# ── Stats ─────────────────────────────────────────────────────────────────────
|
# ── Stats ─────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
class InventoryStats(BaseModel):
|
class InventoryStats(BaseModel):
|
||||||
|
|
|
||||||
|
|
@ -32,6 +32,7 @@ class RecipeSuggestion(BaseModel):
|
||||||
match_count: int
|
match_count: int
|
||||||
element_coverage: dict[str, float] = Field(default_factory=dict)
|
element_coverage: dict[str, float] = Field(default_factory=dict)
|
||||||
swap_candidates: list[SwapCandidate] = Field(default_factory=list)
|
swap_candidates: list[SwapCandidate] = Field(default_factory=list)
|
||||||
|
matched_ingredients: list[str] = Field(default_factory=list)
|
||||||
missing_ingredients: list[str] = Field(default_factory=list)
|
missing_ingredients: list[str] = Field(default_factory=list)
|
||||||
directions: list[str] = Field(default_factory=list)
|
directions: list[str] = Field(default_factory=list)
|
||||||
prep_notes: list[str] = Field(default_factory=list)
|
prep_notes: list[str] = Field(default_factory=list)
|
||||||
|
|
@ -39,6 +40,7 @@ class RecipeSuggestion(BaseModel):
|
||||||
level: int = 1
|
level: int = 1
|
||||||
is_wildcard: bool = False
|
is_wildcard: bool = False
|
||||||
nutrition: NutritionPanel | None = None
|
nutrition: NutritionPanel | None = None
|
||||||
|
source_url: str | None = None
|
||||||
|
|
||||||
|
|
||||||
class GroceryLink(BaseModel):
|
class GroceryLink(BaseModel):
|
||||||
|
|
@ -79,3 +81,4 @@ class RecipeRequest(BaseModel):
|
||||||
allergies: list[str] = Field(default_factory=list)
|
allergies: list[str] = Field(default_factory=list)
|
||||||
nutrition_filters: NutritionFilters = Field(default_factory=NutritionFilters)
|
nutrition_filters: NutritionFilters = Field(default_factory=NutritionFilters)
|
||||||
excluded_ids: list[int] = Field(default_factory=list)
|
excluded_ids: list[int] = Field(default_factory=list)
|
||||||
|
shopping_mode: bool = False
|
||||||
|
|
|
||||||
44
app/models/schemas/saved_recipe.py
Normal file
44
app/models/schemas/saved_recipe.py
Normal file
|
|
@ -0,0 +1,44 @@
|
||||||
|
"""Pydantic schemas for saved recipes and collections."""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from pydantic import BaseModel, Field
|
||||||
|
|
||||||
|
|
||||||
|
class SaveRecipeRequest(BaseModel):
|
||||||
|
recipe_id: int
|
||||||
|
notes: str | None = None
|
||||||
|
rating: int | None = Field(None, ge=0, le=5)
|
||||||
|
|
||||||
|
|
||||||
|
class UpdateSavedRecipeRequest(BaseModel):
|
||||||
|
notes: str | None = None
|
||||||
|
rating: int | None = Field(None, ge=0, le=5)
|
||||||
|
style_tags: list[str] = Field(default_factory=list)
|
||||||
|
|
||||||
|
|
||||||
|
class SavedRecipeSummary(BaseModel):
|
||||||
|
id: int
|
||||||
|
recipe_id: int
|
||||||
|
title: str
|
||||||
|
saved_at: str
|
||||||
|
notes: str | None
|
||||||
|
rating: int | None
|
||||||
|
style_tags: list[str]
|
||||||
|
collection_ids: list[int] = Field(default_factory=list)
|
||||||
|
|
||||||
|
|
||||||
|
class CollectionSummary(BaseModel):
|
||||||
|
id: int
|
||||||
|
name: str
|
||||||
|
description: str | None
|
||||||
|
member_count: int
|
||||||
|
created_at: str
|
||||||
|
|
||||||
|
|
||||||
|
class CollectionRequest(BaseModel):
|
||||||
|
name: str
|
||||||
|
description: str | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class CollectionMemberRequest(BaseModel):
|
||||||
|
saved_recipe_id: int
|
||||||
|
|
@ -248,6 +248,8 @@ ASSEMBLY_TEMPLATES: list[AssemblyTemplate] = [
|
||||||
"zucchini", "mushroom", "corn", "onion", "bean sprout",
|
"zucchini", "mushroom", "corn", "onion", "bean sprout",
|
||||||
"cabbage", "spinach", "asparagus",
|
"cabbage", "spinach", "asparagus",
|
||||||
]),
|
]),
|
||||||
|
# Starch base required — prevents this from firing on any pantry with vegetables
|
||||||
|
AssemblyRole("starch base", ["rice", "noodle", "pasta", "ramen", "cauliflower rice"]),
|
||||||
],
|
],
|
||||||
optional=[
|
optional=[
|
||||||
AssemblyRole("protein", [
|
AssemblyRole("protein", [
|
||||||
|
|
@ -257,7 +259,6 @@ ASSEMBLY_TEMPLATES: list[AssemblyTemplate] = [
|
||||||
"soy sauce", "teriyaki", "oyster sauce", "hoisin",
|
"soy sauce", "teriyaki", "oyster sauce", "hoisin",
|
||||||
"stir fry sauce", "sesame",
|
"stir fry sauce", "sesame",
|
||||||
]),
|
]),
|
||||||
AssemblyRole("starch base", ["rice", "noodle", "pasta", "ramen"]),
|
|
||||||
AssemblyRole("garlic or ginger", ["garlic", "ginger"]),
|
AssemblyRole("garlic or ginger", ["garlic", "ginger"]),
|
||||||
AssemblyRole("oil", ["oil", "sesame"]),
|
AssemblyRole("oil", ["oil", "sesame"]),
|
||||||
],
|
],
|
||||||
|
|
@ -381,9 +382,10 @@ ASSEMBLY_TEMPLATES: list[AssemblyTemplate] = [
|
||||||
id=-8,
|
id=-8,
|
||||||
title="Soup / Stew",
|
title="Soup / Stew",
|
||||||
required=[
|
required=[
|
||||||
AssemblyRole("broth or liquid base", [
|
# Narrow to dedicated soup bases — tomato sauce and coconut milk are
|
||||||
"broth", "stock", "bouillon",
|
# pantry staples used in too many non-soup dishes to serve as anchors.
|
||||||
"tomato sauce", "coconut milk", "cream of",
|
AssemblyRole("broth or stock", [
|
||||||
|
"broth", "stock", "bouillon", "cream of",
|
||||||
]),
|
]),
|
||||||
],
|
],
|
||||||
optional=[
|
optional=[
|
||||||
|
|
@ -572,6 +574,12 @@ ASSEMBLY_TEMPLATES: list[AssemblyTemplate] = [
|
||||||
"egg", "cornstarch", "custard powder", "gelatin",
|
"egg", "cornstarch", "custard powder", "gelatin",
|
||||||
"agar", "tapioca", "arrowroot",
|
"agar", "tapioca", "arrowroot",
|
||||||
]),
|
]),
|
||||||
|
# Require a clear dessert-intent signal — milk + eggs alone is too generic
|
||||||
|
# (also covers white sauce, quiche, etc.)
|
||||||
|
AssemblyRole("sweetener or flavouring", [
|
||||||
|
"sugar", "honey", "maple syrup", "condensed milk",
|
||||||
|
"vanilla", "chocolate", "cocoa", "caramel", "custard powder",
|
||||||
|
]),
|
||||||
],
|
],
|
||||||
optional=[
|
optional=[
|
||||||
AssemblyRole("sweetener", ["sugar", "honey", "maple syrup", "condensed milk"]),
|
AssemblyRole("sweetener", ["sugar", "honey", "maple syrup", "condensed milk"]),
|
||||||
|
|
@ -605,14 +613,20 @@ def match_assembly_templates(
|
||||||
pantry_items: list[str],
|
pantry_items: list[str],
|
||||||
pantry_set: set[str],
|
pantry_set: set[str],
|
||||||
excluded_ids: list[int],
|
excluded_ids: list[int],
|
||||||
|
expiring_set: set[str] | None = None,
|
||||||
) -> list[RecipeSuggestion]:
|
) -> list[RecipeSuggestion]:
|
||||||
"""Return assembly-dish suggestions whose required roles are all satisfied.
|
"""Return assembly-dish suggestions whose required roles are all satisfied.
|
||||||
|
|
||||||
Titles are personalized with specific pantry items (deterministically chosen
|
Titles are personalized with specific pantry items (deterministically chosen
|
||||||
from the pantry contents so the same pantry always produces the same title).
|
from the pantry contents so the same pantry always produces the same title).
|
||||||
Skips templates whose id is in excluded_ids (dismiss/load-more support).
|
Skips templates whose id is in excluded_ids (dismiss/load-more support).
|
||||||
|
|
||||||
|
expiring_set: expanded pantry set of items close to expiry. Templates that
|
||||||
|
use an expiring item in a required role get +2 added to match_count so they
|
||||||
|
rank higher when the caller sorts the combined result list.
|
||||||
"""
|
"""
|
||||||
excluded = set(excluded_ids)
|
excluded = set(excluded_ids)
|
||||||
|
expiring = expiring_set or set()
|
||||||
seed = _pantry_hash(pantry_set)
|
seed = _pantry_hash(pantry_set)
|
||||||
results: list[RecipeSuggestion] = []
|
results: list[RecipeSuggestion] = []
|
||||||
|
|
||||||
|
|
@ -620,20 +634,40 @@ def match_assembly_templates(
|
||||||
if tmpl.id in excluded:
|
if tmpl.id in excluded:
|
||||||
continue
|
continue
|
||||||
|
|
||||||
# All required roles must be satisfied
|
# All required roles must be satisfied; collect matched items for required roles
|
||||||
if any(not _matches_role(role, pantry_set) for role in tmpl.required):
|
required_matches: list[str] = []
|
||||||
|
skip = False
|
||||||
|
for role in tmpl.required:
|
||||||
|
hits = _matches_role(role, pantry_set)
|
||||||
|
if not hits:
|
||||||
|
skip = True
|
||||||
|
break
|
||||||
|
required_matches.append(_pick_one(hits, seed + tmpl.id))
|
||||||
|
if skip:
|
||||||
continue
|
continue
|
||||||
|
|
||||||
optional_hit_count = sum(
|
# Collect matched items for optional roles (one representative per matched role)
|
||||||
1 for role in tmpl.optional if _matches_role(role, pantry_set)
|
optional_matches: list[str] = []
|
||||||
)
|
for role in tmpl.optional:
|
||||||
|
hits = _matches_role(role, pantry_set)
|
||||||
|
if hits:
|
||||||
|
optional_matches.append(_pick_one(hits, seed + tmpl.id))
|
||||||
|
|
||||||
|
matched = required_matches + optional_matches
|
||||||
|
|
||||||
|
# Expiry boost: +2 if any required ingredient is in the expiring set,
|
||||||
|
# so time-sensitive templates surface first in the merged ranking.
|
||||||
|
expiry_bonus = 2 if expiring and any(
|
||||||
|
item.lower() in expiring for item in required_matches
|
||||||
|
) else 0
|
||||||
|
|
||||||
results.append(RecipeSuggestion(
|
results.append(RecipeSuggestion(
|
||||||
id=tmpl.id,
|
id=tmpl.id,
|
||||||
title=_personalized_title(tmpl, pantry_set, seed + tmpl.id),
|
title=_personalized_title(tmpl, pantry_set, seed + tmpl.id),
|
||||||
match_count=len(tmpl.required) + optional_hit_count,
|
match_count=len(matched) + expiry_bonus,
|
||||||
element_coverage={},
|
element_coverage={},
|
||||||
swap_candidates=[],
|
swap_candidates=[],
|
||||||
|
matched_ingredients=matched,
|
||||||
missing_ingredients=[],
|
missing_ingredients=[],
|
||||||
directions=tmpl.directions,
|
directions=tmpl.directions,
|
||||||
notes=tmpl.notes,
|
notes=tmpl.notes,
|
||||||
|
|
|
||||||
89
app/services/recipe/browser_domains.py
Normal file
89
app/services/recipe/browser_domains.py
Normal file
|
|
@ -0,0 +1,89 @@
|
||||||
|
"""
|
||||||
|
Recipe browser domain schemas.
|
||||||
|
|
||||||
|
Each domain provides a two-level category hierarchy for browsing the recipe corpus.
|
||||||
|
Keyword matching is case-insensitive against the recipes.category column and the
|
||||||
|
recipes.keywords JSON array. A recipe may appear in multiple categories (correct).
|
||||||
|
|
||||||
|
These are starter mappings based on the food.com dataset structure. Run:
|
||||||
|
|
||||||
|
SELECT category, count(*) FROM recipes
|
||||||
|
GROUP BY category ORDER BY count(*) DESC LIMIT 50;
|
||||||
|
|
||||||
|
against the corpus to verify coverage and refine keyword lists before the first
|
||||||
|
production deploy.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
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"],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"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"],
|
||||||
|
"Beverage": ["drink", "smoothie", "cocktail", "beverage", "juice", "shake"],
|
||||||
|
"Side Dish": ["side dish", "side", "accompaniment", "garnish"],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"dietary": {
|
||||||
|
"label": "Dietary",
|
||||||
|
"categories": {
|
||||||
|
"Vegetarian": ["vegetarian"],
|
||||||
|
"Vegan": ["vegan", "plant-based", "plant based"],
|
||||||
|
"Gluten-Free": ["gluten-free", "gluten free", "celiac"],
|
||||||
|
"Low-Carb": ["low-carb", "low carb", "keto", "ketogenic"],
|
||||||
|
"High-Protein": ["high protein", "high-protein"],
|
||||||
|
"Low-Fat": ["low-fat", "low fat", "light"],
|
||||||
|
"Dairy-Free": ["dairy-free", "dairy free", "lactose"],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"main_ingredient": {
|
||||||
|
"label": "Main Ingredient",
|
||||||
|
"categories": {
|
||||||
|
"Chicken": ["chicken", "poultry", "turkey"],
|
||||||
|
"Beef": ["beef", "ground beef", "steak", "brisket", "pot roast"],
|
||||||
|
"Pork": ["pork", "bacon", "ham", "sausage", "prosciutto"],
|
||||||
|
"Fish": ["fish", "salmon", "tuna", "tilapia", "cod", "halibut", "shrimp", "seafood"],
|
||||||
|
"Pasta": ["pasta", "noodle", "spaghetti", "penne", "fettuccine", "linguine"],
|
||||||
|
"Vegetables": ["vegetable", "veggie", "cauliflower", "broccoli", "zucchini", "eggplant"],
|
||||||
|
"Eggs": ["egg", "frittata", "omelette", "omelet", "quiche"],
|
||||||
|
"Legumes": ["bean", "lentil", "chickpea", "tofu", "tempeh", "edamame"],
|
||||||
|
"Grains": ["rice", "quinoa", "barley", "farro", "oat", "grain"],
|
||||||
|
"Cheese": ["cheese", "ricotta", "mozzarella", "parmesan", "cheddar"],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
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, [])
|
||||||
|
|
||||||
|
|
||||||
|
def get_category_names(domain: str) -> list[str]:
|
||||||
|
"""Return category names for a domain, or [] if domain unknown."""
|
||||||
|
domain_data = DOMAINS.get(domain, {})
|
||||||
|
return list(domain_data.get("categories", {}).keys())
|
||||||
|
|
@ -1,69 +1,76 @@
|
||||||
"""
|
"""
|
||||||
GroceryLinkBuilder — affiliate deeplinks for missing ingredient grocery lists.
|
GroceryLinkBuilder — affiliate deeplinks for missing ingredient grocery lists.
|
||||||
|
|
||||||
Free tier: URL construction only (Amazon Fresh, Walmart, Instacart).
|
Delegates URL wrapping to circuitforge_core.affiliates.wrap_url, which handles
|
||||||
Paid+: live product search API (stubbed — future task).
|
the full resolution chain: opt-out → BYOK id → CF env var → plain URL.
|
||||||
|
|
||||||
Config (env vars, all optional — missing = retailer disabled):
|
Registered programs (via cf-core):
|
||||||
AMAZON_AFFILIATE_TAG — e.g. "circuitforge-20"
|
amazon — Amazon Associates (env: AMAZON_ASSOCIATES_TAG)
|
||||||
INSTACART_AFFILIATE_ID — e.g. "circuitforge"
|
instacart — Instacart (env: INSTACART_AFFILIATE_ID)
|
||||||
WALMART_AFFILIATE_ID — e.g. "circuitforge" (Impact affiliate network)
|
|
||||||
|
Walmart is kept inline until cf-core adds Impact network support:
|
||||||
|
env: WALMART_AFFILIATE_ID
|
||||||
|
|
||||||
|
Links are always generated (plain URLs are useful even without affiliate IDs).
|
||||||
|
Walmart links only appear when WALMART_AFFILIATE_ID is set.
|
||||||
"""
|
"""
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import logging
|
||||||
import os
|
import os
|
||||||
from urllib.parse import quote_plus
|
from urllib.parse import quote_plus
|
||||||
|
|
||||||
|
from circuitforge_core.affiliates import wrap_url
|
||||||
|
|
||||||
from app.models.schemas.recipe import GroceryLink
|
from app.models.schemas.recipe import GroceryLink
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
def _amazon_link(ingredient: str, tag: str) -> GroceryLink:
|
|
||||||
|
def _amazon_fresh_link(ingredient: str) -> GroceryLink:
|
||||||
q = quote_plus(ingredient)
|
q = quote_plus(ingredient)
|
||||||
url = f"https://www.amazon.com/s?k={q}&i=amazonfresh&tag={tag}"
|
base = f"https://www.amazon.com/s?k={q}&i=amazonfresh"
|
||||||
return GroceryLink(ingredient=ingredient, retailer="Amazon Fresh", url=url)
|
return GroceryLink(ingredient=ingredient, retailer="Amazon Fresh", url=wrap_url(base, "amazon"))
|
||||||
|
|
||||||
|
|
||||||
|
def _instacart_link(ingredient: str) -> GroceryLink:
|
||||||
|
q = quote_plus(ingredient)
|
||||||
|
base = f"https://www.instacart.com/store/s?k={q}"
|
||||||
|
return GroceryLink(ingredient=ingredient, retailer="Instacart", url=wrap_url(base, "instacart"))
|
||||||
|
|
||||||
|
|
||||||
def _walmart_link(ingredient: str, affiliate_id: str) -> GroceryLink:
|
def _walmart_link(ingredient: str, affiliate_id: str) -> GroceryLink:
|
||||||
q = quote_plus(ingredient)
|
q = quote_plus(ingredient)
|
||||||
# Walmart Impact affiliate deeplink pattern
|
# Walmart uses Impact network — affiliate ID is in the redirect path, not a param
|
||||||
url = f"https://goto.walmart.com/c/{affiliate_id}/walmart?u=https://www.walmart.com/search?q={q}"
|
url = (
|
||||||
|
f"https://goto.walmart.com/c/{affiliate_id}/walmart"
|
||||||
|
f"?u=https://www.walmart.com/search?q={q}"
|
||||||
|
)
|
||||||
return GroceryLink(ingredient=ingredient, retailer="Walmart Grocery", url=url)
|
return GroceryLink(ingredient=ingredient, retailer="Walmart Grocery", url=url)
|
||||||
|
|
||||||
|
|
||||||
def _instacart_link(ingredient: str, affiliate_id: str) -> GroceryLink:
|
|
||||||
q = quote_plus(ingredient)
|
|
||||||
url = f"https://www.instacart.com/store/s?k={q}&aff={affiliate_id}"
|
|
||||||
return GroceryLink(ingredient=ingredient, retailer="Instacart", url=url)
|
|
||||||
|
|
||||||
|
|
||||||
class GroceryLinkBuilder:
|
class GroceryLinkBuilder:
|
||||||
def __init__(self, tier: str = "free", has_byok: bool = False) -> None:
|
def __init__(self, tier: str = "free", has_byok: bool = False) -> None:
|
||||||
self._tier = tier
|
self._tier = tier
|
||||||
self._has_byok = has_byok
|
self._walmart_id = os.environ.get("WALMART_AFFILIATE_ID", "").strip()
|
||||||
self._amazon_tag = os.environ.get("AMAZON_AFFILIATE_TAG", "")
|
|
||||||
self._instacart_id = os.environ.get("INSTACART_AFFILIATE_ID", "")
|
|
||||||
self._walmart_id = os.environ.get("WALMART_AFFILIATE_ID", "")
|
|
||||||
|
|
||||||
def build_links(self, ingredient: str) -> list[GroceryLink]:
|
def build_links(self, ingredient: str) -> list[GroceryLink]:
|
||||||
"""Build affiliate deeplinks for a single ingredient.
|
"""Build grocery deeplinks for a single ingredient.
|
||||||
|
|
||||||
Free tier: URL construction only.
|
Amazon Fresh and Instacart links are always included; wrap_url handles
|
||||||
Paid+: would call live product search APIs (stubbed).
|
affiliate ID injection (or returns a plain URL if none is configured).
|
||||||
|
Walmart requires WALMART_AFFILIATE_ID to be set (Impact network uses a
|
||||||
|
path-based redirect that doesn't degrade cleanly to a plain URL).
|
||||||
"""
|
"""
|
||||||
if not ingredient.strip():
|
if not ingredient.strip():
|
||||||
return []
|
return []
|
||||||
links: list[GroceryLink] = []
|
|
||||||
|
|
||||||
if self._amazon_tag:
|
links: list[GroceryLink] = [
|
||||||
links.append(_amazon_link(ingredient, self._amazon_tag))
|
_amazon_fresh_link(ingredient),
|
||||||
|
_instacart_link(ingredient),
|
||||||
|
]
|
||||||
if self._walmart_id:
|
if self._walmart_id:
|
||||||
links.append(_walmart_link(ingredient, self._walmart_id))
|
links.append(_walmart_link(ingredient, self._walmart_id))
|
||||||
if self._instacart_id:
|
|
||||||
links.append(_instacart_link(ingredient, self._instacart_id))
|
|
||||||
|
|
||||||
# Paid+: live API stub (future task)
|
|
||||||
# if self._tier in ("paid", "premium") and not self._has_byok:
|
|
||||||
# links.extend(self._search_kroger_api(ingredient))
|
|
||||||
|
|
||||||
return links
|
return links
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -160,10 +160,18 @@ class LLMRecipeGenerator:
|
||||||
|
|
||||||
With CF_ORCH_URL set: acquires a vLLM allocation via CFOrchClient and
|
With CF_ORCH_URL set: acquires a vLLM allocation via CFOrchClient and
|
||||||
calls the OpenAI-compatible API directly against the allocated service URL.
|
calls the OpenAI-compatible API directly against the allocated service URL.
|
||||||
Without CF_ORCH_URL: falls back to LLMRouter using its configured backends.
|
Allocation failure falls through to LLMRouter rather than silently returning "".
|
||||||
|
Without CF_ORCH_URL: uses LLMRouter directly.
|
||||||
"""
|
"""
|
||||||
|
ctx = self._get_llm_context()
|
||||||
|
alloc = None
|
||||||
|
try:
|
||||||
|
alloc = ctx.__enter__()
|
||||||
|
except Exception as exc:
|
||||||
|
logger.debug("cf-orch allocation failed, falling back to LLMRouter: %s", exc)
|
||||||
|
ctx = None # __enter__ raised — do not call __exit__
|
||||||
|
|
||||||
try:
|
try:
|
||||||
with self._get_llm_context() as alloc:
|
|
||||||
if alloc is not None:
|
if alloc is not None:
|
||||||
base_url = alloc.url.rstrip("/") + "/v1"
|
base_url = alloc.url.rstrip("/") + "/v1"
|
||||||
client = OpenAI(base_url=base_url, api_key="any")
|
client = OpenAI(base_url=base_url, api_key="any")
|
||||||
|
|
@ -177,11 +185,16 @@ class LLMRecipeGenerator:
|
||||||
return resp.choices[0].message.content or ""
|
return resp.choices[0].message.content or ""
|
||||||
else:
|
else:
|
||||||
from circuitforge_core.llm.router import LLMRouter
|
from circuitforge_core.llm.router import LLMRouter
|
||||||
router = LLMRouter()
|
return LLMRouter().complete(prompt)
|
||||||
return router.complete(prompt)
|
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
logger.error("LLM call failed: %s", exc)
|
logger.error("LLM call failed: %s", exc)
|
||||||
return ""
|
return ""
|
||||||
|
finally:
|
||||||
|
if ctx is not None:
|
||||||
|
try:
|
||||||
|
ctx.__exit__(None, None, None)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
# Strips markdown bold/italic markers so "**Directions:**" parses like "Directions:"
|
# Strips markdown bold/italic markers so "**Directions:**" parses like "Directions:"
|
||||||
_MD_BOLD = re.compile(r"\*{1,2}([^*]+)\*{1,2}")
|
_MD_BOLD = re.compile(r"\*{1,2}([^*]+)\*{1,2}")
|
||||||
|
|
|
||||||
|
|
@ -367,6 +367,163 @@ def _pantry_creative_swap(required: str, pantry_items: set[str]) -> str | None:
|
||||||
return best
|
return best
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Functional-category swap table (Level 2 only)
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Maps cleaned ingredient names → functional category label. Used as a
|
||||||
|
# fallback when _pantry_creative_swap returns None (which always happens for
|
||||||
|
# single-token ingredients, because that function requires ≥2 shared tokens).
|
||||||
|
# A pantry item that belongs to the same category is offered as a substitute.
|
||||||
|
_FUNCTIONAL_SWAP_CATEGORIES: dict[str, str] = {
|
||||||
|
# Solid fats
|
||||||
|
"butter": "solid_fat",
|
||||||
|
"margarine": "solid_fat",
|
||||||
|
"shortening": "solid_fat",
|
||||||
|
"lard": "solid_fat",
|
||||||
|
"ghee": "solid_fat",
|
||||||
|
# Liquid/neutral cooking oils
|
||||||
|
"oil": "liquid_fat",
|
||||||
|
"vegetable oil": "liquid_fat",
|
||||||
|
"olive oil": "liquid_fat",
|
||||||
|
"canola oil": "liquid_fat",
|
||||||
|
"sunflower oil": "liquid_fat",
|
||||||
|
"avocado oil": "liquid_fat",
|
||||||
|
# Sweeteners
|
||||||
|
"sugar": "sweetener",
|
||||||
|
"brown sugar": "sweetener",
|
||||||
|
"honey": "sweetener",
|
||||||
|
"maple syrup": "sweetener",
|
||||||
|
"agave": "sweetener",
|
||||||
|
"molasses": "sweetener",
|
||||||
|
"stevia": "sweetener",
|
||||||
|
"powdered sugar": "sweetener",
|
||||||
|
# All-purpose flours and baking bases
|
||||||
|
"flour": "flour",
|
||||||
|
"all-purpose flour": "flour",
|
||||||
|
"whole wheat flour": "flour",
|
||||||
|
"bread flour": "flour",
|
||||||
|
"self-rising flour": "flour",
|
||||||
|
"cake flour": "flour",
|
||||||
|
# Dairy and non-dairy milk
|
||||||
|
"milk": "dairy_milk",
|
||||||
|
"whole milk": "dairy_milk",
|
||||||
|
"skim milk": "dairy_milk",
|
||||||
|
"2% milk": "dairy_milk",
|
||||||
|
"oat milk": "dairy_milk",
|
||||||
|
"almond milk": "dairy_milk",
|
||||||
|
"soy milk": "dairy_milk",
|
||||||
|
"rice milk": "dairy_milk",
|
||||||
|
# Heavy/whipping creams
|
||||||
|
"cream": "heavy_cream",
|
||||||
|
"heavy cream": "heavy_cream",
|
||||||
|
"whipping cream": "heavy_cream",
|
||||||
|
"double cream": "heavy_cream",
|
||||||
|
"coconut cream": "heavy_cream",
|
||||||
|
# Cultured dairy (acid + thick)
|
||||||
|
"sour cream": "cultured_dairy",
|
||||||
|
"greek yogurt": "cultured_dairy",
|
||||||
|
"yogurt": "cultured_dairy",
|
||||||
|
"buttermilk": "cultured_dairy",
|
||||||
|
# Starch thickeners
|
||||||
|
"cornstarch": "thickener",
|
||||||
|
"arrowroot": "thickener",
|
||||||
|
"tapioca starch": "thickener",
|
||||||
|
"potato starch": "thickener",
|
||||||
|
"rice flour": "thickener",
|
||||||
|
# Egg binders
|
||||||
|
"egg": "egg_binder",
|
||||||
|
"eggs": "egg_binder",
|
||||||
|
# Acids
|
||||||
|
"vinegar": "acid",
|
||||||
|
"apple cider vinegar": "acid",
|
||||||
|
"white vinegar": "acid",
|
||||||
|
"red wine vinegar": "acid",
|
||||||
|
"lemon juice": "acid",
|
||||||
|
"lime juice": "acid",
|
||||||
|
# Stocks and broths
|
||||||
|
"broth": "stock",
|
||||||
|
"stock": "stock",
|
||||||
|
"chicken broth": "stock",
|
||||||
|
"beef broth": "stock",
|
||||||
|
"vegetable broth": "stock",
|
||||||
|
"chicken stock": "stock",
|
||||||
|
"beef stock": "stock",
|
||||||
|
"bouillon": "stock",
|
||||||
|
# Hard cheeses (grating / melting interchangeable)
|
||||||
|
"parmesan": "hard_cheese",
|
||||||
|
"romano": "hard_cheese",
|
||||||
|
"pecorino": "hard_cheese",
|
||||||
|
"asiago": "hard_cheese",
|
||||||
|
# Melting cheeses
|
||||||
|
"cheddar": "melting_cheese",
|
||||||
|
"mozzarella": "melting_cheese",
|
||||||
|
"swiss": "melting_cheese",
|
||||||
|
"gouda": "melting_cheese",
|
||||||
|
"monterey jack": "melting_cheese",
|
||||||
|
"colby": "melting_cheese",
|
||||||
|
"provolone": "melting_cheese",
|
||||||
|
# Canned tomato products
|
||||||
|
"tomato sauce": "canned_tomato",
|
||||||
|
"tomato paste": "canned_tomato",
|
||||||
|
"crushed tomatoes": "canned_tomato",
|
||||||
|
"diced tomatoes": "canned_tomato",
|
||||||
|
"marinara": "canned_tomato",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _category_swap(ingredient: str, pantry_items: set[str]) -> str | None:
|
||||||
|
"""Level-2 fallback: find a same-category pantry substitute for a single-token ingredient.
|
||||||
|
|
||||||
|
_pantry_creative_swap requires ≥2 shared content tokens, so it always returns
|
||||||
|
None for single-word ingredients like 'butter' or 'flour'. This function looks
|
||||||
|
up the ingredient's functional category and returns any pantry item in that
|
||||||
|
same category, enabling swaps like butter → ghee, milk → oat milk.
|
||||||
|
"""
|
||||||
|
clean = _strip_quantity(ingredient).lower()
|
||||||
|
category = _FUNCTIONAL_SWAP_CATEGORIES.get(clean)
|
||||||
|
if not category:
|
||||||
|
return None
|
||||||
|
for item in pantry_items:
|
||||||
|
if item.lower() == clean:
|
||||||
|
continue
|
||||||
|
item_lower = item.lower()
|
||||||
|
# Direct match: pantry item name is a known member of the same category
|
||||||
|
if _FUNCTIONAL_SWAP_CATEGORIES.get(item_lower) == category:
|
||||||
|
return item
|
||||||
|
# Substring match: handles "organic oat milk" containing "oat milk"
|
||||||
|
for known_ing, cat in _FUNCTIONAL_SWAP_CATEGORIES.items():
|
||||||
|
if cat == category and known_ing in item_lower and item_lower != clean:
|
||||||
|
return item
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
# Assembly template caps by tier — prevents flooding results with templates
|
||||||
|
# when a well-stocked pantry satisfies every required role.
|
||||||
|
_SOURCE_URL_BUILDERS: dict[str, str] = {
|
||||||
|
"foodcom": "https://www.food.com/recipe/{id}",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _build_source_url(row: dict) -> str | None:
|
||||||
|
"""Construct a canonical source URL from DB row fields, or None for generated recipes."""
|
||||||
|
source = row.get("source") or ""
|
||||||
|
external_id = row.get("external_id")
|
||||||
|
template = _SOURCE_URL_BUILDERS.get(source)
|
||||||
|
if not template or not external_id:
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
return template.format(id=int(float(external_id)))
|
||||||
|
except (ValueError, TypeError):
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
_ASSEMBLY_TIER_LIMITS: dict[str, int] = {
|
||||||
|
"free": 2,
|
||||||
|
"paid": 4,
|
||||||
|
"premium": 6,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
# Method complexity classification patterns
|
# Method complexity classification patterns
|
||||||
_EASY_METHODS = re.compile(
|
_EASY_METHODS = re.compile(
|
||||||
r"\b(microwave|mix|stir|blend|toast|assemble|heat)\b", re.IGNORECASE
|
r"\b(microwave|mix|stir|blend|toast|assemble|heat)\b", re.IGNORECASE
|
||||||
|
|
@ -468,15 +625,21 @@ class RecipeEngine:
|
||||||
# When covered, collect any prep-state annotations (e.g. "melted butter"
|
# When covered, collect any prep-state annotations (e.g. "melted butter"
|
||||||
# → note "Melt the butter before starting.") to surface separately.
|
# → note "Melt the butter before starting.") to surface separately.
|
||||||
swap_candidates: list[SwapCandidate] = []
|
swap_candidates: list[SwapCandidate] = []
|
||||||
|
matched: list[str] = []
|
||||||
missing: list[str] = []
|
missing: list[str] = []
|
||||||
prep_note_set: set[str] = set()
|
prep_note_set: set[str] = set()
|
||||||
for n in ingredient_names:
|
for n in ingredient_names:
|
||||||
if _ingredient_in_pantry(n, pantry_set):
|
if _ingredient_in_pantry(n, pantry_set):
|
||||||
|
matched.append(_strip_quantity(n))
|
||||||
note = _prep_note_for(n)
|
note = _prep_note_for(n)
|
||||||
if note:
|
if note:
|
||||||
prep_note_set.add(note)
|
prep_note_set.add(note)
|
||||||
continue
|
continue
|
||||||
swap_item = _pantry_creative_swap(n, pantry_set)
|
swap_item = _pantry_creative_swap(n, pantry_set)
|
||||||
|
# L2: also try functional-category swap for single-token ingredients
|
||||||
|
# that _pantry_creative_swap can't match (requires ≥2 shared tokens).
|
||||||
|
if swap_item is None and req.level == 2:
|
||||||
|
swap_item = _category_swap(n, pantry_set)
|
||||||
if swap_item:
|
if swap_item:
|
||||||
swap_candidates.append(SwapCandidate(
|
swap_candidates.append(SwapCandidate(
|
||||||
original_name=n,
|
original_name=n,
|
||||||
|
|
@ -488,8 +651,8 @@ class RecipeEngine:
|
||||||
else:
|
else:
|
||||||
missing.append(n)
|
missing.append(n)
|
||||||
|
|
||||||
# Filter by max_missing (pantry swaps don't count as missing)
|
# Filter by max_missing — skipped in shopping mode (user is willing to buy)
|
||||||
if req.max_missing is not None and len(missing) > req.max_missing:
|
if not req.shopping_mode and req.max_missing is not None and len(missing) > req.max_missing:
|
||||||
continue
|
continue
|
||||||
|
|
||||||
# Filter by hard_day_mode
|
# Filter by hard_day_mode
|
||||||
|
|
@ -547,20 +710,38 @@ class RecipeEngine:
|
||||||
match_count=int(row.get("match_count") or 0),
|
match_count=int(row.get("match_count") or 0),
|
||||||
element_coverage=coverage_raw,
|
element_coverage=coverage_raw,
|
||||||
swap_candidates=swap_candidates,
|
swap_candidates=swap_candidates,
|
||||||
|
matched_ingredients=matched,
|
||||||
missing_ingredients=missing,
|
missing_ingredients=missing,
|
||||||
prep_notes=sorted(prep_note_set),
|
prep_notes=sorted(prep_note_set),
|
||||||
level=req.level,
|
level=req.level,
|
||||||
nutrition=nutrition if has_nutrition else None,
|
nutrition=nutrition if has_nutrition else None,
|
||||||
|
source_url=_build_source_url(row),
|
||||||
))
|
))
|
||||||
|
|
||||||
# Prepend assembly-dish templates (burrito, stir fry, omelette, etc.)
|
# Assembly-dish templates (burrito, fried rice, pasta, etc.)
|
||||||
# These fire regardless of corpus coverage — any pantry can make a burrito.
|
# Expiry boost: when expiry_first, the pantry_items list is already sorted
|
||||||
|
# by expiry urgency — treat the first slice as the "expiring" set so templates
|
||||||
|
# that use those items bubble up in the merged ranking.
|
||||||
|
expiring_set: set[str] = set()
|
||||||
|
if req.expiry_first:
|
||||||
|
expiring_set = _expand_pantry_set(req.pantry_items[:10])
|
||||||
|
|
||||||
assembly = match_assembly_templates(
|
assembly = match_assembly_templates(
|
||||||
pantry_items=req.pantry_items,
|
pantry_items=req.pantry_items,
|
||||||
pantry_set=pantry_set,
|
pantry_set=pantry_set,
|
||||||
excluded_ids=req.excluded_ids or [],
|
excluded_ids=req.excluded_ids or [],
|
||||||
|
expiring_set=expiring_set,
|
||||||
)
|
)
|
||||||
suggestions = assembly + suggestions
|
|
||||||
|
# Cap by tier — lifted in shopping mode since missing-ingredient templates
|
||||||
|
# are desirable there (each fires an affiliate link opportunity).
|
||||||
|
if not req.shopping_mode:
|
||||||
|
assembly_limit = _ASSEMBLY_TIER_LIMITS.get(req.tier, 3)
|
||||||
|
assembly = assembly[:assembly_limit]
|
||||||
|
|
||||||
|
# Interleave: sort templates and corpus recipes together by match_count so
|
||||||
|
# assembly dishes earn their position rather than always winning position 0-N.
|
||||||
|
suggestions = sorted(assembly + suggestions, key=lambda s: s.match_count, reverse=True)
|
||||||
|
|
||||||
# Build grocery list — deduplicated union of all missing ingredients
|
# Build grocery list — deduplicated union of all missing ingredients
|
||||||
seen: set[str] = set()
|
seen: set[str] = set()
|
||||||
|
|
|
||||||
|
|
@ -15,6 +15,7 @@ KIWI_BYOK_UNLOCKABLE: frozenset[str] = frozenset({
|
||||||
"recipe_suggestions",
|
"recipe_suggestions",
|
||||||
"expiry_llm_matching",
|
"expiry_llm_matching",
|
||||||
"receipt_ocr",
|
"receipt_ocr",
|
||||||
|
"style_classifier",
|
||||||
})
|
})
|
||||||
|
|
||||||
# Feature → minimum tier required
|
# Feature → minimum tier required
|
||||||
|
|
@ -35,6 +36,8 @@ KIWI_FEATURES: dict[str, str] = {
|
||||||
"meal_planning": "paid",
|
"meal_planning": "paid",
|
||||||
"dietary_profiles": "paid",
|
"dietary_profiles": "paid",
|
||||||
"style_picker": "paid",
|
"style_picker": "paid",
|
||||||
|
"recipe_collections": "paid",
|
||||||
|
"style_classifier": "paid", # LLM auto-tag for saved recipe style tags; BYOK-unlockable
|
||||||
|
|
||||||
# Premium tier
|
# Premium tier
|
||||||
"multi_household": "premium",
|
"multi_household": "premium",
|
||||||
|
|
|
||||||
|
|
@ -18,6 +18,10 @@ services:
|
||||||
# DEV ONLY: comma-separated IPs that bypass JWT auth (LAN testing without Caddy).
|
# DEV ONLY: comma-separated IPs that bypass JWT auth (LAN testing without Caddy).
|
||||||
# Production deployments must NOT set this. Leave blank or omit entirely.
|
# Production deployments must NOT set this. Leave blank or omit entirely.
|
||||||
CLOUD_AUTH_BYPASS_IPS: ${CLOUD_AUTH_BYPASS_IPS:-}
|
CLOUD_AUTH_BYPASS_IPS: ${CLOUD_AUTH_BYPASS_IPS:-}
|
||||||
|
# cf-orch: route LLM calls through the coordinator for managed GPU inference
|
||||||
|
CF_ORCH_URL: http://host.docker.internal:7700
|
||||||
|
extra_hosts:
|
||||||
|
- "host.docker.internal:host-gateway"
|
||||||
volumes:
|
volumes:
|
||||||
- /devl/kiwi-cloud-data:/devl/kiwi-cloud-data
|
- /devl/kiwi-cloud-data:/devl/kiwi-cloud-data
|
||||||
# LLM config — shared with other CF products; read-only in container
|
# LLM config — shared with other CF products; read-only in container
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,12 @@
|
||||||
# Not used in cloud or demo stacks (those use compose.cloud.yml / compose.demo.yml directly).
|
# Not used in cloud or demo stacks (those use compose.cloud.yml / compose.demo.yml directly).
|
||||||
|
|
||||||
services:
|
services:
|
||||||
|
api:
|
||||||
|
volumes:
|
||||||
|
# Symlink /data/kiwi.db → /Library/Assets/kiwi/kiwi.db; mount the NAS path so
|
||||||
|
# Docker can follow the symlink inside the container.
|
||||||
|
- /Library/Assets/kiwi:/Library/Assets/kiwi:rw
|
||||||
|
|
||||||
# cf-orch agent sidecar: registers kiwi as a GPU node with the coordinator.
|
# cf-orch agent sidecar: registers kiwi as a GPU node with the coordinator.
|
||||||
# The API scheduler uses COORDINATOR_URL to lease VRAM cooperatively; this
|
# The API scheduler uses COORDINATOR_URL to lease VRAM cooperatively; this
|
||||||
# agent makes kiwi's VRAM usage visible on the orchestrator dashboard.
|
# agent makes kiwi's VRAM usage visible on the orchestrator dashboard.
|
||||||
|
|
|
||||||
310
frontend/src/components/RecipeBrowserPanel.vue
Normal file
310
frontend/src/components/RecipeBrowserPanel.vue
Normal file
|
|
@ -0,0 +1,310 @@
|
||||||
|
<template>
|
||||||
|
<div class="browser-panel">
|
||||||
|
<!-- Domain picker -->
|
||||||
|
<div class="domain-picker flex flex-wrap gap-sm mb-md">
|
||||||
|
<button
|
||||||
|
v-for="domain in domains"
|
||||||
|
:key="domain.id"
|
||||||
|
:class="['btn', activeDomain === domain.id ? 'btn-primary' : 'btn-secondary']"
|
||||||
|
@click="selectDomain(domain.id)"
|
||||||
|
>
|
||||||
|
{{ domain.label }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="loadingDomains" class="text-secondary text-sm">Loading…</div>
|
||||||
|
|
||||||
|
<div v-else-if="activeDomain" class="browser-body">
|
||||||
|
<!-- Category list -->
|
||||||
|
<div class="category-list mb-md flex flex-wrap gap-xs">
|
||||||
|
<button
|
||||||
|
v-for="cat in categories"
|
||||||
|
:key="cat.category"
|
||||||
|
:class="['btn', 'btn-secondary', 'cat-btn', { active: activeCategory === cat.category }]"
|
||||||
|
@click="selectCategory(cat.category)"
|
||||||
|
>
|
||||||
|
{{ cat.category }}
|
||||||
|
<span class="cat-count">{{ cat.recipe_count }}</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Recipe grid -->
|
||||||
|
<template v-if="activeCategory">
|
||||||
|
<div v-if="loadingRecipes" class="text-secondary text-sm">Loading recipes…</div>
|
||||||
|
|
||||||
|
<template v-else>
|
||||||
|
<div class="results-header flex-between mb-sm">
|
||||||
|
<span class="text-sm text-secondary">
|
||||||
|
{{ total }} recipes
|
||||||
|
<span v-if="pantryCount > 0"> — pantry match shown</span>
|
||||||
|
</span>
|
||||||
|
<div class="pagination flex gap-xs">
|
||||||
|
<button
|
||||||
|
class="btn btn-secondary btn-xs"
|
||||||
|
:disabled="page <= 1"
|
||||||
|
@click="changePage(page - 1)"
|
||||||
|
>‹ Prev</button>
|
||||||
|
<span class="text-sm text-secondary page-indicator">{{ page }} / {{ totalPages }}</span>
|
||||||
|
<button
|
||||||
|
class="btn btn-secondary btn-xs"
|
||||||
|
:disabled="page >= totalPages"
|
||||||
|
@click="changePage(page + 1)"
|
||||||
|
>Next ›</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="recipes.length === 0" class="text-secondary text-sm">No recipes found in this category.</div>
|
||||||
|
|
||||||
|
<div class="recipe-grid">
|
||||||
|
<div
|
||||||
|
v-for="recipe in recipes"
|
||||||
|
:key="recipe.id"
|
||||||
|
class="card-sm recipe-row flex-between gap-sm"
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
class="recipe-title-btn text-left"
|
||||||
|
@click="$emit('open-recipe', recipe.id)"
|
||||||
|
>
|
||||||
|
{{ recipe.title }}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<div class="recipe-row-actions flex gap-xs flex-shrink-0">
|
||||||
|
<!-- Pantry match badge -->
|
||||||
|
<span
|
||||||
|
v-if="recipe.match_pct !== null"
|
||||||
|
class="match-badge status-badge"
|
||||||
|
:class="matchBadgeClass(recipe.match_pct)"
|
||||||
|
>
|
||||||
|
{{ Math.round(recipe.match_pct * 100) }}%
|
||||||
|
</span>
|
||||||
|
|
||||||
|
<!-- Save toggle -->
|
||||||
|
<button
|
||||||
|
class="btn btn-secondary btn-xs"
|
||||||
|
:class="{ 'btn-saved': savedStore.isSaved(recipe.id) }"
|
||||||
|
@click="toggleSave(recipe)"
|
||||||
|
:aria-label="savedStore.isSaved(recipe.id) ? 'Edit saved recipe: ' + recipe.title : 'Save recipe: ' + recipe.title"
|
||||||
|
>
|
||||||
|
{{ savedStore.isSaved(recipe.id) ? '★' : '☆' }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<div v-else class="text-secondary text-sm">Select a category above to browse recipes.</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-else-if="!loadingDomains" class="text-secondary text-sm">Select a domain to start browsing.</div>
|
||||||
|
|
||||||
|
<!-- Save modal -->
|
||||||
|
<SaveRecipeModal
|
||||||
|
v-if="savingRecipe"
|
||||||
|
:recipe-id="savingRecipe.id"
|
||||||
|
:recipe-title="savingRecipe.title"
|
||||||
|
@close="savingRecipe = null"
|
||||||
|
@saved="savingRecipe = null"
|
||||||
|
@unsave="savingRecipe && doUnsave(savingRecipe.id)"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { ref, computed, onMounted } from 'vue'
|
||||||
|
import { browserAPI, type BrowserDomain, type BrowserCategory, type BrowserRecipe } from '../services/api'
|
||||||
|
import { useSavedRecipesStore } from '../stores/savedRecipes'
|
||||||
|
import { useInventoryStore } from '../stores/inventory'
|
||||||
|
import SaveRecipeModal from './SaveRecipeModal.vue'
|
||||||
|
|
||||||
|
defineEmits<{
|
||||||
|
(e: 'open-recipe', recipeId: number): void
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const savedStore = useSavedRecipesStore()
|
||||||
|
const inventoryStore = useInventoryStore()
|
||||||
|
|
||||||
|
const domains = ref<BrowserDomain[]>([])
|
||||||
|
const activeDomain = ref<string | null>(null)
|
||||||
|
const categories = ref<BrowserCategory[]>([])
|
||||||
|
const activeCategory = ref<string | null>(null)
|
||||||
|
const recipes = ref<BrowserRecipe[]>([])
|
||||||
|
const total = ref(0)
|
||||||
|
const page = ref(1)
|
||||||
|
const pageSize = 20
|
||||||
|
const loadingDomains = ref(false)
|
||||||
|
const loadingRecipes = ref(false)
|
||||||
|
const savingRecipe = ref<BrowserRecipe | null>(null)
|
||||||
|
|
||||||
|
const totalPages = computed(() => Math.max(1, Math.ceil(total.value / pageSize)))
|
||||||
|
|
||||||
|
const pantryItems = computed(() =>
|
||||||
|
inventoryStore.items
|
||||||
|
.filter((i) => i.status === 'available' && i.product_name)
|
||||||
|
.map((i) => i.product_name as string)
|
||||||
|
)
|
||||||
|
const pantryCount = computed(() => pantryItems.value.length)
|
||||||
|
|
||||||
|
function matchBadgeClass(pct: number): string {
|
||||||
|
if (pct >= 0.8) return 'status-success'
|
||||||
|
if (pct >= 0.5) return 'status-warning'
|
||||||
|
return 'status-secondary'
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(async () => {
|
||||||
|
loadingDomains.value = true
|
||||||
|
try {
|
||||||
|
domains.value = await browserAPI.listDomains()
|
||||||
|
if (domains.value.length > 0) selectDomain(domains.value[0]!.id)
|
||||||
|
} finally {
|
||||||
|
loadingDomains.value = false
|
||||||
|
}
|
||||||
|
// Ensure pantry is loaded for match badges
|
||||||
|
if (inventoryStore.items.length === 0) inventoryStore.fetchItems()
|
||||||
|
if (!savedStore.savedIds.size) savedStore.load()
|
||||||
|
})
|
||||||
|
|
||||||
|
async function selectDomain(domainId: string) {
|
||||||
|
activeDomain.value = domainId
|
||||||
|
activeCategory.value = null
|
||||||
|
recipes.value = []
|
||||||
|
total.value = 0
|
||||||
|
page.value = 1
|
||||||
|
categories.value = await browserAPI.listCategories(domainId)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function selectCategory(category: string) {
|
||||||
|
activeCategory.value = category
|
||||||
|
page.value = 1
|
||||||
|
await loadRecipes()
|
||||||
|
}
|
||||||
|
|
||||||
|
async function changePage(newPage: number) {
|
||||||
|
page.value = newPage
|
||||||
|
await loadRecipes()
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadRecipes() {
|
||||||
|
if (!activeDomain.value || !activeCategory.value) return
|
||||||
|
loadingRecipes.value = true
|
||||||
|
try {
|
||||||
|
const result = await browserAPI.browse(
|
||||||
|
activeDomain.value,
|
||||||
|
activeCategory.value,
|
||||||
|
{
|
||||||
|
page: page.value,
|
||||||
|
page_size: pageSize,
|
||||||
|
pantry_items: pantryItems.value.length > 0
|
||||||
|
? pantryItems.value.join(',')
|
||||||
|
: undefined,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
recipes.value = result.recipes
|
||||||
|
total.value = result.total
|
||||||
|
} finally {
|
||||||
|
loadingRecipes.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function toggleSave(recipe: BrowserRecipe) {
|
||||||
|
if (savedStore.isSaved(recipe.id)) {
|
||||||
|
savingRecipe.value = recipe // open edit modal
|
||||||
|
} else {
|
||||||
|
savingRecipe.value = recipe // open save modal
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function doUnsave(recipeId: number) {
|
||||||
|
savingRecipe.value = null
|
||||||
|
await savedStore.unsave(recipeId)
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.browser-panel {
|
||||||
|
padding: var(--spacing-sm) 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cat-btn {
|
||||||
|
font-size: var(--font-size-sm);
|
||||||
|
padding: var(--spacing-xs) var(--spacing-sm);
|
||||||
|
}
|
||||||
|
|
||||||
|
.cat-btn.active {
|
||||||
|
background: var(--color-primary);
|
||||||
|
color: white;
|
||||||
|
border-color: var(--color-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.cat-count {
|
||||||
|
background: var(--color-bg-secondary);
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
padding: 0 5px;
|
||||||
|
font-size: var(--font-size-xs, 0.72rem);
|
||||||
|
color: var(--color-text-secondary);
|
||||||
|
margin-left: var(--spacing-xs);
|
||||||
|
}
|
||||||
|
|
||||||
|
.cat-btn.active .cat-count {
|
||||||
|
background: rgba(255, 255, 255, 0.2);
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
.recipe-grid {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: var(--spacing-xs);
|
||||||
|
}
|
||||||
|
|
||||||
|
.recipe-row {
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.recipe-title-btn {
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: var(--font-size-sm);
|
||||||
|
font-weight: 500;
|
||||||
|
color: var(--color-primary);
|
||||||
|
padding: 0;
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.recipe-title-btn:hover {
|
||||||
|
text-decoration: underline;
|
||||||
|
}
|
||||||
|
|
||||||
|
.match-badge {
|
||||||
|
font-size: var(--font-size-xs, 0.72rem);
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-secondary {
|
||||||
|
background: var(--color-bg-secondary);
|
||||||
|
color: var(--color-text-secondary);
|
||||||
|
border: 1px solid var(--color-border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-saved {
|
||||||
|
color: var(--color-warning);
|
||||||
|
border-color: var(--color-warning);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-xs {
|
||||||
|
padding: 2px var(--spacing-xs);
|
||||||
|
font-size: var(--font-size-xs, 0.75rem);
|
||||||
|
}
|
||||||
|
|
||||||
|
.page-indicator {
|
||||||
|
align-self: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.flex-shrink-0 {
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
802
frontend/src/components/RecipeDetailPanel.vue
Normal file
802
frontend/src/components/RecipeDetailPanel.vue
Normal file
|
|
@ -0,0 +1,802 @@
|
||||||
|
<template>
|
||||||
|
<Teleport to="body">
|
||||||
|
<!-- Backdrop — click outside to close -->
|
||||||
|
<div class="detail-overlay" @click.self="$emit('close')">
|
||||||
|
<div ref="dialogRef" class="detail-panel" role="dialog" aria-modal="true" :aria-label="recipe.title" tabindex="-1">
|
||||||
|
|
||||||
|
<!-- Sticky header -->
|
||||||
|
<div class="detail-header">
|
||||||
|
<div class="header-badges">
|
||||||
|
<span class="status-badge status-success">{{ recipe.match_count }} matched</span>
|
||||||
|
<span class="status-badge status-info">Level {{ recipe.level }}</span>
|
||||||
|
<span v-if="recipe.is_wildcard" class="status-badge status-warning">Wildcard</span>
|
||||||
|
</div>
|
||||||
|
<div class="header-row">
|
||||||
|
<h2 class="detail-title">{{ recipe.title }}</h2>
|
||||||
|
<div class="header-actions flex gap-sm">
|
||||||
|
<button
|
||||||
|
class="btn btn-secondary btn-save"
|
||||||
|
:class="{ 'btn-saved': isSaved }"
|
||||||
|
@click="showSaveModal = true"
|
||||||
|
:aria-label="isSaved ? 'Edit saved recipe' : 'Save recipe'"
|
||||||
|
>{{ isSaved ? '★ Saved' : '☆ Save' }}</button>
|
||||||
|
<button class="btn-close" @click="$emit('close')" aria-label="Close panel">✕</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<p v-if="recipe.notes" class="detail-notes">{{ recipe.notes }}</p>
|
||||||
|
<a
|
||||||
|
v-if="recipe.source_url"
|
||||||
|
:href="recipe.source_url"
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
class="source-link"
|
||||||
|
>View original ↗</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Scrollable body -->
|
||||||
|
<div class="detail-body">
|
||||||
|
|
||||||
|
<!-- Ingredients: have vs. need in a two-column layout -->
|
||||||
|
<div class="ingredients-grid">
|
||||||
|
<div v-if="recipe.matched_ingredients?.length > 0" class="ingredient-col ingredient-col-have">
|
||||||
|
<h3 class="col-label col-label-have">From your pantry</h3>
|
||||||
|
<ul class="ingredient-list">
|
||||||
|
<li v-for="ing in recipe.matched_ingredients" :key="ing" class="ing-row">
|
||||||
|
<span class="ing-icon ing-icon-have">✓</span>
|
||||||
|
<span>{{ ing }}</span>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="recipe.missing_ingredients?.length > 0" class="ingredient-col ingredient-col-need">
|
||||||
|
<div class="col-header-row">
|
||||||
|
<h3 class="col-label col-label-need">Still needed</h3>
|
||||||
|
<div class="col-header-actions">
|
||||||
|
<button class="share-btn" @click="shareList" :title="shareCopied ? 'Copied!' : 'Copy / share list'">
|
||||||
|
{{ shareCopied ? '✓ Copied' : 'Share' }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<ul class="ingredient-list">
|
||||||
|
<li v-for="ing in recipe.missing_ingredients" :key="ing" class="ing-row">
|
||||||
|
<label class="ing-check-label">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
class="ing-check"
|
||||||
|
:checked="checkedIngredients.has(ing)"
|
||||||
|
@change="toggleIngredient(ing)"
|
||||||
|
/>
|
||||||
|
<span class="ing-name">{{ ing }}</span>
|
||||||
|
</label>
|
||||||
|
<a
|
||||||
|
v-if="groceryLinkFor(ing)"
|
||||||
|
:href="groceryLinkFor(ing)!.url"
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
class="buy-link"
|
||||||
|
>Buy ↗</a>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
<button
|
||||||
|
v-if="recipe.missing_ingredients.length > 1"
|
||||||
|
class="select-all-btn"
|
||||||
|
@click="toggleSelectAll"
|
||||||
|
>{{ checkedIngredients.size === recipe.missing_ingredients.length ? 'Deselect all' : 'Select all' }}</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Swap candidates -->
|
||||||
|
<details v-if="recipe.swap_candidates.length > 0" class="detail-collapsible">
|
||||||
|
<summary class="detail-collapsible-summary">
|
||||||
|
Possible swaps ({{ recipe.swap_candidates.length }})
|
||||||
|
</summary>
|
||||||
|
<div class="card-secondary mt-xs">
|
||||||
|
<div
|
||||||
|
v-for="swap in recipe.swap_candidates"
|
||||||
|
:key="swap.original_name + swap.substitute_name"
|
||||||
|
class="swap-row text-sm"
|
||||||
|
>
|
||||||
|
<span class="font-semibold">{{ swap.original_name }}</span>
|
||||||
|
<span class="text-muted"> → </span>
|
||||||
|
<span class="font-semibold">{{ swap.substitute_name }}</span>
|
||||||
|
<span v-if="swap.constraint_label" class="status-badge status-info ml-xs">{{ swap.constraint_label }}</span>
|
||||||
|
<p v-if="swap.explanation" class="text-muted mt-xs">{{ swap.explanation }}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</details>
|
||||||
|
|
||||||
|
<!-- Nutrition panel -->
|
||||||
|
<div v-if="recipe.nutrition" class="detail-section">
|
||||||
|
<h3 class="section-label">Nutrition</h3>
|
||||||
|
<div class="nutrition-chips">
|
||||||
|
<span v-if="recipe.nutrition.calories != null" class="nutrition-chip">🔥 {{ Math.round(recipe.nutrition.calories) }} kcal</span>
|
||||||
|
<span v-if="recipe.nutrition.fat_g != null" class="nutrition-chip">🧈 {{ recipe.nutrition.fat_g.toFixed(1) }}g fat</span>
|
||||||
|
<span v-if="recipe.nutrition.protein_g != null" class="nutrition-chip">💪 {{ recipe.nutrition.protein_g.toFixed(1) }}g protein</span>
|
||||||
|
<span v-if="recipe.nutrition.carbs_g != null" class="nutrition-chip">🌾 {{ recipe.nutrition.carbs_g.toFixed(1) }}g carbs</span>
|
||||||
|
<span v-if="recipe.nutrition.fiber_g != null" class="nutrition-chip">🌿 {{ recipe.nutrition.fiber_g.toFixed(1) }}g fiber</span>
|
||||||
|
<span v-if="recipe.nutrition.sugar_g != null" class="nutrition-chip nutrition-chip-sugar">🍬 {{ recipe.nutrition.sugar_g.toFixed(1) }}g sugar</span>
|
||||||
|
<span v-if="recipe.nutrition.sodium_mg != null" class="nutrition-chip">🧂 {{ Math.round(recipe.nutrition.sodium_mg) }}mg sodium</span>
|
||||||
|
<span v-if="recipe.nutrition.servings != null" class="nutrition-chip nutrition-chip-servings">
|
||||||
|
🍽️ {{ recipe.nutrition.servings }} serving{{ recipe.nutrition.servings !== 1 ? 's' : '' }}
|
||||||
|
</span>
|
||||||
|
<span v-if="recipe.nutrition.estimated" class="nutrition-chip nutrition-chip-estimated" title="Estimated from ingredient profiles">~ estimated</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Prep notes -->
|
||||||
|
<div v-if="recipe.prep_notes.length > 0" class="detail-section">
|
||||||
|
<h3 class="section-label">Before you start</h3>
|
||||||
|
<ul class="prep-list">
|
||||||
|
<li v-for="note in recipe.prep_notes" :key="note" class="text-sm prep-item">{{ note }}</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Directions -->
|
||||||
|
<div v-if="recipe.directions.length > 0" class="detail-section">
|
||||||
|
<h3 class="section-label">Steps</h3>
|
||||||
|
<ol class="directions-list">
|
||||||
|
<li v-for="(step, i) in recipe.directions" :key="i" class="text-sm direction-step">{{ step }}</li>
|
||||||
|
</ol>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Bottom padding so last step isn't hidden behind sticky footer -->
|
||||||
|
<div style="height: var(--spacing-xl)" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Sticky footer -->
|
||||||
|
<div class="detail-footer">
|
||||||
|
<div v-if="cookDone" class="cook-success">
|
||||||
|
<span class="cook-success-icon">✓</span>
|
||||||
|
Enjoy your meal! Recipe dismissed from suggestions.
|
||||||
|
<button class="btn btn-secondary btn-sm mt-xs" @click="$emit('close')">Close</button>
|
||||||
|
</div>
|
||||||
|
<template v-else>
|
||||||
|
<button class="btn btn-secondary" @click="$emit('close')">Back</button>
|
||||||
|
<button
|
||||||
|
:class="['btn-bookmark-panel', { active: recipesStore.isBookmarked(recipe.id) }]"
|
||||||
|
@click="recipesStore.toggleBookmark(recipe)"
|
||||||
|
:aria-label="recipesStore.isBookmarked(recipe.id) ? `Remove bookmark: ${recipe.title}` : `Bookmark: ${recipe.title}`"
|
||||||
|
>{{ recipesStore.isBookmarked(recipe.id) ? '★' : '☆' }}</button>
|
||||||
|
<template v-if="checkedCount > 0">
|
||||||
|
<div class="add-pantry-col">
|
||||||
|
<p v-if="addError" role="alert" aria-live="assertive" class="add-error text-xs">{{ addError }}</p>
|
||||||
|
<p v-if="addedToPantry" role="status" aria-live="polite" class="add-success text-xs">✓ Added to pantry!</p>
|
||||||
|
<button
|
||||||
|
class="btn btn-accent flex-1"
|
||||||
|
:disabled="addingToPantry"
|
||||||
|
@click="addToPantry"
|
||||||
|
>
|
||||||
|
<span v-if="addingToPantry">Adding…</span>
|
||||||
|
<span v-else>Add {{ checkedCount }} to pantry</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
<button v-else class="btn btn-primary flex-1" @click="handleCook">
|
||||||
|
✓ I cooked this
|
||||||
|
</button>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Teleport>
|
||||||
|
|
||||||
|
<SaveRecipeModal
|
||||||
|
v-if="showSaveModal"
|
||||||
|
:recipe-id="recipe.id"
|
||||||
|
:recipe-title="recipe.title"
|
||||||
|
@close="showSaveModal = false"
|
||||||
|
@saved="showSaveModal = false"
|
||||||
|
@unsave="savedStore.unsave(recipe.id); showSaveModal = false"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { ref, computed, onMounted, onUnmounted, nextTick } from 'vue'
|
||||||
|
import { useRecipesStore } from '../stores/recipes'
|
||||||
|
import { useSavedRecipesStore } from '../stores/savedRecipes'
|
||||||
|
import { inventoryAPI } from '../services/api'
|
||||||
|
import type { RecipeSuggestion, GroceryLink } from '../services/api'
|
||||||
|
import SaveRecipeModal from './SaveRecipeModal.vue'
|
||||||
|
|
||||||
|
const dialogRef = ref<HTMLElement | null>(null)
|
||||||
|
let previousFocus: HTMLElement | null = null
|
||||||
|
|
||||||
|
function handleKeydown(e: KeyboardEvent) {
|
||||||
|
if (e.key === 'Escape') emit('close')
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
previousFocus = document.activeElement as HTMLElement
|
||||||
|
document.addEventListener('keydown', handleKeydown)
|
||||||
|
nextTick(() => {
|
||||||
|
const focusable = dialogRef.value?.querySelector<HTMLElement>(
|
||||||
|
'button:not([disabled]), [href], input'
|
||||||
|
)
|
||||||
|
;(focusable ?? dialogRef.value)?.focus()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
onUnmounted(() => {
|
||||||
|
document.removeEventListener('keydown', handleKeydown)
|
||||||
|
previousFocus?.focus()
|
||||||
|
})
|
||||||
|
|
||||||
|
const recipesStore = useRecipesStore()
|
||||||
|
const savedStore = useSavedRecipesStore()
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
recipe: RecipeSuggestion
|
||||||
|
groceryLinks: GroceryLink[]
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
close: []
|
||||||
|
cooked: [recipe: RecipeSuggestion]
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const showSaveModal = ref(false)
|
||||||
|
const isSaved = computed(() => savedStore.isSaved(props.recipe.id))
|
||||||
|
|
||||||
|
const cookDone = ref(false)
|
||||||
|
const shareCopied = ref(false)
|
||||||
|
|
||||||
|
// Shopping: add purchased ingredients to pantry
|
||||||
|
const checkedIngredients = ref<Set<string>>(new Set())
|
||||||
|
const addingToPantry = ref(false)
|
||||||
|
const addedToPantry = ref(false)
|
||||||
|
const addError = ref<string | null>(null)
|
||||||
|
|
||||||
|
const checkedCount = computed(() => checkedIngredients.value.size)
|
||||||
|
|
||||||
|
function toggleIngredient(name: string) {
|
||||||
|
const next = new Set(checkedIngredients.value)
|
||||||
|
if (next.has(name)) {
|
||||||
|
next.delete(name)
|
||||||
|
} else {
|
||||||
|
next.add(name)
|
||||||
|
}
|
||||||
|
checkedIngredients.value = next
|
||||||
|
}
|
||||||
|
|
||||||
|
function toggleSelectAll() {
|
||||||
|
if (checkedIngredients.value.size === props.recipe.missing_ingredients.length) {
|
||||||
|
checkedIngredients.value = new Set()
|
||||||
|
} else {
|
||||||
|
checkedIngredients.value = new Set(props.recipe.missing_ingredients)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function addToPantry() {
|
||||||
|
if (!checkedIngredients.value.size || addingToPantry.value) return
|
||||||
|
addingToPantry.value = true
|
||||||
|
addError.value = null
|
||||||
|
try {
|
||||||
|
const items = [...checkedIngredients.value].map((name) => ({ name, location: 'pantry' }))
|
||||||
|
const result = await inventoryAPI.bulkAddByName(items)
|
||||||
|
if (result.failed > 0 && result.added === 0) {
|
||||||
|
addError.value = 'Failed to add items. Please try again.'
|
||||||
|
} else {
|
||||||
|
addedToPantry.value = true
|
||||||
|
checkedIngredients.value = new Set()
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
addError.value = 'Could not reach the pantry. Please try again.'
|
||||||
|
} finally {
|
||||||
|
addingToPantry.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function shareList() {
|
||||||
|
const items = props.recipe.missing_ingredients
|
||||||
|
if (!items?.length) return
|
||||||
|
const text = `Shopping list for ${props.recipe.title}:\n${items.map((i) => `• ${i}`).join('\n')}`
|
||||||
|
if (navigator.share) {
|
||||||
|
await navigator.share({ title: `Shopping list: ${props.recipe.title}`, text })
|
||||||
|
} else {
|
||||||
|
await navigator.clipboard.writeText(text)
|
||||||
|
shareCopied.value = true
|
||||||
|
setTimeout(() => { shareCopied.value = false }, 2000)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function groceryLinkFor(ingredient: string): GroceryLink | undefined {
|
||||||
|
const needle = ingredient.toLowerCase()
|
||||||
|
return props.groceryLinks.find((l) => l.ingredient.toLowerCase() === needle)
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleCook() {
|
||||||
|
cookDone.value = true
|
||||||
|
emit('cooked', props.recipe)
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
/* ── Overlay / bottom-sheet shell ──────────────────────── */
|
||||||
|
.detail-overlay {
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
background: rgba(0, 0, 0, 0.45);
|
||||||
|
z-index: 400; /* above bottom-nav (200) and app-header (100) */
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-end;
|
||||||
|
}
|
||||||
|
|
||||||
|
.detail-panel {
|
||||||
|
width: 100%;
|
||||||
|
max-height: 92dvh;
|
||||||
|
background: var(--color-bg-card);
|
||||||
|
border-radius: var(--radius-lg, 12px) var(--radius-lg, 12px) 0 0;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
overflow: hidden;
|
||||||
|
box-shadow: 0 -4px 24px rgba(0, 0, 0, 0.2);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Centered modal on wider screens */
|
||||||
|
@media (min-width: 640px) {
|
||||||
|
.detail-overlay {
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
padding: var(--spacing-md);
|
||||||
|
}
|
||||||
|
|
||||||
|
.detail-panel {
|
||||||
|
max-width: 680px;
|
||||||
|
max-height: 85dvh;
|
||||||
|
border-radius: var(--radius-lg, 12px);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Header ─────────────────────────────────────────────── */
|
||||||
|
.detail-header {
|
||||||
|
padding: var(--spacing-md) var(--spacing-md) var(--spacing-sm);
|
||||||
|
border-bottom: 1px solid var(--color-border);
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header-badges {
|
||||||
|
display: flex;
|
||||||
|
gap: var(--spacing-xs);
|
||||||
|
flex-wrap: wrap;
|
||||||
|
margin-bottom: var(--spacing-xs);
|
||||||
|
}
|
||||||
|
|
||||||
|
.header-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-start;
|
||||||
|
gap: var(--spacing-sm);
|
||||||
|
}
|
||||||
|
|
||||||
|
.detail-title {
|
||||||
|
font-size: var(--font-size-lg);
|
||||||
|
font-weight: 700;
|
||||||
|
flex: 1;
|
||||||
|
line-height: 1.3;
|
||||||
|
color: var(--color-text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.header-actions {
|
||||||
|
align-items: center;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-save {
|
||||||
|
font-size: var(--font-size-sm);
|
||||||
|
padding: var(--spacing-xs) var(--spacing-sm);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-saved {
|
||||||
|
color: var(--color-warning);
|
||||||
|
border-color: var(--color-warning);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-close {
|
||||||
|
background: transparent;
|
||||||
|
border: none;
|
||||||
|
cursor: pointer;
|
||||||
|
padding: 4px 8px;
|
||||||
|
font-size: 16px;
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
border-radius: var(--radius-sm, 4px);
|
||||||
|
flex-shrink: 0;
|
||||||
|
line-height: 1;
|
||||||
|
transition: color 0.15s, background 0.15s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-close:hover {
|
||||||
|
color: var(--color-text-primary);
|
||||||
|
background: var(--color-bg-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.detail-notes {
|
||||||
|
font-size: var(--font-size-sm);
|
||||||
|
color: var(--color-text-secondary);
|
||||||
|
margin-top: var(--spacing-xs);
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Scrollable body ────────────────────────────────────── */
|
||||||
|
.detail-body {
|
||||||
|
flex: 1;
|
||||||
|
overflow-y: auto;
|
||||||
|
padding: var(--spacing-md);
|
||||||
|
-webkit-overflow-scrolling: touch;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Ingredients grid ───────────────────────────────────── */
|
||||||
|
.ingredients-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr 1fr;
|
||||||
|
gap: var(--spacing-md);
|
||||||
|
margin-bottom: var(--spacing-md);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Stack single column if only have or only need */
|
||||||
|
.ingredients-grid:has(.ingredient-col:only-child) {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 420px) {
|
||||||
|
.ingredients-grid {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.ingredient-col {
|
||||||
|
padding: var(--spacing-sm);
|
||||||
|
border-radius: var(--radius-md, 8px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ingredient-col-have {
|
||||||
|
background: var(--color-success-bg, #dcfce7);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ingredient-col-need {
|
||||||
|
background: var(--color-warning-bg, #fef9c3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.col-label {
|
||||||
|
font-size: var(--font-size-xs);
|
||||||
|
font-weight: 700;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.06em;
|
||||||
|
margin-bottom: var(--spacing-xs);
|
||||||
|
}
|
||||||
|
|
||||||
|
.col-label-have {
|
||||||
|
color: var(--color-success, #16a34a);
|
||||||
|
}
|
||||||
|
|
||||||
|
.col-label-need {
|
||||||
|
color: var(--color-warning, #ca8a04);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ingredient-list {
|
||||||
|
list-style: none;
|
||||||
|
padding: 0;
|
||||||
|
margin: 0;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ing-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: baseline;
|
||||||
|
gap: var(--spacing-xs);
|
||||||
|
font-size: var(--font-size-sm);
|
||||||
|
line-height: 1.4;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ing-icon {
|
||||||
|
font-size: 11px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ing-icon-have {
|
||||||
|
color: var(--color-success, #16a34a);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ing-icon-need {
|
||||||
|
color: var(--color-warning, #ca8a04);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ing-name {
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.source-link {
|
||||||
|
display: inline-block;
|
||||||
|
font-size: var(--font-size-xs);
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
text-decoration: none;
|
||||||
|
margin-top: var(--spacing-xs);
|
||||||
|
}
|
||||||
|
|
||||||
|
.source-link:hover {
|
||||||
|
color: var(--color-primary);
|
||||||
|
text-decoration: underline;
|
||||||
|
}
|
||||||
|
|
||||||
|
.col-header-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
margin-bottom: var(--spacing-xs);
|
||||||
|
}
|
||||||
|
|
||||||
|
.col-header-row .col-label {
|
||||||
|
margin-bottom: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.col-header-actions {
|
||||||
|
display: flex;
|
||||||
|
gap: var(--spacing-xs);
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Ingredient checkboxes */
|
||||||
|
.ing-check-label {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
flex-shrink: 0;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ing-check {
|
||||||
|
width: 14px;
|
||||||
|
height: 14px;
|
||||||
|
cursor: pointer;
|
||||||
|
accent-color: var(--color-warning, #ca8a04);
|
||||||
|
}
|
||||||
|
|
||||||
|
.select-all-btn {
|
||||||
|
background: transparent;
|
||||||
|
border: none;
|
||||||
|
color: var(--color-warning, #ca8a04);
|
||||||
|
font-size: var(--font-size-xs);
|
||||||
|
cursor: pointer;
|
||||||
|
padding: var(--spacing-xs) 0;
|
||||||
|
text-decoration: underline;
|
||||||
|
display: block;
|
||||||
|
margin-top: var(--spacing-xs);
|
||||||
|
}
|
||||||
|
|
||||||
|
.select-all-btn:hover {
|
||||||
|
opacity: 0.8;
|
||||||
|
transform: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Add to pantry footer state */
|
||||||
|
.add-pantry-col {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
flex: 1;
|
||||||
|
gap: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.add-error {
|
||||||
|
color: var(--color-error, #dc2626);
|
||||||
|
}
|
||||||
|
|
||||||
|
.add-success {
|
||||||
|
color: var(--color-success, #16a34a);
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-accent {
|
||||||
|
background: var(--color-success, #16a34a);
|
||||||
|
color: #fff;
|
||||||
|
border: none;
|
||||||
|
border-radius: var(--radius-md, 8px);
|
||||||
|
padding: var(--spacing-sm) var(--spacing-md);
|
||||||
|
font-size: var(--font-size-sm);
|
||||||
|
font-weight: 600;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: opacity 0.15s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-accent:hover {
|
||||||
|
opacity: 0.9;
|
||||||
|
transform: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-accent:disabled {
|
||||||
|
opacity: 0.6;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
.share-btn {
|
||||||
|
background: transparent;
|
||||||
|
border: 1px solid var(--color-warning, #ca8a04);
|
||||||
|
color: var(--color-warning, #ca8a04);
|
||||||
|
border-radius: var(--radius-sm, 4px);
|
||||||
|
font-size: var(--font-size-xs);
|
||||||
|
padding: 2px 8px;
|
||||||
|
cursor: pointer;
|
||||||
|
white-space: nowrap;
|
||||||
|
transition: background 0.15s, color 0.15s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.share-btn:hover {
|
||||||
|
background: var(--color-warning-bg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.buy-link {
|
||||||
|
font-size: var(--font-size-xs);
|
||||||
|
color: var(--color-primary);
|
||||||
|
text-decoration: none;
|
||||||
|
white-space: nowrap;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.buy-link:hover {
|
||||||
|
text-decoration: underline;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Generic detail sections ────────────────────────────── */
|
||||||
|
.detail-section {
|
||||||
|
margin-bottom: var(--spacing-md);
|
||||||
|
}
|
||||||
|
|
||||||
|
.section-label {
|
||||||
|
font-size: var(--font-size-xs);
|
||||||
|
font-weight: 700;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.06em;
|
||||||
|
color: var(--color-text-secondary);
|
||||||
|
margin-bottom: var(--spacing-xs);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Collapsible swaps ──────────────────────────────────── */
|
||||||
|
.detail-collapsible {
|
||||||
|
border-top: 1px solid var(--color-border);
|
||||||
|
padding: var(--spacing-sm) 0;
|
||||||
|
margin-bottom: var(--spacing-md);
|
||||||
|
}
|
||||||
|
|
||||||
|
.detail-collapsible-summary {
|
||||||
|
font-size: var(--font-size-sm);
|
||||||
|
font-weight: 600;
|
||||||
|
cursor: pointer;
|
||||||
|
list-style: none;
|
||||||
|
color: var(--color-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.detail-collapsible-summary::-webkit-details-marker {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.swap-row {
|
||||||
|
padding: var(--spacing-xs) 0;
|
||||||
|
border-bottom: 1px solid var(--color-border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.swap-row:last-child {
|
||||||
|
border-bottom: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Nutrition ──────────────────────────────────────────── */
|
||||||
|
.nutrition-chips {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: var(--spacing-xs);
|
||||||
|
}
|
||||||
|
|
||||||
|
.nutrition-chip {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
padding: 2px 8px;
|
||||||
|
border-radius: 12px;
|
||||||
|
font-size: var(--font-size-xs);
|
||||||
|
background: var(--color-bg-secondary, #f5f5f5);
|
||||||
|
color: var(--color-text-secondary);
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nutrition-chip-sugar {
|
||||||
|
background: var(--color-warning-bg);
|
||||||
|
color: var(--color-warning);
|
||||||
|
}
|
||||||
|
|
||||||
|
.nutrition-chip-servings {
|
||||||
|
background: var(--color-info-bg);
|
||||||
|
color: var(--color-info-light);
|
||||||
|
}
|
||||||
|
|
||||||
|
.nutrition-chip-estimated {
|
||||||
|
font-style: italic;
|
||||||
|
opacity: 0.7;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Prep + Directions ──────────────────────────────────── */
|
||||||
|
.prep-list {
|
||||||
|
padding-left: var(--spacing-lg);
|
||||||
|
list-style-type: disc;
|
||||||
|
}
|
||||||
|
|
||||||
|
.prep-item {
|
||||||
|
margin-bottom: var(--spacing-xs);
|
||||||
|
line-height: 1.5;
|
||||||
|
color: var(--color-text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.directions-list {
|
||||||
|
padding-left: var(--spacing-lg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.direction-step {
|
||||||
|
margin-bottom: var(--spacing-sm);
|
||||||
|
line-height: 1.6;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Sticky footer ──────────────────────────────────────── */
|
||||||
|
.detail-footer {
|
||||||
|
padding: var(--spacing-md);
|
||||||
|
border-top: 1px solid var(--color-border);
|
||||||
|
display: flex;
|
||||||
|
gap: var(--spacing-sm);
|
||||||
|
align-items: center;
|
||||||
|
background: var(--color-bg-card);
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-bookmark-panel {
|
||||||
|
background: var(--color-bg-secondary, #f5f5f5);
|
||||||
|
border: 1px solid var(--color-border);
|
||||||
|
border-radius: var(--radius-sm, 4px);
|
||||||
|
cursor: pointer;
|
||||||
|
padding: 6px 12px;
|
||||||
|
font-size: 16px;
|
||||||
|
line-height: 1;
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
flex-shrink: 0;
|
||||||
|
transition: color 0.15s, background 0.15s, border-color 0.15s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-bookmark-panel:hover,
|
||||||
|
.btn-bookmark-panel.active {
|
||||||
|
color: var(--color-warning, #ca8a04);
|
||||||
|
background: var(--color-warning-bg, #fef9c3);
|
||||||
|
border-color: var(--color-warning, #ca8a04);
|
||||||
|
transform: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cook-success {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
width: 100%;
|
||||||
|
text-align: center;
|
||||||
|
font-size: var(--font-size-sm);
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--color-success, #16a34a);
|
||||||
|
gap: var(--spacing-xs);
|
||||||
|
}
|
||||||
|
|
||||||
|
.cook-success-icon {
|
||||||
|
font-size: 24px;
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
.inline-spinner {
|
||||||
|
display: inline-block;
|
||||||
|
vertical-align: middle;
|
||||||
|
margin-right: var(--spacing-xs);
|
||||||
|
}
|
||||||
|
|
||||||
|
.mt-xs {
|
||||||
|
margin-top: var(--spacing-xs);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ml-xs {
|
||||||
|
margin-left: var(--spacing-xs);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-sm {
|
||||||
|
padding: var(--spacing-xs) var(--spacing-sm);
|
||||||
|
font-size: var(--font-size-sm);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
294
frontend/src/components/SaveRecipeModal.vue
Normal file
294
frontend/src/components/SaveRecipeModal.vue
Normal file
|
|
@ -0,0 +1,294 @@
|
||||||
|
<template>
|
||||||
|
<Teleport to="body">
|
||||||
|
<div class="modal-overlay" @click.self="$emit('close')">
|
||||||
|
<div ref="dialogRef" class="modal-panel card" role="dialog" aria-modal="true" aria-label="Save recipe" tabindex="-1">
|
||||||
|
<div class="flex-between mb-md">
|
||||||
|
<h3 class="section-title">{{ isEditing ? 'Edit saved recipe' : 'Save recipe' }}</h3>
|
||||||
|
<button class="btn-close" @click="$emit('close')" aria-label="Close">✕</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p class="recipe-title-label text-sm text-secondary mb-md">{{ recipeTitle }}</p>
|
||||||
|
|
||||||
|
<!-- Star rating -->
|
||||||
|
<div class="form-group">
|
||||||
|
<label id="rating-label" class="form-label">Rating</label>
|
||||||
|
<div role="group" aria-labelledby="rating-label" class="stars-row flex gap-xs">
|
||||||
|
<button
|
||||||
|
v-for="n in 5"
|
||||||
|
:key="n"
|
||||||
|
class="star-btn"
|
||||||
|
:class="{ filled: n <= (hoverRating ?? localRating ?? 0) }"
|
||||||
|
@mouseenter="hoverRating = n"
|
||||||
|
@mouseleave="hoverRating = null"
|
||||||
|
@click="toggleRating(n)"
|
||||||
|
:aria-label="`${n} star${n !== 1 ? 's' : ''}`"
|
||||||
|
:aria-pressed="n <= (localRating ?? 0)"
|
||||||
|
>★</button>
|
||||||
|
<button
|
||||||
|
v-if="localRating !== null"
|
||||||
|
class="btn btn-secondary btn-xs ml-xs"
|
||||||
|
@click="localRating = null"
|
||||||
|
>Clear</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Notes -->
|
||||||
|
<div class="form-group">
|
||||||
|
<label class="form-label" for="save-notes">Notes</label>
|
||||||
|
<textarea
|
||||||
|
id="save-notes"
|
||||||
|
class="form-input"
|
||||||
|
v-model="localNotes"
|
||||||
|
rows="3"
|
||||||
|
placeholder="e.g. loved with extra garlic, halve the salt next time"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Style tags -->
|
||||||
|
<div class="form-group">
|
||||||
|
<label class="form-label">Style tags</label>
|
||||||
|
<div class="tags-wrap flex flex-wrap gap-xs mb-xs">
|
||||||
|
<span
|
||||||
|
v-for="tag in localTags"
|
||||||
|
:key="tag"
|
||||||
|
class="tag-chip status-badge status-info"
|
||||||
|
>
|
||||||
|
{{ tag }}
|
||||||
|
<button class="chip-remove" @click="removeTag(tag)" :aria-label="`Remove tag: ${tag}`">×</button>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<input
|
||||||
|
class="form-input"
|
||||||
|
v-model="tagInput"
|
||||||
|
placeholder="e.g. comforting, hands-off, quick — press Enter or comma"
|
||||||
|
@keydown="onTagKey"
|
||||||
|
@blur="commitTagInput"
|
||||||
|
/>
|
||||||
|
<div class="tag-suggestions flex flex-wrap gap-xs mt-xs">
|
||||||
|
<button
|
||||||
|
v-for="s in unusedSuggestions"
|
||||||
|
:key="s"
|
||||||
|
class="btn btn-secondary btn-xs"
|
||||||
|
@click="addTag(s)"
|
||||||
|
>+ {{ s }}</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex gap-sm mt-md">
|
||||||
|
<button class="btn btn-primary" :disabled="saving" @click="submit">
|
||||||
|
{{ saving ? 'Saving…' : (isEditing ? 'Update' : 'Save') }}
|
||||||
|
</button>
|
||||||
|
<button v-if="isEditing" class="btn btn-danger" @click="$emit('unsave')">Remove</button>
|
||||||
|
<button class="btn btn-secondary" @click="$emit('close')">Cancel</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Teleport>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { ref, computed, onMounted, onUnmounted, nextTick } from 'vue'
|
||||||
|
import { useSavedRecipesStore } from '../stores/savedRecipes'
|
||||||
|
|
||||||
|
const SUGGESTED_TAGS = [
|
||||||
|
'comforting', 'light', 'spicy', 'umami', 'sweet', 'savory', 'rich',
|
||||||
|
'crispy', 'creamy', 'hearty', 'quick', 'hands-off', 'meal-prep-friendly',
|
||||||
|
'fancy', 'one-pot',
|
||||||
|
]
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
recipeId: number
|
||||||
|
recipeTitle: string
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
(e: 'close'): void
|
||||||
|
(e: 'saved'): void
|
||||||
|
(e: 'unsave'): void
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const dialogRef = ref<HTMLElement | null>(null)
|
||||||
|
let previousFocus: HTMLElement | null = null
|
||||||
|
|
||||||
|
function handleKeydown(e: KeyboardEvent) {
|
||||||
|
if (e.key === 'Escape') emit('close')
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
previousFocus = document.activeElement as HTMLElement
|
||||||
|
document.addEventListener('keydown', handleKeydown)
|
||||||
|
nextTick(() => {
|
||||||
|
const focusable = dialogRef.value?.querySelector<HTMLElement>(
|
||||||
|
'button:not([disabled]), input, textarea'
|
||||||
|
)
|
||||||
|
;(focusable ?? dialogRef.value)?.focus()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
onUnmounted(() => {
|
||||||
|
document.removeEventListener('keydown', handleKeydown)
|
||||||
|
previousFocus?.focus()
|
||||||
|
})
|
||||||
|
|
||||||
|
const store = useSavedRecipesStore()
|
||||||
|
const existing = computed(() => store.getSaved(props.recipeId))
|
||||||
|
const isEditing = computed(() => !!existing.value)
|
||||||
|
|
||||||
|
const localRating = ref<number | null>(existing.value?.rating ?? null)
|
||||||
|
const localNotes = ref<string>(existing.value?.notes ?? '')
|
||||||
|
const localTags = ref<string[]>([...(existing.value?.style_tags ?? [])])
|
||||||
|
const hoverRating = ref<number | null>(null)
|
||||||
|
const tagInput = ref('')
|
||||||
|
const saving = ref(false)
|
||||||
|
|
||||||
|
const unusedSuggestions = computed(() =>
|
||||||
|
SUGGESTED_TAGS.filter((s) => !localTags.value.includes(s))
|
||||||
|
)
|
||||||
|
|
||||||
|
function toggleRating(n: number) {
|
||||||
|
localRating.value = localRating.value === n ? null : n
|
||||||
|
}
|
||||||
|
|
||||||
|
function addTag(tag: string) {
|
||||||
|
const clean = tag.trim().toLowerCase()
|
||||||
|
if (clean && !localTags.value.includes(clean)) {
|
||||||
|
localTags.value = [...localTags.value, clean]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function removeTag(tag: string) {
|
||||||
|
localTags.value = localTags.value.filter((t) => t !== tag)
|
||||||
|
}
|
||||||
|
|
||||||
|
function commitTagInput() {
|
||||||
|
if (tagInput.value.trim()) {
|
||||||
|
addTag(tagInput.value)
|
||||||
|
tagInput.value = ''
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function onTagKey(e: KeyboardEvent) {
|
||||||
|
if (e.key === 'Enter' || e.key === ',') {
|
||||||
|
e.preventDefault()
|
||||||
|
commitTagInput()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function submit() {
|
||||||
|
saving.value = true
|
||||||
|
try {
|
||||||
|
if (isEditing.value) {
|
||||||
|
await store.update(props.recipeId, {
|
||||||
|
notes: localNotes.value || null,
|
||||||
|
rating: localRating.value,
|
||||||
|
style_tags: localTags.value,
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
await store.save(props.recipeId, localNotes.value || undefined, localRating.value ?? undefined)
|
||||||
|
if (localTags.value.length > 0 || localNotes.value) {
|
||||||
|
await store.update(props.recipeId, {
|
||||||
|
notes: localNotes.value || null,
|
||||||
|
rating: localRating.value,
|
||||||
|
style_tags: localTags.value,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
emit('saved')
|
||||||
|
emit('close')
|
||||||
|
} finally {
|
||||||
|
saving.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.modal-overlay {
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
background: rgba(0, 0, 0, 0.5);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
z-index: 200;
|
||||||
|
padding: var(--spacing-md);
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-panel {
|
||||||
|
width: 100%;
|
||||||
|
max-width: 480px;
|
||||||
|
max-height: 90vh;
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.recipe-title-label {
|
||||||
|
font-style: italic;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stars-row {
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.star-btn {
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
font-size: 1.6rem;
|
||||||
|
color: var(--color-border);
|
||||||
|
cursor: pointer;
|
||||||
|
padding: 0;
|
||||||
|
line-height: 1;
|
||||||
|
transition: color 0.1s ease, transform 0.1s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.star-btn.filled {
|
||||||
|
color: var(--color-warning);
|
||||||
|
}
|
||||||
|
|
||||||
|
.star-btn:hover {
|
||||||
|
transform: scale(1.15);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-xs {
|
||||||
|
padding: 2px var(--spacing-xs);
|
||||||
|
font-size: var(--font-size-xs, 0.75rem);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-danger {
|
||||||
|
background: var(--color-error);
|
||||||
|
color: white;
|
||||||
|
border-color: var(--color-error);
|
||||||
|
}
|
||||||
|
|
||||||
|
.tag-chip {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--spacing-xs);
|
||||||
|
}
|
||||||
|
|
||||||
|
.chip-remove {
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
line-height: 1;
|
||||||
|
opacity: 0.7;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chip-remove:hover {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-close {
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
font-size: 1.1rem;
|
||||||
|
cursor: pointer;
|
||||||
|
color: var(--color-text-secondary);
|
||||||
|
padding: var(--spacing-xs);
|
||||||
|
line-height: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-close:hover {
|
||||||
|
color: var(--color-text-primary);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
289
frontend/src/components/SavedRecipesPanel.vue
Normal file
289
frontend/src/components/SavedRecipesPanel.vue
Normal file
|
|
@ -0,0 +1,289 @@
|
||||||
|
<template>
|
||||||
|
<div class="saved-panel">
|
||||||
|
<!-- Empty state -->
|
||||||
|
<div v-if="!store.loading && store.saved.length === 0" class="empty-state card text-center">
|
||||||
|
<p class="text-secondary">No saved recipes yet.</p>
|
||||||
|
<p class="text-sm text-secondary mt-xs">Bookmark a recipe from Find or Browse and it will appear here.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<template v-else>
|
||||||
|
<!-- Controls -->
|
||||||
|
<div class="saved-controls flex-between flex-wrap gap-sm mb-md">
|
||||||
|
<div class="flex gap-sm flex-wrap">
|
||||||
|
<!-- Collection filter -->
|
||||||
|
<label class="sr-only" for="collection-filter">Filter by collection</label>
|
||||||
|
<select id="collection-filter" class="form-input sort-select" v-model="activeCollectionId" @change="reload">
|
||||||
|
<option :value="null">All saved</option>
|
||||||
|
<option v-for="col in store.collections" :key="col.id" :value="col.id">
|
||||||
|
{{ col.name }} ({{ col.member_count }})
|
||||||
|
</option>
|
||||||
|
</select>
|
||||||
|
|
||||||
|
<!-- Sort -->
|
||||||
|
<label class="sr-only" for="sort-order">Sort by</label>
|
||||||
|
<select id="sort-order" class="form-input sort-select" v-model="store.sortBy" @change="reload">
|
||||||
|
<option value="saved_at">Recently saved</option>
|
||||||
|
<option value="rating">Highest rated</option>
|
||||||
|
<option value="title">A–Z</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button class="btn btn-secondary btn-sm" @click="showNewCollection = true">
|
||||||
|
+ New collection
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Loading -->
|
||||||
|
<div v-if="store.loading" class="text-secondary text-sm">Loading…</div>
|
||||||
|
|
||||||
|
<!-- Recipe cards -->
|
||||||
|
<div class="saved-list flex-col gap-sm">
|
||||||
|
<div
|
||||||
|
v-for="recipe in store.saved"
|
||||||
|
:key="recipe.id"
|
||||||
|
class="card-sm saved-card"
|
||||||
|
:class="{ 'card-success': recipe.rating !== null && recipe.rating >= 4 }"
|
||||||
|
>
|
||||||
|
<div class="flex-between gap-sm">
|
||||||
|
<button
|
||||||
|
class="recipe-title-btn text-left"
|
||||||
|
@click="$emit('open-recipe', recipe.recipe_id)"
|
||||||
|
>
|
||||||
|
{{ recipe.title }}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<!-- Stars display -->
|
||||||
|
<div v-if="recipe.rating !== null" class="stars-display flex gap-xs" aria-label="Rating">
|
||||||
|
<span
|
||||||
|
v-for="n in 5"
|
||||||
|
:key="n"
|
||||||
|
class="star-pip"
|
||||||
|
:class="{ filled: n <= recipe.rating }"
|
||||||
|
>★</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Tags -->
|
||||||
|
<div v-if="recipe.style_tags.length > 0" class="flex flex-wrap gap-xs mt-xs">
|
||||||
|
<span
|
||||||
|
v-for="tag in recipe.style_tags"
|
||||||
|
:key="tag"
|
||||||
|
class="tag-chip status-badge status-info"
|
||||||
|
>{{ tag }}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Notes preview -->
|
||||||
|
<p v-if="recipe.notes" class="notes-preview text-sm text-secondary mt-xs">
|
||||||
|
{{ recipe.notes }}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<!-- Actions -->
|
||||||
|
<div class="flex gap-xs mt-sm">
|
||||||
|
<button class="btn btn-secondary btn-xs" @click="editRecipe(recipe)">Edit</button>
|
||||||
|
<button class="btn btn-secondary btn-xs" @click="unsave(recipe)">Remove</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<!-- New collection modal -->
|
||||||
|
<Teleport to="body" v-if="showNewCollection">
|
||||||
|
<div class="modal-overlay" @click.self="showNewCollection = false">
|
||||||
|
<div ref="newColDialogRef" class="modal-panel card" role="dialog" aria-modal="true" aria-label="New collection" tabindex="-1">
|
||||||
|
<h3 class="section-title mb-md">New collection</h3>
|
||||||
|
<div class="form-group">
|
||||||
|
<label class="form-label" for="col-name">Name</label>
|
||||||
|
<input id="col-name" class="form-input" v-model="newColName" placeholder="e.g. Weeknight meals" />
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label class="form-label" for="col-desc">Description (optional)</label>
|
||||||
|
<input id="col-desc" class="form-input" v-model="newColDesc" placeholder="Optional description" />
|
||||||
|
</div>
|
||||||
|
<div class="flex gap-sm mt-md">
|
||||||
|
<button class="btn btn-primary" :disabled="!newColName.trim() || creatingCol" @click="createCollection">
|
||||||
|
{{ creatingCol ? 'Creating…' : 'Create' }}
|
||||||
|
</button>
|
||||||
|
<button class="btn btn-secondary" @click="showNewCollection = false">Cancel</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Teleport>
|
||||||
|
|
||||||
|
<!-- Edit modal -->
|
||||||
|
<SaveRecipeModal
|
||||||
|
v-if="editingRecipe"
|
||||||
|
:recipe-id="editingRecipe.recipe_id"
|
||||||
|
:recipe-title="editingRecipe.title"
|
||||||
|
@close="editingRecipe = null"
|
||||||
|
@saved="editingRecipe = null"
|
||||||
|
@unsave="doUnsave(editingRecipe!.recipe_id)"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { ref, computed, onMounted, onUnmounted, nextTick, watch } from 'vue'
|
||||||
|
import { useSavedRecipesStore } from '../stores/savedRecipes'
|
||||||
|
import type { SavedRecipe } from '../services/api'
|
||||||
|
import SaveRecipeModal from './SaveRecipeModal.vue'
|
||||||
|
|
||||||
|
defineEmits<{
|
||||||
|
(e: 'open-recipe', recipeId: number): void
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const store = useSavedRecipesStore()
|
||||||
|
const editingRecipe = ref<SavedRecipe | null>(null)
|
||||||
|
const showNewCollection = ref(false)
|
||||||
|
const newColDialogRef = ref<HTMLElement | null>(null)
|
||||||
|
let newColPreviousFocus: HTMLElement | null = null
|
||||||
|
|
||||||
|
function handleNewColKeydown(e: KeyboardEvent) {
|
||||||
|
if (e.key === 'Escape') showNewCollection.value = false
|
||||||
|
}
|
||||||
|
|
||||||
|
watch(showNewCollection, (open) => {
|
||||||
|
if (open) {
|
||||||
|
newColPreviousFocus = document.activeElement as HTMLElement
|
||||||
|
document.addEventListener('keydown', handleNewColKeydown)
|
||||||
|
nextTick(() => {
|
||||||
|
const focusable = newColDialogRef.value?.querySelector<HTMLElement>(
|
||||||
|
'button:not([disabled]), input'
|
||||||
|
)
|
||||||
|
;(focusable ?? newColDialogRef.value)?.focus()
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
document.removeEventListener('keydown', handleNewColKeydown)
|
||||||
|
newColPreviousFocus?.focus()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
onUnmounted(() => {
|
||||||
|
document.removeEventListener('keydown', handleNewColKeydown)
|
||||||
|
})
|
||||||
|
const newColName = ref('')
|
||||||
|
const newColDesc = ref('')
|
||||||
|
const creatingCol = ref(false)
|
||||||
|
|
||||||
|
const activeCollectionId = computed({
|
||||||
|
get: () => store.activeCollectionId,
|
||||||
|
set: (v) => { store.activeCollectionId = v },
|
||||||
|
})
|
||||||
|
|
||||||
|
onMounted(() => store.load())
|
||||||
|
|
||||||
|
function reload() {
|
||||||
|
store.load()
|
||||||
|
}
|
||||||
|
|
||||||
|
function editRecipe(recipe: SavedRecipe) {
|
||||||
|
editingRecipe.value = recipe
|
||||||
|
}
|
||||||
|
|
||||||
|
async function unsave(recipe: SavedRecipe) {
|
||||||
|
await store.unsave(recipe.recipe_id)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function doUnsave(recipeId: number) {
|
||||||
|
editingRecipe.value = null
|
||||||
|
await store.unsave(recipeId)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function createCollection() {
|
||||||
|
if (!newColName.value.trim()) return
|
||||||
|
creatingCol.value = true
|
||||||
|
try {
|
||||||
|
await store.createCollection(newColName.value.trim(), newColDesc.value.trim() || undefined)
|
||||||
|
showNewCollection.value = false
|
||||||
|
newColName.value = ''
|
||||||
|
newColDesc.value = ''
|
||||||
|
} finally {
|
||||||
|
creatingCol.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.saved-panel {
|
||||||
|
padding: var(--spacing-sm) 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sort-select {
|
||||||
|
width: auto;
|
||||||
|
min-width: 140px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.saved-card {
|
||||||
|
transition: box-shadow 0.15s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.recipe-title-btn {
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: var(--font-size-base);
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--color-primary);
|
||||||
|
padding: 0;
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.recipe-title-btn:hover {
|
||||||
|
text-decoration: underline;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stars-display {
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.star-pip {
|
||||||
|
font-size: 1rem;
|
||||||
|
color: var(--color-border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.star-pip.filled {
|
||||||
|
color: var(--color-warning);
|
||||||
|
}
|
||||||
|
|
||||||
|
.notes-preview {
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
max-width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tag-chip {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
font-size: var(--font-size-xs, 0.75rem);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-sm {
|
||||||
|
padding: var(--spacing-xs) var(--spacing-sm);
|
||||||
|
font-size: var(--font-size-sm);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-xs {
|
||||||
|
padding: 2px var(--spacing-xs);
|
||||||
|
font-size: var(--font-size-xs, 0.75rem);
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty-state {
|
||||||
|
padding: var(--spacing-xl);
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-overlay {
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
background: rgba(0, 0, 0, 0.5);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
z-index: 200;
|
||||||
|
padding: var(--spacing-md);
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-panel {
|
||||||
|
width: 100%;
|
||||||
|
max-width: 420px;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|
@ -587,4 +587,108 @@ export const householdAPI = {
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ========== Saved Recipes Types ==========
|
||||||
|
|
||||||
|
export interface SavedRecipe {
|
||||||
|
id: number
|
||||||
|
recipe_id: number
|
||||||
|
title: string
|
||||||
|
saved_at: string
|
||||||
|
notes: string | null
|
||||||
|
rating: number | null
|
||||||
|
style_tags: string[]
|
||||||
|
collection_ids: number[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RecipeCollection {
|
||||||
|
id: number
|
||||||
|
name: string
|
||||||
|
description: string | null
|
||||||
|
member_count: number
|
||||||
|
created_at: string
|
||||||
|
}
|
||||||
|
|
||||||
|
// ========== Saved Recipes API ==========
|
||||||
|
|
||||||
|
export const savedRecipesAPI = {
|
||||||
|
async save(recipe_id: number, notes?: string, rating?: number): Promise<SavedRecipe> {
|
||||||
|
const response = await api.post('/recipes/saved', { recipe_id, notes, rating })
|
||||||
|
return response.data
|
||||||
|
},
|
||||||
|
async unsave(recipe_id: number): Promise<void> {
|
||||||
|
await api.delete(`/recipes/saved/${recipe_id}`)
|
||||||
|
},
|
||||||
|
async update(recipe_id: number, data: { notes?: string | null; rating?: number | null; style_tags?: string[] }): Promise<SavedRecipe> {
|
||||||
|
const response = await api.patch(`/recipes/saved/${recipe_id}`, data)
|
||||||
|
return response.data
|
||||||
|
},
|
||||||
|
async list(params?: { sort_by?: string; collection_id?: number }): Promise<SavedRecipe[]> {
|
||||||
|
const response = await api.get('/recipes/saved', { params })
|
||||||
|
return response.data
|
||||||
|
},
|
||||||
|
async listCollections(): Promise<RecipeCollection[]> {
|
||||||
|
const response = await api.get('/recipes/saved/collections')
|
||||||
|
return response.data
|
||||||
|
},
|
||||||
|
async createCollection(name: string, description?: string): Promise<RecipeCollection> {
|
||||||
|
const response = await api.post('/recipes/saved/collections', { name, description })
|
||||||
|
return response.data
|
||||||
|
},
|
||||||
|
async deleteCollection(id: number): Promise<void> {
|
||||||
|
await api.delete(`/recipes/saved/collections/${id}`)
|
||||||
|
},
|
||||||
|
async addToCollection(collection_id: number, saved_recipe_id: number): Promise<void> {
|
||||||
|
await api.post(`/recipes/saved/collections/${collection_id}/members`, { saved_recipe_id })
|
||||||
|
},
|
||||||
|
async removeFromCollection(collection_id: number, saved_recipe_id: number): Promise<void> {
|
||||||
|
await api.delete(`/recipes/saved/collections/${collection_id}/members/${saved_recipe_id}`)
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
// ========== Browser Types ==========
|
||||||
|
|
||||||
|
export interface BrowserDomain {
|
||||||
|
id: string
|
||||||
|
label: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface BrowserCategory {
|
||||||
|
category: string
|
||||||
|
recipe_count: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface BrowserRecipe {
|
||||||
|
id: number
|
||||||
|
title: string
|
||||||
|
category: string | null
|
||||||
|
match_pct: number | null
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface BrowserResult {
|
||||||
|
recipes: BrowserRecipe[]
|
||||||
|
total: number
|
||||||
|
page: number
|
||||||
|
}
|
||||||
|
|
||||||
|
// ========== Browser API ==========
|
||||||
|
|
||||||
|
export const browserAPI = {
|
||||||
|
async listDomains(): Promise<BrowserDomain[]> {
|
||||||
|
const response = await api.get('/recipes/browse/domains')
|
||||||
|
return response.data
|
||||||
|
},
|
||||||
|
async listCategories(domain: string): Promise<BrowserCategory[]> {
|
||||||
|
const response = await api.get(`/recipes/browse/${domain}`)
|
||||||
|
return response.data
|
||||||
|
},
|
||||||
|
async browse(domain: string, category: string, params?: {
|
||||||
|
page?: number
|
||||||
|
page_size?: number
|
||||||
|
pantry_items?: string
|
||||||
|
}): Promise<BrowserResult> {
|
||||||
|
const response = await api.get(`/recipes/browse/${domain}/${encodeURIComponent(category)}`, { params })
|
||||||
|
return response.data
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
export default api
|
export default api
|
||||||
|
|
|
||||||
|
|
@ -143,6 +143,13 @@ export const useInventoryStore = defineStore('inventory', () => {
|
||||||
locationFilter.value = location
|
locationFilter.value = location
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function consumeItem(itemId: number) {
|
||||||
|
await inventoryAPI.consumeItem(itemId)
|
||||||
|
items.value = items.value.map((item) =>
|
||||||
|
item.id === itemId ? { ...item, status: 'consumed' } : item
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
function setStatusFilter(status: string) {
|
function setStatusFilter(status: string) {
|
||||||
statusFilter.value = status
|
statusFilter.value = status
|
||||||
}
|
}
|
||||||
|
|
@ -166,6 +173,7 @@ export const useInventoryStore = defineStore('inventory', () => {
|
||||||
fetchStats,
|
fetchStats,
|
||||||
updateItem,
|
updateItem,
|
||||||
deleteItem,
|
deleteItem,
|
||||||
|
consumeItem,
|
||||||
scanBarcode,
|
scanBarcode,
|
||||||
setLocationFilter,
|
setLocationFilter,
|
||||||
setStatusFilter,
|
setStatusFilter,
|
||||||
|
|
|
||||||
|
|
@ -12,9 +12,21 @@ import { recipesAPI, type RecipeResult, type RecipeSuggestion, type RecipeReques
|
||||||
const DISMISSED_KEY = 'kiwi:dismissed_recipes'
|
const DISMISSED_KEY = 'kiwi:dismissed_recipes'
|
||||||
const DISMISS_TTL_MS = 7 * 24 * 60 * 60 * 1000
|
const DISMISS_TTL_MS = 7 * 24 * 60 * 60 * 1000
|
||||||
|
|
||||||
|
const COOK_LOG_KEY = 'kiwi:cook_log'
|
||||||
|
const COOK_LOG_MAX = 200
|
||||||
|
|
||||||
|
const BOOKMARKS_KEY = 'kiwi:bookmarks'
|
||||||
|
const BOOKMARKS_MAX = 50
|
||||||
|
|
||||||
// [id, dismissedAtMs]
|
// [id, dismissedAtMs]
|
||||||
type DismissEntry = [number, number]
|
type DismissEntry = [number, number]
|
||||||
|
|
||||||
|
export interface CookLogEntry {
|
||||||
|
id: number
|
||||||
|
title: string
|
||||||
|
cookedAt: number // unix ms
|
||||||
|
}
|
||||||
|
|
||||||
function loadDismissed(): Set<number> {
|
function loadDismissed(): Set<number> {
|
||||||
try {
|
try {
|
||||||
const raw = localStorage.getItem(DISMISSED_KEY)
|
const raw = localStorage.getItem(DISMISSED_KEY)
|
||||||
|
|
@ -33,6 +45,32 @@ function saveDismissed(ids: Set<number>) {
|
||||||
localStorage.setItem(DISMISSED_KEY, JSON.stringify(entries))
|
localStorage.setItem(DISMISSED_KEY, JSON.stringify(entries))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function loadCookLog(): CookLogEntry[] {
|
||||||
|
try {
|
||||||
|
const raw = localStorage.getItem(COOK_LOG_KEY)
|
||||||
|
return raw ? JSON.parse(raw) : []
|
||||||
|
} catch {
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function saveCookLog(log: CookLogEntry[]) {
|
||||||
|
localStorage.setItem(COOK_LOG_KEY, JSON.stringify(log.slice(-COOK_LOG_MAX)))
|
||||||
|
}
|
||||||
|
|
||||||
|
function loadBookmarks(): RecipeSuggestion[] {
|
||||||
|
try {
|
||||||
|
const raw = localStorage.getItem(BOOKMARKS_KEY)
|
||||||
|
return raw ? JSON.parse(raw) : []
|
||||||
|
} catch {
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function saveBookmarks(bookmarks: RecipeSuggestion[]) {
|
||||||
|
localStorage.setItem(BOOKMARKS_KEY, JSON.stringify(bookmarks.slice(0, BOOKMARKS_MAX)))
|
||||||
|
}
|
||||||
|
|
||||||
export const useRecipesStore = defineStore('recipes', () => {
|
export const useRecipesStore = defineStore('recipes', () => {
|
||||||
// Suggestion result state
|
// Suggestion result state
|
||||||
const result = ref<RecipeResult | null>(null)
|
const result = ref<RecipeResult | null>(null)
|
||||||
|
|
@ -48,6 +86,7 @@ export const useRecipesStore = defineStore('recipes', () => {
|
||||||
const styleId = ref<string | null>(null)
|
const styleId = ref<string | null>(null)
|
||||||
const category = ref<string | null>(null)
|
const category = ref<string | null>(null)
|
||||||
const wildcardConfirmed = ref(false)
|
const wildcardConfirmed = ref(false)
|
||||||
|
const shoppingMode = ref(false)
|
||||||
const nutritionFilters = ref<NutritionFilters>({
|
const nutritionFilters = ref<NutritionFilters>({
|
||||||
max_calories: null,
|
max_calories: null,
|
||||||
max_sugar_g: null,
|
max_sugar_g: null,
|
||||||
|
|
@ -59,6 +98,10 @@ export const useRecipesStore = defineStore('recipes', () => {
|
||||||
const dismissedIds = ref<Set<number>>(loadDismissed())
|
const dismissedIds = ref<Set<number>>(loadDismissed())
|
||||||
// Seen IDs: session-only, used by Load More to avoid repeating results
|
// Seen IDs: session-only, used by Load More to avoid repeating results
|
||||||
const seenIds = ref<Set<number>>(new Set())
|
const seenIds = ref<Set<number>>(new Set())
|
||||||
|
// Cook log: persisted to localStorage, max COOK_LOG_MAX entries
|
||||||
|
const cookLog = ref<CookLogEntry[]>(loadCookLog())
|
||||||
|
// Bookmarks: full RecipeSuggestion snapshots, max BOOKMARKS_MAX
|
||||||
|
const bookmarks = ref<RecipeSuggestion[]>(loadBookmarks())
|
||||||
|
|
||||||
const dismissedCount = computed(() => dismissedIds.value.size)
|
const dismissedCount = computed(() => dismissedIds.value.size)
|
||||||
|
|
||||||
|
|
@ -77,6 +120,7 @@ export const useRecipesStore = defineStore('recipes', () => {
|
||||||
wildcard_confirmed: wildcardConfirmed.value,
|
wildcard_confirmed: wildcardConfirmed.value,
|
||||||
nutrition_filters: nutritionFilters.value,
|
nutrition_filters: nutritionFilters.value,
|
||||||
excluded_ids: [...excluded],
|
excluded_ids: [...excluded],
|
||||||
|
shopping_mode: shoppingMode.value,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -144,6 +188,35 @@ export const useRecipesStore = defineStore('recipes', () => {
|
||||||
localStorage.removeItem(DISMISSED_KEY)
|
localStorage.removeItem(DISMISSED_KEY)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function logCook(id: number, title: string) {
|
||||||
|
const entry: CookLogEntry = { id, title, cookedAt: Date.now() }
|
||||||
|
cookLog.value = [...cookLog.value, entry]
|
||||||
|
saveCookLog(cookLog.value)
|
||||||
|
}
|
||||||
|
|
||||||
|
function clearCookLog() {
|
||||||
|
cookLog.value = []
|
||||||
|
localStorage.removeItem(COOK_LOG_KEY)
|
||||||
|
}
|
||||||
|
|
||||||
|
function isBookmarked(id: number): boolean {
|
||||||
|
return bookmarks.value.some((b) => b.id === id)
|
||||||
|
}
|
||||||
|
|
||||||
|
function toggleBookmark(recipe: RecipeSuggestion) {
|
||||||
|
if (isBookmarked(recipe.id)) {
|
||||||
|
bookmarks.value = bookmarks.value.filter((b) => b.id !== recipe.id)
|
||||||
|
} else {
|
||||||
|
bookmarks.value = [recipe, ...bookmarks.value]
|
||||||
|
}
|
||||||
|
saveBookmarks(bookmarks.value)
|
||||||
|
}
|
||||||
|
|
||||||
|
function clearBookmarks() {
|
||||||
|
bookmarks.value = []
|
||||||
|
localStorage.removeItem(BOOKMARKS_KEY)
|
||||||
|
}
|
||||||
|
|
||||||
function clearResult() {
|
function clearResult() {
|
||||||
result.value = null
|
result.value = null
|
||||||
error.value = null
|
error.value = null
|
||||||
|
|
@ -162,9 +235,17 @@ export const useRecipesStore = defineStore('recipes', () => {
|
||||||
styleId,
|
styleId,
|
||||||
category,
|
category,
|
||||||
wildcardConfirmed,
|
wildcardConfirmed,
|
||||||
|
shoppingMode,
|
||||||
nutritionFilters,
|
nutritionFilters,
|
||||||
dismissedIds,
|
dismissedIds,
|
||||||
dismissedCount,
|
dismissedCount,
|
||||||
|
cookLog,
|
||||||
|
logCook,
|
||||||
|
clearCookLog,
|
||||||
|
bookmarks,
|
||||||
|
isBookmarked,
|
||||||
|
toggleBookmark,
|
||||||
|
clearBookmarks,
|
||||||
suggest,
|
suggest,
|
||||||
loadMore,
|
loadMore,
|
||||||
dismiss,
|
dismiss,
|
||||||
|
|
|
||||||
83
frontend/src/stores/savedRecipes.ts
Normal file
83
frontend/src/stores/savedRecipes.ts
Normal file
|
|
@ -0,0 +1,83 @@
|
||||||
|
/**
|
||||||
|
* Saved Recipes Store
|
||||||
|
*
|
||||||
|
* Manages bookmarked recipes, ratings, style tags, and collections.
|
||||||
|
*/
|
||||||
|
import { defineStore } from 'pinia'
|
||||||
|
import { ref, computed } from 'vue'
|
||||||
|
import { savedRecipesAPI, type SavedRecipe, type RecipeCollection } from '../services/api'
|
||||||
|
|
||||||
|
export const useSavedRecipesStore = defineStore('savedRecipes', () => {
|
||||||
|
const saved = ref<SavedRecipe[]>([])
|
||||||
|
const collections = ref<RecipeCollection[]>([])
|
||||||
|
const loading = ref(false)
|
||||||
|
const sortBy = ref<'saved_at' | 'rating' | 'title'>('saved_at')
|
||||||
|
const activeCollectionId = ref<number | null>(null)
|
||||||
|
|
||||||
|
const savedIds = computed(() => new Set(saved.value.map((s) => s.recipe_id)))
|
||||||
|
|
||||||
|
function isSaved(recipeId: number): boolean {
|
||||||
|
return savedIds.value.has(recipeId)
|
||||||
|
}
|
||||||
|
|
||||||
|
function getSaved(recipeId: number): SavedRecipe | undefined {
|
||||||
|
return saved.value.find((s) => s.recipe_id === recipeId)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function load() {
|
||||||
|
loading.value = true
|
||||||
|
try {
|
||||||
|
const [items, cols] = await Promise.all([
|
||||||
|
savedRecipesAPI.list({ sort_by: sortBy.value, collection_id: activeCollectionId.value ?? undefined }),
|
||||||
|
savedRecipesAPI.listCollections(),
|
||||||
|
])
|
||||||
|
saved.value = items
|
||||||
|
collections.value = cols
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function save(recipeId: number, notes?: string, rating?: number): Promise<SavedRecipe> {
|
||||||
|
const result = await savedRecipesAPI.save(recipeId, notes, rating)
|
||||||
|
const idx = saved.value.findIndex((s) => s.recipe_id === recipeId)
|
||||||
|
if (idx >= 0) {
|
||||||
|
saved.value = [...saved.value.slice(0, idx), result, ...saved.value.slice(idx + 1)]
|
||||||
|
} else {
|
||||||
|
saved.value = [result, ...saved.value]
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
async function unsave(recipeId: number): Promise<void> {
|
||||||
|
await savedRecipesAPI.unsave(recipeId)
|
||||||
|
saved.value = saved.value.filter((s) => s.recipe_id !== recipeId)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function update(recipeId: number, data: { notes?: string | null; rating?: number | null; style_tags?: string[] }): Promise<SavedRecipe> {
|
||||||
|
const result = await savedRecipesAPI.update(recipeId, data)
|
||||||
|
const idx = saved.value.findIndex((s) => s.recipe_id === recipeId)
|
||||||
|
if (idx >= 0) {
|
||||||
|
saved.value = [...saved.value.slice(0, idx), result, ...saved.value.slice(idx + 1)]
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
async function createCollection(name: string, description?: string): Promise<RecipeCollection> {
|
||||||
|
const col = await savedRecipesAPI.createCollection(name, description)
|
||||||
|
collections.value = [...collections.value, col]
|
||||||
|
return col
|
||||||
|
}
|
||||||
|
|
||||||
|
async function deleteCollection(id: number): Promise<void> {
|
||||||
|
await savedRecipesAPI.deleteCollection(id)
|
||||||
|
collections.value = collections.value.filter((c) => c.id !== id)
|
||||||
|
if (activeCollectionId.value === id) activeCollectionId.value = null
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
saved, collections, loading, sortBy, activeCollectionId,
|
||||||
|
savedIds, isSaved, getSaved,
|
||||||
|
load, save, unsave, update, createCollection, deleteCollection,
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
@ -152,11 +152,15 @@ a:hover {
|
||||||
color: var(--color-primary-light);
|
color: var(--color-primary-light);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
html {
|
||||||
|
overflow-x: hidden; /* iOS Safari: html is the true scroll container — body alone isn't enough */
|
||||||
|
}
|
||||||
|
|
||||||
body {
|
body {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
min-width: 320px;
|
min-width: 320px;
|
||||||
min-height: 100vh;
|
min-height: 100vh;
|
||||||
overflow-x: hidden; /* prevent any element from expanding the mobile viewport */
|
overflow-x: hidden;
|
||||||
background-color: var(--color-bg-primary);
|
background-color: var(--color-bg-primary);
|
||||||
color: var(--color-text-primary);
|
color: var(--color-text-primary);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
20
manage.sh
20
manage.sh
|
|
@ -9,6 +9,10 @@ COMPOSE_FILE="compose.yml"
|
||||||
CLOUD_COMPOSE_FILE="compose.cloud.yml"
|
CLOUD_COMPOSE_FILE="compose.cloud.yml"
|
||||||
CLOUD_PROJECT="kiwi-cloud"
|
CLOUD_PROJECT="kiwi-cloud"
|
||||||
|
|
||||||
|
# Auto-include compose.override.yml when present (local dev extras, NAS mounts, etc.)
|
||||||
|
OVERRIDE_FLAG=""
|
||||||
|
[[ -f "compose.override.yml" ]] && OVERRIDE_FLAG="-f compose.override.yml"
|
||||||
|
|
||||||
usage() {
|
usage() {
|
||||||
echo "Usage: $0 {start|stop|restart|status|logs|open|build|test"
|
echo "Usage: $0 {start|stop|restart|status|logs|open|build|test"
|
||||||
echo " |cloud-start|cloud-stop|cloud-restart|cloud-status|cloud-logs|cloud-build}"
|
echo " |cloud-start|cloud-stop|cloud-restart|cloud-status|cloud-logs|cloud-build}"
|
||||||
|
|
@ -38,23 +42,23 @@ shift || true
|
||||||
|
|
||||||
case "$cmd" in
|
case "$cmd" in
|
||||||
start)
|
start)
|
||||||
docker compose -f "$COMPOSE_FILE" up -d --build
|
docker compose -f "$COMPOSE_FILE" $OVERRIDE_FLAG up -d --build
|
||||||
echo "Kiwi running → http://localhost:${WEB_PORT}"
|
echo "Kiwi running → http://localhost:${WEB_PORT}"
|
||||||
;;
|
;;
|
||||||
stop)
|
stop)
|
||||||
docker compose -f "$COMPOSE_FILE" down
|
docker compose -f "$COMPOSE_FILE" $OVERRIDE_FLAG down
|
||||||
;;
|
;;
|
||||||
restart)
|
restart)
|
||||||
docker compose -f "$COMPOSE_FILE" down
|
docker compose -f "$COMPOSE_FILE" $OVERRIDE_FLAG down
|
||||||
docker compose -f "$COMPOSE_FILE" up -d --build
|
docker compose -f "$COMPOSE_FILE" $OVERRIDE_FLAG up -d --build
|
||||||
echo "Kiwi running → http://localhost:${WEB_PORT}"
|
echo "Kiwi running → http://localhost:${WEB_PORT}"
|
||||||
;;
|
;;
|
||||||
status)
|
status)
|
||||||
docker compose -f "$COMPOSE_FILE" ps
|
docker compose -f "$COMPOSE_FILE" $OVERRIDE_FLAG ps
|
||||||
;;
|
;;
|
||||||
logs)
|
logs)
|
||||||
svc="${1:-}"
|
svc="${1:-}"
|
||||||
docker compose -f "$COMPOSE_FILE" logs -f ${svc}
|
docker compose -f "$COMPOSE_FILE" $OVERRIDE_FLAG logs -f ${svc}
|
||||||
;;
|
;;
|
||||||
open)
|
open)
|
||||||
xdg-open "http://localhost:${WEB_PORT}" 2>/dev/null \
|
xdg-open "http://localhost:${WEB_PORT}" 2>/dev/null \
|
||||||
|
|
@ -62,10 +66,10 @@ case "$cmd" in
|
||||||
|| echo "Open http://localhost:${WEB_PORT} in your browser"
|
|| echo "Open http://localhost:${WEB_PORT} in your browser"
|
||||||
;;
|
;;
|
||||||
build)
|
build)
|
||||||
docker compose -f "$COMPOSE_FILE" build --no-cache
|
docker compose -f "$COMPOSE_FILE" $OVERRIDE_FLAG build --no-cache
|
||||||
;;
|
;;
|
||||||
test)
|
test)
|
||||||
docker compose -f "$COMPOSE_FILE" run --rm api \
|
docker compose -f "$COMPOSE_FILE" $OVERRIDE_FLAG run --rm api \
|
||||||
conda run -n job-seeker pytest tests/ -v
|
conda run -n job-seeker pytest tests/ -v
|
||||||
;;
|
;;
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue