- Edit bookmark: full PATCH via swipe action or long-press context menu; EditBookmarkView covers URL, title, description, tags, and unread toggle - Offline cache: bookmarks persisted to ApplicationSupport JSON and shown instantly on launch; cache is per-server, skipped when unread filter is on - Unread filter: toolbar toggle sends ?unread=true to the API; title updates to "Unread"; no stale-cache flash when toggling - Extract normalizedURL and parsedTags into String+Helpers, removing three copies of URL normalization and two of tag parsing Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
142 lines
5.2 KiB
Swift
142 lines
5.2 KiB
Swift
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)
|
|
.background(canConnect ? Color.primary : Color(.systemGray4))
|
|
.foregroundStyle(canConnect ? Color(UIColor.systemBackground) : Color(.systemGray2))
|
|
.clipShape(RoundedRectangle(cornerRadius: 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"
|
|
)
|
|
}
|
|
}
|