summaryrefslogtreecommitdiff
path: root/Hutch/Views
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
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')
-rw-r--r--Hutch/Views/Projects/DiscoverProjectsView.swift166
-rw-r--r--Hutch/Views/Projects/ManageProjectResourcesView.swift303
-rw-r--r--Hutch/Views/Projects/ProjectDetailView.swift90
-rw-r--r--Hutch/Views/Projects/ProjectFormSheet.swift144
-rw-r--r--Hutch/Views/Projects/ProjectsListView.swift58
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)
}