437 lines
14 KiB
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).count >= MenuBarController.minimumSearchCharacters {
|
|
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")
|
|
}
|