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). --- Hutch/Views/Projects/ProjectFormSheet.swift | 144 ++++++++++++++++++++++++++++ 1 file changed, 144 insertions(+) create mode 100644 Hutch/Views/Projects/ProjectFormSheet.swift (limited to 'Hutch/Views/Projects/ProjectFormSheet.swift') 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() + 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] +} -- cgit v1.2.3