feat: screenshot attachment in feedback form (#82)
Some checks are pending
CI / Backend (Python) (push) Waiting to run
CI / Frontend (Vue) (push) Waiting to run
Mirror / mirror (push) Waiting to run

- 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
This commit is contained in:
pyr0ball 2026-04-15 23:08:02 -07:00
parent 76516abd62
commit 4423373750
3 changed files with 214 additions and 2 deletions

View file

@ -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)

View file

@ -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"])

View file

@ -75,6 +75,21 @@
/>
</div>
<div class="form-group">
<label class="form-label">Screenshot <span class="text-muted text-xs">(optional, max 5 MB)</span></label>
<input
type="file"
accept="image/*"
class="form-input-file"
@change="onScreenshotChange"
ref="fileInput"
/>
<div v-if="screenshotPreview" class="screenshot-preview">
<img :src="screenshotPreview" alt="Screenshot preview" />
<button class="screenshot-remove btn-link" type="button" @click="clearScreenshot" aria-label="Remove screenshot">Remove</button>
</div>
</div>
<p v-if="stepError" class="feedback-error">{{ stepError }}</p>
</div>
@ -140,6 +155,30 @@ import { ref, computed, onMounted } from 'vue'
const props = defineProps<{ currentTab?: string }>()
const fileInput = ref<HTMLInputElement | null>(null)
const screenshotB64 = ref<string | null>(null)
const screenshotPreview = ref<string | null>(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; }