Files
FTStore/admin/FTStoreAdmin/App/AdminFeedbackShotsView.swift
Felitendo 3e62b98577 refactor(admin): FTStoreAdmin/Console is FTStoreAdmin/App
The last place still calling the operator app a console. The Xcode project uses
a synchronized root group pointed at FTStoreAdmin itself, so it never named the
subfolder and the rename is a git mv and nothing else.

App/ rather than Screens/, because the folder holds the transport, the session
and the wire types as well as the views, and because the distinction that
actually matters here is against Support/: this app's own code, versus helpers
inlined from elsewhere with a note saying where they came from.

Both AGENTS.md files follow, including the note recording that the history runs
back through manager/FTStore/Admin/ — it now says the middle stretch was
Console/, since that is the part a git log --follow has to cross.
2026-08-10 21:16:57 +02:00

226 lines
6.0 KiB
Swift

//
// 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()
}
}