import SwiftUI /// A repository's mirrors: where each pushes to or pulls from, how its /// last sync went, a way to sync now, add and remove. struct MirrorsView: View { @State private var model: MirrorsViewModel @State private var adding = false @State private var removing: Mirror? @State private var scheduled = false init(client: GitbayClient, repo: String) { _model = State(initialValue: MirrorsViewModel(client: client, repoPath: repo)) } var body: some View { List { if let error = model.actionError, !adding { Section { GBNotice(error, .gbWarn) } } else if scheduled { Section { GBNotice("Sync scheduled. Pull to refresh for each mirror's result.", .gbOK) } } Section { ForEach(model.state.value ?? []) { mirror in row(mirror) .swipeActions { Button("Remove", role: .destructive) { scheduled = false removing = mirror } .disabled(model.working) } } } footer: { if model.state.value != nil { Text("A push mirror sends every push here to the remote. A pull mirror fetches from it, and pushes here are refused.") } } if model.state.value != nil { Section { Button { Task { scheduled = await model.sync() } } label: { Label("Sync now", systemImage: "arrow.triangle.2.circlepath") .font(.gbSans(.subheadline)) } .disabled(model.working) .accessibilityIdentifier("mirror-sync-button") } } } .overlay { LoadStateOverlay(state: model.state) } .navigationTitle("Mirrors") .navigationBarTitleDisplayMode(.inline) .toolbar { ToolbarItem(placement: .topBarTrailing) { Button { scheduled = false adding = true } label: { Image(systemName: "plus") } .accessibilityLabel("Add Mirror") .disabled(model.working) .accessibilityIdentifier("mirror-add-button") } } .sheet(isPresented: $adding) { MirrorAddSheet(model: model) { adding = false } } .confirmationDialog( "Remove the mirror \(removing?.isPull == true ? "from" : "to") \(removing?.url ?? "")?", isPresented: Binding(get: { removing != nil }, set: { if !$0 { removing = nil } }), titleVisibility: .visible ) { Button("Remove", role: .destructive) { if let mirror = removing { Task { await model.remove(id: mirror.id) } } removing = nil } Button("Cancel", role: .cancel) {} } .task { await model.load() } .refreshable { scheduled = false await model.load() } } private func row(_ mirror: Mirror) -> some View { VStack(alignment: .leading, spacing: 3) { Text(mirror.url) .font(.gbMono(.caption)) .lineLimit(2) HStack(spacing: 6) { GBChip(mirror.isPull ? "pull" : "push", .secondary) if let username = mirror.username { Text("as \(username)") } if mirror.pending { Text("sync pending") } else if let synced = mirror.lastSync { Text("synced \(synced.formatted(.relative(presentation: .named)))") } } .font(.gbSans(.caption2)) .foregroundStyle(.secondary) if let error = mirror.lastError, !error.isEmpty { Text(error) .font(.gbSans(.caption2)) .foregroundStyle(Color.gbBad) } } .padding(.vertical, 2) .accessibilityElement(children: .combine) } } private struct MirrorAddSheet: View { let model: MirrorsViewModel let onDone: () -> Void @State private var url = "" @State private var pull = false @State private var username = "" @State private var token = "" @State private var attempted = false var body: some View { NavigationStack { Form { Section { TextField("https://…", text: $url) .autocorrectionDisabled() .textInputAutocapitalization(.never) .keyboardType(.URL) .accessibilityIdentifier("mirror-url") Picker("Direction", selection: $pull) { Text("Push").tag(false) Text("Pull").tag(true) } .pickerStyle(.segmented) .accessibilityIdentifier("mirror-direction") } footer: { Text(pull ? "Pulls from the remote. Pushes to this repository are refused while the mirror exists." : "Pushes here are sent on to the remote.") } Section { TextField("Username (optional)", text: $username) .autocorrectionDisabled() .textInputAutocapitalization(.never) SecureField("Token (optional)", text: $token) .autocorrectionDisabled() .textInputAutocapitalization(.never) } footer: { Text("For a remote that needs credentials. The token is stored for syncs and not shown again.") } if attempted, let error = model.actionError { Section { GBNotice(error, .gbWarn) } } } .navigationTitle("Add Mirror") .navigationBarTitleDisplayMode(.inline) .toolbar { ToolbarItem(placement: .cancellationAction) { Button("Cancel") { onDone() }.disabled(model.working) } ToolbarItem(placement: .confirmationAction) { if model.working { ProgressView() } else { Button("Add") { Task { attempted = true guard await model.add(url: url, pull: pull, username: username, token: token) else { return } // A secure field that still holds text as the sheet // goes makes iOS offer to save it as a password. token = "" await Task.yield() onDone() } } .disabled(url.trimmingCharacters(in: .whitespaces).isEmpty) .accessibilityIdentifier("mirror-submit") } } } .interactiveDismissDisabled(model.working) } } } /// A repository's custom pages domains: each claim, verified or waiting /// on its DNS record, with verify and remove. struct PageDomainsView: View { @State private var model: PageDomainsViewModel @State private var adding = false @State private var removing: PageDomain? @State private var verified: String? init(client: GitbayClient, repo: String) { _model = State(initialValue: PageDomainsViewModel(client: client, repoPath: repo)) } var body: some View { List { if let error = model.actionError, !adding { Section { GBNotice(error, .gbWarn) } } else if let verified { Section { GBNotice("\(verified) is verified. Point its A and AAAA records at this server.", .gbOK) } } Section { ForEach(model.state.value ?? []) { claim in VStack(alignment: .leading, spacing: 3) { Text(claim.domain).font(.gbMono(.subheadline)) HStack(spacing: 6) { GBChip(claim.state, claim.isVerified ? Color.gbOK : claim.isPending ? Color.gbWarn : Color.secondary) if let at = claim.verifiedAt { Text("since \(at.formatted(date: .abbreviated, time: .omitted))") .font(.gbSans(.caption2)) .foregroundStyle(.secondary) } } } .padding(.vertical, 2) .accessibilityElement(children: .combine) .swipeActions { Button("Remove", role: .destructive) { verified = nil removing = claim } .disabled(model.working) if claim.isPending { Button("Verify") { verify(claim.domain) } .tint(.gbAccent) .disabled(model.working) } } } } footer: { if model.state.value != nil { Text("A domain serves this repository's pages branch once a DNS TXT record proves the claim. Swipe a pending claim to verify it; an expired one has to be claimed again.") } } } .overlay { LoadStateOverlay(state: model.state) } .navigationTitle("Custom Domains") .navigationBarTitleDisplayMode(.inline) .toolbar { ToolbarItem(placement: .topBarTrailing) { Button { verified = nil adding = true } label: { Image(systemName: "plus") } .accessibilityLabel("Claim Domain") .disabled(model.working) .accessibilityIdentifier("domain-add-button") } } .sheet(isPresented: $adding) { DomainClaimSheet(model: model) { domain in adding = false if let domain { verified = domain } } } .confirmationDialog( "Remove \(removing?.domain ?? "")? It stops serving this repository's pages.", isPresented: Binding(get: { removing != nil }, set: { if !$0 { removing = nil } }), titleVisibility: .visible ) { Button("Remove", role: .destructive) { if let claim = removing { Task { await model.remove(claim.domain) } } removing = nil } Button("Cancel", role: .cancel) {} } .task { await model.load() } .refreshable { verified = nil await model.load() } } private func verify(_ domain: String) { verified = nil Task { if await model.verify(domain) { verified = domain } } } } /// Claims a domain, then shows the TXT record to create and a way to /// verify it once it is in place. The record is shown only here. private struct DomainClaimSheet: View { let model: PageDomainsViewModel /// The domain when it was verified from here, else nil. let onDone: (String?) -> Void @State private var domain = "" @State private var challenge: DomainChallenge? @State private var attempted = false var body: some View { NavigationStack { Form { if let challenge { Section { record("Name", challenge.name, id: "domain-copy-name") LabeledContent("Type", value: "TXT") record("Value", challenge.value, id: "domain-copy-value") } header: { Text("Create this DNS record") } footer: { Text("This record is shown only now. Create it, then verify, here or later from the list. An unverified claim expires after a few days.") } } else { Section { TextField("docs.example.com", text: $domain) .autocorrectionDisabled() .textInputAutocapitalization(.never) .keyboardType(.URL) .accessibilityIdentifier("domain-name") } footer: { Text("A domain you control. Claiming it gives you a DNS record that proves it.") } } if attempted, let error = model.actionError { Section { GBNotice(error, .gbWarn) } } } .navigationTitle(challenge == nil ? "Claim Domain" : challenge!.domain) .navigationBarTitleDisplayMode(.inline) .toolbar { ToolbarItem(placement: .cancellationAction) { Button(challenge == nil ? "Cancel" : "Done") { onDone(nil) } .disabled(model.working) .accessibilityIdentifier("domain-done") } ToolbarItem(placement: .confirmationAction) { if model.working { ProgressView() } else if let challenge { Button("Verify") { Task { attempted = true if await model.verify(challenge.domain) { onDone(challenge.domain) } } } .accessibilityIdentifier("domain-verify") } else { Button("Claim") { Task { attempted = true challenge = await model.add(domain) } } .disabled(domain.trimmingCharacters(in: .whitespaces).isEmpty) .accessibilityIdentifier("domain-submit") } } } // The record cannot be fetched again, so it closes only by Done. .interactiveDismissDisabled(model.working || challenge != nil) } } private func record(_ label: String, _ text: String, id: String) -> some View { HStack(alignment: .firstTextBaseline) { VStack(alignment: .leading, spacing: 2) { Text(label).font(.gbSans(.caption)).foregroundStyle(.secondary) Text(text).font(.gbMono(.caption)).textSelection(.enabled) } Spacer() Button { UIPasteboard.general.string = text } label: { Image(systemName: "doc.on.doc") } .buttonStyle(.borderless) .accessibilityLabel("Copy \(label)") .accessibilityIdentifier(id) } } }