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.
226 lines
6.0 KiB
Swift
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()
|
|
}
|
|
}
|