feat(decks): γ-1 bis γ-8 — AI/CSV-Import, Card-Edit, Pull-Update, Marketplace-Publish + Moderation + PDF
Vervollständigt die Cardecky-Web-Parität für Deck- und Card-Workflows. γ-1+γ-2 (AI-Deck-Generierung) - 4-Modi-Picker im DeckEditorView Create-Sheet: Leer/KI/Bild/CSV - POST /api/v1/decks/generate für Text-Prompt + 10/min Rate-Limit-UI - POST /api/v1/decks/from-image mit PhotosPicker + PDF-Importer (max 5 Files, 10 MiB/Bild, 30 MiB/PDF), Multipart-Body in CardsAPI+Generation - Loading-Overlay mit Task-Cancellation, Error-Mapping für 429/413/502 γ-3 (Card-Edit) - CardEditorView mit Mode .create(deckId:) / .edit(card:) - Image-Occlusion + Audio-Front behalten bestehenden Media-Ref, solange User nicht ersetzt — MediaCache lädt Bild nach - Type-Picker im Edit-Modus aus (Server-immutable) - CardEditorPayload + CardEditorMediaFields als Sub-Views γ-4 (Pull-Update + Duplicate + Archive) - POST /marketplace/private/:id/pull-update mit Smart-Merge-Anzeige - POST /decks/:id/duplicate - Archive-Toggle im Edit-Modus, Server filtert Liste serverseitig - DeckSecondaryActions als eigenes Sub-View γ-6 (CSV-Import) - RFC-4180-ish Parser (Quote-Escape, Header-Detect, BOM-strip) - Preview-Liste + sequentielle Card-Inserts mit Live-Progress - Image-Occlusion/Audio-Front werden geskipped (UI flaggt) γ-7 (Marketplace-Publish) + Follow-up (Report + Block + Re-Publish) - MarketplacePublishView mit lazy Author-Setup + Init + Publish 1.0.0 - Re-Publish-Modus: Picker für eigene Marketplace-Decks + Auto-Semver-Bump (Minor +1) - MarketplaceCardConverter (typing → type-in, audio-front → skipped, image-occlusion → skipped — Server hat keinen MP-Media-Re-Upload) - Toolbar-Menü auf PublicDeckView: „Deck melden …" + Author-Blockieren (App-Store-Guideline 5.1.1(v)) - ReportDeckSheet mit Reason-Picker (6 Kategorien) + optional Message - BlockedAuthorsView in Settings mit Swipe-Entblocken γ-8 (PDF-Export) - DeckPrintView mit SFSafariViewController auf cardecky.mana.how/decks/:id/print — iOS Share-Sheet → PDF speichern Side-Fixes (mid-stream) - StudySessionView: Card-Aspect-Ratio springt nicht mehr beim Flip (Bottom-Bar in ZStack fixer Höhe) - RootView: Glass-Pille für „Neues Deck"-Accessory + .guest- und .twoFactorRequired-Cases nachgezogen - DeckListView: Account-Toolbar-Button entfernt (Account-Tab unten ist alleinige Anlaufstelle) Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
8ca7bd3636
commit
73f9081fa1
26 changed files with 3419 additions and 442 deletions
82
Sources/Features/Editor/CSVImportFormSections.swift
Normal file
82
Sources/Features/Editor/CSVImportFormSections.swift
Normal file
|
|
@ -0,0 +1,82 @@
|
|||
import SwiftUI
|
||||
|
||||
/// CSV-Import-Form für den `.csv`-Sub-Modus in `DeckEditorView`. Zeigt
|
||||
/// File-Picker-Button, Deck-Namens-Feld und eine Preview-Liste der
|
||||
/// erkannten Karten.
|
||||
///
|
||||
/// State (Datei-Picker-Bool, geparste Rows, Deck-Name) lebt im Parent —
|
||||
/// dieser View arbeitet nur über `@Binding`.
|
||||
struct CSVImportFormSections: View {
|
||||
@Binding var rows: [CSVRow]
|
||||
@Binding var deckName: String
|
||||
@Binding var showImporter: Bool
|
||||
|
||||
var body: some View {
|
||||
Section {
|
||||
Button {
|
||||
showImporter = true
|
||||
} label: {
|
||||
Label(rows.isEmpty ? "CSV-Datei wählen" : "Andere Datei wählen", systemImage: "doc.text")
|
||||
}
|
||||
} header: {
|
||||
Text("Datei")
|
||||
} footer: {
|
||||
Text("Format pro Zeile: vorne,hinten,typ. Typ-Spalte optional (Default basic).")
|
||||
}
|
||||
|
||||
if !rows.isEmpty {
|
||||
Section("Deck-Name") {
|
||||
TextField("Deck-Name", text: $deckName)
|
||||
.textInputAutocapitalization(.sentences)
|
||||
}
|
||||
|
||||
Section {
|
||||
preview
|
||||
} header: {
|
||||
Text("Vorschau (\(rows.count) Karten)")
|
||||
} footer: {
|
||||
Text("Image-Occlusion und Audio-Cards werden im CSV-Import übersprungen — die brauchen Datei-Uploads.")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private var preview: some View {
|
||||
let visible = rows.prefix(8)
|
||||
ForEach(Array(visible.enumerated()), id: \.offset) { _, row in
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
Text(row.front)
|
||||
.font(.subheadline)
|
||||
.lineLimit(2)
|
||||
.foregroundStyle(CardsTheme.foreground)
|
||||
Text(row.back)
|
||||
.font(.caption)
|
||||
.lineLimit(2)
|
||||
.foregroundStyle(CardsTheme.mutedForeground)
|
||||
if row.type != .basic {
|
||||
Text(typeLabel(row.type))
|
||||
.font(.caption2)
|
||||
.foregroundStyle(CardsTheme.primary)
|
||||
}
|
||||
}
|
||||
.padding(.vertical, 2)
|
||||
}
|
||||
if rows.count > visible.count {
|
||||
Text("… und \(rows.count - visible.count) weitere")
|
||||
.font(.caption)
|
||||
.foregroundStyle(CardsTheme.mutedForeground)
|
||||
}
|
||||
}
|
||||
|
||||
private func typeLabel(_ type: CardType) -> String {
|
||||
switch type {
|
||||
case .basic: "Einfach"
|
||||
case .basicReverse: "Beidseitig"
|
||||
case .cloze: "Lückentext"
|
||||
case .typing: "Eintippen"
|
||||
case .multipleChoice: "Multiple Choice"
|
||||
case .imageOcclusion: "Bild-Verdeckung (übersprungen)"
|
||||
case .audioFront: "Audio (übersprungen)"
|
||||
}
|
||||
}
|
||||
}
|
||||
173
Sources/Features/Editor/CardEditorMediaFields.swift
Normal file
173
Sources/Features/Editor/CardEditorMediaFields.swift
Normal file
|
|
@ -0,0 +1,173 @@
|
|||
import ManaCore
|
||||
import PhotosUI
|
||||
import SwiftUI
|
||||
|
||||
/// Bild + Masken-Editor + Hinweis-Feld + Status für `image-occlusion`-
|
||||
/// Cards. Owned-State: `imagePickerItem` (PhotosPicker-Bridge). Alles
|
||||
/// andere lebt im Parent als `@State` und kommt hier als `@Binding` an.
|
||||
///
|
||||
/// Beim Mount im Edit-Modus wird das bestehende Bild via `MediaCache`
|
||||
/// nachgeladen, damit der User die existierenden Masken sieht.
|
||||
struct ImageOcclusionFields: View {
|
||||
@Binding var image: PlatformImage?
|
||||
@Binding var imageData: Data?
|
||||
@Binding var mimeType: String
|
||||
@Binding var regions: [MaskRegion]
|
||||
@Binding var note: String
|
||||
@Binding var existingImageRef: String?
|
||||
let onLoadError: (String) -> Void
|
||||
|
||||
@Environment(\.mediaCache) private var mediaCache
|
||||
@State private var pickerItem: PhotosPickerItem?
|
||||
|
||||
var body: some View {
|
||||
Section("Bild") {
|
||||
PhotosPicker(selection: $pickerItem, matching: .images) {
|
||||
ImagePickerLabel(hasImage: image != nil)
|
||||
}
|
||||
.onChange(of: pickerItem) { _, newItem in
|
||||
Task { await loadPickedImage(newItem) }
|
||||
}
|
||||
}
|
||||
|
||||
if let image {
|
||||
Section("Masken") {
|
||||
MaskEditorView(image: image, regions: $regions)
|
||||
}
|
||||
}
|
||||
|
||||
Section("Hinweis (optional)") {
|
||||
TextField("z.B. Kurz-Erklärung", text: $note, axis: .vertical)
|
||||
.lineLimit(1 ... 3)
|
||||
}
|
||||
|
||||
Section {
|
||||
statusLabel
|
||||
}
|
||||
.task(id: existingImageRef) {
|
||||
await loadExistingImageIfNeeded()
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private var statusLabel: some View {
|
||||
if image == nil {
|
||||
Label("Erst Bild wählen", systemImage: "info.circle")
|
||||
.font(.caption)
|
||||
.foregroundStyle(CardsTheme.mutedForeground)
|
||||
} else if regions.isEmpty {
|
||||
Label("Mindestens eine Maske nötig", systemImage: "exclamationmark.circle")
|
||||
.font(.caption)
|
||||
.foregroundStyle(CardsTheme.warning)
|
||||
} else {
|
||||
Label(
|
||||
"\(regions.count) Masken → \(regions.count) Reviews",
|
||||
systemImage: "checkmark.circle.fill"
|
||||
)
|
||||
.font(.caption)
|
||||
.foregroundStyle(CardsTheme.success)
|
||||
}
|
||||
}
|
||||
|
||||
private func loadExistingImageIfNeeded() async {
|
||||
guard
|
||||
image == nil,
|
||||
let ref = existingImageRef,
|
||||
let cache = mediaCache
|
||||
else { return }
|
||||
do {
|
||||
let data = try await cache.data(for: ref)
|
||||
if let img = PlatformImage(data: data) {
|
||||
image = img
|
||||
}
|
||||
} catch {
|
||||
onLoadError("Bestehendes Bild konnte nicht geladen werden: \(error.localizedDescription)")
|
||||
}
|
||||
}
|
||||
|
||||
private func loadPickedImage(_ item: PhotosPickerItem?) async {
|
||||
guard let item else { return }
|
||||
do {
|
||||
guard let data = try await item.loadTransferable(type: Data.self) else { return }
|
||||
imageData = data
|
||||
mimeType = inferImageMimeType(from: data)
|
||||
if let img = PlatformImage(data: data) {
|
||||
image = img
|
||||
regions = [] // neue Bildauswahl resetet Masken
|
||||
existingImageRef = nil // bestehender Ref wird ersetzt
|
||||
}
|
||||
} catch {
|
||||
onLoadError("Bild konnte nicht geladen werden: \(error.localizedDescription)")
|
||||
}
|
||||
}
|
||||
|
||||
private func inferImageMimeType(from data: Data) -> String {
|
||||
guard data.count > 4 else { return "image/jpeg" }
|
||||
let bytes = Array(data.prefix(8))
|
||||
if bytes.starts(with: [0xFF, 0xD8, 0xFF]) { return "image/jpeg" }
|
||||
if bytes.starts(with: [0x89, 0x50, 0x4E, 0x47]) { return "image/png" }
|
||||
if bytes.starts(with: [0x47, 0x49, 0x46, 0x38]) { return "image/gif" }
|
||||
if bytes.count >= 4, bytes[0 ... 3] == [0x52, 0x49, 0x46, 0x46] { return "image/webp" }
|
||||
return "image/jpeg"
|
||||
}
|
||||
}
|
||||
|
||||
/// Datei-Picker + Antwort-Feld für `audio-front`-Cards. Owned-State:
|
||||
/// `showAudioPicker`. URL und Antwort kommen als `@Binding` aus dem
|
||||
/// Parent.
|
||||
struct AudioFrontFields: View {
|
||||
@Binding var audioFileURL: URL?
|
||||
@Binding var back: String
|
||||
let existingAudioRef: String?
|
||||
|
||||
@State private var showPicker = false
|
||||
|
||||
var body: some View {
|
||||
Section("Audio-Datei") {
|
||||
Button {
|
||||
showPicker = true
|
||||
} label: {
|
||||
pickerLabel
|
||||
}
|
||||
.fileImporter(
|
||||
isPresented: $showPicker,
|
||||
allowedContentTypes: [.audio, .mp3, .wav, .mpeg4Audio],
|
||||
allowsMultipleSelection: false
|
||||
) { result in
|
||||
if case let .success(urls) = result, let first = urls.first {
|
||||
audioFileURL = first
|
||||
}
|
||||
}
|
||||
}
|
||||
Section("Antwort") {
|
||||
TextField("Was zu hören ist", text: $back, axis: .vertical)
|
||||
.lineLimit(2 ... 4)
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private var pickerLabel: some View {
|
||||
if let audioFileURL {
|
||||
Label(audioFileURL.lastPathComponent, systemImage: "waveform")
|
||||
} else if existingAudioRef != nil {
|
||||
Label("Audio ersetzen", systemImage: "waveform.badge.plus")
|
||||
} else {
|
||||
Label("Audio auswählen", systemImage: "waveform.badge.plus")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// PhotosPicker-Label als eigene View, damit Swift-6-Strict-Concurrency
|
||||
/// nicht über den `@Sendable`-Closure meckert (View-Konstruktor-Calls
|
||||
/// werden zur Build-Zeit MainActor-isoliert evaluiert).
|
||||
struct ImagePickerLabel: View {
|
||||
let hasImage: Bool
|
||||
|
||||
var body: some View {
|
||||
if hasImage {
|
||||
Label("Bild ersetzen", systemImage: "arrow.triangle.2.circlepath")
|
||||
} else {
|
||||
Label("Bild auswählen", systemImage: "photo")
|
||||
}
|
||||
}
|
||||
}
|
||||
149
Sources/Features/Editor/CardEditorPayload.swift
Normal file
149
Sources/Features/Editor/CardEditorPayload.swift
Normal file
|
|
@ -0,0 +1,149 @@
|
|||
import Foundation
|
||||
import ManaCore
|
||||
|
||||
/// Resultat von `CardEditorPayload.build` — was an `CardsAPI.createCard`
|
||||
/// oder `updateCard` durchgereicht wird.
|
||||
struct CardEditorPayload {
|
||||
let fields: [String: String]
|
||||
let mediaRefs: [String]?
|
||||
}
|
||||
|
||||
/// Snapshot der CardEditor-Felder zum Submit-Zeitpunkt. Ein Wert-Typ,
|
||||
/// damit `buildPayload` außerhalb der View testbar ist und der View-
|
||||
/// Struct kompakt bleibt.
|
||||
struct CardEditorPayloadInputs {
|
||||
let type: CardType
|
||||
let front: String
|
||||
let back: String
|
||||
let clozeText: String
|
||||
let typingAnswer: String
|
||||
let multipleChoiceAnswer: String
|
||||
let occlusionImageData: Data?
|
||||
let occlusionMimeType: String
|
||||
let occlusionRegions: [MaskRegion]
|
||||
let occlusionNote: String
|
||||
let existingImageRef: String?
|
||||
let audioFileURL: URL?
|
||||
let existingAudioRef: String?
|
||||
let existingMediaRefs: [String]
|
||||
}
|
||||
|
||||
enum CardEditorPayloadError: LocalizedError {
|
||||
case missingImage
|
||||
case missingAudio
|
||||
|
||||
var errorDescription: String? {
|
||||
switch self {
|
||||
case .missingImage: "Bitte ein Bild wählen."
|
||||
case .missingAudio: "Bitte eine Audio-Datei wählen."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
enum CardEditorPayloadBuilder {
|
||||
/// Baut den Payload für `POST /cards` bzw. `PATCH /cards/:id`.
|
||||
/// Lädt für Image-Occlusion / Audio-Front bei Bedarf neue Media
|
||||
/// hoch; sonst wird der bestehende `*_ref` aus der Card weiterverwendet.
|
||||
static func build(inputs: CardEditorPayloadInputs, api: CardsAPI) async throws -> CardEditorPayload {
|
||||
switch inputs.type {
|
||||
case .basic, .basicReverse:
|
||||
CardEditorPayload(
|
||||
fields: CardFieldsBuilder.basic(front: inputs.front, back: inputs.back),
|
||||
mediaRefs: nil
|
||||
)
|
||||
case .cloze:
|
||||
CardEditorPayload(
|
||||
fields: CardFieldsBuilder.cloze(text: inputs.clozeText),
|
||||
mediaRefs: nil
|
||||
)
|
||||
case .typing:
|
||||
CardEditorPayload(
|
||||
fields: CardFieldsBuilder.typing(front: inputs.front, answer: inputs.typingAnswer),
|
||||
mediaRefs: nil
|
||||
)
|
||||
case .multipleChoice:
|
||||
CardEditorPayload(
|
||||
fields: CardFieldsBuilder.multipleChoice(
|
||||
front: inputs.front,
|
||||
answer: inputs.multipleChoiceAnswer
|
||||
),
|
||||
mediaRefs: nil
|
||||
)
|
||||
case .imageOcclusion:
|
||||
try await buildImageOcclusionPayload(inputs: inputs, api: api)
|
||||
case .audioFront:
|
||||
try await buildAudioFrontPayload(inputs: inputs, api: api)
|
||||
}
|
||||
}
|
||||
|
||||
private static func buildImageOcclusionPayload(
|
||||
inputs: CardEditorPayloadInputs,
|
||||
api: CardsAPI
|
||||
) async throws -> CardEditorPayload {
|
||||
let imageRef: String
|
||||
var refs = inputs.existingMediaRefs
|
||||
|
||||
if let newData = inputs.occlusionImageData {
|
||||
let media = try await api.uploadMedia(
|
||||
data: newData,
|
||||
filename: "occlusion.\(inputs.occlusionMimeType.contains("png") ? "png" : "jpg")",
|
||||
mimeType: inputs.occlusionMimeType
|
||||
)
|
||||
imageRef = media.id
|
||||
refs = [media.id]
|
||||
} else if let ref = inputs.existingImageRef {
|
||||
imageRef = ref
|
||||
} else {
|
||||
throw CardEditorPayloadError.missingImage
|
||||
}
|
||||
|
||||
return CardEditorPayload(
|
||||
fields: CardFieldsBuilder.imageOcclusion(
|
||||
imageRef: imageRef,
|
||||
regions: inputs.occlusionRegions,
|
||||
note: inputs.occlusionNote.isEmpty ? nil : inputs.occlusionNote
|
||||
),
|
||||
mediaRefs: refs
|
||||
)
|
||||
}
|
||||
|
||||
private static func buildAudioFrontPayload(
|
||||
inputs: CardEditorPayloadInputs,
|
||||
api: CardsAPI
|
||||
) async throws -> CardEditorPayload {
|
||||
let audioRef: String
|
||||
var refs = inputs.existingMediaRefs
|
||||
|
||||
if let url = inputs.audioFileURL {
|
||||
let didStart = url.startAccessingSecurityScopedResource()
|
||||
defer { if didStart { url.stopAccessingSecurityScopedResource() } }
|
||||
let data = try Data(contentsOf: url)
|
||||
let media = try await api.uploadMedia(
|
||||
data: data,
|
||||
filename: url.lastPathComponent,
|
||||
mimeType: audioMimeType(for: url)
|
||||
)
|
||||
audioRef = media.id
|
||||
refs = [media.id]
|
||||
} else if let ref = inputs.existingAudioRef {
|
||||
audioRef = ref
|
||||
} else {
|
||||
throw CardEditorPayloadError.missingAudio
|
||||
}
|
||||
|
||||
return CardEditorPayload(
|
||||
fields: CardFieldsBuilder.audioFront(audioRef: audioRef, back: inputs.back),
|
||||
mediaRefs: refs
|
||||
)
|
||||
}
|
||||
|
||||
private static func audioMimeType(for url: URL) -> String {
|
||||
switch url.pathExtension.lowercased() {
|
||||
case "mp3": "audio/mpeg"
|
||||
case "wav": "audio/wav"
|
||||
case "m4a", "mp4": "audio/mp4"
|
||||
case "ogg", "oga": "audio/ogg"
|
||||
default: "audio/mpeg"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1,55 +1,126 @@
|
|||
import ManaCore
|
||||
import PhotosUI
|
||||
import SwiftUI
|
||||
|
||||
#if canImport(UIKit)
|
||||
import UIKit
|
||||
import UIKit
|
||||
#endif
|
||||
|
||||
/// Card-Create-View. Type-Picker oben, type-spezifische Felder unten.
|
||||
/// Deckt alle 7 Card-Types ab.
|
||||
// swiftlint:disable type_body_length
|
||||
|
||||
/// Card-Create und Card-Edit in einer View.
|
||||
///
|
||||
/// - `.create(deckId:)` zeigt Type-Picker + leere Felder.
|
||||
/// - `.edit(card:)` blendet Type-Picker aus (Server-seitig immutable),
|
||||
/// pre-fillt alle Felder, und PATCHt auf Submit.
|
||||
///
|
||||
/// Bei Image-Occlusion und Audio-Front im Edit-Modus bleibt der bestehende
|
||||
/// Media-Ref erhalten, solange der User die Datei nicht explizit ersetzt.
|
||||
struct CardEditorView: View {
|
||||
let deckId: String
|
||||
let onCreated: (Card) -> Void
|
||||
enum Mode {
|
||||
case create(deckId: String)
|
||||
case edit(card: Card)
|
||||
}
|
||||
|
||||
let mode: Mode
|
||||
let onSaved: (Card) -> Void
|
||||
|
||||
@Environment(AuthClient.self) private var auth
|
||||
@Environment(\.dismiss) private var dismiss
|
||||
|
||||
@State private var type: CardType = .basic
|
||||
@State private var front: String = ""
|
||||
@State private var back: String = ""
|
||||
@State private var clozeText: String = ""
|
||||
@State private var typingAnswer: String = ""
|
||||
@State private var multipleChoiceAnswer: String = ""
|
||||
@State private var type: CardType
|
||||
@State private var front: String
|
||||
@State private var back: String
|
||||
@State private var clozeText: String
|
||||
@State private var typingAnswer: String
|
||||
@State private var multipleChoiceAnswer: String
|
||||
@State private var isSubmitting = false
|
||||
@State private var errorMessage: String?
|
||||
|
||||
// Image-Occlusion-State
|
||||
@State private var imagePickerItem: PhotosPickerItem?
|
||||
@State private var occlusionImage: PlatformImage?
|
||||
@State private var occlusionImageData: Data?
|
||||
@State private var occlusionMimeType: String = "image/jpeg"
|
||||
@State private var occlusionRegions: [MaskRegion] = []
|
||||
@State private var occlusionNote: String = ""
|
||||
@State private var occlusionRegions: [MaskRegion]
|
||||
@State private var occlusionNote: String
|
||||
/// Bestehender `image_ref` aus der Card im Edit-Modus. Bleibt erhalten,
|
||||
/// solange der User kein neues Bild wählt.
|
||||
@State private var existingImageRef: String?
|
||||
|
||||
// Audio-Front-State
|
||||
/// Audio-Front-State
|
||||
@State private var audioFileURL: URL?
|
||||
@State private var showAudioPicker = false
|
||||
/// Bestehender `audio_ref` aus der Card im Edit-Modus.
|
||||
@State private var existingAudioRef: String?
|
||||
|
||||
private static let supportedTypes: [CardType] = [
|
||||
.basic, .basicReverse, .cloze, .typing, .multipleChoice,
|
||||
.imageOcclusion, .audioFront,
|
||||
.imageOcclusion, .audioFront
|
||||
]
|
||||
|
||||
init(mode: Mode, onSaved: @escaping (Card) -> Void) {
|
||||
self.mode = mode
|
||||
self.onSaved = onSaved
|
||||
|
||||
let initialType: CardType
|
||||
var initialFront = ""
|
||||
var initialBack = ""
|
||||
var initialCloze = ""
|
||||
var initialTyping = ""
|
||||
var initialMC = ""
|
||||
var initialRegions: [MaskRegion] = []
|
||||
var initialNote = ""
|
||||
var initialImageRef: String?
|
||||
var initialAudioRef: String?
|
||||
|
||||
switch mode {
|
||||
case .create:
|
||||
initialType = .basic
|
||||
case let .edit(card):
|
||||
initialType = card.type
|
||||
switch card.type {
|
||||
case .basic, .basicReverse:
|
||||
initialFront = card.fields["front"] ?? ""
|
||||
initialBack = card.fields["back"] ?? ""
|
||||
case .cloze:
|
||||
initialCloze = card.fields["text"] ?? ""
|
||||
case .typing:
|
||||
initialFront = card.fields["front"] ?? ""
|
||||
initialTyping = card.fields["answer"] ?? ""
|
||||
case .multipleChoice:
|
||||
initialFront = card.fields["front"] ?? ""
|
||||
initialMC = card.fields["answer"] ?? ""
|
||||
case .imageOcclusion:
|
||||
initialRegions = MaskRegions.parse(card.fields["mask_regions"] ?? "[]")
|
||||
initialNote = card.fields["note"] ?? ""
|
||||
initialImageRef = card.fields["image_ref"]
|
||||
case .audioFront:
|
||||
initialBack = card.fields["back"] ?? ""
|
||||
initialAudioRef = card.fields["audio_ref"]
|
||||
}
|
||||
}
|
||||
|
||||
_type = State(initialValue: initialType)
|
||||
_front = State(initialValue: initialFront)
|
||||
_back = State(initialValue: initialBack)
|
||||
_clozeText = State(initialValue: initialCloze)
|
||||
_typingAnswer = State(initialValue: initialTyping)
|
||||
_multipleChoiceAnswer = State(initialValue: initialMC)
|
||||
_occlusionRegions = State(initialValue: initialRegions)
|
||||
_occlusionNote = State(initialValue: initialNote)
|
||||
_existingImageRef = State(initialValue: initialImageRef)
|
||||
_existingAudioRef = State(initialValue: initialAudioRef)
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
Form {
|
||||
Section("Card-Type") {
|
||||
Picker("Typ", selection: $type) {
|
||||
ForEach(Self.supportedTypes, id: \.self) { t in
|
||||
Text(label(for: t)).tag(t)
|
||||
if isCreate {
|
||||
Section("Card-Type") {
|
||||
Picker("Typ", selection: $type) {
|
||||
ForEach(Self.supportedTypes, id: \.self) { cardType in
|
||||
Text(label(for: cardType)).tag(cardType)
|
||||
}
|
||||
}
|
||||
.pickerStyle(.menu)
|
||||
}
|
||||
.pickerStyle(.menu)
|
||||
}
|
||||
|
||||
typeFields
|
||||
|
|
@ -62,7 +133,8 @@ struct CardEditorView: View {
|
|||
}
|
||||
}
|
||||
}
|
||||
.navigationTitle("Neue Karte")
|
||||
.disabled(isSubmitting)
|
||||
.navigationTitle(isCreate ? "Neue Karte" : "Karte bearbeiten")
|
||||
#if os(iOS)
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
#endif
|
||||
|
|
@ -71,8 +143,10 @@ struct CardEditorView: View {
|
|||
Button("Abbrechen") { dismiss() }
|
||||
}
|
||||
ToolbarItem(placement: .confirmationAction) {
|
||||
Button("Erstellen") { Task { await submit() } }
|
||||
.disabled(!canSubmit || isSubmitting)
|
||||
Button(isCreate ? "Erstellen" : "Speichern") {
|
||||
Task { await submit() }
|
||||
}
|
||||
.disabled(!canSubmit || isSubmitting)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -99,12 +173,15 @@ struct CardEditorView: View {
|
|||
|
||||
case .cloze:
|
||||
Section("Cloze-Text") {
|
||||
TextField("Beispiel: Die Hauptstadt von {{c1::Frankreich}} ist {{c2::Paris}}.",
|
||||
text: $clozeText, axis: .vertical)
|
||||
.lineLimit(3 ... 8)
|
||||
.autocorrectionDisabled()
|
||||
.textInputAutocapitalization(.sentences)
|
||||
.monospaced()
|
||||
TextField(
|
||||
"Beispiel: Die Hauptstadt von {{c1::Frankreich}} ist {{c2::Paris}}.",
|
||||
text: $clozeText,
|
||||
axis: .vertical
|
||||
)
|
||||
.lineLimit(3 ... 8)
|
||||
.autocorrectionDisabled()
|
||||
.textInputAutocapitalization(.sentences)
|
||||
.monospaced()
|
||||
}
|
||||
Section {
|
||||
let count = Cloze.subIndexCount(clozeText)
|
||||
|
|
@ -146,119 +223,40 @@ struct CardEditorView: View {
|
|||
}
|
||||
|
||||
case .imageOcclusion:
|
||||
imageOcclusionFields
|
||||
ImageOcclusionFields(
|
||||
image: $occlusionImage,
|
||||
imageData: $occlusionImageData,
|
||||
mimeType: $occlusionMimeType,
|
||||
regions: $occlusionRegions,
|
||||
note: $occlusionNote,
|
||||
existingImageRef: $existingImageRef,
|
||||
onLoadError: { errorMessage = $0 }
|
||||
)
|
||||
|
||||
case .audioFront:
|
||||
audioFrontFields
|
||||
AudioFrontFields(
|
||||
audioFileURL: $audioFileURL,
|
||||
back: $back,
|
||||
existingAudioRef: existingAudioRef
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private var imageOcclusionFields: some View {
|
||||
Section("Bild") {
|
||||
PhotosPicker(selection: $imagePickerItem, matching: .images) {
|
||||
ImagePickerLabel(hasImage: occlusionImage != nil)
|
||||
}
|
||||
.onChange(of: imagePickerItem) { _, newItem in
|
||||
Task { await loadPickedImage(newItem) }
|
||||
}
|
||||
}
|
||||
private var isCreate: Bool {
|
||||
if case .create = mode { return true }
|
||||
return false
|
||||
}
|
||||
|
||||
if let image = occlusionImage {
|
||||
Section("Masken") {
|
||||
MaskEditorView(image: image, regions: $occlusionRegions)
|
||||
}
|
||||
}
|
||||
|
||||
Section("Hinweis (optional)") {
|
||||
TextField("z.B. Kurz-Erklärung", text: $occlusionNote, axis: .vertical)
|
||||
.lineLimit(1 ... 3)
|
||||
}
|
||||
|
||||
Section {
|
||||
if occlusionImage == nil {
|
||||
Label("Erst Bild wählen", systemImage: "info.circle")
|
||||
.font(.caption)
|
||||
.foregroundStyle(CardsTheme.mutedForeground)
|
||||
} else if occlusionRegions.isEmpty {
|
||||
Label("Mindestens eine Maske nötig", systemImage: "exclamationmark.circle")
|
||||
.font(.caption)
|
||||
.foregroundStyle(CardsTheme.warning)
|
||||
} else {
|
||||
Label("\(occlusionRegions.count) Masken → \(occlusionRegions.count) Reviews",
|
||||
systemImage: "checkmark.circle.fill")
|
||||
.font(.caption)
|
||||
.foregroundStyle(CardsTheme.success)
|
||||
}
|
||||
private var deckId: String {
|
||||
switch mode {
|
||||
case let .create(deckId): deckId
|
||||
case let .edit(card): card.deckId
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private var audioFrontFields: some View {
|
||||
Section("Audio-Datei") {
|
||||
Button {
|
||||
showAudioPicker = true
|
||||
} label: {
|
||||
if let audioFileURL {
|
||||
Label(audioFileURL.lastPathComponent, systemImage: "waveform")
|
||||
} else {
|
||||
Label("Audio auswählen", systemImage: "waveform.badge.plus")
|
||||
}
|
||||
}
|
||||
.fileImporter(
|
||||
isPresented: $showAudioPicker,
|
||||
allowedContentTypes: [.audio, .mp3, .wav, .mpeg4Audio],
|
||||
allowsMultipleSelection: false
|
||||
) { result in
|
||||
if case let .success(urls) = result, let first = urls.first {
|
||||
audioFileURL = first
|
||||
}
|
||||
}
|
||||
}
|
||||
Section("Antwort") {
|
||||
TextField("Was zu hören ist", text: $back, axis: .vertical)
|
||||
.lineLimit(2 ... 4)
|
||||
}
|
||||
}
|
||||
|
||||
private func loadPickedImage(_ item: PhotosPickerItem?) async {
|
||||
guard let item else { return }
|
||||
do {
|
||||
guard let data = try await item.loadTransferable(type: Data.self) else { return }
|
||||
occlusionImageData = data
|
||||
occlusionMimeType = inferMimeType(from: data)
|
||||
if let img = PlatformImage(data: data) {
|
||||
occlusionImage = img
|
||||
occlusionRegions = [] // neue Bildauswahl resetet Masken
|
||||
}
|
||||
} catch {
|
||||
errorMessage = "Bild konnte nicht geladen werden: \(error.localizedDescription)"
|
||||
}
|
||||
}
|
||||
|
||||
private func inferMimeType(from data: Data) -> String {
|
||||
// Schneller Magic-Byte-Check für die häufigsten Formate
|
||||
guard data.count > 4 else { return "image/jpeg" }
|
||||
let bytes = Array(data.prefix(8))
|
||||
if bytes.starts(with: [0xFF, 0xD8, 0xFF]) { return "image/jpeg" }
|
||||
if bytes.starts(with: [0x89, 0x50, 0x4E, 0x47]) { return "image/png" }
|
||||
if bytes.starts(with: [0x47, 0x49, 0x46, 0x38]) { return "image/gif" }
|
||||
// WebP: starts with "RIFF" + 4 bytes size + "WEBP"
|
||||
if bytes.count >= 8,
|
||||
bytes[0 ... 3] == [0x52, 0x49, 0x46, 0x46] {
|
||||
return "image/webp"
|
||||
}
|
||||
return "image/jpeg"
|
||||
}
|
||||
|
||||
private func audioMimeType(for url: URL) -> String {
|
||||
switch url.pathExtension.lowercased() {
|
||||
case "mp3": "audio/mpeg"
|
||||
case "wav": "audio/wav"
|
||||
case "m4a", "mp4": "audio/mp4"
|
||||
case "ogg", "oga": "audio/ogg"
|
||||
default: "audio/mpeg"
|
||||
}
|
||||
private var existingMediaRefs: [String] {
|
||||
if case let .edit(card) = mode { return card.mediaRefs }
|
||||
return []
|
||||
}
|
||||
|
||||
private var canSubmit: Bool {
|
||||
|
|
@ -272,12 +270,14 @@ struct CardEditorView: View {
|
|||
case .multipleChoice:
|
||||
!front.trimmed.isEmpty && !multipleChoiceAnswer.trimmed.isEmpty
|
||||
case .imageOcclusion:
|
||||
occlusionImageData != nil && !occlusionRegions.isEmpty
|
||||
(occlusionImageData != nil || existingImageRef != nil) && !occlusionRegions.isEmpty
|
||||
case .audioFront:
|
||||
audioFileURL != nil && !back.trimmed.isEmpty
|
||||
(audioFileURL != nil || existingAudioRef != nil) && !back.trimmed.isEmpty
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Submit
|
||||
|
||||
private func submit() async {
|
||||
isSubmitting = true
|
||||
errorMessage = nil
|
||||
|
|
@ -285,53 +285,47 @@ struct CardEditorView: View {
|
|||
let api = CardsAPI(auth: auth)
|
||||
|
||||
do {
|
||||
let fields: [String: String]
|
||||
var mediaRefs: [String]? = nil
|
||||
switch type {
|
||||
case .basic, .basicReverse:
|
||||
fields = CardFieldsBuilder.basic(front: front.trimmed, back: back.trimmed)
|
||||
case .cloze:
|
||||
fields = CardFieldsBuilder.cloze(text: clozeText.trimmed)
|
||||
case .typing:
|
||||
fields = CardFieldsBuilder.typing(front: front.trimmed, answer: typingAnswer.trimmed)
|
||||
case .multipleChoice:
|
||||
fields = CardFieldsBuilder.multipleChoice(front: front.trimmed, answer: multipleChoiceAnswer.trimmed)
|
||||
case .imageOcclusion:
|
||||
guard let data = occlusionImageData else { return }
|
||||
let media = try await api.uploadMedia(
|
||||
data: data,
|
||||
filename: "occlusion.\(occlusionMimeType.contains("png") ? "png" : "jpg")",
|
||||
mimeType: occlusionMimeType
|
||||
)
|
||||
fields = CardFieldsBuilder.imageOcclusion(
|
||||
imageRef: media.id,
|
||||
regions: occlusionRegions,
|
||||
note: occlusionNote.trimmed.isEmpty ? nil : occlusionNote.trimmed
|
||||
)
|
||||
mediaRefs = [media.id]
|
||||
case .audioFront:
|
||||
guard let url = audioFileURL else { return }
|
||||
let didStart = url.startAccessingSecurityScopedResource()
|
||||
defer { if didStart { url.stopAccessingSecurityScopedResource() } }
|
||||
let data = try Data(contentsOf: url)
|
||||
let media = try await api.uploadMedia(
|
||||
data: data,
|
||||
filename: url.lastPathComponent,
|
||||
mimeType: audioMimeType(for: url)
|
||||
)
|
||||
fields = CardFieldsBuilder.audioFront(audioRef: media.id, back: back.trimmed)
|
||||
mediaRefs = [media.id]
|
||||
let payload = try await CardEditorPayloadBuilder.build(inputs: payloadInputs, api: api)
|
||||
let card: Card = switch mode {
|
||||
case let .create(deckId):
|
||||
try await api.createCard(CardCreateBody(
|
||||
deckId: deckId,
|
||||
type: type,
|
||||
fields: payload.fields,
|
||||
mediaRefs: payload.mediaRefs
|
||||
))
|
||||
case let .edit(existing):
|
||||
try await api.updateCard(id: existing.id, body: CardUpdateBody(
|
||||
fields: payload.fields,
|
||||
mediaRefs: payload.mediaRefs
|
||||
))
|
||||
}
|
||||
|
||||
let body = CardCreateBody(deckId: deckId, type: type, fields: fields, mediaRefs: mediaRefs)
|
||||
let card = try await api.createCard(body)
|
||||
onCreated(card)
|
||||
onSaved(card)
|
||||
dismiss()
|
||||
} catch {
|
||||
errorMessage = (error as? LocalizedError)?.errorDescription ?? String(describing: error)
|
||||
}
|
||||
}
|
||||
|
||||
private var payloadInputs: CardEditorPayloadInputs {
|
||||
CardEditorPayloadInputs(
|
||||
type: type,
|
||||
front: front.trimmed,
|
||||
back: back.trimmed,
|
||||
clozeText: clozeText.trimmed,
|
||||
typingAnswer: typingAnswer.trimmed,
|
||||
multipleChoiceAnswer: multipleChoiceAnswer.trimmed,
|
||||
occlusionImageData: occlusionImageData,
|
||||
occlusionMimeType: occlusionMimeType,
|
||||
occlusionRegions: occlusionRegions,
|
||||
occlusionNote: occlusionNote.trimmed,
|
||||
existingImageRef: existingImageRef,
|
||||
audioFileURL: audioFileURL,
|
||||
existingAudioRef: existingAudioRef,
|
||||
existingMediaRefs: existingMediaRefs
|
||||
)
|
||||
}
|
||||
|
||||
private func label(for type: CardType) -> String {
|
||||
switch type {
|
||||
case .basic: "Einfach (Vorder/Rück)"
|
||||
|
|
@ -345,25 +339,10 @@ struct CardEditorView: View {
|
|||
}
|
||||
}
|
||||
|
||||
// swiftlint:enable type_body_length
|
||||
|
||||
private extension String {
|
||||
var trimmed: String {
|
||||
trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
}
|
||||
}
|
||||
|
||||
/// Wird als Sub-View aus dem PhotosPicker-Label-Closure aufgerufen.
|
||||
/// Eigene `View`-Struct vermeidet die Swift-6-Strict-Concurrency-
|
||||
/// Warning: SwiftUIs `PhotosPicker(label:)`-Closure ist `@Sendable`,
|
||||
/// aber View-Konstruktor-Calls werden zur Build-Zeit MainActor-isoliert
|
||||
/// evaluiert (im Gegensatz zu direktem @State-Zugriff im Closure-Body).
|
||||
private struct ImagePickerLabel: View {
|
||||
let hasImage: Bool
|
||||
|
||||
var body: some View {
|
||||
if hasImage {
|
||||
Label("Bild ersetzen", systemImage: "arrow.triangle.2.circlepath")
|
||||
} else {
|
||||
Label("Bild auswählen", systemImage: "photo")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
82
Sources/Features/Editor/DeckEditorHelpers.swift
Normal file
82
Sources/Features/Editor/DeckEditorHelpers.swift
Normal file
|
|
@ -0,0 +1,82 @@
|
|||
import Foundation
|
||||
import ManaCore
|
||||
|
||||
/// Konstanten für `DeckEditorView` — Farbpalette, File-Limits.
|
||||
/// Werte gespiegelt aus `forest`-Theme und Server-Limits in
|
||||
/// `cards/apps/api/src/routes/decks-from-image.ts`.
|
||||
enum DeckEditorPresets {
|
||||
/// 8 Farb-Presets aus dem forest-Theme. Freie Hex-Werte später
|
||||
/// via Custom-Picker (β-3-extension).
|
||||
static let colors: [String] = [
|
||||
"#10803D", // forest primary light
|
||||
"#1E3A2F", // forest dark
|
||||
"#D97706", // amber
|
||||
"#DC2626", // red
|
||||
"#2563EB", // blue
|
||||
"#7C3AED", // violet
|
||||
"#0D9488", // teal
|
||||
"#737373" // neutral
|
||||
]
|
||||
|
||||
static let maxMediaFiles = 5
|
||||
static let maxImageBytes = 10 * 1024 * 1024
|
||||
static let maxPDFBytes = 30 * 1024 * 1024
|
||||
}
|
||||
|
||||
/// Reine Hilfsfunktionen für `DeckEditorView` — kein State, keine Bindings.
|
||||
enum DeckEditorHelpers {
|
||||
/// Nil zurück wenn String nach Trim leer ist.
|
||||
static func nonEmpty(_ value: String) -> String? {
|
||||
let trimmed = value.trimmingCharacters(in: .whitespaces)
|
||||
return trimmed.isEmpty ? nil : trimmed
|
||||
}
|
||||
|
||||
/// http:// oder https:// und nicht-leer.
|
||||
static func isValidURL(_ value: String) -> Bool {
|
||||
let trimmed = value.trimmingCharacters(in: .whitespaces)
|
||||
guard !trimmed.isEmpty else { return false }
|
||||
guard let url = URL(string: trimmed), let scheme = url.scheme else { return false }
|
||||
return scheme == "http" || scheme == "https"
|
||||
}
|
||||
|
||||
/// Magic-Byte-Check für die häufigsten Image-Formate. Fallback JPEG.
|
||||
static func inferImageMimeType(from data: Data) -> String {
|
||||
guard data.count > 4 else { return "image/jpeg" }
|
||||
let bytes = Array(data.prefix(8))
|
||||
if bytes.starts(with: [0xFF, 0xD8, 0xFF]) { return "image/jpeg" }
|
||||
if bytes.starts(with: [0x89, 0x50, 0x4E, 0x47]) { return "image/png" }
|
||||
if bytes.starts(with: [0x47, 0x49, 0x46, 0x38]) { return "image/gif" }
|
||||
if bytes.count >= 4, bytes[0 ... 3] == [0x52, 0x49, 0x46, 0x46] { return "image/webp" }
|
||||
return "image/jpeg"
|
||||
}
|
||||
|
||||
/// Dateiendung für ein erkanntes Image-MIME.
|
||||
static func imageExtension(forMime mime: String) -> String {
|
||||
switch mime {
|
||||
case "image/png": "png"
|
||||
case "image/gif": "gif"
|
||||
case "image/webp": "webp"
|
||||
default: "jpg"
|
||||
}
|
||||
}
|
||||
|
||||
/// AuthError-Server-Codes auf nutzerfreundliche deutsche Texte mappen.
|
||||
/// Greift für beide AI-Endpoints, fällt sonst auf `errorDescription`.
|
||||
static func mapAIError(_ error: AuthError) -> String {
|
||||
if case let .serverError(status, _, message) = error {
|
||||
switch status {
|
||||
case 429:
|
||||
return "Zu viele KI-Anfragen. Bitte eine Minute warten."
|
||||
case 413:
|
||||
return message ?? "Datei zu groß."
|
||||
case 422, 400:
|
||||
return message ?? "Eingabe ungültig."
|
||||
case 502:
|
||||
return message ?? "KI-Server gerade nicht erreichbar."
|
||||
default:
|
||||
break
|
||||
}
|
||||
}
|
||||
return error.errorDescription ?? "Unbekannter Fehler."
|
||||
}
|
||||
}
|
||||
|
|
@ -1,125 +1,530 @@
|
|||
import ManaCore
|
||||
import PhotosUI
|
||||
import SwiftUI
|
||||
|
||||
/// Deck-Create und Deck-Edit in einer View. `existing == nil` → Create-
|
||||
/// Modus mit "Erstellen"-Button. Sonst Edit-Modus mit "Speichern".
|
||||
// swiftlint:disable file_length
|
||||
// swiftlint:disable type_body_length
|
||||
|
||||
/// Deck-Create und Deck-Edit in einer View. Im Create-Modus stehen vier
|
||||
/// Sub-Modi zur Wahl: manuell („Leer"), AI-Text („Mit KI"), AI-Vision
|
||||
/// („Aus Bild") und CSV. Edit-Modus zeigt nur das manuelle Formular.
|
||||
///
|
||||
/// Web-Vorbild: `cards/apps/web/src/routes/decks/new/+page.svelte`.
|
||||
/// `type_body_length` ist bewusst übersprungen — die 4 Sub-Modi teilen
|
||||
/// sich State + Toolbar; aufspalten ginge nur über @Binding-Plumbing.
|
||||
struct DeckEditorView: View {
|
||||
enum Mode: Sendable {
|
||||
enum Mode {
|
||||
case create
|
||||
case edit(deckId: String)
|
||||
}
|
||||
|
||||
/// Vier Sub-Modi im Create-Sheet.
|
||||
enum CreateMode: Hashable {
|
||||
case manual
|
||||
case aiText
|
||||
case aiMedia
|
||||
case csv
|
||||
}
|
||||
|
||||
let mode: Mode
|
||||
let onSaved: (Deck) -> Void
|
||||
|
||||
@Environment(AuthClient.self) private var auth
|
||||
@Environment(\.dismiss) private var dismiss
|
||||
|
||||
// Manual fields (Edit + Create.manual)
|
||||
@State private var name: String
|
||||
@State private var description: String
|
||||
@State private var color: String
|
||||
@State private var category: DeckCategory?
|
||||
@State private var visibility: DeckVisibility
|
||||
@State private var isSubmitting = false
|
||||
@State private var errorMessage: String?
|
||||
@State private var archived: Bool
|
||||
|
||||
/// Vorgefüllte Farbpalette aus dem forest-Theme. User können
|
||||
/// freie Hex-Werte später via Picker setzen (β-3-extension).
|
||||
private static let presetColors: [String] = [
|
||||
"#10803D", // forest primary light
|
||||
"#1E3A2F", // forest dark
|
||||
"#D97706", // amber
|
||||
"#DC2626", // red
|
||||
"#2563EB", // blue
|
||||
"#7C3AED", // violet
|
||||
"#0D9488", // teal
|
||||
"#737373", // neutral
|
||||
]
|
||||
/// Create-mode selector
|
||||
@State private var createMode: CreateMode = .manual
|
||||
|
||||
// AI-shared (Text + Media)
|
||||
@State private var aiPrompt: String = ""
|
||||
@State private var aiCount: Int = 15
|
||||
@State private var aiLanguage: GenerationLanguage = .de
|
||||
@State private var aiUrl: String = ""
|
||||
|
||||
// AI-Media
|
||||
@State private var aiMediaFiles: [GenerationMediaFile] = []
|
||||
@State private var aiPhotoItems: [PhotosPickerItem] = []
|
||||
@State private var showPDFImporter: Bool = false
|
||||
|
||||
// CSV-Import
|
||||
@State private var csvRows: [CSVRow] = []
|
||||
@State private var csvDeckName: String = ""
|
||||
@State private var showCSVImporter: Bool = false
|
||||
@State private var csvImportProgress: Int = 0
|
||||
|
||||
// Submission
|
||||
@State private var isSubmitting = false
|
||||
@State private var generationTask: Task<Void, Never>?
|
||||
@State private var errorMessage: String?
|
||||
|
||||
init(mode: Mode, existing: CachedDeck? = nil, onSaved: @escaping (Deck) -> Void) {
|
||||
self.mode = mode
|
||||
self.onSaved = onSaved
|
||||
_name = State(initialValue: existing?.name ?? "")
|
||||
_description = State(initialValue: existing?.deckDescription ?? "")
|
||||
_color = State(initialValue: existing?.color ?? Self.presetColors[0])
|
||||
_color = State(initialValue: existing?.color ?? DeckEditorPresets.colors[0])
|
||||
_category = State(initialValue: existing?.category)
|
||||
_visibility = State(initialValue: DeckVisibility(rawValue: existing?.visibilityRaw ?? "private") ?? .private)
|
||||
_archived = State(initialValue: existing?.archivedAt != nil)
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
Form {
|
||||
Section("Name") {
|
||||
TextField("Deck-Name", text: $name)
|
||||
.textInputAutocapitalization(.sentences)
|
||||
}
|
||||
|
||||
Section("Beschreibung") {
|
||||
TextField("optional", text: $description, axis: .vertical)
|
||||
.lineLimit(2 ... 4)
|
||||
}
|
||||
|
||||
Section("Farbe") {
|
||||
ScrollView(.horizontal, showsIndicators: false) {
|
||||
HStack(spacing: 10) {
|
||||
ForEach(Self.presetColors, id: \.self) { hex in
|
||||
colorSwatch(hex)
|
||||
}
|
||||
}
|
||||
.padding(.vertical, 4)
|
||||
ZStack {
|
||||
Form {
|
||||
if isCreate {
|
||||
modePickerSection
|
||||
}
|
||||
formSections
|
||||
errorSection
|
||||
}
|
||||
.disabled(isSubmitting)
|
||||
|
||||
Section("Kategorie") {
|
||||
Picker("Kategorie", selection: $category) {
|
||||
Text("Keine").tag(DeckCategory?.none)
|
||||
ForEach(DeckCategory.allCases, id: \.self) { cat in
|
||||
Text(cat.label).tag(DeckCategory?.some(cat))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Section("Sichtbarkeit") {
|
||||
Picker("Sichtbarkeit", selection: $visibility) {
|
||||
Text("Privat").tag(DeckVisibility.private)
|
||||
Text("Space").tag(DeckVisibility.space)
|
||||
Text("Öffentlich").tag(DeckVisibility.public)
|
||||
}
|
||||
.pickerStyle(.segmented)
|
||||
}
|
||||
|
||||
if let errorMessage {
|
||||
Section {
|
||||
Text(errorMessage)
|
||||
.font(.footnote)
|
||||
.foregroundStyle(CardsTheme.error)
|
||||
}
|
||||
if isSubmitting, activeMode != .manual {
|
||||
GenerationOverlay(
|
||||
message: overlayMessage,
|
||||
onCancel: { generationTask?.cancel() }
|
||||
)
|
||||
}
|
||||
}
|
||||
.navigationTitle(isCreate ? "Neues Deck" : "Deck bearbeiten")
|
||||
.navigationTitle(navTitle)
|
||||
#if os(iOS)
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
#endif
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .cancellationAction) {
|
||||
Button("Abbrechen") { dismiss() }
|
||||
}
|
||||
ToolbarItem(placement: .confirmationAction) {
|
||||
Button(isCreate ? "Erstellen" : "Speichern") {
|
||||
Task { await submit() }
|
||||
}
|
||||
.disabled(name.trimmingCharacters(in: .whitespaces).isEmpty || isSubmitting)
|
||||
}
|
||||
.toolbar { toolbar }
|
||||
.onChange(of: aiPhotoItems) { _, items in
|
||||
guard !items.isEmpty else { return }
|
||||
Task { await ingestPhotoItems(items) }
|
||||
}
|
||||
.fileImporter(
|
||||
isPresented: $showPDFImporter,
|
||||
allowedContentTypes: [.pdf],
|
||||
allowsMultipleSelection: true,
|
||||
onCompletion: handlePDFImport
|
||||
)
|
||||
.fileImporter(
|
||||
isPresented: $showCSVImporter,
|
||||
allowedContentTypes: [.commaSeparatedText, .plainText],
|
||||
allowsMultipleSelection: false,
|
||||
onCompletion: handleCSVImport
|
||||
)
|
||||
}
|
||||
|
||||
// MARK: - Sections
|
||||
|
||||
private var modePickerSection: some View {
|
||||
Section {
|
||||
Picker("Modus", selection: $createMode) {
|
||||
Text("Leer").tag(CreateMode.manual)
|
||||
Text("KI").tag(CreateMode.aiText)
|
||||
Text("Bild").tag(CreateMode.aiMedia)
|
||||
Text("CSV").tag(CreateMode.csv)
|
||||
}
|
||||
.pickerStyle(.segmented)
|
||||
} footer: {
|
||||
modeFooter
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private var modeFooter: some View {
|
||||
switch createMode {
|
||||
case .manual:
|
||||
Text("Leeres Deck — Karten anschließend selbst anlegen.")
|
||||
case .aiText:
|
||||
Text("KI generiert das Deck aus einer kurzen Beschreibung. 10 Anfragen pro Minute.")
|
||||
case .aiMedia:
|
||||
Text("KI liest Bilder oder PDFs und macht daraus Karten. Bis zu 5 Dateien.")
|
||||
case .csv:
|
||||
Text("CSV-Datei einlesen. Format: vorne,hinten[,typ] pro Zeile.")
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private var formSections: some View {
|
||||
switch activeMode {
|
||||
case .manual:
|
||||
ManualFormSections(
|
||||
name: $name,
|
||||
description: $description,
|
||||
color: $color,
|
||||
category: $category,
|
||||
visibility: $visibility,
|
||||
archived: isCreate ? nil : $archived
|
||||
)
|
||||
case .aiText:
|
||||
AITextFormSections(prompt: $aiPrompt)
|
||||
AISharedSections(count: $aiCount, language: $aiLanguage, url: $aiUrl)
|
||||
case .aiMedia:
|
||||
AIMediaFormSections(
|
||||
files: $aiMediaFiles,
|
||||
photoItems: $aiPhotoItems,
|
||||
showPDFImporter: $showPDFImporter
|
||||
)
|
||||
AISharedSections(count: $aiCount, language: $aiLanguage, url: $aiUrl)
|
||||
case .csv:
|
||||
CSVImportFormSections(
|
||||
rows: $csvRows,
|
||||
deckName: $csvDeckName,
|
||||
showImporter: $showCSVImporter
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private var errorSection: some View {
|
||||
if let errorMessage {
|
||||
Section {
|
||||
Text(errorMessage)
|
||||
.font(.footnote)
|
||||
.foregroundStyle(CardsTheme.error)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Toolbar
|
||||
|
||||
@ToolbarContentBuilder
|
||||
private var toolbar: some ToolbarContent {
|
||||
ToolbarItem(placement: .cancellationAction) {
|
||||
Button("Abbrechen") {
|
||||
generationTask?.cancel()
|
||||
dismiss()
|
||||
}
|
||||
}
|
||||
ToolbarItem(placement: .confirmationAction) {
|
||||
Button(confirmLabel) {
|
||||
startSubmit()
|
||||
}
|
||||
.disabled(!canSubmit || isSubmitting)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Computed state
|
||||
|
||||
private var isCreate: Bool {
|
||||
if case .create = mode { return true }
|
||||
return false
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private func colorSwatch(_ hex: String) -> some View {
|
||||
let isSelected = color == hex
|
||||
private var activeMode: CreateMode {
|
||||
isCreate ? createMode : .manual
|
||||
}
|
||||
|
||||
private var navTitle: String {
|
||||
switch activeMode {
|
||||
case .manual: isCreate ? "Neues Deck" : "Deck bearbeiten"
|
||||
case .aiText: "Mit KI generieren"
|
||||
case .aiMedia: "Aus Bild generieren"
|
||||
case .csv: "Aus CSV importieren"
|
||||
}
|
||||
}
|
||||
|
||||
private var confirmLabel: String {
|
||||
switch activeMode {
|
||||
case .manual: isCreate ? "Erstellen" : "Speichern"
|
||||
case .aiText, .aiMedia: "Generieren"
|
||||
case .csv: csvRows.isEmpty ? "Importieren" : "\(csvRows.count) Karten importieren"
|
||||
}
|
||||
}
|
||||
|
||||
private var canSubmit: Bool {
|
||||
switch activeMode {
|
||||
case .manual:
|
||||
!name.trimmingCharacters(in: .whitespaces).isEmpty
|
||||
case .aiText:
|
||||
aiPrompt.trimmingCharacters(in: .whitespaces).count >= 3
|
||||
case .aiMedia:
|
||||
!aiMediaFiles.isEmpty || DeckEditorHelpers.isValidURL(aiUrl)
|
||||
case .csv:
|
||||
!csvRows.isEmpty && !csvDeckName.trimmingCharacters(in: .whitespaces).isEmpty
|
||||
}
|
||||
}
|
||||
|
||||
private var overlayMessage: String {
|
||||
switch activeMode {
|
||||
case .csv:
|
||||
csvImportProgress > 0
|
||||
? "Karten werden importiert (\(csvImportProgress) / \(csvRows.count)) …"
|
||||
: "Import wird vorbereitet …"
|
||||
default:
|
||||
"Karten werden generiert …"
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Photo / PDF ingest
|
||||
|
||||
private func ingestPhotoItems(_ items: [PhotosPickerItem]) async {
|
||||
for item in items {
|
||||
if aiMediaFiles.count >= DeckEditorPresets.maxMediaFiles { break }
|
||||
do {
|
||||
guard let data = try await item.loadTransferable(type: Data.self) else { continue }
|
||||
guard data.count <= DeckEditorPresets.maxImageBytes else {
|
||||
errorMessage = "Bild ist größer als 10 MB und wurde übersprungen."
|
||||
continue
|
||||
}
|
||||
let mime = DeckEditorHelpers.inferImageMimeType(from: data)
|
||||
let ext = DeckEditorHelpers.imageExtension(forMime: mime)
|
||||
let filename = "image-\(UUID().uuidString.prefix(8)).\(ext)"
|
||||
aiMediaFiles.append(GenerationMediaFile(
|
||||
data: data,
|
||||
filename: filename,
|
||||
mimeType: mime
|
||||
))
|
||||
} catch {
|
||||
errorMessage = "Foto konnte nicht geladen werden: \(error.localizedDescription)"
|
||||
}
|
||||
}
|
||||
aiPhotoItems = []
|
||||
}
|
||||
|
||||
private func handleCSVImport(_ result: Result<[URL], Error>) {
|
||||
switch result {
|
||||
case let .success(urls):
|
||||
guard let url = urls.first else { return }
|
||||
let didStart = url.startAccessingSecurityScopedResource()
|
||||
defer { if didStart { url.stopAccessingSecurityScopedResource() } }
|
||||
do {
|
||||
let text = try String(contentsOf: url, encoding: .utf8)
|
||||
let rows = try CSVParser.parse(text)
|
||||
csvRows = rows
|
||||
if csvDeckName.trimmingCharacters(in: .whitespaces).isEmpty {
|
||||
csvDeckName = url.deletingPathExtension().lastPathComponent
|
||||
}
|
||||
} catch {
|
||||
errorMessage = "CSV-Import fehlgeschlagen: \(error.localizedDescription)"
|
||||
}
|
||||
case let .failure(error):
|
||||
errorMessage = "Datei-Auswahl fehlgeschlagen: \(error.localizedDescription)"
|
||||
}
|
||||
}
|
||||
|
||||
private func handlePDFImport(_ result: Result<[URL], Error>) {
|
||||
switch result {
|
||||
case let .success(urls):
|
||||
for url in urls {
|
||||
if aiMediaFiles.count >= DeckEditorPresets.maxMediaFiles { break }
|
||||
let didStart = url.startAccessingSecurityScopedResource()
|
||||
defer { if didStart { url.stopAccessingSecurityScopedResource() } }
|
||||
do {
|
||||
let data = try Data(contentsOf: url)
|
||||
guard data.count <= DeckEditorPresets.maxPDFBytes else {
|
||||
errorMessage = "\(url.lastPathComponent) ist größer als 30 MB."
|
||||
continue
|
||||
}
|
||||
aiMediaFiles.append(GenerationMediaFile(
|
||||
data: data,
|
||||
filename: url.lastPathComponent,
|
||||
mimeType: "application/pdf"
|
||||
))
|
||||
} catch {
|
||||
errorMessage = "PDF konnte nicht gelesen werden: \(error.localizedDescription)"
|
||||
}
|
||||
}
|
||||
case let .failure(error):
|
||||
errorMessage = "PDF-Auswahl fehlgeschlagen: \(error.localizedDescription)"
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Submit
|
||||
|
||||
private func startSubmit() {
|
||||
errorMessage = nil
|
||||
isSubmitting = true
|
||||
generationTask = Task {
|
||||
await submit()
|
||||
isSubmitting = false
|
||||
generationTask = nil
|
||||
}
|
||||
}
|
||||
|
||||
private func submit() async {
|
||||
let api = CardsAPI(auth: auth)
|
||||
do {
|
||||
switch (mode, activeMode) {
|
||||
case (.create, .manual):
|
||||
let deck = try await api.createDeck(manualCreateBody)
|
||||
onSaved(deck)
|
||||
dismiss()
|
||||
case let (.edit(deckId), _):
|
||||
let deck = try await api.updateDeck(id: deckId, body: manualUpdateBody)
|
||||
onSaved(deck)
|
||||
dismiss()
|
||||
case (.create, .aiText):
|
||||
let response = try await api.generateDeckFromText(aiTextBody)
|
||||
try Task.checkCancellation()
|
||||
onSaved(response.deck)
|
||||
dismiss()
|
||||
case (.create, .aiMedia):
|
||||
let response = try await api.generateDeckFromMedia(
|
||||
files: aiMediaFiles,
|
||||
language: aiLanguage,
|
||||
count: aiCount,
|
||||
url: DeckEditorHelpers.nonEmpty(aiUrl)
|
||||
)
|
||||
try Task.checkCancellation()
|
||||
onSaved(response.deck)
|
||||
dismiss()
|
||||
case (.create, .csv):
|
||||
let deck = try await submitCSVImport(api: api)
|
||||
onSaved(deck)
|
||||
dismiss()
|
||||
}
|
||||
} catch is CancellationError {
|
||||
// User-Abbruch → kein Banner.
|
||||
} catch let error as AuthError {
|
||||
errorMessage = DeckEditorHelpers.mapAIError(error)
|
||||
} catch {
|
||||
errorMessage = error.localizedDescription
|
||||
}
|
||||
}
|
||||
|
||||
private var manualCreateBody: DeckCreateBody {
|
||||
DeckCreateBody(
|
||||
name: name.trimmingCharacters(in: .whitespaces),
|
||||
description: DeckEditorHelpers.nonEmpty(description),
|
||||
color: color,
|
||||
category: category,
|
||||
visibility: visibility
|
||||
)
|
||||
}
|
||||
|
||||
private var manualUpdateBody: DeckUpdateBody {
|
||||
DeckUpdateBody(
|
||||
name: name.trimmingCharacters(in: .whitespaces),
|
||||
description: DeckEditorHelpers.nonEmpty(description),
|
||||
color: color,
|
||||
category: category,
|
||||
visibility: visibility,
|
||||
archived: archived
|
||||
)
|
||||
}
|
||||
|
||||
private func submitCSVImport(api: CardsAPI) async throws -> Deck {
|
||||
let deck = try await api.createDeck(DeckCreateBody(
|
||||
name: csvDeckName.trimmingCharacters(in: .whitespaces),
|
||||
description: "Aus CSV-Import (\(csvRows.count) Karten)",
|
||||
color: color,
|
||||
category: category,
|
||||
visibility: visibility
|
||||
))
|
||||
csvImportProgress = 0
|
||||
for (index, row) in csvRows.enumerated() {
|
||||
try Task.checkCancellation()
|
||||
let fields: [String: String]
|
||||
switch row.type {
|
||||
case .basic, .basicReverse:
|
||||
fields = CardFieldsBuilder.basic(front: row.front, back: row.back)
|
||||
case .cloze:
|
||||
fields = CardFieldsBuilder.cloze(text: row.front)
|
||||
case .typing:
|
||||
fields = CardFieldsBuilder.typing(front: row.front, answer: row.back)
|
||||
case .multipleChoice:
|
||||
fields = CardFieldsBuilder.multipleChoice(front: row.front, answer: row.back)
|
||||
case .imageOcclusion, .audioFront:
|
||||
// Media-Types brauchen Uploads — überspringe in CSV-Import.
|
||||
csvImportProgress = index + 1
|
||||
continue
|
||||
}
|
||||
_ = try await api.createCard(CardCreateBody(
|
||||
deckId: deck.id,
|
||||
type: row.type,
|
||||
fields: fields,
|
||||
mediaRefs: nil
|
||||
))
|
||||
csvImportProgress = index + 1
|
||||
}
|
||||
return deck
|
||||
}
|
||||
|
||||
private var aiTextBody: DeckGenerateBody {
|
||||
DeckGenerateBody(
|
||||
prompt: aiPrompt.trimmingCharacters(in: .whitespaces),
|
||||
language: aiLanguage,
|
||||
count: aiCount,
|
||||
url: DeckEditorHelpers.nonEmpty(aiUrl)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// swiftlint:enable type_body_length
|
||||
|
||||
// MARK: - Manual form
|
||||
|
||||
private struct ManualFormSections: View {
|
||||
@Binding var name: String
|
||||
@Binding var description: String
|
||||
@Binding var color: String
|
||||
@Binding var category: DeckCategory?
|
||||
@Binding var visibility: DeckVisibility
|
||||
/// `nil` im Create-Modus — dann wird der Toggle nicht gezeigt.
|
||||
var archived: Binding<Bool>?
|
||||
|
||||
var body: some View {
|
||||
Section("Name") {
|
||||
TextField("Deck-Name", text: $name)
|
||||
.textInputAutocapitalization(.sentences)
|
||||
}
|
||||
|
||||
Section("Beschreibung") {
|
||||
TextField("optional", text: $description, axis: .vertical)
|
||||
.lineLimit(2 ... 4)
|
||||
}
|
||||
|
||||
Section("Farbe") {
|
||||
ScrollView(.horizontal, showsIndicators: false) {
|
||||
HStack(spacing: 10) {
|
||||
ForEach(DeckEditorPresets.colors, id: \.self) { hex in
|
||||
ColorSwatchButton(hex: hex, isSelected: color == hex) {
|
||||
color = hex
|
||||
}
|
||||
}
|
||||
}
|
||||
.padding(.vertical, 4)
|
||||
}
|
||||
}
|
||||
|
||||
Section("Kategorie") {
|
||||
Picker("Kategorie", selection: $category) {
|
||||
Text("Keine").tag(DeckCategory?.none)
|
||||
ForEach(DeckCategory.allCases, id: \.self) { cat in
|
||||
Text(cat.label).tag(DeckCategory?.some(cat))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Section("Sichtbarkeit") {
|
||||
Picker("Sichtbarkeit", selection: $visibility) {
|
||||
Text("Privat").tag(DeckVisibility.private)
|
||||
Text("Space").tag(DeckVisibility.space)
|
||||
Text("Öffentlich").tag(DeckVisibility.public)
|
||||
}
|
||||
.pickerStyle(.segmented)
|
||||
}
|
||||
|
||||
if let archived {
|
||||
Section {
|
||||
Toggle("Archiviert", isOn: archived)
|
||||
} footer: {
|
||||
Text("Archivierte Decks erscheinen nicht in der Hauptliste. Bestehende FSRS-Reviews bleiben erhalten.")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private struct ColorSwatchButton: View {
|
||||
let hex: String
|
||||
let isSelected: Bool
|
||||
let onTap: () -> Void
|
||||
|
||||
var body: some View {
|
||||
Circle()
|
||||
.fill(Color.swatchFromHex(hex))
|
||||
.frame(width: 36, height: 36)
|
||||
|
|
@ -127,51 +532,200 @@ struct DeckEditorView: View {
|
|||
Circle()
|
||||
.stroke(isSelected ? CardsTheme.foreground : CardsTheme.border, lineWidth: isSelected ? 3 : 1)
|
||||
)
|
||||
.onTapGesture { color = hex }
|
||||
.onTapGesture(perform: onTap)
|
||||
}
|
||||
}
|
||||
|
||||
private func submit() async {
|
||||
isSubmitting = true
|
||||
errorMessage = nil
|
||||
defer { isSubmitting = false }
|
||||
let api = CardsAPI(auth: auth)
|
||||
// MARK: - AI text form
|
||||
|
||||
do {
|
||||
switch mode {
|
||||
case .create:
|
||||
let body = DeckCreateBody(
|
||||
name: name.trimmingCharacters(in: .whitespaces),
|
||||
description: nonEmpty(description),
|
||||
color: color,
|
||||
category: category,
|
||||
visibility: visibility
|
||||
)
|
||||
let deck = try await api.createDeck(body)
|
||||
onSaved(deck)
|
||||
dismiss()
|
||||
case let .edit(deckId):
|
||||
let body = DeckUpdateBody(
|
||||
name: name.trimmingCharacters(in: .whitespaces),
|
||||
description: nonEmpty(description),
|
||||
color: color,
|
||||
category: category,
|
||||
visibility: visibility
|
||||
)
|
||||
let deck = try await api.updateDeck(id: deckId, body: body)
|
||||
onSaved(deck)
|
||||
dismiss()
|
||||
private struct AITextFormSections: View {
|
||||
@Binding var prompt: String
|
||||
|
||||
var body: some View {
|
||||
Section {
|
||||
TextField(
|
||||
"z.B. Bodensee-Geographie, französische Verben",
|
||||
text: $prompt,
|
||||
axis: .vertical
|
||||
)
|
||||
.lineLimit(3 ... 6)
|
||||
.textInputAutocapitalization(.sentences)
|
||||
} header: {
|
||||
Text("Thema")
|
||||
} footer: {
|
||||
Text("3–500 Zeichen. Je präziser, desto besser die Karten.")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - AI media form
|
||||
|
||||
private struct AIMediaFormSections: View {
|
||||
@Binding var files: [GenerationMediaFile]
|
||||
@Binding var photoItems: [PhotosPickerItem]
|
||||
@Binding var showPDFImporter: Bool
|
||||
|
||||
var body: some View {
|
||||
Section {
|
||||
mediaPickers
|
||||
ForEach(files) { file in
|
||||
MediaFileRow(file: file) {
|
||||
files.removeAll { $0.id == file.id }
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
errorMessage = (error as? LocalizedError)?.errorDescription ?? String(describing: error)
|
||||
} header: {
|
||||
Text("Quellen")
|
||||
} footer: {
|
||||
Text("Max. \(DeckEditorPresets.maxMediaFiles) Dateien. Bilder ≤ 10 MB, PDFs ≤ 30 MB.")
|
||||
}
|
||||
}
|
||||
|
||||
private func nonEmpty(_ s: String) -> String? {
|
||||
let trimmed = s.trimmingCharacters(in: .whitespaces)
|
||||
return trimmed.isEmpty ? nil : trimmed
|
||||
@ViewBuilder
|
||||
private var mediaPickers: some View {
|
||||
let remaining = DeckEditorPresets.maxMediaFiles - files.count
|
||||
|
||||
PhotosPicker(
|
||||
selection: $photoItems,
|
||||
maxSelectionCount: max(remaining, 0),
|
||||
matching: .images
|
||||
) {
|
||||
Label("Fotos hinzufügen", systemImage: "photo.on.rectangle.angled")
|
||||
}
|
||||
.disabled(remaining <= 0)
|
||||
|
||||
Button {
|
||||
showPDFImporter = true
|
||||
} label: {
|
||||
Label("PDFs hinzufügen", systemImage: "doc.text")
|
||||
}
|
||||
.disabled(remaining <= 0)
|
||||
}
|
||||
}
|
||||
|
||||
private struct MediaFileRow: View {
|
||||
let file: GenerationMediaFile
|
||||
let onRemove: () -> Void
|
||||
|
||||
var body: some View {
|
||||
HStack(spacing: 12) {
|
||||
thumbnail
|
||||
.frame(width: 40, height: 40)
|
||||
.clipShape(RoundedRectangle(cornerRadius: 6, style: .continuous))
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
Text(file.filename)
|
||||
.font(.subheadline)
|
||||
.lineLimit(1)
|
||||
Text(file.sizeLabel)
|
||||
.font(.caption)
|
||||
.foregroundStyle(CardsTheme.mutedForeground)
|
||||
}
|
||||
Spacer()
|
||||
Button(action: onRemove) {
|
||||
Image(systemName: "xmark.circle.fill")
|
||||
.foregroundStyle(CardsTheme.mutedForeground)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.accessibilityLabel("Entfernen")
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private var thumbnail: some View {
|
||||
if file.isPDF {
|
||||
ZStack {
|
||||
CardsTheme.muted
|
||||
Image(systemName: "doc.text.fill")
|
||||
.foregroundStyle(CardsTheme.primary)
|
||||
}
|
||||
} else if let img = PlatformImage(data: file.data) {
|
||||
#if canImport(UIKit)
|
||||
Image(uiImage: img)
|
||||
.resizable()
|
||||
.scaledToFill()
|
||||
#else
|
||||
Image(nsImage: img)
|
||||
.resizable()
|
||||
.scaledToFill()
|
||||
#endif
|
||||
} else {
|
||||
CardsTheme.muted
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Shared AI controls
|
||||
|
||||
private struct AISharedSections: View {
|
||||
@Binding var count: Int
|
||||
@Binding var language: GenerationLanguage
|
||||
@Binding var url: String
|
||||
|
||||
var body: some View {
|
||||
Section("Anzahl Karten") {
|
||||
Stepper(value: $count, in: 3 ... 40) {
|
||||
Text("\(count) Karten")
|
||||
}
|
||||
}
|
||||
|
||||
Section("Sprache") {
|
||||
Picker("Sprache", selection: $language) {
|
||||
ForEach(GenerationLanguage.allCases, id: \.self) { lang in
|
||||
Text(lang.label).tag(lang)
|
||||
}
|
||||
}
|
||||
.pickerStyle(.segmented)
|
||||
}
|
||||
|
||||
Section {
|
||||
TextField("https://…", text: $url)
|
||||
.textInputAutocapitalization(.never)
|
||||
.autocorrectionDisabled(true)
|
||||
.keyboardType(.URL)
|
||||
} header: {
|
||||
Text("Zusätzliche URL (optional)")
|
||||
} footer: {
|
||||
Text("KI liest den Inhalt der Seite als zusätzliche Quelle.")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Generation overlay
|
||||
|
||||
private struct GenerationOverlay: View {
|
||||
let message: String
|
||||
let onCancel: () -> Void
|
||||
|
||||
var body: some View {
|
||||
ZStack {
|
||||
Color.black.opacity(0.55)
|
||||
.ignoresSafeArea()
|
||||
VStack(spacing: 16) {
|
||||
ProgressView()
|
||||
.controlSize(.large)
|
||||
.tint(CardsTheme.primary)
|
||||
Text(message)
|
||||
.font(.headline)
|
||||
.foregroundStyle(CardsTheme.foreground)
|
||||
.multilineTextAlignment(.center)
|
||||
Text("Das kann eine Weile dauern.")
|
||||
.font(.caption)
|
||||
.foregroundStyle(CardsTheme.mutedForeground)
|
||||
.multilineTextAlignment(.center)
|
||||
Button("Abbrechen", action: onCancel)
|
||||
.buttonStyle(.bordered)
|
||||
.tint(CardsTheme.mutedForeground)
|
||||
.padding(.top, 4)
|
||||
}
|
||||
.padding(24)
|
||||
.frame(maxWidth: 320)
|
||||
.background(.regularMaterial, in: RoundedRectangle(cornerRadius: 16, style: .continuous))
|
||||
}
|
||||
.transition(.opacity)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Color helper
|
||||
|
||||
extension Color {
|
||||
static func swatchFromHex(_ hex: String) -> Color {
|
||||
var trimmed = hex.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
|
|
@ -179,9 +733,9 @@ extension Color {
|
|||
guard let rgb = UInt32(trimmed, radix: 16) else {
|
||||
return CardsTheme.primary
|
||||
}
|
||||
let r = Double((rgb >> 16) & 0xFF) / 255.0
|
||||
let g = Double((rgb >> 8) & 0xFF) / 255.0
|
||||
let b = Double(rgb & 0xFF) / 255.0
|
||||
return Color(red: r, green: g, blue: b)
|
||||
let red = Double((rgb >> 16) & 0xFF) / 255.0
|
||||
let green = Double((rgb >> 8) & 0xFF) / 255.0
|
||||
let blue = Double(rgb & 0xFF) / 255.0
|
||||
return Color(red: red, green: green, blue: blue)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue