// // AdminFeedbackShotsView.swift // FTStore Admin // // The screenshots on a report or a reply, and what can be done about them. // // A bug report is very often a picture, so this is frequently the most useful // thing on the screen. It sits with the text rather than behind a disclosure, // and tapping one opens it full size, because the detail that matters is // usually small. // // Two actions, and the difference between them is the whole point. Hiding is // reversible: the row and the file stay, the picture stops being served to the // board, and whoever uploaded it still sees it marked as removed. Deleting is // admin-only, takes the bytes off the disk, and has no undo. // // Hiding deliberately frees nothing, so the size stays on screen either way — // a takedown that quietly stopped counting would make the storage figure a // story about what is visible rather than about what is stored. // import SwiftUI struct AdminFeedbackShotsView: View { @EnvironmentObject private var session: AdminSession let shots: [AdminFeedbackAttachment] /// Reload the thread, so a hide or a delete is reflected rather than assumed. let onChanged: () async -> Void /// Where a failure goes. The thread already has one banner; a second place /// for errors on the same screen would be a second place to look. @Binding var banner: String? @State private var _busyId: Int? @State private var _viewing: AdminFeedbackAttachment? @State private var _deleting: AdminFeedbackAttachment? private static let _height: CGFloat = 110 var body: some View { if !shots.isEmpty { ScrollView(.horizontal, showsIndicators: false) { HStack(alignment: .top, spacing: 10) { ForEach(shots) { shot in _tile(shot) } } .padding(.vertical, 2) } .fullScreenCover(item: $_viewing) { shot in AdminShotViewer(shot: shot) } .alert(item: $_deleting) { shot in Alert( title: Text("Delete this picture?"), message: Text("The file goes off the disk and there is no undo. To take it off the board reversibly, hide it instead."), primaryButton: .destructive(Text("Delete")) { _delete(shot) }, secondaryButton: .cancel() ) } } } @ViewBuilder private func _tile(_ shot: AdminFeedbackAttachment) -> some View { VStack(alignment: .leading, spacing: 4) { Button { _viewing = shot } label: { AsyncImage(url: AdminAPI.mediaURL(shot.url)) { phase in switch phase { case .success(let image): image.resizable().scaledToFill() case .failure: // Worth saying rather than showing a grey rectangle that // looks like it is still trying. Image(systemName: "exclamationmark.triangle") .foregroundStyle(.secondary) default: ProgressView() } } .frame( width: Self._height * shot.aspectRatio, height: Self._height ) .background(Color(uiColor: .tertiarySystemFill)) .clipShape(RoundedRectangle(cornerRadius: 10, style: .continuous)) .opacity(shot.hidden ? 0.45 : 1) } .buttonStyle(.plain) HStack(spacing: 6) { Text(AdminFormat.bytes(Int64(shot.bytes))) .font(.caption2) .foregroundStyle(.secondary) if shot.hidden { Text("Hidden") .font(.caption2.weight(.semibold)) .foregroundStyle(.orange) } Spacer(minLength: 0) if _busyId == shot.id { ProgressView().controlSize(.mini) } else { Button { _setHidden(shot, to: !shot.hidden) } label: { Image(systemName: shot.hidden ? "eye" : "eye.slash") .font(.caption) } .buttonStyle(.plain) .foregroundStyle(shot.hidden ? .green : .orange) if session.isAdmin { Button { _deleting = shot } label: { Image(systemName: "trash") .font(.caption) } .buttonStyle(.plain) .foregroundStyle(.red) } } } .frame(width: Self._height * shot.aspectRatio) } } // MARK: Actions private func _setHidden(_ shot: AdminFeedbackAttachment, to hidden: Bool) { _run(shot) { _ = try await session.act( .put, "/api/feedback/attachments/\(shot.id)", body: AdminRequest.FeedbackAttachmentModeration(hidden: hidden) ) } } private func _delete(_ shot: AdminFeedbackAttachment) { _run(shot) { _ = try await session.act(.delete, "/api/feedback/attachments/\(shot.id)") } } private func _run( _ shot: AdminFeedbackAttachment, _ operation: @escaping @MainActor () async throws -> Void ) { _busyId = shot.id Task { @MainActor in do { try await operation() } catch { banner = (error as? LocalizedError)?.errorDescription ?? error.localizedDescription } _busyId = nil await onChanged() } } } // MARK: - Viewer /// One screenshot, as large as the screen will make it. /// /// Pinch to zoom, because the reason a screenshot was attached is often a detail /// in a corner of it. `MagnificationGesture` rather than the iOS 17 /// `MagnifyGesture`, matching the rest of the codebase's deployment target. struct AdminShotViewer: View { @Environment(\.dismiss) private var dismiss let shot: AdminFeedbackAttachment @State private var _zoom: CGFloat = 1 @State private var _committedZoom: CGFloat = 1 var body: some View { ZStack { Color.black.ignoresSafeArea() AsyncImage(url: AdminAPI.mediaURL(shot.url)) { phase in switch phase { case .success(let image): image .resizable() .scaledToFit() .scaleEffect(_zoom) .gesture( MagnificationGesture() .onChanged { scale in _zoom = max(1, _committedZoom * scale) } .onEnded { _ in _committedZoom = _zoom } ) case .failure: Label("That picture couldn't be loaded.", systemImage: "exclamationmark.triangle") .foregroundStyle(.white) default: ProgressView().tint(.white) } } } .overlay(alignment: .topTrailing) { Button { dismiss() } label: { Image(systemName: "xmark") .font(.headline) .foregroundStyle(.white) .padding(12) .background(.ultraThinMaterial, in: Circle()) } .padding() } .statusBarHidden() } }