245 lines
7.9 KiB
Swift
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")
|
|
}
|