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
This commit is contained in:
parent
76516abd62
commit
4423373750
3 changed files with 214 additions and 2 deletions
103
app/api/endpoints/feedback_attach.py
Normal file
103
app/api/endpoints/feedback_attach.py
Normal 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"
|
||||
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)
|
||||
|
|
@ -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()
|
||||
|
|
@ -14,6 +14,7 @@ api_router.include_router(recipes.router, prefix="/recipes", tags=
|
|||
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_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"])
|
||||
|
|
|
|||
|
|
@ -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; }
|
||||
|
|
|
|||
Loading…
Reference in a new issue