summaryrefslogtreecommitdiff
path: root/Hutch/Views/Pastes/PasteDetailView.swift
diff options
context:
space:
mode:
authorChristian Cleberg <[email protected]>2026-03-19 15:18:38 -0500
committerChristian Cleberg <[email protected]>2026-03-19 15:18:38 -0500
commit659c76df9ff3926cb68886c16167808c23bd8f75 (patch)
tree572bef546fcca19ad37bc1aa7cfb153eb2bf8eb7 /Hutch/Views/Pastes/PasteDetailView.swift
parented48e72520c51e4635cd3840dbc569bdd74c950f (diff)
parent6ba4e967d5dfb5d3c7bb97a0f2662f3180595563 (diff)
downloadhutch-2.tar.gz
hutch-2.tar.bz2
hutch-2.zip
v2.0: Merge branch 'dev'v2
Diffstat (limited to 'Hutch/Views/Pastes/PasteDetailView.swift')
-rw-r--r--Hutch/Views/Pastes/PasteDetailView.swift288
1 files changed, 288 insertions, 0 deletions
diff --git a/Hutch/Views/Pastes/PasteDetailView.swift b/Hutch/Views/Pastes/PasteDetailView.swift
new file mode 100644
index 0000000..df3a8be
--- /dev/null
+++ b/Hutch/Views/Pastes/PasteDetailView.swift
@@ -0,0 +1,288 @@
+import SwiftUI
+
+struct PasteDetailView: View {
+ let paste: Paste
+ var onUpdated: ((Paste) -> Void)? = nil
+ var onDeleted: ((String) -> Void)? = nil
+
+ @Environment(AppState.self) private var appState
+ @Environment(\.dismiss) private var dismiss
+ @State private var viewModel: PasteDetailViewModel?
+ @State private var showVisibilitySheet = false
+ @State private var showDeleteConfirmation = false
+
+ var body: some View {
+ Group {
+ if let viewModel {
+ content(viewModel)
+ } else {
+ SRHTLoadingStateView(message: "Loading paste…")
+ }
+ }
+ .navigationTitle(displayTitle)
+ .navigationBarTitleDisplayMode(.inline)
+ .toolbar {
+ ToolbarItemGroup(placement: .topBarTrailing) {
+ SRHTShareButton(
+ url: currentPaste.flatMap { SRHTWebURL.paste(ownerCanonicalName: $0.user.canonicalName, pasteId: $0.id) },
+ target: .paste
+ ) {
+ Image(systemName: "square.and.arrow.up")
+ }
+
+ if viewModel != nil {
+ Menu {
+ Button {
+ showVisibilitySheet = true
+ } label: {
+ Label("Change Visibility", systemImage: "eye")
+ }
+
+ Button(role: .destructive) {
+ showDeleteConfirmation = true
+ } label: {
+ Label("Delete Paste", systemImage: "trash")
+ }
+ } label: {
+ Image(systemName: "ellipsis.circle")
+ }
+ }
+ }
+ }
+ .sheet(isPresented: $showVisibilitySheet) {
+ if let viewModel, let currentPaste {
+ PasteVisibilitySheet(
+ currentVisibility: currentPaste.visibility,
+ isUpdating: viewModel.isUpdatingVisibility
+ ) { visibility in
+ if let updated = await viewModel.updateVisibility(visibility) {
+ onUpdated?(updated)
+ showVisibilitySheet = false
+ }
+ }
+ }
+ }
+ .alert("Delete Paste?", isPresented: $showDeleteConfirmation) {
+ Button("Cancel", role: .cancel) {}
+ Button("Delete", role: .destructive) {
+ Task {
+ if await viewModel?.deletePaste() == true {
+ onDeleted?(paste.id)
+ dismiss()
+ }
+ }
+ }
+ } message: {
+ Text("This paste will be permanently removed.")
+ }
+ .task {
+ if viewModel == nil {
+ let vm = PasteDetailViewModel(
+ pasteID: paste.id,
+ initialPaste: paste,
+ service: PasteService(client: appState.client)
+ )
+ viewModel = vm
+ await vm.loadPaste()
+ }
+ }
+ }
+
+ private var currentPaste: Paste? {
+ viewModel?.paste ?? paste
+ }
+
+ private var displayTitle: String {
+ if let filename = currentPaste?.files.first?.filename, !filename.isEmpty {
+ return filename
+ }
+ return "Paste \(paste.id)"
+ }
+
+ @ViewBuilder
+ private func content(_ viewModel: PasteDetailViewModel) -> some View {
+ @Bindable var vm = viewModel
+
+ if viewModel.isLoading, viewModel.paste == nil {
+ SRHTLoadingStateView(message: "Loading paste…")
+ } else if let error = viewModel.error, viewModel.paste == nil {
+ SRHTErrorStateView(
+ title: "Couldn't Load Paste",
+ message: error,
+ retryAction: { await viewModel.loadPaste() }
+ )
+ } else if let paste = viewModel.paste {
+ List {
+ Section("Details") {
+ LabeledContent("ID", value: paste.id)
+ LabeledContent("Owner", value: paste.user.canonicalName)
+ LabeledContent("Created", value: paste.created.relativeDescription)
+ LabeledContent("Visibility", value: visibilityLabel(paste.visibility))
+ LabeledContent("Files", value: "\(paste.files.count)")
+ }
+
+ if paste.files.count > 1 {
+ Section("Files") {
+ Picker("Selected File", selection: Binding(
+ get: { viewModel.selectedFileHash ?? paste.files.first?.hash ?? "" },
+ set: { viewModel.selectFile(hash: $0) }
+ )) {
+ ForEach(paste.files) { file in
+ Text(file.filename ?? String(file.hash.prefix(8)))
+ .tag(file.hash)
+ }
+ }
+ }
+ }
+
+ if let file = viewModel.selectedFile {
+ Section("Current File") {
+ if let filename = file.filename, !filename.isEmpty {
+ LabeledContent("Filename", value: filename)
+ }
+ LabeledContent("Hash", value: file.hash)
+ }
+
+ Section {
+ if viewModel.loadingFileHashes.contains(file.hash) && viewModel.selectedFileContents == nil {
+ SRHTLoadingStateView(message: "Loading paste contents…")
+ .frame(minHeight: 180)
+ } else if let contents = viewModel.selectedFileContents {
+ PasteCodeBlock(text: contents)
+ } else {
+ Text("This file’s contents are unavailable.")
+ .foregroundStyle(.secondary)
+ }
+ } header: {
+ Text("Contents")
+ }
+ }
+ }
+ .listStyle(.insetGrouped)
+ .srhtErrorBanner(error: $vm.error)
+ .refreshable {
+ await viewModel.loadPaste()
+ }
+ }
+ }
+
+ private func visibilityLabel(_ visibility: Visibility) -> String {
+ switch visibility {
+ case .public:
+ return "Public"
+ case .unlisted:
+ return "Unlisted"
+ case .private:
+ return "Private"
+ }
+ }
+}
+
+private struct PasteCodeBlock: View {
+ let text: String
+
+ var body: some View {
+ ScrollView([.horizontal, .vertical], showsIndicators: true) {
+ Text(text.isEmpty ? " " : text)
+ .font(.system(.body, design: .monospaced))
+ .textSelection(.enabled)
+ .frame(maxWidth: .infinity, alignment: .leading)
+ .padding(.vertical, 4)
+ }
+ .frame(minHeight: 220)
+ }
+}
+
+private struct PasteVisibilitySheet: View {
+ let currentVisibility: Visibility
+ let isUpdating: Bool
+ let onSave: (Visibility) async -> Void
+
+ @Environment(\.dismiss) private var dismiss
+ @State private var visibility: Visibility
+
+ init(currentVisibility: Visibility, isUpdating: Bool, onSave: @escaping (Visibility) async -> Void) {
+ self.currentVisibility = currentVisibility
+ self.isUpdating = isUpdating
+ self.onSave = onSave
+ _visibility = State(initialValue: currentVisibility)
+ }
+
+ var body: some View {
+ NavigationStack {
+ List {
+ ForEach(visibilityOptions, id: \.self) { option in
+ Button {
+ visibility = option
+ } label: {
+ HStack {
+ VStack(alignment: .leading, spacing: 2) {
+ Text(title(for: option))
+ .foregroundStyle(.primary)
+ Text(description(for: option))
+ .font(.caption)
+ .foregroundStyle(.secondary)
+ }
+
+ Spacer()
+
+ if visibility == option {
+ Image(systemName: "checkmark")
+ .foregroundStyle(.tint)
+ }
+ }
+ .contentShape(Rectangle())
+ }
+ .buttonStyle(.plain)
+ }
+ }
+ .listStyle(.insetGrouped)
+ .navigationTitle("Visibility")
+ .navigationBarTitleDisplayMode(.inline)
+ .toolbar {
+ ToolbarItem(placement: .cancellationAction) {
+ Button("Cancel") { dismiss() }
+ }
+ ToolbarItem(placement: .confirmationAction) {
+ Button("Save") {
+ Task {
+ await onSave(visibility)
+ }
+ }
+ .disabled(isUpdating || visibility == currentVisibility)
+ }
+ }
+ .overlay {
+ if isUpdating {
+ ProgressView()
+ }
+ }
+ }
+ }
+
+ private var visibilityOptions: [Visibility] {
+ [.public, .unlisted, .private]
+ }
+
+ private func title(for visibility: Visibility) -> String {
+ switch visibility {
+ case .public:
+ "Public"
+ case .unlisted:
+ "Unlisted"
+ case .private:
+ "Private"
+ }
+ }
+
+ private func description(for visibility: Visibility) -> String {
+ switch visibility {
+ case .public:
+ "Visible to everyone and listed on your profile."
+ case .unlisted:
+ "Visible to anyone with the URL, but not listed on your profile."
+ case .private:
+ "Visible only to explicitly allowed viewers."
+ }
+ }
+}