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_REPO=Circuit-Forge/kiwi
|
||||
# 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)
|
||||
<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.
|
||||
|
||||
**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
|
||||
- **Expiry alerts** — know what's about to go bad
|
||||
- **Receipt OCR** — extract line items from receipt photos automatically (Paid tier)
|
||||
- **Recipe suggestions** — LLM-powered ideas based on what's expiring (Paid tier, BYOK-unlockable)
|
||||
- **Recipe browser** — browse the full recipe corpus by cuisine, meal type, dietary preference, or main ingredient; pantry match percentage shown inline (Free)
|
||||
- **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)
|
||||
- **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
|
||||
|
||||
|
|
@ -52,8 +59,13 @@ cp .env.example .env
|
|||
| Receipt upload | ✓ | ✓ | ✓ |
|
||||
| Expiry alerts | ✓ | ✓ | ✓ |
|
||||
| CSV export | ✓ | ✓ | ✓ |
|
||||
| Recipe browser (domain/category) | ✓ | ✓ | ✓ |
|
||||
| Save recipes + notes + star rating | ✓ | ✓ | ✓ |
|
||||
| Style tags (manual, free-text) | ✓ | ✓ | ✓ |
|
||||
| Receipt OCR | BYOK | ✓ | ✓ |
|
||||
| Recipe suggestions | BYOK | ✓ | ✓ |
|
||||
| Recipe suggestions (L1–L4) | BYOK | ✓ | ✓ |
|
||||
| Named recipe collections | — | ✓ | ✓ |
|
||||
| LLM style auto-classifier | — | BYOK | ✓ |
|
||||
| Meal planning | — | ✓ | ✓ |
|
||||
| Multi-household | — | — | ✓ |
|
||||
| Leftover mode | — | — | ✓ |
|
||||
|
|
|
|||
|
|
@ -16,6 +16,9 @@ from app.db.session import get_store
|
|||
from app.db.store import Store
|
||||
from app.models.schemas.inventory import (
|
||||
BarcodeScanResponse,
|
||||
BulkAddByNameRequest,
|
||||
BulkAddByNameResponse,
|
||||
BulkAddItemResult,
|
||||
InventoryItemCreate,
|
||||
InventoryItemResponse,
|
||||
InventoryItemUpdate,
|
||||
|
|
@ -130,6 +133,34 @@ async def create_inventory_item(body: InventoryItemCreate, store: Store = Depend
|
|||
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])
|
||||
async def list_inventory_items(
|
||||
location: Optional[str] = None,
|
||||
|
|
|
|||
|
|
@ -1,14 +1,21 @@
|
|||
"""Recipe suggestion endpoints."""
|
||||
"""Recipe suggestion and browser endpoints."""
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
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.db.store import Store
|
||||
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.tiers import can_use
|
||||
|
||||
|
|
@ -52,6 +59,90 @@ async def suggest_recipes(
|
|||
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}")
|
||||
async def get_recipe(recipe_id: int, session: CloudUser = Depends(get_session)) -> dict:
|
||||
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 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()
|
||||
|
||||
|
|
@ -12,4 +12,5 @@ api_router.include_router(recipes.router, prefix="/recipes", tags=["recipes
|
|||
api_router.include_router(settings.router, prefix="/settings", tags=["settings"])
|
||||
api_router.include_router(staples.router, prefix="/staples", tags=["staples"])
|
||||
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",
|
||||
# recipe columns
|
||||
"ingredients", "ingredient_names", "directions",
|
||||
"keywords", "element_coverage"):
|
||||
"keywords", "element_coverage",
|
||||
# saved recipe columns
|
||||
"style_tags"):
|
||||
if key in d and isinstance(d[key], str):
|
||||
try:
|
||||
d[key] = json.loads(d[key])
|
||||
|
|
@ -735,3 +737,265 @@ class Store:
|
|||
int(approved), int(opted_in),
|
||||
))
|
||||
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
|
||||
|
||||
|
||||
# ── 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 ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
class InventoryStats(BaseModel):
|
||||
|
|
|
|||
|
|
@ -32,6 +32,7 @@ class RecipeSuggestion(BaseModel):
|
|||
match_count: int
|
||||
element_coverage: dict[str, float] = Field(default_factory=dict)
|
||||
swap_candidates: list[SwapCandidate] = Field(default_factory=list)
|
||||
matched_ingredients: list[str] = Field(default_factory=list)
|
||||
missing_ingredients: list[str] = Field(default_factory=list)
|
||||
directions: list[str] = Field(default_factory=list)
|
||||
prep_notes: list[str] = Field(default_factory=list)
|
||||
|
|
@ -39,6 +40,7 @@ class RecipeSuggestion(BaseModel):
|
|||
level: int = 1
|
||||
is_wildcard: bool = False
|
||||
nutrition: NutritionPanel | None = None
|
||||
source_url: str | None = None
|
||||
|
||||
|
||||
class GroceryLink(BaseModel):
|
||||
|
|
@ -79,3 +81,4 @@ class RecipeRequest(BaseModel):
|
|||
allergies: list[str] = Field(default_factory=list)
|
||||
nutrition_filters: NutritionFilters = Field(default_factory=NutritionFilters)
|
||||
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",
|
||||
"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=[
|
||||
AssemblyRole("protein", [
|
||||
|
|
@ -257,7 +259,6 @@ ASSEMBLY_TEMPLATES: list[AssemblyTemplate] = [
|
|||
"soy sauce", "teriyaki", "oyster sauce", "hoisin",
|
||||
"stir fry sauce", "sesame",
|
||||
]),
|
||||
AssemblyRole("starch base", ["rice", "noodle", "pasta", "ramen"]),
|
||||
AssemblyRole("garlic or ginger", ["garlic", "ginger"]),
|
||||
AssemblyRole("oil", ["oil", "sesame"]),
|
||||
],
|
||||
|
|
@ -381,9 +382,10 @@ ASSEMBLY_TEMPLATES: list[AssemblyTemplate] = [
|
|||
id=-8,
|
||||
title="Soup / Stew",
|
||||
required=[
|
||||
AssemblyRole("broth or liquid base", [
|
||||
"broth", "stock", "bouillon",
|
||||
"tomato sauce", "coconut milk", "cream of",
|
||||
# Narrow to dedicated soup bases — tomato sauce and coconut milk are
|
||||
# pantry staples used in too many non-soup dishes to serve as anchors.
|
||||
AssemblyRole("broth or stock", [
|
||||
"broth", "stock", "bouillon", "cream of",
|
||||
]),
|
||||
],
|
||||
optional=[
|
||||
|
|
@ -572,6 +574,12 @@ ASSEMBLY_TEMPLATES: list[AssemblyTemplate] = [
|
|||
"egg", "cornstarch", "custard powder", "gelatin",
|
||||
"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=[
|
||||
AssemblyRole("sweetener", ["sugar", "honey", "maple syrup", "condensed milk"]),
|
||||
|
|
@ -605,14 +613,20 @@ def match_assembly_templates(
|
|||
pantry_items: list[str],
|
||||
pantry_set: set[str],
|
||||
excluded_ids: list[int],
|
||||
expiring_set: set[str] | None = None,
|
||||
) -> list[RecipeSuggestion]:
|
||||
"""Return assembly-dish suggestions whose required roles are all satisfied.
|
||||
|
||||
Titles are personalized with specific pantry items (deterministically chosen
|
||||
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).
|
||||
|
||||
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)
|
||||
expiring = expiring_set or set()
|
||||
seed = _pantry_hash(pantry_set)
|
||||
results: list[RecipeSuggestion] = []
|
||||
|
||||
|
|
@ -620,20 +634,40 @@ def match_assembly_templates(
|
|||
if tmpl.id in excluded:
|
||||
continue
|
||||
|
||||
# All required roles must be satisfied
|
||||
if any(not _matches_role(role, pantry_set) for role in tmpl.required):
|
||||
# All required roles must be satisfied; collect matched items for required roles
|
||||
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
|
||||
|
||||
optional_hit_count = sum(
|
||||
1 for role in tmpl.optional if _matches_role(role, pantry_set)
|
||||
)
|
||||
# Collect matched items for optional roles (one representative per matched role)
|
||||
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(
|
||||
id=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={},
|
||||
swap_candidates=[],
|
||||
matched_ingredients=matched,
|
||||
missing_ingredients=[],
|
||||
directions=tmpl.directions,
|
||||
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.
|
||||
|
||||
Free tier: URL construction only (Amazon Fresh, Walmart, Instacart).
|
||||
Paid+: live product search API (stubbed — future task).
|
||||
Delegates URL wrapping to circuitforge_core.affiliates.wrap_url, which handles
|
||||
the full resolution chain: opt-out → BYOK id → CF env var → plain URL.
|
||||
|
||||
Config (env vars, all optional — missing = retailer disabled):
|
||||
AMAZON_AFFILIATE_TAG — e.g. "circuitforge-20"
|
||||
INSTACART_AFFILIATE_ID — e.g. "circuitforge"
|
||||
WALMART_AFFILIATE_ID — e.g. "circuitforge" (Impact affiliate network)
|
||||
Registered programs (via cf-core):
|
||||
amazon — Amazon Associates (env: AMAZON_ASSOCIATES_TAG)
|
||||
instacart — Instacart (env: INSTACART_AFFILIATE_ID)
|
||||
|
||||
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
|
||||
|
||||
import logging
|
||||
import os
|
||||
from urllib.parse import quote_plus
|
||||
|
||||
from circuitforge_core.affiliates import wrap_url
|
||||
|
||||
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)
|
||||
url = f"https://www.amazon.com/s?k={q}&i=amazonfresh&tag={tag}"
|
||||
return GroceryLink(ingredient=ingredient, retailer="Amazon Fresh", url=url)
|
||||
base = f"https://www.amazon.com/s?k={q}&i=amazonfresh"
|
||||
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:
|
||||
q = quote_plus(ingredient)
|
||||
# Walmart Impact affiliate deeplink pattern
|
||||
url = f"https://goto.walmart.com/c/{affiliate_id}/walmart?u=https://www.walmart.com/search?q={q}"
|
||||
# Walmart uses Impact network — affiliate ID is in the redirect path, not a param
|
||||
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)
|
||||
|
||||
|
||||
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:
|
||||
def __init__(self, tier: str = "free", has_byok: bool = False) -> None:
|
||||
self._tier = tier
|
||||
self._has_byok = has_byok
|
||||
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", "")
|
||||
self._walmart_id = os.environ.get("WALMART_AFFILIATE_ID", "").strip()
|
||||
|
||||
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.
|
||||
Paid+: would call live product search APIs (stubbed).
|
||||
Amazon Fresh and Instacart links are always included; wrap_url handles
|
||||
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():
|
||||
return []
|
||||
links: list[GroceryLink] = []
|
||||
|
||||
if self._amazon_tag:
|
||||
links.append(_amazon_link(ingredient, self._amazon_tag))
|
||||
links: list[GroceryLink] = [
|
||||
_amazon_fresh_link(ingredient),
|
||||
_instacart_link(ingredient),
|
||||
]
|
||||
if 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
|
||||
|
||||
|
|
|
|||
|
|
@ -160,28 +160,41 @@ class LLMRecipeGenerator:
|
|||
|
||||
With CF_ORCH_URL set: acquires a vLLM allocation via CFOrchClient and
|
||||
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:
|
||||
with self._get_llm_context() as alloc:
|
||||
if alloc is not None:
|
||||
base_url = alloc.url.rstrip("/") + "/v1"
|
||||
client = OpenAI(base_url=base_url, api_key="any")
|
||||
model = alloc.model or "__auto__"
|
||||
if model == "__auto__":
|
||||
model = client.models.list().data[0].id
|
||||
resp = client.chat.completions.create(
|
||||
model=model,
|
||||
messages=[{"role": "user", "content": prompt}],
|
||||
)
|
||||
return resp.choices[0].message.content or ""
|
||||
else:
|
||||
from circuitforge_core.llm.router import LLMRouter
|
||||
router = LLMRouter()
|
||||
return router.complete(prompt)
|
||||
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:
|
||||
if alloc is not None:
|
||||
base_url = alloc.url.rstrip("/") + "/v1"
|
||||
client = OpenAI(base_url=base_url, api_key="any")
|
||||
model = alloc.model or "__auto__"
|
||||
if model == "__auto__":
|
||||
model = client.models.list().data[0].id
|
||||
resp = client.chat.completions.create(
|
||||
model=model,
|
||||
messages=[{"role": "user", "content": prompt}],
|
||||
)
|
||||
return resp.choices[0].message.content or ""
|
||||
else:
|
||||
from circuitforge_core.llm.router import LLMRouter
|
||||
return LLMRouter().complete(prompt)
|
||||
except Exception as exc:
|
||||
logger.error("LLM call failed: %s", exc)
|
||||
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:"
|
||||
_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
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 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
|
||||
_EASY_METHODS = re.compile(
|
||||
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"
|
||||
# → note "Melt the butter before starting.") to surface separately.
|
||||
swap_candidates: list[SwapCandidate] = []
|
||||
matched: list[str] = []
|
||||
missing: list[str] = []
|
||||
prep_note_set: set[str] = set()
|
||||
for n in ingredient_names:
|
||||
if _ingredient_in_pantry(n, pantry_set):
|
||||
matched.append(_strip_quantity(n))
|
||||
note = _prep_note_for(n)
|
||||
if note:
|
||||
prep_note_set.add(note)
|
||||
continue
|
||||
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:
|
||||
swap_candidates.append(SwapCandidate(
|
||||
original_name=n,
|
||||
|
|
@ -488,8 +651,8 @@ class RecipeEngine:
|
|||
else:
|
||||
missing.append(n)
|
||||
|
||||
# Filter by max_missing (pantry swaps don't count as missing)
|
||||
if req.max_missing is not None and len(missing) > req.max_missing:
|
||||
# Filter by max_missing — skipped in shopping mode (user is willing to buy)
|
||||
if not req.shopping_mode and req.max_missing is not None and len(missing) > req.max_missing:
|
||||
continue
|
||||
|
||||
# Filter by hard_day_mode
|
||||
|
|
@ -547,20 +710,38 @@ class RecipeEngine:
|
|||
match_count=int(row.get("match_count") or 0),
|
||||
element_coverage=coverage_raw,
|
||||
swap_candidates=swap_candidates,
|
||||
matched_ingredients=matched,
|
||||
missing_ingredients=missing,
|
||||
prep_notes=sorted(prep_note_set),
|
||||
level=req.level,
|
||||
nutrition=nutrition if has_nutrition else None,
|
||||
source_url=_build_source_url(row),
|
||||
))
|
||||
|
||||
# Prepend assembly-dish templates (burrito, stir fry, omelette, etc.)
|
||||
# These fire regardless of corpus coverage — any pantry can make a burrito.
|
||||
# Assembly-dish templates (burrito, fried rice, pasta, etc.)
|
||||
# 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(
|
||||
pantry_items=req.pantry_items,
|
||||
pantry_set=pantry_set,
|
||||
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
|
||||
seen: set[str] = set()
|
||||
|
|
|
|||
|
|
@ -15,6 +15,7 @@ KIWI_BYOK_UNLOCKABLE: frozenset[str] = frozenset({
|
|||
"recipe_suggestions",
|
||||
"expiry_llm_matching",
|
||||
"receipt_ocr",
|
||||
"style_classifier",
|
||||
})
|
||||
|
||||
# Feature → minimum tier required
|
||||
|
|
@ -35,6 +36,8 @@ KIWI_FEATURES: dict[str, str] = {
|
|||
"meal_planning": "paid",
|
||||
"dietary_profiles": "paid",
|
||||
"style_picker": "paid",
|
||||
"recipe_collections": "paid",
|
||||
"style_classifier": "paid", # LLM auto-tag for saved recipe style tags; BYOK-unlockable
|
||||
|
||||
# Premium tier
|
||||
"multi_household": "premium",
|
||||
|
|
|
|||
|
|
@ -18,6 +18,10 @@ services:
|
|||
# DEV ONLY: comma-separated IPs that bypass JWT auth (LAN testing without Caddy).
|
||||
# Production deployments must NOT set this. Leave blank or omit entirely.
|
||||
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:
|
||||
- /devl/kiwi-cloud-data:/devl/kiwi-cloud-data
|
||||
# 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).
|
||||
|
||||
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.
|
||||
# The API scheduler uses COORDINATOR_URL to lease VRAM cooperatively; this
|
||||
# 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
|
||||
|
|
|
|||
|
|
@ -143,6 +143,13 @@ export const useInventoryStore = defineStore('inventory', () => {
|
|||
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) {
|
||||
statusFilter.value = status
|
||||
}
|
||||
|
|
@ -166,6 +173,7 @@ export const useInventoryStore = defineStore('inventory', () => {
|
|||
fetchStats,
|
||||
updateItem,
|
||||
deleteItem,
|
||||
consumeItem,
|
||||
scanBarcode,
|
||||
setLocationFilter,
|
||||
setStatusFilter,
|
||||
|
|
|
|||
|
|
@ -12,9 +12,21 @@ import { recipesAPI, type RecipeResult, type RecipeSuggestion, type RecipeReques
|
|||
const DISMISSED_KEY = 'kiwi:dismissed_recipes'
|
||||
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]
|
||||
type DismissEntry = [number, number]
|
||||
|
||||
export interface CookLogEntry {
|
||||
id: number
|
||||
title: string
|
||||
cookedAt: number // unix ms
|
||||
}
|
||||
|
||||
function loadDismissed(): Set<number> {
|
||||
try {
|
||||
const raw = localStorage.getItem(DISMISSED_KEY)
|
||||
|
|
@ -33,6 +45,32 @@ function saveDismissed(ids: Set<number>) {
|
|||
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', () => {
|
||||
// Suggestion result state
|
||||
const result = ref<RecipeResult | null>(null)
|
||||
|
|
@ -48,6 +86,7 @@ export const useRecipesStore = defineStore('recipes', () => {
|
|||
const styleId = ref<string | null>(null)
|
||||
const category = ref<string | null>(null)
|
||||
const wildcardConfirmed = ref(false)
|
||||
const shoppingMode = ref(false)
|
||||
const nutritionFilters = ref<NutritionFilters>({
|
||||
max_calories: null,
|
||||
max_sugar_g: null,
|
||||
|
|
@ -59,6 +98,10 @@ export const useRecipesStore = defineStore('recipes', () => {
|
|||
const dismissedIds = ref<Set<number>>(loadDismissed())
|
||||
// Seen IDs: session-only, used by Load More to avoid repeating results
|
||||
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)
|
||||
|
||||
|
|
@ -77,6 +120,7 @@ export const useRecipesStore = defineStore('recipes', () => {
|
|||
wildcard_confirmed: wildcardConfirmed.value,
|
||||
nutrition_filters: nutritionFilters.value,
|
||||
excluded_ids: [...excluded],
|
||||
shopping_mode: shoppingMode.value,
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -144,6 +188,35 @@ export const useRecipesStore = defineStore('recipes', () => {
|
|||
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() {
|
||||
result.value = null
|
||||
error.value = null
|
||||
|
|
@ -162,9 +235,17 @@ export const useRecipesStore = defineStore('recipes', () => {
|
|||
styleId,
|
||||
category,
|
||||
wildcardConfirmed,
|
||||
shoppingMode,
|
||||
nutritionFilters,
|
||||
dismissedIds,
|
||||
dismissedCount,
|
||||
cookLog,
|
||||
logCook,
|
||||
clearCookLog,
|
||||
bookmarks,
|
||||
isBookmarked,
|
||||
toggleBookmark,
|
||||
clearBookmarks,
|
||||
suggest,
|
||||
loadMore,
|
||||
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);
|
||||
}
|
||||
|
||||
html {
|
||||
overflow-x: hidden; /* iOS Safari: html is the true scroll container — body alone isn't enough */
|
||||
}
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
min-width: 320px;
|
||||
min-height: 100vh;
|
||||
overflow-x: hidden; /* prevent any element from expanding the mobile viewport */
|
||||
overflow-x: hidden;
|
||||
background-color: var(--color-bg-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_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() {
|
||||
echo "Usage: $0 {start|stop|restart|status|logs|open|build|test"
|
||||
echo " |cloud-start|cloud-stop|cloud-restart|cloud-status|cloud-logs|cloud-build}"
|
||||
|
|
@ -38,23 +42,23 @@ shift || true
|
|||
|
||||
case "$cmd" in
|
||||
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}"
|
||||
;;
|
||||
stop)
|
||||
docker compose -f "$COMPOSE_FILE" down
|
||||
docker compose -f "$COMPOSE_FILE" $OVERRIDE_FLAG down
|
||||
;;
|
||||
restart)
|
||||
docker compose -f "$COMPOSE_FILE" down
|
||||
docker compose -f "$COMPOSE_FILE" up -d --build
|
||||
docker compose -f "$COMPOSE_FILE" $OVERRIDE_FLAG down
|
||||
docker compose -f "$COMPOSE_FILE" $OVERRIDE_FLAG up -d --build
|
||||
echo "Kiwi running → http://localhost:${WEB_PORT}"
|
||||
;;
|
||||
status)
|
||||
docker compose -f "$COMPOSE_FILE" ps
|
||||
docker compose -f "$COMPOSE_FILE" $OVERRIDE_FLAG ps
|
||||
;;
|
||||
logs)
|
||||
svc="${1:-}"
|
||||
docker compose -f "$COMPOSE_FILE" logs -f ${svc}
|
||||
docker compose -f "$COMPOSE_FILE" $OVERRIDE_FLAG logs -f ${svc}
|
||||
;;
|
||||
open)
|
||||
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"
|
||||
;;
|
||||
build)
|
||||
docker compose -f "$COMPOSE_FILE" build --no-cache
|
||||
docker compose -f "$COMPOSE_FILE" $OVERRIDE_FLAG build --no-cache
|
||||
;;
|
||||
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
|
||||
;;
|
||||
|
||||
|
|
|
|||
Loading…
Reference in a new issue