qstatus/qStatus/Views/ImageAttachmentArea.swift
Paweł Orzech c27437b33c
Initial implementation of qStatus macOS menubar app
Native macOS app for posting to Mastodon, WordPress (self-hosted), and Micro.blog.
Features: global hotkey (Ctrl+Option+Cmd+T), multiple accounts with selection,
image attachments (up to 4, drag-and-drop), floating panel UI, Keychain storage.
2026-02-27 23:40:51 +01:00

149 lines
6.1 KiB
Swift

import SwiftUI
import UniformTypeIdentifiers
struct ImageAttachmentArea: View {
@Binding var attachedImages: [ImageAttachment]
@State private var showFilePicker = false
@State private var isDropTargeted = false
var body: some View {
VStack(spacing: 8) {
// Thumbnail grid
if !attachedImages.isEmpty {
ScrollView(.horizontal, showsIndicators: false) {
HStack(spacing: 8) {
ForEach(attachedImages) { attachment in
ZStack(alignment: .topTrailing) {
Image(nsImage: attachment.image)
.resizable()
.aspectRatio(contentMode: .fill)
.frame(width: 72, height: 72)
.clipShape(RoundedRectangle(cornerRadius: 6))
Button {
attachedImages.removeAll { $0.id == attachment.id }
} label: {
Image(systemName: "xmark.circle.fill")
.font(.system(size: 16))
.foregroundStyle(.white)
.shadow(color: .black.opacity(0.5), radius: 2)
}
.buttonStyle(.plain)
.offset(x: 4, y: -4)
}
}
}
}
}
// Drop zone
if attachedImages.count < AppState.maxImages {
ZStack {
RoundedRectangle(cornerRadius: 8)
.strokeBorder(
isDropTargeted ? Color.accentColor : Color.secondary.opacity(0.3),
style: StrokeStyle(lineWidth: 1.5, dash: [6])
)
.frame(height: 44)
.background(
(isDropTargeted ? Color.accentColor.opacity(0.08) : Color.clear)
.clipShape(RoundedRectangle(cornerRadius: 8))
)
HStack(spacing: 4) {
Image(systemName: "photo.on.rectangle.angled")
.font(.caption)
Text("Drop images or")
.font(.caption)
Button("browse") { showFilePicker = true }
.font(.caption)
.buttonStyle(.link)
Text("(\(attachedImages.count)/\(AppState.maxImages))")
.font(.caption)
.foregroundStyle(.secondary)
}
}
.onDrop(of: [.image, .fileURL], isTargeted: $isDropTargeted) { providers in
handleDrop(providers)
}
}
}
.fileImporter(
isPresented: $showFilePicker,
allowedContentTypes: [.png, .jpeg, .gif, .webP],
allowsMultipleSelection: true
) { result in
handleFileImport(result)
}
}
private func handleDrop(_ providers: [NSItemProvider]) -> Bool {
let remaining = AppState.maxImages - attachedImages.count
for provider in providers.prefix(remaining) {
if provider.hasItemConformingToTypeIdentifier(UTType.fileURL.identifier) {
provider.loadItem(forTypeIdentifier: UTType.fileURL.identifier) { data, _ in
guard let data = data as? Data,
let url = URL(dataRepresentation: data, relativeTo: nil),
let image = NSImage(contentsOf: url),
let imageData = imageDataFrom(url: url)
else { return }
DispatchQueue.main.async {
if attachedImages.count < AppState.maxImages {
attachedImages.append(
ImageAttachment(
image: image,
data: imageData,
filename: url.lastPathComponent
)
)
}
}
}
} else if provider.hasItemConformingToTypeIdentifier(UTType.image.identifier) {
provider.loadItem(forTypeIdentifier: UTType.image.identifier) { data, _ in
guard let data = data as? Data,
let image = NSImage(data: data)
else { return }
DispatchQueue.main.async {
if attachedImages.count < AppState.maxImages {
attachedImages.append(
ImageAttachment(
image: image,
data: data,
filename: "image.jpg"
)
)
}
}
}
}
}
return true
}
private func handleFileImport(_ result: Result<[URL], Error>) {
guard case .success(let urls) = result else { return }
let remaining = AppState.maxImages - attachedImages.count
for url in urls.prefix(remaining) {
guard url.startAccessingSecurityScopedResource() else { continue }
defer { url.stopAccessingSecurityScopedResource() }
guard let image = NSImage(contentsOf: url),
let data = imageDataFrom(url: url)
else { continue }
attachedImages.append(
ImageAttachment(
image: image,
data: data,
filename: url.lastPathComponent
)
)
}
}
private func imageDataFrom(url: URL) -> Data? {
try? Data(contentsOf: url)
}
}