diff options
Diffstat (limited to 'Hutch/Views')
| -rw-r--r-- | Hutch/Views/Projects/DiscoverProjectsView.swift | 166 | ||||
| -rw-r--r-- | Hutch/Views/Projects/ManageProjectResourcesView.swift | 303 | ||||
| -rw-r--r-- | Hutch/Views/Projects/ProjectDetailView.swift | 90 | ||||
| -rw-r--r-- | Hutch/Views/Projects/ProjectFormSheet.swift | 144 | ||||
| -rw-r--r-- | Hutch/Views/Projects/ProjectsListView.swift | 58 |
5 files changed, 759 insertions, 2 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) + } +} 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<String> { + 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<Item: Identifiable>( + 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 + } +} diff --git a/Hutch/Views/Projects/ProjectDetailView.swift b/Hutch/Views/Projects/ProjectDetailView.swift index c30ec88..0685a83 100644 --- a/Hutch/Views/Projects/ProjectDetailView.swift +++ b/Hutch/Views/Projects/ProjectDetailView.swift @@ -2,6 +2,7 @@ import SwiftUI struct ProjectDetailView: View { let project: Project + var canManage: Bool = false @Environment(AppState.self) private var appState @Environment(\.dismiss) private var dismiss @@ -10,6 +11,14 @@ struct ProjectDetailView: View { @State private var isLoading = false @State private var error: String? @State private var pinChangeCount = 0 + @State private var isPresentingEdit = false + @State private var isPresentingManage = false + @State private var isSavingEdit = false + @State private var editError: String? + + private var projectService: ProjectService { + ProjectService(client: appState.client) + } private var displayedProject: Project { detailProject ?? project @@ -59,6 +68,46 @@ struct ProjectDetailView: View { .accessibilityLabel(isPinnedToHome ? "Unpin from Home" : "Pin to Home") } } + if canManage { + ToolbarItem(placement: .topBarTrailing) { + Menu { + Button { + editError = nil + isPresentingEdit = true + } label: { + Label("Edit Project", systemImage: "pencil") + } + Button { + isPresentingManage = true + } label: { + Label("Manage Resources", systemImage: "link") + } + } label: { + Image(systemName: "ellipsis.circle") + } + .accessibilityLabel("Manage project") + } + } + } + .sheet(isPresented: $isPresentingEdit) { + ProjectFormSheet( + title: "Edit Project", + confirmationTitle: "Save", + isSaving: isSavingEdit, + error: editError, + includeWebsite: true, + initialName: displayedProject.name, + initialDescription: displayedProject.description ?? "", + initialWebsite: displayedProject.website ?? "", + initialTags: displayedProject.tags, + initialVisibility: displayedProject.visibility, + onSave: { await saveEdits($0) } + ) + } + .sheet(isPresented: $isPresentingManage) { + ManageProjectResourcesView(project: displayedProject) { + await loadProjectIfNeeded(forceRefresh: true) + } } .task { await loadProjectIfNeeded() @@ -69,6 +118,47 @@ struct ProjectDetailView: View { .srhtErrorBanner(error: $error) } + private func saveEdits(_ values: ProjectFormValues) async -> Bool { + guard !isSavingEdit else { return false } + isSavingEdit = true + editError = nil + defer { isSavingEdit = false } + + do { + let updated = try await projectService.updateProject( + rid: displayedProject.id, + name: values.name, + description: values.description, + website: values.website, + visibility: values.visibility, + tags: values.tags + ) + // Preserve already-loaded linked resources; the mutation returns metadata only. + detailProject = Project( + metadata: .init( + id: updated.id, + name: updated.name, + description: updated.description, + website: updated.website, + visibility: updated.visibility, + tags: updated.tags, + updated: updated.updated + ), + resources: .init( + mailingLists: displayedProject.mailingLists, + sources: displayedProject.sources, + trackers: displayedProject.trackers, + isFullyLoaded: displayedProject.isFullyLoaded + ) + ) + await loadProjectIfNeeded(forceRefresh: true) + return true + } catch { + editError = error.userFacingMessage + return false + } + } + @ViewBuilder private var headerSection: some View { Section { diff --git a/Hutch/Views/Projects/ProjectFormSheet.swift b/Hutch/Views/Projects/ProjectFormSheet.swift new file mode 100644 index 0000000..b05ae66 --- /dev/null +++ b/Hutch/Views/Projects/ProjectFormSheet.swift @@ -0,0 +1,144 @@ +import SwiftUI + +/// Shared create/edit form for hub.sr.ht projects. The parent owns the save +/// state and performs the mutation via `onSave`, dismissing on success. +struct ProjectFormSheet: View { + let title: String + let confirmationTitle: String + let isSaving: Bool + let error: String? + /// `createProject` takes no website; only the edit flow shows the field. + let includeWebsite: Bool + let onSave: (ProjectFormValues) async -> Bool + + @Environment(\.dismiss) private var dismiss + @State private var name: String + @State private var description: String + @State private var website: String + @State private var tags: String + @State private var visibility: Visibility + + init( + title: String, + confirmationTitle: String, + isSaving: Bool, + error: String?, + includeWebsite: Bool, + initialName: String = "", + initialDescription: String = "", + initialWebsite: String = "", + initialTags: [String] = [], + initialVisibility: Visibility = .publicVisibility, + onSave: @escaping (ProjectFormValues) async -> Bool + ) { + self.title = title + self.confirmationTitle = confirmationTitle + self.isSaving = isSaving + self.error = error + self.includeWebsite = includeWebsite + self.onSave = onSave + _name = State(initialValue: initialName) + _description = State(initialValue: initialDescription) + _website = State(initialValue: initialWebsite) + _tags = State(initialValue: initialTags.joined(separator: ", ")) + _visibility = State(initialValue: initialVisibility) + } + + private var trimmedName: String { + name.trimmingCharacters(in: .whitespacesAndNewlines) + } + + var body: some View { + NavigationStack { + Form { + Section("Project Details") { + TextField("Project name", text: $name) + .textInputAutocapitalization(.never) + .autocorrectionDisabled() + .themedRow() + TextField("Description (optional)", text: $description, axis: .vertical) + .lineLimit(2...4) + .themedRow() + if includeWebsite { + TextField("Website (optional)", text: $website) + .textInputAutocapitalization(.never) + .autocorrectionDisabled() + .keyboardType(.URL) + .themedRow() + } + Picker("Visibility", selection: $visibility) { + Text("Public").tag(Visibility.publicVisibility) + Text("Unlisted").tag(Visibility.unlisted) + Text("Private").tag(Visibility.privateVisibility) + } + .themedRow() + } + + Section { + TextField("Tags (comma separated)", text: $tags) + .textInputAutocapitalization(.never) + .autocorrectionDisabled() + .themedRow() + } footer: { + Text("Separate tags with commas.") + } + + if let error, !error.isEmpty { + Section { + Text(error) + .foregroundStyle(.red) + .themedRow() + } + } + } + .themedList() + .navigationTitle(title) + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .cancellationAction) { + Button("Cancel") { dismiss() } + } + ToolbarItem(placement: .confirmationAction) { + Button { + Task { + let values = ProjectFormValues( + name: trimmedName, + description: description.trimmingCharacters(in: .whitespacesAndNewlines), + website: website.trimmingCharacters(in: .whitespacesAndNewlines), + visibility: visibility, + tags: Self.parseTags(tags) + ) + if await onSave(values) { + dismiss() + } + } + } label: { + if isSaving { + ProgressView().controlSize(.small) + } else { + Text(confirmationTitle) + } + } + .disabled(trimmedName.isEmpty || isSaving) + } + } + } + } + + static func parseTags(_ raw: String) -> [String] { + var seen = Set<String>() + return raw + .split(whereSeparator: { $0 == "," || $0.isNewline }) + .map { $0.trimmingCharacters(in: .whitespaces) } + .filter { !$0.isEmpty } + .filter { seen.insert($0.lowercased()).inserted } + } +} + +struct ProjectFormValues: Sendable { + let name: String + let description: String + let website: String + let visibility: Visibility + let tags: [String] +} diff --git a/Hutch/Views/Projects/ProjectsListView.swift b/Hutch/Views/Projects/ProjectsListView.swift index 783417c..1a34346 100644 --- a/Hutch/Views/Projects/ProjectsListView.swift +++ b/Hutch/Views/Projects/ProjectsListView.swift @@ -5,15 +5,38 @@ import SwiftUI final class ProjectsListViewModel { private(set) var projects: [Project] = [] private(set) var isLoading = false + private(set) var isSaving = false var error: String? + var saveError: String? var searchText = "" - private let service: ProjectService + let service: ProjectService init(service: ProjectService) { self.service = service } + func createProject(_ values: ProjectFormValues) async -> Bool { + guard !isSaving else { return false } + isSaving = true + saveError = nil + defer { isSaving = false } + + do { + _ = try await service.createProject( + name: values.name, + visibility: values.visibility, + description: values.description.isEmpty ? nil : values.description, + tags: values.tags + ) + await loadProjects(forceRefresh: true) + return true + } catch { + saveError = error.userFacingMessage + return false + } + } + var filteredProjects: [Project] { let query = searchText.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() guard !query.isEmpty else { return projects } @@ -46,11 +69,42 @@ final class ProjectsListViewModel { struct ProjectsListView: View { @Environment(AppState.self) private var appState @State private var viewModel: ProjectsListViewModel? + @State private var isPresentingCreate = false var body: some View { Group { if let viewModel { content(viewModel) + .toolbar { + ToolbarItem(placement: .topBarTrailing) { + NavigationLink { + DiscoverProjectsView() + } label: { + Image(systemName: "sparkle.magnifyingglass") + } + .accessibilityLabel("Discover public projects") + } + if appState.currentUser != nil { + ToolbarItem(placement: .topBarTrailing) { + Button { + isPresentingCreate = true + } label: { + Image(systemName: "plus") + } + .accessibilityLabel("Create project") + } + } + } + .sheet(isPresented: $isPresentingCreate) { + ProjectFormSheet( + title: "New Project", + confirmationTitle: "Create", + isSaving: viewModel.isSaving, + error: viewModel.saveError, + includeWebsite: false, + onSave: { await viewModel.createProject($0) } + ) + } } else { SRHTLoadingStateView(message: "Loading projects…") } @@ -70,7 +124,7 @@ struct ProjectsListView: View { List { ForEach(viewModel.filteredProjects) { project in NavigationLink { - ProjectDetailView(project: project) + ProjectDetailView(project: project, canManage: true) } label: { ProjectListRow(project: project) } |
