feat: feedback with photo support + web dashboard feedback button
All checks were successful
Security Checks / dependency-audit (push) Successful in 15s
Security Checks / secret-scanning (push) Successful in 4s
Security Checks / dockerfile-lint (push) Successful in 5s

iOS:
- Photo picker in feedback sheet (screenshot/photo attachment)
- Image sent as base64, uploaded to Gitea issue as attachment

Web:
- Feedback button in sidebar rail
- Modal with text area + send
- Auto-labels same as iOS

Gateway:
- Multipart image upload to Gitea issue assets API

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Yusuf Suleman
2026-04-03 13:31:20 -05:00
parent 557bd80174
commit 96fa49fae2
4 changed files with 198 additions and 4 deletions

View File

@@ -56,6 +56,28 @@
let mobileNavOpen = $state(false);
let uploadInput: HTMLInputElement;
let feedbackOpen = $state(false);
let feedbackText = $state('');
let feedbackSending = $state(false);
let feedbackSent = $state(false);
async function sendFeedback() {
if (!feedbackText.trim()) return;
feedbackSending = true;
try {
const res = await fetch('/api/feedback', {
method: 'POST',
credentials: 'include',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ text: feedbackText.trim(), source: 'web' }),
});
if (res.ok) {
feedbackSent = true;
setTimeout(() => { feedbackOpen = false; feedbackSent = false; feedbackText = ''; }, 1500);
}
} catch {}
feedbackSending = false;
}
let uploadStatus = $state<'' | 'uploading' | 'done'>('');
async function handleUpload(file: File) {
@@ -176,6 +198,10 @@
</button>
</div>
<input bind:this={uploadInput} type="file" accept="image/*,.pdf" onchange={onUploadInput} hidden />
<button class="rail-feedback-btn" onclick={() => feedbackOpen = true} title="Send feedback">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" width="13" height="13"><path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z"/></svg>
Feedback
</button>
<div class="rail-date">
<CalendarDays size={14} strokeWidth={1.8} />
<span>{new Intl.DateTimeFormat('en-US', { month: 'short', day: 'numeric' }).format(new Date())}</span>
@@ -246,6 +272,28 @@
</div>
</div>
{#if feedbackOpen}
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div class="feedback-overlay" onclick={() => feedbackOpen = false}>
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div class="feedback-modal" onclick={(e) => e.stopPropagation()}>
{#if feedbackSent}
<div class="feedback-success">
<svg viewBox="0 0 24 24" fill="none" stroke="#059669" stroke-width="2.5" width="40" height="40"><path d="M20 6L9 17l-5-5"/></svg>
<div>Sent!</div>
</div>
{:else}
<div class="feedback-header">Feedback</div>
<div class="feedback-sub">Bug report, feature request, or anything</div>
<textarea class="feedback-input" bind:value={feedbackText} placeholder="What's on your mind?"></textarea>
<button class="feedback-send" onclick={sendFeedback} disabled={!feedbackText.trim() || feedbackSending}>
{feedbackSending ? 'Sending...' : 'Send'}
</button>
{/if}
</div>
</div>
{/if}
<style>
:global(body) {
background:
@@ -633,4 +681,63 @@
border-top: 1px solid var(--shell-line);
}
}
.rail-feedback-btn {
display: flex;
align-items: center;
gap: 6px;
padding: 6px 10px;
border-radius: 8px;
border: 1px dashed rgba(35,26,17,0.12);
background: none;
color: #8c7b69;
font-size: 0.72rem;
font-family: var(--font);
cursor: pointer;
transition: all 160ms;
}
.rail-feedback-btn:hover { background: rgba(255,248,241,0.8); color: #1e1812; }
.feedback-overlay {
position: fixed; inset: 0; z-index: 90;
background: rgba(18,12,8,0.34); backdrop-filter: blur(8px);
display: flex; align-items: center; justify-content: center;
}
.feedback-modal {
width: min(420px, calc(100vw - 32px));
padding: 24px;
border-radius: 22px;
background: linear-gradient(180deg, rgba(255,250,242,0.98), rgba(247,239,229,0.98));
box-shadow: 0 24px 60px rgba(35,24,15,0.2);
}
.feedback-header {
font-size: 1.1rem; font-weight: 700; color: #1e1812; margin-bottom: 4px;
}
.feedback-sub {
font-size: 0.78rem; color: #8c7b69; margin-bottom: 14px;
}
.feedback-input {
width: 100%; min-height: 100px; padding: 12px;
border-radius: 12px; border: 1px solid rgba(35,26,17,0.12);
background: rgba(255,255,255,0.6); color: #1e1812;
font-size: 0.92rem; font-family: var(--font); resize: vertical;
outline: none; margin-bottom: 12px;
}
.feedback-input:focus { border-color: rgba(35,26,17,0.3); }
.feedback-send {
width: 100%; padding: 12px;
border-radius: 12px; border: none;
background: #5d4737; color: white;
font-size: 0.92rem; font-weight: 600;
font-family: var(--font); cursor: pointer;
transition: opacity 160ms;
}
.feedback-send:hover { opacity: 0.9; }
.feedback-send:disabled { opacity: 0.4; cursor: not-allowed; }
.feedback-success {
display: flex; flex-direction: column;
align-items: center; gap: 10px;
padding: 32px; font-size: 1.1rem;
font-weight: 600; color: #059669;
}
</style>

View File

@@ -84,12 +84,16 @@
.dock-head,
.dock-card {
pointer-events: auto;
backdrop-filter: blur(16px);
border: 1px solid rgba(35, 26, 17, 0.1);
box-shadow: 0 24px 40px rgba(35, 24, 15, 0.14);
}
.dock-head,
.dock-card {
pointer-events: none;
}
.dock-head {
display: flex;
align-items: center;
@@ -209,6 +213,7 @@
}
.dock-action {
pointer-events: auto;
justify-self: start;
padding: 8px 12px;
border-radius: 999px;

View File

@@ -98,10 +98,39 @@ def handle_feedback(handler, body, user):
)
resp = urllib.request.urlopen(req, timeout=10)
result = json.loads(resp.read())
issue_number = result.get("number")
# Upload image attachment if provided
image_b64 = data.get("image")
if image_b64 and issue_number:
import base64
try:
img_bytes = base64.b64decode(image_b64)
boundary = "----FeedbackBoundary123"
body_parts = []
body_parts.append(f"--{boundary}\r\n".encode())
body_parts.append(b'Content-Disposition: form-data; name="attachment"; filename="screenshot.png"\r\n')
body_parts.append(b"Content-Type: image/png\r\n\r\n")
body_parts.append(img_bytes)
body_parts.append(f"\r\n--{boundary}--\r\n".encode())
multipart_body = b"".join(body_parts)
img_req = urllib.request.Request(
f"{GITEA_URL}/api/v1/repos/{REPO}/issues/{issue_number}/assets",
data=multipart_body,
headers={
"Authorization": f"token {GITEA_TOKEN}",
"Content-Type": f"multipart/form-data; boundary={boundary}",
},
method="POST",
)
urllib.request.urlopen(img_req, timeout=15)
except Exception as img_err:
pass # Image upload failed but issue was created
handler._send_json({
"ok": True,
"issue_number": result.get("number"),
"issue_number": issue_number,
"url": result.get("html_url"),
})
except Exception as e:

View File

@@ -1,4 +1,5 @@
import SwiftUI
import PhotosUI
struct FeedbackButton: View {
@State private var showSheet = false
@@ -15,7 +16,7 @@ struct FeedbackButton: View {
}
.sheet(isPresented: $showSheet) {
FeedbackSheet()
.presentationDetents([.medium])
.presentationDetents([.large, .medium])
}
}
}
@@ -26,6 +27,9 @@ struct FeedbackSheet: View {
@State private var isSending = false
@State private var sent = false
@State private var error: String?
@State private var selectedPhoto: PhotosPickerItem?
@State private var photoData: Data?
@State private var photoPreview: UIImage?
var body: some View {
VStack(spacing: 16) {
@@ -64,6 +68,43 @@ struct FeedbackSheet: View {
.stroke(Color.textTertiary.opacity(0.2), lineWidth: 1)
)
// Photo attachment
HStack {
PhotosPicker(selection: $selectedPhoto, matching: .screenshots) {
HStack(spacing: 6) {
Image(systemName: "camera.fill")
.font(.caption)
Text(photoPreview != nil ? "Change photo" : "Add screenshot")
.font(.caption.weight(.medium))
}
.foregroundStyle(Color.accentWarm)
.padding(.horizontal, 12)
.padding(.vertical, 8)
.background(Color.accentWarm.opacity(0.1))
.clipShape(Capsule())
}
if let preview = photoPreview {
Image(uiImage: preview)
.resizable()
.scaledToFill()
.frame(width: 40, height: 40)
.clipShape(RoundedRectangle(cornerRadius: 8))
Button {
photoData = nil
photoPreview = nil
selectedPhoto = nil
} label: {
Image(systemName: "xmark.circle.fill")
.font(.caption)
.foregroundStyle(Color.textTertiary)
}
}
Spacer()
}
if let error {
Text(error)
.font(.caption)
@@ -90,6 +131,15 @@ struct FeedbackSheet: View {
}
.padding()
.background(Color.surfaceCard)
.onChange(of: selectedPhoto) {
Task {
if let item = selectedPhoto,
let data = try? await item.loadTransferable(type: Data.self) {
photoData = data
photoPreview = UIImage(data: data)
}
}
}
}
private func sendFeedback() {
@@ -98,7 +148,10 @@ struct FeedbackSheet: View {
Task {
do {
let body: [String: String] = ["text": text.trimmingCharacters(in: .whitespaces), "source": "ios"]
var body: [String: Any] = ["text": text.trimmingCharacters(in: .whitespaces), "source": "ios"]
if let imgData = photoData {
body["image"] = imgData.base64EncodedString()
}
let jsonData = try JSONSerialization.data(withJSONObject: body)
let data = try await APIClient.shared.rawPost(path: "/api/feedback", data: jsonData)