| @@ -10,6 +10,12 @@ struct PasteDetailView: View { |
| 10 | @State private var viewModel: PasteDetailViewModel? |
10 | @State private var viewModel: PasteDetailViewModel? |
| 11 | @State private var showVisibilitySheet = false |
11 | @State private var showVisibilitySheet = false |
| 12 | @State private var showDeleteConfirmation = false |
12 | @State private var showDeleteConfirmation = false |
| |
13 | @State private var showInfoSheet = false |
| |
14 | @State private var showFileShareSheet = false |
| |
15 | @State private var showShareUnavailableAlert = false |
| |
16 | @State private var didCopyContents = false |
| |
17 | @State private var copyResetTask: Task<Void, Never>? |
| |
18 | @AppStorage(AppStorageKeys.wrapPasteFileLines) private var wrapLines = false |
| 13 | |
19 | |
| 14 | var body: some View { |
20 | var body: some View { |
| 15 | Group { |
21 | Group { |
| @@ -23,15 +29,14 @@ struct PasteDetailView: View { |
| 23 | .navigationBarTitleDisplayMode(.inline) |
29 | .navigationBarTitleDisplayMode(.inline) |
| 24 | .toolbar { |
30 | .toolbar { |
| 25 | ToolbarItemGroup(placement: .topBarTrailing) { |
31 | ToolbarItemGroup(placement: .topBarTrailing) { |
| 26 | SRHTShareButton( |
| |
| 27 | url: currentPaste.flatMap { SRHTWebURL.paste(ownerCanonicalName: $0.user.canonicalName, pasteId: $0.id) }, |
| |
| 28 | target: .paste |
| |
| 29 | ) { |
| |
| 30 | Image(systemName: "square.and.arrow.up") |
| |
| 31 | } |
| |
| 32 | |
| |
| 33 | if viewModel != nil { |
32 | if viewModel != nil { |
| 34 | Menu { |
33 | Menu { |
| |
34 | if let url = currentPaste.flatMap({ SRHTWebURL.paste(ownerCanonicalName: $0.user.canonicalName, pasteId: $0.id) }) { |
| |
35 | ShareLink(item: url) { |
| |
36 | Label("Share Link", systemImage: "link") |
| |
37 | } |
| |
38 | } |
| |
39 | |
| 35 | Button { |
40 | Button { |
| 36 | showVisibilitySheet = true |
41 | showVisibilitySheet = true |
| 37 | } label: { |
42 | } label: { |
| @@ -114,87 +119,252 @@ struct PasteDetailView: View { |
| 114 | retryAction: { await viewModel.loadPaste() } |
119 | retryAction: { await viewModel.loadPaste() } |
| 115 | ) |
120 | ) |
| 116 | } else if let paste = viewModel.paste { |
121 | } else if let paste = viewModel.paste { |
| 117 | List { |
122 | VStack(spacing: 0) { |
| 118 | Section("Details") { |
123 | fileHeaderBar(paste: paste, viewModel: viewModel) |
| 119 | LabeledContent("ID", value: paste.id) |
124 | Divider() |
| 120 | LabeledContent("Owner", value: paste.user.canonicalName) |
125 | fileContentArea(viewModel: viewModel) |
| 121 | LabeledContent("Created", value: paste.created.relativeDescription) |
126 | } |
| 122 | LabeledContent("Visibility", value: visibilityLabel(paste.visibility)) |
127 | .safeAreaInset(edge: .bottom, spacing: 0) { |
| 123 | LabeledContent("Files", value: "\(paste.files.count)") |
128 | actionToolbar(viewModel: viewModel) |
| |
129 | } |
| |
130 | .srhtErrorBanner(error: $vm.error) |
| |
131 | .sheet(isPresented: $showInfoSheet) { |
| |
132 | PasteInfoSheet(paste: paste, selectedFile: viewModel.selectedFile) |
| |
133 | } |
| |
134 | .sheet(isPresented: $showFileShareSheet) { |
| |
135 | if let contents = viewModel.selectedFileContents { |
| |
136 | FileContentShareSheet(activityItems: [contents]) |
| |
137 | } |
| |
138 | } |
| |
139 | .alert("Share Unavailable", isPresented: $showShareUnavailableAlert) { |
| |
140 | Button("OK", role: .cancel) { |
| |
141 | // Alert dismissal is implicit; no additional action required. |
| 124 | } |
142 | } |
| |
143 | } message: { |
| |
144 | Text(SRHTShareTarget.file.fallbackMessage) |
| |
145 | } |
| |
146 | } |
| |
147 | } |
| 125 | |
148 | |
| 126 | if paste.files.count > 1 { |
149 | // MARK: - File Header Bar |
| 127 | Section("Files") { |
150 | |
| 128 | Picker("Selected File", selection: Binding( |
151 | private func fileHeaderBar(paste: Paste, viewModel: PasteDetailViewModel) -> some View { |
| 129 | get: { viewModel.selectedFileHash ?? paste.files.first?.hash ?? "" }, |
152 | VStack(alignment: .leading, spacing: 6) { |
| 130 | set: { viewModel.selectFile(hash: $0) } |
153 | HStack(alignment: .firstTextBaseline, spacing: 8) { |
| 131 | )) { |
154 | let filename = viewModel.selectedFile.flatMap { $0.filename.flatMap { $0.isEmpty ? nil : $0 } } |
| 132 | ForEach(paste.files) { file in |
155 | Text(filename ?? "Untitled") |
| 133 | Text(file.filename ?? String(file.hash.prefix(8))) |
156 | .font(.subheadline.monospaced()) |
| 134 | .tag(file.hash) |
157 | .foregroundStyle(.primary) |
| 135 | } |
158 | .lineLimit(1) |
| 136 | } |
159 | |
| 137 | } |
160 | Spacer(minLength: 4) |
| |
161 | |
| |
162 | VisibilityBadge(visibility: paste.visibility) |
| |
163 | } |
| |
164 | |
| |
165 | HStack(spacing: 4) { |
| |
166 | Text(paste.user.canonicalName) |
| |
167 | .font(.caption) |
| |
168 | .foregroundStyle(.secondary) |
| |
169 | |
| |
170 | Text("·") |
| |
171 | .font(.caption) |
| |
172 | .foregroundStyle(.tertiary) |
| |
173 | |
| |
174 | Text(paste.created.relativeDescription) |
| |
175 | .font(.caption) |
| |
176 | .foregroundStyle(.secondary) |
| |
177 | |
| |
178 | if let hash = viewModel.selectedFile?.hash { |
| |
179 | Text("·") |
| |
180 | .font(.caption) |
| |
181 | .foregroundStyle(.tertiary) |
| |
182 | |
| |
183 | Text(hash.prefix(8)) |
| |
184 | .font(.caption.monospaced()) |
| |
185 | .foregroundStyle(.tertiary) |
| 138 | } |
186 | } |
| |
187 | } |
| 139 | |
188 | |
| 140 | if let file = viewModel.selectedFile { |
189 | if paste.files.count > 1 { |
| 141 | Section("Current File") { |
190 | Picker( |
| 142 | if let filename = file.filename, !filename.isEmpty { |
191 | "File", |
| 143 | LabeledContent("Filename", value: filename) |
192 | selection: Binding( |
| 144 | } |
193 | get: { viewModel.selectedFileHash ?? paste.files.first?.hash ?? "" }, |
| 145 | LabeledContent("Hash", value: file.hash) |
194 | set: { viewModel.selectFile(hash: $0) } |
| |
195 | ) |
| |
196 | ) { |
| |
197 | ForEach(paste.files) { file in |
| |
198 | Text(file.filename ?? String(file.hash.prefix(8))) |
| |
199 | .tag(file.hash) |
| 146 | } |
200 | } |
| |
201 | } |
| |
202 | .pickerStyle(.segmented) |
| |
203 | } |
| |
204 | } |
| |
205 | .padding(.horizontal) |
| |
206 | .padding(.vertical, 10) |
| |
207 | .background(.bar) |
| |
208 | } |
| 147 | |
209 | |
| 148 | Section { |
210 | // MARK: - File Content Area |
| 149 | if viewModel.loadingFileHashes.contains(file.hash) && viewModel.selectedFileContents == nil { |
211 | |
| 150 | SRHTLoadingStateView(message: "Loading paste contents…") |
212 | @ViewBuilder |
| 151 | .frame(minHeight: 180) |
213 | private func fileContentArea(viewModel: PasteDetailViewModel) -> some View { |
| 152 | } else if let contents = viewModel.selectedFileContents { |
214 | if let file = viewModel.selectedFile { |
| 153 | PasteCodeBlock(text: contents) |
215 | if viewModel.loadingFileHashes.contains(file.hash) && viewModel.selectedFileContents == nil { |
| 154 | } else { |
216 | SRHTLoadingStateView(message: "Loading contents…") |
| 155 | Text("This file’s contents are unavailable.") |
217 | } else if let contents = viewModel.selectedFileContents { |
| 156 | .foregroundStyle(.secondary) |
218 | CodeFileTextView( |
| 157 | } |
219 | text: contents, |
| 158 | } header: { |
220 | fileName: file.filename ?? "", |
| 159 | Text("Contents") |
221 | wrapLines: wrapLines |
| |
222 | ) |
| |
223 | } else { |
| |
224 | ContentUnavailableView( |
| |
225 | "Contents Unavailable", |
| |
226 | systemImage: "doc.questionmark", |
| |
227 | description: Text("This file's contents could not be loaded.") |
| |
228 | ) |
| |
229 | } |
| |
230 | } else { |
| |
231 | ContentUnavailableView( |
| |
232 | "No File Selected", |
| |
233 | systemImage: "doc", |
| |
234 | description: Text("Select a file to view its contents.") |
| |
235 | ) |
| |
236 | } |
| |
237 | } |
| |
238 | |
| |
239 | // MARK: - Action Toolbar |
| |
240 | |
| |
241 | private func actionToolbar(viewModel: PasteDetailViewModel) -> some View { |
| |
242 | HStack(spacing: 0) { |
| |
243 | toolbarButton( |
| |
244 | title: didCopyContents ? "Copied" : "Copy All", |
| |
245 | systemImage: didCopyContents ? "checkmark" : "doc.on.doc" |
| |
246 | ) { |
| |
247 | if let contents = viewModel.selectedFileContents { |
| |
248 | UIPasteboard.general.string = contents |
| |
249 | didCopyContents = true |
| |
250 | copyResetTask?.cancel() |
| |
251 | copyResetTask = Task { |
| |
252 | try? await Task.sleep(for: .seconds(2)) |
| |
253 | guard !Task.isCancelled else { return } |
| |
254 | await MainActor.run { didCopyContents = false } |
| 160 | } |
255 | } |
| 161 | } |
256 | } |
| 162 | } |
257 | } |
| 163 | .listStyle(.insetGrouped) |
258 | .disabled(viewModel.selectedFileContents == nil) |
| 164 | .srhtErrorBanner(error: $vm.error) |
259 | |
| 165 | .refreshable { |
260 | toolbarButton( |
| 166 | await viewModel.loadPaste() |
261 | title: "Share", |
| |
262 | systemImage: "square.and.arrow.up" |
| |
263 | ) { |
| |
264 | if let contents = viewModel.selectedFileContents, !contents.isEmpty { |
| |
265 | showFileShareSheet = true |
| |
266 | } else { |
| |
267 | showShareUnavailableAlert = true |
| |
268 | } |
| |
269 | } |
| |
270 | .disabled(viewModel.selectedFileContents == nil) |
| |
271 | |
| |
272 | toolbarButton( |
| |
273 | title: wrapLines ? "Wrap On" : "Wrap Off", |
| |
274 | systemImage: "text.word.spacing" |
| |
275 | ) { |
| |
276 | wrapLines.toggle() |
| |
277 | } |
| |
278 | |
| |
279 | toolbarButton( |
| |
280 | title: "Details", |
| |
281 | systemImage: "info.circle" |
| |
282 | ) { |
| |
283 | showInfoSheet = true |
| 167 | } |
284 | } |
| 168 | } |
285 | } |
| |
286 | .padding(.horizontal, 8) |
| |
287 | .padding(.top, 10) |
| |
288 | .padding(.bottom, 8) |
| |
289 | .background(.bar) |
| |
290 | .overlay(alignment: .top) { |
| |
291 | Divider() |
| |
292 | } |
| 169 | } |
293 | } |
| 170 | |
294 | |
| 171 | private func visibilityLabel(_ visibility: Visibility) -> String { |
295 | private func toolbarButton( |
| 172 | switch visibility { |
296 | title: String, |
| 173 | case .public: |
297 | systemImage: String, |
| 174 | return "Public" |
298 | action: @escaping () -> Void |
| 175 | case .unlisted: |
299 | ) -> some View { |
| 176 | return "Unlisted" |
300 | Button(action: action) { |
| 177 | case .private: |
301 | VStack(spacing: 4) { |
| 178 | return "Private" |
302 | Image(systemName: systemImage) |
| |
303 | .font(.system(size: 17, weight: .semibold)) |
| |
304 | Text(title) |
| |
305 | .font(.caption2) |
| |
306 | .lineLimit(1) |
| |
307 | } |
| |
308 | .frame(maxWidth: .infinity) |
| |
309 | .contentShape(Rectangle()) |
| 179 | } |
310 | } |
| |
311 | .buttonStyle(.plain) |
| |
312 | .foregroundStyle(.primary) |
| 180 | } |
313 | } |
| 181 | } |
314 | } |
| 182 | |
315 | |
| 183 | private struct PasteCodeBlock: View { |
316 | // MARK: - Paste Info Sheet |
| 184 | let text: String |
317 | |
| |
318 | private struct PasteInfoSheet: View { |
| |
319 | let paste: Paste |
| |
320 | let selectedFile: PasteFile? |
| |
321 | |
| |
322 | @Environment(\.dismiss) private var dismiss |
| 185 | |
323 | |
| 186 | var body: some View { |
324 | var body: some View { |
| 187 | ScrollView([.horizontal, .vertical], showsIndicators: true) { |
325 | NavigationStack { |
| 188 | Text(text.isEmpty ? " " : text) |
326 | List { |
| 189 | .font(.system(.body, design: .monospaced)) |
327 | Section("Paste") { |
| 190 | .textSelection(.enabled) |
328 | LabeledContent("ID", value: paste.id) |
| 191 | .frame(maxWidth: .infinity, alignment: .leading) |
329 | LabeledContent("Owner", value: paste.user.canonicalName) |
| 192 | .padding(.vertical, 4) |
330 | LabeledContent("Created", value: paste.created.relativeDescription) |
| |
331 | LabeledContent("Visibility") { |
| |
332 | VisibilityBadge(visibility: paste.visibility) |
| |
333 | } |
| |
334 | if paste.files.count > 1 { |
| |
335 | LabeledContent("Files", value: "\(paste.files.count)") |
| |
336 | } |
| |
337 | } |
| |
338 | |
| |
339 | if let file = selectedFile { |
| |
340 | Section("File") { |
| |
341 | if let filename = file.filename, !filename.isEmpty { |
| |
342 | LabeledContent("Filename", value: filename) |
| |
343 | } |
| |
344 | LabeledContent("Hash") { |
| |
345 | Text(file.hash) |
| |
346 | .font(.caption.monospaced()) |
| |
347 | .foregroundStyle(.secondary) |
| |
348 | .textSelection(.enabled) |
| |
349 | } |
| |
350 | } |
| |
351 | } |
| |
352 | } |
| |
353 | .listStyle(.insetGrouped) |
| |
354 | .themedList() |
| |
355 | .navigationTitle("Details") |
| |
356 | .navigationBarTitleDisplayMode(.inline) |
| |
357 | .toolbar { |
| |
358 | ToolbarItem(placement: .confirmationAction) { |
| |
359 | Button("Done") { dismiss() } |
| |
360 | } |
| |
361 | } |
| 193 | } |
362 | } |
| 194 | .frame(minHeight: 220) |
| |
| 195 | } |
363 | } |
| 196 | } |
364 | } |
| 197 | |
365 | |
| |
366 | // MARK: - Visibility Sheet |
| |
367 | |
| 198 | private struct PasteVisibilitySheet: View { |
368 | private struct PasteVisibilitySheet: View { |
| 199 | let currentVisibility: Visibility |
369 | let currentVisibility: Visibility |
| 200 | let isUpdating: Bool |
370 | let isUpdating: Bool |