| @@ -10,6 +10,12 @@ struct PasteDetailView: View { |
| 10 | 10 | @State private var viewModel: PasteDetailViewModel? |
| 11 | 11 | @State private var showVisibilitySheet = false |
| 12 | 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 | 20 | var body: some View { |
| 15 | 21 | Group { |
| @@ -23,15 +29,14 @@ struct PasteDetailView: View { |
| 23 | 29 | .navigationBarTitleDisplayMode(.inline) |
| 24 | 30 | .toolbar { |
| 25 | 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 | 32 | if viewModel != nil { |
| 34 | 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 | 40 | Button { |
| 36 | 41 | showVisibilitySheet = true |
| 37 | 42 | } label: { |
| @@ -114,87 +119,252 @@ struct PasteDetailView: View { |
| 114 | 119 | retryAction: { await viewModel.loadPaste() } |
| 115 | 120 | ) |
| 116 | 121 | } else if let paste = viewModel.paste { |
| 117 | | List { |
| 118 | | Section("Details") { |
| 119 | | LabeledContent("ID", value: paste.id) |
| 120 | | LabeledContent("Owner", value: paste.user.canonicalName) |
| 121 | | LabeledContent("Created", value: paste.created.relativeDescription) |
| 122 | | LabeledContent("Visibility", value: visibilityLabel(paste.visibility)) |
| 123 | | LabeledContent("Files", value: "\(paste.files.count)") |
| 122 | VStack(spacing: 0) { |
| 123 | fileHeaderBar(paste: paste, viewModel: viewModel) |
| 124 | Divider() |
| 125 | fileContentArea(viewModel: viewModel) |
| 126 | } |
| 127 | .safeAreaInset(edge: .bottom, spacing: 0) { |
| 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 { |
| 127 | | Section("Files") { |
| 128 | | Picker("Selected File", selection: Binding( |
| 129 | | get: { viewModel.selectedFileHash ?? paste.files.first?.hash ?? "" }, |
| 130 | | set: { viewModel.selectFile(hash: $0) } |
| 131 | | )) { |
| 132 | | ForEach(paste.files) { file in |
| 133 | | Text(file.filename ?? String(file.hash.prefix(8))) |
| 134 | | .tag(file.hash) |
| 135 | | } |
| 136 | | } |
| 137 | | } |
| 149 | // MARK: - File Header Bar |
| 150 | |
| 151 | private func fileHeaderBar(paste: Paste, viewModel: PasteDetailViewModel) -> some View { |
| 152 | VStack(alignment: .leading, spacing: 6) { |
| 153 | HStack(alignment: .firstTextBaseline, spacing: 8) { |
| 154 | let filename = viewModel.selectedFile.flatMap { $0.filename.flatMap { $0.isEmpty ? nil : $0 } } |
| 155 | Text(filename ?? "Untitled") |
| 156 | .font(.subheadline.monospaced()) |
| 157 | .foregroundStyle(.primary) |
| 158 | .lineLimit(1) |
| 159 | |
| 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 { |
| 141 | | Section("Current File") { |
| 142 | | if let filename = file.filename, !filename.isEmpty { |
| 143 | | LabeledContent("Filename", value: filename) |
| 144 | | } |
| 145 | | LabeledContent("Hash", value: file.hash) |
| 189 | if paste.files.count > 1 { |
| 190 | Picker( |
| 191 | "File", |
| 192 | selection: Binding( |
| 193 | get: { viewModel.selectedFileHash ?? paste.files.first?.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 { |
| 149 | | if viewModel.loadingFileHashes.contains(file.hash) && viewModel.selectedFileContents == nil { |
| 150 | | SRHTLoadingStateView(message: "Loading paste contents…") |
| 151 | | .frame(minHeight: 180) |
| 152 | | } else if let contents = viewModel.selectedFileContents { |
| 153 | | PasteCodeBlock(text: contents) |
| 154 | | } else { |
| 155 | | Text("This file’s contents are unavailable.") |
| 156 | | .foregroundStyle(.secondary) |
| 157 | | } |
| 158 | | } header: { |
| 159 | | Text("Contents") |
| 210 | // MARK: - File Content Area |
| 211 | |
| 212 | @ViewBuilder |
| 213 | private func fileContentArea(viewModel: PasteDetailViewModel) -> some View { |
| 214 | if let file = viewModel.selectedFile { |
| 215 | if viewModel.loadingFileHashes.contains(file.hash) && viewModel.selectedFileContents == nil { |
| 216 | SRHTLoadingStateView(message: "Loading contents…") |
| 217 | } else if let contents = viewModel.selectedFileContents { |
| 218 | CodeFileTextView( |
| 219 | text: contents, |
| 220 | fileName: file.filename ?? "", |
| 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) |
| 164 | | .srhtErrorBanner(error: $vm.error) |
| 165 | | .refreshable { |
| 166 | | await viewModel.loadPaste() |
| 258 | .disabled(viewModel.selectedFileContents == nil) |
| 259 | |
| 260 | toolbarButton( |
| 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 { |
| 172 | | switch visibility { |
| 173 | | case .public: |
| 174 | | return "Public" |
| 175 | | case .unlisted: |
| 176 | | return "Unlisted" |
| 177 | | case .private: |
| 178 | | return "Private" |
| 295 | private func toolbarButton( |
| 296 | title: String, |
| 297 | systemImage: String, |
| 298 | action: @escaping () -> Void |
| 299 | ) -> some View { |
| 300 | Button(action: action) { |
| 301 | VStack(spacing: 4) { |
| 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 { |
| 184 | | let text: String |
| 316 | // MARK: - Paste Info Sheet |
| 317 | |
| 318 | private struct PasteInfoSheet: View { |
| 319 | let paste: Paste |
| 320 | let selectedFile: PasteFile? |
| 321 | |
| 322 | @Environment(\.dismiss) private var dismiss |
| 185 | 323 | |
| 186 | 324 | var body: some View { |
| 187 | | ScrollView([.horizontal, .vertical], showsIndicators: true) { |
| 188 | | Text(text.isEmpty ? " " : text) |
| 189 | | .font(.system(.body, design: .monospaced)) |
| 190 | | .textSelection(.enabled) |
| 191 | | .frame(maxWidth: .infinity, alignment: .leading) |
| 192 | | .padding(.vertical, 4) |
| 325 | NavigationStack { |
| 326 | List { |
| 327 | Section("Paste") { |
| 328 | LabeledContent("ID", value: paste.id) |
| 329 | LabeledContent("Owner", value: paste.user.canonicalName) |
| 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 | 368 | private struct PasteVisibilitySheet: View { |
| 199 | 369 | let currentVisibility: Visibility |
| 200 | 370 | let isUpdating: Bool |