From 44233737500c844f6ec834e00c65a1e1d4408c1b Mon Sep 17 00:00:00 2001 From: pyr0ball Date: Wed, 15 Apr 2026 23:08:02 -0700 Subject: [PATCH] feat: screenshot attachment in feedback form (#82) - Backend: new /api/v1/feedback/attach endpoint uploads image to Forgejo as an issue asset, then pins it as a comment so the screenshot is visible inline on the issue - Frontend: file input in feedback form (all types, max 5 MB) with inline thumbnail preview and remove button - Attach call is non-fatal: if upload fails after issue creation, the issue is still filed and the user sees success - Screenshot state clears on modal reset Closes #82 --- app/api/endpoints/feedback_attach.py | 103 ++++++++++++++++++++ app/api/routes.py | 5 +- frontend/src/components/FeedbackButton.vue | 108 +++++++++++++++++++++ 3 files changed, 214 insertions(+), 2 deletions(-) create mode 100644 app/api/endpoints/feedback_attach.py diff --git a/app/api/endpoints/feedback_attach.py b/app/api/endpoints/feedback_attach.py new file mode 100644 index 0000000..f28f4aa --- /dev/null +++ b/app/api/endpoints/feedback_attach.py @@ -0,0 +1,103 @@ +"""Screenshot attachment endpoint for in-app feedback. + +After the cf-core feedback router creates a Forgejo issue, the frontend +can call POST /feedback/attach to upload a screenshot and pin it as a +comment on that issue. + +The endpoint is separate from the cf-core router so Kiwi owns it +without modifying shared infrastructure. +""" +from __future__ import annotations + +import base64 +import os + +import requests +from fastapi import APIRouter, HTTPException +from pydantic import BaseModel, Field + +router = APIRouter() + +_FORGEJO_BASE = os.environ.get( + "FORGEJO_API_URL", "https://git.opensourcesolarpunk.com/api/v1" +) +_REPO = "Circuit-Forge/kiwi" +_MAX_BYTES = 5 * 1024 * 1024 # 5 MB + + +class AttachRequest(BaseModel): + issue_number: int + filename: str = Field(default="screenshot.png", max_length=80) + image_b64: str # data URI or raw base64 + + +class AttachResponse(BaseModel): + comment_url: str + + +def _forgejo_headers() -> dict[str, str]: + token = os.environ.get("FORGEJO_API_TOKEN", "") + return {"Authorization": f"token {token}"} + + +def _decode_image(image_b64: str) -> tuple[bytes, str]: + """Return (raw_bytes, mime_type) from a base64 string or data URI.""" + if image_b64.startswith("data:"): + header, _, data = image_b64.partition(",") + mime = header.split(";")[0].split(":")[1] if ":" in header else "image/png" + else: + data = image_b64 + mime = "image/png" + return base64.b64decode(data), mime + + +@router.post("/attach", response_model=AttachResponse) +def attach_screenshot(payload: AttachRequest) -> AttachResponse: + """Upload a screenshot to a Forgejo issue as a comment with embedded image. + + The image is uploaded as an issue asset, then referenced in a comment + so it is visible inline when the issue is viewed. + """ + token = os.environ.get("FORGEJO_API_TOKEN", "") + if not token: + raise HTTPException(status_code=503, detail="Feedback not configured.") + + raw_bytes, mime = _decode_image(payload.image_b64) + + if len(raw_bytes) > _MAX_BYTES: + raise HTTPException( + status_code=413, + detail=f"Screenshot exceeds 5 MB limit ({len(raw_bytes) // 1024} KB received).", + ) + + # Upload image as issue asset + asset_resp = requests.post( + f"{_FORGEJO_BASE}/repos/{_REPO}/issues/{payload.issue_number}/assets", + headers=_forgejo_headers(), + files={"attachment": (payload.filename, raw_bytes, mime)}, + timeout=20, + ) + if not asset_resp.ok: + raise HTTPException( + status_code=502, + detail=f"Forgejo asset upload failed: {asset_resp.text[:200]}", + ) + + asset_url = asset_resp.json().get("browser_download_url", "") + + # Pin as a comment so the image is visible inline + comment_body = f"**Screenshot attached by reporter:**\n\n![screenshot]({asset_url})" + comment_resp = requests.post( + f"{_FORGEJO_BASE}/repos/{_REPO}/issues/{payload.issue_number}/comments", + headers={**_forgejo_headers(), "Content-Type": "application/json"}, + json={"body": comment_body}, + timeout=15, + ) + if not comment_resp.ok: + raise HTTPException( + status_code=502, + detail=f"Forgejo comment failed: {comment_resp.text[:200]}", + ) + + comment_url = comment_resp.json().get("html_url", "") + return AttachResponse(comment_url=comment_url) diff --git a/app/api/routes.py b/app/api/routes.py index e5fd5a0..451c395 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, feedback, household, saved_recipes, imitate, meal_plans, orch_usage +from app.api.endpoints import health, receipts, export, inventory, ocr, recipes, settings, staples, feedback, feedback_attach, household, saved_recipes, imitate, meal_plans, orch_usage from app.api.endpoints.community import router as community_router api_router = APIRouter() @@ -13,7 +13,8 @@ api_router.include_router(saved_recipes.router, prefix="/recipes/saved", tags= 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(feedback.router, prefix="/feedback", tags=["feedback"]) +api_router.include_router(feedback_attach.router, prefix="/feedback", tags=["feedback"]) api_router.include_router(household.router, prefix="/household", tags=["household"]) api_router.include_router(imitate.router, prefix="/imitate", tags=["imitate"]) api_router.include_router(meal_plans.router, prefix="/meal-plans", tags=["meal-plans"]) diff --git a/frontend/src/components/FeedbackButton.vue b/frontend/src/components/FeedbackButton.vue index 800ab25..6d75a27 100644 --- a/frontend/src/components/FeedbackButton.vue +++ b/frontend/src/components/FeedbackButton.vue @@ -75,6 +75,21 @@ /> +
+ + +
+ Screenshot preview + +
+
+

{{ stepError }}

@@ -140,6 +155,30 @@ import { ref, computed, onMounted } from 'vue' const props = defineProps<{ currentTab?: string }>() +const fileInput = ref(null) +const screenshotB64 = ref(null) +const screenshotPreview = ref(null) +const screenshotFilename = ref('screenshot.png') + +function onScreenshotChange(event: Event) { + const file = (event.target as HTMLInputElement).files?.[0] + if (!file) return + screenshotFilename.value = file.name + const reader = new FileReader() + reader.onload = (e) => { + const result = e.target?.result as string + screenshotB64.value = result + screenshotPreview.value = result + } + reader.readAsDataURL(file) +} + +function clearScreenshot() { + screenshotB64.value = null + screenshotPreview.value = null + if (fileInput.value) fileInput.value.value = '' +} + const apiBase = (import.meta.env.VITE_API_BASE as string) ?? '' // Probe once on mount — hidden until confirmed enabled so button never flashes @@ -192,6 +231,7 @@ function reset() { submitted.value = false issueUrl.value = '' form.value = { type: 'bug', title: '', description: '', repro: '', submitter: '' } + clearScreenshot() } function nextStep() { @@ -226,6 +266,23 @@ async function submit() { } const data = await res.json() issueUrl.value = data.issue_url + + // Upload screenshot if provided + if (screenshotB64.value) { + try { + await fetch(`${apiBase}/api/v1/feedback/attach`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + issue_number: data.issue_number, + filename: screenshotFilename.value, + image_b64: screenshotB64.value, + }), + }) + // Non-fatal: if attach fails, the issue was still filed + } catch { /* ignore attach errors */ } + } + submitted.value = true } catch (e) { submitError.value = 'Network error — please try again.' @@ -517,6 +574,57 @@ async function submit() { .text-xs { font-size: 0.75rem; line-height: 1.5; } .font-semibold { font-weight: 600; } +/* ── Screenshot attachment ────────────────────────────────────────────── */ +.form-input-file { + display: block; + width: 100%; + padding: var(--spacing-xs) var(--spacing-sm); + background: var(--color-bg-secondary); + border: 1px dashed var(--color-border); + border-radius: var(--radius-md); + color: var(--color-text-secondary); + font-family: var(--font-body); + font-size: var(--font-size-sm); + cursor: pointer; + box-sizing: border-box; +} +.form-input-file:focus { outline: 2px solid var(--color-border-focus); outline-offset: 2px; } + +.screenshot-preview { + margin-top: var(--spacing-xs); + display: flex; + align-items: flex-start; + gap: var(--spacing-sm); +} +.screenshot-preview img { + max-width: 160px; + max-height: 100px; + border-radius: var(--radius-sm); + border: 1px solid var(--color-border); + object-fit: cover; +} +.screenshot-remove { + font-size: var(--font-size-xs); + color: var(--color-text-muted); + background: none; + border: none; + cursor: pointer; + padding: 2px 4px; + min-height: 24px; +} +.screenshot-remove:hover { color: var(--color-error); } + +.btn-link { + background: none; + border: none; + color: var(--color-primary); + cursor: pointer; + padding: 0; + font-family: var(--font-body); + font-size: inherit; + text-decoration: underline; +} + /* Transition */ .modal-fade-enter-active, .modal-fade-leave-active { transition: opacity 0.2s ease; } .modal-fade-enter-from, .modal-fade-leave-to { opacity: 0; }