krz/hutch

an ios client for sourcehut

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

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