feat: feedback with photo support + web dashboard feedback button
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:
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
Reference in New Issue
Block a user