diff options
| author | Christian Cleberg <[email protected]> | 2026-08-07 04:12:50 -0500 |
|---|---|---|
| committer | Christian Cleberg <[email protected]> | 2026-08-07 04:12:50 -0500 |
| commit | 1f8f651a9c2ac2d6be231ff9f71ee3bcce9936d8 (patch) | |
| tree | 25cf810fa0e9a0ab343b60b59c26d8cc096c9a98 /Hutch/Views/Projects/ProjectFormSheet.swift | |
| parent | db40d346c28800a1eec384a5e4f3d7d090198e4d (diff) | |
| download | hutch-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/ProjectFormSheet.swift')
| -rw-r--r-- | Hutch/Views/Projects/ProjectFormSheet.swift | 144 |
1 files changed, 144 insertions, 0 deletions
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] +} |
