diff --git a/home/doris-kitchen/app/routes/api_dashboard.py b/home/doris-kitchen/app/routes/api_dashboard.py index 53c05a6..efbe7ac 100644 --- a/home/doris-kitchen/app/routes/api_dashboard.py +++ b/home/doris-kitchen/app/routes/api_dashboard.py @@ -1,5 +1,4 @@ -from fastapi import APIRouter -from fastapi import HTTPException +from fastapi import APIRouter, HTTPException, Request from app.services.repository import repo from app.services.recipe_search import recipe_search @@ -14,6 +13,27 @@ def usable_results(items): return [entry for entry in items if str(entry.get("status") or "suggested") != "rejected"] +def _find_suggestion(suggestion_id: str | None): + if not suggestion_id: + return None + return next((item for item in repo.list_suggestions() if str(item.get("id")) == str(suggestion_id)), None) + + +def _find_existing_open_import_flag(recipe_url: str, kitchenowl_recipe_id: str | int | None): + recipe_url = str(recipe_url or "").strip().lower() + recipe_id = str(kitchenowl_recipe_id or "").strip() + for item in repo.list_import_flags(): + if str(item.get("status") or "open") != "open": + continue + item_url = str(item.get("recipe_url") or "").strip().lower() + item_recipe_id = str(item.get("kitchenowl_recipe_id") or "").strip() + if recipe_id and item_recipe_id and recipe_id == item_recipe_id: + return item + if recipe_url and item_url and recipe_url == item_url: + return item + return None + + @router.get("/health") async def health(): return {"status": "ok", "backend": repo.backend} @@ -25,6 +45,7 @@ async def summary(): suggestions = repo.list_suggestions() plans = repo.list_meal_plans() failed_searches = repo.recent_failed_searches(limit=6) + import_flags = repo.list_import_flags() counts = { "suggested": sum(1 for item in suggestions if item.get("status") == "suggested"), "approved": sum(1 for item in suggestions if item.get("status") == "approved"), @@ -32,6 +53,7 @@ async def summary(): "rejected": sum(1 for item in suggestions if item.get("status") == "rejected"), "plans": len(plans), "failed_searches": len(repo.list_failed_searches()), + "open_import_flags": sum(1 for item in import_flags if str(item.get("status") or "open") == "open"), } return { "backend": repo.backend, @@ -40,6 +62,7 @@ async def summary(): "recent_suggestions": repo.recent_suggestions(), "recent_plans": list(reversed(plans[-4:])), "recent_failed_searches": failed_searches, + "recent_import_flags": repo.recent_import_flags(limit=6), } @@ -96,3 +119,87 @@ async def refresh_failed_searches(): saved = repo.update_failed_search(str(item.get("id")), updates) or {**item, **updates} refreshed.append(saved) return {"status": "ok", "items": refreshed} + + +@router.get("/import-flags") +async def list_import_flags(): + return {"status": "ok", "items": repo.list_import_flags()} + + +@router.post("/import-flags") +async def create_import_flag(request: Request): + payload = await request.json() + suggestion_id = str(payload.get("suggestion_id") or "").strip() or None + suggestion = _find_suggestion(suggestion_id) + if suggestion_id and not suggestion: + raise HTTPException(status_code=404, detail="Suggestion not found.") + + recipe_title = str(payload.get("recipe_title") or (suggestion or {}).get("title") or ((suggestion or {}).get("payload") or {}).get("name") or "").strip() + recipe_url = str(payload.get("recipe_url") or ((suggestion or {}).get("payload") or {}).get("source") or (suggestion or {}).get("url") or "").strip() + issue_type = str(payload.get("issue_type") or "formatting").strip().lower() or "formatting" + notes = str(payload.get("notes") or "").strip() + kitchenowl_recipe_id = payload.get("kitchenowl_recipe_id") + if kitchenowl_recipe_id in {None, "", 0}: + kitchenowl_recipe_id = (((suggestion or {}).get("kitchenowl_result") or {}).get("recipe") or {}).get("id") + + if not notes: + raise HTTPException(status_code=400, detail="Issue notes are required.") + if not recipe_title and not recipe_url and not kitchenowl_recipe_id: + raise HTTPException(status_code=400, detail="Provide at least a recipe title, recipe URL, or KitchenOwl recipe id.") + + existing = _find_existing_open_import_flag(recipe_url, kitchenowl_recipe_id) + if existing: + history = list(existing.get("notes_history") or []) + history.append({"at": store.now(), "issue_type": issue_type, "notes": notes}) + updates = { + "updated_at": store.now(), + "last_flagged_at": store.now(), + "issue_type": issue_type, + "notes": notes, + "notes_history": history[-20:], + "flag_count": int(existing.get("flag_count") or 1) + 1, + "recipe_title": recipe_title or existing.get("recipe_title"), + "recipe_url": recipe_url or existing.get("recipe_url"), + "kitchenowl_recipe_id": kitchenowl_recipe_id or existing.get("kitchenowl_recipe_id"), + "suggestion_id": suggestion_id or existing.get("suggestion_id"), + "kitchenowl_status": (suggestion or {}).get("kitchenowl_status") or existing.get("kitchenowl_status"), + } + saved = repo.update_import_flag(str(existing.get("id")), updates) or {**existing, **updates} + return {"status": "ok", "item": saved, "deduplicated": True} + + item = { + "id": store.new_id("import-flag"), + "status": "open", + "issue_type": issue_type, + "notes": notes, + "notes_history": [{"at": store.now(), "issue_type": issue_type, "notes": notes}], + "flag_count": 1, + "created_at": store.now(), + "updated_at": store.now(), + "last_flagged_at": store.now(), + "suggestion_id": suggestion_id, + "recipe_title": recipe_title, + "recipe_url": recipe_url, + "kitchenowl_recipe_id": kitchenowl_recipe_id, + "kitchenowl_status": (suggestion or {}).get("kitchenowl_status"), + } + saved = repo.log_import_flag(item) + return {"status": "ok", "item": saved, "deduplicated": False} + + +@router.post("/import-flags/{import_flag_id}/resolve") +async def resolve_import_flag(import_flag_id: str, request: Request): + payload = await request.json() + current = next((entry for entry in repo.list_import_flags() if str(entry.get("id")) == str(import_flag_id)), None) + if not current: + raise HTTPException(status_code=404, detail="Import flag not found.") + resolution_notes = str(payload.get("resolution_notes") or "").strip() + updates = { + "status": "resolved", + "updated_at": store.now(), + "resolved_at": store.now(), + } + if resolution_notes: + updates["resolution_notes"] = resolution_notes + saved = repo.update_import_flag(import_flag_id, updates) or {**current, **updates} + return {"status": "ok", "item": saved} diff --git a/home/doris-kitchen/app/routes/ui.py b/home/doris-kitchen/app/routes/ui.py index ad1d039..b2d1875 100644 --- a/home/doris-kitchen/app/routes/ui.py +++ b/home/doris-kitchen/app/routes/ui.py @@ -27,3 +27,8 @@ async def planner_page(request: Request): @router.get("/failures", response_class=HTMLResponse) async def failures_page(request: Request): return templates.TemplateResponse("failures.html", {"request": request, "title": "Failed Searches"}) + + +@router.get("/imports", response_class=HTMLResponse) +async def imports_page(request: Request): + return templates.TemplateResponse("imports.html", {"request": request, "title": "Import Review"}) diff --git a/home/doris-kitchen/app/services/repository.py b/home/doris-kitchen/app/services/repository.py index dfe58f2..108856f 100644 --- a/home/doris-kitchen/app/services/repository.py +++ b/home/doris-kitchen/app/services/repository.py @@ -115,5 +115,25 @@ class JsonRepository: lambda item: {**item, **updates}, ) + def list_import_flags(self) -> list[dict[str, Any]]: + return self.store.load("import_flags", []) + + def log_import_flag(self, item: dict[str, Any]) -> dict[str, Any]: + current = self.list_import_flags() + current.append(item) + current = sorted(current, key=lambda entry: entry.get("updated_at") or entry.get("created_at") or "", reverse=True)[:200] + self.store.save("import_flags", current) + return item + + def recent_import_flags(self, limit: int = 12) -> list[dict[str, Any]]: + return self.list_import_flags()[:limit] + + def update_import_flag(self, import_flag_id: str, updates: dict[str, Any]) -> dict[str, Any] | None: + return self.store.update_collection_item( + "import_flags", + lambda item: str(item.get("id")) == str(import_flag_id), + lambda item: {**item, **updates}, + ) + repo = JsonRepository() diff --git a/home/doris-kitchen/app/static/app.css b/home/doris-kitchen/app/static/app.css index 29ceaec..2a51e33 100644 --- a/home/doris-kitchen/app/static/app.css +++ b/home/doris-kitchen/app/static/app.css @@ -1,25 +1,47 @@ :root { - --ink: #2c1810; - --paper: #f8f0df; - --paper-2: #fff8eb; - --accent: #c7682d; - --accent-deep: #8b3d14; - --olive: #6c7a3d; - --rose: #a74735; - --line: rgba(92, 52, 28, 0.2); - --muted: #755f53; - --shadow: 0 18px 40px rgba(73, 34, 11, 0.13); + color-scheme: dark; + --hf-night: #090b10; + --hf-panel: rgba(17, 21, 29, 0.94); + --hf-panel-raise: rgba(25, 31, 43, 0.96); + --hf-paper: #f2efdd; + --hf-muted: #cbbda7; + --hf-line: rgba(242, 239, 221, 0.12); + --hf-shadow: 0 20px 48px rgba(0, 0, 0, 0.34); + --hf-red: #d53600; + --hf-red-deep: #910f3f; + --hf-amber: #f2c14e; + --hf-blue: #7b8fb2; + --hf-green: #5c7f69; + --ink: var(--hf-paper); + --paper: var(--hf-panel); + --paper-2: var(--hf-panel-raise); + --accent: var(--hf-red); + --accent-deep: var(--hf-amber); + --olive: var(--hf-green); + --rose: #f27d72; + --line: var(--hf-line); + --muted: var(--hf-muted); + --shadow: var(--hf-shadow); } * { box-sizing: border-box; } +html { scroll-behavior: smooth; } body { margin: 0; color: var(--ink); - font-family: Georgia, "Palatino Linotype", serif; + font-family: Inter, system-ui, sans-serif; background: - radial-gradient(circle at top left, rgba(199, 104, 45, 0.14), transparent 24rem), - radial-gradient(circle at 85% 10%, rgba(108, 122, 61, 0.14), transparent 18rem), - linear-gradient(180deg, #f4ead7, #efe0c8 55%, #ead7bf); + radial-gradient(circle at top left, rgba(213, 54, 0, 0.22), transparent 22rem), + radial-gradient(circle at top right, rgba(123, 143, 178, 0.16), transparent 20rem), + linear-gradient(180deg, #07090d 0%, #0d1017 52%, #131824 100%); +} + +a { color: inherit; } + +.incident-tape { + height: 14px; + background: repeating-linear-gradient(135deg, var(--hf-amber) 0 18px, #111 18px 36px); + box-shadow: 0 3px 14px rgba(0, 0, 0, 0.32); } .site-header, @@ -29,93 +51,14 @@ body { padding: 24px; } -.site-header { +.casefile-header { display: grid; gap: 18px; + padding-top: 18px; } -.eyebrow, -.section-label { - text-transform: uppercase; - letter-spacing: 0.14em; - font-size: 0.75rem; - color: var(--accent-deep); - margin: 0 0 8px; -} - -h1, h2, h3, p { - margin-top: 0; -} - -.lede, -.muted { - color: var(--muted); -} - -nav { - display: flex; - gap: 14px; - flex-wrap: wrap; -} - -nav a, -.button-link { - color: var(--accent-deep); - text-decoration: none; - font-weight: 700; -} - -.page-shell { - display: grid; - gap: 22px; - padding-bottom: 44px; -} - -.hero-grid, -.split-grid { - display: grid; - grid-template-columns: repeat(auto-fit, minmax(280px, 1fr)); - gap: 22px; -} - -.card { - background: rgba(255, 248, 235, 0.92); - border: 1px solid var(--line); - border-radius: 24px; - padding: 24px; - box-shadow: var(--shadow); -} - -.spotlight { - background: - linear-gradient(145deg, rgba(199, 104, 45, 0.12), rgba(255, 248, 235, 0.88)), - rgba(255, 248, 235, 0.92); -} - -.stats-grid { - display: grid; - grid-template-columns: repeat(3, minmax(0, 1fr)); - gap: 14px; -} - -.stats-grid div { - background: var(--paper); - border: 1px solid var(--line); - border-radius: 16px; - padding: 14px; - display: grid; - gap: 8px; -} - -.stats-grid span { - color: var(--muted); - font-size: 0.85rem; -} - -.stats-grid strong { - font-size: 1.6rem; -} - +.brand-row, +.casefile-title-row, .section-heading, .suggestion-top, .action-row, @@ -128,110 +71,251 @@ nav a, flex-wrap: wrap; } -.stack-form { +.nav-brand { display: grid; - gap: 14px; + gap: 3px; } -.search-examples { - margin-top: 16px; - display: grid; - gap: 10px; -} - -.search-examples .chip-list { - align-items: flex-start; -} - -.search-examples button { - text-align: left; -} - -label { - display: grid; - gap: 8px; - color: var(--muted); -} - -input, -textarea, +.nav-brand strong, +.nav-link, +.button-link, button { - font: inherit; - border-radius: 14px; - border: 1px solid var(--line); - padding: 12px 14px; + font-weight: 700; } -input, -textarea { - background: rgba(255, 255, 255, 0.7); - color: var(--ink); +.nav-kicker, +.eyebrow, +.section-label { + text-transform: uppercase; + letter-spacing: 0.18em; + font-size: 0.72rem; + color: var(--hf-amber); + margin: 0; } -button, -.button-link { - background: var(--accent); - color: #fff7ef; - border: none; - padding: 12px 16px; - border-radius: 999px; - cursor: pointer; +.nav-links { + display: flex; + gap: 8px; + flex-wrap: wrap; + justify-content: flex-end; } +.nav-link, .button-link { display: inline-flex; align-items: center; -} - -button.ghost { - background: rgba(255, 255, 255, 0.7); - color: var(--accent-deep); + text-decoration: none; + padding: 9px 13px; + border-radius: 999px; border: 1px solid var(--line); + background: rgba(255, 255, 255, 0.04); + color: var(--ink); } -button.danger { - color: var(--rose); +.nav-link:hover, +.button-link:hover { + border-color: rgba(242, 193, 78, 0.58); + background: rgba(242, 193, 78, 0.12); } -.chip-list { +h1, h2, h3, p { margin-top: 0; } + +h1 { + margin-bottom: 10px; + font-size: clamp(2.3rem, 5vw, 4rem); + line-height: 0.95; + letter-spacing: -0.04em; +} + +.lede, +.muted, +label, +.reason-list, +.flag-history { + color: var(--muted); +} + +.brand-badges, +.chip-list, +.flag-meta { display: flex; flex-wrap: wrap; gap: 8px; } +.hot-fuzz-art { + position: relative; + flex: 0 1 320px; + min-width: min(100%, 280px); +} + +.poster-illustration { + width: 100%; + height: auto; + display: block; + filter: drop-shadow(0 24px 30px rgba(0, 0, 0, 0.35)); +} + +.poster-frame { + fill: rgba(10, 12, 18, 0.88); + stroke: rgba(242, 239, 221, 0.2); + stroke-width: 2; +} + +.poster-halo { + fill: url(#kitchen-sunset); + opacity: 0.9; +} + +.poster-tape { + fill: rgba(242, 193, 78, 0.88); + stroke: rgba(17, 17, 17, 0.8); + stroke-width: 2; +} + +.constable-silhouette { + fill: rgba(8, 10, 15, 0.92); + stroke: rgba(242, 239, 221, 0.12); + stroke-width: 1.5; +} + +.constable-silhouette.partner { + fill: rgba(24, 30, 42, 0.94); +} + +.swan-stamp { + fill: rgba(123, 143, 178, 0.2); + stroke: rgba(242, 239, 221, 0.42); + stroke-width: 2; +} + +.swan-mark { + fill: rgba(242, 239, 221, 0.94); +} + +.poster-callout { + fill: rgba(242, 239, 221, 0.82); + font-size: 18px; + font-weight: 800; + letter-spacing: 0.24em; + text-anchor: middle; +} + +.recipe-card, +.folder-body, +.folder-tab, +.barbell-bar, +.plate-outer, +.plate-inner { + fill: rgba(242, 239, 221, 0.9); +} + +.plate-inner { + fill: rgba(10, 12, 18, 0.72); +} + +.film-grain, +.cinematic-glow { + position: absolute; + inset: 0; + pointer-events: none; + border-radius: 28px; +} + +.film-grain { + background: + repeating-linear-gradient(0deg, rgba(255,255,255,0.025) 0 2px, transparent 2px 5px), + repeating-linear-gradient(90deg, rgba(255,255,255,0.018) 0 1px, transparent 1px 4px); + mix-blend-mode: soft-light; + opacity: 0.35; +} + +.cinematic-glow { + background: + radial-gradient(circle at 16% 18%, rgba(123, 143, 178, 0.22), transparent 24%), + radial-gradient(circle at 84% 24%, rgba(213, 54, 0, 0.24), transparent 26%), + linear-gradient(115deg, rgba(242, 193, 78, 0.05), transparent 40%, rgba(145, 15, 63, 0.1) 78%, transparent 100%); + animation: emergencyPulse 8s ease-in-out infinite alternate; +} + +.app-casefile { + position: relative; + overflow: hidden; + isolation: isolate; +} + +.casefile-stamp { + display: inline-flex; + align-items: center; + margin: 0 0 0.85rem; + padding: 0.35rem 0.7rem; + border: 1px solid rgba(242, 193, 78, 0.45); + border-radius: 999px; + color: #ffe5b0; + background: rgba(242, 193, 78, 0.1); + text-transform: uppercase; + letter-spacing: 0.16em; + font-size: 0.72rem; +} + +.app-casefile-kitchen .casefile-stamp { + box-shadow: 0 0 0 1px rgba(213, 54, 0, 0.12), 0 0 28px rgba(213, 54, 0, 0.16); +} + +@keyframes emergencyPulse { + 0% { opacity: 0.72; transform: scale(1); } + 50% { opacity: 0.92; transform: scale(1.015); } + 100% { opacity: 0.78; transform: scale(1.03); } +} + +.pill, .chip-list span, .chip-toggle, .score-pill, .status-pill, -.pill { +.pill-good, +.pill-warn { border-radius: 999px; padding: 6px 10px; font-size: 0.82rem; -} - -.chip-list span, -.chip-toggle { - background: var(--paper); border: 1px solid var(--line); } +.pill, +.chip-list span, +.chip-toggle { + background: rgba(255, 255, 255, 0.05); +} + +.badge-hotfuzz, +.score-pill, .chip-toggle.active, -.score-pill { - background: rgba(199, 104, 45, 0.12); - color: var(--accent-deep); -} - -.status-pill { - background: rgba(108, 122, 61, 0.12); - color: var(--olive); +button, +.button-link { + background: linear-gradient(135deg, var(--hf-red) 0%, var(--hf-red-deep) 100%); + color: #fff4ea; + border-color: transparent; } +.status-pill, .pill-good { - background: rgba(108, 122, 61, 0.14); - color: var(--olive); - display: inline-block; + background: rgba(92, 127, 105, 0.18); + color: #cfe8d8; } +.pill-warn { + background: rgba(242, 193, 78, 0.16); + color: var(--hf-amber); +} + +.page-shell { + display: grid; + gap: 22px; + padding-bottom: 44px; +} + +.hero-grid, +.split-grid, .suggestion-grid, .plan-grid, .failure-list { @@ -240,51 +324,199 @@ button.danger { gap: 18px; } +.card, .suggestion-card, .plan-card, .plan-summary, .failure-card { + background: var(--paper); border: 1px solid var(--line); + border-radius: 22px; + padding: 22px; + box-shadow: var(--shadow); +} + +.card, +.suggestion-card, +.plan-card, +.failure-card { + position: relative; + overflow: hidden; +} + +.card::before, +.suggestion-card::before, +.plan-card::before, +.failure-card::before { + content: ''; + position: absolute; + inset: 0 auto auto 0; + width: 100%; + height: 4px; + background: linear-gradient(90deg, var(--hf-red), var(--hf-amber), var(--hf-blue)); +} + +.spotlight { + background: + radial-gradient(circle at top right, rgba(242, 193, 78, 0.16), transparent 15rem), + linear-gradient(145deg, rgba(145, 15, 63, 0.2), rgba(17, 21, 29, 0.96)); +} + +.evidence-board, +.evidence-card, +.evidence-slab, +.intake-docket { + position: relative; +} + +.dossier-grid { + align-items: stretch; +} + +.case-legend { + display: flex; + align-items: center; + justify-content: space-between; + gap: 10px; + flex-wrap: wrap; + margin-bottom: 10px; +} + +.case-legend::after { + content: 'FOR THE GREATER GOOD'; + font-size: 0.67rem; + letter-spacing: 0.16em; + color: rgba(242, 193, 78, 0.7); +} + +.evidence-board::after, +.evidence-card::after { + content: ''; + position: absolute; + inset: 14px; + border: 1px dashed rgba(242, 193, 78, 0.09); + border-radius: 16px; + pointer-events: none; +} + +.dossier-note { + padding-left: 12px; + border-left: 3px solid rgba(242, 193, 78, 0.4); +} + +.case-list li { + padding-left: 18px; + position: relative; +} + +.case-list li::before { + content: '•'; + position: absolute; + left: 0; + color: var(--hf-amber); +} + +.intake-docket { + background: linear-gradient(180deg, rgba(255,255,255,0.03), rgba(255,255,255,0.015)); + border: 1px solid rgba(242, 239, 221, 0.06); border-radius: 18px; - background: rgba(255, 255, 255, 0.55); - padding: 18px; + padding: 14px; } -.failure-results { - margin-top: 14px; - padding-top: 14px; - border-top: 1px solid var(--line); +.chain-of-custody { + padding-bottom: 10px; + border-bottom: 1px dashed rgba(242, 193, 78, 0.2); } -.failure-results .suggestion-grid, -.failure-results.suggestion-grid { - margin-top: 8px; +.evidence-slab { + background: rgba(255, 255, 255, 0.035); + border: 1px solid var(--line); + border-radius: 16px; + padding: 14px; } -.status-open { - background: rgba(167, 71, 53, 0.12); - color: var(--rose); +.stats-grid { + display: grid; + grid-template-columns: repeat(3, minmax(0, 1fr)); + gap: 14px; } -.status-resolved { - background: rgba(108, 122, 61, 0.14); - color: var(--olive); +.stats-grid div, +.suggestion-card, +.plan-summary, +.failure-results, +.suggestion-grid .muted, +.list-item { + background: rgba(255, 255, 255, 0.035); + border: 1px solid var(--line); + border-radius: 16px; } -.suggestion-card h3, -.plan-card h3 { - margin-bottom: 8px; +.stats-grid div { + padding: 14px; + display: grid; + gap: 8px; } -.suggestion-card a, -.plan-card a { +.stats-grid span { font-size: 0.85rem; } +.stats-grid strong { font-size: 1.6rem; } + +.stack-form, +.search-examples, +.flag-meta { display: grid; gap: 12px; } + +label { display: grid; gap: 8px; } + +input, +textarea, +select, +button { + font: inherit; + border-radius: 14px; + padding: 12px 14px; +} + +input, +textarea, +select { + border: 1px solid var(--line); + background: rgba(255, 255, 255, 0.05); color: var(--ink); } -.reason-list { - margin: 12px 0; - padding-left: 18px; - color: var(--muted); +button { + border: none; + cursor: pointer; +} + +button.ghost { + background: rgba(255, 255, 255, 0.06); + color: var(--hf-amber); + border: 1px solid var(--line); +} + +button.danger { color: #ffd7d1; } + +.reason-list, +.flag-history { margin: 12px 0 0; padding-left: 18px; } +.failure-results { margin-top: 14px; padding: 14px; } +.failure-results .suggestion-grid, +.failure-results.suggestion-grid { margin-top: 8px; } + +.status-open { + background: rgba(210, 55, 0, 0.16); + color: #ffbeb0; +} + +.status-resolved { + background: rgba(92, 127, 105, 0.16); + color: #d7efdf; +} + +.suggestion-card a, +.plan-card a, +.flag-meta a { + color: var(--hf-amber); } @media (max-width: 640px) { @@ -293,6 +525,11 @@ button.danger { padding: 18px; } + .hot-fuzz-art { + flex-basis: 100%; + min-width: 0; + } + .stats-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); } diff --git a/home/doris-kitchen/app/static/app.js b/home/doris-kitchen/app/static/app.js index 4a7da1c..a15bbca 100644 --- a/home/doris-kitchen/app/static/app.js +++ b/home/doris-kitchen/app/static/app.js @@ -14,6 +14,14 @@ function escapeHtml(value) { .replace(/'/g, '''); } +function setStatusMessage(message, tone = 'muted') { + const status = document.getElementById('search-status'); + if (!status) return; + status.textContent = message || ''; + status.className = tone; +} + + function renderSuggestionCards(target, items) { if (!target) return; if (!items.length) { @@ -28,6 +36,10 @@ function renderSuggestionCards(target, items) { const kitchenowl = item.kitchenowl_status && item.kitchenowl_status !== 'not-imported' ? '

KitchenOwl: ' + escapeHtml(item.kitchenowl_status) + '

' : ''; + const canFlagImport = ['created', 'updated', 'duplicate'].includes(String(item.kitchenowl_status || '')); + const flagButton = canFlagImport + ? '' + : ''; return `
@@ -45,6 +57,7 @@ function renderSuggestionCards(target, items) { + ${flagButton}
`; @@ -86,6 +99,45 @@ function renderFailureCards(target, items) { }).join(''); } +function renderImportFlagCards(target, items) { + if (!target) return; + if (!items.length) { + target.textContent = 'No import issues flagged yet.'; + target.classList.add('muted'); + return; + } + target.classList.remove('muted'); + target.innerHTML = items.map((item) => { + const status = String(item.status || 'open'); + const resolved = status === 'resolved'; + const history = (item.notes_history || []).slice(-3).reverse().map((entry) => '
  • ' + escapeHtml(entry.issue_type || 'issue') + ': ' + escapeHtml(entry.notes || '') + '
  • ').join(''); + const sourceLink = item.recipe_url ? 'Open recipe source' : 'No source URL saved'; + const recipeId = item.kitchenowl_recipe_id ? 'KitchenOwl #' + escapeHtml(item.kitchenowl_recipe_id) + '' : ''; + const resolveButton = resolved ? '' : ''; + return ` +
    +
    +
    + +

    ${escapeHtml(item.recipe_title || item.recipe_url || 'KitchenOwl recipe issue')}

    +
    +

    ${escapeHtml(status)}

    +
    +

    ${escapeHtml(item.notes || '')}

    +
    + ${recipeId} + Flags: ${escapeHtml(item.flag_count || 1)} + Last flagged: ${escapeHtml(item.last_flagged_at || item.updated_at || item.created_at || '')} +
    +
    ${sourceLink}
    + +
    ${resolveButton}
    +
    + `; + }).join(''); +} + + async function loadDashboard() { const data = await fetchJson('/api/dashboard/summary'); const counts = data.counts || {}; @@ -100,6 +152,7 @@ async function loadDashboard() { setText('count-rejected', counts.rejected || 0); setText('count-plans', counts.plans || 0); setText('count-failed-searches', counts.failed_searches || 0); + setText('count-import-flags', counts.open_import_flags || 0); setText('backend-note', 'Storage backend: ' + data.backend); const blacklist = document.getElementById('blacklist-items'); @@ -122,6 +175,7 @@ async function loadDashboard() { renderSuggestionCards(document.getElementById('suggestion-list'), data.recent_suggestions || []); renderFailureCards(document.getElementById('failed-search-preview'), data.recent_failed_searches || []); + renderImportFlagCards(document.getElementById('import-flag-preview'), data.recent_import_flags || []); } async function handleDecision(event) { @@ -132,13 +186,34 @@ async function handleDecision(event) { const decision = button.dataset.decision; if (!suggestionId || !decision) return; const failurePanel = card ? card.closest('[data-failure-results]') : null; - button.disabled = true; + const buttons = card ? Array.from(card.querySelectorAll('button')) : [button]; + const originalText = button.textContent; + const workingText = decision === 'approve' + ? 'Adding to KitchenOwl…' + : (decision === 'later' ? 'Saving…' : 'Rejecting…'); + buttons.forEach((entry) => { entry.disabled = true; }); + button.textContent = workingText; + setStatusMessage(workingText, 'muted'); try { const data = await fetchJson('/api/suggestions/' + encodeURIComponent(suggestionId) + '/decision', { method: 'POST', headers: {'Content-Type': 'application/json'}, body: JSON.stringify({decision, import_to_kitchenowl: decision === 'approve'}), }); + + const suggestion = data.suggestion || {}; + const title = suggestion.title || 'Recipe'; + const kitchenowl = data.kitchenowl || {}; + if (decision === 'approve') { + const outcome = kitchenowl.status || suggestion.kitchenowl_status || 'updated'; + const verb = outcome === 'duplicate' ? 'already exists in' : 'added to'; + setStatusMessage(title + ' ' + verb + ' KitchenOwl. Status: ' + outcome + '.', 'pill pill-good'); + } else if (decision === 'later') { + setStatusMessage(title + ' saved for later review.', 'pill pill-good'); + } else { + setStatusMessage(title + ' rejected.', 'pill pill-good'); + } + await loadDashboard(); if (failurePanel && card) { card.remove(); @@ -165,12 +240,29 @@ async function handleDecision(event) { } return data; } catch (error) { + setStatusMessage('KitchenOwl action failed: ' + error.message, 'pill status-open'); alert(error.message); } finally { - button.disabled = false; + buttons.forEach((entry) => { entry.disabled = false; }); + button.textContent = originalText; } } +async function createImportFlag(payload) { + return fetchJson('/api/import-flags', { + method: 'POST', + headers: {'Content-Type': 'application/json'}, + body: JSON.stringify(payload), + }); +} + + +async function loadImportsInto(target) { + const data = await fetchJson('/api/import-flags'); + renderImportFlagCards(target, data.items || []); +} + + async function loadSuggestionsInto(target) { const data = await fetchJson('/api/suggestions'); renderSuggestionCards(target, data.items || []); @@ -232,6 +324,33 @@ function wireDashboard() { await handleDecision(event); return; } + if (event.target.matches('button[data-flag-suggestion]')) { + const button = event.target; + const suggestionId = button.dataset.flagSuggestion; + const issueType = window.prompt('What kind of import issue is this? (formatting, ingredients, timings, duplicate, other)', 'formatting'); + if (!issueType) return; + const notes = window.prompt('What went wrong with this import?'); + if (!notes) return; + await createImportFlag({suggestion_id: suggestionId, issue_type: issueType, notes}); + await loadDashboard(); + const importsTarget = document.getElementById('import-flags-list'); + if (importsTarget) await loadImportsInto(importsTarget); + return; + } + if (event.target.matches('button[data-import-flag-resolve]')) { + const button = event.target; + const importFlagId = button.dataset.importFlagResolve; + const resolutionNotes = window.prompt('Optional resolution note:', ''); + await fetchJson('/api/import-flags/' + encodeURIComponent(importFlagId) + '/resolve', { + method: 'POST', + headers: {'Content-Type': 'application/json'}, + body: JSON.stringify({resolution_notes: resolutionNotes || ''}), + }); + await loadDashboard(); + const importsTarget = document.getElementById('import-flags-list'); + if (importsTarget) await loadImportsInto(importsTarget); + return; + } if (event.target.matches('button[data-failure-open]')) { const button = event.target; const failureId = button.dataset.failureOpen; @@ -435,6 +554,42 @@ function wirePlanner() { }); } +function wireImports() { + const target = document.getElementById('import-flags-list'); + if (target) { + loadImportsInto(target).catch(() => { + target.textContent = 'Could not load import flags.'; + }); + } + + const form = document.getElementById('import-flag-form'); + if (!form) return; + form.addEventListener('submit', async (event) => { + event.preventDefault(); + const urlEl = document.getElementById('import-flag-url'); + const titleEl = document.getElementById('import-flag-title'); + const typeEl = document.getElementById('import-flag-type'); + const notesEl = document.getElementById('import-flag-notes'); + const payload = { + recipe_url: urlEl ? urlEl.value.trim() : '', + recipe_title: titleEl ? titleEl.value.trim() : '', + issue_type: typeEl ? typeEl.value : 'formatting', + notes: notesEl ? notesEl.value.trim() : '', + }; + if (!payload.notes) { + alert('Tell me what is wrong with the import first.'); + return; + } + await createImportFlag(payload); + if (urlEl) urlEl.value = ''; + if (titleEl) titleEl.value = ''; + if (notesEl) notesEl.value = ''; + await loadDashboard(); + if (target) await loadImportsInto(target); + }); +} + + function wireFailures() { const target = document.getElementById('failures-list'); if (target) { @@ -462,6 +617,7 @@ loadDashboard().catch(() => {}); wireDashboard(); wireSearch(); wirePlanner(); +wireImports(); wireFailures(); if (document.getElementById('search-results')) { loadSuggestionsInto(document.getElementById('search-results')).catch(() => {}); diff --git a/home/doris-kitchen/app/templates/base.html b/home/doris-kitchen/app/templates/base.html index 6654607..8be83c3 100644 --- a/home/doris-kitchen/app/templates/base.html +++ b/home/doris-kitchen/app/templates/base.html @@ -4,25 +4,67 @@ {{ title or 'Doris Kitchen' }} - + - -