Files
cairnquire/apps/macos-sync/Sources/CairnquireSync/UI/StatusPopoverView.swift

437 lines
14 KiB
Swift

import SwiftUI
public struct StatusPopoverView: View {
@ObservedObject public var controller: MenuBarController
@FocusState private var searchFocused: Bool
public init(controller: MenuBarController) {
self.controller = controller
}
public var body: some View {
VStack(spacing: 0) {
// Header with status
headerSection
Divider()
searchSection
Divider()
// Quick actions
actionsSection
Divider()
// Recent uploads preview (last 3)
recentUploadsSection
Divider()
// Footer
footerSection
}
.frame(width: 360)
.padding(.vertical, 12)
.onAppear {
Task { @MainActor in
searchFocused = true
}
}
.onMoveCommand { direction in
switch direction {
case .up:
controller.moveSearchSelection(by: -1)
case .down:
controller.moveSearchSelection(by: 1)
default:
break
}
}
}
// 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.createNote()
}
ActionButton(
icon: "square.and.arrow.down",
label: "Drop",
color: .orange
) {
controller.showDropZone()
}
}
.padding(.horizontal, 16)
.padding(.vertical, 12)
}
@ViewBuilder
private var searchSection: some View {
VStack(alignment: .leading, spacing: 8) {
HStack(spacing: 8) {
Image(systemName: "magnifyingglass")
.font(.system(size: 13, weight: .medium))
.foregroundColor(.secondary)
TextField(
"Search documents",
text: Binding(
get: { controller.searchQuery },
set: { controller.updateSearchQuery($0) }
)
)
.textFieldStyle(.plain)
.focused($searchFocused)
.onSubmit {
controller.openSelectedSearchResult()
}
if controller.isSearching {
ProgressView()
.controlSize(.small)
.scaleEffect(0.65)
.frame(width: 14, height: 14)
}
}
.padding(.horizontal, 10)
.padding(.vertical, 8)
.background(Color(NSColor.controlBackgroundColor))
.clipShape(RoundedRectangle(cornerRadius: 8))
if !controller.searchQuery.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
searchResultsList
}
}
.padding(.horizontal, 16)
.padding(.vertical, 10)
}
@ViewBuilder
private var searchResultsList: some View {
if controller.searchResults.isEmpty {
Text(controller.isSearching ? "Searching..." : (controller.searchError == nil ? "No results" : "Search unavailable"))
.font(.caption)
.foregroundColor(.secondary)
.frame(maxWidth: .infinity, alignment: .center)
.padding(.vertical, 8)
} else {
VStack(spacing: 2) {
ForEach(Array(controller.searchResults.enumerated()), id: \.element.id) { index, result in
SearchResultRow(
result: result,
isSelected: index == controller.selectedSearchIndex
) {
controller.selectedSearchIndex = index
controller.openSearchResult(result)
}
.onHover { isHovered in
if isHovered {
controller.selectedSearchIndex = index
}
}
}
}
}
}
@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())
}
}
private struct SearchResultRow: View {
let result: SearchResult
let isSelected: Bool
let action: () -> Void
var body: some View {
Button(action: action) {
HStack(spacing: 8) {
Image(systemName: "doc.text")
.font(.system(size: 14, weight: .medium))
.foregroundColor(isSelected ? .white : .accentColor)
.frame(width: 18)
VStack(alignment: .leading, spacing: 2) {
Text(result.title.isEmpty ? result.path : result.title)
.font(.caption)
.foregroundColor(isSelected ? .white : .primary)
.lineLimit(1)
Text(result.path)
.font(.caption2)
.foregroundColor(isSelected ? .white.opacity(0.82) : .secondary)
.lineLimit(1)
}
Spacer(minLength: 0)
Image(systemName: "return")
.font(.caption2)
.foregroundColor(isSelected ? .white.opacity(0.75) : .secondary)
.opacity(isSelected ? 1 : 0)
}
.padding(.horizontal, 8)
.padding(.vertical, 6)
.frame(height: 44)
.background(isSelected ? Color.accentColor : Color.clear)
.clipShape(RoundedRectangle(cornerRadius: 6))
}
.buttonStyle(.plain)
}
}
// 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")
}