krz/hutch

an ios client for sourcehut

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

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