Merge pull request 'feat: Phase 2 — saved recipes, browser, accessibility, level UX' (#69) from feature/orch-auto-lifecycle into main
This commit is contained in:
commit
3530071187
46 changed files with 4490 additions and 365 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 | — | — | ✓ |
|
||||
|
|
|
|||
|
|
@ -1,169 +1,9 @@
|
|||
"""
|
||||
Feedback endpoint — creates Forgejo issues from in-app feedback.
|
||||
Ported from peregrine/scripts/feedback_api.py; adapted for Kiwi context.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import platform
|
||||
import subprocess
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
from typing import Literal
|
||||
|
||||
import requests
|
||||
from fastapi import APIRouter, HTTPException
|
||||
from pydantic import BaseModel
|
||||
|
||||
"""Feedback router — provided by circuitforge-core."""
|
||||
from circuitforge_core.api import make_feedback_router
|
||||
from app.core.config import settings
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
_ROOT = Path(__file__).resolve().parents[3]
|
||||
|
||||
# ── Forgejo helpers ────────────────────────────────────────────────────────────
|
||||
|
||||
_LABEL_COLORS = {
|
||||
"beta-feedback": "#0075ca",
|
||||
"needs-triage": "#e4e669",
|
||||
"bug": "#d73a4a",
|
||||
"feature-request": "#a2eeef",
|
||||
"question": "#d876e3",
|
||||
}
|
||||
|
||||
|
||||
def _forgejo_headers() -> dict:
|
||||
token = os.environ.get("FORGEJO_API_TOKEN", "")
|
||||
return {"Authorization": f"token {token}", "Content-Type": "application/json"}
|
||||
|
||||
|
||||
def _ensure_labels(label_names: list[str]) -> list[int]:
|
||||
base = os.environ.get("FORGEJO_API_URL", "https://git.opensourcesolarpunk.com/api/v1")
|
||||
repo = os.environ.get("FORGEJO_REPO", "Circuit-Forge/kiwi")
|
||||
headers = _forgejo_headers()
|
||||
resp = requests.get(f"{base}/repos/{repo}/labels", headers=headers, timeout=10)
|
||||
existing = {lb["name"]: lb["id"] for lb in resp.json()} if resp.ok else {}
|
||||
ids: list[int] = []
|
||||
for name in label_names:
|
||||
if name in existing:
|
||||
ids.append(existing[name])
|
||||
else:
|
||||
r = requests.post(
|
||||
f"{base}/repos/{repo}/labels",
|
||||
headers=headers,
|
||||
json={"name": name, "color": _LABEL_COLORS.get(name, "#ededed")},
|
||||
timeout=10,
|
||||
)
|
||||
if r.ok:
|
||||
ids.append(r.json()["id"])
|
||||
return ids
|
||||
|
||||
|
||||
def _collect_context(tab: str) -> dict:
|
||||
"""Collect lightweight app context: tab, version, platform, timestamp."""
|
||||
try:
|
||||
version = subprocess.check_output(
|
||||
["git", "describe", "--tags", "--always"],
|
||||
cwd=_ROOT, text=True, timeout=5,
|
||||
).strip()
|
||||
except Exception:
|
||||
version = "dev"
|
||||
|
||||
return {
|
||||
"tab": tab,
|
||||
"version": version,
|
||||
"demo_mode": settings.DEMO_MODE,
|
||||
"cloud_mode": settings.CLOUD_MODE,
|
||||
"platform": platform.platform(),
|
||||
"timestamp": datetime.now(timezone.utc).isoformat().replace("+00:00", "Z"),
|
||||
}
|
||||
|
||||
|
||||
def _build_issue_body(form: dict, context: dict) -> str:
|
||||
_TYPE_LABELS = {"bug": "🐛 Bug", "feature": "✨ Feature Request", "other": "💬 Other"}
|
||||
lines: list[str] = [
|
||||
f"## {_TYPE_LABELS.get(form.get('type', 'other'), '💬 Other')}",
|
||||
"",
|
||||
form.get("description", ""),
|
||||
"",
|
||||
]
|
||||
if form.get("type") == "bug" and form.get("repro"):
|
||||
lines += ["### Reproduction Steps", "", form["repro"], ""]
|
||||
|
||||
lines += ["### Context", ""]
|
||||
for k, v in context.items():
|
||||
lines.append(f"- **{k}:** {v}")
|
||||
lines.append("")
|
||||
|
||||
if form.get("submitter"):
|
||||
lines += ["---", f"*Submitted by: {form['submitter']}*"]
|
||||
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
# ── Schemas ────────────────────────────────────────────────────────────────────
|
||||
|
||||
class FeedbackRequest(BaseModel):
|
||||
title: str
|
||||
description: str
|
||||
type: Literal["bug", "feature", "other"] = "other"
|
||||
repro: str = ""
|
||||
tab: str = "unknown"
|
||||
submitter: str = "" # optional "Name <email>" attribution
|
||||
|
||||
|
||||
class FeedbackResponse(BaseModel):
|
||||
issue_number: int
|
||||
issue_url: str
|
||||
|
||||
|
||||
# ── Routes ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
@router.get("/status")
|
||||
def feedback_status() -> dict:
|
||||
"""Return whether feedback submission is configured on this instance."""
|
||||
return {"enabled": bool(os.environ.get("FORGEJO_API_TOKEN")) and not settings.DEMO_MODE}
|
||||
|
||||
|
||||
@router.post("", response_model=FeedbackResponse)
|
||||
def submit_feedback(payload: FeedbackRequest) -> FeedbackResponse:
|
||||
"""
|
||||
File a Forgejo issue from in-app feedback.
|
||||
Silently disabled when FORGEJO_API_TOKEN is not set (demo/offline mode).
|
||||
"""
|
||||
token = os.environ.get("FORGEJO_API_TOKEN", "")
|
||||
if not token:
|
||||
raise HTTPException(
|
||||
status_code=503,
|
||||
detail="Feedback disabled: FORGEJO_API_TOKEN not configured.",
|
||||
)
|
||||
if settings.DEMO_MODE:
|
||||
raise HTTPException(status_code=403, detail="Feedback disabled in demo mode.")
|
||||
|
||||
context = _collect_context(payload.tab)
|
||||
form = {
|
||||
"type": payload.type,
|
||||
"description": payload.description,
|
||||
"repro": payload.repro,
|
||||
"submitter": payload.submitter,
|
||||
}
|
||||
body = _build_issue_body(form, context)
|
||||
labels = ["beta-feedback", "needs-triage"]
|
||||
labels.append({"bug": "bug", "feature": "feature-request"}.get(payload.type, "question"))
|
||||
|
||||
base = os.environ.get("FORGEJO_API_URL", "https://git.opensourcesolarpunk.com/api/v1")
|
||||
repo = os.environ.get("FORGEJO_REPO", "Circuit-Forge/kiwi")
|
||||
headers = _forgejo_headers()
|
||||
|
||||
label_ids = _ensure_labels(labels)
|
||||
resp = requests.post(
|
||||
f"{base}/repos/{repo}/issues",
|
||||
headers=headers,
|
||||
json={"title": payload.title, "body": body, "labels": label_ids},
|
||||
timeout=15,
|
||||
)
|
||||
if not resp.ok:
|
||||
raise HTTPException(status_code=502, detail=f"Forgejo error: {resp.text[:200]}")
|
||||
|
||||
data = resp.json()
|
||||
return FeedbackResponse(issue_number=data["number"], issue_url=data["html_url"])
|
||||
router = make_feedback_router(
|
||||
repo="Circuit-Forge/kiwi",
|
||||
product="kiwi",
|
||||
demo_mode_fn=lambda: settings.DEMO_MODE,
|
||||
)
|
||||
|
|
|
|||
211
app/api/endpoints/household.py
Normal file
211
app/api/endpoints/household.py
Normal file
|
|
@ -0,0 +1,211 @@
|
|||
"""Household management endpoints — shared pantry for Premium users."""
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import os
|
||||
import secrets
|
||||
from datetime import datetime, timedelta, timezone
|
||||
|
||||
import sqlite3
|
||||
|
||||
import requests
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
|
||||
from app.cloud_session import CloudUser, CLOUD_DATA_ROOT, HEIMDALL_URL, HEIMDALL_ADMIN_TOKEN, get_session
|
||||
from app.db.store import Store
|
||||
from app.models.schemas.household import (
|
||||
HouseholdAcceptRequest,
|
||||
HouseholdAcceptResponse,
|
||||
HouseholdCreateResponse,
|
||||
HouseholdInviteResponse,
|
||||
HouseholdMember,
|
||||
HouseholdRemoveMemberRequest,
|
||||
HouseholdStatusResponse,
|
||||
MessageResponse,
|
||||
)
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
router = APIRouter()
|
||||
|
||||
_INVITE_TTL_DAYS = 7
|
||||
_KIWI_BASE_URL = os.environ.get("KIWI_BASE_URL", "https://menagerie.circuitforge.tech/kiwi")
|
||||
|
||||
|
||||
def _require_premium(session: CloudUser = Depends(get_session)) -> CloudUser:
|
||||
if session.tier not in ("premium", "ultra", "local"):
|
||||
raise HTTPException(status_code=403, detail="Household features require Premium tier.")
|
||||
return session
|
||||
|
||||
|
||||
def _require_household_owner(session: CloudUser = Depends(_require_premium)) -> CloudUser:
|
||||
if not session.is_household_owner or not session.household_id:
|
||||
raise HTTPException(status_code=403, detail="Only the household owner can perform this action.")
|
||||
return session
|
||||
|
||||
|
||||
def _household_store(household_id: str) -> Store:
|
||||
"""Open the household DB directly (used during invite acceptance).
|
||||
Sets row_factory so dict-style column access works on raw conn queries.
|
||||
"""
|
||||
db_path = CLOUD_DATA_ROOT / f"household_{household_id}" / "kiwi.db"
|
||||
db_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
store = Store(db_path)
|
||||
store.conn.row_factory = sqlite3.Row
|
||||
return store
|
||||
|
||||
|
||||
def _heimdall_post(path: str, body: dict) -> dict:
|
||||
"""Call Heimdall admin API. Returns response dict or raises HTTPException."""
|
||||
if not HEIMDALL_ADMIN_TOKEN:
|
||||
log.warning("HEIMDALL_ADMIN_TOKEN not set — household Heimdall call skipped")
|
||||
return {}
|
||||
try:
|
||||
resp = requests.post(
|
||||
f"{HEIMDALL_URL}{path}",
|
||||
json=body,
|
||||
headers={"Authorization": f"Bearer {HEIMDALL_ADMIN_TOKEN}"},
|
||||
timeout=10,
|
||||
)
|
||||
if not resp.ok:
|
||||
raise HTTPException(status_code=502, detail=f"Heimdall error: {resp.text}")
|
||||
return resp.json()
|
||||
except requests.RequestException as exc:
|
||||
raise HTTPException(status_code=502, detail=f"Heimdall unreachable: {exc}")
|
||||
|
||||
|
||||
@router.post("/create", response_model=HouseholdCreateResponse)
|
||||
async def create_household(session: CloudUser = Depends(_require_premium)):
|
||||
"""Create a new household. The calling user becomes owner."""
|
||||
if session.household_id:
|
||||
raise HTTPException(status_code=409, detail="You are already in a household.")
|
||||
data = _heimdall_post("/admin/household/create", {"owner_user_id": session.user_id})
|
||||
household_id = data.get("household_id")
|
||||
if not household_id:
|
||||
# Heimdall returned OK but without a household_id — treat as server error.
|
||||
# Fall back to a local stub only when HEIMDALL_ADMIN_TOKEN is unset (dev mode).
|
||||
if HEIMDALL_ADMIN_TOKEN:
|
||||
raise HTTPException(status_code=500, detail="Heimdall did not return a household_id.")
|
||||
household_id = "local-household"
|
||||
return HouseholdCreateResponse(
|
||||
household_id=household_id,
|
||||
message="Household created. Share an invite link to add members.",
|
||||
)
|
||||
|
||||
|
||||
@router.get("/status", response_model=HouseholdStatusResponse)
|
||||
async def household_status(session: CloudUser = Depends(_require_premium)):
|
||||
"""Return current user's household membership status."""
|
||||
if not session.household_id:
|
||||
return HouseholdStatusResponse(in_household=False)
|
||||
|
||||
members: list[HouseholdMember] = []
|
||||
if HEIMDALL_ADMIN_TOKEN:
|
||||
try:
|
||||
resp = requests.get(
|
||||
f"{HEIMDALL_URL}/admin/household/{session.household_id}",
|
||||
headers={"Authorization": f"Bearer {HEIMDALL_ADMIN_TOKEN}"},
|
||||
timeout=5,
|
||||
)
|
||||
if resp.ok:
|
||||
raw = resp.json()
|
||||
for m in raw.get("members", []):
|
||||
members.append(HouseholdMember(
|
||||
user_id=m["user_id"],
|
||||
joined_at=m.get("joined_at", ""),
|
||||
is_owner=m["user_id"] == raw.get("owner_user_id"),
|
||||
))
|
||||
except Exception as exc:
|
||||
log.warning("Could not fetch household members: %s", exc)
|
||||
|
||||
return HouseholdStatusResponse(
|
||||
in_household=True,
|
||||
household_id=session.household_id,
|
||||
is_owner=session.is_household_owner,
|
||||
members=members,
|
||||
)
|
||||
|
||||
|
||||
@router.post("/invite", response_model=HouseholdInviteResponse)
|
||||
async def create_invite(session: CloudUser = Depends(_require_household_owner)):
|
||||
"""Generate a one-time invite token valid for 7 days."""
|
||||
store = Store(session.db)
|
||||
token = secrets.token_hex(32)
|
||||
expires_at = (datetime.now(timezone.utc) + timedelta(days=_INVITE_TTL_DAYS)).isoformat()
|
||||
store.conn.execute(
|
||||
"""INSERT INTO household_invites (token, household_id, created_by, expires_at)
|
||||
VALUES (?, ?, ?, ?)""",
|
||||
(token, session.household_id, session.user_id, expires_at),
|
||||
)
|
||||
store.conn.commit()
|
||||
invite_url = f"{_KIWI_BASE_URL}/#/join?household_id={session.household_id}&token={token}"
|
||||
return HouseholdInviteResponse(token=token, invite_url=invite_url, expires_at=expires_at)
|
||||
|
||||
|
||||
@router.post("/accept", response_model=HouseholdAcceptResponse)
|
||||
async def accept_invite(
|
||||
body: HouseholdAcceptRequest,
|
||||
session: CloudUser = Depends(get_session),
|
||||
):
|
||||
"""Accept a household invite. Opens the household DB directly to validate the token."""
|
||||
if session.household_id:
|
||||
raise HTTPException(status_code=409, detail="You are already in a household.")
|
||||
|
||||
hh_store = _household_store(body.household_id)
|
||||
now = datetime.now(timezone.utc).isoformat()
|
||||
row = hh_store.conn.execute(
|
||||
"""SELECT token, expires_at, used_at FROM household_invites
|
||||
WHERE token = ? AND household_id = ?""",
|
||||
(body.token, body.household_id),
|
||||
).fetchone()
|
||||
|
||||
if not row:
|
||||
raise HTTPException(status_code=404, detail="Invite not found.")
|
||||
if row["used_at"] is not None:
|
||||
raise HTTPException(status_code=410, detail="Invite already used.")
|
||||
if row["expires_at"] < now:
|
||||
raise HTTPException(status_code=410, detail="Invite has expired.")
|
||||
|
||||
hh_store.conn.execute(
|
||||
"UPDATE household_invites SET used_at = ?, used_by = ? WHERE token = ?",
|
||||
(now, session.user_id, body.token),
|
||||
)
|
||||
hh_store.conn.commit()
|
||||
|
||||
_heimdall_post("/admin/household/add-member", {
|
||||
"household_id": body.household_id,
|
||||
"user_id": session.user_id,
|
||||
})
|
||||
|
||||
return HouseholdAcceptResponse(
|
||||
message="You have joined the household. Reload the app to switch to the shared pantry.",
|
||||
household_id=body.household_id,
|
||||
)
|
||||
|
||||
|
||||
@router.post("/leave", response_model=MessageResponse)
|
||||
async def leave_household(session: CloudUser = Depends(_require_premium)) -> MessageResponse:
|
||||
"""Leave the current household (non-owners only)."""
|
||||
if not session.household_id:
|
||||
raise HTTPException(status_code=400, detail="You are not in a household.")
|
||||
if session.is_household_owner:
|
||||
raise HTTPException(status_code=400, detail="The household owner cannot leave. Delete the household instead.")
|
||||
_heimdall_post("/admin/household/remove-member", {
|
||||
"household_id": session.household_id,
|
||||
"user_id": session.user_id,
|
||||
})
|
||||
return MessageResponse(message="You have left the household. Reload the app to return to your personal pantry.")
|
||||
|
||||
|
||||
@router.post("/remove-member", response_model=MessageResponse)
|
||||
async def remove_member(
|
||||
body: HouseholdRemoveMemberRequest,
|
||||
session: CloudUser = Depends(_require_household_owner),
|
||||
) -> MessageResponse:
|
||||
"""Remove a member from the household (owner only)."""
|
||||
if body.user_id == session.user_id:
|
||||
raise HTTPException(status_code=400, detail="Use /leave to remove yourself.")
|
||||
_heimdall_post("/admin/household/remove-member", {
|
||||
"household_id": session.household_id,
|
||||
"user_id": body.user_id,
|
||||
})
|
||||
return MessageResponse(message=f"Member {body.user_id} removed from household.")
|
||||
|
|
@ -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
|
||||
from app.api.endpoints import health, receipts, export, inventory, ocr, recipes, settings, staples, feedback, household, saved_recipes
|
||||
|
||||
api_router = APIRouter()
|
||||
|
||||
|
|
@ -12,3 +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(saved_recipes.router, prefix="/recipes/saved", tags=["saved-recipes"])
|
||||
|
|
@ -76,7 +76,7 @@ def _is_bypass_ip(ip: str) -> bool:
|
|||
|
||||
_LOCAL_KIWI_DB: Path = Path(os.environ.get("KIWI_DB", "data/kiwi.db"))
|
||||
|
||||
_TIER_CACHE: dict[str, tuple[str, float]] = {}
|
||||
_TIER_CACHE: dict[str, tuple[dict, float]] = {}
|
||||
_TIER_CACHE_TTL = 300 # 5 minutes
|
||||
|
||||
TIERS = ["free", "paid", "premium", "ultra"]
|
||||
|
|
@ -90,6 +90,8 @@ class CloudUser:
|
|||
tier: str # free | paid | premium | ultra | local
|
||||
db: Path # per-user SQLite DB path
|
||||
has_byok: bool # True if a configured LLM backend is present in llm.yaml
|
||||
household_id: str | None = None
|
||||
is_household_owner: bool = False
|
||||
|
||||
|
||||
# ── JWT validation ─────────────────────────────────────────────────────────────
|
||||
|
|
@ -130,14 +132,16 @@ def _ensure_provisioned(user_id: str) -> None:
|
|||
log.warning("Heimdall provision failed for user %s: %s", user_id, exc)
|
||||
|
||||
|
||||
def _fetch_cloud_tier(user_id: str) -> str:
|
||||
def _fetch_cloud_tier(user_id: str) -> tuple[str, str | None, bool]:
|
||||
"""Returns (tier, household_id | None, is_household_owner)."""
|
||||
now = time.monotonic()
|
||||
cached = _TIER_CACHE.get(user_id)
|
||||
if cached and (now - cached[1]) < _TIER_CACHE_TTL:
|
||||
return cached[0]
|
||||
entry = cached[0]
|
||||
return entry["tier"], entry.get("household_id"), entry.get("is_household_owner", False)
|
||||
|
||||
if not HEIMDALL_ADMIN_TOKEN:
|
||||
return "free"
|
||||
return "free", None, False
|
||||
try:
|
||||
resp = requests.post(
|
||||
f"{HEIMDALL_URL}/admin/cloud/resolve",
|
||||
|
|
@ -145,17 +149,23 @@ def _fetch_cloud_tier(user_id: str) -> str:
|
|||
headers={"Authorization": f"Bearer {HEIMDALL_ADMIN_TOKEN}"},
|
||||
timeout=5,
|
||||
)
|
||||
tier = resp.json().get("tier", "free") if resp.ok else "free"
|
||||
data = resp.json() if resp.ok else {}
|
||||
tier = data.get("tier", "free")
|
||||
household_id = data.get("household_id")
|
||||
is_owner = data.get("is_household_owner", False)
|
||||
except Exception as exc:
|
||||
log.warning("Heimdall tier resolve failed for user %s: %s", user_id, exc)
|
||||
tier = "free"
|
||||
tier, household_id, is_owner = "free", None, False
|
||||
|
||||
_TIER_CACHE[user_id] = (tier, now)
|
||||
return tier
|
||||
_TIER_CACHE[user_id] = ({"tier": tier, "household_id": household_id, "is_household_owner": is_owner}, now)
|
||||
return tier, household_id, is_owner
|
||||
|
||||
|
||||
def _user_db_path(user_id: str) -> Path:
|
||||
path = CLOUD_DATA_ROOT / user_id / "kiwi.db"
|
||||
def _user_db_path(user_id: str, household_id: str | None = None) -> Path:
|
||||
if household_id:
|
||||
path = CLOUD_DATA_ROOT / f"household_{household_id}" / "kiwi.db"
|
||||
else:
|
||||
path = CLOUD_DATA_ROOT / user_id / "kiwi.db"
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
return path
|
||||
|
||||
|
|
@ -198,8 +208,6 @@ def get_session(request: Request) -> CloudUser:
|
|||
if not CLOUD_MODE:
|
||||
return CloudUser(user_id="local", tier="local", db=_LOCAL_KIWI_DB, has_byok=has_byok)
|
||||
|
||||
# Prefer X-Real-IP (set by nginx from the actual client address) over the
|
||||
# TCP peer address (which is nginx's container IP when behind the proxy).
|
||||
# Prefer X-Real-IP (set by nginx from the actual client address) over the
|
||||
# TCP peer address (which is nginx's container IP when behind the proxy).
|
||||
client_ip = (
|
||||
|
|
@ -225,8 +233,15 @@ def get_session(request: Request) -> CloudUser:
|
|||
|
||||
user_id = validate_session_jwt(token)
|
||||
_ensure_provisioned(user_id)
|
||||
tier = _fetch_cloud_tier(user_id)
|
||||
return CloudUser(user_id=user_id, tier=tier, db=_user_db_path(user_id), has_byok=has_byok)
|
||||
tier, household_id, is_household_owner = _fetch_cloud_tier(user_id)
|
||||
return CloudUser(
|
||||
user_id=user_id,
|
||||
tier=tier,
|
||||
db=_user_db_path(user_id, household_id=household_id),
|
||||
has_byok=has_byok,
|
||||
household_id=household_id,
|
||||
is_household_owner=is_household_owner,
|
||||
)
|
||||
|
||||
|
||||
def require_tier(min_tier: str):
|
||||
|
|
|
|||
10
app/db/migrations/017_household_invites.sql
Normal file
10
app/db/migrations/017_household_invites.sql
Normal file
|
|
@ -0,0 +1,10 @@
|
|||
-- 017_household_invites.sql
|
||||
CREATE TABLE IF NOT EXISTS household_invites (
|
||||
token TEXT PRIMARY KEY,
|
||||
household_id TEXT NOT NULL,
|
||||
created_by TEXT NOT NULL,
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
expires_at TEXT NOT NULL,
|
||||
used_at TEXT,
|
||||
used_by TEXT
|
||||
);
|
||||
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()
|
||||
|
|
|
|||
47
app/models/schemas/household.py
Normal file
47
app/models/schemas/household.py
Normal file
|
|
@ -0,0 +1,47 @@
|
|||
"""Pydantic schemas for household management endpoints."""
|
||||
from __future__ import annotations
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
|
||||
class HouseholdCreateResponse(BaseModel):
|
||||
household_id: str
|
||||
message: str
|
||||
|
||||
|
||||
class HouseholdMember(BaseModel):
|
||||
user_id: str
|
||||
joined_at: str
|
||||
is_owner: bool
|
||||
|
||||
|
||||
class HouseholdStatusResponse(BaseModel):
|
||||
in_household: bool
|
||||
household_id: str | None = None
|
||||
is_owner: bool = False
|
||||
members: list[HouseholdMember] = Field(default_factory=list)
|
||||
max_seats: int = 4
|
||||
|
||||
|
||||
class HouseholdInviteResponse(BaseModel):
|
||||
invite_url: str
|
||||
token: str
|
||||
expires_at: str
|
||||
|
||||
|
||||
class HouseholdAcceptRequest(BaseModel):
|
||||
household_id: str
|
||||
token: str
|
||||
|
||||
|
||||
class HouseholdAcceptResponse(BaseModel):
|
||||
message: str
|
||||
household_id: str
|
||||
|
||||
|
||||
class HouseholdRemoveMemberRequest(BaseModel):
|
||||
user_id: str
|
||||
|
||||
|
||||
class MessageResponse(BaseModel):
|
||||
message: str
|
||||
|
|
@ -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
|
||||
|
|
@ -33,7 +33,7 @@ def _try_docuvision(image_path: str | Path) -> str | None:
|
|||
if not cf_orch_url:
|
||||
return None
|
||||
try:
|
||||
from circuitforge_core.resources import CFOrchClient
|
||||
from circuitforge_orch.client import CFOrchClient
|
||||
from app.services.ocr.docuvision_client import DocuvisionClient
|
||||
|
||||
client = CFOrchClient(cf_orch_url)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -143,7 +143,7 @@ class LLMRecipeGenerator:
|
|||
cf_orch_url = os.environ.get("CF_ORCH_URL")
|
||||
if cf_orch_url:
|
||||
try:
|
||||
from circuitforge_core.resources import CFOrchClient
|
||||
from circuitforge_orch.client import CFOrchClient
|
||||
client = CFOrchClient(cf_orch_url)
|
||||
return client.allocate(
|
||||
service="vllm",
|
||||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -13,10 +13,15 @@ services:
|
|||
environment:
|
||||
CLOUD_MODE: "true"
|
||||
CLOUD_DATA_ROOT: /devl/kiwi-cloud-data
|
||||
KIWI_BASE_URL: https://menagerie.circuitforge.tech/kiwi
|
||||
# DIRECTUS_JWT_SECRET, HEIMDALL_URL, HEIMDALL_ADMIN_TOKEN — set in .env
|
||||
# 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.
|
||||
|
|
|
|||
|
|
@ -16,6 +16,18 @@
|
|||
</div>
|
||||
|
||||
<nav class="sidebar-nav">
|
||||
<button :class="['sidebar-item', { active: currentTab === 'recipes' }]" @click="switchTab('recipes')">
|
||||
<svg class="nav-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round">
|
||||
<!-- Ramen bowl: chopsticks, rim, body, wavy noodles -->
|
||||
<line x1="9" y1="2" x2="11" y2="9"/>
|
||||
<line x1="15" y1="2" x2="13" y2="9"/>
|
||||
<path d="M4 9 Q4 6 12 6 Q20 6 20 9"/>
|
||||
<path d="M4 9 L4.5 17 Q4.5 21 12 21 Q19.5 21 19.5 17 L20 9"/>
|
||||
<path d="M7 14 Q9 12 11 14 Q13 16 15 14 Q17 12 19 14"/>
|
||||
</svg>
|
||||
<span class="sidebar-label">Recipes</span>
|
||||
</button>
|
||||
|
||||
<button :class="['sidebar-item', { active: currentTab === 'inventory' }]" @click="switchTab('inventory')">
|
||||
<svg class="nav-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round">
|
||||
<rect x="3" y="4" width="18" height="4" rx="1"/>
|
||||
|
|
@ -34,14 +46,6 @@
|
|||
<span class="sidebar-label">Receipts</span>
|
||||
</button>
|
||||
|
||||
<button :class="['sidebar-item', { active: currentTab === 'recipes' }]" @click="switchTab('recipes')">
|
||||
<svg class="nav-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M12 2C9 2 7 5 7 8c0 2.5 1 4.5 3 5.5V20h4v-6.5c2-1 3-3 3-5.5 0-3-2-6-5-6z"/>
|
||||
<line x1="9" y1="12" x2="15" y2="12"/>
|
||||
</svg>
|
||||
<span class="sidebar-label">Recipes</span>
|
||||
</button>
|
||||
|
||||
<button :class="['sidebar-item', { active: currentTab === 'settings' }]" @click="switchTab('settings')">
|
||||
<svg class="nav-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round">
|
||||
<circle cx="12" cy="12" r="3"/>
|
||||
|
|
@ -81,6 +85,16 @@
|
|||
|
||||
<!-- Mobile bottom nav only -->
|
||||
<nav class="bottom-nav" role="navigation" aria-label="Main navigation">
|
||||
<button :class="['nav-item', { active: currentTab === 'recipes' }]" @click="switchTab('recipes')" aria-label="Recipes">
|
||||
<svg class="nav-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round">
|
||||
<line x1="9" y1="2" x2="11" y2="9"/>
|
||||
<line x1="15" y1="2" x2="13" y2="9"/>
|
||||
<path d="M4 9 Q4 6 12 6 Q20 6 20 9"/>
|
||||
<path d="M4 9 L4.5 17 Q4.5 21 12 21 Q19.5 21 19.5 17 L20 9"/>
|
||||
<path d="M7 14 Q9 12 11 14 Q13 16 15 14 Q17 12 19 14"/>
|
||||
</svg>
|
||||
<span class="nav-label">Recipes</span>
|
||||
</button>
|
||||
<button :class="['nav-item', { active: currentTab === 'inventory' }]" @click="switchTab('inventory')" aria-label="Pantry">
|
||||
<svg class="nav-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round">
|
||||
<rect x="3" y="4" width="18" height="4" rx="1"/>
|
||||
|
|
@ -97,13 +111,6 @@
|
|||
</svg>
|
||||
<span class="nav-label">Receipts</span>
|
||||
</button>
|
||||
<button :class="['nav-item', { active: currentTab === 'recipes' }]" @click="switchTab('recipes')" aria-label="Recipes">
|
||||
<svg class="nav-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M12 2C9 2 7 5 7 8c0 2.5 1 4.5 3 5.5V20h4v-6.5c2-1 3-3 3-5.5 0-3-2-6-5-6z"/>
|
||||
<line x1="9" y1="12" x2="15" y2="12"/>
|
||||
</svg>
|
||||
<span class="nav-label">Recipes</span>
|
||||
</button>
|
||||
<button :class="['nav-item', { active: currentTab === 'settings' }]" @click="switchTab('settings')" aria-label="Settings">
|
||||
<svg class="nav-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round">
|
||||
<circle cx="12" cy="12" r="3"/>
|
||||
|
|
@ -151,7 +158,7 @@
|
|||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue'
|
||||
import { ref, onMounted } from 'vue'
|
||||
import InventoryList from './components/InventoryList.vue'
|
||||
import ReceiptsView from './components/ReceiptsView.vue'
|
||||
import RecipesView from './components/RecipesView.vue'
|
||||
|
|
@ -159,6 +166,7 @@ import SettingsView from './components/SettingsView.vue'
|
|||
import FeedbackButton from './components/FeedbackButton.vue'
|
||||
import { useInventoryStore } from './stores/inventory'
|
||||
import { useEasterEggs } from './composables/useEasterEggs'
|
||||
import { householdAPI } from './services/api'
|
||||
|
||||
type Tab = 'inventory' | 'receipts' | 'recipes' | 'settings'
|
||||
|
||||
|
|
@ -188,6 +196,31 @@ async function switchTab(tab: Tab) {
|
|||
await inventoryStore.fetchItems()
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
// Handle household invite links: /#/join?household_id=xxx&token=yyy
|
||||
const hash = window.location.hash
|
||||
if (hash.includes('/join')) {
|
||||
const params = new URLSearchParams(hash.split('?')[1] ?? '')
|
||||
const householdId = params.get('household_id')
|
||||
const token = params.get('token')
|
||||
if (householdId && token) {
|
||||
try {
|
||||
const result = await householdAPI.accept(householdId, token)
|
||||
alert(result.message)
|
||||
// Clear the invite params from URL and reload
|
||||
window.location.hash = '/'
|
||||
window.location.reload()
|
||||
} catch (err: unknown) {
|
||||
const msg = (err instanceof Object && 'response' in err)
|
||||
? ((err as { response?: { data?: { detail?: string } } }).response?.data?.detail ?? 'Could not join household.')
|
||||
: 'Could not join household.'
|
||||
alert(`Failed to join: ${msg}`)
|
||||
window.location.hash = '/'
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<style>
|
||||
|
|
@ -224,6 +257,9 @@ body {
|
|||
min-height: 100vh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
width: 100%;
|
||||
max-width: 100vw;
|
||||
overflow-x: hidden;
|
||||
}
|
||||
|
||||
.sidebar { display: none; }
|
||||
|
|
|
|||
|
|
@ -105,9 +105,9 @@
|
|||
<div class="form-group scan-qty-group">
|
||||
<label class="form-label">Qty</label>
|
||||
<div class="quantity-control">
|
||||
<button class="btn-qty" @click="scannerQuantity = Math.max(0.1, scannerQuantity - 1)" type="button">−</button>
|
||||
<input v-model.number="scannerQuantity" type="number" min="0.1" step="0.1" class="qty-input" />
|
||||
<button class="btn-qty" @click="scannerQuantity += 1" type="button">+</button>
|
||||
<button class="btn-qty" @click="scannerQuantity = Math.max(0.1, scannerQuantity - 1)" type="button" aria-label="Decrease quantity">−</button>
|
||||
<input v-model.number="scannerQuantity" type="number" min="0.1" step="0.1" class="qty-input" aria-label="Quantity" />
|
||||
<button class="btn-qty" @click="scannerQuantity += 1" type="button" aria-label="Increase quantity">+</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -160,9 +160,9 @@
|
|||
<div class="form-group scan-qty-group">
|
||||
<label class="form-label">Qty</label>
|
||||
<div class="quantity-control">
|
||||
<button class="btn-qty" @click="barcodeQuantity = Math.max(0.1, barcodeQuantity - 1)" type="button">−</button>
|
||||
<input v-model.number="barcodeQuantity" type="number" min="0.1" step="0.1" class="qty-input" />
|
||||
<button class="btn-qty" @click="barcodeQuantity += 1" type="button">+</button>
|
||||
<button class="btn-qty" @click="barcodeQuantity = Math.max(0.1, barcodeQuantity - 1)" type="button" aria-label="Decrease quantity">−</button>
|
||||
<input v-model.number="barcodeQuantity" type="number" min="0.1" step="0.1" class="qty-input" aria-label="Quantity" />
|
||||
<button class="btn-qty" @click="barcodeQuantity += 1" type="button" aria-label="Increase quantity">+</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -209,9 +209,9 @@
|
|||
<div class="form-group scan-qty-group">
|
||||
<label class="form-label">Qty</label>
|
||||
<div class="quantity-control">
|
||||
<button class="btn-qty" @click="manualForm.quantity = Math.max(0.1, manualForm.quantity - 1)" type="button">−</button>
|
||||
<input v-model.number="manualForm.quantity" type="number" min="0.1" step="0.1" required class="qty-input" />
|
||||
<button class="btn-qty" @click="manualForm.quantity += 1" type="button">+</button>
|
||||
<button class="btn-qty" @click="manualForm.quantity = Math.max(0.1, manualForm.quantity - 1)" type="button" aria-label="Decrease quantity">−</button>
|
||||
<input v-model.number="manualForm.quantity" type="number" min="0.1" step="0.1" required class="qty-input" aria-label="Quantity" />
|
||||
<button class="btn-qty" @click="manualForm.quantity += 1" type="button" aria-label="Increase quantity">+</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -765,7 +765,8 @@ function getItemClass(item: InventoryItem): string {
|
|||
flex-direction: column;
|
||||
gap: var(--spacing-md);
|
||||
padding: var(--spacing-xs) 0 0;
|
||||
overflow-x: hidden; /* prevent item rows from expanding page width on mobile */
|
||||
overflow-x: hidden;
|
||||
width: 100%; /* Firefox: explicit width stops flex column from auto-sizing to content */
|
||||
}
|
||||
|
||||
/* ============================================
|
||||
|
|
@ -989,6 +990,9 @@ function getItemClass(item: InventoryItem): string {
|
|||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-sm);
|
||||
width: 100%;
|
||||
max-width: 100%;
|
||||
overflow-x: hidden;
|
||||
}
|
||||
|
||||
.inventory-header {
|
||||
|
|
@ -1016,6 +1020,8 @@ function getItemClass(item: InventoryItem): string {
|
|||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-lg);
|
||||
overflow: hidden;
|
||||
width: 100%;
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
.inv-row {
|
||||
|
|
@ -1024,6 +1030,8 @@ function getItemClass(item: InventoryItem): string {
|
|||
gap: var(--spacing-sm);
|
||||
padding: var(--spacing-sm) var(--spacing-md);
|
||||
border-left: 3px solid var(--color-border);
|
||||
max-width: 100%;
|
||||
box-sizing: border-box;
|
||||
background: var(--color-bg-card);
|
||||
transition: background 0.15s ease;
|
||||
min-height: 52px;
|
||||
|
|
|
|||
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>
|
||||
|
|
@ -1,28 +1,65 @@
|
|||
<template>
|
||||
<div class="recipes-view">
|
||||
|
||||
<!-- Tab bar: Find / Browse / Saved -->
|
||||
<div role="tablist" aria-label="Recipe sections" class="tab-bar flex gap-xs mb-md">
|
||||
<button
|
||||
v-for="tab in tabs"
|
||||
:key="tab.id"
|
||||
:id="`tab-${tab.id}`"
|
||||
role="tab"
|
||||
:aria-selected="activeTab === tab.id"
|
||||
:tabindex="activeTab === tab.id ? 0 : -1"
|
||||
:class="['btn', 'tab-btn', activeTab === tab.id ? 'btn-primary' : 'btn-secondary']"
|
||||
@click="activeTab = tab.id"
|
||||
@keydown="onTabKeydown"
|
||||
>{{ tab.label }}</button>
|
||||
</div>
|
||||
|
||||
<!-- Browse tab -->
|
||||
<RecipeBrowserPanel
|
||||
v-if="activeTab === 'browse'"
|
||||
role="tabpanel"
|
||||
aria-labelledby="tab-browse"
|
||||
@open-recipe="openRecipeById"
|
||||
/>
|
||||
|
||||
<!-- Saved tab -->
|
||||
<SavedRecipesPanel
|
||||
v-else-if="activeTab === 'saved'"
|
||||
role="tabpanel"
|
||||
aria-labelledby="tab-saved"
|
||||
@open-recipe="openRecipeById"
|
||||
/>
|
||||
|
||||
<!-- Find tab (existing search UI) -->
|
||||
<div v-else role="tabpanel" aria-labelledby="tab-find">
|
||||
<!-- Controls Panel -->
|
||||
<div class="card mb-controls">
|
||||
<h2 class="section-title text-xl mb-md">Find Recipes</h2>
|
||||
|
||||
<!-- Level Selector -->
|
||||
<div class="form-group">
|
||||
<label class="form-label">Creativity Level</label>
|
||||
<label class="form-label">How far should we stretch?</label>
|
||||
<div class="flex flex-wrap gap-sm">
|
||||
<button
|
||||
v-for="lvl in levels"
|
||||
:key="lvl.value"
|
||||
:class="['btn', 'btn-secondary', { active: recipesStore.level === lvl.value }]"
|
||||
@click="recipesStore.level = lvl.value"
|
||||
:title="lvl.description"
|
||||
>
|
||||
{{ lvl.label }}
|
||||
</button>
|
||||
</div>
|
||||
<p v-if="activeLevel" class="level-description text-sm text-secondary mt-xs">
|
||||
{{ activeLevel.description }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Wildcard warning -->
|
||||
<!-- Surprise Me confirmation -->
|
||||
<div v-if="recipesStore.level === 4" class="status-badge status-warning wildcard-warning">
|
||||
Wildcard mode uses LLM to generate creative recipes with whatever you have. Results may be
|
||||
unusual.
|
||||
The AI will freestyle recipes from whatever you have. Results can be unusual — that's part of the fun.
|
||||
<label class="flex-start gap-sm mt-xs">
|
||||
<input type="checkbox" v-model="recipesStore.wildcardConfirmed" />
|
||||
<span>I understand, go for it</span>
|
||||
|
|
@ -39,7 +76,7 @@
|
|||
class="tag-chip status-badge status-info"
|
||||
>
|
||||
{{ tag }}
|
||||
<button class="chip-remove" @click="removeConstraint(tag)" aria-label="Remove">×</button>
|
||||
<button class="chip-remove" @click="removeConstraint(tag)" :aria-label="'Remove constraint: ' + tag">×</button>
|
||||
</span>
|
||||
</div>
|
||||
<input
|
||||
|
|
@ -61,7 +98,7 @@
|
|||
class="tag-chip status-badge status-error"
|
||||
>
|
||||
{{ tag }}
|
||||
<button class="chip-remove" @click="removeAllergy(tag)" aria-label="Remove">×</button>
|
||||
<button class="chip-remove" @click="removeAllergy(tag)" :aria-label="'Remove allergy: ' + tag">×</button>
|
||||
</span>
|
||||
</div>
|
||||
<input
|
||||
|
|
@ -84,8 +121,19 @@
|
|||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Max Missing -->
|
||||
<!-- Shopping Mode -->
|
||||
<div class="form-group">
|
||||
<label class="flex-start gap-sm shopping-toggle">
|
||||
<input type="checkbox" v-model="recipesStore.shoppingMode" />
|
||||
<span class="form-label" style="margin-bottom: 0;">Open to buying missing ingredients</span>
|
||||
</label>
|
||||
<p v-if="recipesStore.shoppingMode" class="text-sm text-secondary mt-xs">
|
||||
All recipes shown regardless of missing ingredients. Affiliate links appear for anything you'd need to buy.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Max Missing — hidden in shopping mode (it's lifted automatically) -->
|
||||
<div v-if="!recipesStore.shoppingMode" class="form-group">
|
||||
<label class="form-label">Max Missing Ingredients (optional)</label>
|
||||
<input
|
||||
type="number"
|
||||
|
|
@ -210,6 +258,13 @@
|
|||
{{ recipesStore.error }}
|
||||
</div>
|
||||
|
||||
<!-- Screen reader announcement when results load -->
|
||||
<div aria-live="polite" aria-atomic="true" class="sr-only">
|
||||
<span v-if="recipesStore.result && !recipesStore.loading">
|
||||
{{ filteredSuggestions.length }} recipe{{ filteredSuggestions.length !== 1 ? 's' : '' }} found
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<!-- Results -->
|
||||
<div v-if="recipesStore.result" class="results-section fade-in">
|
||||
<!-- Rate limit warning -->
|
||||
|
|
@ -233,18 +288,55 @@
|
|||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Filter bar -->
|
||||
<div v-if="recipesStore.result.suggestions.length > 0" class="filter-bar mb-md">
|
||||
<input
|
||||
class="form-input filter-search"
|
||||
v-model="filterText"
|
||||
placeholder="Search recipes or ingredients…"
|
||||
aria-label="Filter recipes"
|
||||
/>
|
||||
<div class="filter-chips">
|
||||
<template v-if="availableLevels.length > 1">
|
||||
<button
|
||||
v-for="lvl in availableLevels"
|
||||
:key="lvl"
|
||||
:class="['filter-chip', { active: filterLevel === lvl }]"
|
||||
@click="filterLevel = filterLevel === lvl ? null : lvl"
|
||||
>Lv{{ lvl }}</button>
|
||||
</template>
|
||||
<button
|
||||
:class="['filter-chip', { active: filterMissing === 0 }]"
|
||||
@click="filterMissing = filterMissing === 0 ? null : 0"
|
||||
>Can make now</button>
|
||||
<button
|
||||
:class="['filter-chip', { active: filterMissing === 2 }]"
|
||||
@click="filterMissing = filterMissing === 2 ? null : 2"
|
||||
>≤2 missing</button>
|
||||
<button
|
||||
v-if="hasActiveFilters"
|
||||
class="filter-chip filter-chip-clear"
|
||||
@click="clearFilters"
|
||||
>✕ Clear</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- No suggestions -->
|
||||
<div
|
||||
v-if="recipesStore.result.suggestions.length === 0"
|
||||
v-if="filteredSuggestions.length === 0"
|
||||
class="card text-center text-muted"
|
||||
>
|
||||
<p>No recipes found for your current pantry and settings. Try lowering the creativity level or adding more items.</p>
|
||||
<template v-if="hasActiveFilters">
|
||||
<p>No recipes match your filters.</p>
|
||||
<button class="btn btn-ghost btn-sm mt-xs" @click="clearFilters">Clear filters</button>
|
||||
</template>
|
||||
<p v-else>No recipes found for your current pantry and settings. Try lowering the creativity level or adding more items.</p>
|
||||
</div>
|
||||
|
||||
<!-- Recipe Cards -->
|
||||
<div class="grid-auto mb-md">
|
||||
<div
|
||||
v-for="recipe in recipesStore.result.suggestions"
|
||||
v-for="recipe in filteredSuggestions"
|
||||
:key="recipe.id"
|
||||
class="card slide-up"
|
||||
>
|
||||
|
|
@ -255,12 +347,17 @@
|
|||
<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>
|
||||
<button
|
||||
v-if="recipe.id"
|
||||
:class="['btn-bookmark', { 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>
|
||||
<button
|
||||
v-if="recipe.id"
|
||||
class="btn-dismiss"
|
||||
@click="recipesStore.dismiss(recipe.id)"
|
||||
title="Hide this recipe"
|
||||
aria-label="Dismiss recipe"
|
||||
:aria-label="'Hide recipe: ' + recipe.title"
|
||||
>✕</button>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -268,6 +365,18 @@
|
|||
<!-- Notes -->
|
||||
<p v-if="recipe.notes" class="text-sm text-secondary mb-sm">{{ recipe.notes }}</p>
|
||||
|
||||
<!-- Matched ingredients (what you already have) -->
|
||||
<div v-if="recipe.matched_ingredients?.length > 0" class="ingredient-section mb-sm">
|
||||
<p class="text-sm font-semibold ingredient-label ingredient-label-have">From your pantry:</p>
|
||||
<div class="flex flex-wrap gap-xs mt-xs">
|
||||
<span
|
||||
v-for="ing in recipe.matched_ingredients"
|
||||
:key="ing"
|
||||
class="ingredient-chip ingredient-chip-have"
|
||||
>{{ ing }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Nutrition chips -->
|
||||
<div v-if="recipe.nutrition" class="nutrition-chips mb-sm">
|
||||
<span v-if="recipe.nutrition.calories != null" class="nutrition-chip">
|
||||
|
|
@ -358,17 +467,22 @@
|
|||
</ul>
|
||||
</div>
|
||||
|
||||
<!-- Directions collapsible -->
|
||||
<details v-if="recipe.directions.length > 0" class="collapsible">
|
||||
<summary class="text-sm font-semibold collapsible-summary">
|
||||
Directions ({{ recipe.directions.length }} steps)
|
||||
</summary>
|
||||
<!-- Directions — always visible; this is the content people came for -->
|
||||
<div v-if="recipe.directions.length > 0" class="directions-section">
|
||||
<p class="text-sm font-semibold directions-label">Steps</p>
|
||||
<ol class="directions-list mt-xs">
|
||||
<li v-for="(step, idx) in recipe.directions" :key="idx" class="text-sm direction-step">
|
||||
{{ step }}
|
||||
</li>
|
||||
</ol>
|
||||
</details>
|
||||
</div>
|
||||
|
||||
<!-- Primary action: open detail panel -->
|
||||
<div class="card-actions">
|
||||
<button class="btn btn-primary btn-make" @click="openRecipe(recipe)">
|
||||
Make this
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
|
@ -386,21 +500,17 @@
|
|||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Grocery list summary -->
|
||||
<div v-if="recipesStore.result.grocery_list.length > 0" class="card card-info">
|
||||
<h3 class="text-lg font-bold mb-sm">Shopping List</h3>
|
||||
<ul class="grocery-list">
|
||||
<li
|
||||
v-for="item in recipesStore.result.grocery_list"
|
||||
:key="item"
|
||||
class="text-sm grocery-item"
|
||||
>
|
||||
{{ item }}
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Recipe detail panel — mounts as a full-screen overlay -->
|
||||
<RecipeDetailPanel
|
||||
v-if="selectedRecipe"
|
||||
:recipe="selectedRecipe"
|
||||
:grocery-links="selectedGroceryLinks"
|
||||
@close="selectedRecipe = null"
|
||||
@cooked="(recipe) => { onCooked(recipe); selectedRecipe = null }"
|
||||
/>
|
||||
|
||||
<!-- Empty state when no results yet and pantry has items -->
|
||||
<div
|
||||
v-if="!recipesStore.result && !recipesStore.loading && pantryItems.length > 0"
|
||||
|
|
@ -413,6 +523,17 @@
|
|||
</svg>
|
||||
<p class="mt-xs">Tap "Suggest Recipes" to find recipes using your pantry items.</p>
|
||||
</div>
|
||||
|
||||
</div><!-- end Find tab -->
|
||||
|
||||
<!-- Detail panel for browser/saved recipe lookups -->
|
||||
<RecipeDetailPanel
|
||||
v-if="browserSelectedRecipe"
|
||||
:recipe="browserSelectedRecipe"
|
||||
:grocery-links="[]"
|
||||
@close="browserSelectedRecipe = null"
|
||||
@cooked="browserSelectedRecipe = null"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
|
|
@ -420,24 +541,120 @@
|
|||
import { ref, computed, onMounted } from 'vue'
|
||||
import { useRecipesStore } from '../stores/recipes'
|
||||
import { useInventoryStore } from '../stores/inventory'
|
||||
import RecipeDetailPanel from './RecipeDetailPanel.vue'
|
||||
import RecipeBrowserPanel from './RecipeBrowserPanel.vue'
|
||||
import SavedRecipesPanel from './SavedRecipesPanel.vue'
|
||||
import type { RecipeSuggestion, GroceryLink } from '../services/api'
|
||||
import { recipesAPI } from '../services/api'
|
||||
|
||||
const recipesStore = useRecipesStore()
|
||||
const inventoryStore = useInventoryStore()
|
||||
|
||||
// Tab state
|
||||
type TabId = 'find' | 'browse' | 'saved'
|
||||
const tabs: Array<{ id: TabId; label: string }> = [
|
||||
{ id: 'find', label: 'Find' },
|
||||
{ id: 'browse', label: 'Browse' },
|
||||
{ id: 'saved', label: 'Saved' },
|
||||
]
|
||||
const activeTab = ref<TabId>('find')
|
||||
|
||||
function onTabKeydown(e: KeyboardEvent) {
|
||||
const tabIds: TabId[] = ['find', 'browse', 'saved']
|
||||
const current = tabIds.indexOf(activeTab.value)
|
||||
if (e.key === 'ArrowRight') {
|
||||
e.preventDefault()
|
||||
activeTab.value = tabIds[(current + 1) % tabIds.length]!
|
||||
} else if (e.key === 'ArrowLeft') {
|
||||
e.preventDefault()
|
||||
activeTab.value = tabIds[(current - 1 + tabIds.length) % tabIds.length]!
|
||||
}
|
||||
}
|
||||
|
||||
// Browser/saved tab recipe detail panel (fetches full recipe from API)
|
||||
const browserSelectedRecipe = ref<RecipeSuggestion | null>(null)
|
||||
|
||||
async function openRecipeById(recipeId: number) {
|
||||
try {
|
||||
browserSelectedRecipe.value = await recipesAPI.getRecipe(recipeId)
|
||||
} catch {
|
||||
// silently ignore — recipe may not exist
|
||||
}
|
||||
}
|
||||
|
||||
// Local input state for tags
|
||||
const constraintInput = ref('')
|
||||
const allergyInput = ref('')
|
||||
const categoryInput = ref('')
|
||||
const isLoadingMore = ref(false)
|
||||
|
||||
// Recipe detail panel (Find tab)
|
||||
const selectedRecipe = ref<RecipeSuggestion | null>(null)
|
||||
|
||||
// Filter state (#21)
|
||||
const filterText = ref('')
|
||||
const filterLevel = ref<number | null>(null)
|
||||
const filterMissing = ref<number | null>(null)
|
||||
|
||||
const availableLevels = computed(() => {
|
||||
if (!recipesStore.result) return []
|
||||
return [...new Set(recipesStore.result.suggestions.map((r) => r.level))].sort()
|
||||
})
|
||||
|
||||
const filteredSuggestions = computed(() => {
|
||||
if (!recipesStore.result) return []
|
||||
let items = recipesStore.result.suggestions
|
||||
const q = filterText.value.trim().toLowerCase()
|
||||
if (q) {
|
||||
items = items.filter((r) =>
|
||||
r.title.toLowerCase().includes(q) ||
|
||||
r.matched_ingredients.some((i) => i.toLowerCase().includes(q)) ||
|
||||
r.missing_ingredients.some((i) => i.toLowerCase().includes(q))
|
||||
)
|
||||
}
|
||||
if (filterLevel.value !== null) {
|
||||
items = items.filter((r) => r.level === filterLevel.value)
|
||||
}
|
||||
if (filterMissing.value !== null) {
|
||||
items = items.filter((r) => r.missing_ingredients.length <= filterMissing.value!)
|
||||
}
|
||||
return items
|
||||
})
|
||||
|
||||
const hasActiveFilters = computed(
|
||||
() => filterText.value.trim() !== '' || filterLevel.value !== null || filterMissing.value !== null
|
||||
)
|
||||
|
||||
function clearFilters() {
|
||||
filterText.value = ''
|
||||
filterLevel.value = null
|
||||
filterMissing.value = null
|
||||
}
|
||||
|
||||
const selectedGroceryLinks = computed<GroceryLink[]>(() => {
|
||||
if (!selectedRecipe.value || !recipesStore.result) return []
|
||||
const missing = new Set(selectedRecipe.value.missing_ingredients.map((s) => s.toLowerCase()))
|
||||
return recipesStore.result.grocery_links.filter((l) => missing.has(l.ingredient.toLowerCase()))
|
||||
})
|
||||
|
||||
function openRecipe(recipe: RecipeSuggestion) {
|
||||
selectedRecipe.value = recipe
|
||||
}
|
||||
|
||||
function onCooked(recipe: RecipeSuggestion) {
|
||||
recipesStore.logCook(recipe.id, recipe.title)
|
||||
recipesStore.dismiss(recipe.id)
|
||||
}
|
||||
|
||||
const levels = [
|
||||
{ value: 1, label: '1 — From Pantry' },
|
||||
{ value: 2, label: '2 — Creative Swaps' },
|
||||
{ value: 3, label: '3 — AI Scaffold' },
|
||||
{ value: 4, label: '4 — Wildcard 🎲' },
|
||||
{ value: 1, label: 'Use What I Have', description: 'Finds recipes you can make right now using exactly what\'s in your pantry.' },
|
||||
{ value: 2, label: 'Allow Swaps', description: 'Same as above, plus recipes where one or two ingredients can be substituted.' },
|
||||
{ value: 3, label: 'Get Creative', description: 'AI builds recipes in your chosen cuisine style from what you have. Requires paid tier.' },
|
||||
{ value: 4, label: 'Surprise Me 🎲', description: 'Fully AI-generated — open-ended and occasionally unexpected. Requires paid tier.' },
|
||||
]
|
||||
|
||||
const activeLevel = computed(() => levels.find(l => l.value === recipesStore.level))
|
||||
|
||||
const cuisineStyles = [
|
||||
{ id: 'italian', label: 'Italian' },
|
||||
{ id: 'mediterranean', label: 'Mediterranean' },
|
||||
|
|
@ -554,6 +771,16 @@ onMounted(async () => {
|
|||
</script>
|
||||
|
||||
<style scoped>
|
||||
.tab-bar {
|
||||
border-bottom: 1px solid var(--color-border);
|
||||
padding-bottom: var(--spacing-sm);
|
||||
}
|
||||
|
||||
.tab-btn {
|
||||
border-radius: var(--radius-md) var(--radius-md) 0 0;
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.mb-controls {
|
||||
margin-bottom: var(--spacing-md);
|
||||
}
|
||||
|
|
@ -574,6 +801,11 @@ onMounted(async () => {
|
|||
margin-left: var(--spacing-xs);
|
||||
}
|
||||
|
||||
.level-description {
|
||||
font-style: italic;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.wildcard-warning {
|
||||
display: block;
|
||||
margin-bottom: var(--spacing-md);
|
||||
|
|
@ -585,6 +817,11 @@ onMounted(async () => {
|
|||
user-select: none;
|
||||
}
|
||||
|
||||
.shopping-toggle {
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.tag-chip {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
|
|
@ -643,6 +880,116 @@ onMounted(async () => {
|
|||
transform: none;
|
||||
}
|
||||
|
||||
.btn-bookmark {
|
||||
background: transparent;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
padding: 2px 6px;
|
||||
font-size: 14px;
|
||||
line-height: 1;
|
||||
color: var(--color-text-muted);
|
||||
border-radius: 4px;
|
||||
transition: color 0.15s, background 0.15s;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.btn-bookmark:hover,
|
||||
.btn-bookmark.active {
|
||||
color: var(--color-warning, #ca8a04);
|
||||
background: var(--color-warning-bg, #fef9c3);
|
||||
transform: none;
|
||||
}
|
||||
|
||||
/* Saved recipes section */
|
||||
.saved-header {
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.saved-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.saved-row {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
gap: var(--spacing-sm);
|
||||
padding: var(--spacing-xs) 0;
|
||||
border-bottom: 1px solid var(--color-border);
|
||||
}
|
||||
|
||||
.saved-row:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.saved-title {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
color: var(--color-primary);
|
||||
}
|
||||
|
||||
.saved-title:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
/* Filter bar */
|
||||
.filter-bar {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-xs);
|
||||
}
|
||||
|
||||
.filter-search {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.filter-chips {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: var(--spacing-xs);
|
||||
}
|
||||
|
||||
.filter-chip {
|
||||
background: var(--color-bg-secondary, #f5f5f5);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: 16px;
|
||||
padding: 2px 10px;
|
||||
font-size: var(--font-size-xs);
|
||||
cursor: pointer;
|
||||
color: var(--color-text-secondary);
|
||||
transition: background 0.15s, color 0.15s, border-color 0.15s;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.filter-chip:hover {
|
||||
border-color: var(--color-primary);
|
||||
color: var(--color-primary);
|
||||
background: var(--color-bg-secondary);
|
||||
transform: none;
|
||||
}
|
||||
|
||||
.filter-chip.active {
|
||||
background: var(--color-primary);
|
||||
border-color: var(--color-primary);
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.filter-chip-clear {
|
||||
border-color: var(--color-error, #dc2626);
|
||||
color: var(--color-error, #dc2626);
|
||||
}
|
||||
|
||||
.filter-chip-clear:hover {
|
||||
background: var(--color-error-bg, #fee2e2);
|
||||
border-color: var(--color-error, #dc2626);
|
||||
color: var(--color-error, #dc2626);
|
||||
}
|
||||
|
||||
.suggest-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
|
@ -721,13 +1068,54 @@ details[open] .collapsible-summary::before {
|
|||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
.ingredient-section {
|
||||
border-top: 1px solid var(--color-border);
|
||||
padding-top: var(--spacing-sm);
|
||||
}
|
||||
|
||||
.ingredient-label {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.ingredient-label-have {
|
||||
color: var(--color-success, #16a34a);
|
||||
}
|
||||
|
||||
.ingredient-chip {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
padding: 2px 8px;
|
||||
border-radius: 12px;
|
||||
font-size: var(--font-size-xs);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.ingredient-chip-have {
|
||||
background: var(--color-success-bg, #dcfce7);
|
||||
color: var(--color-success, #16a34a);
|
||||
}
|
||||
|
||||
.directions-section {
|
||||
border-top: 1px solid var(--color-border);
|
||||
padding-top: var(--spacing-sm);
|
||||
margin-top: var(--spacing-xs);
|
||||
}
|
||||
|
||||
.directions-label {
|
||||
color: var(--color-text-secondary);
|
||||
text-transform: uppercase;
|
||||
font-size: var(--font-size-xs);
|
||||
letter-spacing: 0.05em;
|
||||
margin-bottom: var(--spacing-xs);
|
||||
}
|
||||
|
||||
.directions-list {
|
||||
padding-left: var(--spacing-lg);
|
||||
}
|
||||
|
||||
.direction-step {
|
||||
margin-bottom: var(--spacing-xs);
|
||||
line-height: 1.5;
|
||||
margin-bottom: var(--spacing-sm);
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.grocery-link {
|
||||
|
|
@ -740,12 +1128,17 @@ details[open] .collapsible-summary::before {
|
|||
opacity: 0.8;
|
||||
}
|
||||
|
||||
.grocery-list {
|
||||
padding-left: var(--spacing-lg);
|
||||
.card-actions {
|
||||
border-top: 1px solid var(--color-border);
|
||||
padding-top: var(--spacing-sm);
|
||||
margin-top: var(--spacing-sm);
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.grocery-item {
|
||||
margin-bottom: var(--spacing-xs);
|
||||
.btn-make {
|
||||
font-size: var(--font-size-sm);
|
||||
padding: var(--spacing-xs) var(--spacing-md);
|
||||
}
|
||||
|
||||
.results-section {
|
||||
|
|
|
|||
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>
|
||||
|
|
@ -64,14 +64,114 @@
|
|||
</div>
|
||||
</section>
|
||||
</div>
|
||||
<div class="card mt-md">
|
||||
<h2 class="section-title text-xl mb-md">Cook History</h2>
|
||||
<p v-if="recipesStore.cookLog.length === 0" class="text-sm text-muted">
|
||||
No recipes cooked yet. Tap "I cooked this" on any recipe to log it.
|
||||
</p>
|
||||
<template v-else>
|
||||
<div class="log-list">
|
||||
<div
|
||||
v-for="entry in sortedCookLog"
|
||||
:key="entry.cookedAt"
|
||||
class="log-entry"
|
||||
>
|
||||
<span class="log-title text-sm">{{ entry.title }}</span>
|
||||
<span class="log-date text-xs text-muted">{{ formatCookDate(entry.cookedAt) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<button class="btn btn-ghost btn-sm mt-sm" @click="recipesStore.clearCookLog()">
|
||||
Clear history
|
||||
</button>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<!-- Household (Premium) -->
|
||||
<div v-if="householdVisible" class="card mt-md">
|
||||
<h2 class="section-title text-xl mb-md">Household</h2>
|
||||
|
||||
<!-- Loading -->
|
||||
<p v-if="householdLoading" class="text-sm text-muted">Loading…</p>
|
||||
|
||||
<!-- Error -->
|
||||
<p v-if="householdError" class="text-sm status-badge status-error">{{ householdError }}</p>
|
||||
|
||||
<!-- Not in a household -->
|
||||
<template v-else-if="!householdStatus?.in_household">
|
||||
<p class="text-sm text-secondary mb-md">
|
||||
Create a household to share your pantry with family or housemates.
|
||||
All members see and edit the same inventory.
|
||||
</p>
|
||||
<button class="btn btn-primary" :disabled="householdLoading" @click="handleCreateHousehold">
|
||||
Create Household
|
||||
</button>
|
||||
</template>
|
||||
|
||||
<!-- In household -->
|
||||
<template v-else>
|
||||
<p class="text-sm text-muted mb-sm">
|
||||
Household ID: <code class="household-id">{{ householdStatus.household_id }}</code>
|
||||
</p>
|
||||
|
||||
<!-- Owner: member list + invite -->
|
||||
<template v-if="householdStatus.is_owner">
|
||||
<h3 class="text-base font-semibold mb-xs">Members ({{ householdStatus.members.length }}/{{ householdStatus.max_seats }})</h3>
|
||||
<div class="member-list mb-md">
|
||||
<div v-for="m in householdStatus.members" :key="m.user_id" class="member-row">
|
||||
<span class="text-sm member-id">{{ m.user_id }}</span>
|
||||
<span v-if="m.is_owner" class="status-badge status-info text-xs">Owner</span>
|
||||
<button
|
||||
v-else
|
||||
class="btn btn-ghost btn-sm"
|
||||
@click="handleRemoveMember(m.user_id)"
|
||||
>Remove</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Invite link -->
|
||||
<div v-if="inviteUrl" class="invite-row mb-sm">
|
||||
<input class="form-input invite-input" :value="inviteUrl" readonly />
|
||||
<button class="btn btn-secondary btn-sm" @click="copyInvite">
|
||||
{{ inviteCopied ? '✓ Copied' : 'Copy' }}
|
||||
</button>
|
||||
</div>
|
||||
<button
|
||||
class="btn btn-primary btn-sm"
|
||||
:disabled="householdLoading"
|
||||
@click="handleInvite"
|
||||
>{{ inviteUrl ? 'New invite link' : 'Generate invite link' }}</button>
|
||||
</template>
|
||||
|
||||
<!-- Member: leave button -->
|
||||
<template v-else>
|
||||
<p class="text-sm text-secondary mb-md">
|
||||
You are a member of this shared household pantry.
|
||||
</p>
|
||||
<button class="btn btn-ghost btn-sm" @click="handleLeave">Leave Household</button>
|
||||
</template>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { ref, computed, onMounted } from 'vue'
|
||||
import { useSettingsStore } from '../stores/settings'
|
||||
import { useRecipesStore } from '../stores/recipes'
|
||||
import { householdAPI, type HouseholdStatus } from '../services/api'
|
||||
|
||||
const settingsStore = useSettingsStore()
|
||||
const recipesStore = useRecipesStore()
|
||||
|
||||
const sortedCookLog = computed(() =>
|
||||
[...recipesStore.cookLog].sort((a, b) => b.cookedAt - a.cookedAt)
|
||||
)
|
||||
|
||||
function formatCookDate(ms: number): string {
|
||||
return new Date(ms).toLocaleString(undefined, {
|
||||
month: 'short', day: 'numeric', hour: 'numeric', minute: '2-digit',
|
||||
})
|
||||
}
|
||||
|
||||
const equipmentInput = ref('')
|
||||
|
||||
|
|
@ -119,8 +219,93 @@ function commitEquipmentInput() {
|
|||
}
|
||||
}
|
||||
|
||||
// Household (#5)
|
||||
const householdVisible = ref(false)
|
||||
const householdLoading = ref(false)
|
||||
const householdError = ref<string | null>(null)
|
||||
const householdStatus = ref<HouseholdStatus | null>(null)
|
||||
const inviteUrl = ref<string | null>(null)
|
||||
const inviteCopied = ref(false)
|
||||
|
||||
async function loadHouseholdStatus() {
|
||||
householdLoading.value = true
|
||||
householdError.value = null
|
||||
try {
|
||||
householdStatus.value = await householdAPI.status()
|
||||
householdVisible.value = true
|
||||
} catch (err: unknown) {
|
||||
// 403 = not premium — hide the card silently
|
||||
const status = (err as any)?.response?.status
|
||||
if (status !== 403) {
|
||||
householdError.value = 'Could not load household status.'
|
||||
householdVisible.value = true
|
||||
}
|
||||
} finally {
|
||||
householdLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function handleCreateHousehold() {
|
||||
householdLoading.value = true
|
||||
try {
|
||||
await householdAPI.create()
|
||||
await loadHouseholdStatus()
|
||||
} catch {
|
||||
householdError.value = 'Could not create household. Please try again.'
|
||||
} finally {
|
||||
householdLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function handleInvite() {
|
||||
householdLoading.value = true
|
||||
try {
|
||||
const result = await householdAPI.invite()
|
||||
inviteUrl.value = result.invite_url
|
||||
} catch {
|
||||
householdError.value = 'Could not generate invite link.'
|
||||
} finally {
|
||||
householdLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function copyInvite() {
|
||||
if (!inviteUrl.value) return
|
||||
await navigator.clipboard.writeText(inviteUrl.value)
|
||||
inviteCopied.value = true
|
||||
setTimeout(() => { inviteCopied.value = false }, 2000)
|
||||
}
|
||||
|
||||
async function handleLeave() {
|
||||
if (!confirm('Leave the household? You will return to your personal pantry.')) return
|
||||
householdLoading.value = true
|
||||
try {
|
||||
await householdAPI.leave()
|
||||
await loadHouseholdStatus()
|
||||
inviteUrl.value = null
|
||||
} catch {
|
||||
householdError.value = 'Could not leave household. Please try again.'
|
||||
} finally {
|
||||
householdLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function handleRemoveMember(userId: string) {
|
||||
if (!confirm(`Remove member ${userId}?`)) return
|
||||
householdLoading.value = true
|
||||
try {
|
||||
await householdAPI.removeMember(userId)
|
||||
await loadHouseholdStatus()
|
||||
} catch {
|
||||
householdError.value = 'Could not remove member.'
|
||||
} finally {
|
||||
householdLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
await settingsStore.load()
|
||||
await loadHouseholdStatus()
|
||||
})
|
||||
</script>
|
||||
|
||||
|
|
@ -159,4 +344,110 @@ onMounted(async () => {
|
|||
opacity: 1;
|
||||
transform: none;
|
||||
}
|
||||
|
||||
.mt-md {
|
||||
margin-top: var(--spacing-md);
|
||||
}
|
||||
|
||||
.mt-sm {
|
||||
margin-top: var(--spacing-sm);
|
||||
}
|
||||
|
||||
.log-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.log-entry {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: baseline;
|
||||
gap: var(--spacing-sm);
|
||||
padding: var(--spacing-xs) 0;
|
||||
border-bottom: 1px solid var(--color-border);
|
||||
}
|
||||
|
||||
.log-entry:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.log-title {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.log-date {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.btn-ghost {
|
||||
background: transparent;
|
||||
border: none;
|
||||
color: var(--color-text-muted);
|
||||
font-size: var(--font-size-sm);
|
||||
cursor: pointer;
|
||||
padding: var(--spacing-xs) var(--spacing-sm);
|
||||
}
|
||||
|
||||
.btn-ghost:hover {
|
||||
color: var(--color-error, #dc2626);
|
||||
background: transparent;
|
||||
transform: none;
|
||||
}
|
||||
|
||||
.btn-sm {
|
||||
padding: var(--spacing-xs) var(--spacing-sm);
|
||||
font-size: var(--font-size-sm);
|
||||
}
|
||||
|
||||
.household-id {
|
||||
font-size: var(--font-size-xs);
|
||||
background: var(--color-bg-secondary);
|
||||
padding: 2px 6px;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.member-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.member-row {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: var(--spacing-xs) 0;
|
||||
border-bottom: 1px solid var(--color-border);
|
||||
gap: var(--spacing-sm);
|
||||
}
|
||||
|
||||
.member-row:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.member-id {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
.invite-row {
|
||||
display: flex;
|
||||
gap: var(--spacing-xs);
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.invite-input {
|
||||
flex: 1;
|
||||
font-size: var(--font-size-xs);
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
</style>
|
||||
|
|
|
|||
|
|
@ -266,6 +266,20 @@ export const inventoryAPI = {
|
|||
return response.data
|
||||
},
|
||||
|
||||
/**
|
||||
* Bulk-add items by ingredient name (no barcode required).
|
||||
* Idempotent: re-adding an existing product just creates a new inventory entry.
|
||||
*/
|
||||
async bulkAddByName(items: Array<{
|
||||
name: string
|
||||
quantity?: number
|
||||
unit?: string
|
||||
location?: string
|
||||
}>): Promise<{ added: number; failed: number; results: Array<{ name: string; ok: boolean; item_id?: number; error?: string }> }> {
|
||||
const response = await api.post('/inventory/items/bulk-add-by-name', { items })
|
||||
return response.data
|
||||
},
|
||||
|
||||
/**
|
||||
* Scan barcode from image
|
||||
*/
|
||||
|
|
@ -433,6 +447,7 @@ export interface RecipeSuggestion {
|
|||
match_count: number
|
||||
element_coverage: Record<string, number>
|
||||
swap_candidates: SwapCandidate[]
|
||||
matched_ingredients: string[]
|
||||
missing_ingredients: string[]
|
||||
directions: string[]
|
||||
prep_notes: string[]
|
||||
|
|
@ -440,6 +455,7 @@ export interface RecipeSuggestion {
|
|||
level: number
|
||||
is_wildcard: boolean
|
||||
nutrition: NutritionPanel | null
|
||||
source_url: string | null
|
||||
}
|
||||
|
||||
export interface NutritionFilters {
|
||||
|
|
@ -477,6 +493,7 @@ export interface RecipeRequest {
|
|||
wildcard_confirmed: boolean
|
||||
nutrition_filters: NutritionFilters
|
||||
excluded_ids: number[]
|
||||
shopping_mode: boolean
|
||||
}
|
||||
|
||||
export interface Staple {
|
||||
|
|
@ -519,4 +536,159 @@ export const settingsAPI = {
|
|||
},
|
||||
}
|
||||
|
||||
// ========== Household Types ==========
|
||||
|
||||
export interface HouseholdMember {
|
||||
user_id: string
|
||||
joined_at: string
|
||||
is_owner: boolean
|
||||
}
|
||||
|
||||
export interface HouseholdStatus {
|
||||
in_household: boolean
|
||||
household_id: string | null
|
||||
is_owner: boolean
|
||||
members: HouseholdMember[]
|
||||
max_seats: number
|
||||
}
|
||||
|
||||
export interface HouseholdInvite {
|
||||
invite_url: string
|
||||
token: string
|
||||
expires_at: string
|
||||
}
|
||||
|
||||
// ========== Household API ==========
|
||||
|
||||
export const householdAPI = {
|
||||
async create(): Promise<{ household_id: string; message: string }> {
|
||||
const response = await api.post('/household/create')
|
||||
return response.data
|
||||
},
|
||||
async status(): Promise<HouseholdStatus> {
|
||||
const response = await api.get('/household/status')
|
||||
return response.data
|
||||
},
|
||||
async invite(): Promise<HouseholdInvite> {
|
||||
const response = await api.post('/household/invite')
|
||||
return response.data
|
||||
},
|
||||
async accept(householdId: string, token: string): Promise<{ message: string; household_id: string }> {
|
||||
const response = await api.post('/household/accept', { household_id: householdId, token })
|
||||
return response.data
|
||||
},
|
||||
async leave(): Promise<{ message: string }> {
|
||||
const response = await api.post('/household/leave')
|
||||
return response.data
|
||||
},
|
||||
async removeMember(userId: string): Promise<{ message: string }> {
|
||||
const response = await api.post('/household/remove-member', { user_id: userId })
|
||||
return response.data
|
||||
},
|
||||
}
|
||||
|
||||
// ========== 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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -5,6 +5,37 @@
|
|||
* Components should use these classes instead of custom styles where possible.
|
||||
*/
|
||||
|
||||
/* ============================================
|
||||
ACCESSIBILITY UTILITIES
|
||||
============================================ */
|
||||
|
||||
/* Visually hidden but announced by screen readers */
|
||||
.sr-only {
|
||||
position: absolute;
|
||||
width: 1px;
|
||||
height: 1px;
|
||||
padding: 0;
|
||||
margin: -1px;
|
||||
overflow: hidden;
|
||||
clip: rect(0, 0, 0, 0);
|
||||
white-space: nowrap;
|
||||
border: 0;
|
||||
}
|
||||
|
||||
/* Keyboard focus ring — shown only for keyboard navigation, not mouse/touch */
|
||||
:focus-visible {
|
||||
outline: 2px solid var(--color-primary);
|
||||
outline-offset: 2px;
|
||||
border-radius: var(--radius-sm);
|
||||
}
|
||||
|
||||
/* Form inputs use a different focus treatment (border + shadow); suppress the ring */
|
||||
.form-input:focus-visible,
|
||||
.form-select:focus-visible,
|
||||
.form-textarea:focus-visible {
|
||||
outline: none;
|
||||
}
|
||||
|
||||
/* ============================================
|
||||
LAYOUT UTILITIES - RESPONSIVE GRIDS
|
||||
============================================ */
|
||||
|
|
@ -639,14 +670,20 @@
|
|||
}
|
||||
}
|
||||
|
||||
/* Urgency pulse — for items expiring very soon */
|
||||
@keyframes urgencyPulse {
|
||||
0%, 100% { opacity: 1; }
|
||||
50% { opacity: 0.6; }
|
||||
}
|
||||
|
||||
.pulse-urgent {
|
||||
animation: urgencyPulse 1.8s ease-in-out infinite;
|
||||
/* ============================================
|
||||
REDUCED MOTION — global guard (WCAG 2.3.3)
|
||||
All animations/transitions are suppressed when
|
||||
the user has requested reduced motion. This
|
||||
single rule covers every animation in this file.
|
||||
Do NOT add urgency/pulse animations to Kiwi —
|
||||
see design policy in circuitforge-plans.
|
||||
============================================ */
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
*, *::before, *::after {
|
||||
animation-duration: 0.01ms !important;
|
||||
animation-iteration-count: 1 !important;
|
||||
transition-duration: 0.01ms !important;
|
||||
}
|
||||
}
|
||||
|
||||
/* ============================================
|
||||
|
|
|
|||
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
|
||||
;;
|
||||
|
||||
|
|
|
|||
|
|
@ -23,7 +23,7 @@ dependencies = [
|
|||
"httpx>=0.27",
|
||||
"requests>=2.31",
|
||||
# CircuitForge shared scaffold
|
||||
"circuitforge-core>=0.6.0",
|
||||
"circuitforge-core>=0.8.0",
|
||||
]
|
||||
|
||||
[tool.setuptools.packages.find]
|
||||
|
|
|
|||
|
|
@ -1,21 +1,34 @@
|
|||
"""Tests for the /feedback endpoints."""
|
||||
"""Tests for the shared feedback router (circuitforge-core) mounted in kiwi."""
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Callable
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
from fastapi import FastAPI
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from app.main import app
|
||||
|
||||
client = TestClient(app)
|
||||
from circuitforge_core.api.feedback import make_feedback_router
|
||||
|
||||
|
||||
# ── /feedback/status ──────────────────────────────────────────────────────────
|
||||
# ── Test app factory ──────────────────────────────────────────────────────────
|
||||
|
||||
def _make_client(demo_mode_fn: Callable[[], bool] | None = None) -> TestClient:
|
||||
app = FastAPI()
|
||||
router = make_feedback_router(
|
||||
repo="Circuit-Forge/kiwi",
|
||||
product="kiwi",
|
||||
demo_mode_fn=demo_mode_fn,
|
||||
)
|
||||
app.include_router(router, prefix="/api/v1/feedback")
|
||||
return TestClient(app)
|
||||
|
||||
|
||||
# ── /api/v1/feedback/status ───────────────────────────────────────────────────
|
||||
|
||||
def test_status_disabled_when_no_token(monkeypatch):
|
||||
monkeypatch.delenv("FORGEJO_API_TOKEN", raising=False)
|
||||
monkeypatch.setattr("app.core.config.settings.DEMO_MODE", False)
|
||||
monkeypatch.delenv("DEMO_MODE", raising=False)
|
||||
client = _make_client(demo_mode_fn=lambda: False)
|
||||
res = client.get("/api/v1/feedback/status")
|
||||
assert res.status_code == 200
|
||||
assert res.json() == {"enabled": False}
|
||||
|
|
@ -23,7 +36,7 @@ def test_status_disabled_when_no_token(monkeypatch):
|
|||
|
||||
def test_status_enabled_when_token_set(monkeypatch):
|
||||
monkeypatch.setenv("FORGEJO_API_TOKEN", "test-token")
|
||||
monkeypatch.setattr("app.core.config.settings.DEMO_MODE", False)
|
||||
client = _make_client(demo_mode_fn=lambda: False)
|
||||
res = client.get("/api/v1/feedback/status")
|
||||
assert res.status_code == 200
|
||||
assert res.json() == {"enabled": True}
|
||||
|
|
@ -31,16 +44,18 @@ def test_status_enabled_when_token_set(monkeypatch):
|
|||
|
||||
def test_status_disabled_in_demo_mode(monkeypatch):
|
||||
monkeypatch.setenv("FORGEJO_API_TOKEN", "test-token")
|
||||
monkeypatch.setattr("app.core.config.settings.DEMO_MODE", True)
|
||||
demo = True
|
||||
client = _make_client(demo_mode_fn=lambda: demo)
|
||||
res = client.get("/api/v1/feedback/status")
|
||||
assert res.status_code == 200
|
||||
assert res.json() == {"enabled": False}
|
||||
|
||||
|
||||
# ── POST /feedback ────────────────────────────────────────────────────────────
|
||||
# ── POST /api/v1/feedback ─────────────────────────────────────────────────────
|
||||
|
||||
def test_submit_returns_503_when_no_token(monkeypatch):
|
||||
monkeypatch.delenv("FORGEJO_API_TOKEN", raising=False)
|
||||
client = _make_client(demo_mode_fn=lambda: False)
|
||||
res = client.post("/api/v1/feedback", json={
|
||||
"title": "Test", "description": "desc", "type": "bug",
|
||||
})
|
||||
|
|
@ -49,8 +64,13 @@ def test_submit_returns_503_when_no_token(monkeypatch):
|
|||
|
||||
def test_submit_returns_403_in_demo_mode(monkeypatch):
|
||||
monkeypatch.setenv("FORGEJO_API_TOKEN", "test-token")
|
||||
monkeypatch.setattr("app.core.config.settings.DEMO_MODE", True)
|
||||
res = client.post("/api/v1/feedback", json={
|
||||
demo = False
|
||||
client = _make_client(demo_mode_fn=lambda: demo)
|
||||
|
||||
# Confirm non-demo path isn't 403 (sanity), then flip demo flag
|
||||
demo = True
|
||||
client2 = _make_client(demo_mode_fn=lambda: demo)
|
||||
res = client2.post("/api/v1/feedback", json={
|
||||
"title": "Test", "description": "desc", "type": "bug",
|
||||
})
|
||||
assert res.status_code == 403
|
||||
|
|
@ -58,10 +78,7 @@ def test_submit_returns_403_in_demo_mode(monkeypatch):
|
|||
|
||||
def test_submit_creates_issue(monkeypatch):
|
||||
monkeypatch.setenv("FORGEJO_API_TOKEN", "test-token")
|
||||
monkeypatch.setenv("FORGEJO_REPO", "Circuit-Forge/kiwi")
|
||||
monkeypatch.setattr("app.core.config.settings.DEMO_MODE", False)
|
||||
|
||||
# Mock the two Forgejo HTTP calls: label fetch + issue create
|
||||
label_response = MagicMock()
|
||||
label_response.ok = True
|
||||
label_response.json.return_value = [
|
||||
|
|
@ -72,10 +89,15 @@ def test_submit_creates_issue(monkeypatch):
|
|||
|
||||
issue_response = MagicMock()
|
||||
issue_response.ok = True
|
||||
issue_response.json.return_value = {"number": 42, "html_url": "https://example.com/issues/42"}
|
||||
issue_response.json.return_value = {
|
||||
"number": 42,
|
||||
"html_url": "https://example.com/issues/42",
|
||||
}
|
||||
|
||||
with patch("app.api.endpoints.feedback.requests.get", return_value=label_response), \
|
||||
patch("app.api.endpoints.feedback.requests.post", return_value=issue_response):
|
||||
client = _make_client(demo_mode_fn=lambda: False)
|
||||
|
||||
with patch("circuitforge_core.api.feedback.requests.get", return_value=label_response), \
|
||||
patch("circuitforge_core.api.feedback.requests.post", return_value=issue_response):
|
||||
res = client.post("/api/v1/feedback", json={
|
||||
"title": "Something broke",
|
||||
"description": "It broke when I tapped X",
|
||||
|
|
@ -92,18 +114,23 @@ def test_submit_creates_issue(monkeypatch):
|
|||
|
||||
def test_submit_returns_502_on_forgejo_error(monkeypatch):
|
||||
monkeypatch.setenv("FORGEJO_API_TOKEN", "test-token")
|
||||
monkeypatch.setattr("app.core.config.settings.DEMO_MODE", False)
|
||||
|
||||
label_response = MagicMock()
|
||||
label_response.ok = True
|
||||
label_response.json.return_value = []
|
||||
label_response.json.return_value = [
|
||||
{"id": 1, "name": "beta-feedback"},
|
||||
{"id": 2, "name": "needs-triage"},
|
||||
{"id": 3, "name": "question"},
|
||||
]
|
||||
|
||||
bad_response = MagicMock()
|
||||
bad_response.ok = False
|
||||
bad_response.text = "forbidden"
|
||||
|
||||
with patch("app.api.endpoints.feedback.requests.get", return_value=label_response), \
|
||||
patch("app.api.endpoints.feedback.requests.post", return_value=bad_response):
|
||||
client = _make_client(demo_mode_fn=lambda: False)
|
||||
|
||||
with patch("circuitforge_core.api.feedback.requests.get", return_value=label_response), \
|
||||
patch("circuitforge_core.api.feedback.requests.post", return_value=bad_response):
|
||||
res = client.post("/api/v1/feedback", json={
|
||||
"title": "Oops", "description": "desc", "type": "other",
|
||||
})
|
||||
|
|
|
|||
112
tests/test_household.py
Normal file
112
tests/test_household.py
Normal file
|
|
@ -0,0 +1,112 @@
|
|||
"""Tests for household session resolution in cloud_session.py."""
|
||||
import os
|
||||
from pathlib import Path
|
||||
from unittest.mock import patch, MagicMock
|
||||
import pytest
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
os.environ.setdefault("CLOUD_MODE", "false")
|
||||
|
||||
import app.cloud_session as cs
|
||||
from app.cloud_session import (
|
||||
CloudUser,
|
||||
_user_db_path,
|
||||
)
|
||||
|
||||
|
||||
def test_clouduser_has_household_fields():
|
||||
u = CloudUser(
|
||||
user_id="u1", tier="premium", db=Path("/tmp/u1.db"),
|
||||
has_byok=False, household_id="hh-1", is_household_owner=True
|
||||
)
|
||||
assert u.household_id == "hh-1"
|
||||
assert u.is_household_owner is True
|
||||
|
||||
|
||||
def test_clouduser_household_defaults_none():
|
||||
u = CloudUser(user_id="u1", tier="free", db=Path("/tmp/u1.db"), has_byok=False)
|
||||
assert u.household_id is None
|
||||
assert u.is_household_owner is False
|
||||
|
||||
|
||||
def test_user_db_path_personal(tmp_path, monkeypatch):
|
||||
monkeypatch.setattr(cs, "CLOUD_DATA_ROOT", tmp_path)
|
||||
result = cs._user_db_path("abc123")
|
||||
assert result == tmp_path / "abc123" / "kiwi.db"
|
||||
|
||||
|
||||
def test_user_db_path_household(tmp_path, monkeypatch):
|
||||
monkeypatch.setattr(cs, "CLOUD_DATA_ROOT", tmp_path)
|
||||
result = cs._user_db_path("abc123", household_id="hh-xyz")
|
||||
assert result == tmp_path / "household_hh-xyz" / "kiwi.db"
|
||||
|
||||
|
||||
# ── Integration tests (require router) ─────────────────────────────────
|
||||
|
||||
def test_create_household_requires_premium():
|
||||
"""Non-premium users cannot create a household."""
|
||||
from app.main import app
|
||||
from app.cloud_session import get_session
|
||||
import tempfile, pathlib
|
||||
|
||||
db = pathlib.Path(tempfile.mktemp(suffix=".db"))
|
||||
from app.db.store import Store
|
||||
Store(str(db))
|
||||
|
||||
free_user = CloudUser(user_id="u1", tier="free", db=db, has_byok=False)
|
||||
app.dependency_overrides[get_session] = lambda: free_user
|
||||
client = TestClient(app)
|
||||
resp = client.post("/api/v1/household/create")
|
||||
assert resp.status_code == 403
|
||||
app.dependency_overrides.clear()
|
||||
|
||||
|
||||
def test_invite_generates_token():
|
||||
"""Invite endpoint returns a token and URL for owner in a household."""
|
||||
from app.main import app
|
||||
from app.cloud_session import get_session
|
||||
import tempfile, pathlib
|
||||
|
||||
db = pathlib.Path(tempfile.mktemp(suffix=".db"))
|
||||
from app.db.store import Store
|
||||
Store(str(db))
|
||||
|
||||
session = CloudUser(
|
||||
user_id="owner-1", tier="premium", db=db,
|
||||
has_byok=False, household_id="hh-test", is_household_owner=True
|
||||
)
|
||||
app.dependency_overrides[get_session] = lambda: session
|
||||
client = TestClient(app)
|
||||
resp = client.post("/api/v1/household/invite")
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert "token" in data
|
||||
assert "invite_url" in data
|
||||
assert len(data["token"]) == 64 # 32 bytes hex
|
||||
app.dependency_overrides.clear()
|
||||
|
||||
|
||||
def test_accept_invalid_token_returns_404(tmp_path, monkeypatch):
|
||||
"""Accepting a non-existent token returns 404."""
|
||||
import app.api.endpoints.household as hh_ep
|
||||
import app.cloud_session as cs
|
||||
monkeypatch.setattr(hh_ep, "CLOUD_DATA_ROOT", tmp_path)
|
||||
monkeypatch.setattr(cs, "CLOUD_DATA_ROOT", tmp_path)
|
||||
|
||||
from app.main import app
|
||||
from app.cloud_session import get_session
|
||||
import tempfile, pathlib
|
||||
|
||||
db = pathlib.Path(tempfile.mktemp(suffix=".db"))
|
||||
from app.db.store import Store
|
||||
Store(str(db))
|
||||
|
||||
session = CloudUser(user_id="new-user", tier="free", db=db, has_byok=False)
|
||||
app.dependency_overrides[get_session] = lambda: session
|
||||
client = TestClient(app)
|
||||
resp = client.post("/api/v1/household/accept", json={
|
||||
"household_id": "hh-test",
|
||||
"token": "deadbeef" * 8,
|
||||
})
|
||||
assert resp.status_code == 404
|
||||
app.dependency_overrides.clear()
|
||||
Loading…
Reference in a new issue