krz/hutch

an ios client for sourcehut

clone: git clone https://gitbay.org/krz/hutch.git

v2.1: Hutch/Views/Pastes/PasteListView.swift · raw

  1import SwiftUI
  2
  3struct PasteListView: View {
  4    @Environment(AppState.self) private var appState
  5    @State private var viewModel: PasteListViewModel?
  6    @State private var showCreatePasteSheet = false
  7    @State private var createdPaste: Paste?
  8
  9    var body: some View {
 10        Group {
 11            if let viewModel {
 12                content(viewModel)
 13            } else {
 14                SRHTLoadingStateView(message: "Loading pastes…")
 15            }
 16        }
 17        .navigationTitle("Pastes")
 18        .toolbar {
 19            if viewModel != nil {
 20                ToolbarItem(placement: .topBarTrailing) {
 21                    Button {
 22                        showCreatePasteSheet = true
 23                    } label: {
 24                        Image(systemName: "plus")
 25                    }
 26                }
 27            }
 28        }
 29        .sheet(isPresented: $showCreatePasteSheet) {
 30            if let viewModel {
 31                CreatePasteSheet(viewModel: viewModel) { paste in
 32                    showCreatePasteSheet = false
 33                    createdPaste = paste
 34                }
 35            }
 36        }
 37        .navigationDestination(isPresented: Binding(
 38            get: { createdPaste != nil },
 39            set: { isPresented in
 40                if !isPresented {
 41                    createdPaste = nil
 42                }
 43            }
 44        )) {
 45            if let createdPaste {
 46                PasteDetailView(
 47                    paste: createdPaste,
 48                    onUpdated: { updated in
 49                        viewModel?.upsertPaste(updated)
 50                    },
 51                    onDeleted: { id in
 52                        viewModel?.removePaste(id: id)
 53                    }
 54                )
 55            }
 56        }
 57        .task {
 58            if viewModel == nil {
 59                let vm = PasteListViewModel(service: PasteService(client: appState.client))
 60                viewModel = vm
 61                await vm.loadPastes()
 62            }
 63        }
 64    }
 65
 66    @ViewBuilder
 67    private func content(_ viewModel: PasteListViewModel) -> some View {
 68        @Bindable var vm = viewModel
 69
 70        List {
 71            ForEach(viewModel.pastes) { paste in
 72                NavigationLink(value: paste) {
 73                    PasteRowView(paste: paste)
 74                }
 75                .task {
 76                    await viewModel.loadMoreIfNeeded(currentItem: paste)
 77                }
 78            }
 79
 80            if viewModel.isLoadingMore {
 81                HStack {
 82                    Spacer()
 83                    ProgressView()
 84                    Spacer()
 85                }
 86                .listRowSeparator(.hidden)
 87            }
 88        }
 89        .listStyle(.plain)
 90        .overlay {
 91            if viewModel.isLoading, viewModel.pastes.isEmpty {
 92                SRHTLoadingStateView(message: "Loading pastes…")
 93            } else if let error = viewModel.error, viewModel.pastes.isEmpty {
 94                SRHTErrorStateView(
 95                    title: "Couldn't Load Pastes",
 96                    message: error,
 97                    retryAction: { await viewModel.loadPastes() }
 98                )
 99            } else if viewModel.pastes.isEmpty {
100                ContentUnavailableView(
101                    "No Pastes",
102                    systemImage: "doc.on.clipboard",
103                    description: Text("Your pastes will appear here.")
104                )
105            }
106        }
107        .connectivityOverlay(hasContent: !viewModel.pastes.isEmpty) {
108            await viewModel.loadPastes()
109        }
110        .srhtErrorBanner(error: $vm.error)
111        .refreshable {
112            await viewModel.loadPastes()
113        }
114        .navigationDestination(for: Paste.self) { paste in
115            PasteDetailView(
116                paste: paste,
117                onUpdated: { updated in
118                    viewModel.upsertPaste(updated)
119                },
120                onDeleted: { id in
121                    viewModel.removePaste(id: id)
122                }
123            )
124        }
125    }
126}
127
128private struct PasteRowView: View {
129    let paste: Paste
130
131    var body: some View {
132        HStack(alignment: .top, spacing: 12) {
133            Image(systemName: "doc.text")
134                .foregroundStyle(.secondary)
135                .frame(width: 20)
136
137            VStack(alignment: .leading, spacing: 4) {
138                Text(primaryTitle)
139                    .font(.subheadline.weight(.medium))
140                    .lineLimit(1)
141
142                Text(secondaryLine)
143                    .font(.caption)
144                    .foregroundStyle(.secondary)
145                    .lineLimit(2)
146
147                HStack(spacing: 8) {
148                    VisibilityBadge(visibility: paste.visibility)
149                    Text("")
150                        .foregroundStyle(.tertiary)
151                    Text(paste.created.relativeDescription)
152                        .foregroundStyle(.tertiary)
153                }
154                .font(.caption2)
155            }
156        }
157        .padding(.vertical, 2)
158    }
159
160    private var primaryTitle: String {
161        if let filename = paste.files.first?.filename, !filename.isEmpty {
162            return filename
163        }
164        return paste.files.count > 1 ? "Untitled Paste (\(paste.files.count) files)" : "Untitled Paste"
165    }
166
167    private var secondaryLine: String {
168        var parts: [String] = [paste.user.canonicalName]
169        if paste.files.count > 1 {
170            parts.append("\(paste.files.count) files")
171        } else {
172            parts.append("1 file")
173        }
174        if let firstHash = paste.files.first?.hash {
175            parts.append(String(firstHash.prefix(8)))
176        }
177        return parts.joined(separator: "")
178    }
179}
180
181private struct CreatePasteSheet: View {
182    let viewModel: PasteListViewModel
183    let onCreated: (Paste) -> Void
184
185    @Environment(\.dismiss) private var dismiss
186    @State private var files = [PasteUploadDraft()]
187    @State private var visibility: Visibility = .unlisted
188
189    var body: some View {
190        NavigationStack {
191            Form {
192                Section("Files") {
193                    ForEach($files) { $file in
194                        VStack(alignment: .leading, spacing: 8) {
195                            TextField("Filename (optional)", text: $file.filename)
196                                .autocorrectionDisabled()
197                                .textInputAutocapitalization(.never)
198
199                            ZStack(alignment: .topLeading) {
200                                if file.contents.isEmpty {
201                                    Text("Paste contents")
202                                        .foregroundStyle(.tertiary)
203                                        .padding(.top, 8)
204                                        .padding(.leading, 5)
205                                        .allowsHitTesting(false)
206                                }
207
208                                TextEditor(text: $file.contents)
209                                    .font(.system(.body, design: .monospaced))
210                                    .frame(minHeight: 180)
211                            }
212                        }
213                        .padding(.vertical, 4)
214                    }
215                    .onDelete { offsets in
216                        files.remove(atOffsets: offsets)
217                        if files.isEmpty {
218                            files = [PasteUploadDraft()]
219                        }
220                    }
221
222                    Button {
223                        files.append(PasteUploadDraft())
224                    } label: {
225                        Label("Add File", systemImage: "plus")
226                    }
227                }
228
229                Section("Visibility") {
230                    Picker("Visibility", selection: $visibility) {
231                        Text("Public").tag(Visibility.public)
232                        Text("Unlisted").tag(Visibility.unlisted)
233                        Text("Private").tag(Visibility.private)
234                    }
235                }
236
237                Section {
238                    Text("Paste contents are uploaded as UTF-8 text files. Hutch can change visibility later, but the API does not support editing file contents after creation.")
239                        .font(.footnote)
240                        .foregroundStyle(.secondary)
241                }
242
243                if let error = viewModel.error {
244                    Section {
245                        Label(error, systemImage: "exclamationmark.triangle.fill")
246                            .foregroundStyle(.red)
247                    }
248                }
249            }
250            .navigationTitle("New Paste")
251            .navigationBarTitleDisplayMode(.inline)
252            .toolbar {
253                ToolbarItem(placement: .cancellationAction) {
254                    Button("Cancel") { dismiss() }
255                }
256                ToolbarItem(placement: .confirmationAction) {
257                    Button {
258                        Task {
259                            if let paste = await viewModel.createPaste(files: files, visibility: visibility) {
260                                onCreated(paste)
261                            }
262                        }
263                    } label: {
264                        if viewModel.isCreatingPaste {
265                            ProgressView()
266                                .controlSize(.small)
267                        } else {
268                            Text("Create Paste")
269                        }
270                    }
271                    .disabled(!hasValidContent || viewModel.isCreatingPaste)
272                }
273            }
274        }
275    }
276
277    private var hasValidContent: Bool {
278        files.contains { !$0.contents.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty }
279    }
280}