feat: multi-photo support in feedback (up to 5 screenshots)
iOS: - PhotosPicker with maxSelectionCount: 5 - Horizontal scroll preview strip with individual remove buttons - Sends "images" array instead of single "image" Server: - Gateway accepts both "image" (single, backwards compat) and "images" (array) fields - Uploads each as separate Gitea issue attachment Also closed Gitea issues #11, #12, #13, #14. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -100,16 +100,23 @@ def handle_feedback(handler, body, user):
|
|||||||
result = json.loads(resp.read())
|
result = json.loads(resp.read())
|
||||||
issue_number = result.get("number")
|
issue_number = result.get("number")
|
||||||
|
|
||||||
# Upload image attachment if provided
|
# Upload image attachments if provided
|
||||||
image_b64 = data.get("image")
|
if issue_number:
|
||||||
if image_b64 and issue_number:
|
|
||||||
import base64
|
import base64
|
||||||
|
# Support single "image" or multiple "images" array
|
||||||
|
images_b64 = data.get("images", [])
|
||||||
|
single_image = data.get("image")
|
||||||
|
if single_image:
|
||||||
|
images_b64.insert(0, single_image)
|
||||||
|
|
||||||
|
for idx, img_b64 in enumerate(images_b64):
|
||||||
try:
|
try:
|
||||||
img_bytes = base64.b64decode(image_b64)
|
img_bytes = base64.b64decode(img_b64)
|
||||||
boundary = "----FeedbackBoundary123"
|
filename = f"screenshot{'_' + str(idx + 1) if idx > 0 else ''}.png"
|
||||||
|
boundary = f"----FeedbackBoundary{idx}"
|
||||||
body_parts = []
|
body_parts = []
|
||||||
body_parts.append(f"--{boundary}\r\n".encode())
|
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(f'Content-Disposition: form-data; name="attachment"; filename="{filename}"\r\n'.encode())
|
||||||
body_parts.append(b"Content-Type: image/png\r\n\r\n")
|
body_parts.append(b"Content-Type: image/png\r\n\r\n")
|
||||||
body_parts.append(img_bytes)
|
body_parts.append(img_bytes)
|
||||||
body_parts.append(f"\r\n--{boundary}--\r\n".encode())
|
body_parts.append(f"\r\n--{boundary}--\r\n".encode())
|
||||||
@@ -125,7 +132,7 @@ def handle_feedback(handler, body, user):
|
|||||||
method="POST",
|
method="POST",
|
||||||
)
|
)
|
||||||
urllib.request.urlopen(img_req, timeout=15)
|
urllib.request.urlopen(img_req, timeout=15)
|
||||||
except Exception as img_err:
|
except Exception:
|
||||||
pass # Image upload failed but issue was created
|
pass # Image upload failed but issue was created
|
||||||
|
|
||||||
handler._send_json({
|
handler._send_json({
|
||||||
|
|||||||
@@ -27,9 +27,8 @@ struct FeedbackSheet: View {
|
|||||||
@State private var isSending = false
|
@State private var isSending = false
|
||||||
@State private var sent = false
|
@State private var sent = false
|
||||||
@State private var error: String?
|
@State private var error: String?
|
||||||
@State private var selectedPhoto: PhotosPickerItem?
|
@State private var selectedPhotos: [PhotosPickerItem] = []
|
||||||
@State private var photoData: Data?
|
@State private var photoPreviews: [(Data, UIImage)] = []
|
||||||
@State private var photoPreview: UIImage?
|
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
VStack(spacing: 16) {
|
VStack(spacing: 16) {
|
||||||
@@ -68,13 +67,17 @@ struct FeedbackSheet: View {
|
|||||||
.stroke(Color.textTertiary.opacity(0.2), lineWidth: 1)
|
.stroke(Color.textTertiary.opacity(0.2), lineWidth: 1)
|
||||||
)
|
)
|
||||||
|
|
||||||
// Photo attachment
|
// Photo attachments
|
||||||
HStack {
|
VStack(alignment: .leading, spacing: 8) {
|
||||||
PhotosPicker(selection: $selectedPhoto, matching: .screenshots) {
|
PhotosPicker(
|
||||||
|
selection: $selectedPhotos,
|
||||||
|
maxSelectionCount: 5,
|
||||||
|
matching: .images
|
||||||
|
) {
|
||||||
HStack(spacing: 6) {
|
HStack(spacing: 6) {
|
||||||
Image(systemName: "camera.fill")
|
Image(systemName: "camera.fill")
|
||||||
.font(.caption)
|
.font(.caption)
|
||||||
Text(photoPreview != nil ? "Change photo" : "Add screenshot")
|
Text(photoPreviews.isEmpty ? "Add screenshots" : "Add more")
|
||||||
.font(.caption.weight(.medium))
|
.font(.caption.weight(.medium))
|
||||||
}
|
}
|
||||||
.foregroundStyle(Color.accentWarm)
|
.foregroundStyle(Color.accentWarm)
|
||||||
@@ -84,25 +87,34 @@ struct FeedbackSheet: View {
|
|||||||
.clipShape(Capsule())
|
.clipShape(Capsule())
|
||||||
}
|
}
|
||||||
|
|
||||||
if let preview = photoPreview {
|
if !photoPreviews.isEmpty {
|
||||||
Image(uiImage: preview)
|
ScrollView(.horizontal, showsIndicators: false) {
|
||||||
|
HStack(spacing: 8) {
|
||||||
|
ForEach(Array(photoPreviews.enumerated()), id: \.offset) { index, item in
|
||||||
|
ZStack(alignment: .topTrailing) {
|
||||||
|
Image(uiImage: item.1)
|
||||||
.resizable()
|
.resizable()
|
||||||
.scaledToFill()
|
.scaledToFill()
|
||||||
.frame(width: 40, height: 40)
|
.frame(width: 60, height: 60)
|
||||||
.clipShape(RoundedRectangle(cornerRadius: 8))
|
.clipShape(RoundedRectangle(cornerRadius: 8))
|
||||||
|
|
||||||
Button {
|
Button {
|
||||||
photoData = nil
|
photoPreviews.remove(at: index)
|
||||||
photoPreview = nil
|
if index < selectedPhotos.count {
|
||||||
selectedPhoto = nil
|
selectedPhotos.remove(at: index)
|
||||||
|
}
|
||||||
} label: {
|
} label: {
|
||||||
Image(systemName: "xmark.circle.fill")
|
Image(systemName: "xmark.circle.fill")
|
||||||
.font(.caption)
|
.font(.system(size: 16))
|
||||||
.foregroundStyle(Color.textTertiary)
|
.foregroundStyle(.white)
|
||||||
|
.background(Circle().fill(.black.opacity(0.5)))
|
||||||
|
}
|
||||||
|
.offset(x: 4, y: -4)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Spacer()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if let error {
|
if let error {
|
||||||
@@ -131,14 +143,17 @@ struct FeedbackSheet: View {
|
|||||||
}
|
}
|
||||||
.padding()
|
.padding()
|
||||||
.background(Color.surfaceCard)
|
.background(Color.surfaceCard)
|
||||||
.onChange(of: selectedPhoto) {
|
.onChange(of: selectedPhotos) {
|
||||||
Task {
|
Task {
|
||||||
if let item = selectedPhoto,
|
var newPreviews: [(Data, UIImage)] = []
|
||||||
let data = try? await item.loadTransferable(type: Data.self) {
|
for item in selectedPhotos {
|
||||||
photoData = data
|
if let data = try? await item.loadTransferable(type: Data.self),
|
||||||
photoPreview = UIImage(data: data)
|
let image = UIImage(data: data) {
|
||||||
|
newPreviews.append((data, image))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
photoPreviews = newPreviews
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -149,9 +164,13 @@ struct FeedbackSheet: View {
|
|||||||
|
|
||||||
Task { @MainActor in
|
Task { @MainActor in
|
||||||
do {
|
do {
|
||||||
var body: [String: Any] = ["text": text.trimmingCharacters(in: .whitespaces), "source": "ios"]
|
var body: [String: Any] = [
|
||||||
if let imgData = photoData {
|
"text": text.trimmingCharacters(in: .whitespaces),
|
||||||
body["image"] = imgData.base64EncodedString()
|
"source": "ios"
|
||||||
|
]
|
||||||
|
if !photoPreviews.isEmpty {
|
||||||
|
let images = photoPreviews.map { $0.0.base64EncodedString() }
|
||||||
|
body["images"] = images
|
||||||
}
|
}
|
||||||
let jsonData = try JSONSerialization.data(withJSONObject: body)
|
let jsonData = try JSONSerialization.data(withJSONObject: body)
|
||||||
let data = try await APIClient.shared.rawPost(path: "/api/feedback", data: jsonData)
|
let data = try await APIClient.shared.rawPost(path: "/api/feedback", data: jsonData)
|
||||||
|
|||||||
Reference in New Issue
Block a user