diff --git a/Sources/DropTwo/Assets.xcassets/AppIcon.appiconset/Contents.json b/Sources/DropTwo/Assets.xcassets/AppIcon.appiconset/Contents.json
new file mode 100644
index 0000000..64dc11e
--- /dev/null
+++ b/Sources/DropTwo/Assets.xcassets/AppIcon.appiconset/Contents.json
@@ -0,0 +1,68 @@
+{
+ "images" : [
+ {
+ "filename" : "icon_16x16.png",
+ "idiom" : "mac",
+ "scale" : "1x",
+ "size" : "16x16"
+ },
+ {
+ "filename" : "icon_16x16@2x.png",
+ "idiom" : "mac",
+ "scale" : "2x",
+ "size" : "16x16"
+ },
+ {
+ "filename" : "icon_32x32.png",
+ "idiom" : "mac",
+ "scale" : "1x",
+ "size" : "32x32"
+ },
+ {
+ "filename" : "icon_32x32@2x.png",
+ "idiom" : "mac",
+ "scale" : "2x",
+ "size" : "32x32"
+ },
+ {
+ "filename" : "icon_128x128.png",
+ "idiom" : "mac",
+ "scale" : "1x",
+ "size" : "128x128"
+ },
+ {
+ "filename" : "icon_128x128@2x.png",
+ "idiom" : "mac",
+ "scale" : "2x",
+ "size" : "128x128"
+ },
+ {
+ "filename" : "icon_256x256.png",
+ "idiom" : "mac",
+ "scale" : "1x",
+ "size" : "256x256"
+ },
+ {
+ "filename" : "icon_256x256@2x.png",
+ "idiom" : "mac",
+ "scale" : "2x",
+ "size" : "256x256"
+ },
+ {
+ "filename" : "icon_512x512.png",
+ "idiom" : "mac",
+ "scale" : "1x",
+ "size" : "512x512"
+ },
+ {
+ "filename" : "icon_512x512@2x.png",
+ "idiom" : "mac",
+ "scale" : "2x",
+ "size" : "512x512"
+ }
+ ],
+ "info" : {
+ "author" : "xcode",
+ "version" : 1
+ }
+}
diff --git a/Sources/DropTwo/Assets.xcassets/AppIcon.appiconset/icon_128x128.png b/Sources/DropTwo/Assets.xcassets/AppIcon.appiconset/icon_128x128.png
new file mode 100644
index 0000000..00031a3
Binary files /dev/null and b/Sources/DropTwo/Assets.xcassets/AppIcon.appiconset/icon_128x128.png differ
diff --git a/Sources/DropTwo/Assets.xcassets/AppIcon.appiconset/icon_128x128@2x.png b/Sources/DropTwo/Assets.xcassets/AppIcon.appiconset/icon_128x128@2x.png
new file mode 100644
index 0000000..19c79b3
Binary files /dev/null and b/Sources/DropTwo/Assets.xcassets/AppIcon.appiconset/icon_128x128@2x.png differ
diff --git a/Sources/DropTwo/Assets.xcassets/AppIcon.appiconset/icon_16x16.png b/Sources/DropTwo/Assets.xcassets/AppIcon.appiconset/icon_16x16.png
new file mode 100644
index 0000000..61095c3
Binary files /dev/null and b/Sources/DropTwo/Assets.xcassets/AppIcon.appiconset/icon_16x16.png differ
diff --git a/Sources/DropTwo/Assets.xcassets/AppIcon.appiconset/icon_16x16@2x.png b/Sources/DropTwo/Assets.xcassets/AppIcon.appiconset/icon_16x16@2x.png
new file mode 100644
index 0000000..a9ae93b
Binary files /dev/null and b/Sources/DropTwo/Assets.xcassets/AppIcon.appiconset/icon_16x16@2x.png differ
diff --git a/Sources/DropTwo/Assets.xcassets/AppIcon.appiconset/icon_256x256.png b/Sources/DropTwo/Assets.xcassets/AppIcon.appiconset/icon_256x256.png
new file mode 100644
index 0000000..19c79b3
Binary files /dev/null and b/Sources/DropTwo/Assets.xcassets/AppIcon.appiconset/icon_256x256.png differ
diff --git a/Sources/DropTwo/Assets.xcassets/AppIcon.appiconset/icon_256x256@2x.png b/Sources/DropTwo/Assets.xcassets/AppIcon.appiconset/icon_256x256@2x.png
new file mode 100644
index 0000000..09c8388
Binary files /dev/null and b/Sources/DropTwo/Assets.xcassets/AppIcon.appiconset/icon_256x256@2x.png differ
diff --git a/Sources/DropTwo/Assets.xcassets/AppIcon.appiconset/icon_32x32.png b/Sources/DropTwo/Assets.xcassets/AppIcon.appiconset/icon_32x32.png
new file mode 100644
index 0000000..a9ae93b
Binary files /dev/null and b/Sources/DropTwo/Assets.xcassets/AppIcon.appiconset/icon_32x32.png differ
diff --git a/Sources/DropTwo/Assets.xcassets/AppIcon.appiconset/icon_32x32@2x.png b/Sources/DropTwo/Assets.xcassets/AppIcon.appiconset/icon_32x32@2x.png
new file mode 100644
index 0000000..f1ba35a
Binary files /dev/null and b/Sources/DropTwo/Assets.xcassets/AppIcon.appiconset/icon_32x32@2x.png differ
diff --git a/Sources/DropTwo/Assets.xcassets/AppIcon.appiconset/icon_512x512.png b/Sources/DropTwo/Assets.xcassets/AppIcon.appiconset/icon_512x512.png
new file mode 100644
index 0000000..09c8388
Binary files /dev/null and b/Sources/DropTwo/Assets.xcassets/AppIcon.appiconset/icon_512x512.png differ
diff --git a/Sources/DropTwo/Assets.xcassets/AppIcon.appiconset/icon_512x512@2x.png b/Sources/DropTwo/Assets.xcassets/AppIcon.appiconset/icon_512x512@2x.png
new file mode 100644
index 0000000..a4f3e05
Binary files /dev/null and b/Sources/DropTwo/Assets.xcassets/AppIcon.appiconset/icon_512x512@2x.png differ
diff --git a/Sources/DropTwo/Assets.xcassets/Contents.json b/Sources/DropTwo/Assets.xcassets/Contents.json
new file mode 100644
index 0000000..73c0059
--- /dev/null
+++ b/Sources/DropTwo/Assets.xcassets/Contents.json
@@ -0,0 +1,6 @@
+{
+ "info" : {
+ "author" : "xcode",
+ "version" : 1
+ }
+}
diff --git a/Sources/DropTwo/DropTwoApp.swift b/Sources/DropTwo/DropTwoApp.swift
new file mode 100644
index 0000000..156fe9c
--- /dev/null
+++ b/Sources/DropTwo/DropTwoApp.swift
@@ -0,0 +1,13 @@
+import SwiftUI
+
+@main
+struct DropTwoApp: App {
+ @StateObject private var settings = AppSettings()
+
+ var body: some Scene {
+ WindowGroup {
+ ContentView()
+ .environmentObject(settings)
+ }
+ }
+}
diff --git a/Sources/DropTwo/Info.plist b/Sources/DropTwo/Info.plist
new file mode 100644
index 0000000..5370cd8
--- /dev/null
+++ b/Sources/DropTwo/Info.plist
@@ -0,0 +1,30 @@
+
+
+
+
+ CFBundleDevelopmentRegion
+ $(DEVELOPMENT_LANGUAGE)
+ CFBundleDisplayName
+ drop.two
+ CFBundleExecutable
+ $(EXECUTABLE_NAME)
+ CFBundleIdentifier
+ $(PRODUCT_BUNDLE_IDENTIFIER)
+ CFBundleInfoDictionaryVersion
+ 6.0
+ CFBundleName
+ drop.two
+ CFBundlePackageType
+ APPL
+ CFBundleShortVersionString
+ 1.0
+ CFBundleVersion
+ 1
+ LSMinimumSystemVersion
+ 13.0
+ LSUIElement
+
+ NSHumanReadableCopyright
+ (c) Samuel Zielke
+
+
diff --git a/Sources/DropTwo/Model/ConversionCoordinator.swift b/Sources/DropTwo/Model/ConversionCoordinator.swift
new file mode 100644
index 0000000..18668c4
--- /dev/null
+++ b/Sources/DropTwo/Model/ConversionCoordinator.swift
@@ -0,0 +1,102 @@
+import DropTwoCore
+import Foundation
+
+/// Treibt die Verarbeitung der Dateiliste über `JobQueue` an und hält den
+/// UI-Status pro Datei nach. Kennt keine Views — nur `@Published`-Zustand,
+/// den SwiftUI beobachtet.
+@MainActor
+final class ConversionCoordinator: ObservableObject {
+ @Published private(set) var items: [DropItem] = []
+ @Published private(set) var isRunning = false
+
+ private var runningTask: Task?
+
+ var overallProgress: Double {
+ guard !items.isEmpty else { return 0 }
+ let sum = items.reduce(0.0) { partial, item in
+ switch item.status {
+ case .waiting:
+ return partial
+ case .running(let progress):
+ return partial + progress
+ case .skipped, .rejected, .succeeded, .failed:
+ return partial + 1
+ }
+ }
+ return sum / Double(items.count)
+ }
+
+ var problemItems: [DropItem] {
+ items.filter { $0.status.needsAttention }
+ }
+
+ /// README: "Dateiauswahl vorsortiert nach zuletzt geändert" — neueste zuerst.
+ func addFiles(_ urls: [URL]) {
+ let existing = Set(items.map(\.sourceURL))
+ let newURLs = urls
+ .filter { !existing.contains($0) }
+ .sorted { modificationDate(for: $0) > modificationDate(for: $1) }
+ items.append(contentsOf: newURLs.map(DropItem.init))
+ }
+
+ func removeAll() {
+ guard !isRunning else { return }
+ items.removeAll()
+ }
+
+ func start(targetFolder: URL) {
+ guard !isRunning, !items.isEmpty else { return }
+ isRunning = true
+
+ for index in items.indices {
+ items[index].status = .waiting
+ }
+ let jobs = items.map { JobQueue.Job(id: $0.id, sourceURL: $0.sourceURL) }
+
+ runningTask = Task {
+ let stream = JobQueue.run(
+ jobs: jobs,
+ outputRootDirectory: targetFolder,
+ progress: { [weak self] job, value in
+ Task { @MainActor in
+ self?.updateProgress(jobID: job.id, value: value)
+ }
+ }
+ )
+ for await result in stream {
+ apply(result)
+ }
+ isRunning = false
+ }
+ }
+
+ private func updateProgress(jobID: UUID, value: Double) {
+ guard let index = items.firstIndex(where: { $0.id == jobID }) else { return }
+ items[index].status = .running(progress: value)
+ }
+
+ private func apply(_ result: JobQueue.JobResult) {
+ guard let index = items.firstIndex(where: { $0.id == result.job.id }) else { return }
+ switch result.outcome {
+ case .skipped(_, let reason):
+ items[index].status = .skipped(reason)
+ case .rejected(_, let reason):
+ items[index].status = .rejected(reason)
+ case .succeeded(let success):
+ items[index].status = .succeeded(croppedDestination: success.croppedDestination)
+ case .failed(_, let error):
+ items[index].status = .failed(message: Self.message(for: error))
+ }
+ }
+
+ private static func message(for error: Error) -> String {
+ if let localizedError = error as? LocalizedError, let description = localizedError.errorDescription {
+ return description
+ }
+ return error.localizedDescription
+ }
+
+ private func modificationDate(for url: URL) -> Date {
+ (try? url.resourceValues(forKeys: [.contentModificationDateKey]).contentModificationDate) ?? .distantPast
+ }
+}
diff --git a/Sources/DropTwo/Model/DropItem.swift b/Sources/DropTwo/Model/DropItem.swift
new file mode 100644
index 0000000..6411294
--- /dev/null
+++ b/Sources/DropTwo/Model/DropItem.swift
@@ -0,0 +1,39 @@
+import DropTwoCore
+import Foundation
+
+/// Ein Element in der Dateiliste der Oberfläche, mit seinem aktuellen
+/// Verarbeitungsstatus. Rein UI-seitig — die eigentliche Logik steckt in
+/// `ConversionPipeline`/`JobQueue` in DropTwoCore.
+struct DropItem: Identifiable {
+ let id: UUID
+ let sourceURL: URL
+ var status: Status
+
+ enum Status {
+ case waiting
+ case running(progress: Double)
+ case skipped(SkipReason)
+ case rejected(RejectionReason)
+ case succeeded(croppedDestination: URL)
+ case failed(message: String)
+
+ /// Fehlerliste statt stiller Abbrüche: alles außer "läuft"/"fertig"
+ /// braucht sichtbare Aufmerksamkeit.
+ var needsAttention: Bool {
+ switch self {
+ case .skipped, .rejected, .failed:
+ return true
+ case .waiting, .running, .succeeded:
+ return false
+ }
+ }
+ }
+
+ init(sourceURL: URL) {
+ self.id = UUID()
+ self.sourceURL = sourceURL
+ self.status = .waiting
+ }
+
+ var fileName: String { sourceURL.lastPathComponent }
+}
diff --git a/Sources/DropTwo/Settings/AppSettings.swift b/Sources/DropTwo/Settings/AppSettings.swift
new file mode 100644
index 0000000..21ea182
--- /dev/null
+++ b/Sources/DropTwo/Settings/AppSettings.swift
@@ -0,0 +1,50 @@
+import Foundation
+
+/// Quell- und Zielordner, persistiert über Security-Scoped Bookmarks statt
+/// roher Pfade — überlebt damit auch einen späteren Wechsel in den App
+/// Sandbox (README: "@AppStorage + Security-Scoped Bookmarks").
+@MainActor
+final class AppSettings: ObservableObject {
+ @Published private(set) var sourceFolder: URL?
+ @Published private(set) var targetFolder: URL?
+
+ private enum DefaultsKey {
+ static let sourceBookmark = "dev.zielke.droptwo.sourceFolderBookmark"
+ static let targetBookmark = "dev.zielke.droptwo.targetFolderBookmark"
+ }
+
+ init() {
+ sourceFolder = Self.resolveBookmark(forKey: DefaultsKey.sourceBookmark)
+ targetFolder = Self.resolveBookmark(forKey: DefaultsKey.targetBookmark)
+ }
+
+ func setSourceFolder(_ url: URL) {
+ sourceFolder = url
+ Self.storeBookmark(for: url, key: DefaultsKey.sourceBookmark)
+ }
+
+ func setTargetFolder(_ url: URL) {
+ targetFolder = url
+ Self.storeBookmark(for: url, key: DefaultsKey.targetBookmark)
+ }
+
+ private static func storeBookmark(for url: URL, key: String) {
+ do {
+ let data = try url.bookmarkData(options: .withSecurityScope, includingResourceValuesForKeys: nil, relativeTo: nil)
+ UserDefaults.standard.set(data, forKey: key)
+ } catch {
+ // Best effort: der Ordner bleibt für die laufende Sitzung trotzdem
+ // gesetzt, nur der Neustart-Fall geht dann verloren.
+ }
+ }
+
+ private static func resolveBookmark(forKey key: String) -> URL? {
+ guard let data = UserDefaults.standard.data(forKey: key) else { return nil }
+ var isStale = false
+ guard let url = try? URL(resolvingBookmarkData: data, options: .withSecurityScope, relativeTo: nil, bookmarkDataIsStale: &isStale) else {
+ return nil
+ }
+ _ = url.startAccessingSecurityScopedResource()
+ return url
+ }
+}
diff --git a/Sources/DropTwo/Support/CoreErrors+LocalizedError.swift b/Sources/DropTwo/Support/CoreErrors+LocalizedError.swift
new file mode 100644
index 0000000..a91d26a
--- /dev/null
+++ b/Sources/DropTwo/Support/CoreErrors+LocalizedError.swift
@@ -0,0 +1,53 @@
+import DropTwoCore
+import Foundation
+
+// DropTwoCore hält seine Enums bewusst textfrei (siehe FileNameParser).
+// Die deutschen Klartexte für die Oberfläche leben hier — das CLI-Target
+// formuliert für dieselben Fälle knapper/technischer in seinen eigenen
+// `print`-Aufrufen.
+
+extension SkipReason: LocalizedError {
+ public var errorDescription: String? {
+ switch self {
+ case .alreadyTargetFormat:
+ return String(localized: "Ist bereits im 4:5-Format.")
+ }
+ }
+}
+
+extension RejectionReason: LocalizedError {
+ public var errorDescription: String? {
+ switch self {
+ case .finderDuplicate:
+ return String(localized: "Sieht nach einem Finder-Duplikat aus (z. B. „Name (1)“) — bitte die richtige Originaldatei wählen.")
+ case .formatSegmentMissing:
+ return String(localized: "Kein 9x16- oder 4x5-Format-Segment im Dateinamen gefunden.")
+ }
+ }
+}
+
+extension VideoInspectorError: LocalizedError {
+ public var errorDescription: String? {
+ switch self {
+ case .noVideoTrack:
+ return String(localized: "Die Datei enthält keine Videospur.")
+ case .assetUnreadable:
+ return String(localized: "Die Datei konnte nicht gelesen werden.")
+ }
+ }
+}
+
+extension CropEngineError: LocalizedError {
+ public var errorDescription: String? {
+ switch self {
+ case .sourceNotNineBySixteen(let actualSize):
+ return String(localized: "Kein 9:16-Seitenverhältnis (\(Int(actualSize.width))×\(Int(actualSize.height))).")
+ case .destinationAlreadyExists:
+ return String(localized: "Die Zieldatei existiert bereits.")
+ case .unsupportedContainer:
+ return String(localized: "Nicht unterstütztes Zielformat — es wird immer MP4 erzeugt.")
+ case .readerSetupFailed, .writerSetupFailed, .processingFailed:
+ return String(localized: "Die Datei konnte nicht verarbeitet werden.")
+ }
+ }
+}
diff --git a/Sources/DropTwo/Views/ContentView.swift b/Sources/DropTwo/Views/ContentView.swift
new file mode 100644
index 0000000..82e278d
--- /dev/null
+++ b/Sources/DropTwo/Views/ContentView.swift
@@ -0,0 +1,48 @@
+import SwiftUI
+
+struct ContentView: View {
+ @EnvironmentObject private var settings: AppSettings
+ @StateObject private var coordinator = ConversionCoordinator()
+ @State private var showSettings = false
+
+ var body: some View {
+ VStack(spacing: 16) {
+ DropZoneView()
+
+ if !coordinator.items.isEmpty {
+ FileListView()
+
+ if !coordinator.problemItems.isEmpty {
+ Text("\(coordinator.problemItems.count) Datei(en) brauchen Aufmerksamkeit.")
+ .font(.footnote)
+ .foregroundStyle(.orange)
+ }
+
+ HStack {
+ ProgressView(value: coordinator.overallProgress)
+ Button("Leeren") { coordinator.removeAll() }
+ .disabled(coordinator.isRunning)
+ }
+ }
+
+ Divider()
+
+ HStack {
+ Button("Einstellungen…") { showSettings = true }
+ Spacer()
+ Button("Start") {
+ guard let target = settings.targetFolder else { return }
+ coordinator.start(targetFolder: target)
+ }
+ .keyboardShortcut(.defaultAction)
+ .disabled(coordinator.items.isEmpty || coordinator.isRunning || settings.targetFolder == nil)
+ }
+ }
+ .padding(20)
+ .frame(minWidth: 520, minHeight: 460)
+ .environmentObject(coordinator)
+ .sheet(isPresented: $showSettings) {
+ SettingsView()
+ }
+ }
+}
diff --git a/Sources/DropTwo/Views/DropZoneView.swift b/Sources/DropTwo/Views/DropZoneView.swift
new file mode 100644
index 0000000..2f7e122
--- /dev/null
+++ b/Sources/DropTwo/Views/DropZoneView.swift
@@ -0,0 +1,56 @@
+import SwiftUI
+import UniformTypeIdentifiers
+
+struct DropZoneView: View {
+ @EnvironmentObject private var settings: AppSettings
+ @EnvironmentObject private var coordinator: ConversionCoordinator
+ @State private var isTargeted = false
+
+ var body: some View {
+ VStack(spacing: 12) {
+ Image(systemName: "arrow.down.doc")
+ .font(.system(size: 36))
+ .foregroundStyle(.secondary)
+ Text("9:16-Dateien hierher ziehen")
+ .font(.headline)
+ Button("Datei(en) wählen…", action: chooseFiles)
+ }
+ .frame(maxWidth: .infinity, minHeight: 140)
+ .background(
+ RoundedRectangle(cornerRadius: 12)
+ .strokeBorder(style: StrokeStyle(lineWidth: 2, dash: [6]))
+ .foregroundStyle(isTargeted ? Color.accentColor : Color.secondary.opacity(0.35))
+ )
+ .onDrop(of: [.fileURL], isTargeted: $isTargeted, perform: handleDrop)
+ }
+
+ private func chooseFiles() {
+ let panel = NSOpenPanel()
+ panel.allowsMultipleSelection = true
+ panel.canChooseDirectories = false
+ panel.canChooseFiles = true
+ panel.allowedContentTypes = [.movie, .quickTimeMovie, .mpeg4Movie]
+ if let sourceFolder = settings.sourceFolder {
+ panel.directoryURL = sourceFolder
+ }
+ guard panel.runModal() == .OK else { return }
+ coordinator.addFiles(panel.urls)
+ }
+
+ private func handleDrop(providers: [NSItemProvider]) -> Bool {
+ guard !providers.isEmpty else { return false }
+ let group = DispatchGroup()
+ var urls: [URL] = []
+ for provider in providers {
+ group.enter()
+ _ = provider.loadObject(ofClass: URL.self) { url, _ in
+ if let url { urls.append(url) }
+ group.leave()
+ }
+ }
+ group.notify(queue: .main) {
+ coordinator.addFiles(urls)
+ }
+ return true
+ }
+}
diff --git a/Sources/DropTwo/Views/FileListView.swift b/Sources/DropTwo/Views/FileListView.swift
new file mode 100644
index 0000000..652ee42
--- /dev/null
+++ b/Sources/DropTwo/Views/FileListView.swift
@@ -0,0 +1,62 @@
+import SwiftUI
+
+struct FileListView: View {
+ @EnvironmentObject private var coordinator: ConversionCoordinator
+
+ var body: some View {
+ List(coordinator.items) { item in
+ HStack {
+ VStack(alignment: .leading, spacing: 2) {
+ Text(item.fileName)
+ .lineLimit(1)
+ .truncationMode(.middle)
+ statusText(for: item.status)
+ .font(.caption)
+ .foregroundStyle(item.status.needsAttention ? Color.orange : .secondary)
+ }
+ Spacer()
+ statusIndicator(for: item.status)
+ }
+ .padding(.vertical, 2)
+ }
+ }
+
+ @ViewBuilder
+ private func statusText(for status: DropItem.Status) -> some View {
+ switch status {
+ case .waiting:
+ Text("Wartet …")
+ case .running(let progress):
+ Text("\(Int(progress * 100)) %")
+ case .skipped(let reason):
+ Text(reason.errorDescription ?? "Übersprungen")
+ case .rejected(let reason):
+ Text(reason.errorDescription ?? "Abgelehnt")
+ case .succeeded(let destination):
+ Text("Fertig — \(destination.lastPathComponent)")
+ case .failed(let message):
+ Text(message)
+ }
+ }
+
+ @ViewBuilder
+ private func statusIndicator(for status: DropItem.Status) -> some View {
+ switch status {
+ case .waiting:
+ Image(systemName: "clock")
+ .foregroundStyle(.secondary)
+ case .running(let progress):
+ ProgressView(value: progress)
+ .frame(width: 60)
+ case .skipped:
+ Image(systemName: "arrow.uturn.forward.circle")
+ .foregroundStyle(.secondary)
+ case .rejected, .failed:
+ Image(systemName: "exclamationmark.triangle.fill")
+ .foregroundStyle(.orange)
+ case .succeeded:
+ Image(systemName: "checkmark.circle.fill")
+ .foregroundStyle(.green)
+ }
+ }
+}
diff --git a/Sources/DropTwo/Views/SettingsView.swift b/Sources/DropTwo/Views/SettingsView.swift
new file mode 100644
index 0000000..905200d
--- /dev/null
+++ b/Sources/DropTwo/Views/SettingsView.swift
@@ -0,0 +1,59 @@
+import SwiftUI
+
+struct SettingsView: View {
+ @EnvironmentObject private var settings: AppSettings
+ @Environment(\.dismiss) private var dismiss
+
+ var body: some View {
+ VStack(alignment: .leading, spacing: 16) {
+ Text("Einstellungen")
+ .font(.title2.bold())
+
+ VStack(alignment: .leading, spacing: 6) {
+ Text("Quellordner").font(.headline)
+ Text("Der Dateiauswahl-Dialog öffnet sich hier.")
+ .font(.caption)
+ .foregroundStyle(.secondary)
+ folderRow(current: settings.sourceFolder) { settings.setSourceFolder($0) }
+ }
+
+ VStack(alignment: .leading, spacing: 6) {
+ Text("Zielordner").font(.headline)
+ Text("Hier entsteht pro Projekt ein Unterordner mit Original und 4:5-Fassung.")
+ .font(.caption)
+ .foregroundStyle(.secondary)
+ folderRow(current: settings.targetFolder) { settings.setTargetFolder($0) }
+ }
+
+ Spacer()
+
+ HStack {
+ Spacer()
+ Button("Fertig") { dismiss() }
+ .keyboardShortcut(.defaultAction)
+ }
+ }
+ .padding(20)
+ .frame(minWidth: 440, minHeight: 280)
+ }
+
+ @ViewBuilder
+ private func folderRow(current: URL?, onChoose: @escaping (URL) -> Void) -> some View {
+ HStack {
+ Text(current?.path ?? "Nicht gesetzt")
+ .lineLimit(1)
+ .truncationMode(.middle)
+ .foregroundStyle(current == nil ? .secondary : .primary)
+ Spacer()
+ Button("Wählen…") {
+ let panel = NSOpenPanel()
+ panel.canChooseDirectories = true
+ panel.canChooseFiles = false
+ panel.allowsMultipleSelection = false
+ if let current { panel.directoryURL = current }
+ guard panel.runModal() == .OK, let url = panel.url else { return }
+ onChoose(url)
+ }
+ }
+ }
+}