summaryrefslogtreecommitdiff
path: root/Hutch/Views/Projects/DiscoverProjectsView.swift
diff options
context:
space:
mode:
authorChristian Cleberg <[email protected]>2026-08-07 04:12:50 -0500
committerChristian Cleberg <[email protected]>2026-08-07 04:12:50 -0500
commit1f8f651a9c2ac2d6be231ff9f71ee3bcce9936d8 (patch)
tree25cf810fa0e9a0ab343b60b59c26d8cc096c9a98 /Hutch/Views/Projects/DiscoverProjectsView.swift
parentdb40d346c28800a1eec384a5e4f3d7d090198e4d (diff)
downloadhutch-1f8f651a9c2ac2d6be231ff9f71ee3bcce9936d8.tar.gz
hutch-1f8f651a9c2ac2d6be231ff9f71ee3bcce9936d8.tar.bz2
hutch-1f8f651a9c2ac2d6be231ff9f71ee3bcce9936d8.zip
Projects: discovery, create, edit, manage linked resources (#12, #13, #14, #15)
hub.sr.ht's GraphQL now exposes the project write API (create/update/ link/unlink) and a public `projects` discovery query, so these previously-blocked issues can be built. ProjectService gains: fetchPublicProjects (#12); createProject (#13); updateProject with ProjectInput (#14); link/unlink for sources, trackers, and mailing lists plus linkable-resource candidate fetches (#15); and hub cache invalidation after every mutation. UI: a Discover browser and a create form reached from the projects list; an Edit form and a Manage Resources sheet on project detail, gated to projects the user owns. Mutations degrade to a visible error if a field isn't live yet. Built against the master schema; live deployment on sr.ht/query is not yet confirmed (introspection there requires a token).
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)
+ }
+}