cards-native/Sources/Features/Editor/DeckEditorView.swift
Till JS 73f9081fa1 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>
2026-05-14 02:03:59 +02:00

741 lines
24 KiB
Swift
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import ManaCore
import PhotosUI
import SwiftUI
// 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 {
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 archived: Bool
/// 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 ?? 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 {
ZStack {
Form {
if isCreate {
modePickerSection
}
formSections
errorSection
}
.disabled(isSubmitting)
if isSubmitting, activeMode != .manual {
GenerationOverlay(
message: overlayMessage,
onCancel: { generationTask?.cancel() }
)
}
}
.navigationTitle(navTitle)
#if os(iOS)
.navigationBarTitleDisplayMode(.inline)
#endif
.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
}
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)
.overlay(
Circle()
.stroke(isSelected ? CardsTheme.foreground : CardsTheme.border, lineWidth: isSelected ? 3 : 1)
)
.onTapGesture(perform: onTap)
}
}
// MARK: - AI text form
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("3500 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 }
}
}
} header: {
Text("Quellen")
} footer: {
Text("Max. \(DeckEditorPresets.maxMediaFiles) Dateien. Bilder ≤ 10 MB, PDFs ≤ 30 MB.")
}
}
@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)
if trimmed.hasPrefix("#") { trimmed = String(trimmed.dropFirst()) }
guard let rgb = UInt32(trimmed, radix: 16) else {
return CardsTheme.primary
}
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)
}
}