sync app filled in
This commit is contained in:
141
apps/macos-sync/Sources/CairnquireSync/UI/DropZoneView.swift
Normal file
141
apps/macos-sync/Sources/CairnquireSync/UI/DropZoneView.swift
Normal file
@@ -0,0 +1,141 @@
|
||||
import SwiftUI
|
||||
import UniformTypeIdentifiers
|
||||
|
||||
public struct DropZoneView: View {
|
||||
@State private var isDragging = false
|
||||
@State private var uploadResult: UploadResult?
|
||||
@State private var error: String?
|
||||
@State private var isUploading = false
|
||||
@State private var uploadProgress: Double = 0
|
||||
|
||||
public let uploadManager: UploadManager
|
||||
|
||||
public init(uploadManager: UploadManager) {
|
||||
self.uploadManager = uploadManager
|
||||
}
|
||||
|
||||
public var body: some View {
|
||||
VStack(spacing: 20) {
|
||||
Image(systemName: "arrow.up.doc.fill")
|
||||
.font(.system(size: 48))
|
||||
.foregroundColor(isDragging ? .blue : .secondary)
|
||||
|
||||
Text("Drop files here")
|
||||
.font(.title2)
|
||||
|
||||
if isUploading {
|
||||
ProgressView(value: uploadProgress)
|
||||
.frame(width: 180)
|
||||
Text("\(Int(uploadProgress * 100))%")
|
||||
.font(.caption)
|
||||
.foregroundColor(.secondary)
|
||||
} else if let result = uploadResult {
|
||||
VStack(spacing: 8) {
|
||||
Image(systemName: "checkmark.circle.fill")
|
||||
.font(.system(size: 32))
|
||||
.foregroundColor(.green)
|
||||
|
||||
Text("Uploaded \(result.filename)")
|
||||
.font(.headline)
|
||||
|
||||
Text("URL copied to clipboard")
|
||||
.font(.caption)
|
||||
.foregroundColor(.secondary)
|
||||
|
||||
Text(result.url.absoluteString)
|
||||
.font(.caption2)
|
||||
.foregroundColor(.blue)
|
||||
.lineLimit(1)
|
||||
.truncationMode(.middle)
|
||||
.padding(.horizontal)
|
||||
}
|
||||
} else if let error = error {
|
||||
VStack(spacing: 8) {
|
||||
Image(systemName: "xmark.circle.fill")
|
||||
.font(.system(size: 32))
|
||||
.foregroundColor(.red)
|
||||
|
||||
Text("Upload failed")
|
||||
.font(.headline)
|
||||
|
||||
Text(error)
|
||||
.font(.caption)
|
||||
.foregroundColor(.secondary)
|
||||
}
|
||||
}
|
||||
}
|
||||
.frame(width: 300, height: 250)
|
||||
.background(
|
||||
RoundedRectangle(cornerRadius: 12)
|
||||
.fill(isDragging ? Color.blue.opacity(0.1) : Color.secondary.opacity(0.1))
|
||||
.overlay(
|
||||
RoundedRectangle(cornerRadius: 12)
|
||||
.strokeBorder(isDragging ? Color.blue : Color.secondary.opacity(0.3), style: StrokeStyle(lineWidth: 2, dash: [8]))
|
||||
)
|
||||
)
|
||||
.onDrop(of: [.fileURL], isTargeted: $isDragging) { providers in
|
||||
guard let provider = providers.first else { return false }
|
||||
|
||||
_ = provider.loadObject(ofClass: URL.self) { url, error in
|
||||
guard let url = url else { return }
|
||||
|
||||
Task {
|
||||
await MainActor.run {
|
||||
isUploading = true
|
||||
uploadProgress = 0
|
||||
self.error = nil
|
||||
self.uploadResult = nil
|
||||
}
|
||||
|
||||
do {
|
||||
let result = try await uploadFileResolvingDuplicates(url, onProgress: { progress in
|
||||
Task { @MainActor in
|
||||
uploadProgress = min(max(progress, 0), 1)
|
||||
}
|
||||
})
|
||||
await MainActor.run {
|
||||
uploadResult = result
|
||||
isUploading = false
|
||||
uploadProgress = 0
|
||||
}
|
||||
} catch {
|
||||
await MainActor.run {
|
||||
self.error = error.localizedDescription
|
||||
isUploading = false
|
||||
uploadProgress = 0
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
private func uploadFileResolvingDuplicates(
|
||||
_ url: URL,
|
||||
onProgress: UploadProgressHandler?
|
||||
) async throws -> UploadResult {
|
||||
var duplicateAction: UploadDuplicateAction?
|
||||
|
||||
while true {
|
||||
do {
|
||||
return try await uploadManager.uploadFile(
|
||||
url,
|
||||
duplicateAction: duplicateAction,
|
||||
onProgress: onProgress
|
||||
)
|
||||
} catch let error as APIError where error.isUploadConflict {
|
||||
guard let action = promptForUploadDuplicate(error, filename: url.lastPathComponent) else {
|
||||
throw UploadCancelledError()
|
||||
}
|
||||
duplicateAction = action
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#Preview {
|
||||
// This preview won't work without a real UploadManager
|
||||
Text("DropZoneView Preview")
|
||||
}
|
||||
@@ -0,0 +1,319 @@
|
||||
import SwiftUI
|
||||
import Combine
|
||||
|
||||
public struct ClipboardNotice: Identifiable, Equatable {
|
||||
public let id = UUID()
|
||||
public let filename: String
|
||||
}
|
||||
|
||||
@MainActor
|
||||
public class MenuBarController: NSObject, ObservableObject, NSWindowDelegate {
|
||||
@Published public var config: SyncConfig
|
||||
@Published public var isSyncing = false
|
||||
@Published public var lastSyncTime: Date?
|
||||
@Published public var syncStatus: String = "Ready"
|
||||
@Published public var pendingChanges = 0
|
||||
@Published public var uploadProgress: Double? = nil
|
||||
@Published public private(set) var recentUploads: [AttachmentRecord] = []
|
||||
@Published public private(set) var isLoadingRecentUploads = false
|
||||
@Published public private(set) var recentUploadsError: String?
|
||||
@Published public private(set) var clipboardNotice: ClipboardNotice?
|
||||
|
||||
public var cancellables = Set<AnyCancellable>()
|
||||
|
||||
private let configStore = ConfigStore.shared
|
||||
private var apiClient: APIClient
|
||||
private var syncEngine: SyncEngine?
|
||||
private var uploadManager: UploadManager
|
||||
private var folderWatcher: FolderWatcher?
|
||||
private var syncTimer: Timer?
|
||||
|
||||
private var settingsWindow: NSWindow?
|
||||
private var uploadsWindow: NSWindow?
|
||||
private var dropZoneWindow: NSWindow?
|
||||
|
||||
public override init() {
|
||||
let config = ConfigStore.shared.load()
|
||||
self.config = config
|
||||
self.apiClient = APIClient(baseURL: config.serverBaseURL ?? URL(string: "http://localhost:8080")!, apiToken: config.apiToken)
|
||||
self.uploadManager = UploadManager(client: apiClient, config: config)
|
||||
self.syncEngine = SyncEngine(client: apiClient, config: config)
|
||||
super.init()
|
||||
|
||||
Task {
|
||||
await setupSync()
|
||||
}
|
||||
Task {
|
||||
await refreshRecentUploads()
|
||||
}
|
||||
}
|
||||
|
||||
public func updateConfig(_ newConfig: SyncConfig) {
|
||||
config = newConfig
|
||||
|
||||
Task {
|
||||
await configStore.save(newConfig)
|
||||
|
||||
if let url = newConfig.serverBaseURL {
|
||||
await apiClient.setAPIToken(newConfig.apiToken)
|
||||
apiClient = APIClient(baseURL: url, apiToken: newConfig.apiToken)
|
||||
uploadManager = UploadManager(client: apiClient, config: newConfig)
|
||||
syncEngine = SyncEngine(client: apiClient, config: newConfig)
|
||||
await refreshRecentUploads()
|
||||
await setupSync()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func setupSync() async {
|
||||
// Stop existing watcher
|
||||
folderWatcher?.stop()
|
||||
syncTimer?.invalidate()
|
||||
|
||||
guard config.autoSync, let syncFolder = config.syncFolder else {
|
||||
syncStatus = "Auto-sync disabled"
|
||||
return
|
||||
}
|
||||
|
||||
// Setup folder watcher
|
||||
let watcher = FolderWatcher(folderPath: syncFolder)
|
||||
watcher.delegate = self
|
||||
watcher.start()
|
||||
self.folderWatcher = watcher
|
||||
|
||||
// Setup periodic sync
|
||||
syncTimer = Timer.scheduledTimer(withTimeInterval: TimeInterval(config.syncInterval), repeats: true) { [weak self] _ in
|
||||
Task { [weak self] in
|
||||
await self?.performSync()
|
||||
}
|
||||
}
|
||||
|
||||
// Initial sync
|
||||
await performSync()
|
||||
}
|
||||
|
||||
public func performSync() async {
|
||||
guard !isSyncing else { return }
|
||||
guard config.syncFolder != nil else {
|
||||
syncStatus = "No sync folder configured"
|
||||
return
|
||||
}
|
||||
|
||||
isSyncing = true
|
||||
syncStatus = "Syncing..."
|
||||
|
||||
do {
|
||||
let engine = syncEngine ?? SyncEngine(client: apiClient, config: config)
|
||||
syncEngine = engine
|
||||
try await engine.performSync()
|
||||
|
||||
lastSyncTime = Date()
|
||||
syncStatus = "Synced"
|
||||
} catch {
|
||||
syncStatus = "Sync failed: \(error.localizedDescription)"
|
||||
}
|
||||
|
||||
isSyncing = false
|
||||
}
|
||||
|
||||
// MARK: - Window Management
|
||||
|
||||
public func showSettings() {
|
||||
if let window = settingsWindow {
|
||||
present(window)
|
||||
return
|
||||
}
|
||||
|
||||
let window = NSWindow(
|
||||
contentRect: NSRect(x: 0, y: 0, width: 520, height: 640),
|
||||
styleMask: [.titled, .closable],
|
||||
backing: .buffered,
|
||||
defer: false
|
||||
)
|
||||
window.title = "Cairnquire Settings"
|
||||
window.titlebarAppearsTransparent = true
|
||||
window.isReleasedWhenClosed = false
|
||||
window.delegate = self
|
||||
window.contentView = NSHostingView(rootView: SettingsView(config: config, onSave: { [weak self, weak window] newConfig in
|
||||
self?.updateConfig(newConfig)
|
||||
window?.close()
|
||||
}))
|
||||
window.center()
|
||||
|
||||
settingsWindow = window
|
||||
present(window)
|
||||
}
|
||||
|
||||
public func showUploads() {
|
||||
if let window = uploadsWindow {
|
||||
present(window)
|
||||
return
|
||||
}
|
||||
|
||||
let window = NSWindow(
|
||||
contentRect: NSRect(x: 0, y: 0, width: 400, height: 500),
|
||||
styleMask: [.titled, .closable, .miniaturizable, .resizable],
|
||||
backing: .buffered,
|
||||
defer: false
|
||||
)
|
||||
window.title = "Uploaded Files"
|
||||
window.isReleasedWhenClosed = false
|
||||
window.delegate = self
|
||||
window.contentView = NSHostingView(rootView: UploadsListView(client: apiClient))
|
||||
window.center()
|
||||
|
||||
uploadsWindow = window
|
||||
present(window)
|
||||
}
|
||||
|
||||
public func showDropZone() {
|
||||
if let window = dropZoneWindow {
|
||||
present(window)
|
||||
return
|
||||
}
|
||||
|
||||
let window = NSWindow(
|
||||
contentRect: NSRect(x: 0, y: 0, width: 300, height: 250),
|
||||
styleMask: [.titled, .closable],
|
||||
backing: .buffered,
|
||||
defer: false
|
||||
)
|
||||
window.title = "Upload File"
|
||||
window.isReleasedWhenClosed = false
|
||||
window.delegate = self
|
||||
window.contentView = NSHostingView(rootView: DropZoneView(uploadManager: uploadManager))
|
||||
window.center()
|
||||
|
||||
dropZoneWindow = window
|
||||
present(window)
|
||||
}
|
||||
|
||||
public func windowWillClose(_ notification: Notification) {
|
||||
guard let window = notification.object as? NSWindow else { return }
|
||||
|
||||
if window === settingsWindow {
|
||||
settingsWindow = nil
|
||||
} else if window === uploadsWindow {
|
||||
uploadsWindow = nil
|
||||
} else if window === dropZoneWindow {
|
||||
dropZoneWindow = nil
|
||||
}
|
||||
}
|
||||
|
||||
private func present(_ window: NSWindow) {
|
||||
NSApp.activate(ignoringOtherApps: true)
|
||||
window.makeKeyAndOrderFront(nil)
|
||||
}
|
||||
|
||||
public func quickUpload() {
|
||||
let panel = NSOpenPanel()
|
||||
panel.canChooseFiles = true
|
||||
panel.canChooseDirectories = false
|
||||
panel.allowsMultipleSelection = true
|
||||
|
||||
guard panel.runModal() == .OK else { return }
|
||||
|
||||
Task {
|
||||
await uploadURLList(panel.urls)
|
||||
}
|
||||
}
|
||||
|
||||
public func uploadFiles(_ urls: [URL]) {
|
||||
Task {
|
||||
await uploadURLList(urls)
|
||||
}
|
||||
}
|
||||
|
||||
private func uploadURLList(_ urls: [URL]) async {
|
||||
guard !urls.isEmpty else { return }
|
||||
|
||||
let total = Double(urls.count)
|
||||
for (index, url) in urls.enumerated() {
|
||||
let baseProgress = Double(index) / total
|
||||
let fileWeight = 1 / total
|
||||
uploadProgress = baseProgress
|
||||
syncStatus = "Uploading \(url.lastPathComponent)"
|
||||
|
||||
let onProgress: UploadProgressHandler = { [weak self] fileProgress in
|
||||
let combinedProgress = baseProgress + min(max(fileProgress, 0), 1) * fileWeight
|
||||
Task { @MainActor in
|
||||
self?.uploadProgress = combinedProgress
|
||||
}
|
||||
}
|
||||
|
||||
do {
|
||||
let result = try await uploadFileResolvingDuplicates(url, onProgress: onProgress)
|
||||
uploadProgress = Double(index + 1) / total
|
||||
syncStatus = "Uploaded \(result.filename)"
|
||||
clipboardNotice = ClipboardNotice(filename: result.filename)
|
||||
await refreshRecentUploads()
|
||||
} catch {
|
||||
syncStatus = "Upload failed: \(error.localizedDescription)"
|
||||
}
|
||||
}
|
||||
uploadProgress = nil
|
||||
}
|
||||
|
||||
private func uploadFileResolvingDuplicates(
|
||||
_ url: URL,
|
||||
onProgress: UploadProgressHandler?
|
||||
) async throws -> UploadResult {
|
||||
var duplicateAction: UploadDuplicateAction?
|
||||
|
||||
while true {
|
||||
do {
|
||||
return try await uploadManager.uploadFile(
|
||||
url,
|
||||
duplicateAction: duplicateAction,
|
||||
onProgress: onProgress
|
||||
)
|
||||
} catch let error as APIError where error.isUploadConflict {
|
||||
guard let action = promptForUploadDuplicate(error, filename: url.lastPathComponent) else {
|
||||
throw UploadCancelledError()
|
||||
}
|
||||
duplicateAction = action
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public func refreshRecentUploads() async {
|
||||
guard !isLoadingRecentUploads else { return }
|
||||
|
||||
isLoadingRecentUploads = true
|
||||
defer { isLoadingRecentUploads = false }
|
||||
|
||||
do {
|
||||
recentUploads = Array(try await apiClient.listAttachments().prefix(3))
|
||||
recentUploadsError = nil
|
||||
} catch {
|
||||
recentUploadsError = error.localizedDescription
|
||||
}
|
||||
}
|
||||
|
||||
public func createNoteFromClipboard() {
|
||||
guard let text = NSPasteboard.general.string(forType: .string) else {
|
||||
syncStatus = "Clipboard is empty"
|
||||
return
|
||||
}
|
||||
|
||||
Task {
|
||||
do {
|
||||
let result = try await uploadManager.createNote(from: text)
|
||||
syncStatus = "Created note: \(result.title)"
|
||||
} catch {
|
||||
syncStatus = "Failed to create note: \(error.localizedDescription)"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
extension MenuBarController: FolderWatcherDelegate {
|
||||
public nonisolated func folderWatcher(_ watcher: FolderWatcher, didDetectChanges paths: [String]) {
|
||||
Task { @MainActor in
|
||||
pendingChanges += paths.count
|
||||
syncStatus = "\(pendingChanges) pending changes"
|
||||
await performSync()
|
||||
pendingChanges = 0
|
||||
}
|
||||
}
|
||||
}
|
||||
245
apps/macos-sync/Sources/CairnquireSync/UI/SettingsView.swift
Normal file
245
apps/macos-sync/Sources/CairnquireSync/UI/SettingsView.swift
Normal file
@@ -0,0 +1,245 @@
|
||||
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 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 {
|
||||
ScrollView {
|
||||
VStack(spacing: 0) {
|
||||
serverSection
|
||||
Divider().padding(.vertical, 16)
|
||||
syncSection
|
||||
Divider().padding(.vertical, 16)
|
||||
uploadsSection
|
||||
Divider().padding(.vertical, 16)
|
||||
appearanceSection
|
||||
|
||||
Spacer(minLength: 32)
|
||||
|
||||
saveButton
|
||||
}
|
||||
.padding(24)
|
||||
}
|
||||
.frame(width: 520, height: 600)
|
||||
.onAppear {
|
||||
serverURL = config.serverURL
|
||||
apiToken = config.apiToken ?? ""
|
||||
syncFolder = config.syncFolder ?? ""
|
||||
defaultUploadFolder = config.defaultUploadFolder ?? ""
|
||||
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")
|
||||
.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)
|
||||
}
|
||||
|
||||
HStack {
|
||||
Spacer()
|
||||
.frame(width: labelWidth)
|
||||
Text("Notes and uploads will be saved to this folder")
|
||||
.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,
|
||||
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 }
|
||||
}
|
||||
@@ -0,0 +1,306 @@
|
||||
import SwiftUI
|
||||
|
||||
public struct StatusPopoverView: View {
|
||||
@ObservedObject public var controller: MenuBarController
|
||||
|
||||
public init(controller: MenuBarController) {
|
||||
self.controller = controller
|
||||
}
|
||||
|
||||
public var body: some View {
|
||||
VStack(spacing: 0) {
|
||||
// Header with status
|
||||
headerSection
|
||||
|
||||
Divider()
|
||||
|
||||
// Quick actions
|
||||
actionsSection
|
||||
|
||||
Divider()
|
||||
|
||||
// Recent uploads preview (last 3)
|
||||
recentUploadsSection
|
||||
|
||||
Divider()
|
||||
|
||||
// Footer
|
||||
footerSection
|
||||
}
|
||||
.frame(width: 320)
|
||||
.padding(.vertical, 12)
|
||||
}
|
||||
|
||||
// MARK: - Sections
|
||||
|
||||
private var headerSection: some View {
|
||||
VStack(spacing: 8) {
|
||||
HStack {
|
||||
statusIndicator
|
||||
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
Text(controller.syncStatus)
|
||||
.font(.system(size: 13, weight: .medium))
|
||||
|
||||
if let lastSync = controller.lastSyncTime {
|
||||
Text("Last sync: \(timeAgo(lastSync))")
|
||||
.font(.caption2)
|
||||
.foregroundColor(.secondary)
|
||||
} else {
|
||||
Text("Never synced")
|
||||
.font(.caption2)
|
||||
.foregroundColor(.secondary)
|
||||
}
|
||||
}
|
||||
|
||||
Spacer()
|
||||
|
||||
if controller.isSyncing {
|
||||
ProgressView()
|
||||
.scaleEffect(0.6)
|
||||
}
|
||||
}
|
||||
|
||||
if controller.pendingChanges > 0 {
|
||||
Text("\(controller.pendingChanges) pending changes")
|
||||
.font(.caption)
|
||||
.foregroundColor(.orange)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, 16)
|
||||
}
|
||||
|
||||
private var statusIndicator: some View {
|
||||
Circle()
|
||||
.fill(statusColor)
|
||||
.frame(width: 8, height: 8)
|
||||
.overlay(
|
||||
Circle()
|
||||
.stroke(statusColor.opacity(0.3), lineWidth: 2)
|
||||
.frame(width: 14, height: 14)
|
||||
)
|
||||
}
|
||||
|
||||
private var statusColor: Color {
|
||||
if controller.isSyncing { return .blue }
|
||||
if controller.syncStatus.contains("fail") || controller.syncStatus.contains("error") {
|
||||
return .red
|
||||
}
|
||||
if controller.pendingChanges > 0 { return .orange }
|
||||
return .green
|
||||
}
|
||||
|
||||
private var actionsSection: some View {
|
||||
HStack(spacing: 16) {
|
||||
ActionButton(
|
||||
icon: "arrow.clockwise",
|
||||
label: "Sync",
|
||||
color: .blue
|
||||
) {
|
||||
Task {
|
||||
await controller.performSync()
|
||||
}
|
||||
}
|
||||
|
||||
ActionButton(
|
||||
icon: "arrow.up.doc",
|
||||
label: "Upload",
|
||||
color: .purple
|
||||
) {
|
||||
controller.quickUpload()
|
||||
}
|
||||
|
||||
ActionButton(
|
||||
icon: "doc.badge.plus",
|
||||
label: "Note",
|
||||
color: .green
|
||||
) {
|
||||
controller.createNoteFromClipboard()
|
||||
}
|
||||
|
||||
ActionButton(
|
||||
icon: "square.and.arrow.down",
|
||||
label: "Drop",
|
||||
color: .orange
|
||||
) {
|
||||
controller.showDropZone()
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, 16)
|
||||
.padding(.vertical, 12)
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private var recentUploadsSection: some View {
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
HStack {
|
||||
Text("Recent Uploads")
|
||||
.font(.caption)
|
||||
.fontWeight(.semibold)
|
||||
.foregroundColor(.secondary)
|
||||
|
||||
Spacer()
|
||||
|
||||
Button("View All") {
|
||||
controller.showUploads()
|
||||
}
|
||||
.font(.caption)
|
||||
.buttonStyle(.plain)
|
||||
.foregroundColor(.accentColor)
|
||||
}
|
||||
|
||||
if controller.isLoadingRecentUploads && controller.recentUploads.isEmpty {
|
||||
ProgressView()
|
||||
.scaleEffect(0.6)
|
||||
.frame(maxWidth: .infinity, alignment: .center)
|
||||
.padding(.vertical, 8)
|
||||
} else if controller.recentUploads.isEmpty {
|
||||
Text(controller.recentUploadsError == nil ? "No recent uploads" : "Unable to load recent uploads")
|
||||
.font(.caption2)
|
||||
.foregroundColor(.secondary)
|
||||
.frame(maxWidth: .infinity, alignment: .center)
|
||||
.padding(.vertical, 8)
|
||||
} else {
|
||||
ForEach(controller.recentUploads) { upload in
|
||||
RecentUploadRow(upload: upload, baseURL: controller.config.serverBaseURL)
|
||||
}
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, 16)
|
||||
.padding(.vertical, 8)
|
||||
}
|
||||
|
||||
private var footerSection: some View {
|
||||
HStack(spacing: 12) {
|
||||
Button {
|
||||
controller.showSettings()
|
||||
} label: {
|
||||
HStack(spacing: 4) {
|
||||
Image(systemName: "gear")
|
||||
Text("Settings")
|
||||
}
|
||||
.font(.caption)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.foregroundColor(.secondary)
|
||||
|
||||
Button {
|
||||
NSApp.terminate(nil)
|
||||
} label: {
|
||||
HStack(spacing: 4) {
|
||||
Image(systemName: "power")
|
||||
.font(.caption2)
|
||||
Text("Quit")
|
||||
}
|
||||
.font(.caption)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.foregroundColor(.secondary)
|
||||
|
||||
Spacer()
|
||||
|
||||
if let url = controller.config.serverBaseURL {
|
||||
Button {
|
||||
NSWorkspace.shared.open(url)
|
||||
} label: {
|
||||
HStack(spacing: 4) {
|
||||
Text("Open Server")
|
||||
Image(systemName: "arrow.up.right.square")
|
||||
.font(.caption2)
|
||||
}
|
||||
.font(.caption)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.foregroundColor(.accentColor)
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, 16)
|
||||
.padding(.top, 8)
|
||||
}
|
||||
|
||||
// MARK: - Helpers
|
||||
|
||||
private func timeAgo(_ date: Date) -> String {
|
||||
let formatter = RelativeDateTimeFormatter()
|
||||
formatter.unitsStyle = .abbreviated
|
||||
return formatter.localizedString(for: date, relativeTo: Date())
|
||||
}
|
||||
}
|
||||
|
||||
private struct RecentUploadRow: View {
|
||||
let upload: AttachmentRecord
|
||||
let baseURL: URL?
|
||||
|
||||
var body: some View {
|
||||
Button(action: openUpload) {
|
||||
HStack(spacing: 8) {
|
||||
Image(systemName: upload.kind == "document" ? "doc.text" : "paperclip")
|
||||
.foregroundColor(.accentColor)
|
||||
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
Text(upload.originalName)
|
||||
.font(.caption)
|
||||
.lineLimit(1)
|
||||
Text(timeAgo(upload.createdAt))
|
||||
.font(.caption2)
|
||||
.foregroundColor(.secondary)
|
||||
}
|
||||
|
||||
Spacer()
|
||||
|
||||
Image(systemName: "arrow.up.right.square")
|
||||
.font(.caption2)
|
||||
.foregroundColor(.secondary)
|
||||
}
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
}
|
||||
|
||||
private func openUpload() {
|
||||
guard let baseURL,
|
||||
let url = URL(string: upload.url, relativeTo: baseURL)?.absoluteURL else {
|
||||
return
|
||||
}
|
||||
NSWorkspace.shared.open(url)
|
||||
}
|
||||
|
||||
private func timeAgo(_ date: Date) -> String {
|
||||
let formatter = RelativeDateTimeFormatter()
|
||||
formatter.unitsStyle = .abbreviated
|
||||
return formatter.localizedString(for: date, relativeTo: Date())
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Action Button
|
||||
|
||||
struct ActionButton: View {
|
||||
let icon: String
|
||||
let label: String
|
||||
let color: Color
|
||||
let action: () -> Void
|
||||
|
||||
var body: some View {
|
||||
Button(action: action) {
|
||||
VStack(spacing: 6) {
|
||||
Image(systemName: icon)
|
||||
.font(.system(size: 20, weight: .medium))
|
||||
.foregroundColor(color)
|
||||
.frame(width: 40, height: 40)
|
||||
.background(color.opacity(0.1))
|
||||
.clipShape(RoundedRectangle(cornerRadius: 10))
|
||||
|
||||
Text(label)
|
||||
.font(.caption2)
|
||||
.fontWeight(.medium)
|
||||
.foregroundColor(.primary)
|
||||
}
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
}
|
||||
}
|
||||
|
||||
#Preview {
|
||||
// Preview won't work without a real controller
|
||||
Text("StatusPopoverView Preview")
|
||||
}
|
||||
244
apps/macos-sync/Sources/CairnquireSync/UI/UploadsListView.swift
Normal file
244
apps/macos-sync/Sources/CairnquireSync/UI/UploadsListView.swift
Normal file
@@ -0,0 +1,244 @@
|
||||
import SwiftUI
|
||||
|
||||
public struct UploadsListView: View {
|
||||
@State private var attachments: [AttachmentRecord] = []
|
||||
@State private var isLoading = false
|
||||
@State private var error: String?
|
||||
|
||||
public let client: APIClient
|
||||
|
||||
public init(client: APIClient) {
|
||||
self.client = client
|
||||
}
|
||||
|
||||
public var body: some View {
|
||||
VStack(spacing: 0) {
|
||||
// Header
|
||||
HStack {
|
||||
Text("Uploaded Files")
|
||||
.font(.system(size: 16, weight: .semibold))
|
||||
|
||||
Spacer()
|
||||
|
||||
Button(action: loadAttachments) {
|
||||
Image(systemName: "arrow.clockwise")
|
||||
.font(.system(size: 13))
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.disabled(isLoading)
|
||||
}
|
||||
.padding(.horizontal, 20)
|
||||
.padding(.vertical, 16)
|
||||
|
||||
Divider()
|
||||
|
||||
// Content
|
||||
if isLoading {
|
||||
Spacer()
|
||||
ProgressView()
|
||||
.scaleEffect(0.8)
|
||||
Spacer()
|
||||
} else if let error = error {
|
||||
Spacer()
|
||||
VStack(spacing: 8) {
|
||||
Image(systemName: "exclamationmark.triangle")
|
||||
.font(.system(size: 32))
|
||||
.foregroundColor(.orange)
|
||||
Text("Failed to load uploads")
|
||||
.font(.headline)
|
||||
Text(error)
|
||||
.font(.caption)
|
||||
.foregroundColor(.secondary)
|
||||
.multilineTextAlignment(.center)
|
||||
}
|
||||
.padding()
|
||||
Spacer()
|
||||
} else if attachments.isEmpty {
|
||||
Spacer()
|
||||
VStack(spacing: 12) {
|
||||
Image(systemName: "tray")
|
||||
.font(.system(size: 40))
|
||||
.foregroundColor(.secondary.opacity(0.5))
|
||||
Text("No uploads yet")
|
||||
.font(.system(size: 14, weight: .medium))
|
||||
.foregroundColor(.secondary)
|
||||
Text("Drop files on the menubar icon to upload")
|
||||
.font(.caption)
|
||||
.foregroundColor(.secondary.opacity(0.7))
|
||||
}
|
||||
Spacer()
|
||||
} else {
|
||||
List(attachments) { attachment in
|
||||
AttachmentRow(attachment: attachment, client: client)
|
||||
.listRowSeparator(.visible)
|
||||
.listRowInsets(EdgeInsets(top: 8, leading: 20, bottom: 8, trailing: 20))
|
||||
}
|
||||
.listStyle(.plain)
|
||||
}
|
||||
}
|
||||
.frame(width: 400, height: 500)
|
||||
.onAppear {
|
||||
loadAttachments()
|
||||
}
|
||||
}
|
||||
|
||||
private func loadAttachments() {
|
||||
isLoading = true
|
||||
error = nil
|
||||
|
||||
Task {
|
||||
do {
|
||||
let items = try await client.listAttachments()
|
||||
await MainActor.run {
|
||||
attachments = items
|
||||
isLoading = false
|
||||
}
|
||||
} catch {
|
||||
await MainActor.run {
|
||||
self.error = error.localizedDescription
|
||||
isLoading = false
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct AttachmentRow: View {
|
||||
let attachment: AttachmentRecord
|
||||
let client: APIClient
|
||||
|
||||
@State private var showingCopied = false
|
||||
|
||||
var body: some View {
|
||||
HStack(spacing: 12) {
|
||||
// File icon
|
||||
fileIcon
|
||||
|
||||
// Info
|
||||
VStack(alignment: .leading, spacing: 3) {
|
||||
Text(attachment.originalName)
|
||||
.font(.system(size: 13, weight: .medium))
|
||||
.lineLimit(1)
|
||||
|
||||
HStack(spacing: 8) {
|
||||
Text(attachment.contentType)
|
||||
.font(.caption2)
|
||||
.foregroundColor(.secondary)
|
||||
|
||||
Text("·")
|
||||
.font(.caption2)
|
||||
.foregroundColor(.secondary.opacity(0.5))
|
||||
|
||||
Text(formatBytes(attachment.sizeBytes))
|
||||
.font(.caption2)
|
||||
.foregroundColor(.secondary)
|
||||
|
||||
Text("·")
|
||||
.font(.caption2)
|
||||
.foregroundColor(.secondary.opacity(0.5))
|
||||
|
||||
Text(formatDate(attachment.createdAt))
|
||||
.font(.caption2)
|
||||
.foregroundColor(.secondary)
|
||||
}
|
||||
}
|
||||
|
||||
Spacer()
|
||||
|
||||
// Copy button
|
||||
Button(action: copyURL) {
|
||||
Image(systemName: showingCopied ? "checkmark" : "doc.on.doc")
|
||||
.font(.system(size: 12))
|
||||
.foregroundColor(showingCopied ? .green : .accentColor)
|
||||
.frame(width: 28, height: 28)
|
||||
.background(showingCopied ? Color.green.opacity(0.1) : Color.accentColor.opacity(0.1))
|
||||
.clipShape(Circle())
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
}
|
||||
.contextMenu {
|
||||
Button("Copy URL") {
|
||||
copyURL()
|
||||
}
|
||||
Button("Open in Browser") {
|
||||
if let url = URL(string: attachment.url, relativeTo: client.baseURL)?.absoluteURL {
|
||||
NSWorkspace.shared.open(url)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private var fileIcon: some View {
|
||||
ZStack {
|
||||
RoundedRectangle(cornerRadius: 6)
|
||||
.fill(iconColor.opacity(0.1))
|
||||
.frame(width: 36, height: 36)
|
||||
|
||||
Image(systemName: iconName)
|
||||
.font(.system(size: 16))
|
||||
.foregroundColor(iconColor)
|
||||
}
|
||||
}
|
||||
|
||||
private var iconName: String {
|
||||
switch attachment.contentType {
|
||||
case let type where type.hasPrefix("image/"):
|
||||
return "photo"
|
||||
case let type where type.hasPrefix("video/"):
|
||||
return "film"
|
||||
case let type where type.hasPrefix("audio/"):
|
||||
return "music.note"
|
||||
case "application/pdf":
|
||||
return "doc.text"
|
||||
case let type where type.hasPrefix("text/"):
|
||||
return "doc.plaintext"
|
||||
default:
|
||||
return "doc"
|
||||
}
|
||||
}
|
||||
|
||||
private var iconColor: Color {
|
||||
switch attachment.contentType {
|
||||
case let type where type.hasPrefix("image/"):
|
||||
return .purple
|
||||
case let type where type.hasPrefix("video/"):
|
||||
return .red
|
||||
case let type where type.hasPrefix("audio/"):
|
||||
return .pink
|
||||
case "application/pdf":
|
||||
return .red
|
||||
case let type where type.hasPrefix("text/"):
|
||||
return .blue
|
||||
default:
|
||||
return .gray
|
||||
}
|
||||
}
|
||||
|
||||
private func copyURL() {
|
||||
let url = URL(string: attachment.url, relativeTo: client.baseURL)?.absoluteURL
|
||||
?? client.baseURL.appendingPathComponent("attachments/\(attachment.hash)")
|
||||
NSPasteboard.general.clearContents()
|
||||
NSPasteboard.general.setString(url.absoluteString, forType: .string)
|
||||
|
||||
showingCopied = true
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 2) {
|
||||
showingCopied = false
|
||||
}
|
||||
}
|
||||
|
||||
private func formatBytes(_ bytes: Int) -> String {
|
||||
let formatter = ByteCountFormatter()
|
||||
formatter.countStyle = .file
|
||||
return formatter.string(fromByteCount: Int64(bytes))
|
||||
}
|
||||
|
||||
private func formatDate(_ date: Date) -> String {
|
||||
let formatter = RelativeDateTimeFormatter()
|
||||
formatter.unitsStyle = .abbreviated
|
||||
return formatter.localizedString(for: date, relativeTo: Date())
|
||||
}
|
||||
}
|
||||
|
||||
#Preview {
|
||||
Text("UploadsListView Preview")
|
||||
}
|
||||
Reference in New Issue
Block a user