From 1f8f651a9c2ac2d6be231ff9f71ee3bcce9936d8 Mon Sep 17 00:00:00 2001 From: Christian Cleberg Date: Fri, 7 Aug 2026 04:12:50 -0500 Subject: 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). --- .../Projects/ManageProjectResourcesView.swift | 303 +++++++++++++++++++++ 1 file changed, 303 insertions(+) create mode 100644 Hutch/Views/Projects/ManageProjectResourcesView.swift (limited to 'Hutch/Views/Projects/ManageProjectResourcesView.swift') diff --git a/Hutch/Views/Projects/ManageProjectResourcesView.swift b/Hutch/Views/Projects/ManageProjectResourcesView.swift new file mode 100644 index 0000000..5769d17 --- /dev/null +++ b/Hutch/Views/Projects/ManageProjectResourcesView.swift @@ -0,0 +1,303 @@ +import SwiftUI + +@Observable +@MainActor +final class ManageProjectResourcesViewModel { + private(set) var sources: [Project.SourceRepo] + private(set) var trackers: [Project.Tracker] + private(set) var mailingLists: [Project.MailingList] + private(set) var busyID: String? + var error: String? + + let projectID: String + let projectName: String + private let service: ProjectService + private let onChange: () async -> Void + + init(project: Project, service: ProjectService, onChange: @escaping () async -> Void) { + projectID = project.id + projectName = project.displayName + sources = project.sources + trackers = project.trackers + mailingLists = project.mailingLists + self.service = service + self.onChange = onChange + } + + /// rids already linked, so the candidate pickers can hide them. + var linkedRIDs: Set { + Set(sources.map(\.id) + trackers.map(\.id) + mailingLists.map(\.id)) + } + + func unlink(source: Project.SourceRepo) async { + await mutate(id: source.id) { + try await self.service.unlinkSource(projectID: self.projectID, sourceRepoID: source.id) + self.sources.removeAll { $0.id == source.id } + } + } + + func unlink(tracker: Project.Tracker) async { + await mutate(id: tracker.id) { + try await self.service.unlinkTracker(projectID: self.projectID, trackerID: tracker.id) + self.trackers.removeAll { $0.id == tracker.id } + } + } + + func unlink(mailingList: Project.MailingList) async { + await mutate(id: mailingList.id) { + try await self.service.unlinkMailingList(projectID: self.projectID, listID: mailingList.id) + self.mailingLists.removeAll { $0.id == mailingList.id } + } + } + + func link(_ resource: LinkableResource) async { + await mutate(id: resource.rid) { + switch resource.kind { + case .source: + try await self.service.linkSource(projectID: self.projectID, sourceRepoID: resource.rid) + case .tracker: + try await self.service.linkTracker(projectID: self.projectID, trackerID: resource.rid) + case .mailingList: + try await self.service.linkMailingList(projectID: self.projectID, listID: resource.rid) + } + try await self.reload() + } + } + + func candidates(for kind: LinkableResource.Kind) async throws -> [LinkableResource] { + let all: [LinkableResource] + switch kind { + case .source: all = try await service.fetchLinkableSources() + case .tracker: all = try await service.fetchLinkableTrackers() + case .mailingList: all = try await service.fetchLinkableMailingLists() + } + let linked = linkedRIDs + return all.filter { !linked.contains($0.rid) } + } + + private func reload() async throws { + let project = try await service.fetchProjectDetail(rid: projectID) + sources = project.sources + trackers = project.trackers + mailingLists = project.mailingLists + } + + private func mutate(id: String, _ work: @escaping () async throws -> Void) async { + guard busyID == nil else { return } + busyID = id + error = nil + defer { busyID = nil } + do { + try await work() + await onChange() + } catch { + self.error = error.userFacingMessage + } + } +} + +struct ManageProjectResourcesView: View { + let project: Project + let onChange: () async -> Void + + @Environment(AppState.self) private var appState + @Environment(\.dismiss) private var dismiss + @State private var viewModel: ManageProjectResourcesViewModel? + @State private var addKind: LinkableResource.Kind? + + var body: some View { + NavigationStack { + Group { + if let viewModel { + content(viewModel) + } else { + SRHTLoadingStateView(message: "Loading…") + } + } + .navigationTitle("Linked Resources") + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .confirmationAction) { + Button("Done") { dismiss() } + } + ToolbarItem(placement: .topBarLeading) { + Menu { + Button("Add Repository") { addKind = .source } + Button("Add Tracker") { addKind = .tracker } + Button("Add Mailing List") { addKind = .mailingList } + } label: { + Image(systemName: "plus") + } + .accessibilityLabel("Add linked resource") + } + } + .sheet(item: Binding(get: { addKind.map { AddKind(kind: $0) } }, set: { addKind = $0?.kind })) { wrapper in + if let viewModel { + LinkableResourcePicker(kind: wrapper.kind, viewModel: viewModel) + } + } + } + .task { + if viewModel == nil { + viewModel = ManageProjectResourcesViewModel( + project: project, + service: ProjectService(client: appState.client), + onChange: onChange + ) + } + } + } + + @ViewBuilder + private func content(_ viewModel: ManageProjectResourcesViewModel) -> some View { + List { + resourceSection( + title: "Repositories", + items: viewModel.sources, + busyID: viewModel.busyID, + label: { "\($0.ownerUsername)/\($0.displayName)" }, + onDelete: { await viewModel.unlink(source: $0) } + ) + resourceSection( + title: "Trackers", + items: viewModel.trackers, + busyID: viewModel.busyID, + label: { "\($0.ownerUsername)/\($0.displayName)" }, + onDelete: { await viewModel.unlink(tracker: $0) } + ) + resourceSection( + title: "Mailing Lists", + items: viewModel.mailingLists, + busyID: viewModel.busyID, + label: { "\($0.ownerUsername)/\($0.displayName)" }, + onDelete: { await viewModel.unlink(mailingList: $0) } + ) + + if viewModel.sources.isEmpty, viewModel.trackers.isEmpty, viewModel.mailingLists.isEmpty { + Section { + Text("No linked resources. Use + to add repositories, trackers, or mailing lists.") + .foregroundStyle(.secondary) + .themedRow() + } + } + } + .themedList() + .srhtErrorBanner( + error: Binding(get: { viewModel.error }, set: { viewModel.error = $0 }) + ) + } + + @ViewBuilder + private func resourceSection( + title: String, + items: [Item], + busyID: String?, + label: @escaping (Item) -> String, + onDelete: @escaping (Item) async -> Void + ) -> some View where Item.ID == String { + if !items.isEmpty { + Section(title) { + ForEach(items) { item in + HStack { + Text(label(item)) + .font(.body.monospaced()) + .lineLimit(1) + Spacer() + if busyID == item.id { + ProgressView().controlSize(.small) + } + } + .themedRow() + .swipeActions(edge: .trailing, allowsFullSwipe: true) { + Button(role: .destructive) { + Task { await onDelete(item) } + } label: { + Label("Unlink", systemImage: "link.badge.minus") + } + } + } + } + } + } +} + +private struct AddKind: Identifiable { + let kind: LinkableResource.Kind + var id: String { + switch kind { + case .source: "source" + case .tracker: "tracker" + case .mailingList: "mailingList" + } + } +} + +private struct LinkableResourcePicker: View { + let kind: LinkableResource.Kind + let viewModel: ManageProjectResourcesViewModel + + @Environment(\.dismiss) private var dismiss + @State private var candidates: [LinkableResource] = [] + @State private var isLoading = true + @State private var error: String? + + private var title: String { + switch kind { + case .source: "Add Repository" + case .tracker: "Add Tracker" + case .mailingList: "Add Mailing List" + } + } + + var body: some View { + NavigationStack { + Group { + if isLoading { + SRHTLoadingStateView(message: "Loading…") + } else if let error { + SRHTErrorStateView(title: "Couldn't Load", message: error, retryAction: { await load() }) + } else if candidates.isEmpty { + ContentUnavailableView( + "Nothing to Add", + systemImage: "checkmark.circle", + description: Text("There are no more resources of this type to link.") + ) + } else { + List(candidates) { candidate in + Button { + Task { + await viewModel.link(candidate) + dismiss() + } + } label: { + Text(candidate.displayName) + .font(.body.monospaced()) + .lineLimit(1) + } + .themedRow() + } + .themedList() + } + } + .navigationTitle(title) + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .cancellationAction) { + Button("Cancel") { dismiss() } + } + } + } + .task { await load() } + } + + private func load() async { + isLoading = true + error = nil + do { + candidates = try await viewModel.candidates(for: kind) + } catch { + self.error = error.userFacingMessage + } + isLoading = false + } +} -- cgit v1.2.3