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}