summaryrefslogtreecommitdiff
path: root/Hutch/Views/Projects/DiscoverProjectsView.swift
diff options
context:
space:
mode:
authorChristian Cleberg <[email protected]>2026-08-07 04:19:04 -0500
committerGitHub <[email protected]>2026-08-07 04:19:04 -0500
commit3ae35daa9495b9b1e01f1063bd05c636b97fd927 (patch)
tree25cf810fa0e9a0ab343b60b59c26d8cc096c9a98 /Hutch/Views/Projects/DiscoverProjectsView.swift
parentdb40d346c28800a1eec384a5e4f3d7d090198e4d (diff)
parent1f8f651a9c2ac2d6be231ff9f71ee3bcce9936d8 (diff)
downloadhutch-3ae35daa9495b9b1e01f1063bd05c636b97fd927.tar.gz
hutch-3ae35daa9495b9b1e01f1063bd05c636b97fd927.tar.bz2
hutch-3ae35daa9495b9b1e01f1063bd05c636b97fd927.zip
Merge pull request #33 from krazywarez/projects-crud
Projects: discovery, create, edit, manage linked resources (#12, #13, #14, #15)
Diffstat (limited to 'Hutch/Views/Projects/DiscoverProjectsView.swift')
-rw-r--r--Hutch/Views/Projects/DiscoverProjectsView.swift166
1 files changed, 166 insertions, 0 deletions
diff --git a/Hutch/Views/Projects/DiscoverProjectsView.swift b/Hutch/Views/Projects/DiscoverProjectsView.swift
new file mode 100644
index 0000000..fab165e
--- /dev/null
+++ b/Hutch/Views/Projects/DiscoverProjectsView.swift
@@ -0,0 +1,166 @@
+import SwiftUI
+
+@Observable
+@MainActor
+final class DiscoverProjectsViewModel {
+ private(set) var projects: [DiscoveredProject] = []
+ private(set) var isLoading = false
+ private(set) var isLoadingMore = false
+ var error: String?
+
+ private var cursor: String?
+ private var canLoadMore = true
+ private let service: ProjectService
+
+ init(service: ProjectService) {
+ self.service = service
+ }
+
+ func loadInitial() async {
+ guard projects.isEmpty, !isLoading else { return }
+ isLoading = true
+ error = nil
+ defer { isLoading = false }
+
+ do {
+ let page = try await service.fetchPublicProjects(cursor: nil)
+ projects = page.projects
+ cursor = page.cursor
+ canLoadMore = page.cursor != nil
+ } catch {
+ self.error = error.userFacingMessage
+ }
+ }
+
+ func reload() async {
+ cursor = nil
+ canLoadMore = true
+ projects = []
+ await loadInitial()
+ }
+
+ func loadMoreIfNeeded(current item: DiscoveredProject) async {
+ guard canLoadMore, !isLoadingMore, !isLoading else { return }
+ guard let index = projects.firstIndex(of: item), index >= projects.count - 3 else { return }
+
+ isLoadingMore = true
+ defer { isLoadingMore = false }
+
+ do {
+ let page = try await service.fetchPublicProjects(cursor: cursor)
+ let existing = Set(projects.map(\.id))
+ projects.append(contentsOf: page.projects.filter { !existing.contains($0.id) })
+ cursor = page.cursor
+ canLoadMore = page.cursor != nil
+ } catch {
+ self.error = error.userFacingMessage
+ }
+ }
+}
+
+struct DiscoverProjectsView: View {
+ @Environment(AppState.self) private var appState
+ @State private var viewModel: DiscoverProjectsViewModel?
+
+ var body: some View {
+ Group {
+ if let viewModel {
+ content(viewModel)
+ } else {
+ SRHTLoadingStateView(message: "Loading projects…")
+ }
+ }
+ .navigationTitle("Discover")
+ .navigationBarTitleDisplayMode(.inline)
+ .task {
+ if viewModel == nil {
+ let vm = DiscoverProjectsViewModel(service: ProjectService(client: appState.client))
+ viewModel = vm
+ await vm.loadInitial()
+ }
+ }
+ }
+
+ @ViewBuilder
+ private func content(_ viewModel: DiscoverProjectsViewModel) -> some View {
+ List {
+ ForEach(viewModel.projects) { discovered in
+ NavigationLink {
+ ProjectDetailView(
+ project: discovered.project,
+ canManage: discovered.ownerCanonicalName == appState.currentUser?.canonicalName
+ )
+ } label: {
+ DiscoveredProjectRow(discovered: discovered)
+ }
+ .buttonStyle(.plain)
+ .task {
+ await viewModel.loadMoreIfNeeded(current: discovered)
+ }
+ }
+ .themedRow()
+
+ if viewModel.isLoadingMore {
+ HStack {
+ Spacer()
+ ProgressView()
+ Spacer()
+ }
+ .listRowSeparator(.hidden)
+ .themedRow()
+ }
+ }
+ .themedList()
+ .listStyle(.plain)
+ .overlay {
+ if viewModel.isLoading, viewModel.projects.isEmpty {
+ SRHTLoadingStateView(message: "Loading projects…")
+ } else if let error = viewModel.error, viewModel.projects.isEmpty {
+ SRHTErrorStateView(
+ title: "Couldn't Load Projects",
+ message: error,
+ retryAction: { await viewModel.reload() }
+ )
+ } else if viewModel.projects.isEmpty {
+ ContentUnavailableView(
+ "No Public Projects",
+ systemImage: "sparkle.magnifyingglass",
+ description: Text("Public projects on SourceHut will appear here.")
+ )
+ }
+ }
+ .srhtErrorBanner(
+ error: Binding(
+ get: { viewModel.error },
+ set: { viewModel.error = $0 }
+ )
+ )
+ .refreshable {
+ await viewModel.reload()
+ }
+ }
+}
+
+private struct DiscoveredProjectRow: View {
+ let discovered: DiscoveredProject
+
+ private var project: Project { discovered.project }
+
+ var body: some View {
+ VStack(alignment: .leading, spacing: 4) {
+ Text(project.displayName)
+ .font(.headline)
+ .lineLimit(1)
+ Text(discovered.ownerCanonicalName)
+ .font(.caption)
+ .foregroundStyle(.secondary)
+ if let description = project.displayDescription {
+ Text(description)
+ .font(.subheadline)
+ .foregroundStyle(.secondary)
+ .lineLimit(2)
+ }
+ }
+ .padding(.vertical, 2)
+ }
+}