diff options
Diffstat (limited to 'Hutch/Views/Pastes/PasteListView.swift')
| -rw-r--r-- | Hutch/Views/Pastes/PasteListView.swift | 280 |
1 files changed, 280 insertions, 0 deletions
diff --git a/Hutch/Views/Pastes/PasteListView.swift b/Hutch/Views/Pastes/PasteListView.swift new file mode 100644 index 0000000..9d8c0d4 --- /dev/null +++ b/Hutch/Views/Pastes/PasteListView.swift @@ -0,0 +1,280 @@ +import SwiftUI + +struct PasteListView: View { + @Environment(AppState.self) private var appState + @State private var viewModel: PasteListViewModel? + @State private var showCreatePasteSheet = false + @State private var createdPaste: Paste? + + var body: some View { + Group { + if let viewModel { + content(viewModel) + } else { + SRHTLoadingStateView(message: "Loading pastes…") + } + } + .navigationTitle("Pastes") + .toolbar { + if viewModel != nil { + ToolbarItem(placement: .topBarTrailing) { + Button { + showCreatePasteSheet = true + } label: { + Image(systemName: "plus") + } + } + } + } + .sheet(isPresented: $showCreatePasteSheet) { + if let viewModel { + CreatePasteSheet(viewModel: viewModel) { paste in + showCreatePasteSheet = false + createdPaste = paste + } + } + } + .navigationDestination(isPresented: Binding( + get: { createdPaste != nil }, + set: { isPresented in + if !isPresented { + createdPaste = nil + } + } + )) { + if let createdPaste { + PasteDetailView( + paste: createdPaste, + onUpdated: { updated in + viewModel?.upsertPaste(updated) + }, + onDeleted: { id in + viewModel?.removePaste(id: id) + } + ) + } + } + .task { + if viewModel == nil { + let vm = PasteListViewModel(service: PasteService(client: appState.client)) + viewModel = vm + await vm.loadPastes() + } + } + } + + @ViewBuilder + private func content(_ viewModel: PasteListViewModel) -> some View { + @Bindable var vm = viewModel + + List { + ForEach(viewModel.pastes) { paste in + NavigationLink(value: paste) { + PasteRowView(paste: paste) + } + .task { + await viewModel.loadMoreIfNeeded(currentItem: paste) + } + } + + if viewModel.isLoadingMore { + HStack { + Spacer() + ProgressView() + Spacer() + } + .listRowSeparator(.hidden) + } + } + .listStyle(.plain) + .overlay { + if viewModel.isLoading, viewModel.pastes.isEmpty { + SRHTLoadingStateView(message: "Loading pastes…") + } else if let error = viewModel.error, viewModel.pastes.isEmpty { + SRHTErrorStateView( + title: "Couldn't Load Pastes", + message: error, + retryAction: { await viewModel.loadPastes() } + ) + } else if viewModel.pastes.isEmpty { + ContentUnavailableView( + "No Pastes", + systemImage: "doc.on.clipboard", + description: Text("Your pastes will appear here.") + ) + } + } + .connectivityOverlay(hasContent: !viewModel.pastes.isEmpty) { + await viewModel.loadPastes() + } + .srhtErrorBanner(error: $vm.error) + .refreshable { + await viewModel.loadPastes() + } + .navigationDestination(for: Paste.self) { paste in + PasteDetailView( + paste: paste, + onUpdated: { updated in + viewModel.upsertPaste(updated) + }, + onDeleted: { id in + viewModel.removePaste(id: id) + } + ) + } + } +} + +private struct PasteRowView: View { + let paste: Paste + + var body: some View { + HStack(alignment: .top, spacing: 12) { + Image(systemName: "doc.text") + .foregroundStyle(.secondary) + .frame(width: 20) + + VStack(alignment: .leading, spacing: 4) { + Text(primaryTitle) + .font(.subheadline.weight(.medium)) + .lineLimit(1) + + Text(secondaryLine) + .font(.caption) + .foregroundStyle(.secondary) + .lineLimit(2) + + HStack(spacing: 8) { + VisibilityBadge(visibility: paste.visibility) + Text("•") + .foregroundStyle(.tertiary) + Text(paste.created.relativeDescription) + .foregroundStyle(.tertiary) + } + .font(.caption2) + } + } + .padding(.vertical, 2) + } + + private var primaryTitle: String { + if let filename = paste.files.first?.filename, !filename.isEmpty { + return filename + } + return paste.files.count > 1 ? "Untitled Paste (\(paste.files.count) files)" : "Untitled Paste" + } + + private var secondaryLine: String { + var parts: [String] = [paste.user.canonicalName] + if paste.files.count > 1 { + parts.append("\(paste.files.count) files") + } else { + parts.append("1 file") + } + if let firstHash = paste.files.first?.hash { + parts.append(String(firstHash.prefix(8))) + } + return parts.joined(separator: " • ") + } +} + +private struct CreatePasteSheet: View { + let viewModel: PasteListViewModel + let onCreated: (Paste) -> Void + + @Environment(\.dismiss) private var dismiss + @State private var files = [PasteUploadDraft()] + @State private var visibility: Visibility = .unlisted + + var body: some View { + NavigationStack { + Form { + Section("Files") { + ForEach($files) { $file in + VStack(alignment: .leading, spacing: 8) { + TextField("Filename (optional)", text: $file.filename) + .autocorrectionDisabled() + .textInputAutocapitalization(.never) + + ZStack(alignment: .topLeading) { + if file.contents.isEmpty { + Text("Paste contents") + .foregroundStyle(.tertiary) + .padding(.top, 8) + .padding(.leading, 5) + .allowsHitTesting(false) + } + + TextEditor(text: $file.contents) + .font(.system(.body, design: .monospaced)) + .frame(minHeight: 180) + } + } + .padding(.vertical, 4) + } + .onDelete { offsets in + files.remove(atOffsets: offsets) + if files.isEmpty { + files = [PasteUploadDraft()] + } + } + + Button { + files.append(PasteUploadDraft()) + } label: { + Label("Add File", systemImage: "plus") + } + } + + Section("Visibility") { + Picker("Visibility", selection: $visibility) { + Text("Public").tag(Visibility.public) + Text("Unlisted").tag(Visibility.unlisted) + Text("Private").tag(Visibility.private) + } + } + + Section { + 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.") + .font(.footnote) + .foregroundStyle(.secondary) + } + + if let error = viewModel.error { + Section { + Label(error, systemImage: "exclamationmark.triangle.fill") + .foregroundStyle(.red) + } + } + } + .navigationTitle("New Paste") + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .cancellationAction) { + Button("Cancel") { dismiss() } + } + ToolbarItem(placement: .confirmationAction) { + Button { + Task { + if let paste = await viewModel.createPaste(files: files, visibility: visibility) { + onCreated(paste) + } + } + } label: { + if viewModel.isCreatingPaste { + ProgressView() + .controlSize(.small) + } else { + Text("Create Paste") + } + } + .disabled(!hasValidContent || viewModel.isCreatingPaste) + } + } + } + } + + private var hasValidContent: Bool { + files.contains { !$0.contents.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty } + } +} |
