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
|
|
@ -123,13 +123,12 @@ struct RootView: View {
|
||||||
let parts = url.pathComponents.filter { $0 != "/" }
|
let parts = url.pathComponents.filter { $0 != "/" }
|
||||||
|
|
||||||
// Auth-Reset-Link aus der Passwort-Vergessen-Email.
|
// Auth-Reset-Link aus der Passwort-Vergessen-Email.
|
||||||
if parts == ["auth", "reset"],
|
if parts == ["auth", "reset"] {
|
||||||
let token = URLComponents(url: url, resolvingAgainstBaseURL: false)?
|
let components = URLComponents(url: url, resolvingAgainstBaseURL: false)
|
||||||
.queryItems?
|
if let token = components?.queryItems?.first(where: { $0.name == "token" })?.value {
|
||||||
.first(where: { $0.name == "token" })?.value
|
resetPasswordToken = token
|
||||||
{
|
return
|
||||||
resetPasswordToken = token
|
}
|
||||||
return
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if parts.count >= 2, parts[0] == "d" {
|
if parts.count >= 2, parts[0] == "d" {
|
||||||
|
|
|
||||||
114
Sources/Core/API/CardsAPI+Generation.swift
Normal file
114
Sources/Core/API/CardsAPI+Generation.swift
Normal file
|
|
@ -0,0 +1,114 @@
|
||||||
|
import Foundation
|
||||||
|
import ManaCore
|
||||||
|
|
||||||
|
/// AI-Deck-Generierung + Multipart-Helpers — ausgelagert aus `CardsAPI`,
|
||||||
|
/// damit der Haupt-Actor unter der Type-Body-Length-Grenze bleibt.
|
||||||
|
extension CardsAPI {
|
||||||
|
/// `POST /api/v1/decks/generate` — KI generiert Deck aus Prompt.
|
||||||
|
/// Rate-Limit serverseitig 10/min. Antwort dauert typisch 10–60s
|
||||||
|
/// (synchron, kein Streaming).
|
||||||
|
func generateDeckFromText(_ body: DeckGenerateBody) async throws -> DeckGenerateResponse {
|
||||||
|
let data = try makeJSON(body)
|
||||||
|
let (responseData, http) = try await transport.request(
|
||||||
|
path: "/api/v1/decks/generate",
|
||||||
|
method: "POST",
|
||||||
|
body: data
|
||||||
|
)
|
||||||
|
try ensureOK(http, data: responseData)
|
||||||
|
return try decoder.decode(DeckGenerateResponse.self, from: responseData)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `POST /api/v1/decks/from-image` — Vision-LLM generiert Deck aus
|
||||||
|
/// Bildern und/oder PDFs (max 5 Files, 10 MiB pro Bild, 30 MiB pro PDF)
|
||||||
|
/// und optional einer URL für Zusatz-Kontext. Rate-Limit 10/min.
|
||||||
|
/// Multipart-Body mit `file`-Parts (wiederholt) + Text-Felder.
|
||||||
|
func generateDeckFromMedia(
|
||||||
|
files: [GenerationMediaFile],
|
||||||
|
language: GenerationLanguage,
|
||||||
|
count: Int,
|
||||||
|
url: String?
|
||||||
|
) async throws -> DeckGenerateResponse {
|
||||||
|
let boundary = "cards-native-\(UUID().uuidString)"
|
||||||
|
let body = makeFromImageMultipartBody(
|
||||||
|
files: files,
|
||||||
|
language: language,
|
||||||
|
count: count,
|
||||||
|
url: url,
|
||||||
|
boundary: boundary
|
||||||
|
)
|
||||||
|
let (responseData, http) = try await transport.request(
|
||||||
|
path: "/api/v1/decks/from-image",
|
||||||
|
method: "POST",
|
||||||
|
body: body,
|
||||||
|
contentType: "multipart/form-data; boundary=\(boundary)"
|
||||||
|
)
|
||||||
|
try ensureOK(http, data: responseData)
|
||||||
|
return try decoder.decode(DeckGenerateResponse.self, from: responseData)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Multipart
|
||||||
|
|
||||||
|
/// Single-File-Multipart-Body für `/media/upload`.
|
||||||
|
func makeMultipartBody(
|
||||||
|
file: Data,
|
||||||
|
filename: String,
|
||||||
|
mimeType: String,
|
||||||
|
boundary: String
|
||||||
|
) -> Data {
|
||||||
|
var body = Data()
|
||||||
|
let lineBreak = "\r\n"
|
||||||
|
let header = """
|
||||||
|
--\(boundary)\(lineBreak)\
|
||||||
|
Content-Disposition: form-data; name="file"; filename="\(filename)"\(lineBreak)\
|
||||||
|
Content-Type: \(mimeType)\(lineBreak)\(lineBreak)
|
||||||
|
"""
|
||||||
|
body.append(Data(header.utf8))
|
||||||
|
body.append(file)
|
||||||
|
body.append(Data(lineBreak.utf8))
|
||||||
|
body.append(Data("--\(boundary)--\(lineBreak)".utf8))
|
||||||
|
return body
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Multi-File-Multipart-Body für `/decks/from-image` — mehrere Files
|
||||||
|
/// unter dem Form-Feld `file` (Server liest sie via `getAll('file')`)
|
||||||
|
/// plus optional `language`, `count`, `url` als Text-Felder.
|
||||||
|
func makeFromImageMultipartBody(
|
||||||
|
files: [GenerationMediaFile],
|
||||||
|
language: GenerationLanguage,
|
||||||
|
count: Int,
|
||||||
|
url: String?,
|
||||||
|
boundary: String
|
||||||
|
) -> Data {
|
||||||
|
var body = Data()
|
||||||
|
let lineBreak = "\r\n"
|
||||||
|
|
||||||
|
func appendField(name: String, value: String) {
|
||||||
|
let part = """
|
||||||
|
--\(boundary)\(lineBreak)\
|
||||||
|
Content-Disposition: form-data; name="\(name)"\(lineBreak)\(lineBreak)\
|
||||||
|
\(value)\(lineBreak)
|
||||||
|
"""
|
||||||
|
body.append(Data(part.utf8))
|
||||||
|
}
|
||||||
|
|
||||||
|
appendField(name: "language", value: language.rawValue)
|
||||||
|
appendField(name: "count", value: String(count))
|
||||||
|
if let url, !url.trimmingCharacters(in: .whitespaces).isEmpty {
|
||||||
|
appendField(name: "url", value: url)
|
||||||
|
}
|
||||||
|
|
||||||
|
for file in files {
|
||||||
|
let header = """
|
||||||
|
--\(boundary)\(lineBreak)\
|
||||||
|
Content-Disposition: form-data; name="file"; filename="\(file.filename)"\(lineBreak)\
|
||||||
|
Content-Type: \(file.mimeType)\(lineBreak)\(lineBreak)
|
||||||
|
"""
|
||||||
|
body.append(Data(header.utf8))
|
||||||
|
body.append(file.data)
|
||||||
|
body.append(Data(lineBreak.utf8))
|
||||||
|
}
|
||||||
|
|
||||||
|
body.append(Data("--\(boundary)--\(lineBreak)".utf8))
|
||||||
|
return body
|
||||||
|
}
|
||||||
|
}
|
||||||
59
Sources/Core/API/CardsAPI+Marketplace.swift
Normal file
59
Sources/Core/API/CardsAPI+Marketplace.swift
Normal file
|
|
@ -0,0 +1,59 @@
|
||||||
|
import Foundation
|
||||||
|
import ManaCore
|
||||||
|
|
||||||
|
/// Marketplace-Moderation und Self-Endpoints — ausgelagert aus `CardsAPI`,
|
||||||
|
/// damit der Haupt-Actor unter der Type-Body-Length-Grenze bleibt.
|
||||||
|
///
|
||||||
|
/// `transport`, `decoder`, `makeJSON`, `ensureOK` sind im Actor
|
||||||
|
/// internal-zugänglich.
|
||||||
|
extension CardsAPI {
|
||||||
|
/// `GET /api/v1/marketplace/me/decks` — eigene Marketplace-Decks
|
||||||
|
/// (mit aktueller Version) für den Re-Publish-Flow.
|
||||||
|
func myMarketplaceDecks() async throws -> [OwnedMarketplaceDeck] {
|
||||||
|
let (data, http) = try await transport.request(path: "/api/v1/marketplace/me/decks")
|
||||||
|
try ensureOK(http, data: data)
|
||||||
|
return try decoder.decode(OwnedMarketplaceDecksResponse.self, from: data).decks
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `POST /api/v1/marketplace/decks/:slug/report` — Meldung melden.
|
||||||
|
/// Idempotent: doppeltes Melden mit gleicher Kategorie liefert
|
||||||
|
/// `already_reported: true` ohne Fehler.
|
||||||
|
@discardableResult
|
||||||
|
func reportDeck(slug: String, body: ReportDeckBody) async throws -> ReportDeckResponse {
|
||||||
|
let payload = try makeJSON(body)
|
||||||
|
let (data, http) = try await transport.request(
|
||||||
|
path: "/api/v1/marketplace/decks/\(slug)/report",
|
||||||
|
method: "POST",
|
||||||
|
body: payload
|
||||||
|
)
|
||||||
|
try ensureOK(http, data: data)
|
||||||
|
return try decoder.decode(ReportDeckResponse.self, from: data)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `POST /api/v1/marketplace/authors/:slug/block` — Author blockieren.
|
||||||
|
/// Decks dieses Authors verschwinden für den aufrufenden User aus
|
||||||
|
/// allen Marketplace-Listings.
|
||||||
|
func blockAuthor(slug: String) async throws {
|
||||||
|
let (data, http) = try await transport.request(
|
||||||
|
path: "/api/v1/marketplace/authors/\(slug)/block",
|
||||||
|
method: "POST"
|
||||||
|
)
|
||||||
|
try ensureOK(http, data: data)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `DELETE /api/v1/marketplace/authors/:slug/block`.
|
||||||
|
func unblockAuthor(slug: String) async throws {
|
||||||
|
let (data, http) = try await transport.request(
|
||||||
|
path: "/api/v1/marketplace/authors/\(slug)/block",
|
||||||
|
method: "DELETE"
|
||||||
|
)
|
||||||
|
try ensureOK(http, data: data)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `GET /api/v1/marketplace/me/blocks` — eigene Block-Liste.
|
||||||
|
func myBlocks() async throws -> [BlockEntry] {
|
||||||
|
let (data, http) = try await transport.request(path: "/api/v1/marketplace/me/blocks")
|
||||||
|
try ensureOK(http, data: data)
|
||||||
|
return try decoder.decode(BlockListResponse.self, from: data).blocks
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -1,11 +1,16 @@
|
||||||
import Foundation
|
import Foundation
|
||||||
import ManaCore
|
import ManaCore
|
||||||
|
|
||||||
|
// swiftlint:disable file_length
|
||||||
|
// swiftlint:disable type_body_length
|
||||||
|
|
||||||
/// Cards-spezifischer API-Client. Wrapper um `AuthenticatedTransport`
|
/// Cards-spezifischer API-Client. Wrapper um `AuthenticatedTransport`
|
||||||
/// aus ManaCore, der die Cardecky-Endpoints kennt.
|
/// aus ManaCore, der die Cardecky-Endpoints kennt. Marketplace-Moderation
|
||||||
|
/// + Self-Endpoints + AI-Generation sind in `CardsAPI+Marketplace.swift`
|
||||||
|
/// und `CardsAPI+Generation.swift` ausgelagert.
|
||||||
actor CardsAPI {
|
actor CardsAPI {
|
||||||
private let transport: AuthenticatedTransport
|
let transport: AuthenticatedTransport
|
||||||
private let decoder: JSONDecoder
|
let decoder: JSONDecoder
|
||||||
|
|
||||||
init(auth: AuthClient) {
|
init(auth: AuthClient) {
|
||||||
transport = AuthenticatedTransport(baseURL: AppConfig.apiBaseURL, auth: auth)
|
transport = AuthenticatedTransport(baseURL: AppConfig.apiBaseURL, auth: auth)
|
||||||
|
|
@ -97,7 +102,7 @@ actor CardsAPI {
|
||||||
var items: [URLQueryItem] = [
|
var items: [URLQueryItem] = [
|
||||||
.init(name: "sort", value: sort.rawValue),
|
.init(name: "sort", value: sort.rawValue),
|
||||||
.init(name: "limit", value: "\(limit)"),
|
.init(name: "limit", value: "\(limit)"),
|
||||||
.init(name: "offset", value: "\(offset)"),
|
.init(name: "offset", value: "\(offset)")
|
||||||
]
|
]
|
||||||
if let query, !query.trimmingCharacters(in: .whitespaces).isEmpty {
|
if let query, !query.trimmingCharacters(in: .whitespaces).isEmpty {
|
||||||
items.append(.init(name: "q", value: query))
|
items.append(.init(name: "q", value: query))
|
||||||
|
|
@ -218,6 +223,87 @@ actor CardsAPI {
|
||||||
try ensureOK(http, data: data)
|
try ensureOK(http, data: data)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// `POST /api/v1/decks/:id/duplicate` — Server-seitige Kopie mit
|
||||||
|
/// "(Kopie)"-Suffix, ohne FSRS-Verlauf, ohne Marketplace-Pointer.
|
||||||
|
@discardableResult
|
||||||
|
func duplicateDeck(id: String) async throws -> Deck {
|
||||||
|
let (data, http) = try await transport.request(
|
||||||
|
path: "/api/v1/decks/\(id)/duplicate",
|
||||||
|
method: "POST"
|
||||||
|
)
|
||||||
|
try ensureOK(http, data: data)
|
||||||
|
return try decoder.decode(Deck.self, from: data)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `POST /api/v1/marketplace/private/:deckId/pull-update` — Smart-Merge-
|
||||||
|
/// Pull. Holt neue/geänderte Karten aus der jüngsten Marketplace-Version
|
||||||
|
/// in das geforkte private Deck. Removed-Karten bleiben lokal (User-
|
||||||
|
/// Choice gewinnt). 422 wenn das Deck kein Fork ist.
|
||||||
|
func pullUpdate(deckId: String) async throws -> PullUpdateResponse {
|
||||||
|
let (data, http) = try await transport.request(
|
||||||
|
path: "/api/v1/marketplace/private/\(deckId)/pull-update",
|
||||||
|
method: "POST"
|
||||||
|
)
|
||||||
|
try ensureOK(http, data: data)
|
||||||
|
return try decoder.decode(PullUpdateResponse.self, from: data)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Marketplace-Publish
|
||||||
|
|
||||||
|
/// `POST /api/v1/marketplace/authors/me` — Author-Profil upserten.
|
||||||
|
/// Pflicht-Schritt vor dem ersten Deck-Init im Marketplace.
|
||||||
|
func upsertAuthor(_ body: AuthorUpsertBody) async throws {
|
||||||
|
let payload = try makeJSON(body)
|
||||||
|
let (data, http) = try await transport.request(
|
||||||
|
path: "/api/v1/marketplace/authors/me",
|
||||||
|
method: "POST",
|
||||||
|
body: payload
|
||||||
|
)
|
||||||
|
try ensureOK(http, data: data)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `GET /api/v1/marketplace/authors/me` — eigenes Author-Profil
|
||||||
|
/// lesen, gibt `nil` zurück wenn noch keins existiert.
|
||||||
|
func myAuthor() async throws -> Bool {
|
||||||
|
let (data, http) = try await transport.request(path: "/api/v1/marketplace/authors/me")
|
||||||
|
try ensureOK(http, data: data)
|
||||||
|
// Server liefert entweder Author-Objekt oder JSON-null.
|
||||||
|
if let raw = try? JSONSerialization.jsonObject(with: data), raw is NSNull {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `POST /api/v1/marketplace/decks` — Marketplace-Deck-Init.
|
||||||
|
/// Erstellt nur Metadaten; Karten folgen via `publishMarketplaceVersion`.
|
||||||
|
@discardableResult
|
||||||
|
func initMarketplaceDeck(_ body: MarketplaceDeckInitBody) async throws -> PublicDeck {
|
||||||
|
let payload = try makeJSON(body)
|
||||||
|
let (data, http) = try await transport.request(
|
||||||
|
path: "/api/v1/marketplace/decks",
|
||||||
|
method: "POST",
|
||||||
|
body: payload
|
||||||
|
)
|
||||||
|
try ensureOK(http, data: data)
|
||||||
|
return try decoder.decode(PublicDeck.self, from: data)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `POST /api/v1/marketplace/decks/:slug/publish` — neue Version
|
||||||
|
/// publishen. Karten werden serverseitig durch AI-Moderation geschickt.
|
||||||
|
func publishMarketplaceVersion(
|
||||||
|
slug: String,
|
||||||
|
body: MarketplacePublishBody
|
||||||
|
) async throws -> MarketplacePublishResponse {
|
||||||
|
let payload = try makeJSON(body)
|
||||||
|
let (data, http) = try await transport.request(
|
||||||
|
path: "/api/v1/marketplace/decks/\(slug)/publish",
|
||||||
|
method: "POST",
|
||||||
|
body: payload
|
||||||
|
)
|
||||||
|
try ensureOK(http, data: data)
|
||||||
|
return try decoder.decode(MarketplacePublishResponse.self, from: data)
|
||||||
|
}
|
||||||
|
|
||||||
// MARK: - Card-Mutations
|
// MARK: - Card-Mutations
|
||||||
|
|
||||||
/// `POST /api/v1/cards` — Karte anlegen. Server validiert `fields`
|
/// `POST /api/v1/cards` — Karte anlegen. Server validiert `fields`
|
||||||
|
|
@ -293,37 +379,15 @@ actor CardsAPI {
|
||||||
|
|
||||||
// MARK: - JSON-Encoding
|
// MARK: - JSON-Encoding
|
||||||
|
|
||||||
private func makeJSON<T: Encodable>(_ value: T) throws -> Data {
|
func makeJSON(_ value: some Encodable) throws -> Data {
|
||||||
let encoder = JSONEncoder()
|
let encoder = JSONEncoder()
|
||||||
encoder.dateEncodingStrategy = .iso8601
|
encoder.dateEncodingStrategy = .iso8601
|
||||||
return try encoder.encode(value)
|
return try encoder.encode(value)
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Multipart
|
|
||||||
|
|
||||||
private func makeMultipartBody(
|
|
||||||
file: Data,
|
|
||||||
filename: String,
|
|
||||||
mimeType: String,
|
|
||||||
boundary: String
|
|
||||||
) -> Data {
|
|
||||||
var body = Data()
|
|
||||||
let lineBreak = "\r\n"
|
|
||||||
let header = """
|
|
||||||
--\(boundary)\(lineBreak)\
|
|
||||||
Content-Disposition: form-data; name="file"; filename="\(filename)"\(lineBreak)\
|
|
||||||
Content-Type: \(mimeType)\(lineBreak)\(lineBreak)
|
|
||||||
"""
|
|
||||||
body.append(header.data(using: .utf8) ?? Data())
|
|
||||||
body.append(file)
|
|
||||||
body.append(lineBreak.data(using: .utf8) ?? Data())
|
|
||||||
body.append("--\(boundary)--\(lineBreak)".data(using: .utf8) ?? Data())
|
|
||||||
return body
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - Helpers
|
// MARK: - Helpers
|
||||||
|
|
||||||
private func ensureOK(_ http: HTTPURLResponse, data: Data) throws {
|
func ensureOK(_ http: HTTPURLResponse, data: Data) throws {
|
||||||
guard (200 ..< 300).contains(http.statusCode) else {
|
guard (200 ..< 300).contains(http.statusCode) else {
|
||||||
let message = (try? JSONDecoder().decode(CardsServerError.self, from: data))?.error
|
let message = (try? JSONDecoder().decode(CardsServerError.self, from: data))?.error
|
||||||
throw AuthError.serverError(status: http.statusCode, code: nil, message: message)
|
throw AuthError.serverError(status: http.statusCode, code: nil, message: message)
|
||||||
|
|
@ -331,6 +395,8 @@ actor CardsAPI {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// swiftlint:enable type_body_length
|
||||||
|
|
||||||
private struct CardsServerError: Decodable {
|
private struct CardsServerError: Decodable {
|
||||||
let error: String?
|
let error: String?
|
||||||
}
|
}
|
||||||
|
|
|
||||||
164
Sources/Core/Domain/CSVParser.swift
Normal file
164
Sources/Core/Domain/CSVParser.swift
Normal file
|
|
@ -0,0 +1,164 @@
|
||||||
|
import Foundation
|
||||||
|
|
||||||
|
/// CSV-Zeile aus dem Import-Flow. `type` ist optional — fehlt es,
|
||||||
|
/// wird `.basic` angenommen.
|
||||||
|
struct CSVRow: Equatable {
|
||||||
|
let front: String
|
||||||
|
let back: String
|
||||||
|
let type: CardType
|
||||||
|
|
||||||
|
init(front: String, back: String, type: CardType = .basic) {
|
||||||
|
self.front = front
|
||||||
|
self.back = back
|
||||||
|
self.type = type
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Pragmatischer CSV-Parser für den Cards-Import. Format pro Zeile:
|
||||||
|
///
|
||||||
|
/// <question>,<answer>[,<type>]
|
||||||
|
///
|
||||||
|
/// - Quote-Escape via `""` (RFC-4180).
|
||||||
|
/// - Felder dürfen Kommas und Newlines enthalten, wenn sie in `"…"`
|
||||||
|
/// gekapselt sind.
|
||||||
|
/// - Header-Row wird automatisch übersprungen, wenn Front/Back beide
|
||||||
|
/// wie Header-Tokens aussehen (`front`, `back`, `question`, `answer`,
|
||||||
|
/// `vorderseite`, `rückseite` …).
|
||||||
|
/// - BOM (`\u{FEFF}`) am Anfang wird gestrippt.
|
||||||
|
/// - `type` darf jede Cardecky-Type-Bezeichnung sein; unbekannte Werte
|
||||||
|
/// landen als `.basic`.
|
||||||
|
enum CSVParser {
|
||||||
|
enum ParseError: LocalizedError {
|
||||||
|
case empty
|
||||||
|
case noValidRows
|
||||||
|
|
||||||
|
var errorDescription: String? {
|
||||||
|
switch self {
|
||||||
|
case .empty: "Datei ist leer."
|
||||||
|
case .noValidRows: "Keine gültigen Zeilen gefunden — erwartet ‚vorne,hinten[,typ]'."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
static func parse(_ rawText: String) throws -> [CSVRow] {
|
||||||
|
var text = rawText
|
||||||
|
if text.hasPrefix("\u{FEFF}") {
|
||||||
|
text.removeFirst()
|
||||||
|
}
|
||||||
|
if text.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
|
||||||
|
throw ParseError.empty
|
||||||
|
}
|
||||||
|
|
||||||
|
let allRows = parseFields(text)
|
||||||
|
guard !allRows.isEmpty else { throw ParseError.noValidRows }
|
||||||
|
|
||||||
|
// Header-Detection: erste Zeile droppen wenn beide Felder Headerwords sind.
|
||||||
|
let headerTokens: Set = [
|
||||||
|
"front", "back", "question", "answer",
|
||||||
|
"vorderseite", "rückseite", "rueckseite", "frage", "antwort"
|
||||||
|
]
|
||||||
|
var rows = allRows
|
||||||
|
if let first = rows.first,
|
||||||
|
first.count >= 2,
|
||||||
|
headerTokens.contains(first[0].lowercased()),
|
||||||
|
headerTokens.contains(first[1].lowercased())
|
||||||
|
{
|
||||||
|
rows.removeFirst()
|
||||||
|
}
|
||||||
|
|
||||||
|
let parsed: [CSVRow] = rows.compactMap { fields in
|
||||||
|
guard fields.count >= 2 else { return nil }
|
||||||
|
let front = fields[0].trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
|
let back = fields[1].trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
|
if front.isEmpty, back.isEmpty { return nil }
|
||||||
|
let type: CardType = fields.count >= 3
|
||||||
|
? CardType(rawValue: fields[2].trimmingCharacters(in: .whitespacesAndNewlines)) ?? .basic
|
||||||
|
: .basic
|
||||||
|
return CSVRow(front: front, back: back, type: type)
|
||||||
|
}
|
||||||
|
|
||||||
|
if parsed.isEmpty {
|
||||||
|
throw ParseError.noValidRows
|
||||||
|
}
|
||||||
|
return parsed
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Parser-State-Machine: liest Zeichen-für-Zeichen, beachtet Quote-
|
||||||
|
/// Modus für Kommas/Newlines innerhalb von `"…"`-Feldern. `""` wird
|
||||||
|
/// als wörtliches `"` im Feld behandelt.
|
||||||
|
private static func parseFields(_ text: String) -> [[String]] {
|
||||||
|
var state = ParseState()
|
||||||
|
var iterator = text.makeIterator()
|
||||||
|
|
||||||
|
while let char = iterator.next() {
|
||||||
|
if state.inQuotes {
|
||||||
|
handleQuotedChar(char, iterator: &iterator, state: &state)
|
||||||
|
} else if char == "\"", state.currentField.isEmpty {
|
||||||
|
state.inQuotes = true
|
||||||
|
} else {
|
||||||
|
handleUnquotedChar(char, state: &state)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Tail-Flush — letzte Zeile ohne abschließendes Newline.
|
||||||
|
if !state.currentField.isEmpty || !state.currentRow.isEmpty {
|
||||||
|
state.currentRow.append(state.currentField)
|
||||||
|
state.rows.append(state.currentRow)
|
||||||
|
}
|
||||||
|
|
||||||
|
return state.rows
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Mutable State der Parse-Machine — als `inout`-Struct in die
|
||||||
|
/// Char-Handler durchgereicht, damit die Parameter-Listen kompakt
|
||||||
|
/// bleiben.
|
||||||
|
fileprivate struct ParseState {
|
||||||
|
var rows: [[String]] = []
|
||||||
|
var currentRow: [String] = []
|
||||||
|
var currentField = ""
|
||||||
|
var inQuotes = false
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Im Quote-Modus: `"` schließt das Feld oder escaped sich selbst,
|
||||||
|
/// alles andere ist Inhalt.
|
||||||
|
private static func handleQuotedChar(
|
||||||
|
_ char: Character,
|
||||||
|
iterator: inout String.Iterator,
|
||||||
|
state: inout ParseState
|
||||||
|
) {
|
||||||
|
guard char == "\"" else {
|
||||||
|
state.currentField.append(char)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if let next = iterator.next(), next == "\"" {
|
||||||
|
state.currentField.append("\"")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
state.inQuotes = false
|
||||||
|
// Das Zeichen nach dem End-Quote ist ein Separator (Komma/Newline
|
||||||
|
// /EOF) — über den Unquoted-Handler routen.
|
||||||
|
if let next = iterator.next() {
|
||||||
|
handleUnquotedChar(next, state: &state)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func handleUnquotedChar(_ char: Character, state: inout ParseState) {
|
||||||
|
switch char {
|
||||||
|
case ",":
|
||||||
|
state.currentRow.append(state.currentField)
|
||||||
|
state.currentField = ""
|
||||||
|
case "\n":
|
||||||
|
state.currentRow.append(state.currentField)
|
||||||
|
state.rows.append(state.currentRow)
|
||||||
|
state.currentField = ""
|
||||||
|
state.currentRow = []
|
||||||
|
case "\r":
|
||||||
|
// CRLF: `\r` schluken, `\n` macht den Row-Break.
|
||||||
|
break
|
||||||
|
case "\"" where state.currentField.isEmpty:
|
||||||
|
state.inQuotes = true
|
||||||
|
default:
|
||||||
|
state.currentField.append(char)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
62
Sources/Core/Domain/DeckGeneration.swift
Normal file
62
Sources/Core/Domain/DeckGeneration.swift
Normal file
|
|
@ -0,0 +1,62 @@
|
||||||
|
import Foundation
|
||||||
|
|
||||||
|
/// Body für `POST /api/v1/decks/generate` — AI-Text-Generierung.
|
||||||
|
/// Aus `cards/apps/api/src/routes/decks-generate.ts:GenerateInputSchema`.
|
||||||
|
struct DeckGenerateBody: Encodable {
|
||||||
|
let prompt: String
|
||||||
|
let language: GenerationLanguage
|
||||||
|
let count: Int
|
||||||
|
let url: String?
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Sprache für AI-Deck-Generierung. Server akzeptiert `de` oder `en`.
|
||||||
|
enum GenerationLanguage: String, Codable, CaseIterable {
|
||||||
|
case de
|
||||||
|
case en
|
||||||
|
|
||||||
|
var label: String {
|
||||||
|
switch self {
|
||||||
|
case .de: "Deutsch"
|
||||||
|
case .en: "English"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Eine hochzuladende Datei für `POST /api/v1/decks/from-image`.
|
||||||
|
/// Wird als multipart-`file`-Part gesendet.
|
||||||
|
struct GenerationMediaFile: Identifiable {
|
||||||
|
let id: UUID
|
||||||
|
let data: Data
|
||||||
|
let filename: String
|
||||||
|
let mimeType: String
|
||||||
|
|
||||||
|
init(id: UUID = UUID(), data: Data, filename: String, mimeType: String) {
|
||||||
|
self.id = id
|
||||||
|
self.data = data
|
||||||
|
self.filename = filename
|
||||||
|
self.mimeType = mimeType
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `application/pdf` → PDF-Dokument, sonst Bild.
|
||||||
|
var isPDF: Bool {
|
||||||
|
mimeType == "application/pdf"
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Größen-Label für die UI ("3.2 MB").
|
||||||
|
var sizeLabel: String {
|
||||||
|
ByteCountFormatter.string(fromByteCount: Int64(data.count), countStyle: .file)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Response von beiden AI-Generate-Endpoints (`/decks/generate` und
|
||||||
|
/// `/decks/from-image`). Beide rufen serverseitig `insertGeneratedDeck`
|
||||||
|
/// und liefern dieselbe Shape.
|
||||||
|
struct DeckGenerateResponse: Decodable {
|
||||||
|
let deck: Deck
|
||||||
|
let cardsCreated: Int
|
||||||
|
|
||||||
|
enum CodingKeys: String, CodingKey {
|
||||||
|
case deck
|
||||||
|
case cardsCreated = "cards_created"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -2,7 +2,7 @@ import Foundation
|
||||||
|
|
||||||
/// Body für `POST /api/v1/decks`. Aus `DeckCreateSchema` in
|
/// Body für `POST /api/v1/decks`. Aus `DeckCreateSchema` in
|
||||||
/// `cards/packages/cards-domain/src/schemas/deck.ts`.
|
/// `cards/packages/cards-domain/src/schemas/deck.ts`.
|
||||||
struct DeckCreateBody: Encodable, Sendable {
|
struct DeckCreateBody: Encodable {
|
||||||
let name: String
|
let name: String
|
||||||
let description: String?
|
let description: String?
|
||||||
let color: String?
|
let color: String?
|
||||||
|
|
@ -19,7 +19,7 @@ struct DeckCreateBody: Encodable, Sendable {
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Body für `PATCH /api/v1/decks/:id`. Alle Felder optional plus `archived`.
|
/// Body für `PATCH /api/v1/decks/:id`. Alle Felder optional plus `archived`.
|
||||||
struct DeckUpdateBody: Encodable, Sendable {
|
struct DeckUpdateBody: Encodable {
|
||||||
var name: String?
|
var name: String?
|
||||||
var description: String?
|
var description: String?
|
||||||
var color: String?
|
var color: String?
|
||||||
|
|
@ -36,3 +36,37 @@ struct DeckUpdateBody: Encodable, Sendable {
|
||||||
case archived
|
case archived
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Kurze Marketplace-Version-Info: Semver + Version-ID.
|
||||||
|
struct PullUpdateVersion: Decodable {
|
||||||
|
let semver: String
|
||||||
|
let versionId: String
|
||||||
|
|
||||||
|
enum CodingKeys: String, CodingKey {
|
||||||
|
case semver
|
||||||
|
case versionId = "version_id"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Response von `POST /api/v1/marketplace/private/:deckId/pull-update`.
|
||||||
|
/// `up_to_date == true` heißt: keine neue Marketplace-Version verfügbar,
|
||||||
|
/// die anderen Counts sind dann 0.
|
||||||
|
struct PullUpdateResponse: Decodable {
|
||||||
|
let upToDate: Bool
|
||||||
|
let from: PullUpdateVersion?
|
||||||
|
let to: PullUpdateVersion?
|
||||||
|
let added: Int
|
||||||
|
let changed: Int
|
||||||
|
let removed: Int
|
||||||
|
let cardsInserted: Int?
|
||||||
|
|
||||||
|
enum CodingKeys: String, CodingKey {
|
||||||
|
case upToDate = "up_to_date"
|
||||||
|
case from
|
||||||
|
case to
|
||||||
|
case added
|
||||||
|
case changed
|
||||||
|
case removed
|
||||||
|
case cardsInserted = "cards_inserted"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
122
Sources/Core/Domain/MarketplaceModeration.swift
Normal file
122
Sources/Core/Domain/MarketplaceModeration.swift
Normal file
|
|
@ -0,0 +1,122 @@
|
||||||
|
import Foundation
|
||||||
|
|
||||||
|
/// Kategorien für Deck-Reports — entspricht serverseitig
|
||||||
|
/// `report_category` Enum in `marketplace.deck_reports`.
|
||||||
|
enum ReportCategory: String, Codable, CaseIterable {
|
||||||
|
case spam
|
||||||
|
case copyright
|
||||||
|
case nsfw
|
||||||
|
case misinformation
|
||||||
|
case hate
|
||||||
|
case other
|
||||||
|
|
||||||
|
var label: String {
|
||||||
|
switch self {
|
||||||
|
case .spam: "Spam"
|
||||||
|
case .copyright: "Urheberrecht"
|
||||||
|
case .nsfw: "Anstößige Inhalte"
|
||||||
|
case .misinformation: "Falschinformation"
|
||||||
|
case .hate: "Hass / Diskriminierung"
|
||||||
|
case .other: "Sonstiges"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Body für `POST /api/v1/marketplace/decks/:slug/report`.
|
||||||
|
struct ReportDeckBody: Encodable {
|
||||||
|
let category: ReportCategory
|
||||||
|
let body: String?
|
||||||
|
let versionId: String?
|
||||||
|
let cardContentHash: String?
|
||||||
|
|
||||||
|
enum CodingKeys: String, CodingKey {
|
||||||
|
case category
|
||||||
|
case body
|
||||||
|
case versionId
|
||||||
|
case cardContentHash
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Antwort vom Report-Endpoint.
|
||||||
|
struct ReportDeckResponse: Decodable {
|
||||||
|
let ok: Bool
|
||||||
|
let alreadyReported: Bool
|
||||||
|
let reportId: String?
|
||||||
|
|
||||||
|
enum CodingKeys: String, CodingKey {
|
||||||
|
case ok
|
||||||
|
case alreadyReported = "already_reported"
|
||||||
|
case reportId = "report_id"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Eintrag aus `GET /api/v1/marketplace/me/blocks`.
|
||||||
|
struct BlockEntry: Decodable, Identifiable {
|
||||||
|
let authorSlug: String
|
||||||
|
let displayName: String
|
||||||
|
let blockedAt: Date
|
||||||
|
|
||||||
|
var id: String {
|
||||||
|
authorSlug
|
||||||
|
}
|
||||||
|
|
||||||
|
enum CodingKeys: String, CodingKey {
|
||||||
|
case authorSlug = "author_slug"
|
||||||
|
case displayName = "display_name"
|
||||||
|
case blockedAt = "blocked_at"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct BlockListResponse: Decodable {
|
||||||
|
let blocks: [BlockEntry]
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Aktuelle Version eines `OwnedMarketplaceDeck` — semver + Karten-Count.
|
||||||
|
struct OwnedMarketplaceVersion: Decodable {
|
||||||
|
let versionId: String
|
||||||
|
let semver: String
|
||||||
|
let cardCount: Int
|
||||||
|
let publishedAt: Date?
|
||||||
|
|
||||||
|
enum CodingKeys: String, CodingKey {
|
||||||
|
case versionId = "version_id"
|
||||||
|
case semver
|
||||||
|
case cardCount = "card_count"
|
||||||
|
case publishedAt = "published_at"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Eintrag aus `GET /api/v1/marketplace/me/decks` — Re-Publish-Flow.
|
||||||
|
struct OwnedMarketplaceDeck: Decodable, Identifiable {
|
||||||
|
let slug: String
|
||||||
|
let title: String
|
||||||
|
let description: String?
|
||||||
|
let language: String?
|
||||||
|
let category: String?
|
||||||
|
let license: String
|
||||||
|
let priceCredits: Int
|
||||||
|
let isTakedown: Bool
|
||||||
|
let createdAt: Date
|
||||||
|
let latestVersion: OwnedMarketplaceVersion?
|
||||||
|
|
||||||
|
var id: String {
|
||||||
|
slug
|
||||||
|
}
|
||||||
|
|
||||||
|
enum CodingKeys: String, CodingKey {
|
||||||
|
case slug
|
||||||
|
case title
|
||||||
|
case description
|
||||||
|
case language
|
||||||
|
case category
|
||||||
|
case license
|
||||||
|
case priceCredits = "price_credits"
|
||||||
|
case isTakedown = "is_takedown"
|
||||||
|
case createdAt = "created_at"
|
||||||
|
case latestVersion = "latest_version"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct OwnedMarketplaceDecksResponse: Decodable {
|
||||||
|
let decks: [OwnedMarketplaceDeck]
|
||||||
|
}
|
||||||
116
Sources/Core/Domain/MarketplacePublish.swift
Normal file
116
Sources/Core/Domain/MarketplacePublish.swift
Normal file
|
|
@ -0,0 +1,116 @@
|
||||||
|
import Foundation
|
||||||
|
|
||||||
|
/// Body für `POST /api/v1/marketplace/authors/me` — Upsert des
|
||||||
|
/// Author-Profils. Pflicht-Schritt vor dem ersten Deck-Init im
|
||||||
|
/// Marketplace.
|
||||||
|
struct AuthorUpsertBody: Encodable {
|
||||||
|
let slug: String
|
||||||
|
let displayName: String
|
||||||
|
let bio: String?
|
||||||
|
let avatarUrl: String?
|
||||||
|
let pseudonym: Bool?
|
||||||
|
|
||||||
|
enum CodingKeys: String, CodingKey {
|
||||||
|
case slug
|
||||||
|
case displayName
|
||||||
|
case bio
|
||||||
|
case avatarUrl
|
||||||
|
case pseudonym
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Body für `POST /api/v1/marketplace/decks` — Deck-Init.
|
||||||
|
/// Erstellt nur die Metadaten; Karten kommen mit der ersten `publish`.
|
||||||
|
struct MarketplaceDeckInitBody: Encodable {
|
||||||
|
let slug: String
|
||||||
|
let title: String
|
||||||
|
let description: String?
|
||||||
|
let language: String?
|
||||||
|
let license: String?
|
||||||
|
let priceCredits: Int?
|
||||||
|
let category: DeckCategory?
|
||||||
|
|
||||||
|
enum CodingKeys: String, CodingKey {
|
||||||
|
case slug
|
||||||
|
case title
|
||||||
|
case description
|
||||||
|
case language
|
||||||
|
case license
|
||||||
|
case priceCredits
|
||||||
|
case category
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Eine Card-Payload-Zeile für `POST /:slug/publish`. Andere Type-
|
||||||
|
/// Namen als bei privaten Karten — der Server nutzt `'type-in'` statt
|
||||||
|
/// `'typing'` und `'audio'` statt `'audio-front'`.
|
||||||
|
struct MarketplacePublishCard: Encodable {
|
||||||
|
let type: String
|
||||||
|
let fields: [String: String]
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Body für `POST /api/v1/marketplace/decks/:slug/publish`.
|
||||||
|
struct MarketplacePublishBody: Encodable {
|
||||||
|
let semver: String
|
||||||
|
let changelog: String?
|
||||||
|
let cards: [MarketplacePublishCard]
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Antwort von `POST /:slug/publish`. Enthält das aktualisierte Deck,
|
||||||
|
/// die neue Version und das AI-Moderation-Verdict.
|
||||||
|
struct MarketplacePublishResponse: Decodable {
|
||||||
|
let deck: PublicDeck
|
||||||
|
let version: PublicDeckVersion
|
||||||
|
let moderation: ModerationResult
|
||||||
|
|
||||||
|
struct ModerationResult: Decodable {
|
||||||
|
let verdict: String
|
||||||
|
let categories: [String]?
|
||||||
|
let model: String?
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Liste von Cardecky-Marketplace-Lizenzen. Server akzeptiert beliebige
|
||||||
|
/// Strings ≤ 60 Zeichen — wir bieten die kanonischen vier.
|
||||||
|
enum MarketplaceLicense: String, CaseIterable {
|
||||||
|
case personalUse = "Cardecky-Personal-Use-1.0"
|
||||||
|
case shareAlike = "Cardecky-Share-Alike-1.0"
|
||||||
|
case attribution = "Cardecky-Attribution-1.0"
|
||||||
|
case proOnly = "Cardecky-Pro-Only-1.0"
|
||||||
|
|
||||||
|
var label: String {
|
||||||
|
switch self {
|
||||||
|
case .personalUse: "Persönlicher Gebrauch"
|
||||||
|
case .shareAlike: "Share-Alike (CC-BY-SA-Stil)"
|
||||||
|
case .attribution: "Namensnennung (CC-BY-Stil)"
|
||||||
|
case .proOnly: "Nur für Cardecky-Pro (Bezahl-Decks)"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Konvertiert eine private `Card` in eine `MarketplacePublishCard`
|
||||||
|
/// mit dem korrekten Marketplace-Type und Feld-Mapping. Liefert `nil`,
|
||||||
|
/// wenn der Type im Marketplace nicht unterstützt wird (z.B. Image-
|
||||||
|
/// Occlusion und Audio-Front brauchen Media-Re-Uploads, das gibt es
|
||||||
|
/// im Marketplace-Publish-Flow heute nicht).
|
||||||
|
enum MarketplaceCardConverter {
|
||||||
|
static func convert(_ card: Card) -> MarketplacePublishCard? {
|
||||||
|
switch card.type {
|
||||||
|
case .basic, .basicReverse, .cloze, .multipleChoice:
|
||||||
|
return MarketplacePublishCard(type: card.type.rawValue, fields: card.fields)
|
||||||
|
case .typing:
|
||||||
|
// typing → 'type-in' mit umgeschlüsselten Feldern.
|
||||||
|
let front = card.fields["front"] ?? ""
|
||||||
|
let answer = card.fields["answer"] ?? ""
|
||||||
|
return MarketplacePublishCard(
|
||||||
|
type: "type-in",
|
||||||
|
fields: ["question": front, "expected": answer]
|
||||||
|
)
|
||||||
|
case .imageOcclusion, .audioFront:
|
||||||
|
// Media-Refs zeigen auf user-private Media-IDs — Marketplace-
|
||||||
|
// User können die nicht laden. Skip bis Server-seitig ein
|
||||||
|
// Media-Publish-Flow existiert.
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
122
Sources/Features/Decks/DeckCoverTile.swift
Normal file
122
Sources/Features/Decks/DeckCoverTile.swift
Normal file
|
|
@ -0,0 +1,122 @@
|
||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
/// Gemeinsame Karten-Tile mit Fan-Stack-Hintergrund-Layern.
|
||||||
|
/// Basis für `DeckStackTile` (eigene Decks) und `PublicDeckCard`
|
||||||
|
/// (Marketplace-Decks). Web-Vorbild:
|
||||||
|
/// `cards/apps/web/src/lib/components/DeckStack.svelte` und
|
||||||
|
/// `MarketplaceDeckStack.svelte` — selbe Größe, selbes Stack-Visual,
|
||||||
|
/// nur der Footer variiert.
|
||||||
|
struct DeckCoverTile<Footer: View>: View {
|
||||||
|
let title: String
|
||||||
|
let description: String?
|
||||||
|
let category: DeckCategory?
|
||||||
|
let seed: String
|
||||||
|
let colorAccentHex: String?
|
||||||
|
let isFeatured: Bool
|
||||||
|
@ViewBuilder let footer: () -> Footer
|
||||||
|
|
||||||
|
init(
|
||||||
|
title: String,
|
||||||
|
description: String? = nil,
|
||||||
|
category: DeckCategory? = nil,
|
||||||
|
seed: String,
|
||||||
|
colorAccentHex: String? = nil,
|
||||||
|
isFeatured: Bool = false,
|
||||||
|
@ViewBuilder footer: @escaping () -> Footer
|
||||||
|
) {
|
||||||
|
self.title = title
|
||||||
|
self.description = description
|
||||||
|
self.category = category
|
||||||
|
self.seed = seed
|
||||||
|
self.colorAccentHex = colorAccentHex
|
||||||
|
self.isFeatured = isFeatured
|
||||||
|
self.footer = footer
|
||||||
|
}
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
ZStack {
|
||||||
|
ForEach(Array(layers.enumerated()), id: \.offset) { _, layer in
|
||||||
|
RoundedRectangle(cornerRadius: 14, style: .continuous)
|
||||||
|
.fill(CardsTheme.surface)
|
||||||
|
.overlay(
|
||||||
|
RoundedRectangle(cornerRadius: 14, style: .continuous)
|
||||||
|
.stroke(CardsTheme.border, lineWidth: 1)
|
||||||
|
)
|
||||||
|
.opacity(layer.opacity)
|
||||||
|
.rotationEffect(.degrees(layer.tilt))
|
||||||
|
.offset(x: layer.dx, y: layer.dy)
|
||||||
|
.shadow(color: CardsTheme.foreground.opacity(0.05), radius: 2, y: 1)
|
||||||
|
}
|
||||||
|
|
||||||
|
CardSurface(size: .md, elevation: .standard, colorAccentHex: colorAccentHex) {
|
||||||
|
cardContent
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.aspectRatio(5.0 / 7.0, contentMode: .fit)
|
||||||
|
.frame(maxWidth: 280)
|
||||||
|
}
|
||||||
|
|
||||||
|
private var cardContent: some View {
|
||||||
|
VStack(alignment: .leading, spacing: 6) {
|
||||||
|
HStack(alignment: .top) {
|
||||||
|
if isFeatured {
|
||||||
|
Image(systemName: "star.fill")
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundStyle(CardsTheme.warning)
|
||||||
|
}
|
||||||
|
Spacer()
|
||||||
|
Image(systemName: category?.systemImageName ?? "rectangle.stack")
|
||||||
|
.font(.title2)
|
||||||
|
.foregroundStyle(CardsTheme.primary.opacity(0.85))
|
||||||
|
}
|
||||||
|
|
||||||
|
Spacer(minLength: 0)
|
||||||
|
|
||||||
|
VStack(alignment: .leading, spacing: 6) {
|
||||||
|
Text(title)
|
||||||
|
.font(.system(size: 17, weight: .semibold))
|
||||||
|
.foregroundStyle(CardsTheme.foreground)
|
||||||
|
.lineLimit(3)
|
||||||
|
|
||||||
|
if let description, !description.isEmpty {
|
||||||
|
Text(description)
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundStyle(CardsTheme.mutedForeground)
|
||||||
|
.lineLimit(2)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Spacer(minLength: 0)
|
||||||
|
|
||||||
|
footer()
|
||||||
|
}
|
||||||
|
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
|
||||||
|
}
|
||||||
|
|
||||||
|
private var layers: [DeckCoverStackLayer] {
|
||||||
|
var hash = UInt64(0)
|
||||||
|
for byte in seed.utf8 {
|
||||||
|
hash = hash &* 31 &+ UInt64(byte)
|
||||||
|
}
|
||||||
|
return (0 ..< 3).map { index in
|
||||||
|
let seedHash = hash &+ UInt64(index) &* 17
|
||||||
|
let tiltRaw = Double((seedHash >> 8) & 0xFF) / 255.0 - 0.5
|
||||||
|
let xRaw = Double((seedHash >> 16) & 0xFF) / 255.0 - 0.5
|
||||||
|
let yRaw = Double((seedHash >> 24) & 0xFF) / 255.0 - 0.5
|
||||||
|
let depth = Double(index + 1)
|
||||||
|
return DeckCoverStackLayer(
|
||||||
|
tilt: tiltRaw * 4.0,
|
||||||
|
dx: xRaw * 6.0,
|
||||||
|
dy: depth * 3.0 + yRaw * 2.0,
|
||||||
|
opacity: 0.7 - depth * 0.18
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private struct DeckCoverStackLayer {
|
||||||
|
let tilt: Double
|
||||||
|
let dx: Double
|
||||||
|
let dy: Double
|
||||||
|
let opacity: Double
|
||||||
|
}
|
||||||
|
|
@ -2,8 +2,16 @@ import ManaCore
|
||||||
import SwiftData
|
import SwiftData
|
||||||
import SwiftUI
|
import SwiftUI
|
||||||
|
|
||||||
|
// swiftlint:disable file_length
|
||||||
|
// swiftlint:disable type_body_length
|
||||||
|
|
||||||
/// Deck-Detail mit Aktionen + Card-Liste. Wird per Tap auf eine Deck-Row
|
/// Deck-Detail mit Aktionen + Card-Liste. Wird per Tap auf eine Deck-Row
|
||||||
/// aus der DeckListView geöffnet.
|
/// aus der DeckListView geöffnet.
|
||||||
|
///
|
||||||
|
/// `type_body_length` ist bewusst übersprungen — Detail-View hostet
|
||||||
|
/// 5 verschiedene Sheets (Edit, CardCreate, CardEdit, Publish, Print),
|
||||||
|
/// Confirmation-Dialog + Alerts; aufspalten ginge nur über Multi-State-
|
||||||
|
/// Plumbing zwischen Parent und Children.
|
||||||
struct DeckDetailView: View {
|
struct DeckDetailView: View {
|
||||||
let deckId: String
|
let deckId: String
|
||||||
|
|
||||||
|
|
@ -17,11 +25,19 @@ struct DeckDetailView: View {
|
||||||
@State private var showDeleteConfirm = false
|
@State private var showDeleteConfirm = false
|
||||||
@State private var navigateToStudy = false
|
@State private var navigateToStudy = false
|
||||||
@State private var deleteError: String?
|
@State private var deleteError: String?
|
||||||
|
@State private var editingCard: Card?
|
||||||
|
|
||||||
@State private var cards: [Card] = []
|
@State private var cards: [Card] = []
|
||||||
@State private var isLoadingCards = false
|
@State private var isLoadingCards = false
|
||||||
@State private var cardsError: String?
|
@State private var cardsError: String?
|
||||||
|
|
||||||
|
@State private var isPullingUpdate = false
|
||||||
|
@State private var isDuplicating = false
|
||||||
|
@State private var pullAlert: AlertMessage?
|
||||||
|
@State private var actionError: String?
|
||||||
|
@State private var showPublishSheet = false
|
||||||
|
@State private var showPrintSheet = false
|
||||||
|
|
||||||
init(deckId: String) {
|
init(deckId: String) {
|
||||||
self.deckId = deckId
|
self.deckId = deckId
|
||||||
_decks = Query(filter: #Predicate<CachedDeck> { $0.id == deckId })
|
_decks = Query(filter: #Predicate<CachedDeck> { $0.id == deckId })
|
||||||
|
|
@ -53,7 +69,7 @@ struct DeckDetailView: View {
|
||||||
}
|
}
|
||||||
.sheet(isPresented: $showCardEditor) {
|
.sheet(isPresented: $showCardEditor) {
|
||||||
NavigationStack {
|
NavigationStack {
|
||||||
CardEditorView(deckId: deckId) { _ in
|
CardEditorView(mode: .create(deckId: deckId)) { _ in
|
||||||
Task {
|
Task {
|
||||||
await refreshAfterEdit()
|
await refreshAfterEdit()
|
||||||
await loadCards()
|
await loadCards()
|
||||||
|
|
@ -61,6 +77,36 @@ struct DeckDetailView: View {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
.sheet(item: $editingCard) { card in
|
||||||
|
NavigationStack {
|
||||||
|
CardEditorView(mode: .edit(card: card)) { _ in
|
||||||
|
Task {
|
||||||
|
await refreshAfterEdit()
|
||||||
|
await loadCards()
|
||||||
|
editingCard = nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.sheet(isPresented: $showPublishSheet) {
|
||||||
|
if let deck = decks.first {
|
||||||
|
NavigationStack {
|
||||||
|
MarketplacePublishView(privateDeck: deck) { _ in
|
||||||
|
showPublishSheet = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.sheet(isPresented: $showPrintSheet) {
|
||||||
|
NavigationStack {
|
||||||
|
DeckPrintView(deckId: deckId)
|
||||||
|
.toolbar {
|
||||||
|
ToolbarItem(placement: .cancellationAction) {
|
||||||
|
Button("Fertig") { showPrintSheet = false }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
.confirmationDialog(
|
.confirmationDialog(
|
||||||
"Deck löschen?",
|
"Deck löschen?",
|
||||||
isPresented: $showDeleteConfirm,
|
isPresented: $showDeleteConfirm,
|
||||||
|
|
@ -71,7 +117,12 @@ struct DeckDetailView: View {
|
||||||
}
|
}
|
||||||
Button("Abbrechen", role: .cancel) {}
|
Button("Abbrechen", role: .cancel) {}
|
||||||
} message: {
|
} message: {
|
||||||
Text("Alle Karten und Reviews dieses Decks werden ebenfalls gelöscht. Diese Aktion kann nicht rückgängig gemacht werden.")
|
Text(
|
||||||
|
"""
|
||||||
|
Alle Karten und Reviews dieses Decks werden ebenfalls \
|
||||||
|
gelöscht. Diese Aktion kann nicht rückgängig gemacht werden.
|
||||||
|
"""
|
||||||
|
)
|
||||||
}
|
}
|
||||||
.navigationDestination(isPresented: $navigateToStudy) {
|
.navigationDestination(isPresented: $navigateToStudy) {
|
||||||
if let deck = decks.first {
|
if let deck = decks.first {
|
||||||
|
|
@ -84,6 +135,21 @@ struct DeckDetailView: View {
|
||||||
.refreshable {
|
.refreshable {
|
||||||
await loadCards()
|
await loadCards()
|
||||||
}
|
}
|
||||||
|
.alert(item: $pullAlert) { alert in
|
||||||
|
Alert(title: Text(alert.title), message: Text(alert.message), dismissButton: .default(Text("OK")))
|
||||||
|
}
|
||||||
|
.alert(
|
||||||
|
"Aktion fehlgeschlagen",
|
||||||
|
isPresented: Binding(
|
||||||
|
get: { actionError != nil },
|
||||||
|
set: { if !$0 { actionError = nil } }
|
||||||
|
),
|
||||||
|
presenting: actionError
|
||||||
|
) { _ in
|
||||||
|
Button("OK") { actionError = nil }
|
||||||
|
} message: { message in
|
||||||
|
Text(message)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private func content(deck: CachedDeck) -> some View {
|
private func content(deck: CachedDeck) -> some View {
|
||||||
|
|
@ -136,65 +202,56 @@ struct DeckDetailView: View {
|
||||||
|
|
||||||
private func actions(deck: CachedDeck) -> some View {
|
private func actions(deck: CachedDeck) -> some View {
|
||||||
VStack(spacing: 12) {
|
VStack(spacing: 12) {
|
||||||
Button {
|
primaryActions
|
||||||
navigateToStudy = true
|
secondaryActions(deck: deck)
|
||||||
} label: {
|
|
||||||
Label("Karten lernen", systemImage: "play.fill")
|
|
||||||
.frame(maxWidth: .infinity)
|
|
||||||
.padding(.vertical, 12)
|
|
||||||
.background(CardsTheme.primary, in: RoundedRectangle(cornerRadius: 10))
|
|
||||||
.foregroundStyle(CardsTheme.primaryForeground)
|
|
||||||
}
|
|
||||||
.buttonStyle(.plain)
|
|
||||||
.disabled(deck.dueCount == 0)
|
|
||||||
|
|
||||||
Button {
|
|
||||||
showCardEditor = true
|
|
||||||
} label: {
|
|
||||||
Label("Karte hinzufügen", systemImage: "plus.rectangle.on.rectangle")
|
|
||||||
.frame(maxWidth: .infinity)
|
|
||||||
.padding(.vertical, 12)
|
|
||||||
.background(CardsTheme.surface, in: RoundedRectangle(cornerRadius: 10))
|
|
||||||
.foregroundStyle(CardsTheme.foreground)
|
|
||||||
.overlay(
|
|
||||||
RoundedRectangle(cornerRadius: 10)
|
|
||||||
.stroke(CardsTheme.border, lineWidth: 1)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
.buttonStyle(.plain)
|
|
||||||
|
|
||||||
HStack(spacing: 12) {
|
|
||||||
Button {
|
|
||||||
showEditor = true
|
|
||||||
} label: {
|
|
||||||
Label("Bearbeiten", systemImage: "pencil")
|
|
||||||
.frame(maxWidth: .infinity)
|
|
||||||
.padding(.vertical, 10)
|
|
||||||
.background(CardsTheme.surface, in: RoundedRectangle(cornerRadius: 10))
|
|
||||||
.foregroundStyle(CardsTheme.foreground)
|
|
||||||
.overlay(
|
|
||||||
RoundedRectangle(cornerRadius: 10)
|
|
||||||
.stroke(CardsTheme.border, lineWidth: 1)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
.buttonStyle(.plain)
|
|
||||||
|
|
||||||
Button {
|
|
||||||
showDeleteConfirm = true
|
|
||||||
} label: {
|
|
||||||
Label("Löschen", systemImage: "trash")
|
|
||||||
.frame(maxWidth: .infinity)
|
|
||||||
.padding(.vertical, 10)
|
|
||||||
.background(CardsTheme.error.opacity(0.1), in: RoundedRectangle(cornerRadius: 10))
|
|
||||||
.foregroundStyle(CardsTheme.error)
|
|
||||||
}
|
|
||||||
.buttonStyle(.plain)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
.padding(.horizontal, 16)
|
.padding(.horizontal, 16)
|
||||||
}
|
}
|
||||||
|
|
||||||
@ViewBuilder
|
@ViewBuilder
|
||||||
|
private var primaryActions: some View {
|
||||||
|
Button {
|
||||||
|
navigateToStudy = true
|
||||||
|
} label: {
|
||||||
|
Label("Karten lernen", systemImage: "play.fill")
|
||||||
|
.frame(maxWidth: .infinity)
|
||||||
|
.padding(.vertical, 12)
|
||||||
|
.background(CardsTheme.primary, in: RoundedRectangle(cornerRadius: 10))
|
||||||
|
.foregroundStyle(CardsTheme.primaryForeground)
|
||||||
|
}
|
||||||
|
.buttonStyle(.plain)
|
||||||
|
.disabled((decks.first?.dueCount ?? 0) == 0)
|
||||||
|
|
||||||
|
Button {
|
||||||
|
showCardEditor = true
|
||||||
|
} label: {
|
||||||
|
Label("Karte hinzufügen", systemImage: "plus.rectangle.on.rectangle")
|
||||||
|
.frame(maxWidth: .infinity)
|
||||||
|
.padding(.vertical, 12)
|
||||||
|
.background(CardsTheme.surface, in: RoundedRectangle(cornerRadius: 10))
|
||||||
|
.foregroundStyle(CardsTheme.foreground)
|
||||||
|
.overlay(
|
||||||
|
RoundedRectangle(cornerRadius: 10)
|
||||||
|
.stroke(CardsTheme.border, lineWidth: 1)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
.buttonStyle(.plain)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func secondaryActions(deck: CachedDeck) -> some View {
|
||||||
|
DeckSecondaryActions(
|
||||||
|
isForkedFromMarketplace: deck.isFromMarketplace,
|
||||||
|
isPullingUpdate: isPullingUpdate,
|
||||||
|
isDuplicating: isDuplicating,
|
||||||
|
onPullUpdate: { Task { await pullUpdate() } },
|
||||||
|
onDuplicate: { Task { await duplicate() } },
|
||||||
|
onPublish: { showPublishSheet = true },
|
||||||
|
onPrint: { showPrintSheet = true },
|
||||||
|
onEdit: { showEditor = true },
|
||||||
|
onDelete: { showDeleteConfirm = true }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
private var cardListSection: some View {
|
private var cardListSection: some View {
|
||||||
VStack(alignment: .leading, spacing: 8) {
|
VStack(alignment: .leading, spacing: 8) {
|
||||||
HStack {
|
HStack {
|
||||||
|
|
@ -233,8 +290,14 @@ struct DeckDetailView: View {
|
||||||
} else {
|
} else {
|
||||||
LazyVStack(spacing: 8) {
|
LazyVStack(spacing: 8) {
|
||||||
ForEach(cards) { card in
|
ForEach(cards) { card in
|
||||||
CardPreviewRow(card: card)
|
Button {
|
||||||
.padding(.horizontal, 16)
|
editingCard = card
|
||||||
|
} label: {
|
||||||
|
CardPreviewRow(card: card)
|
||||||
|
.padding(.horizontal, 16)
|
||||||
|
}
|
||||||
|
.buttonStyle(.plain)
|
||||||
|
.accessibilityHint("Tippen zum Bearbeiten")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -246,6 +309,55 @@ struct DeckDetailView: View {
|
||||||
await store.refresh()
|
await store.refresh()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private func pullUpdate() async {
|
||||||
|
isPullingUpdate = true
|
||||||
|
defer { isPullingUpdate = false }
|
||||||
|
let api = CardsAPI(auth: auth)
|
||||||
|
do {
|
||||||
|
let result = try await api.pullUpdate(deckId: deckId)
|
||||||
|
pullAlert = formatPullResult(result)
|
||||||
|
await refreshAfterEdit()
|
||||||
|
await loadCards()
|
||||||
|
} catch let error as AuthError {
|
||||||
|
actionError = error.errorDescription ?? "Update fehlgeschlagen"
|
||||||
|
} catch {
|
||||||
|
actionError = error.localizedDescription
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func formatPullResult(_ result: PullUpdateResponse) -> AlertMessage {
|
||||||
|
if result.upToDate {
|
||||||
|
return AlertMessage(
|
||||||
|
title: "Schon aktuell",
|
||||||
|
message: "Es gibt keine neue Marketplace-Version dieses Decks."
|
||||||
|
)
|
||||||
|
}
|
||||||
|
let inserted = result.cardsInserted ?? 0
|
||||||
|
let parts = [
|
||||||
|
inserted > 0 ? "\(inserted) Karten hinzugefügt" : nil,
|
||||||
|
result.changed > 0 ? "\(result.changed) Karten geändert" : nil,
|
||||||
|
result.removed > 0 ? "\(result.removed) im Marketplace entfernt (lokal behalten)" : nil
|
||||||
|
].compactMap(\.self)
|
||||||
|
let body = parts.isEmpty ? "Update angewendet." : parts.joined(separator: ", ")
|
||||||
|
let versionText = result.to.map { "Version \($0.semver)" } ?? "Update angewendet"
|
||||||
|
return AlertMessage(title: versionText, message: body)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func duplicate() async {
|
||||||
|
isDuplicating = true
|
||||||
|
defer { isDuplicating = false }
|
||||||
|
let api = CardsAPI(auth: auth)
|
||||||
|
do {
|
||||||
|
_ = try await api.duplicateDeck(id: deckId)
|
||||||
|
await refreshAfterEdit()
|
||||||
|
dismiss()
|
||||||
|
} catch let error as AuthError {
|
||||||
|
actionError = error.errorDescription ?? "Duplizieren fehlgeschlagen"
|
||||||
|
} catch {
|
||||||
|
actionError = error.localizedDescription
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private func loadCards() async {
|
private func loadCards() async {
|
||||||
isLoadingCards = true
|
isLoadingCards = true
|
||||||
cardsError = nil
|
cardsError = nil
|
||||||
|
|
@ -275,6 +387,15 @@ struct DeckDetailView: View {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// swiftlint:enable type_body_length
|
||||||
|
|
||||||
|
/// Einfacher Alert-Body — Title + Message für `.alert(item:)`-Trigger.
|
||||||
|
struct AlertMessage: Identifiable {
|
||||||
|
let id = UUID()
|
||||||
|
let title: String
|
||||||
|
let message: String
|
||||||
|
}
|
||||||
|
|
||||||
/// Kompakte Card-Row mit Front-Vorschau und Type-Badge.
|
/// Kompakte Card-Row mit Front-Vorschau und Type-Badge.
|
||||||
private struct CardPreviewRow: View {
|
private struct CardPreviewRow: View {
|
||||||
let card: Card
|
let card: Card
|
||||||
|
|
@ -307,15 +428,15 @@ private struct CardPreviewRow: View {
|
||||||
private func preview(card: Card) -> String {
|
private func preview(card: Card) -> String {
|
||||||
switch card.type {
|
switch card.type {
|
||||||
case .basic, .basicReverse, .typing, .multipleChoice:
|
case .basic, .basicReverse, .typing, .multipleChoice:
|
||||||
return card.fields["front"] ?? "—"
|
card.fields["front"] ?? "—"
|
||||||
case .cloze:
|
case .cloze:
|
||||||
return card.fields["text"] ?? "—"
|
card.fields["text"] ?? "—"
|
||||||
case .imageOcclusion:
|
case .imageOcclusion:
|
||||||
return card.fields["note"]?.isEmpty == false
|
card.fields["note"]?.isEmpty == false
|
||||||
? card.fields["note"]!
|
? card.fields["note"]!
|
||||||
: "Bild-Verdeckung (\(MaskRegions.count(card.fields["mask_regions"] ?? "")) Masken)"
|
: "Bild-Verdeckung (\(MaskRegions.count(card.fields["mask_regions"] ?? "")) Masken)"
|
||||||
case .audioFront:
|
case .audioFront:
|
||||||
return card.fields["back"] ?? "Audio-Karte"
|
card.fields["back"] ?? "Audio-Karte"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -11,6 +11,8 @@ enum DeckRoute: Hashable {
|
||||||
case detail(deckId: String)
|
case detail(deckId: String)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// swiftlint:disable type_body_length
|
||||||
|
|
||||||
/// Decks-Hauptbildschirm im Cardecky-Look: horizontale Scroll-Reihen
|
/// Decks-Hauptbildschirm im Cardecky-Look: horizontale Scroll-Reihen
|
||||||
/// mit Fan-Stack-Karten-Tiles. Web-Vorbild:
|
/// mit Fan-Stack-Karten-Tiles. Web-Vorbild:
|
||||||
/// `cards/apps/web/src/routes/decks/+page.svelte`.
|
/// `cards/apps/web/src/routes/decks/+page.svelte`.
|
||||||
|
|
@ -238,7 +240,10 @@ struct DeckListView: View {
|
||||||
.foregroundStyle(CardsTheme.foreground)
|
.foregroundStyle(CardsTheme.foreground)
|
||||||
} description: {
|
} description: {
|
||||||
Text(
|
Text(
|
||||||
"Browse den Marketplace im Entdecken-Tab — kein Konto nötig. Für eigene Decks und Cloud-Sync logge dich ein."
|
"""
|
||||||
|
Browse den Marketplace im Entdecken-Tab — kein Konto \
|
||||||
|
nötig. Für eigene Decks und Cloud-Sync logge dich ein.
|
||||||
|
"""
|
||||||
)
|
)
|
||||||
.foregroundStyle(CardsTheme.mutedForeground)
|
.foregroundStyle(CardsTheme.mutedForeground)
|
||||||
} actions: {
|
} actions: {
|
||||||
|
|
@ -254,7 +259,10 @@ struct DeckListView: View {
|
||||||
.foregroundStyle(CardsTheme.foreground)
|
.foregroundStyle(CardsTheme.foreground)
|
||||||
} description: {
|
} description: {
|
||||||
Text(
|
Text(
|
||||||
"Tippe unten auf »+«, um dein erstes Deck zu erstellen, oder browse den Marketplace im Entdecken-Tab."
|
"""
|
||||||
|
Tippe unten auf »+«, um dein erstes Deck zu erstellen, \
|
||||||
|
oder browse den Marketplace im Entdecken-Tab.
|
||||||
|
"""
|
||||||
)
|
)
|
||||||
.foregroundStyle(CardsTheme.mutedForeground)
|
.foregroundStyle(CardsTheme.mutedForeground)
|
||||||
}
|
}
|
||||||
|
|
@ -285,3 +293,5 @@ struct DeckListView: View {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// swiftlint:enable type_body_length
|
||||||
|
|
|
||||||
56
Sources/Features/Decks/DeckPrintView.swift
Normal file
56
Sources/Features/Decks/DeckPrintView.swift
Normal file
|
|
@ -0,0 +1,56 @@
|
||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
#if canImport(SafariServices) && canImport(UIKit)
|
||||||
|
import SafariServices
|
||||||
|
import UIKit
|
||||||
|
#endif
|
||||||
|
|
||||||
|
/// In-App-Browser für die Druck-Ansicht des Decks. Nutzt
|
||||||
|
/// `SFSafariViewController`, weil iOS dort die Print-Sheet und
|
||||||
|
/// „In Dateien speichern" → PDF von Haus aus mitbringt — kein eigener
|
||||||
|
/// PDF-Renderer in der App nötig.
|
||||||
|
///
|
||||||
|
/// Auth-Cookies für `cardecky.mana.how` werden geteilt mit Safari auf
|
||||||
|
/// dem Gerät; der User muss dort eingeloggt sein, damit die Print-
|
||||||
|
/// Seite den Deck-Inhalt rendert.
|
||||||
|
struct DeckPrintView: View {
|
||||||
|
let deckId: String
|
||||||
|
|
||||||
|
@Environment(\.dismiss) private var dismiss
|
||||||
|
|
||||||
|
private var printURL: URL {
|
||||||
|
URL(string: "https://cardecky.mana.how/decks/\(deckId)/print")!
|
||||||
|
}
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
#if canImport(SafariServices) && canImport(UIKit)
|
||||||
|
SafariViewRepresentable(url: printURL)
|
||||||
|
.ignoresSafeArea()
|
||||||
|
#else
|
||||||
|
VStack(spacing: 16) {
|
||||||
|
Text("Druck-Ansicht ist nur auf iOS verfügbar.")
|
||||||
|
.font(.subheadline)
|
||||||
|
Link("Im Web öffnen", destination: printURL)
|
||||||
|
Button("Schließen") { dismiss() }
|
||||||
|
}
|
||||||
|
.padding(32)
|
||||||
|
#endif
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#if canImport(SafariServices) && canImport(UIKit)
|
||||||
|
private struct SafariViewRepresentable: UIViewControllerRepresentable {
|
||||||
|
let url: URL
|
||||||
|
|
||||||
|
func makeUIViewController(context _: Context) -> SFSafariViewController {
|
||||||
|
let config = SFSafariViewController.Configuration()
|
||||||
|
config.entersReaderIfAvailable = false
|
||||||
|
let controller = SFSafariViewController(url: url, configuration: config)
|
||||||
|
controller.preferredControlTintColor = .systemGreen
|
||||||
|
controller.dismissButtonStyle = .close
|
||||||
|
return controller
|
||||||
|
}
|
||||||
|
|
||||||
|
func updateUIViewController(_: SFSafariViewController, context _: Context) {}
|
||||||
|
}
|
||||||
|
#endif
|
||||||
138
Sources/Features/Decks/DeckSecondaryActions.swift
Normal file
138
Sources/Features/Decks/DeckSecondaryActions.swift
Normal file
|
|
@ -0,0 +1,138 @@
|
||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
/// Sekundär-Action-Buttons unterhalb der Lern-/Karten-hinzufügen-Buttons
|
||||||
|
/// in `DeckDetailView`. Eigenständige View, damit `DeckDetailView` selbst
|
||||||
|
/// nicht über die Type-Body-Length-Grenze rutscht und die einzelnen
|
||||||
|
/// Aktionen einzeln (z.B. via Snapshot-Tests) prüfbar bleiben.
|
||||||
|
///
|
||||||
|
/// Reines Layout — alle Side-Effects laufen über die Callbacks im Parent.
|
||||||
|
struct DeckSecondaryActions: View {
|
||||||
|
let isForkedFromMarketplace: Bool
|
||||||
|
let isPullingUpdate: Bool
|
||||||
|
let isDuplicating: Bool
|
||||||
|
let onPullUpdate: () -> Void
|
||||||
|
let onDuplicate: () -> Void
|
||||||
|
let onPublish: () -> Void
|
||||||
|
let onPrint: () -> Void
|
||||||
|
let onEdit: () -> Void
|
||||||
|
let onDelete: () -> Void
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
if isForkedFromMarketplace {
|
||||||
|
updateButton
|
||||||
|
} else {
|
||||||
|
publishButton
|
||||||
|
}
|
||||||
|
duplicateButton
|
||||||
|
printButton
|
||||||
|
editDeleteRow
|
||||||
|
}
|
||||||
|
|
||||||
|
private var printButton: some View {
|
||||||
|
Button(action: onPrint) {
|
||||||
|
HStack {
|
||||||
|
Image(systemName: "printer")
|
||||||
|
Text("Druck-Ansicht / PDF")
|
||||||
|
}
|
||||||
|
.frame(maxWidth: .infinity)
|
||||||
|
.padding(.vertical, 10)
|
||||||
|
.background(CardsTheme.surface, in: RoundedRectangle(cornerRadius: 10))
|
||||||
|
.foregroundStyle(CardsTheme.foreground)
|
||||||
|
.overlay(
|
||||||
|
RoundedRectangle(cornerRadius: 10)
|
||||||
|
.stroke(CardsTheme.border, lineWidth: 1)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
.buttonStyle(.plain)
|
||||||
|
}
|
||||||
|
|
||||||
|
private var publishButton: some View {
|
||||||
|
Button(action: onPublish) {
|
||||||
|
HStack {
|
||||||
|
Image(systemName: "globe.badge.chevron.backward")
|
||||||
|
Text("Im Marketplace veröffentlichen")
|
||||||
|
}
|
||||||
|
.frame(maxWidth: .infinity)
|
||||||
|
.padding(.vertical, 10)
|
||||||
|
.background(CardsTheme.surface, in: RoundedRectangle(cornerRadius: 10))
|
||||||
|
.foregroundStyle(CardsTheme.primary)
|
||||||
|
.overlay(
|
||||||
|
RoundedRectangle(cornerRadius: 10)
|
||||||
|
.stroke(CardsTheme.primary.opacity(0.3), lineWidth: 1)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
.buttonStyle(.plain)
|
||||||
|
}
|
||||||
|
|
||||||
|
private var updateButton: some View {
|
||||||
|
Button(action: onPullUpdate) {
|
||||||
|
HStack {
|
||||||
|
if isPullingUpdate {
|
||||||
|
ProgressView().tint(CardsTheme.primary)
|
||||||
|
} else {
|
||||||
|
Image(systemName: "arrow.triangle.2.circlepath")
|
||||||
|
}
|
||||||
|
Text(isPullingUpdate ? "Wird geprüft …" : "Updates aus Marketplace prüfen")
|
||||||
|
}
|
||||||
|
.frame(maxWidth: .infinity)
|
||||||
|
.padding(.vertical, 10)
|
||||||
|
.background(CardsTheme.surface, in: RoundedRectangle(cornerRadius: 10))
|
||||||
|
.foregroundStyle(CardsTheme.primary)
|
||||||
|
.overlay(
|
||||||
|
RoundedRectangle(cornerRadius: 10)
|
||||||
|
.stroke(CardsTheme.primary.opacity(0.3), lineWidth: 1)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
.buttonStyle(.plain)
|
||||||
|
.disabled(isPullingUpdate)
|
||||||
|
}
|
||||||
|
|
||||||
|
private var duplicateButton: some View {
|
||||||
|
Button(action: onDuplicate) {
|
||||||
|
HStack {
|
||||||
|
if isDuplicating {
|
||||||
|
ProgressView().tint(CardsTheme.foreground)
|
||||||
|
} else {
|
||||||
|
Image(systemName: "doc.on.doc")
|
||||||
|
}
|
||||||
|
Text(isDuplicating ? "Wird dupliziert …" : "Deck duplizieren")
|
||||||
|
}
|
||||||
|
.frame(maxWidth: .infinity)
|
||||||
|
.padding(.vertical, 10)
|
||||||
|
.background(CardsTheme.surface, in: RoundedRectangle(cornerRadius: 10))
|
||||||
|
.foregroundStyle(CardsTheme.foreground)
|
||||||
|
.overlay(
|
||||||
|
RoundedRectangle(cornerRadius: 10)
|
||||||
|
.stroke(CardsTheme.border, lineWidth: 1)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
.buttonStyle(.plain)
|
||||||
|
.disabled(isDuplicating)
|
||||||
|
}
|
||||||
|
|
||||||
|
private var editDeleteRow: some View {
|
||||||
|
HStack(spacing: 12) {
|
||||||
|
Button(action: onEdit) {
|
||||||
|
Label("Bearbeiten", systemImage: "pencil")
|
||||||
|
.frame(maxWidth: .infinity)
|
||||||
|
.padding(.vertical, 10)
|
||||||
|
.background(CardsTheme.surface, in: RoundedRectangle(cornerRadius: 10))
|
||||||
|
.foregroundStyle(CardsTheme.foreground)
|
||||||
|
.overlay(
|
||||||
|
RoundedRectangle(cornerRadius: 10)
|
||||||
|
.stroke(CardsTheme.border, lineWidth: 1)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
.buttonStyle(.plain)
|
||||||
|
|
||||||
|
Button(action: onDelete) {
|
||||||
|
Label("Löschen", systemImage: "trash")
|
||||||
|
.frame(maxWidth: .infinity)
|
||||||
|
.padding(.vertical, 10)
|
||||||
|
.background(CardsTheme.error.opacity(0.1), in: RoundedRectangle(cornerRadius: 10))
|
||||||
|
.foregroundStyle(CardsTheme.error)
|
||||||
|
}
|
||||||
|
.buttonStyle(.plain)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
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 ManaCore
|
||||||
import PhotosUI
|
|
||||||
import SwiftUI
|
import SwiftUI
|
||||||
|
|
||||||
#if canImport(UIKit)
|
#if canImport(UIKit)
|
||||||
import UIKit
|
import UIKit
|
||||||
#endif
|
#endif
|
||||||
|
|
||||||
/// Card-Create-View. Type-Picker oben, type-spezifische Felder unten.
|
// swiftlint:disable type_body_length
|
||||||
/// Deckt alle 7 Card-Types ab.
|
|
||||||
|
/// 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 {
|
struct CardEditorView: View {
|
||||||
let deckId: String
|
enum Mode {
|
||||||
let onCreated: (Card) -> Void
|
case create(deckId: String)
|
||||||
|
case edit(card: Card)
|
||||||
|
}
|
||||||
|
|
||||||
|
let mode: Mode
|
||||||
|
let onSaved: (Card) -> Void
|
||||||
|
|
||||||
@Environment(AuthClient.self) private var auth
|
@Environment(AuthClient.self) private var auth
|
||||||
@Environment(\.dismiss) private var dismiss
|
@Environment(\.dismiss) private var dismiss
|
||||||
|
|
||||||
@State private var type: CardType = .basic
|
@State private var type: CardType
|
||||||
@State private var front: String = ""
|
@State private var front: String
|
||||||
@State private var back: String = ""
|
@State private var back: String
|
||||||
@State private var clozeText: String = ""
|
@State private var clozeText: String
|
||||||
@State private var typingAnswer: String = ""
|
@State private var typingAnswer: String
|
||||||
@State private var multipleChoiceAnswer: String = ""
|
@State private var multipleChoiceAnswer: String
|
||||||
@State private var isSubmitting = false
|
@State private var isSubmitting = false
|
||||||
@State private var errorMessage: String?
|
@State private var errorMessage: String?
|
||||||
|
|
||||||
// Image-Occlusion-State
|
// Image-Occlusion-State
|
||||||
@State private var imagePickerItem: PhotosPickerItem?
|
|
||||||
@State private var occlusionImage: PlatformImage?
|
@State private var occlusionImage: PlatformImage?
|
||||||
@State private var occlusionImageData: Data?
|
@State private var occlusionImageData: Data?
|
||||||
@State private var occlusionMimeType: String = "image/jpeg"
|
@State private var occlusionMimeType: String = "image/jpeg"
|
||||||
@State private var occlusionRegions: [MaskRegion] = []
|
@State private var occlusionRegions: [MaskRegion]
|
||||||
@State private var occlusionNote: String = ""
|
@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 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] = [
|
private static let supportedTypes: [CardType] = [
|
||||||
.basic, .basicReverse, .cloze, .typing, .multipleChoice,
|
.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 {
|
var body: some View {
|
||||||
Form {
|
Form {
|
||||||
Section("Card-Type") {
|
if isCreate {
|
||||||
Picker("Typ", selection: $type) {
|
Section("Card-Type") {
|
||||||
ForEach(Self.supportedTypes, id: \.self) { t in
|
Picker("Typ", selection: $type) {
|
||||||
Text(label(for: t)).tag(t)
|
ForEach(Self.supportedTypes, id: \.self) { cardType in
|
||||||
|
Text(label(for: cardType)).tag(cardType)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
.pickerStyle(.menu)
|
||||||
}
|
}
|
||||||
.pickerStyle(.menu)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
typeFields
|
typeFields
|
||||||
|
|
@ -62,7 +133,8 @@ struct CardEditorView: View {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.navigationTitle("Neue Karte")
|
.disabled(isSubmitting)
|
||||||
|
.navigationTitle(isCreate ? "Neue Karte" : "Karte bearbeiten")
|
||||||
#if os(iOS)
|
#if os(iOS)
|
||||||
.navigationBarTitleDisplayMode(.inline)
|
.navigationBarTitleDisplayMode(.inline)
|
||||||
#endif
|
#endif
|
||||||
|
|
@ -71,8 +143,10 @@ struct CardEditorView: View {
|
||||||
Button("Abbrechen") { dismiss() }
|
Button("Abbrechen") { dismiss() }
|
||||||
}
|
}
|
||||||
ToolbarItem(placement: .confirmationAction) {
|
ToolbarItem(placement: .confirmationAction) {
|
||||||
Button("Erstellen") { Task { await submit() } }
|
Button(isCreate ? "Erstellen" : "Speichern") {
|
||||||
.disabled(!canSubmit || isSubmitting)
|
Task { await submit() }
|
||||||
|
}
|
||||||
|
.disabled(!canSubmit || isSubmitting)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -99,12 +173,15 @@ struct CardEditorView: View {
|
||||||
|
|
||||||
case .cloze:
|
case .cloze:
|
||||||
Section("Cloze-Text") {
|
Section("Cloze-Text") {
|
||||||
TextField("Beispiel: Die Hauptstadt von {{c1::Frankreich}} ist {{c2::Paris}}.",
|
TextField(
|
||||||
text: $clozeText, axis: .vertical)
|
"Beispiel: Die Hauptstadt von {{c1::Frankreich}} ist {{c2::Paris}}.",
|
||||||
.lineLimit(3 ... 8)
|
text: $clozeText,
|
||||||
.autocorrectionDisabled()
|
axis: .vertical
|
||||||
.textInputAutocapitalization(.sentences)
|
)
|
||||||
.monospaced()
|
.lineLimit(3 ... 8)
|
||||||
|
.autocorrectionDisabled()
|
||||||
|
.textInputAutocapitalization(.sentences)
|
||||||
|
.monospaced()
|
||||||
}
|
}
|
||||||
Section {
|
Section {
|
||||||
let count = Cloze.subIndexCount(clozeText)
|
let count = Cloze.subIndexCount(clozeText)
|
||||||
|
|
@ -146,119 +223,40 @@ struct CardEditorView: View {
|
||||||
}
|
}
|
||||||
|
|
||||||
case .imageOcclusion:
|
case .imageOcclusion:
|
||||||
imageOcclusionFields
|
ImageOcclusionFields(
|
||||||
|
image: $occlusionImage,
|
||||||
|
imageData: $occlusionImageData,
|
||||||
|
mimeType: $occlusionMimeType,
|
||||||
|
regions: $occlusionRegions,
|
||||||
|
note: $occlusionNote,
|
||||||
|
existingImageRef: $existingImageRef,
|
||||||
|
onLoadError: { errorMessage = $0 }
|
||||||
|
)
|
||||||
|
|
||||||
case .audioFront:
|
case .audioFront:
|
||||||
audioFrontFields
|
AudioFrontFields(
|
||||||
|
audioFileURL: $audioFileURL,
|
||||||
|
back: $back,
|
||||||
|
existingAudioRef: existingAudioRef
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ViewBuilder
|
private var isCreate: Bool {
|
||||||
private var imageOcclusionFields: some View {
|
if case .create = mode { return true }
|
||||||
Section("Bild") {
|
return false
|
||||||
PhotosPicker(selection: $imagePickerItem, matching: .images) {
|
}
|
||||||
ImagePickerLabel(hasImage: occlusionImage != nil)
|
|
||||||
}
|
|
||||||
.onChange(of: imagePickerItem) { _, newItem in
|
|
||||||
Task { await loadPickedImage(newItem) }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if let image = occlusionImage {
|
private var deckId: String {
|
||||||
Section("Masken") {
|
switch mode {
|
||||||
MaskEditorView(image: image, regions: $occlusionRegions)
|
case let .create(deckId): deckId
|
||||||
}
|
case let .edit(card): card.deckId
|
||||||
}
|
|
||||||
|
|
||||||
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)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ViewBuilder
|
private var existingMediaRefs: [String] {
|
||||||
private var audioFrontFields: some View {
|
if case let .edit(card) = mode { return card.mediaRefs }
|
||||||
Section("Audio-Datei") {
|
return []
|
||||||
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 canSubmit: Bool {
|
private var canSubmit: Bool {
|
||||||
|
|
@ -272,12 +270,14 @@ struct CardEditorView: View {
|
||||||
case .multipleChoice:
|
case .multipleChoice:
|
||||||
!front.trimmed.isEmpty && !multipleChoiceAnswer.trimmed.isEmpty
|
!front.trimmed.isEmpty && !multipleChoiceAnswer.trimmed.isEmpty
|
||||||
case .imageOcclusion:
|
case .imageOcclusion:
|
||||||
occlusionImageData != nil && !occlusionRegions.isEmpty
|
(occlusionImageData != nil || existingImageRef != nil) && !occlusionRegions.isEmpty
|
||||||
case .audioFront:
|
case .audioFront:
|
||||||
audioFileURL != nil && !back.trimmed.isEmpty
|
(audioFileURL != nil || existingAudioRef != nil) && !back.trimmed.isEmpty
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// MARK: - Submit
|
||||||
|
|
||||||
private func submit() async {
|
private func submit() async {
|
||||||
isSubmitting = true
|
isSubmitting = true
|
||||||
errorMessage = nil
|
errorMessage = nil
|
||||||
|
|
@ -285,53 +285,47 @@ struct CardEditorView: View {
|
||||||
let api = CardsAPI(auth: auth)
|
let api = CardsAPI(auth: auth)
|
||||||
|
|
||||||
do {
|
do {
|
||||||
let fields: [String: String]
|
let payload = try await CardEditorPayloadBuilder.build(inputs: payloadInputs, api: api)
|
||||||
var mediaRefs: [String]? = nil
|
let card: Card = switch mode {
|
||||||
switch type {
|
case let .create(deckId):
|
||||||
case .basic, .basicReverse:
|
try await api.createCard(CardCreateBody(
|
||||||
fields = CardFieldsBuilder.basic(front: front.trimmed, back: back.trimmed)
|
deckId: deckId,
|
||||||
case .cloze:
|
type: type,
|
||||||
fields = CardFieldsBuilder.cloze(text: clozeText.trimmed)
|
fields: payload.fields,
|
||||||
case .typing:
|
mediaRefs: payload.mediaRefs
|
||||||
fields = CardFieldsBuilder.typing(front: front.trimmed, answer: typingAnswer.trimmed)
|
))
|
||||||
case .multipleChoice:
|
case let .edit(existing):
|
||||||
fields = CardFieldsBuilder.multipleChoice(front: front.trimmed, answer: multipleChoiceAnswer.trimmed)
|
try await api.updateCard(id: existing.id, body: CardUpdateBody(
|
||||||
case .imageOcclusion:
|
fields: payload.fields,
|
||||||
guard let data = occlusionImageData else { return }
|
mediaRefs: payload.mediaRefs
|
||||||
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]
|
|
||||||
}
|
}
|
||||||
|
onSaved(card)
|
||||||
let body = CardCreateBody(deckId: deckId, type: type, fields: fields, mediaRefs: mediaRefs)
|
|
||||||
let card = try await api.createCard(body)
|
|
||||||
onCreated(card)
|
|
||||||
dismiss()
|
dismiss()
|
||||||
} catch {
|
} catch {
|
||||||
errorMessage = (error as? LocalizedError)?.errorDescription ?? String(describing: error)
|
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 {
|
private func label(for type: CardType) -> String {
|
||||||
switch type {
|
switch type {
|
||||||
case .basic: "Einfach (Vorder/Rück)"
|
case .basic: "Einfach (Vorder/Rück)"
|
||||||
|
|
@ -345,25 +339,10 @@ struct CardEditorView: View {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// swiftlint:enable type_body_length
|
||||||
|
|
||||||
private extension String {
|
private extension String {
|
||||||
var trimmed: String {
|
var trimmed: String {
|
||||||
trimmingCharacters(in: .whitespacesAndNewlines)
|
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 ManaCore
|
||||||
|
import PhotosUI
|
||||||
import SwiftUI
|
import SwiftUI
|
||||||
|
|
||||||
/// Deck-Create und Deck-Edit in einer View. `existing == nil` → Create-
|
// swiftlint:disable file_length
|
||||||
/// Modus mit "Erstellen"-Button. Sonst Edit-Modus mit "Speichern".
|
// 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 {
|
struct DeckEditorView: View {
|
||||||
enum Mode: Sendable {
|
enum Mode {
|
||||||
case create
|
case create
|
||||||
case edit(deckId: String)
|
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 mode: Mode
|
||||||
let onSaved: (Deck) -> Void
|
let onSaved: (Deck) -> Void
|
||||||
|
|
||||||
@Environment(AuthClient.self) private var auth
|
@Environment(AuthClient.self) private var auth
|
||||||
@Environment(\.dismiss) private var dismiss
|
@Environment(\.dismiss) private var dismiss
|
||||||
|
|
||||||
|
// Manual fields (Edit + Create.manual)
|
||||||
@State private var name: String
|
@State private var name: String
|
||||||
@State private var description: String
|
@State private var description: String
|
||||||
@State private var color: String
|
@State private var color: String
|
||||||
@State private var category: DeckCategory?
|
@State private var category: DeckCategory?
|
||||||
@State private var visibility: DeckVisibility
|
@State private var visibility: DeckVisibility
|
||||||
@State private var isSubmitting = false
|
@State private var archived: Bool
|
||||||
@State private var errorMessage: String?
|
|
||||||
|
|
||||||
/// Vorgefüllte Farbpalette aus dem forest-Theme. User können
|
/// Create-mode selector
|
||||||
/// freie Hex-Werte später via Picker setzen (β-3-extension).
|
@State private var createMode: CreateMode = .manual
|
||||||
private static let presetColors: [String] = [
|
|
||||||
"#10803D", // forest primary light
|
// AI-shared (Text + Media)
|
||||||
"#1E3A2F", // forest dark
|
@State private var aiPrompt: String = ""
|
||||||
"#D97706", // amber
|
@State private var aiCount: Int = 15
|
||||||
"#DC2626", // red
|
@State private var aiLanguage: GenerationLanguage = .de
|
||||||
"#2563EB", // blue
|
@State private var aiUrl: String = ""
|
||||||
"#7C3AED", // violet
|
|
||||||
"#0D9488", // teal
|
// AI-Media
|
||||||
"#737373", // neutral
|
@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) {
|
init(mode: Mode, existing: CachedDeck? = nil, onSaved: @escaping (Deck) -> Void) {
|
||||||
self.mode = mode
|
self.mode = mode
|
||||||
self.onSaved = onSaved
|
self.onSaved = onSaved
|
||||||
_name = State(initialValue: existing?.name ?? "")
|
_name = State(initialValue: existing?.name ?? "")
|
||||||
_description = State(initialValue: existing?.deckDescription ?? "")
|
_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)
|
_category = State(initialValue: existing?.category)
|
||||||
_visibility = State(initialValue: DeckVisibility(rawValue: existing?.visibilityRaw ?? "private") ?? .private)
|
_visibility = State(initialValue: DeckVisibility(rawValue: existing?.visibilityRaw ?? "private") ?? .private)
|
||||||
|
_archived = State(initialValue: existing?.archivedAt != nil)
|
||||||
}
|
}
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
Form {
|
ZStack {
|
||||||
Section("Name") {
|
Form {
|
||||||
TextField("Deck-Name", text: $name)
|
if isCreate {
|
||||||
.textInputAutocapitalization(.sentences)
|
modePickerSection
|
||||||
}
|
|
||||||
|
|
||||||
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)
|
|
||||||
}
|
}
|
||||||
|
formSections
|
||||||
|
errorSection
|
||||||
}
|
}
|
||||||
|
.disabled(isSubmitting)
|
||||||
|
|
||||||
Section("Kategorie") {
|
if isSubmitting, activeMode != .manual {
|
||||||
Picker("Kategorie", selection: $category) {
|
GenerationOverlay(
|
||||||
Text("Keine").tag(DeckCategory?.none)
|
message: overlayMessage,
|
||||||
ForEach(DeckCategory.allCases, id: \.self) { cat in
|
onCancel: { generationTask?.cancel() }
|
||||||
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)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.navigationTitle(isCreate ? "Neues Deck" : "Deck bearbeiten")
|
.navigationTitle(navTitle)
|
||||||
#if os(iOS)
|
#if os(iOS)
|
||||||
.navigationBarTitleDisplayMode(.inline)
|
.navigationBarTitleDisplayMode(.inline)
|
||||||
#endif
|
#endif
|
||||||
.toolbar {
|
.toolbar { toolbar }
|
||||||
ToolbarItem(placement: .cancellationAction) {
|
.onChange(of: aiPhotoItems) { _, items in
|
||||||
Button("Abbrechen") { dismiss() }
|
guard !items.isEmpty else { return }
|
||||||
}
|
Task { await ingestPhotoItems(items) }
|
||||||
ToolbarItem(placement: .confirmationAction) {
|
|
||||||
Button(isCreate ? "Erstellen" : "Speichern") {
|
|
||||||
Task { await submit() }
|
|
||||||
}
|
|
||||||
.disabled(name.trimmingCharacters(in: .whitespaces).isEmpty || isSubmitting)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
.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 {
|
private var isCreate: Bool {
|
||||||
if case .create = mode { return true }
|
if case .create = mode { return true }
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
@ViewBuilder
|
private var activeMode: CreateMode {
|
||||||
private func colorSwatch(_ hex: String) -> some View {
|
isCreate ? createMode : .manual
|
||||||
let isSelected = color == hex
|
}
|
||||||
|
|
||||||
|
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()
|
Circle()
|
||||||
.fill(Color.swatchFromHex(hex))
|
.fill(Color.swatchFromHex(hex))
|
||||||
.frame(width: 36, height: 36)
|
.frame(width: 36, height: 36)
|
||||||
|
|
@ -127,51 +532,200 @@ struct DeckEditorView: View {
|
||||||
Circle()
|
Circle()
|
||||||
.stroke(isSelected ? CardsTheme.foreground : CardsTheme.border, lineWidth: isSelected ? 3 : 1)
|
.stroke(isSelected ? CardsTheme.foreground : CardsTheme.border, lineWidth: isSelected ? 3 : 1)
|
||||||
)
|
)
|
||||||
.onTapGesture { color = hex }
|
.onTapGesture(perform: onTap)
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private func submit() async {
|
// MARK: - AI text form
|
||||||
isSubmitting = true
|
|
||||||
errorMessage = nil
|
|
||||||
defer { isSubmitting = false }
|
|
||||||
let api = CardsAPI(auth: auth)
|
|
||||||
|
|
||||||
do {
|
private struct AITextFormSections: View {
|
||||||
switch mode {
|
@Binding var prompt: String
|
||||||
case .create:
|
|
||||||
let body = DeckCreateBody(
|
var body: some View {
|
||||||
name: name.trimmingCharacters(in: .whitespaces),
|
Section {
|
||||||
description: nonEmpty(description),
|
TextField(
|
||||||
color: color,
|
"z.B. Bodensee-Geographie, französische Verben",
|
||||||
category: category,
|
text: $prompt,
|
||||||
visibility: visibility
|
axis: .vertical
|
||||||
)
|
)
|
||||||
let deck = try await api.createDeck(body)
|
.lineLimit(3 ... 6)
|
||||||
onSaved(deck)
|
.textInputAutocapitalization(.sentences)
|
||||||
dismiss()
|
} header: {
|
||||||
case let .edit(deckId):
|
Text("Thema")
|
||||||
let body = DeckUpdateBody(
|
} footer: {
|
||||||
name: name.trimmingCharacters(in: .whitespaces),
|
Text("3–500 Zeichen. Je präziser, desto besser die Karten.")
|
||||||
description: nonEmpty(description),
|
}
|
||||||
color: color,
|
}
|
||||||
category: category,
|
}
|
||||||
visibility: visibility
|
|
||||||
)
|
// MARK: - AI media form
|
||||||
let deck = try await api.updateDeck(id: deckId, body: body)
|
|
||||||
onSaved(deck)
|
private struct AIMediaFormSections: View {
|
||||||
dismiss()
|
@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 {
|
} header: {
|
||||||
errorMessage = (error as? LocalizedError)?.errorDescription ?? String(describing: error)
|
Text("Quellen")
|
||||||
|
} footer: {
|
||||||
|
Text("Max. \(DeckEditorPresets.maxMediaFiles) Dateien. Bilder ≤ 10 MB, PDFs ≤ 30 MB.")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private func nonEmpty(_ s: String) -> String? {
|
@ViewBuilder
|
||||||
let trimmed = s.trimmingCharacters(in: .whitespaces)
|
private var mediaPickers: some View {
|
||||||
return trimmed.isEmpty ? nil : trimmed
|
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 {
|
extension Color {
|
||||||
static func swatchFromHex(_ hex: String) -> Color {
|
static func swatchFromHex(_ hex: String) -> Color {
|
||||||
var trimmed = hex.trimmingCharacters(in: .whitespacesAndNewlines)
|
var trimmed = hex.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
|
|
@ -179,9 +733,9 @@ extension Color {
|
||||||
guard let rgb = UInt32(trimmed, radix: 16) else {
|
guard let rgb = UInt32(trimmed, radix: 16) else {
|
||||||
return CardsTheme.primary
|
return CardsTheme.primary
|
||||||
}
|
}
|
||||||
let r = Double((rgb >> 16) & 0xFF) / 255.0
|
let red = Double((rgb >> 16) & 0xFF) / 255.0
|
||||||
let g = Double((rgb >> 8) & 0xFF) / 255.0
|
let green = Double((rgb >> 8) & 0xFF) / 255.0
|
||||||
let b = Double(rgb & 0xFF) / 255.0
|
let blue = Double(rgb & 0xFF) / 255.0
|
||||||
return Color(red: r, green: g, blue: b)
|
return Color(red: red, green: green, blue: blue)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
474
Sources/Features/Marketplace/MarketplacePublishView.swift
Normal file
474
Sources/Features/Marketplace/MarketplacePublishView.swift
Normal file
|
|
@ -0,0 +1,474 @@
|
||||||
|
import ManaCore
|
||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
// swiftlint:disable file_length
|
||||||
|
// swiftlint:disable type_body_length
|
||||||
|
|
||||||
|
/// Publish eines privaten Decks in den Cardecky-Marketplace.
|
||||||
|
///
|
||||||
|
/// Modi: Erst-Publish (mit Author-Setup + Init + Publish 1.0.0) oder
|
||||||
|
/// neue Version eines existierenden Marketplace-Decks (Auto-Semver-Bump).
|
||||||
|
/// Image-Occlusion- und Audio-Front-Karten werden übersprungen — der
|
||||||
|
/// Server hat heute keinen Marketplace-Media-Re-Upload-Flow.
|
||||||
|
///
|
||||||
|
/// `type_body_length` ist bewusst übersprungen — Publish-Flow ist eine
|
||||||
|
/// zusammenhängende State-Maschine (Author → Init → Publish).
|
||||||
|
struct MarketplacePublishView: View {
|
||||||
|
enum PublishMode: Hashable {
|
||||||
|
case firstPublish
|
||||||
|
case newVersion(slug: String)
|
||||||
|
}
|
||||||
|
|
||||||
|
let privateDeck: CachedDeck
|
||||||
|
let onPublished: (MarketplacePublishResponse) -> Void
|
||||||
|
|
||||||
|
@Environment(AuthClient.self) private var auth
|
||||||
|
@Environment(\.dismiss) private var dismiss
|
||||||
|
|
||||||
|
// Publish-Mode
|
||||||
|
@State private var publishMode: PublishMode = .firstPublish
|
||||||
|
@State private var ownedDecks: [OwnedMarketplaceDeck] = []
|
||||||
|
@State private var selectedExistingSlug: String?
|
||||||
|
|
||||||
|
// Author-Profil-State
|
||||||
|
@State private var hasAuthor: Bool?
|
||||||
|
@State private var authorSlug: String = ""
|
||||||
|
@State private var authorDisplayName: String = ""
|
||||||
|
@State private var authorBio: String = ""
|
||||||
|
@State private var authorPseudonym: Bool = false
|
||||||
|
|
||||||
|
// Deck-Metadaten
|
||||||
|
@State private var slug: String = ""
|
||||||
|
@State private var title: String = ""
|
||||||
|
@State private var deckDescription: String = ""
|
||||||
|
@State private var language: GenerationLanguage = .de
|
||||||
|
@State private var license: MarketplaceLicense = .personalUse
|
||||||
|
@State private var priceCredits: Int = 0
|
||||||
|
@State private var category: DeckCategory?
|
||||||
|
|
||||||
|
// Version-Metadaten
|
||||||
|
@State private var semver: String = "1.0.0"
|
||||||
|
@State private var changelog: String = ""
|
||||||
|
|
||||||
|
// Submit-State
|
||||||
|
@State private var isSubmitting = false
|
||||||
|
@State private var errorMessage: String?
|
||||||
|
@State private var result: MarketplacePublishResponse?
|
||||||
|
@State private var skippedCardCount: Int = 0
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
Form {
|
||||||
|
if !ownedDecks.isEmpty {
|
||||||
|
publishModeSection
|
||||||
|
}
|
||||||
|
if isFirstPublish, hasAuthor == false {
|
||||||
|
authorSection
|
||||||
|
}
|
||||||
|
if isFirstPublish {
|
||||||
|
deckMetadataSection
|
||||||
|
licenseSection
|
||||||
|
categorySection
|
||||||
|
} else if let existing = currentExistingDeck {
|
||||||
|
existingDeckInfoSection(deck: existing)
|
||||||
|
}
|
||||||
|
versionSection
|
||||||
|
if skippedCardCount > 0 {
|
||||||
|
skippedNoteSection
|
||||||
|
}
|
||||||
|
if let errorMessage {
|
||||||
|
Section {
|
||||||
|
Text(errorMessage)
|
||||||
|
.font(.footnote)
|
||||||
|
.foregroundStyle(CardsTheme.error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.disabled(isSubmitting)
|
||||||
|
.navigationTitle("Im Marketplace veröffentlichen")
|
||||||
|
#if os(iOS)
|
||||||
|
.navigationBarTitleDisplayMode(.inline)
|
||||||
|
#endif
|
||||||
|
.toolbar {
|
||||||
|
ToolbarItem(placement: .cancellationAction) {
|
||||||
|
Button("Abbrechen") { dismiss() }
|
||||||
|
}
|
||||||
|
ToolbarItem(placement: .confirmationAction) {
|
||||||
|
Button("Veröffentlichen") { Task { await submit() } }
|
||||||
|
.disabled(!canSubmit || isSubmitting)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.overlay {
|
||||||
|
if isSubmitting {
|
||||||
|
publishProgressOverlay
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.alert(item: $result) { response in
|
||||||
|
Alert(
|
||||||
|
title: Text("Veröffentlicht: \(response.deck.title)"),
|
||||||
|
message: Text(alertMessage(for: response)),
|
||||||
|
dismissButton: .default(Text("OK")) {
|
||||||
|
onPublished(response)
|
||||||
|
dismiss()
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
.task {
|
||||||
|
await prefill()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private var isFirstPublish: Bool {
|
||||||
|
if case .firstPublish = publishMode { return true }
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
private var currentExistingDeck: OwnedMarketplaceDeck? {
|
||||||
|
guard let slug = selectedExistingSlug else { return nil }
|
||||||
|
return ownedDecks.first { $0.slug == slug }
|
||||||
|
}
|
||||||
|
|
||||||
|
private var publishModeSection: some View {
|
||||||
|
Section {
|
||||||
|
Picker("Modus", selection: $publishMode) {
|
||||||
|
Text("Neues Marketplace-Deck").tag(PublishMode.firstPublish)
|
||||||
|
ForEach(ownedDecks) { deck in
|
||||||
|
Text("Neue Version: \(deck.title)")
|
||||||
|
.tag(PublishMode.newVersion(slug: deck.slug))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.pickerStyle(.menu)
|
||||||
|
.onChange(of: publishMode) { _, newMode in
|
||||||
|
applyPublishMode(newMode)
|
||||||
|
}
|
||||||
|
} header: {
|
||||||
|
Text("Veröffentlichungs-Modus")
|
||||||
|
} footer: {
|
||||||
|
Text("Du hast schon Decks im Marketplace. Wähle eine, um eine neue Version zu publishen.")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func existingDeckInfoSection(deck: OwnedMarketplaceDeck) -> some View {
|
||||||
|
Section {
|
||||||
|
LabeledContent("Slug", value: deck.slug)
|
||||||
|
LabeledContent("Titel", value: deck.title)
|
||||||
|
if let latest = deck.latestVersion {
|
||||||
|
LabeledContent("Aktuelle Version", value: "v\(latest.semver) · \(latest.cardCount) Karten")
|
||||||
|
} else {
|
||||||
|
LabeledContent("Aktuelle Version", value: "—")
|
||||||
|
}
|
||||||
|
} header: {
|
||||||
|
Text("Bestehendes Deck")
|
||||||
|
} footer: {
|
||||||
|
Text("Metadaten ändern: Marketplace-Webansicht → Deck → Bearbeiten.")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private var authorSection: some View {
|
||||||
|
Section {
|
||||||
|
TextField("Author-Slug (URL)", text: $authorSlug)
|
||||||
|
.textInputAutocapitalization(.never)
|
||||||
|
.autocorrectionDisabled(true)
|
||||||
|
TextField("Anzeigename", text: $authorDisplayName)
|
||||||
|
TextField("Bio (optional)", text: $authorBio, axis: .vertical)
|
||||||
|
.lineLimit(2 ... 4)
|
||||||
|
Toggle("Pseudonym-Modus", isOn: $authorPseudonym)
|
||||||
|
} header: {
|
||||||
|
Text("Author-Profil anlegen")
|
||||||
|
} footer: {
|
||||||
|
Text("Pflicht-Schritt vor dem ersten Marketplace-Deck. Slug erscheint in Marketplace-URLs.")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private var deckMetadataSection: some View {
|
||||||
|
Section {
|
||||||
|
TextField("Slug (URL)", text: $slug)
|
||||||
|
.textInputAutocapitalization(.never)
|
||||||
|
.autocorrectionDisabled(true)
|
||||||
|
TextField("Titel", text: $title)
|
||||||
|
.textInputAutocapitalization(.sentences)
|
||||||
|
TextField("Beschreibung", text: $deckDescription, axis: .vertical)
|
||||||
|
.lineLimit(2 ... 6)
|
||||||
|
Picker("Sprache", selection: $language) {
|
||||||
|
ForEach(GenerationLanguage.allCases, id: \.self) { lang in
|
||||||
|
Text(lang.label).tag(lang)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.pickerStyle(.segmented)
|
||||||
|
} header: {
|
||||||
|
Text("Deck-Metadaten")
|
||||||
|
} footer: {
|
||||||
|
Text("Der Slug wird Teil der Marketplace-URL: cardecky.mana.how/d/<slug>.")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private var licenseSection: some View {
|
||||||
|
Section("Lizenz") {
|
||||||
|
Picker("Lizenz", selection: $license) {
|
||||||
|
ForEach(MarketplaceLicense.allCases, id: \.self) { lic in
|
||||||
|
Text(lic.label).tag(lic)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if license == .proOnly {
|
||||||
|
Stepper(value: $priceCredits, in: 0 ... 100_000, step: 10) {
|
||||||
|
Text("Preis: \(priceCredits) Credits")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private var categorySection: some View {
|
||||||
|
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))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private var versionSection: some View {
|
||||||
|
Section {
|
||||||
|
TextField("SemVer (z.B. 1.0.0)", text: $semver)
|
||||||
|
.textInputAutocapitalization(.never)
|
||||||
|
.autocorrectionDisabled(true)
|
||||||
|
.keyboardType(.numbersAndPunctuation)
|
||||||
|
TextField("Changelog (optional)", text: $changelog, axis: .vertical)
|
||||||
|
.lineLimit(2 ... 4)
|
||||||
|
} header: {
|
||||||
|
Text("Version")
|
||||||
|
} footer: {
|
||||||
|
Text("Erst-Publish: 1.0.0. Spätere Versionen müssen semver-größer sein.")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private var skippedNoteSection: some View {
|
||||||
|
Section {
|
||||||
|
Label(
|
||||||
|
"""
|
||||||
|
\(skippedCardCount) Karten werden übersprungen — Bild-\
|
||||||
|
Verdeckung und Audio brauchen Marketplace-Media-Upload.
|
||||||
|
""",
|
||||||
|
systemImage: "info.circle"
|
||||||
|
)
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundStyle(CardsTheme.mutedForeground)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private var publishProgressOverlay: some View {
|
||||||
|
ZStack {
|
||||||
|
Color.black.opacity(0.55).ignoresSafeArea()
|
||||||
|
VStack(spacing: 12) {
|
||||||
|
ProgressView().controlSize(.large).tint(CardsTheme.primary)
|
||||||
|
Text("Wird veröffentlicht …")
|
||||||
|
.font(.headline)
|
||||||
|
.foregroundStyle(CardsTheme.foreground)
|
||||||
|
Text("AI-Moderation läuft — kann ein paar Sekunden dauern.")
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundStyle(CardsTheme.mutedForeground)
|
||||||
|
.multilineTextAlignment(.center)
|
||||||
|
}
|
||||||
|
.padding(24)
|
||||||
|
.frame(maxWidth: 320)
|
||||||
|
.background(.regularMaterial, in: RoundedRectangle(cornerRadius: 16, style: .continuous))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private var canSubmit: Bool {
|
||||||
|
let semverOK = semver.range(of: "^\\d+\\.\\d+\\.\\d+$", options: .regularExpression) != nil
|
||||||
|
guard semverOK else { return false }
|
||||||
|
switch publishMode {
|
||||||
|
case .firstPublish:
|
||||||
|
if hasAuthor == false {
|
||||||
|
guard authorDisplayName.trimmed.count >= 1 else { return false }
|
||||||
|
guard authorSlug.trimmed.count >= 3 else { return false }
|
||||||
|
}
|
||||||
|
return slug.trimmed.count >= 3 && !title.trimmed.isEmpty
|
||||||
|
case .newVersion:
|
||||||
|
return selectedExistingSlug != nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func prefill() async {
|
||||||
|
title = privateDeck.name
|
||||||
|
deckDescription = privateDeck.deckDescription ?? ""
|
||||||
|
category = privateDeck.category
|
||||||
|
slug = slugify(privateDeck.name)
|
||||||
|
let api = CardsAPI(auth: auth)
|
||||||
|
async let authorState = api.myAuthor()
|
||||||
|
async let ownedState = api.myMarketplaceDecks()
|
||||||
|
do {
|
||||||
|
hasAuthor = try await authorState
|
||||||
|
} catch {
|
||||||
|
hasAuthor = false
|
||||||
|
errorMessage = "Author-Profil konnte nicht geladen werden: \(error.localizedDescription)"
|
||||||
|
}
|
||||||
|
ownedDecks = await (try? ownedState) ?? []
|
||||||
|
}
|
||||||
|
|
||||||
|
/// State-Übergang beim Wechsel des Publish-Modus.
|
||||||
|
/// - Erst-Publish: Slug aus dem privaten Deck-Namen, Semver 1.0.0.
|
||||||
|
/// - Neue Version: Slug-Feld unbenutzt (Server kennt Slug),
|
||||||
|
/// Semver-Default = Bump der aktuellen Version.
|
||||||
|
private func applyPublishMode(_ mode: PublishMode) {
|
||||||
|
switch mode {
|
||||||
|
case .firstPublish:
|
||||||
|
selectedExistingSlug = nil
|
||||||
|
semver = "1.0.0"
|
||||||
|
case let .newVersion(existingSlug):
|
||||||
|
selectedExistingSlug = existingSlug
|
||||||
|
if let latest = ownedDecks.first(where: { $0.slug == existingSlug })?.latestVersion {
|
||||||
|
semver = bumpMinor(latest.semver)
|
||||||
|
} else {
|
||||||
|
semver = "1.0.0"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `1.4.2` → `1.5.0`. Bei unparsbarem Input bleibt 1.0.0 als Default.
|
||||||
|
private func bumpMinor(_ version: String) -> String {
|
||||||
|
let parts = version.split(separator: ".")
|
||||||
|
guard parts.count == 3,
|
||||||
|
let major = Int(parts[0]),
|
||||||
|
let minor = Int(parts[1])
|
||||||
|
else { return "1.0.0" }
|
||||||
|
return "\(major).\(minor + 1).0"
|
||||||
|
}
|
||||||
|
|
||||||
|
private func submit() async {
|
||||||
|
isSubmitting = true
|
||||||
|
errorMessage = nil
|
||||||
|
defer { isSubmitting = false }
|
||||||
|
let api = CardsAPI(auth: auth)
|
||||||
|
do {
|
||||||
|
let targetSlug = try await prepareTargetSlug(api: api)
|
||||||
|
try await publishCards(toSlug: targetSlug, api: api)
|
||||||
|
} catch let error as AuthError {
|
||||||
|
errorMessage = mapPublishError(error)
|
||||||
|
} catch {
|
||||||
|
errorMessage = error.localizedDescription
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Erst-Publish-Pfad: Author-Profil + Marketplace-Deck-Init.
|
||||||
|
/// Liefert den Slug auf den `publishCards` veröffentlicht.
|
||||||
|
private func prepareTargetSlug(api: CardsAPI) async throws -> String {
|
||||||
|
switch publishMode {
|
||||||
|
case .firstPublish:
|
||||||
|
if hasAuthor == false {
|
||||||
|
try await api.upsertAuthor(AuthorUpsertBody(
|
||||||
|
slug: authorSlug.trimmed,
|
||||||
|
displayName: authorDisplayName.trimmed,
|
||||||
|
bio: authorBio.trimmed.isEmpty ? nil : authorBio.trimmed,
|
||||||
|
avatarUrl: nil,
|
||||||
|
pseudonym: authorPseudonym
|
||||||
|
))
|
||||||
|
hasAuthor = true
|
||||||
|
}
|
||||||
|
_ = try await api.initMarketplaceDeck(MarketplaceDeckInitBody(
|
||||||
|
slug: slug.trimmed,
|
||||||
|
title: title.trimmed,
|
||||||
|
description: deckDescription.trimmed.isEmpty ? nil : deckDescription.trimmed,
|
||||||
|
language: language.rawValue,
|
||||||
|
license: license.rawValue,
|
||||||
|
priceCredits: license == .proOnly ? priceCredits : 0,
|
||||||
|
category: category
|
||||||
|
))
|
||||||
|
return slug.trimmed
|
||||||
|
case let .newVersion(existingSlug):
|
||||||
|
return existingSlug
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Lädt alle Karten des privaten Decks, konvertiert in Marketplace-
|
||||||
|
/// Format und veröffentlicht die neue Version.
|
||||||
|
private func publishCards(toSlug targetSlug: String, api: CardsAPI) async throws {
|
||||||
|
let cards = try await api.listCards(deckId: privateDeck.id)
|
||||||
|
let converted = cards.compactMap(MarketplaceCardConverter.convert)
|
||||||
|
skippedCardCount = cards.count - converted.count
|
||||||
|
guard !converted.isEmpty else {
|
||||||
|
errorMessage = "Keine Karten kompatibel mit dem Marketplace-Format."
|
||||||
|
return
|
||||||
|
}
|
||||||
|
result = try await api.publishMarketplaceVersion(
|
||||||
|
slug: targetSlug,
|
||||||
|
body: MarketplacePublishBody(
|
||||||
|
semver: semver.trimmed,
|
||||||
|
changelog: changelog.trimmed.isEmpty ? nil : changelog.trimmed,
|
||||||
|
cards: converted
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func mapPublishError(_ error: AuthError) -> String {
|
||||||
|
if case let .serverError(status, _, message) = error {
|
||||||
|
switch status {
|
||||||
|
case 409:
|
||||||
|
if let message, message.contains("slug_taken") {
|
||||||
|
return "Dieser Slug ist schon vergeben. Bitte einen anderen wählen."
|
||||||
|
}
|
||||||
|
return message ?? "Konflikt — Version-Bump nötig?"
|
||||||
|
case 403:
|
||||||
|
if let message, message.contains("moderation_block") {
|
||||||
|
return "AI-Moderation hat den Inhalt blockiert."
|
||||||
|
}
|
||||||
|
return message ?? "Aktion nicht erlaubt."
|
||||||
|
case 422:
|
||||||
|
return message ?? "Eingabe ungültig."
|
||||||
|
default:
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return error.errorDescription ?? "Veröffentlichen fehlgeschlagen."
|
||||||
|
}
|
||||||
|
|
||||||
|
private func alertMessage(for response: MarketplacePublishResponse) -> String {
|
||||||
|
let parts = [
|
||||||
|
"Version \(response.version.semver)",
|
||||||
|
"\(response.version.cardCount) Karten",
|
||||||
|
skippedCardCount > 0 ? "\(skippedCardCount) übersprungen" : nil,
|
||||||
|
"Moderation: \(response.moderation.verdict)"
|
||||||
|
].compactMap(\.self)
|
||||||
|
return parts.joined(separator: " · ")
|
||||||
|
}
|
||||||
|
|
||||||
|
private func slugify(_ input: String) -> String {
|
||||||
|
let allowed = CharacterSet(charactersIn: "abcdefghijklmnopqrstuvwxyz0123456789-")
|
||||||
|
let lowered = input
|
||||||
|
.folding(options: .diacriticInsensitive, locale: .current)
|
||||||
|
.lowercased()
|
||||||
|
var result = ""
|
||||||
|
for scalar in lowered.unicodeScalars {
|
||||||
|
if allowed.contains(scalar) {
|
||||||
|
result.unicodeScalars.append(scalar)
|
||||||
|
} else {
|
||||||
|
result.append("-")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
while result.hasPrefix("-") {
|
||||||
|
result.removeFirst()
|
||||||
|
}
|
||||||
|
while result.hasSuffix("-") {
|
||||||
|
result.removeLast()
|
||||||
|
}
|
||||||
|
while result.contains("--") {
|
||||||
|
result = result.replacingOccurrences(of: "--", with: "-")
|
||||||
|
}
|
||||||
|
return String(result.prefix(60))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// swiftlint:enable type_body_length
|
||||||
|
|
||||||
|
private extension String {
|
||||||
|
var trimmed: String {
|
||||||
|
trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
extension MarketplacePublishResponse: Identifiable {
|
||||||
|
var id: String {
|
||||||
|
version.id
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -1,13 +1,18 @@
|
||||||
|
import ManaAuthUI
|
||||||
import ManaCore
|
import ManaCore
|
||||||
import SwiftData
|
import SwiftData
|
||||||
import SwiftUI
|
import SwiftUI
|
||||||
|
|
||||||
|
// swiftlint:disable type_body_length
|
||||||
|
|
||||||
/// Detail-View für ein Public-Deck. Subscribe-Button löst Auto-Fork
|
/// Detail-View für ein Public-Deck. Subscribe-Button löst Auto-Fork
|
||||||
/// serverseitig aus und navigiert anschließend zur eigenen Deck-Detail.
|
/// serverseitig aus und navigiert anschließend zur eigenen Deck-Detail.
|
||||||
|
/// Toolbar-Menu („…") hostet Report + Block-Author (App-Review-Pflicht).
|
||||||
struct PublicDeckView: View {
|
struct PublicDeckView: View {
|
||||||
let slug: String
|
let slug: String
|
||||||
|
|
||||||
@Environment(AuthClient.self) private var auth
|
@Environment(AuthClient.self) private var auth
|
||||||
|
@Environment(ManaAuthGate.self) private var authGate
|
||||||
@Environment(\.modelContext) private var context
|
@Environment(\.modelContext) private var context
|
||||||
@State private var detail: PublicDeckDetail?
|
@State private var detail: PublicDeckDetail?
|
||||||
@State private var isLoading = false
|
@State private var isLoading = false
|
||||||
|
|
@ -15,6 +20,11 @@ struct PublicDeckView: View {
|
||||||
@State private var errorMessage: String?
|
@State private var errorMessage: String?
|
||||||
@State private var subscribed: SubscribeResponse?
|
@State private var subscribed: SubscribeResponse?
|
||||||
|
|
||||||
|
// Moderation-State
|
||||||
|
@State private var showReportSheet = false
|
||||||
|
@State private var showBlockConfirm = false
|
||||||
|
@State private var moderationToast: String?
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
ZStack {
|
ZStack {
|
||||||
CardsTheme.background.ignoresSafeArea()
|
CardsTheme.background.ignoresSafeArea()
|
||||||
|
|
@ -24,9 +34,69 @@ struct PublicDeckView: View {
|
||||||
#if os(iOS)
|
#if os(iOS)
|
||||||
.navigationBarTitleDisplayMode(.inline)
|
.navigationBarTitleDisplayMode(.inline)
|
||||||
#endif
|
#endif
|
||||||
|
.toolbar {
|
||||||
|
if detail != nil {
|
||||||
|
ToolbarItem(placement: .topBarTrailing) {
|
||||||
|
moderationMenu
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
.task(id: slug) {
|
.task(id: slug) {
|
||||||
await load()
|
await load()
|
||||||
}
|
}
|
||||||
|
.sheet(isPresented: $showReportSheet) {
|
||||||
|
NavigationStack {
|
||||||
|
ReportDeckSheet(slug: slug) { message in
|
||||||
|
moderationToast = message
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.confirmationDialog(
|
||||||
|
"Author blockieren?",
|
||||||
|
isPresented: $showBlockConfirm,
|
||||||
|
titleVisibility: .visible,
|
||||||
|
presenting: detail?.owner
|
||||||
|
) { owner in
|
||||||
|
Button("\(owner.displayName) blockieren", role: .destructive) {
|
||||||
|
Task { await blockAuthor(slug: owner.slug, name: owner.displayName) }
|
||||||
|
}
|
||||||
|
Button("Abbrechen", role: .cancel) {}
|
||||||
|
} message: { _ in
|
||||||
|
Text("Decks dieses Authors erscheinen für dich nicht mehr im Marketplace.")
|
||||||
|
}
|
||||||
|
.overlay(alignment: .top) {
|
||||||
|
if let toast = moderationToast {
|
||||||
|
ToastBanner(text: toast)
|
||||||
|
.padding(.top, 8)
|
||||||
|
.task {
|
||||||
|
try? await Task.sleep(for: .seconds(3))
|
||||||
|
moderationToast = nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private var moderationMenu: some View {
|
||||||
|
Menu {
|
||||||
|
Button {
|
||||||
|
authGate.require(reason: "marketplace-report") {
|
||||||
|
showReportSheet = true
|
||||||
|
}
|
||||||
|
} label: {
|
||||||
|
Label("Deck melden …", systemImage: "flag")
|
||||||
|
}
|
||||||
|
if let owner = detail?.owner {
|
||||||
|
Button(role: .destructive) {
|
||||||
|
authGate.require(reason: "marketplace-block") {
|
||||||
|
showBlockConfirm = true
|
||||||
|
}
|
||||||
|
} label: {
|
||||||
|
Label("\(owner.displayName) blockieren", systemImage: "hand.raised")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} label: {
|
||||||
|
Image(systemName: "ellipsis.circle")
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ViewBuilder
|
@ViewBuilder
|
||||||
|
|
@ -122,7 +192,6 @@ struct PublicDeckView: View {
|
||||||
.padding(.horizontal, 16)
|
.padding(.horizontal, 16)
|
||||||
}
|
}
|
||||||
|
|
||||||
@ViewBuilder
|
|
||||||
private func subscribeSection(detail: PublicDeckDetail) -> some View {
|
private func subscribeSection(detail: PublicDeckDetail) -> some View {
|
||||||
VStack(spacing: 12) {
|
VStack(spacing: 12) {
|
||||||
if let subscribed {
|
if let subscribed {
|
||||||
|
|
@ -147,7 +216,9 @@ struct PublicDeckView: View {
|
||||||
.foregroundStyle(CardsTheme.mutedForeground)
|
.foregroundStyle(CardsTheme.mutedForeground)
|
||||||
} else {
|
} else {
|
||||||
Button {
|
Button {
|
||||||
Task { await subscribe(detail: detail) }
|
authGate.require(reason: "marketplace-subscribe") {
|
||||||
|
Task { await subscribe(detail: detail) }
|
||||||
|
}
|
||||||
} label: {
|
} label: {
|
||||||
HStack {
|
HStack {
|
||||||
if isSubscribing {
|
if isSubscribing {
|
||||||
|
|
@ -156,8 +227,8 @@ struct PublicDeckView: View {
|
||||||
.tint(CardsTheme.primaryForeground)
|
.tint(CardsTheme.primaryForeground)
|
||||||
}
|
}
|
||||||
Text(detail.deck.priceCredits > 0
|
Text(detail.deck.priceCredits > 0
|
||||||
? "Abonnieren (\(detail.deck.priceCredits) Credits)"
|
? "Abonnieren (\(detail.deck.priceCredits) Credits)"
|
||||||
: "Abonnieren")
|
: "Abonnieren")
|
||||||
.fontWeight(.semibold)
|
.fontWeight(.semibold)
|
||||||
}
|
}
|
||||||
.frame(maxWidth: .infinity)
|
.frame(maxWidth: .infinity)
|
||||||
|
|
@ -183,7 +254,17 @@ struct PublicDeckView: View {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private func subscribe(detail: PublicDeckDetail) async {
|
private func blockAuthor(slug: String, name: String) async {
|
||||||
|
let api = CardsAPI(auth: auth)
|
||||||
|
do {
|
||||||
|
try await api.blockAuthor(slug: slug)
|
||||||
|
moderationToast = "\(name) blockiert."
|
||||||
|
} catch {
|
||||||
|
moderationToast = "Blockieren fehlgeschlagen: \(error.localizedDescription)"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func subscribe(detail _: PublicDeckDetail) async {
|
||||||
isSubscribing = true
|
isSubscribing = true
|
||||||
errorMessage = nil
|
errorMessage = nil
|
||||||
defer { isSubscribing = false }
|
defer { isSubscribing = false }
|
||||||
|
|
@ -199,3 +280,5 @@ struct PublicDeckView: View {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// swiftlint:enable type_body_length
|
||||||
|
|
|
||||||
109
Sources/Features/Marketplace/ReportDeckSheet.swift
Normal file
109
Sources/Features/Marketplace/ReportDeckSheet.swift
Normal file
|
|
@ -0,0 +1,109 @@
|
||||||
|
import ManaCore
|
||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
/// Report-Form für ein Marketplace-Deck — Pflicht-Komponente nach
|
||||||
|
/// App-Store-Guideline 5.1.1(v) (Report-Mechanismus für UGC).
|
||||||
|
///
|
||||||
|
/// Owned-State (Kategorie, Message, Submit-Status). Bei Erfolg schließt
|
||||||
|
/// das Sheet und ruft `onCompleted` mit einer Toast-Message auf.
|
||||||
|
struct ReportDeckSheet: View {
|
||||||
|
let slug: String
|
||||||
|
let onCompleted: (String) -> Void
|
||||||
|
|
||||||
|
@Environment(AuthClient.self) private var auth
|
||||||
|
@Environment(\.dismiss) private var dismiss
|
||||||
|
|
||||||
|
@State private var category: ReportCategory = .spam
|
||||||
|
@State private var message: String = ""
|
||||||
|
@State private var isSubmitting = false
|
||||||
|
@State private var errorMessage: String?
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
Form {
|
||||||
|
Section("Grund") {
|
||||||
|
Picker("Grund", selection: $category) {
|
||||||
|
ForEach(ReportCategory.allCases, id: \.self) { cat in
|
||||||
|
Text(cat.label).tag(cat)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.pickerStyle(.inline)
|
||||||
|
.labelsHidden()
|
||||||
|
}
|
||||||
|
|
||||||
|
Section {
|
||||||
|
TextField("Optional: Details", text: $message, axis: .vertical)
|
||||||
|
.lineLimit(3 ... 6)
|
||||||
|
.textInputAutocapitalization(.sentences)
|
||||||
|
} header: {
|
||||||
|
Text("Beschreibung")
|
||||||
|
} footer: {
|
||||||
|
Text("Wir prüfen jede Meldung. Hass und Rechtsverletzungen werden bevorzugt behandelt.")
|
||||||
|
}
|
||||||
|
|
||||||
|
if let errorMessage {
|
||||||
|
Section {
|
||||||
|
Text(errorMessage)
|
||||||
|
.font(.footnote)
|
||||||
|
.foregroundStyle(CardsTheme.error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.disabled(isSubmitting)
|
||||||
|
.navigationTitle("Deck melden")
|
||||||
|
#if os(iOS)
|
||||||
|
.navigationBarTitleDisplayMode(.inline)
|
||||||
|
#endif
|
||||||
|
.toolbar {
|
||||||
|
ToolbarItem(placement: .cancellationAction) {
|
||||||
|
Button("Abbrechen") { dismiss() }
|
||||||
|
}
|
||||||
|
ToolbarItem(placement: .confirmationAction) {
|
||||||
|
Button("Senden") { Task { await submit() } }
|
||||||
|
.disabled(isSubmitting)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func submit() async {
|
||||||
|
isSubmitting = true
|
||||||
|
errorMessage = nil
|
||||||
|
defer { isSubmitting = false }
|
||||||
|
let api = CardsAPI(auth: auth)
|
||||||
|
let trimmed = message.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
|
do {
|
||||||
|
let response = try await api.reportDeck(
|
||||||
|
slug: slug,
|
||||||
|
body: ReportDeckBody(
|
||||||
|
category: category,
|
||||||
|
body: trimmed.isEmpty ? nil : trimmed,
|
||||||
|
versionId: nil,
|
||||||
|
cardContentHash: nil
|
||||||
|
)
|
||||||
|
)
|
||||||
|
let toast = response.alreadyReported
|
||||||
|
? "Du hast dieses Deck bereits gemeldet."
|
||||||
|
: "Meldung gesendet. Danke fürs Aufpassen."
|
||||||
|
onCompleted(toast)
|
||||||
|
dismiss()
|
||||||
|
} catch {
|
||||||
|
errorMessage = (error as? LocalizedError)?.errorDescription ?? String(describing: error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Schlichtes Top-Banner für kurze Bestätigungen.
|
||||||
|
struct ToastBanner: View {
|
||||||
|
let text: String
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
Text(text)
|
||||||
|
.font(.subheadline.weight(.medium))
|
||||||
|
.foregroundStyle(CardsTheme.foreground)
|
||||||
|
.padding(.horizontal, 14)
|
||||||
|
.padding(.vertical, 10)
|
||||||
|
.background(.regularMaterial, in: Capsule())
|
||||||
|
.overlay(Capsule().stroke(CardsTheme.border, lineWidth: 0.5))
|
||||||
|
.padding(.horizontal, 16)
|
||||||
|
.transition(.move(edge: .top).combined(with: .opacity))
|
||||||
|
}
|
||||||
|
}
|
||||||
89
Sources/Features/Settings/BlockedAuthorsView.swift
Normal file
89
Sources/Features/Settings/BlockedAuthorsView.swift
Normal file
|
|
@ -0,0 +1,89 @@
|
||||||
|
import ManaCore
|
||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
/// Liste der vom User blockierten Marketplace-Authors. Entblock-Action
|
||||||
|
/// per Swipe — analog zur iOS-Mail-Inbox.
|
||||||
|
///
|
||||||
|
/// App-Store-Guideline 5.1.1(v) verlangt: Block-Mechanismus für UGC
|
||||||
|
/// muss verwaltbar sein. Diese View ist der Verwaltungs-Endpunkt.
|
||||||
|
struct BlockedAuthorsView: View {
|
||||||
|
@Environment(AuthClient.self) private var auth
|
||||||
|
|
||||||
|
@State private var blocks: [BlockEntry] = []
|
||||||
|
@State private var isLoading = false
|
||||||
|
@State private var errorMessage: String?
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
ZStack {
|
||||||
|
CardsTheme.background.ignoresSafeArea()
|
||||||
|
content
|
||||||
|
}
|
||||||
|
.navigationTitle("Blockierte Authors")
|
||||||
|
#if os(iOS)
|
||||||
|
.navigationBarTitleDisplayMode(.inline)
|
||||||
|
#endif
|
||||||
|
.task { await load() }
|
||||||
|
.refreshable { await load() }
|
||||||
|
}
|
||||||
|
|
||||||
|
@ViewBuilder
|
||||||
|
private var content: some View {
|
||||||
|
if isLoading, blocks.isEmpty {
|
||||||
|
ProgressView().tint(CardsTheme.primary)
|
||||||
|
} else if blocks.isEmpty {
|
||||||
|
ContentUnavailableView(
|
||||||
|
"Keine blockierten Authors",
|
||||||
|
systemImage: "hand.raised.slash",
|
||||||
|
description: Text("Blockiere Authors über das Menü oben rechts auf Marketplace-Decks.")
|
||||||
|
)
|
||||||
|
.foregroundStyle(CardsTheme.mutedForeground)
|
||||||
|
} else {
|
||||||
|
List {
|
||||||
|
ForEach(blocks) { block in
|
||||||
|
VStack(alignment: .leading, spacing: 2) {
|
||||||
|
Text(block.displayName)
|
||||||
|
.font(.subheadline.weight(.semibold))
|
||||||
|
Text("@\(block.authorSlug)")
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundStyle(CardsTheme.mutedForeground)
|
||||||
|
}
|
||||||
|
.swipeActions {
|
||||||
|
Button("Entblocken") {
|
||||||
|
Task { await unblock(block) }
|
||||||
|
}
|
||||||
|
.tint(CardsTheme.primary)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.listStyle(.plain)
|
||||||
|
.scrollContentBackground(.hidden)
|
||||||
|
if let errorMessage {
|
||||||
|
Text(errorMessage)
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundStyle(CardsTheme.error)
|
||||||
|
.padding(.horizontal, 16)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func load() async {
|
||||||
|
isLoading = true
|
||||||
|
defer { isLoading = false }
|
||||||
|
let api = CardsAPI(auth: auth)
|
||||||
|
do {
|
||||||
|
blocks = try await api.myBlocks()
|
||||||
|
} catch {
|
||||||
|
errorMessage = (error as? LocalizedError)?.errorDescription ?? String(describing: error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func unblock(_ block: BlockEntry) async {
|
||||||
|
let api = CardsAPI(auth: auth)
|
||||||
|
do {
|
||||||
|
try await api.unblockAuthor(slug: block.authorSlug)
|
||||||
|
blocks.removeAll { $0.id == block.id }
|
||||||
|
} catch {
|
||||||
|
errorMessage = (error as? LocalizedError)?.errorDescription ?? String(describing: error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -40,10 +40,20 @@ struct SettingsView: View {
|
||||||
}
|
}
|
||||||
|
|
||||||
if notifications.authorization == .denied {
|
if notifications.authorization == .denied {
|
||||||
Label("Benachrichtigungen sind in den iOS-Einstellungen blockiert.",
|
Label(
|
||||||
systemImage: "exclamationmark.circle")
|
"Benachrichtigungen sind in den iOS-Einstellungen blockiert.",
|
||||||
.font(.caption)
|
systemImage: "exclamationmark.circle"
|
||||||
.foregroundStyle(CardsTheme.warning)
|
)
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundStyle(CardsTheme.warning)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Section("Marketplace") {
|
||||||
|
NavigationLink {
|
||||||
|
BlockedAuthorsView()
|
||||||
|
} label: {
|
||||||
|
Label("Blockierte Authors", systemImage: "hand.raised")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -3,7 +3,7 @@ import SwiftData
|
||||||
import SwiftUI
|
import SwiftUI
|
||||||
|
|
||||||
#if canImport(UIKit)
|
#if canImport(UIKit)
|
||||||
import UIKit
|
import UIKit
|
||||||
#endif
|
#endif
|
||||||
|
|
||||||
/// Vollbild-Study-View. Wird per Navigation aus DeckListView geöffnet.
|
/// Vollbild-Study-View. Wird per Navigation aus DeckListView geöffnet.
|
||||||
|
|
@ -73,26 +73,7 @@ struct StudySessionView: View {
|
||||||
session.flip()
|
session.flip()
|
||||||
}
|
}
|
||||||
keyboardShortcuts(session: session)
|
keyboardShortcuts(session: session)
|
||||||
if session.isFlipped {
|
bottomBar(session: session)
|
||||||
RatingBar { rating in
|
|
||||||
Task { await session.grade(rating) }
|
|
||||||
}
|
|
||||||
.transition(.move(edge: .bottom).combined(with: .opacity))
|
|
||||||
} else {
|
|
||||||
Button {
|
|
||||||
flipHaptic()
|
|
||||||
session.flip()
|
|
||||||
} label: {
|
|
||||||
Text("Antwort anzeigen")
|
|
||||||
.font(.subheadline.weight(.semibold))
|
|
||||||
.frame(maxWidth: .infinity)
|
|
||||||
.padding(.vertical, 14)
|
|
||||||
.background(CardsTheme.primary, in: RoundedRectangle(cornerRadius: 10))
|
|
||||||
.foregroundStyle(CardsTheme.primaryForeground)
|
|
||||||
}
|
|
||||||
.buttonStyle(.plain)
|
|
||||||
.padding(.horizontal, 16)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.padding(.bottom, 20)
|
.padding(.bottom, 20)
|
||||||
|
|
@ -100,6 +81,36 @@ struct StudySessionView: View {
|
||||||
.animation(.easeInOut(duration: 0.2), value: session.currentIndex)
|
.animation(.easeInOut(duration: 0.2), value: session.currentIndex)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Fixe Höhe, damit der Wechsel zwischen "Antwort anzeigen" und
|
||||||
|
/// `RatingBar` die Card oben nicht stauchen kann — sonst proportioniert
|
||||||
|
/// `.aspectRatio(.fit)` die Card neu und das Layout springt.
|
||||||
|
private func bottomBar(session: StudySession) -> some View {
|
||||||
|
ZStack {
|
||||||
|
if session.isFlipped {
|
||||||
|
RatingBar { rating in
|
||||||
|
Task { await session.grade(rating) }
|
||||||
|
}
|
||||||
|
.transition(.opacity)
|
||||||
|
} else {
|
||||||
|
Button {
|
||||||
|
flipHaptic()
|
||||||
|
session.flip()
|
||||||
|
} label: {
|
||||||
|
Text("Antwort anzeigen")
|
||||||
|
.font(.subheadline.weight(.semibold))
|
||||||
|
.frame(maxWidth: .infinity)
|
||||||
|
.padding(.vertical, 14)
|
||||||
|
.background(CardsTheme.primary, in: RoundedRectangle(cornerRadius: 10))
|
||||||
|
.foregroundStyle(CardsTheme.primaryForeground)
|
||||||
|
}
|
||||||
|
.buttonStyle(.plain)
|
||||||
|
.padding(.horizontal, 16)
|
||||||
|
.transition(.opacity)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.frame(height: 52)
|
||||||
|
}
|
||||||
|
|
||||||
private func cardSurface(due: DueReview, isFlipped: Bool) -> some View {
|
private func cardSurface(due: DueReview, isFlipped: Bool) -> some View {
|
||||||
CardSurface(size: .hero, elevation: .raised) {
|
CardSurface(size: .hero, elevation: .raised) {
|
||||||
CardRenderer(
|
CardRenderer(
|
||||||
|
|
@ -155,7 +166,6 @@ struct StudySessionView: View {
|
||||||
|
|
||||||
/// Unsichtbare Buttons mit Keyboard-Shortcuts. Funktionieren auf
|
/// Unsichtbare Buttons mit Keyboard-Shortcuts. Funktionieren auf
|
||||||
/// iPad (Magic Keyboard) und macOS. Space = flip, 1-4 = Rating.
|
/// iPad (Magic Keyboard) und macOS. Space = flip, 1-4 = Rating.
|
||||||
@ViewBuilder
|
|
||||||
private func keyboardShortcuts(session: StudySession) -> some View {
|
private func keyboardShortcuts(session: StudySession) -> some View {
|
||||||
Group {
|
Group {
|
||||||
Button("Flip") {
|
Button("Flip") {
|
||||||
|
|
@ -180,7 +190,7 @@ struct StudySessionView: View {
|
||||||
|
|
||||||
private func flipHaptic() {
|
private func flipHaptic() {
|
||||||
#if canImport(UIKit)
|
#if canImport(UIKit)
|
||||||
UIImpactFeedbackGenerator(style: .soft).impactOccurred()
|
UIImpactFeedbackGenerator(style: .soft).impactOccurred()
|
||||||
#endif
|
#endif
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue