sync app filled in
This commit is contained in:
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