Files
cairnquire/apps/macos-sync/Sources/CairnquireSync/UI/UploadsListView.swift
2026-06-01 10:53:28 -04:00

245 lines
7.9 KiB
Swift

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