import SwiftUI struct OnboardingView: View { let onConnect: (ServerConfig) -> Void @State private var urlText = "" @State private var token = "" @State private var isConnecting = false @State private var errorMessage: String? @FocusState private var focused: Field? enum Field { case url, token } var body: some View { VStack(spacing: 0) { Spacer() VStack(alignment: .leading, spacing: 8) { Text("Marks") .font(.system(size: 42, weight: .semibold, design: .rounded)) Text("Your bookmarks, beautifully.") .font(.system(size: 17)) .foregroundStyle(.secondary) } .frame(maxWidth: .infinity, alignment: .leading) .padding(.horizontal, 28) .padding(.bottom, 48) VStack(spacing: 14) { VStack(alignment: .leading, spacing: 6) { Text("Server URL") .font(.system(size: 13, weight: .medium)) .foregroundStyle(.secondary) .padding(.horizontal, 2) TextField("https://links.example.com", text: $urlText) .textContentType(.URL) .keyboardType(.URL) .autocorrectionDisabled() .textInputAutocapitalization(.never) .focused($focused, equals: .url) .submitLabel(.next) .onSubmit { focused = .token } .padding(14) .background(Color(.systemGray6)) .clipShape(RoundedRectangle(cornerRadius: 12)) } VStack(alignment: .leading, spacing: 6) { Text("API Token") .font(.system(size: 13, weight: .medium)) .foregroundStyle(.secondary) .padding(.horizontal, 2) SecureField("Paste your token", text: $token) .textContentType(.password) .autocorrectionDisabled() .textInputAutocapitalization(.never) .focused($focused, equals: .token) .submitLabel(.done) .onSubmit { Task { await connect() } } .padding(14) .background(Color(.systemGray6)) .clipShape(RoundedRectangle(cornerRadius: 12)) Text("Find your token at Settings → API Token in Linkding.") .font(.system(size: 12)) .foregroundStyle(.tertiary) .padding(.horizontal, 2) } } .padding(.horizontal, 24) if let err = errorMessage { Text(err) .font(.system(size: 14)) .foregroundStyle(.red) .padding(.top, 12) .padding(.horizontal, 28) } Button { Task { await connect() } } label: { Group { if isConnecting { ProgressView().tint(.white) } else { Text("Connect") .font(.system(size: 17, weight: .semibold)) } } .frame(maxWidth: .infinity) .frame(height: 52) } .buttonStyle(.glassProminent) .buttonBorderShape(.roundedRectangle(radius: 14)) .disabled(!canConnect || isConnecting) .padding(.horizontal, 24) .padding(.top, 24) Spacer() } } private var canConnect: Bool { !urlText.isEmpty && !token.isEmpty } private func connect() async { focused = nil isConnecting = true errorMessage = nil do { let config = try parseConfig() let api = LinkdingAPI(config: config) try await api.verifyConnection() onConnect(config) } catch APIError.badStatus(401) { errorMessage = "Invalid token. Check your API token in Linkding settings." } catch APIError.badStatus(let code) { errorMessage = "Server returned \(code). Check the URL." } catch { errorMessage = "Could not connect. Check the URL and try again." } isConnecting = false } private func parseConfig() throws -> ServerConfig { let raw = urlText.normalizedURL guard let comps = URLComponents(string: raw), let host = comps.host, !host.isEmpty else { throw URLError(.badURL) } return ServerConfig( host: host, port: comps.port, path: comps.path.hasSuffix("/") ? String(comps.path.dropLast()) : comps.path, token: token.trimmingCharacters(in: .whitespacesAndNewlines), useHttps: comps.scheme == "https" ) } }