Files
cairnquire/apps/macos-sync/Sources/CairnquireSync/UI/SettingsView.swift
2026-06-04 08:50:34 -04:00

263 lines
8.9 KiB
Swift

import SwiftUI
public struct SettingsView: View {
@State public var config: SyncConfig
public var onSave: (SyncConfig) -> Void
@State private var serverURL: String = ""
@State private var apiToken: String = ""
@State private var syncFolder: String = ""
@State private var defaultUploadFolder: String = ""
@State private var newNoteAction: NewNoteAction = .clipboard
@State private var autoSync: Bool = true
@State private var syncInterval: Double = 30
@State private var selectedIcon: String = "tray.circle.fill"
private let labelWidth: CGFloat = 120
public init(config: SyncConfig, onSave: @escaping (SyncConfig) -> Void) {
self.config = config
self.onSave = onSave
}
public var body: some View {
VStack(spacing: 0) {
ScrollView {
VStack(spacing: 0) {
serverSection
Divider().padding(.vertical, 12)
syncSection
Divider().padding(.vertical, 12)
uploadsSection
Divider().padding(.vertical, 12)
appearanceSection
}
.padding(20)
}
.scrollIndicators(.hidden)
Divider()
saveButton
.padding(.horizontal, 20)
.padding(.vertical, 12)
}
.frame(width: 520, height: 640)
.onAppear {
serverURL = config.serverURL
apiToken = config.apiToken ?? ""
syncFolder = config.syncFolder ?? ""
defaultUploadFolder = config.defaultUploadFolder ?? ""
newNoteAction = config.effectiveNewNoteAction
autoSync = config.autoSync
syncInterval = Double(config.syncInterval)
selectedIcon = config.iconName
}
}
// MARK: - Sections
private var serverSection: some View {
VStack(alignment: .leading, spacing: 12) {
sectionHeader("Server", icon: "server.rack")
formRow(label: "Server URL") {
TextField("", text: $serverURL)
.textFieldStyle(.roundedBorder)
.frame(width: 280)
}
formRow(label: "API Token") {
SecureField("", text: $apiToken)
.textFieldStyle(.roundedBorder)
.frame(width: 280)
}
HStack {
Spacer()
.frame(width: labelWidth)
Text("Create an API token from the web app at /account/tokens/new")
.font(.caption)
.foregroundColor(.secondary)
.lineLimit(nil)
.fixedSize(horizontal: false, vertical: true)
.frame(width: 280, alignment: .leading)
}
}
}
private var syncSection: some View {
VStack(alignment: .leading, spacing: 12) {
sectionHeader("Sync", icon: "arrow.triangle.2.circlepath")
formRow(label: "Sync Folder") {
HStack(spacing: 8) {
TextField("", text: $syncFolder)
.textFieldStyle(.roundedBorder)
.frame(width: 200)
Button("Choose...") {
chooseFolder()
}
.controlSize(.small)
}
}
formRow(label: "") {
Toggle("Auto Sync", isOn: $autoSync)
}
if autoSync {
formRow(label: "Interval") {
HStack(spacing: 12) {
Slider(value: $syncInterval, in: 5...300, step: 5)
.frame(width: 160)
Text("\(Int(syncInterval))s")
.font(.system(size: 12, design: .monospaced))
.foregroundColor(.secondary)
.frame(width: 40, alignment: .trailing)
}
}
}
}
}
private var uploadsSection: some View {
VStack(alignment: .leading, spacing: 12) {
sectionHeader("Uploads", icon: "arrow.up.doc")
formRow(label: "Upload Folder") {
TextField("e.g. inbox", text: $defaultUploadFolder)
.textFieldStyle(.roundedBorder)
.frame(width: 280)
}
formRow(label: "New Note") {
Picker("", selection: $newNoteAction) {
Text("From Clipboard").tag(NewNoteAction.clipboard)
Text("Open Terminal $EDITOR").tag(NewNoteAction.editor)
}
.pickerStyle(.menu)
.frame(width: 200)
}
HStack {
Spacer()
.frame(width: labelWidth)
Text("Notes and uploads will be saved to this folder. Editor notes upload after the editor exits.")
.font(.caption)
.foregroundColor(.secondary)
.frame(width: 280, alignment: .leading)
}
}
}
private var appearanceSection: some View {
VStack(alignment: .leading, spacing: 12) {
sectionHeader("Appearance", icon: "paintbrush")
formRow(label: "Menu Icon") {
Picker("", selection: $selectedIcon) {
ForEach(SyncConfig.iconOptions, id: \.self) { icon in
HStack(spacing: 8) {
Image(systemName: icon)
.frame(width: 20, alignment: .center)
Text(iconNameToLabel(icon))
}
.tag(icon)
}
}
.pickerStyle(.menu)
.frame(width: 180)
}
}
}
private var saveButton: some View {
HStack {
Spacer()
Button {
let newConfig = SyncConfig(
serverURL: serverURL,
apiToken: apiToken.isEmpty ? nil : apiToken,
syncFolder: syncFolder.isEmpty ? nil : syncFolder,
defaultUploadFolder: defaultUploadFolder.isEmpty ? nil : defaultUploadFolder,
newNoteAction: newNoteAction,
autoSync: autoSync,
syncInterval: Int(syncInterval),
deviceId: config.deviceId,
iconName: selectedIcon
)
onSave(newConfig)
} label: {
Text("Save")
.frame(width: 80)
}
.keyboardShortcut(.defaultAction)
.controlSize(.large)
.buttonStyle(.borderedProminent)
}
.frame(maxWidth: .infinity)
}
// MARK: - Helpers
private func sectionHeader(_ title: String, icon: String) -> some View {
HStack(spacing: 8) {
Image(systemName: icon)
.foregroundColor(.accentColor)
.font(.system(size: 16, weight: .medium))
Text(title)
.font(.system(size: 16, weight: .semibold))
Spacer()
}
.padding(.bottom, 4)
}
private func formRow<Content: View>(label: String, @ViewBuilder content: () -> Content) -> some View {
HStack(alignment: .firstTextBaseline, spacing: 16) {
Text(label)
.font(.system(size: 13))
.foregroundColor(.primary)
.frame(width: labelWidth, alignment: .trailing)
content()
Spacer()
}
}
private func chooseFolder() {
let panel = NSOpenPanel()
panel.canChooseFiles = false
panel.canChooseDirectories = true
panel.allowsMultipleSelection = false
panel.prompt = "Select Folder"
if panel.runModal() == .OK {
syncFolder = panel.url?.path ?? ""
}
}
private func iconNameToLabel(_ name: String) -> String {
let labels: [String: String] = [
"tray.circle.fill": "Tray (Default)",
"arrow.triangle.2.circlepath": "Sync",
"arrow.up.doc": "Upload",
"doc.badge.arrow.up": "Doc Upload",
"arrow.up.and.down.and.arrow.left.and.right": "Transfer",
"cloud": "Cloud",
"externaldrive.connected.to.line.below": "Server",
"note.text": "Notes",
"doc.text": "Documents",
"arrow.clockwise.circle": "Refresh",
"rectangle.stack.badge.plus": "Stack"
]
return labels[name] ?? name
}
}
#Preview {
SettingsView(config: SyncConfig()) { _ in }
}