From 61c16af754ae056206cc9d338b37004ca864cab6 Mon Sep 17 00:00:00 2001 From: pyr0ball Date: Fri, 3 Apr 2026 16:47:50 -0700 Subject: [PATCH] feat: in-app feedback FAB + mobile safe-area-inset fixes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - FeedbackButton.vue — floating action button; fires POST /feedback if FORGEJO_API_TOKEN is configured, silently hidden otherwise - feedback.py endpoint — creates Forgejo issues with beta-feedback + needs-triage labels; includes version, OS, device info - App.vue: wire FeedbackButton; use env(safe-area-inset-bottom) for bottom-nav clearance (fixes clipped content on iPhone home-bar) - style.css: overflow-x: hidden prevents stray elements expanding mobile viewport --- app/api/endpoints/feedback.py | 164 +++++++++ app/api/routes.py | 5 +- frontend/src/App.vue | 12 +- frontend/src/components/FeedbackButton.vue | 409 +++++++++++++++++++++ frontend/src/style.css | 1 + 5 files changed, 587 insertions(+), 4 deletions(-) create mode 100644 app/api/endpoints/feedback.py create mode 100644 frontend/src/components/FeedbackButton.vue diff --git a/app/api/endpoints/feedback.py b/app/api/endpoints/feedback.py new file mode 100644 index 0000000..a992b9f --- /dev/null +++ b/app/api/endpoints/feedback.py @@ -0,0 +1,164 @@ +""" +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, Depends, HTTPException +from pydantic import BaseModel + +from app.core.config import settings +from app.db.store import get_db + +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 " attribution + + +class FeedbackResponse(BaseModel): + issue_number: int + issue_url: str + + +# ── Route ────────────────────────────────────────────────────────────────────── + +@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"]) diff --git a/app/api/routes.py b/app/api/routes.py index fd642c7..79395a2 100644 --- a/app/api/routes.py +++ b/app/api/routes.py @@ -1,5 +1,5 @@ from fastapi import APIRouter -from app.api.endpoints import health, receipts, export, inventory, ocr, recipes, settings, staples +from app.api.endpoints import health, receipts, export, inventory, ocr, recipes, settings, staples, feedback api_router = APIRouter() @@ -10,4 +10,5 @@ api_router.include_router(export.router, tags=["export" api_router.include_router(inventory.router, prefix="/inventory", tags=["inventory"]) 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"]) \ No newline at end of file +api_router.include_router(staples.router, prefix="/staples", tags=["staples"]) +api_router.include_router(feedback.router, prefix="/feedback", tags=["feedback"]) \ No newline at end of file diff --git a/frontend/src/App.vue b/frontend/src/App.vue index 406471c..fe179f8 100644 --- a/frontend/src/App.vue +++ b/frontend/src/App.vue @@ -113,6 +113,9 @@ + + +