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") }