krz/hutch

an ios client for sourcehut

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

main: Hutch/Views/Pastes/PasteListView.swift · raw

  1import SwiftUI
  2
  3struct PasteListView: View {
  4    @AppStorage(AppStorageKeys.swipeActionsEnabled, store: .standard) private var swipeActionsEnabled = true
  5    @Environment(AppState.self) private var appState
  6    @State private var viewModel: PasteListViewModel?
  7    @State private var showCreatePasteSheet = false
  8    @State private var createdPaste: Paste?
  9    @State private var pasteToDelete: Paste?
 10
 11    var body: some View {
 12        Group {
 13            if let viewModel {
 14                content(viewModel)
 15            } else {
 16                SRHTLoadingStateView(message: "Loading pastes…")
 17            }
 18        }
 19        .navigationTitle("Pastes")
 20        .toolbar {
 21            if viewModel != nil {
 22                ToolbarItem(placement: .topBarTrailing) {
 23                    Button {
 24                        showCreatePasteSheet = true
 25                    } label: {
 26                        Image(systemName: "plus")
 27                    }
 28                    .accessibilityLabel("Create paste")
 29                }
 30            }
 31        }
 32        .sheet(isPresented: $showCreatePasteSheet) {
 33            if let viewModel {
 34                CreatePasteSheet(viewModel: viewModel) { paste in
 35                    showCreatePasteSheet = false
 36                    createdPaste = paste
 37                }
 38            }
 39        }
 40        .navigationDestination(isPresented: Binding(
 41            get: { createdPaste != nil },
 42            set: { isPresented in
 43                if !isPresented {
 44                    createdPaste = nil
 45                }
 46            }
 47        )) {
 48            if let createdPaste {
 49                PasteDetailView(
 50                    paste: createdPaste,
 51                    onUpdated: { updated in
 52                        viewModel?.upsertPaste(updated)
 53                    },
 54                    onDeleted: { id in
 55                        viewModel?.removePaste(id: id)
 56                    }
 57                )
 58            }
 59        }
 60        .task {
 61            if viewModel == nil {
 62                let vm = PasteListViewModel(service: PasteService(client: appState.client))
 63                viewModel = vm
 64                await vm.loadPastes()
 65            }
 66        }
 67    }
 68
 69    @ViewBuilder
 70    private func content(_ viewModel: PasteListViewModel) -> some View {
 71        @Bindable var vm = viewModel
 72
 73        List {
 74            ForEach(viewModel.filteredPastes) { paste in
 75                NavigationLink(value: paste) {
 76                    PasteRowView(paste: paste)
 77                        .equatable()
 78                }
 79                .swipeActions(edge: .leading, allowsFullSwipe: true) {
 80                    if swipeActionsEnabled {
 81                        Button {
 82                            Task { await viewModel.cycleVisibility(for: paste) }
 83                        } label: {
 84                            Label(
 85                                nextVisibilityLabel(for: paste.visibility),
 86                                systemImage: nextVisibilityIcon(for: paste.visibility)
 87                            )
 88                        }
 89                        .tint(nextVisibilityColor(for: paste.visibility))
 90                    }
 91                }
 92                .swipeActions(edge: .trailing, allowsFullSwipe: false) {
 93                    if swipeActionsEnabled {
 94                        Button {
 95                            pasteToDelete = paste
 96                        } label: {
 97                            Label("Delete", systemImage: "trash")
 98                        }
 99                        .tint(.red)
100                    }
101                }
102                .task {
103                    await viewModel.loadMoreIfNeeded(currentItem: paste)
104                }
105            }
106            .themedRow()
107
108            if viewModel.isLoadingMore {
109                HStack {
110                    Spacer()
111                    ProgressView()
112                    Spacer()
113                }
114                .listRowSeparator(.hidden)
115                .themedRow()
116            }
117        }
118        .themedList()
119        .listStyle(.plain)
120        .searchable(
121            text: $vm.searchText,
122            placement: .navigationBarDrawer(displayMode: .always),
123            prompt: "Search pastes"
124        )
125        .overlay {
126            if viewModel.isLoading, viewModel.pastes.isEmpty {
127                SRHTLoadingStateView(message: "Loading pastes…")
128            } else if let error = viewModel.error, viewModel.pastes.isEmpty {
129                SRHTErrorStateView(
130                    title: "Couldn't Load Pastes",
131                    message: error,
132                    retryAction: { await viewModel.loadPastes() }
133                )
134            } else if !viewModel.pastes.isEmpty, viewModel.filteredPastes.isEmpty {
135                ContentUnavailableView.search(text: viewModel.searchText)
136            } else if viewModel.pastes.isEmpty {
137                ContentUnavailableView(
138                    "No Pastes",
139                    systemImage: "doc.on.clipboard",
140                    description: Text("Your pastes will appear here.")
141                )
142            }
143        }
144        .connectivityOverlay(hasContent: !viewModel.pastes.isEmpty) {
145            await viewModel.loadPastes()
146        }
147        .srhtErrorBanner(error: $vm.error)
148        .alert("Delete Paste?", isPresented: Binding(
149            get: { pasteToDelete != nil },
150            set: { if !$0 { pasteToDelete = nil } }
151        )) {
152            Button("Cancel", role: .cancel) {
153                pasteToDelete = nil
154            }
155            Button("Delete", role: .destructive) {
156                if let paste = pasteToDelete {
157                    pasteToDelete = nil
158                    Task {
159                        await viewModel.deletePaste(paste)
160                    }
161                }
162            }
163        } message: {
164            Text("This paste will be permanently deleted from SourceHut.")
165        }
166        .refreshable {
167            await viewModel.loadPastes()
168        }
169        .navigationDestination(for: Paste.self) { paste in
170            PasteDetailView(
171                paste: paste,
172                onUpdated: { updated in
173                    viewModel.upsertPaste(updated)
174                },
175                onDeleted: { id in
176                    viewModel.removePaste(id: id)
177                }
178            )
179        }
180    }
181
182    private func nextVisibilityLabel(for visibility: Visibility) -> String {
183        switch visibility {
184        case .publicVisibility:
185            return "Make Unlisted"
186        case .unlisted:
187            return "Make Private"
188        case .privateVisibility:
189            return "Make Public"
190        }
191    }
192
193    private func nextVisibilityIcon(for visibility: Visibility) -> String {
194        switch visibility {
195        case .publicVisibility:
196            return "eye.slash"
197        case .unlisted:
198            return "lock"
199        case .privateVisibility:
200            return "globe"
201        }
202    }
203
204    private func nextVisibilityColor(for visibility: Visibility) -> Color {
205        switch visibility {
206        case .publicVisibility:
207            return .orange
208        case .unlisted:
209            return .red
210        case .privateVisibility:
211            return .green
212        }
213    }
214}
215
216private struct PasteRowView: View, Equatable {
217    let paste: Paste
218
219    var body: some View {
220        HStack(alignment: .top, spacing: 12) {
221            Image(systemName: "doc.text")
222                .foregroundStyle(.secondary)
223                .frame(width: 20)
224
225            VStack(alignment: .leading, spacing: 4) {
226                Text(primaryTitle)
227                    .font(.subheadline.weight(.medium))
228                    .lineLimit(1)
229
230                Text(secondaryLine)
231                    .font(.caption)
232                    .foregroundStyle(.secondary)
233                    .lineLimit(2)
234
235                HStack(spacing: 8) {
236                    VisibilityBadge(visibility: paste.visibility)
237                    Text("")
238                        .foregroundStyle(.tertiary)
239                    Text(paste.created.relativeDescription)
240                        .foregroundStyle(.tertiary)
241                }
242                .font(.caption2)
243            }
244        }
245        .padding(.vertical, 2)
246    }
247
248    private var primaryTitle: String {
249        if let filename = paste.files.first?.filename, !filename.isEmpty {
250            return filename
251        }
252        return paste.files.count > 1 ? "Untitled Paste (\(paste.files.count) files)" : "Untitled Paste"
253    }
254
255    private var secondaryLine: String {
256        var parts: [String] = [paste.user.canonicalName]
257        if paste.files.count > 1 {
258            parts.append("\(paste.files.count) files")
259        } else {
260            parts.append("1 file")
261        }
262        if let firstHash = paste.files.first?.hash {
263            parts.append(String(firstHash.prefix(8)))
264        }
265        return parts.joined(separator: "")
266    }
267}
268
269private struct CreatePasteSheet: View {
270    let viewModel: PasteListViewModel
271    let onCreated: (Paste) -> Void
272
273    @Environment(\.dismiss) private var dismiss
274    @State private var files = [PasteUploadDraft()]
275    @State private var visibility: Visibility = .unlisted
276
277    var body: some View {
278        NavigationStack {
279            Form {
280                Section("Files") {
281                    ForEach($files) { fileBinding in
282                        VStack(alignment: .leading, spacing: 8) {
283                            TextField("Filename (optional)", text: fileBinding.filename)
284                                .autocorrectionDisabled()
285                                .textInputAutocapitalization(.never)
286
287                            ZStack(alignment: .topLeading) {
288                                if fileBinding.wrappedValue.contents.isEmpty {
289                                    Text("Paste contents")
290                                        .foregroundStyle(.tertiary)
291                                        .padding(.top, 8)
292                                        .padding(.leading, 5)
293                                        .allowsHitTesting(false)
294                                }
295
296                                TextEditor(text: fileBinding.contents)
297                                    .font(.system(.body, design: .monospaced))
298                                    .frame(minHeight: 180)
299                            }
300                        }
301                        .padding(.vertical, 4)
302                    }
303                    .onDelete { offsets in
304                        files.remove(atOffsets: offsets)
305                        if files.isEmpty {
306                            files = [PasteUploadDraft()]
307                        }
308                    }
309                    .themedRow()
310
311                    Button {
312                        files.append(PasteUploadDraft())
313                    } label: {
314                        Label("Add File", systemImage: "plus")
315                    }
316                    .themedRow()
317                }
318
319                Section("Visibility") {
320                    Picker("Visibility", selection: $visibility) {
321                        Text("Public").tag(Visibility.publicVisibility)
322                        Text("Unlisted").tag(Visibility.unlisted)
323                        Text("Private").tag(Visibility.privateVisibility)
324                    }
325                    .themedRow()
326                }
327
328                Section {
329                    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.")
330                        .font(.footnote)
331                        .foregroundStyle(.secondary)
332                        .themedRow()
333                }
334
335                if let error = viewModel.error {
336                    Section {
337                        Label(error, systemImage: "exclamationmark.triangle.fill")
338                            .foregroundStyle(.red)
339                            .themedRow()
340                    }
341                }
342            }
343            .themedList()
344            .navigationTitle("New Paste")
345            .navigationBarTitleDisplayMode(.inline)
346            .toolbar {
347                ToolbarItem(placement: .cancellationAction) {
348                    Button("Cancel") { dismiss() }
349                }
350                ToolbarItem(placement: .confirmationAction) {
351                    Button {
352                        Task {
353                            if let paste = await viewModel.createPaste(files: files, visibility: visibility) {
354                                onCreated(paste)
355                            }
356                        }
357                    } label: {
358                        if viewModel.isCreatingPaste {
359                            ProgressView()
360                                .controlSize(.small)
361                        } else {
362                            Text("Create Paste")
363                        }
364                    }
365                    .disabled(!hasValidContent || viewModel.isCreatingPaste)
366                }
367            }
368        }
369    }
370
371    private var hasValidContent: Bool {
372        files.contains { !$0.contents.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty }
373    }
374}