diff --git a/frontend-v2/src/lib/components/layout/AppShell.svelte b/frontend-v2/src/lib/components/layout/AppShell.svelte index 7245ae0..6554d4f 100644 --- a/frontend-v2/src/lib/components/layout/AppShell.svelte +++ b/frontend-v2/src/lib/components/layout/AppShell.svelte @@ -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 @@ +
{new Intl.DateTimeFormat('en-US', { month: 'short', day: 'numeric' }).format(new Date())} @@ -246,6 +272,28 @@
+{#if feedbackOpen} + +
feedbackOpen = false}> + +
e.stopPropagation()}> + {#if feedbackSent} + + {:else} + + + + + {/if} +
+
+{/if} + diff --git a/frontend-v2/src/lib/components/media/DownloadStatusDock.svelte b/frontend-v2/src/lib/components/media/DownloadStatusDock.svelte index 2bda2ea..b0c807f 100644 --- a/frontend-v2/src/lib/components/media/DownloadStatusDock.svelte +++ b/frontend-v2/src/lib/components/media/DownloadStatusDock.svelte @@ -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; diff --git a/gateway/feedback.py b/gateway/feedback.py index d60611f..a1fa1ae 100644 --- a/gateway/feedback.py +++ b/gateway/feedback.py @@ -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: diff --git a/ios/Platform/Platform/Features/Feedback/FeedbackView.swift b/ios/Platform/Platform/Features/Feedback/FeedbackView.swift index 39057cc..091fd69 100644 --- a/ios/Platform/Platform/Features/Feedback/FeedbackView.swift +++ b/ios/Platform/Platform/Features/Feedback/FeedbackView.swift @@ -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)