// // AdminBoardMaintenanceView.swift // FTStore Admin // // The lever for when the boards have gone badly wrong. Mirrors the panel on // /admin/users. // // Two controls that belong together and nowhere else. The lock stops the // flood; emptying a board clears up behind it. They sit in that order because // emptying a board that is still accepting posts is a way to be back where you // started in ten minutes. // // The lock is a pause and not a shutdown: reading is untouched, and so is the // operator side, so answering and moderating still work while it is on. A lock // that also shut out the people doing the clearing would be a worse version of // turning the server off. // import SwiftUI struct AdminBoardMaintenanceView: View { @EnvironmentObject private var session: AdminSession @StateObject private var _settings = AdminLoader() @State private var _locked = false @State private var _note = "" @State private var _savedNote = "" @State private var _busy = false @State private var _banner: String? /// Which board the confirmation is about, if any. @State private var _purging: Target? /// The message writes when editing ends rather than on a button, matching /// the switch above it and Server Settings. Saving per keystroke would put a /// half-typed sentence in front of every user of the app. @FocusState private var _noteFocused: Bool private enum Target: Identifiable { case feedback, requests, both var id: String { title } var title: String { switch self { case .feedback: .localized("Empty feedback") case .requests: .localized("Empty tweak requests") case .both: .localized("Empty both") } } /// What actually goes, said plainly. The number is not knowable here, so /// the sentence names the thing rather than pretending to count it. var detail: String { switch self { case .feedback: .localized("Every report on the feedback board goes, with every reply, vote and mention on it.") case .requests: .localized("Every tweak request goes, with every reply, vote and mention on it.") case .both: .localized("Every report and every tweak request goes, with every reply, vote and mention on them.") } } var body: AdminRequest.BoardPurge { switch self { case .feedback: .init(feedback: true, tweakRequests: false) case .requests: .init(feedback: false, tweakRequests: true) case .both: .init(feedback: true, tweakRequests: true) } } } var body: some View { List { if let banner = _banner { Section { Text(banner).font(.footnote).foregroundStyle(.secondary) } } Section { Toggle(isOn: Binding(get: { _locked }, set: { _setLocked($0) })) { Label("Pause new posts", systemImage: _locked ? "pause.circle.fill" : "pause.circle") } .disabled(_busy || !_settings.hasLoaded) } header: { Text("The lock") } footer: { Text("Stops reports, requests, replies and votes coming from the app. Reading still works, and so do you: answering and moderating are unaffected, which is what makes it possible to catch up.") } Section { TextField("Optional message", text: $_note, axis: .vertical) .lineLimit(1...4) .disabled(_busy) .focused($_noteFocused) } header: { Text("What the app says") } footer: { Text("Saved when you finish typing. Left empty, the app says posting is paused and nothing more.") } Section { Button(role: .destructive) { _purging = .feedback } label: { Label(Target.feedback.title, systemImage: "trash") } Button(role: .destructive) { _purging = .requests } label: { Label(Target.requests.title, systemImage: "trash") } Button(role: .destructive) { _purging = .both } label: { Label(Target.both.title, systemImage: "trash.fill") } } header: { Text("Emptying a board") } footer: { Text("There is no undo, and a settings backup does not carry the boards. Names and mutes are kept, so nobody gets their handle back and nobody quietly stops being muted. Pause posting first, or it fills up again while you work.") } .disabled(_busy) } .navigationTitle("Board Maintenance") .navigationBarTitleDisplayMode(.inline) .refreshable { await _reload() } .task { await _reload() } // Single-argument onChange: the two-argument form is iOS 17. .onChange(of: _noteFocused) { focused in if !focused { _saveNote() } } // Leaving the screen ends editing without focus moving anywhere, so the // last thing typed would otherwise be dropped. .onDisappear { _saveNote() } .alert(item: $_purging) { target in Alert( title: Text("Delete it all?"), message: Text(verbatim: target.detail), primaryButton: .destructive(Text("Delete everything")) { _purge(target) }, secondaryButton: .cancel() ) } } // MARK: Actions private func _reload() async { await _settings.load { try await session.get("/api/settings", as: AdminSettingsResponse.self) } if let data = _settings.value?.data { _locked = data.bool("boards_locked") ?? false _savedNote = data.string("boards_locked_note") ?? "" // Not while it is being typed in: flipping the lock reloads, and // that must not replace a half-written message under the cursor. if !_noteFocused { _note = _savedNote } } } private func _setLocked(_ locked: Bool) { // Moved first so the switch does not sit under a thumb waiting for a // round trip; the reload afterwards is what makes it honest again. _locked = locked _run( AdminRequest.BoardLock(boards_locked: locked), locked ? "New posts paused." : "Boards open again." ) } private func _saveNote() { let trimmed = _note.trimmingCharacters(in: .whitespacesAndNewlines) // Nothing to say and nothing to send. Without this every dismissal of // the keyboard would be a write. guard trimmed != _savedNote else { return } // Recorded before the round trip so two triggers landing together // (focus leaving as the screen closes) do not send it twice. _savedNote = trimmed _run( AdminRequest.BoardLock(boards_locked_note: trimmed), .localized("Message saved.") ) } private func _run(_ body: AdminRequest.BoardLock, _ fallback: String) { _busy = true Task { @MainActor in do { let ack = try await session.act(.put, "/api/settings", body: body) _banner = ack.message ?? fallback } catch { _banner = (error as? LocalizedError)?.errorDescription ?? error.localizedDescription } _busy = false await _reload() } } private func _purge(_ target: Target) { _busy = true Task { @MainActor in do { let ack = try await session.act( .post, "/api/actions/purge-boards", body: target.body ) _banner = ack.summary } catch { _banner = (error as? LocalizedError)?.errorDescription ?? error.localizedDescription } _busy = false } } }