krz/hutch

an ios client for sourcehut

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

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