feat: add in-app feedback FAB

- api/main.py: GET /api/feedback/status + POST /api/feedback — creates
  Forgejo issues; disabled (503) when FORGEJO_API_TOKEN unset, 403 in
  demo mode; includes view, version, platform context in issue body
- FeedbackButton.vue: 2-step modal (type → review → submit); probes
  /api/feedback/status on mount, stays hidden until confirmed enabled
- App.vue: mount FeedbackButton with current route name as view context;
  import useRoute for reactive route name tracking
- .env.example: document FORGEJO_API_TOKEN / FORGEJO_REPO / FORGEJO_API_URL
This commit is contained in:
pyr0ball 2026-04-03 19:05:47 -07:00
parent d5419d2b1b
commit 0617fc8256
4 changed files with 548 additions and 1 deletions

View file

@ -46,3 +46,10 @@ SNIPE_DB=data/snipe.db
# Heimdall license server — for tier resolution and free-key auto-provisioning. # Heimdall license server — for tier resolution and free-key auto-provisioning.
# HEIMDALL_URL=https://license.circuitforge.tech # HEIMDALL_URL=https://license.circuitforge.tech
# HEIMDALL_ADMIN_TOKEN= # HEIMDALL_ADMIN_TOKEN=
# ── In-app feedback (beta) ────────────────────────────────────────────────────
# When set, a feedback FAB appears in the UI and routes submissions to Forgejo.
# Leave unset to silently hide the button (demo/offline deployments).
# FORGEJO_API_TOKEN=
# FORGEJO_REPO=Circuit-Forge/snipe
# FORGEJO_API_URL=https://git.opensourcesolarpunk.com/api/v1

View file

@ -11,6 +11,12 @@ from pathlib import Path
import csv import csv
import io import io
import platform as _platform
import subprocess
from datetime import datetime, timezone
from typing import Literal
import requests as _requests
from fastapi import Depends, FastAPI, HTTPException, UploadFile, File from fastapi import Depends, FastAPI, HTTPException, UploadFile, File
from fastapi.responses import StreamingResponse from fastapi.responses import StreamingResponse
from pydantic import BaseModel from pydantic import BaseModel
@ -629,3 +635,119 @@ async def import_blocklist(
log.info("Blocklist import: %d added, %d errors", imported, len(errors)) log.info("Blocklist import: %d added, %d errors", imported, len(errors))
return {"imported": imported, "errors": errors} return {"imported": imported, "errors": errors}
# ── Feedback ────────────────────────────────────────────────────────────────
# Creates Forgejo issues from in-app beta feedback.
# Silently disabled when FORGEJO_API_TOKEN is not set.
_FEEDBACK_LABEL_COLORS = {
"beta-feedback": "#0075ca",
"needs-triage": "#e4e669",
"bug": "#d73a4a",
"feature-request": "#a2eeef",
"question": "#d876e3",
}
def _fb_headers() -> dict:
token = os.environ.get("FORGEJO_API_TOKEN", "")
return {"Authorization": f"token {token}", "Content-Type": "application/json"}
def _ensure_feedback_labels(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/snipe")
resp = _requests.get(f"{base}/repos/{repo}/labels", headers=_fb_headers(), timeout=10)
existing = {lb["name"]: lb["id"] for lb in resp.json()} if resp.ok else {}
ids: list[int] = []
for name in names:
if name in existing:
ids.append(existing[name])
else:
r = _requests.post(
f"{base}/repos/{repo}/labels",
headers=_fb_headers(),
json={"name": name, "color": _FEEDBACK_LABEL_COLORS.get(name, "#ededed")},
timeout=10,
)
if r.ok:
ids.append(r.json()["id"])
return ids
class FeedbackRequest(BaseModel):
title: str
description: str
type: Literal["bug", "feature", "other"] = "other"
repro: str = ""
view: str = "unknown"
submitter: str = ""
class FeedbackResponse(BaseModel):
issue_number: int
issue_url: str
@app.get("/api/feedback/status")
def feedback_status() -> dict:
"""Return whether feedback submission is configured on this instance."""
demo = os.environ.get("DEMO_MODE", "false").lower() in ("1", "true", "yes")
return {"enabled": bool(os.environ.get("FORGEJO_API_TOKEN")) and not demo}
@app.post("/api/feedback", response_model=FeedbackResponse)
def submit_feedback(payload: FeedbackRequest) -> FeedbackResponse:
"""File a Forgejo issue from in-app feedback."""
token = os.environ.get("FORGEJO_API_TOKEN", "")
if not token:
raise HTTPException(status_code=503, detail="Feedback disabled: FORGEJO_API_TOKEN not configured.")
if os.environ.get("DEMO_MODE", "false").lower() in ("1", "true", "yes"):
raise HTTPException(status_code=403, detail="Feedback disabled in demo mode.")
try:
version = subprocess.check_output(
["git", "describe", "--tags", "--always"],
cwd=Path(__file__).resolve().parents[1], text=True, timeout=5,
).strip()
except Exception:
version = "dev"
_TYPE_LABELS = {"bug": "🐛 Bug", "feature": "✨ Feature Request", "other": "💬 Other"}
body_lines = [
f"## {_TYPE_LABELS.get(payload.type, '💬 Other')}",
"",
payload.description,
"",
]
if payload.type == "bug" and payload.repro:
body_lines += ["### Reproduction Steps", "", payload.repro, ""]
body_lines += [
"### Context", "",
f"- **view:** {payload.view}",
f"- **version:** {version}",
f"- **platform:** {_platform.platform()}",
f"- **timestamp:** {datetime.now(timezone.utc).isoformat().replace('+00:00', 'Z')}",
"",
]
if payload.submitter:
body_lines += ["---", f"*Submitted by: {payload.submitter}*"]
labels = ["beta-feedback", "needs-triage",
{"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/snipe")
label_ids = _ensure_feedback_labels(labels)
resp = _requests.post(
f"{base}/repos/{repo}/issues",
headers=_fb_headers(),
json={"title": payload.title, "body": "\n".join(body_lines), "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"])

View file

@ -8,23 +8,28 @@
<a href="#main-content" class="skip-link">Skip to main content</a> <a href="#main-content" class="skip-link">Skip to main content</a>
<RouterView /> <RouterView />
</main> </main>
<!-- Feedback FAB hidden when FORGEJO_API_TOKEN not configured -->
<FeedbackButton :current-view="String(route.name ?? 'unknown')" />
</div> </div>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { onMounted } from 'vue' import { onMounted } from 'vue'
import { RouterView } from 'vue-router' import { RouterView, useRoute } from 'vue-router'
import { useMotion } from './composables/useMotion' import { useMotion } from './composables/useMotion'
import { useSnipeMode } from './composables/useSnipeMode' import { useSnipeMode } from './composables/useSnipeMode'
import { useKonamiCode } from './composables/useKonamiCode' import { useKonamiCode } from './composables/useKonamiCode'
import { useSessionStore } from './stores/session' import { useSessionStore } from './stores/session'
import { useBlocklistStore } from './stores/blocklist' import { useBlocklistStore } from './stores/blocklist'
import AppNav from './components/AppNav.vue' import AppNav from './components/AppNav.vue'
import FeedbackButton from './components/FeedbackButton.vue'
const motion = useMotion() const motion = useMotion()
const { activate, restore } = useSnipeMode() const { activate, restore } = useSnipeMode()
const session = useSessionStore() const session = useSessionStore()
const blocklistStore = useBlocklistStore() const blocklistStore = useBlocklistStore()
const route = useRoute()
useKonamiCode(activate) useKonamiCode(activate)

View file

@ -0,0 +1,413 @@
<template>
<!-- Floating trigger button -->
<button
v-if="enabled"
class="feedback-fab"
@click="open = true"
aria-label="Send feedback or report a bug"
title="Send feedback or report a bug"
>
<svg class="feedback-fab-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round">
<path d="M21 15a2 2 0 01-2 2H7l-4 4V5a2 2 0 012-2h14a2 2 0 012 2z"/>
</svg>
<span class="feedback-fab-label">Feedback</span>
</button>
<!-- Modal teleported to body to avoid z-index / overflow clipping -->
<Teleport to="body">
<Transition name="modal-fade">
<div v-if="open" class="feedback-overlay" @click.self="close">
<div class="feedback-modal" role="dialog" aria-modal="true" aria-label="Send Feedback">
<!-- Header -->
<div class="feedback-header">
<h2 class="feedback-title">{{ step === 1 ? "What's on your mind?" : "Review & submit" }}</h2>
<button class="feedback-close" @click="close" aria-label="Close">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" width="18" height="18">
<line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/>
</svg>
</button>
</div>
<!-- Step 1: Form -->
<div v-if="step === 1" class="feedback-body">
<div class="form-group">
<label class="form-label">Type</label>
<div class="filter-chip-row">
<button
v-for="t in types"
:key="t.value"
:class="['btn-chip', { active: form.type === t.value }]"
@click="form.type = t.value"
type="button"
>{{ t.label }}</button>
</div>
</div>
<div class="form-group">
<label class="form-label">Title <span class="form-required">*</span></label>
<input
v-model="form.title"
class="form-input"
type="text"
placeholder="Short summary of the issue or idea"
maxlength="120"
/>
</div>
<div class="form-group">
<label class="form-label">Description <span class="form-required">*</span></label>
<textarea
v-model="form.description"
class="form-input feedback-textarea"
placeholder="Describe what happened or what you'd like to see…"
rows="4"
/>
</div>
<div v-if="form.type === 'bug'" class="form-group">
<label class="form-label">Reproduction steps</label>
<textarea
v-model="form.repro"
class="form-input feedback-textarea"
placeholder="1. Go to…&#10;2. Tap…&#10;3. See error"
rows="3"
/>
</div>
<p v-if="stepError" class="feedback-error">{{ stepError }}</p>
</div>
<!-- Step 2: Attribution + confirm -->
<div v-if="step === 2" class="feedback-body">
<div class="feedback-summary card">
<div class="feedback-summary-row">
<span class="text-muted text-sm">Type</span>
<span class="text-sm font-semibold">{{ typeLabel }}</span>
</div>
<div class="feedback-summary-row">
<span class="text-muted text-sm">Title</span>
<span class="text-sm">{{ form.title }}</span>
</div>
<div class="feedback-summary-row">
<span class="text-muted text-sm">Description</span>
<span class="text-sm feedback-summary-desc">{{ form.description }}</span>
</div>
</div>
<div class="form-group mt-md">
<label class="form-label">Attribution (optional)</label>
<input
v-model="form.submitter"
class="form-input"
type="text"
placeholder="Your name &lt;email@example.com&gt;"
/>
<p class="text-muted text-xs mt-xs">Include your name and email in the issue if you'd like a response. Never required.</p>
</div>
<p v-if="submitError" class="feedback-error">{{ submitError }}</p>
<div v-if="submitted" class="feedback-success">
Issue filed! <a :href="issueUrl" target="_blank" rel="noopener" class="feedback-link">View on Forgejo </a>
</div>
</div>
<!-- Footer nav -->
<div class="feedback-footer">
<button v-if="step === 2 && !submitted" class="btn btn-ghost" @click="step = 1" :disabled="loading"> Back</button>
<button v-if="!submitted" class="btn btn-ghost" @click="close" :disabled="loading">Cancel</button>
<button
v-if="step === 1"
class="btn btn-primary"
@click="nextStep"
>Next </button>
<button
v-if="step === 2 && !submitted"
class="btn btn-primary"
@click="submit"
:disabled="loading"
>{{ loading ? 'Filing…' : 'Submit' }}</button>
<button v-if="submitted" class="btn btn-primary" @click="close">Done</button>
</div>
</div>
</div>
</Transition>
</Teleport>
</template>
<script setup lang="ts">
import { ref, computed, onMounted } from 'vue'
const props = defineProps<{ currentView?: string }>()
// Probe once on mount hidden until confirmed enabled so button never flashes
const enabled = ref(false)
onMounted(async () => {
try {
const res = await fetch('/api/feedback/status')
if (res.ok) {
const data = await res.json()
enabled.value = data.enabled === true
}
} catch { /* network error — stay hidden */ }
})
const open = ref(false)
const step = ref(1)
const loading = ref(false)
const stepError = ref('')
const submitError = ref('')
const submitted = ref(false)
const issueUrl = ref('')
const types: { value: 'bug' | 'feature' | 'other'; label: string }[] = [
{ value: 'bug', label: '🐛 Bug' },
{ value: 'feature', label: '✨ Feature request' },
{ value: 'other', label: '💬 Other' },
]
const form = ref({
type: 'bug' as 'bug' | 'feature' | 'other',
title: '',
description: '',
repro: '',
submitter: '',
})
const typeLabel = computed(() => types.find(t => t.value === form.value.type)?.label ?? '')
function close() {
open.value = false
// reset after transition
setTimeout(reset, 300)
}
function reset() {
step.value = 1
loading.value = false
stepError.value = ''
submitError.value = ''
submitted.value = false
issueUrl.value = ''
form.value = { type: 'bug', title: '', description: '', repro: '', submitter: '' }
}
function nextStep() {
stepError.value = ''
if (!form.value.title.trim() || !form.value.description.trim()) {
stepError.value = 'Please fill in both Title and Description.'
return
}
step.value = 2
}
async function submit() {
loading.value = true
submitError.value = ''
try {
const res = await fetch('/api/feedback', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
title: form.value.title.trim(),
description: form.value.description.trim(),
type: form.value.type,
repro: form.value.repro.trim(),
view: props.currentView ?? 'unknown',
submitter: form.value.submitter.trim(),
}),
})
if (!res.ok) {
const err = await res.json().catch(() => ({ detail: res.statusText }))
submitError.value = err.detail ?? 'Submission failed.'
return
}
const data = await res.json()
issueUrl.value = data.issue_url
submitted.value = true
} catch (e) {
submitError.value = 'Network error — please try again.'
} finally {
loading.value = false
}
}
</script>
<style scoped>
/* ── Floating action button ─────────────────────────────────────────── */
.feedback-fab {
position: fixed;
right: var(--spacing-md);
bottom: calc(68px + var(--spacing-md)); /* above mobile bottom nav */
z-index: 190;
display: flex;
align-items: center;
gap: var(--spacing-xs);
padding: 9px var(--spacing-md);
background: var(--color-bg-elevated);
border: 1px solid var(--color-border);
border-radius: 999px;
color: var(--color-text-secondary);
font-size: var(--font-size-sm);
font-family: var(--font-body);
font-weight: 500;
cursor: pointer;
box-shadow: var(--shadow-md);
transition: background 0.15s, color 0.15s, box-shadow 0.15s, border-color 0.15s;
}
.feedback-fab:hover {
background: var(--color-bg-card);
color: var(--color-text-primary);
border-color: var(--color-border-focus);
box-shadow: var(--shadow-lg);
}
.feedback-fab-icon { width: 15px; height: 15px; flex-shrink: 0; }
.feedback-fab-label { white-space: nowrap; }
/* On desktop, bottom nav is gone — drop to standard corner */
@media (min-width: 769px) {
.feedback-fab {
bottom: var(--spacing-lg);
}
}
/* ── Overlay ──────────────────────────────────────────────────────────── */
.feedback-overlay {
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.55);
z-index: 1000;
display: flex;
align-items: flex-end;
justify-content: center;
padding: 0;
}
@media (min-width: 500px) {
.feedback-overlay {
align-items: center;
padding: var(--spacing-md);
}
}
/* ── Modal ────────────────────────────────────────────────────────────── */
.feedback-modal {
background: var(--color-bg-elevated);
border: 1px solid var(--color-border);
border-radius: var(--radius-lg) var(--radius-lg) 0 0;
width: 100%;
max-height: 90vh;
overflow-y: auto;
display: flex;
flex-direction: column;
box-shadow: var(--shadow-xl);
}
@media (min-width: 500px) {
.feedback-modal {
border-radius: var(--radius-lg);
width: 100%;
max-width: 520px;
max-height: 85vh;
}
}
.feedback-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: var(--spacing-md) var(--spacing-md) var(--spacing-sm);
border-bottom: 1px solid var(--color-border);
flex-shrink: 0;
}
.feedback-title {
font-family: var(--font-display);
font-size: var(--font-size-lg);
font-weight: 600;
margin: 0;
}
.feedback-close {
background: transparent;
border: none;
color: var(--color-text-muted);
cursor: pointer;
padding: 4px;
border-radius: var(--radius-sm);
display: flex;
align-items: center;
justify-content: center;
}
.feedback-close:hover { color: var(--color-text-primary); }
.feedback-body {
padding: var(--spacing-md);
flex: 1;
overflow-y: auto;
display: flex;
flex-direction: column;
gap: var(--spacing-md);
}
.feedback-footer {
display: flex;
align-items: center;
justify-content: flex-end;
gap: var(--spacing-sm);
padding: var(--spacing-sm) var(--spacing-md);
border-top: 1px solid var(--color-border);
flex-shrink: 0;
}
.feedback-textarea {
resize: vertical;
min-height: 80px;
font-family: var(--font-body);
font-size: var(--font-size-sm);
}
.form-required { color: var(--color-error); margin-left: 2px; }
.feedback-error {
color: var(--color-error);
font-size: var(--font-size-sm);
margin: 0;
}
.feedback-success {
color: var(--color-success);
font-size: var(--font-size-sm);
padding: var(--spacing-sm) var(--spacing-md);
background: var(--color-success-bg);
border: 1px solid var(--color-success-border);
border-radius: var(--radius-md);
}
.feedback-link { color: var(--color-success); font-weight: 600; text-decoration: underline; }
/* Summary card (step 2) */
.feedback-summary {
display: flex;
flex-direction: column;
gap: var(--spacing-xs);
padding: var(--spacing-sm) var(--spacing-md);
background: var(--color-bg-secondary);
border-radius: var(--radius-md);
border: 1px solid var(--color-border);
}
.feedback-summary-row {
display: flex;
gap: var(--spacing-md);
align-items: flex-start;
}
.feedback-summary-row > :first-child { min-width: 72px; flex-shrink: 0; }
.feedback-summary-desc {
white-space: pre-wrap;
word-break: break-word;
}
.mt-md { margin-top: var(--spacing-md); }
.mt-xs { margin-top: var(--spacing-xs); }
/* Transition */
.modal-fade-enter-active, .modal-fade-leave-active { transition: opacity 0.2s ease; }
.modal-fade-enter-from, .modal-fade-leave-to { opacity: 0; }
</style>