summaryrefslogtreecommitdiff
path: root/Hutch/Views/Pastes/PasteListView.swift
diff options
context:
space:
mode:
Diffstat (limited to 'Hutch/Views/Pastes/PasteListView.swift')
-rw-r--r--Hutch/Views/Pastes/PasteListView.swift280
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 }
+ }
+}