diff --git a/Sources/App/RootView.swift b/Sources/App/RootView.swift index 599df9b..6bcfe71 100644 --- a/Sources/App/RootView.swift +++ b/Sources/App/RootView.swift @@ -123,13 +123,12 @@ struct RootView: View { let parts = url.pathComponents.filter { $0 != "/" } // Auth-Reset-Link aus der Passwort-Vergessen-Email. - if parts == ["auth", "reset"], - let token = URLComponents(url: url, resolvingAgainstBaseURL: false)? - .queryItems? - .first(where: { $0.name == "token" })?.value - { - resetPasswordToken = token - return + if parts == ["auth", "reset"] { + let components = URLComponents(url: url, resolvingAgainstBaseURL: false) + if let token = components?.queryItems?.first(where: { $0.name == "token" })?.value { + resetPasswordToken = token + return + } } if parts.count >= 2, parts[0] == "d" { diff --git a/Sources/Core/API/CardsAPI+Generation.swift b/Sources/Core/API/CardsAPI+Generation.swift new file mode 100644 index 0000000..c656c54 --- /dev/null +++ b/Sources/Core/API/CardsAPI+Generation.swift @@ -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 + } +} diff --git a/Sources/Core/API/CardsAPI+Marketplace.swift b/Sources/Core/API/CardsAPI+Marketplace.swift new file mode 100644 index 0000000..cf78fc7 --- /dev/null +++ b/Sources/Core/API/CardsAPI+Marketplace.swift @@ -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 + } +} diff --git a/Sources/Core/API/CardsAPI.swift b/Sources/Core/API/CardsAPI.swift index 63b04b7..5c9ce6e 100644 --- a/Sources/Core/API/CardsAPI.swift +++ b/Sources/Core/API/CardsAPI.swift @@ -1,11 +1,16 @@ import Foundation import ManaCore +// swiftlint:disable file_length +// swiftlint:disable type_body_length + /// 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 { - private let transport: AuthenticatedTransport - private let decoder: JSONDecoder + let transport: AuthenticatedTransport + let decoder: JSONDecoder init(auth: AuthClient) { transport = AuthenticatedTransport(baseURL: AppConfig.apiBaseURL, auth: auth) @@ -97,7 +102,7 @@ actor CardsAPI { var items: [URLQueryItem] = [ .init(name: "sort", value: sort.rawValue), .init(name: "limit", value: "\(limit)"), - .init(name: "offset", value: "\(offset)"), + .init(name: "offset", value: "\(offset)") ] if let query, !query.trimmingCharacters(in: .whitespaces).isEmpty { items.append(.init(name: "q", value: query)) @@ -218,6 +223,87 @@ actor CardsAPI { 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 /// `POST /api/v1/cards` — Karte anlegen. Server validiert `fields` @@ -293,37 +379,15 @@ actor CardsAPI { // MARK: - JSON-Encoding - private func makeJSON(_ value: T) throws -> Data { + func makeJSON(_ value: some Encodable) throws -> Data { let encoder = JSONEncoder() encoder.dateEncodingStrategy = .iso8601 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 - private func ensureOK(_ http: HTTPURLResponse, data: Data) throws { + func ensureOK(_ http: HTTPURLResponse, data: Data) throws { guard (200 ..< 300).contains(http.statusCode) else { let message = (try? JSONDecoder().decode(CardsServerError.self, from: data))?.error 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 { let error: String? } diff --git a/Sources/Core/Domain/CSVParser.swift b/Sources/Core/Domain/CSVParser.swift new file mode 100644 index 0000000..3f44cb7 --- /dev/null +++ b/Sources/Core/Domain/CSVParser.swift @@ -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: +/// +/// ,[,] +/// +/// - 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) + } + } +} diff --git a/Sources/Core/Domain/DeckGeneration.swift b/Sources/Core/Domain/DeckGeneration.swift new file mode 100644 index 0000000..1b4bc78 --- /dev/null +++ b/Sources/Core/Domain/DeckGeneration.swift @@ -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" + } +} diff --git a/Sources/Core/Domain/DeckMutations.swift b/Sources/Core/Domain/DeckMutations.swift index c40d62f..6faccd7 100644 --- a/Sources/Core/Domain/DeckMutations.swift +++ b/Sources/Core/Domain/DeckMutations.swift @@ -2,7 +2,7 @@ import Foundation /// Body für `POST /api/v1/decks`. Aus `DeckCreateSchema` in /// `cards/packages/cards-domain/src/schemas/deck.ts`. -struct DeckCreateBody: Encodable, Sendable { +struct DeckCreateBody: Encodable { let name: String let description: 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`. -struct DeckUpdateBody: Encodable, Sendable { +struct DeckUpdateBody: Encodable { var name: String? var description: String? var color: String? @@ -36,3 +36,37 @@ struct DeckUpdateBody: Encodable, Sendable { 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" + } +} diff --git a/Sources/Core/Domain/MarketplaceModeration.swift b/Sources/Core/Domain/MarketplaceModeration.swift new file mode 100644 index 0000000..e4972ec --- /dev/null +++ b/Sources/Core/Domain/MarketplaceModeration.swift @@ -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] +} diff --git a/Sources/Core/Domain/MarketplacePublish.swift b/Sources/Core/Domain/MarketplacePublish.swift new file mode 100644 index 0000000..a5323c3 --- /dev/null +++ b/Sources/Core/Domain/MarketplacePublish.swift @@ -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 + } + } +} diff --git a/Sources/Features/Decks/DeckCoverTile.swift b/Sources/Features/Decks/DeckCoverTile.swift new file mode 100644 index 0000000..77aa5bd --- /dev/null +++ b/Sources/Features/Decks/DeckCoverTile.swift @@ -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: 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 +} diff --git a/Sources/Features/Decks/DeckDetailView.swift b/Sources/Features/Decks/DeckDetailView.swift index d037ea1..f764952 100644 --- a/Sources/Features/Decks/DeckDetailView.swift +++ b/Sources/Features/Decks/DeckDetailView.swift @@ -2,8 +2,16 @@ import ManaCore import SwiftData import SwiftUI +// swiftlint:disable file_length +// swiftlint:disable type_body_length + /// Deck-Detail mit Aktionen + Card-Liste. Wird per Tap auf eine Deck-Row /// 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 { let deckId: String @@ -17,11 +25,19 @@ struct DeckDetailView: View { @State private var showDeleteConfirm = false @State private var navigateToStudy = false @State private var deleteError: String? + @State private var editingCard: Card? @State private var cards: [Card] = [] @State private var isLoadingCards = false @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) { self.deckId = deckId _decks = Query(filter: #Predicate { $0.id == deckId }) @@ -53,7 +69,7 @@ struct DeckDetailView: View { } .sheet(isPresented: $showCardEditor) { NavigationStack { - CardEditorView(deckId: deckId) { _ in + CardEditorView(mode: .create(deckId: deckId)) { _ in Task { await refreshAfterEdit() 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( "Deck löschen?", isPresented: $showDeleteConfirm, @@ -71,7 +117,12 @@ struct DeckDetailView: View { } Button("Abbrechen", role: .cancel) {} } 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) { if let deck = decks.first { @@ -84,6 +135,21 @@ struct DeckDetailView: View { .refreshable { 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 { @@ -136,65 +202,56 @@ struct DeckDetailView: View { private func actions(deck: CachedDeck) -> some View { VStack(spacing: 12) { - 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(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) - } + primaryActions + secondaryActions(deck: deck) } .padding(.horizontal, 16) } @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 { VStack(alignment: .leading, spacing: 8) { HStack { @@ -233,8 +290,14 @@ struct DeckDetailView: View { } else { LazyVStack(spacing: 8) { ForEach(cards) { card in - CardPreviewRow(card: card) - .padding(.horizontal, 16) + Button { + 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() } + 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 { isLoadingCards = true 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. private struct CardPreviewRow: View { let card: Card @@ -307,15 +428,15 @@ private struct CardPreviewRow: View { private func preview(card: Card) -> String { switch card.type { case .basic, .basicReverse, .typing, .multipleChoice: - return card.fields["front"] ?? "—" + card.fields["front"] ?? "—" case .cloze: - return card.fields["text"] ?? "—" + card.fields["text"] ?? "—" case .imageOcclusion: - return card.fields["note"]?.isEmpty == false + card.fields["note"]?.isEmpty == false ? card.fields["note"]! : "Bild-Verdeckung (\(MaskRegions.count(card.fields["mask_regions"] ?? "")) Masken)" case .audioFront: - return card.fields["back"] ?? "Audio-Karte" + card.fields["back"] ?? "Audio-Karte" } } diff --git a/Sources/Features/Decks/DeckListView.swift b/Sources/Features/Decks/DeckListView.swift index 3b3a302..d2ef285 100644 --- a/Sources/Features/Decks/DeckListView.swift +++ b/Sources/Features/Decks/DeckListView.swift @@ -11,6 +11,8 @@ enum DeckRoute: Hashable { case detail(deckId: String) } +// swiftlint:disable type_body_length + /// Decks-Hauptbildschirm im Cardecky-Look: horizontale Scroll-Reihen /// mit Fan-Stack-Karten-Tiles. Web-Vorbild: /// `cards/apps/web/src/routes/decks/+page.svelte`. @@ -238,7 +240,10 @@ struct DeckListView: View { .foregroundStyle(CardsTheme.foreground) } description: { 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) } actions: { @@ -254,7 +259,10 @@ struct DeckListView: View { .foregroundStyle(CardsTheme.foreground) } description: { 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) } @@ -285,3 +293,5 @@ struct DeckListView: View { } } } + +// swiftlint:enable type_body_length diff --git a/Sources/Features/Decks/DeckPrintView.swift b/Sources/Features/Decks/DeckPrintView.swift new file mode 100644 index 0000000..57b7bdf --- /dev/null +++ b/Sources/Features/Decks/DeckPrintView.swift @@ -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 diff --git a/Sources/Features/Decks/DeckSecondaryActions.swift b/Sources/Features/Decks/DeckSecondaryActions.swift new file mode 100644 index 0000000..84dfd99 --- /dev/null +++ b/Sources/Features/Decks/DeckSecondaryActions.swift @@ -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) + } + } +} diff --git a/Sources/Features/Editor/CSVImportFormSections.swift b/Sources/Features/Editor/CSVImportFormSections.swift new file mode 100644 index 0000000..3ce4d0e --- /dev/null +++ b/Sources/Features/Editor/CSVImportFormSections.swift @@ -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)" + } + } +} diff --git a/Sources/Features/Editor/CardEditorMediaFields.swift b/Sources/Features/Editor/CardEditorMediaFields.swift new file mode 100644 index 0000000..77cd3b8 --- /dev/null +++ b/Sources/Features/Editor/CardEditorMediaFields.swift @@ -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") + } + } +} diff --git a/Sources/Features/Editor/CardEditorPayload.swift b/Sources/Features/Editor/CardEditorPayload.swift new file mode 100644 index 0000000..989de04 --- /dev/null +++ b/Sources/Features/Editor/CardEditorPayload.swift @@ -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" + } + } +} diff --git a/Sources/Features/Editor/CardEditorView.swift b/Sources/Features/Editor/CardEditorView.swift index 2f2249b..ef2898e 100644 --- a/Sources/Features/Editor/CardEditorView.swift +++ b/Sources/Features/Editor/CardEditorView.swift @@ -1,55 +1,126 @@ import ManaCore -import PhotosUI import SwiftUI #if canImport(UIKit) -import UIKit + import UIKit #endif -/// Card-Create-View. Type-Picker oben, type-spezifische Felder unten. -/// Deckt alle 7 Card-Types ab. +// swiftlint:disable type_body_length + +/// Card-Create und Card-Edit in einer View. +/// +/// - `.create(deckId:)` zeigt Type-Picker + leere Felder. +/// - `.edit(card:)` blendet Type-Picker aus (Server-seitig immutable), +/// pre-fillt alle Felder, und PATCHt auf Submit. +/// +/// Bei Image-Occlusion und Audio-Front im Edit-Modus bleibt der bestehende +/// Media-Ref erhalten, solange der User die Datei nicht explizit ersetzt. struct CardEditorView: View { - let deckId: String - let onCreated: (Card) -> Void + enum Mode { + case create(deckId: String) + case edit(card: Card) + } + + let mode: Mode + let onSaved: (Card) -> Void @Environment(AuthClient.self) private var auth @Environment(\.dismiss) private var dismiss - @State private var type: CardType = .basic - @State private var front: String = "" - @State private var back: String = "" - @State private var clozeText: String = "" - @State private var typingAnswer: String = "" - @State private var multipleChoiceAnswer: String = "" + @State private var type: CardType + @State private var front: String + @State private var back: String + @State private var clozeText: String + @State private var typingAnswer: String + @State private var multipleChoiceAnswer: String @State private var isSubmitting = false @State private var errorMessage: String? // Image-Occlusion-State - @State private var imagePickerItem: PhotosPickerItem? @State private var occlusionImage: PlatformImage? @State private var occlusionImageData: Data? @State private var occlusionMimeType: String = "image/jpeg" - @State private var occlusionRegions: [MaskRegion] = [] - @State private var occlusionNote: String = "" + @State private var occlusionRegions: [MaskRegion] + @State private var occlusionNote: String + /// Bestehender `image_ref` aus der Card im Edit-Modus. Bleibt erhalten, + /// solange der User kein neues Bild wählt. + @State private var existingImageRef: String? - // Audio-Front-State + /// Audio-Front-State @State private var audioFileURL: URL? - @State private var showAudioPicker = false + /// Bestehender `audio_ref` aus der Card im Edit-Modus. + @State private var existingAudioRef: String? private static let supportedTypes: [CardType] = [ .basic, .basicReverse, .cloze, .typing, .multipleChoice, - .imageOcclusion, .audioFront, + .imageOcclusion, .audioFront ] + init(mode: Mode, onSaved: @escaping (Card) -> Void) { + self.mode = mode + self.onSaved = onSaved + + let initialType: CardType + var initialFront = "" + var initialBack = "" + var initialCloze = "" + var initialTyping = "" + var initialMC = "" + var initialRegions: [MaskRegion] = [] + var initialNote = "" + var initialImageRef: String? + var initialAudioRef: String? + + switch mode { + case .create: + initialType = .basic + case let .edit(card): + initialType = card.type + switch card.type { + case .basic, .basicReverse: + initialFront = card.fields["front"] ?? "" + initialBack = card.fields["back"] ?? "" + case .cloze: + initialCloze = card.fields["text"] ?? "" + case .typing: + initialFront = card.fields["front"] ?? "" + initialTyping = card.fields["answer"] ?? "" + case .multipleChoice: + initialFront = card.fields["front"] ?? "" + initialMC = card.fields["answer"] ?? "" + case .imageOcclusion: + initialRegions = MaskRegions.parse(card.fields["mask_regions"] ?? "[]") + initialNote = card.fields["note"] ?? "" + initialImageRef = card.fields["image_ref"] + case .audioFront: + initialBack = card.fields["back"] ?? "" + initialAudioRef = card.fields["audio_ref"] + } + } + + _type = State(initialValue: initialType) + _front = State(initialValue: initialFront) + _back = State(initialValue: initialBack) + _clozeText = State(initialValue: initialCloze) + _typingAnswer = State(initialValue: initialTyping) + _multipleChoiceAnswer = State(initialValue: initialMC) + _occlusionRegions = State(initialValue: initialRegions) + _occlusionNote = State(initialValue: initialNote) + _existingImageRef = State(initialValue: initialImageRef) + _existingAudioRef = State(initialValue: initialAudioRef) + } + var body: some View { Form { - Section("Card-Type") { - Picker("Typ", selection: $type) { - ForEach(Self.supportedTypes, id: \.self) { t in - Text(label(for: t)).tag(t) + if isCreate { + Section("Card-Type") { + Picker("Typ", selection: $type) { + ForEach(Self.supportedTypes, id: \.self) { cardType in + Text(label(for: cardType)).tag(cardType) + } } + .pickerStyle(.menu) } - .pickerStyle(.menu) } typeFields @@ -62,7 +133,8 @@ struct CardEditorView: View { } } } - .navigationTitle("Neue Karte") + .disabled(isSubmitting) + .navigationTitle(isCreate ? "Neue Karte" : "Karte bearbeiten") #if os(iOS) .navigationBarTitleDisplayMode(.inline) #endif @@ -71,8 +143,10 @@ struct CardEditorView: View { Button("Abbrechen") { dismiss() } } ToolbarItem(placement: .confirmationAction) { - Button("Erstellen") { Task { await submit() } } - .disabled(!canSubmit || isSubmitting) + Button(isCreate ? "Erstellen" : "Speichern") { + Task { await submit() } + } + .disabled(!canSubmit || isSubmitting) } } } @@ -99,12 +173,15 @@ struct CardEditorView: View { case .cloze: Section("Cloze-Text") { - TextField("Beispiel: Die Hauptstadt von {{c1::Frankreich}} ist {{c2::Paris}}.", - text: $clozeText, axis: .vertical) - .lineLimit(3 ... 8) - .autocorrectionDisabled() - .textInputAutocapitalization(.sentences) - .monospaced() + TextField( + "Beispiel: Die Hauptstadt von {{c1::Frankreich}} ist {{c2::Paris}}.", + text: $clozeText, + axis: .vertical + ) + .lineLimit(3 ... 8) + .autocorrectionDisabled() + .textInputAutocapitalization(.sentences) + .monospaced() } Section { let count = Cloze.subIndexCount(clozeText) @@ -146,119 +223,40 @@ struct CardEditorView: View { } case .imageOcclusion: - imageOcclusionFields + ImageOcclusionFields( + image: $occlusionImage, + imageData: $occlusionImageData, + mimeType: $occlusionMimeType, + regions: $occlusionRegions, + note: $occlusionNote, + existingImageRef: $existingImageRef, + onLoadError: { errorMessage = $0 } + ) case .audioFront: - audioFrontFields + AudioFrontFields( + audioFileURL: $audioFileURL, + back: $back, + existingAudioRef: existingAudioRef + ) } } - @ViewBuilder - private var imageOcclusionFields: some View { - Section("Bild") { - PhotosPicker(selection: $imagePickerItem, matching: .images) { - ImagePickerLabel(hasImage: occlusionImage != nil) - } - .onChange(of: imagePickerItem) { _, newItem in - Task { await loadPickedImage(newItem) } - } - } + private var isCreate: Bool { + if case .create = mode { return true } + return false + } - if let image = occlusionImage { - Section("Masken") { - MaskEditorView(image: image, regions: $occlusionRegions) - } - } - - Section("Hinweis (optional)") { - TextField("z.B. Kurz-Erklärung", text: $occlusionNote, axis: .vertical) - .lineLimit(1 ... 3) - } - - Section { - if occlusionImage == nil { - Label("Erst Bild wählen", systemImage: "info.circle") - .font(.caption) - .foregroundStyle(CardsTheme.mutedForeground) - } else if occlusionRegions.isEmpty { - Label("Mindestens eine Maske nötig", systemImage: "exclamationmark.circle") - .font(.caption) - .foregroundStyle(CardsTheme.warning) - } else { - Label("\(occlusionRegions.count) Masken → \(occlusionRegions.count) Reviews", - systemImage: "checkmark.circle.fill") - .font(.caption) - .foregroundStyle(CardsTheme.success) - } + private var deckId: String { + switch mode { + case let .create(deckId): deckId + case let .edit(card): card.deckId } } - @ViewBuilder - private var audioFrontFields: some View { - Section("Audio-Datei") { - Button { - showAudioPicker = true - } label: { - if let audioFileURL { - Label(audioFileURL.lastPathComponent, systemImage: "waveform") - } else { - Label("Audio auswählen", systemImage: "waveform.badge.plus") - } - } - .fileImporter( - isPresented: $showAudioPicker, - allowedContentTypes: [.audio, .mp3, .wav, .mpeg4Audio], - allowsMultipleSelection: false - ) { result in - if case let .success(urls) = result, let first = urls.first { - audioFileURL = first - } - } - } - Section("Antwort") { - TextField("Was zu hören ist", text: $back, axis: .vertical) - .lineLimit(2 ... 4) - } - } - - private func loadPickedImage(_ item: PhotosPickerItem?) async { - guard let item else { return } - do { - guard let data = try await item.loadTransferable(type: Data.self) else { return } - occlusionImageData = data - occlusionMimeType = inferMimeType(from: data) - if let img = PlatformImage(data: data) { - occlusionImage = img - occlusionRegions = [] // neue Bildauswahl resetet Masken - } - } catch { - errorMessage = "Bild konnte nicht geladen werden: \(error.localizedDescription)" - } - } - - private func inferMimeType(from data: Data) -> String { - // Schneller Magic-Byte-Check für die häufigsten Formate - guard data.count > 4 else { return "image/jpeg" } - let bytes = Array(data.prefix(8)) - if bytes.starts(with: [0xFF, 0xD8, 0xFF]) { return "image/jpeg" } - if bytes.starts(with: [0x89, 0x50, 0x4E, 0x47]) { return "image/png" } - if bytes.starts(with: [0x47, 0x49, 0x46, 0x38]) { return "image/gif" } - // WebP: starts with "RIFF" + 4 bytes size + "WEBP" - if bytes.count >= 8, - bytes[0 ... 3] == [0x52, 0x49, 0x46, 0x46] { - return "image/webp" - } - return "image/jpeg" - } - - private func audioMimeType(for url: URL) -> String { - switch url.pathExtension.lowercased() { - case "mp3": "audio/mpeg" - case "wav": "audio/wav" - case "m4a", "mp4": "audio/mp4" - case "ogg", "oga": "audio/ogg" - default: "audio/mpeg" - } + private var existingMediaRefs: [String] { + if case let .edit(card) = mode { return card.mediaRefs } + return [] } private var canSubmit: Bool { @@ -272,12 +270,14 @@ struct CardEditorView: View { case .multipleChoice: !front.trimmed.isEmpty && !multipleChoiceAnswer.trimmed.isEmpty case .imageOcclusion: - occlusionImageData != nil && !occlusionRegions.isEmpty + (occlusionImageData != nil || existingImageRef != nil) && !occlusionRegions.isEmpty case .audioFront: - audioFileURL != nil && !back.trimmed.isEmpty + (audioFileURL != nil || existingAudioRef != nil) && !back.trimmed.isEmpty } } + // MARK: - Submit + private func submit() async { isSubmitting = true errorMessage = nil @@ -285,53 +285,47 @@ struct CardEditorView: View { let api = CardsAPI(auth: auth) do { - let fields: [String: String] - var mediaRefs: [String]? = nil - switch type { - case .basic, .basicReverse: - fields = CardFieldsBuilder.basic(front: front.trimmed, back: back.trimmed) - case .cloze: - fields = CardFieldsBuilder.cloze(text: clozeText.trimmed) - case .typing: - fields = CardFieldsBuilder.typing(front: front.trimmed, answer: typingAnswer.trimmed) - case .multipleChoice: - fields = CardFieldsBuilder.multipleChoice(front: front.trimmed, answer: multipleChoiceAnswer.trimmed) - case .imageOcclusion: - guard let data = occlusionImageData else { return } - let media = try await api.uploadMedia( - data: data, - filename: "occlusion.\(occlusionMimeType.contains("png") ? "png" : "jpg")", - mimeType: occlusionMimeType - ) - fields = CardFieldsBuilder.imageOcclusion( - imageRef: media.id, - regions: occlusionRegions, - note: occlusionNote.trimmed.isEmpty ? nil : occlusionNote.trimmed - ) - mediaRefs = [media.id] - case .audioFront: - guard let url = audioFileURL else { return } - let didStart = url.startAccessingSecurityScopedResource() - defer { if didStart { url.stopAccessingSecurityScopedResource() } } - let data = try Data(contentsOf: url) - let media = try await api.uploadMedia( - data: data, - filename: url.lastPathComponent, - mimeType: audioMimeType(for: url) - ) - fields = CardFieldsBuilder.audioFront(audioRef: media.id, back: back.trimmed) - mediaRefs = [media.id] + let payload = try await CardEditorPayloadBuilder.build(inputs: payloadInputs, api: api) + let card: Card = switch mode { + case let .create(deckId): + try await api.createCard(CardCreateBody( + deckId: deckId, + type: type, + fields: payload.fields, + mediaRefs: payload.mediaRefs + )) + case let .edit(existing): + try await api.updateCard(id: existing.id, body: CardUpdateBody( + fields: payload.fields, + mediaRefs: payload.mediaRefs + )) } - - let body = CardCreateBody(deckId: deckId, type: type, fields: fields, mediaRefs: mediaRefs) - let card = try await api.createCard(body) - onCreated(card) + onSaved(card) dismiss() } catch { errorMessage = (error as? LocalizedError)?.errorDescription ?? String(describing: error) } } + private var payloadInputs: CardEditorPayloadInputs { + CardEditorPayloadInputs( + type: type, + front: front.trimmed, + back: back.trimmed, + clozeText: clozeText.trimmed, + typingAnswer: typingAnswer.trimmed, + multipleChoiceAnswer: multipleChoiceAnswer.trimmed, + occlusionImageData: occlusionImageData, + occlusionMimeType: occlusionMimeType, + occlusionRegions: occlusionRegions, + occlusionNote: occlusionNote.trimmed, + existingImageRef: existingImageRef, + audioFileURL: audioFileURL, + existingAudioRef: existingAudioRef, + existingMediaRefs: existingMediaRefs + ) + } + private func label(for type: CardType) -> String { switch type { case .basic: "Einfach (Vorder/Rück)" @@ -345,25 +339,10 @@ struct CardEditorView: View { } } +// swiftlint:enable type_body_length + private extension String { var trimmed: String { trimmingCharacters(in: .whitespacesAndNewlines) } } - -/// Wird als Sub-View aus dem PhotosPicker-Label-Closure aufgerufen. -/// Eigene `View`-Struct vermeidet die Swift-6-Strict-Concurrency- -/// Warning: SwiftUIs `PhotosPicker(label:)`-Closure ist `@Sendable`, -/// aber View-Konstruktor-Calls werden zur Build-Zeit MainActor-isoliert -/// evaluiert (im Gegensatz zu direktem @State-Zugriff im Closure-Body). -private struct ImagePickerLabel: View { - let hasImage: Bool - - var body: some View { - if hasImage { - Label("Bild ersetzen", systemImage: "arrow.triangle.2.circlepath") - } else { - Label("Bild auswählen", systemImage: "photo") - } - } -} diff --git a/Sources/Features/Editor/DeckEditorHelpers.swift b/Sources/Features/Editor/DeckEditorHelpers.swift new file mode 100644 index 0000000..f3123d4 --- /dev/null +++ b/Sources/Features/Editor/DeckEditorHelpers.swift @@ -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." + } +} diff --git a/Sources/Features/Editor/DeckEditorView.swift b/Sources/Features/Editor/DeckEditorView.swift index 6be1022..16eb230 100644 --- a/Sources/Features/Editor/DeckEditorView.swift +++ b/Sources/Features/Editor/DeckEditorView.swift @@ -1,125 +1,530 @@ import ManaCore +import PhotosUI import SwiftUI -/// Deck-Create und Deck-Edit in einer View. `existing == nil` → Create- -/// Modus mit "Erstellen"-Button. Sonst Edit-Modus mit "Speichern". +// swiftlint:disable file_length +// swiftlint:disable type_body_length + +/// Deck-Create und Deck-Edit in einer View. Im Create-Modus stehen vier +/// Sub-Modi zur Wahl: manuell („Leer"), AI-Text („Mit KI"), AI-Vision +/// („Aus Bild") und CSV. Edit-Modus zeigt nur das manuelle Formular. +/// +/// Web-Vorbild: `cards/apps/web/src/routes/decks/new/+page.svelte`. +/// `type_body_length` ist bewusst übersprungen — die 4 Sub-Modi teilen +/// sich State + Toolbar; aufspalten ginge nur über @Binding-Plumbing. struct DeckEditorView: View { - enum Mode: Sendable { + enum Mode { case create case edit(deckId: String) } + /// Vier Sub-Modi im Create-Sheet. + enum CreateMode: Hashable { + case manual + case aiText + case aiMedia + case csv + } + let mode: Mode let onSaved: (Deck) -> Void @Environment(AuthClient.self) private var auth @Environment(\.dismiss) private var dismiss + // Manual fields (Edit + Create.manual) @State private var name: String @State private var description: String @State private var color: String @State private var category: DeckCategory? @State private var visibility: DeckVisibility - @State private var isSubmitting = false - @State private var errorMessage: String? + @State private var archived: Bool - /// Vorgefüllte Farbpalette aus dem forest-Theme. User können - /// freie Hex-Werte später via Picker setzen (β-3-extension). - private static let presetColors: [String] = [ - "#10803D", // forest primary light - "#1E3A2F", // forest dark - "#D97706", // amber - "#DC2626", // red - "#2563EB", // blue - "#7C3AED", // violet - "#0D9488", // teal - "#737373", // neutral - ] + /// Create-mode selector + @State private var createMode: CreateMode = .manual + + // AI-shared (Text + Media) + @State private var aiPrompt: String = "" + @State private var aiCount: Int = 15 + @State private var aiLanguage: GenerationLanguage = .de + @State private var aiUrl: String = "" + + // AI-Media + @State private var aiMediaFiles: [GenerationMediaFile] = [] + @State private var aiPhotoItems: [PhotosPickerItem] = [] + @State private var showPDFImporter: Bool = false + + // CSV-Import + @State private var csvRows: [CSVRow] = [] + @State private var csvDeckName: String = "" + @State private var showCSVImporter: Bool = false + @State private var csvImportProgress: Int = 0 + + // Submission + @State private var isSubmitting = false + @State private var generationTask: Task? + @State private var errorMessage: String? init(mode: Mode, existing: CachedDeck? = nil, onSaved: @escaping (Deck) -> Void) { self.mode = mode self.onSaved = onSaved _name = State(initialValue: existing?.name ?? "") _description = State(initialValue: existing?.deckDescription ?? "") - _color = State(initialValue: existing?.color ?? Self.presetColors[0]) + _color = State(initialValue: existing?.color ?? DeckEditorPresets.colors[0]) _category = State(initialValue: existing?.category) _visibility = State(initialValue: DeckVisibility(rawValue: existing?.visibilityRaw ?? "private") ?? .private) + _archived = State(initialValue: existing?.archivedAt != nil) } var body: some View { - Form { - Section("Name") { - TextField("Deck-Name", text: $name) - .textInputAutocapitalization(.sentences) - } - - Section("Beschreibung") { - TextField("optional", text: $description, axis: .vertical) - .lineLimit(2 ... 4) - } - - Section("Farbe") { - ScrollView(.horizontal, showsIndicators: false) { - HStack(spacing: 10) { - ForEach(Self.presetColors, id: \.self) { hex in - colorSwatch(hex) - } - } - .padding(.vertical, 4) + ZStack { + Form { + if isCreate { + modePickerSection } + formSections + errorSection } + .disabled(isSubmitting) - Section("Kategorie") { - Picker("Kategorie", selection: $category) { - Text("Keine").tag(DeckCategory?.none) - ForEach(DeckCategory.allCases, id: \.self) { cat in - Text(cat.label).tag(DeckCategory?.some(cat)) - } - } - } - - Section("Sichtbarkeit") { - Picker("Sichtbarkeit", selection: $visibility) { - Text("Privat").tag(DeckVisibility.private) - Text("Space").tag(DeckVisibility.space) - Text("Öffentlich").tag(DeckVisibility.public) - } - .pickerStyle(.segmented) - } - - if let errorMessage { - Section { - Text(errorMessage) - .font(.footnote) - .foregroundStyle(CardsTheme.error) - } + if isSubmitting, activeMode != .manual { + GenerationOverlay( + message: overlayMessage, + onCancel: { generationTask?.cancel() } + ) } } - .navigationTitle(isCreate ? "Neues Deck" : "Deck bearbeiten") + .navigationTitle(navTitle) #if os(iOS) .navigationBarTitleDisplayMode(.inline) #endif - .toolbar { - ToolbarItem(placement: .cancellationAction) { - Button("Abbrechen") { dismiss() } - } - ToolbarItem(placement: .confirmationAction) { - Button(isCreate ? "Erstellen" : "Speichern") { - Task { await submit() } - } - .disabled(name.trimmingCharacters(in: .whitespaces).isEmpty || isSubmitting) - } + .toolbar { toolbar } + .onChange(of: aiPhotoItems) { _, items in + guard !items.isEmpty else { return } + Task { await ingestPhotoItems(items) } } + .fileImporter( + isPresented: $showPDFImporter, + allowedContentTypes: [.pdf], + allowsMultipleSelection: true, + onCompletion: handlePDFImport + ) + .fileImporter( + isPresented: $showCSVImporter, + allowedContentTypes: [.commaSeparatedText, .plainText], + allowsMultipleSelection: false, + onCompletion: handleCSVImport + ) } + // MARK: - Sections + + private var modePickerSection: some View { + Section { + Picker("Modus", selection: $createMode) { + Text("Leer").tag(CreateMode.manual) + Text("KI").tag(CreateMode.aiText) + Text("Bild").tag(CreateMode.aiMedia) + Text("CSV").tag(CreateMode.csv) + } + .pickerStyle(.segmented) + } footer: { + modeFooter + } + } + + @ViewBuilder + private var modeFooter: some View { + switch createMode { + case .manual: + Text("Leeres Deck — Karten anschließend selbst anlegen.") + case .aiText: + Text("KI generiert das Deck aus einer kurzen Beschreibung. 10 Anfragen pro Minute.") + case .aiMedia: + Text("KI liest Bilder oder PDFs und macht daraus Karten. Bis zu 5 Dateien.") + case .csv: + Text("CSV-Datei einlesen. Format: vorne,hinten[,typ] pro Zeile.") + } + } + + @ViewBuilder + private var formSections: some View { + switch activeMode { + case .manual: + ManualFormSections( + name: $name, + description: $description, + color: $color, + category: $category, + visibility: $visibility, + archived: isCreate ? nil : $archived + ) + case .aiText: + AITextFormSections(prompt: $aiPrompt) + AISharedSections(count: $aiCount, language: $aiLanguage, url: $aiUrl) + case .aiMedia: + AIMediaFormSections( + files: $aiMediaFiles, + photoItems: $aiPhotoItems, + showPDFImporter: $showPDFImporter + ) + AISharedSections(count: $aiCount, language: $aiLanguage, url: $aiUrl) + case .csv: + CSVImportFormSections( + rows: $csvRows, + deckName: $csvDeckName, + showImporter: $showCSVImporter + ) + } + } + + @ViewBuilder + private var errorSection: some View { + if let errorMessage { + Section { + Text(errorMessage) + .font(.footnote) + .foregroundStyle(CardsTheme.error) + } + } + } + + // MARK: - Toolbar + + @ToolbarContentBuilder + private var toolbar: some ToolbarContent { + ToolbarItem(placement: .cancellationAction) { + Button("Abbrechen") { + generationTask?.cancel() + dismiss() + } + } + ToolbarItem(placement: .confirmationAction) { + Button(confirmLabel) { + startSubmit() + } + .disabled(!canSubmit || isSubmitting) + } + } + + // MARK: - Computed state + private var isCreate: Bool { if case .create = mode { return true } return false } - @ViewBuilder - private func colorSwatch(_ hex: String) -> some View { - let isSelected = color == hex + private var activeMode: CreateMode { + isCreate ? createMode : .manual + } + + private var navTitle: String { + switch activeMode { + case .manual: isCreate ? "Neues Deck" : "Deck bearbeiten" + case .aiText: "Mit KI generieren" + case .aiMedia: "Aus Bild generieren" + case .csv: "Aus CSV importieren" + } + } + + private var confirmLabel: String { + switch activeMode { + case .manual: isCreate ? "Erstellen" : "Speichern" + case .aiText, .aiMedia: "Generieren" + case .csv: csvRows.isEmpty ? "Importieren" : "\(csvRows.count) Karten importieren" + } + } + + private var canSubmit: Bool { + switch activeMode { + case .manual: + !name.trimmingCharacters(in: .whitespaces).isEmpty + case .aiText: + aiPrompt.trimmingCharacters(in: .whitespaces).count >= 3 + case .aiMedia: + !aiMediaFiles.isEmpty || DeckEditorHelpers.isValidURL(aiUrl) + case .csv: + !csvRows.isEmpty && !csvDeckName.trimmingCharacters(in: .whitespaces).isEmpty + } + } + + private var overlayMessage: String { + switch activeMode { + case .csv: + csvImportProgress > 0 + ? "Karten werden importiert (\(csvImportProgress) / \(csvRows.count)) …" + : "Import wird vorbereitet …" + default: + "Karten werden generiert …" + } + } + + // MARK: - Photo / PDF ingest + + private func ingestPhotoItems(_ items: [PhotosPickerItem]) async { + for item in items { + if aiMediaFiles.count >= DeckEditorPresets.maxMediaFiles { break } + do { + guard let data = try await item.loadTransferable(type: Data.self) else { continue } + guard data.count <= DeckEditorPresets.maxImageBytes else { + errorMessage = "Bild ist größer als 10 MB und wurde übersprungen." + continue + } + let mime = DeckEditorHelpers.inferImageMimeType(from: data) + let ext = DeckEditorHelpers.imageExtension(forMime: mime) + let filename = "image-\(UUID().uuidString.prefix(8)).\(ext)" + aiMediaFiles.append(GenerationMediaFile( + data: data, + filename: filename, + mimeType: mime + )) + } catch { + errorMessage = "Foto konnte nicht geladen werden: \(error.localizedDescription)" + } + } + aiPhotoItems = [] + } + + private func handleCSVImport(_ result: Result<[URL], Error>) { + switch result { + case let .success(urls): + guard let url = urls.first else { return } + let didStart = url.startAccessingSecurityScopedResource() + defer { if didStart { url.stopAccessingSecurityScopedResource() } } + do { + let text = try String(contentsOf: url, encoding: .utf8) + let rows = try CSVParser.parse(text) + csvRows = rows + if csvDeckName.trimmingCharacters(in: .whitespaces).isEmpty { + csvDeckName = url.deletingPathExtension().lastPathComponent + } + } catch { + errorMessage = "CSV-Import fehlgeschlagen: \(error.localizedDescription)" + } + case let .failure(error): + errorMessage = "Datei-Auswahl fehlgeschlagen: \(error.localizedDescription)" + } + } + + private func handlePDFImport(_ result: Result<[URL], Error>) { + switch result { + case let .success(urls): + for url in urls { + if aiMediaFiles.count >= DeckEditorPresets.maxMediaFiles { break } + let didStart = url.startAccessingSecurityScopedResource() + defer { if didStart { url.stopAccessingSecurityScopedResource() } } + do { + let data = try Data(contentsOf: url) + guard data.count <= DeckEditorPresets.maxPDFBytes else { + errorMessage = "\(url.lastPathComponent) ist größer als 30 MB." + continue + } + aiMediaFiles.append(GenerationMediaFile( + data: data, + filename: url.lastPathComponent, + mimeType: "application/pdf" + )) + } catch { + errorMessage = "PDF konnte nicht gelesen werden: \(error.localizedDescription)" + } + } + case let .failure(error): + errorMessage = "PDF-Auswahl fehlgeschlagen: \(error.localizedDescription)" + } + } + + // MARK: - Submit + + private func startSubmit() { + errorMessage = nil + isSubmitting = true + generationTask = Task { + await submit() + isSubmitting = false + generationTask = nil + } + } + + private func submit() async { + let api = CardsAPI(auth: auth) + do { + switch (mode, activeMode) { + case (.create, .manual): + let deck = try await api.createDeck(manualCreateBody) + onSaved(deck) + dismiss() + case let (.edit(deckId), _): + let deck = try await api.updateDeck(id: deckId, body: manualUpdateBody) + onSaved(deck) + dismiss() + case (.create, .aiText): + let response = try await api.generateDeckFromText(aiTextBody) + try Task.checkCancellation() + onSaved(response.deck) + dismiss() + case (.create, .aiMedia): + let response = try await api.generateDeckFromMedia( + files: aiMediaFiles, + language: aiLanguage, + count: aiCount, + url: DeckEditorHelpers.nonEmpty(aiUrl) + ) + try Task.checkCancellation() + onSaved(response.deck) + dismiss() + case (.create, .csv): + let deck = try await submitCSVImport(api: api) + onSaved(deck) + dismiss() + } + } catch is CancellationError { + // User-Abbruch → kein Banner. + } catch let error as AuthError { + errorMessage = DeckEditorHelpers.mapAIError(error) + } catch { + errorMessage = error.localizedDescription + } + } + + private var manualCreateBody: DeckCreateBody { + DeckCreateBody( + name: name.trimmingCharacters(in: .whitespaces), + description: DeckEditorHelpers.nonEmpty(description), + color: color, + category: category, + visibility: visibility + ) + } + + private var manualUpdateBody: DeckUpdateBody { + DeckUpdateBody( + name: name.trimmingCharacters(in: .whitespaces), + description: DeckEditorHelpers.nonEmpty(description), + color: color, + category: category, + visibility: visibility, + archived: archived + ) + } + + private func submitCSVImport(api: CardsAPI) async throws -> Deck { + let deck = try await api.createDeck(DeckCreateBody( + name: csvDeckName.trimmingCharacters(in: .whitespaces), + description: "Aus CSV-Import (\(csvRows.count) Karten)", + color: color, + category: category, + visibility: visibility + )) + csvImportProgress = 0 + for (index, row) in csvRows.enumerated() { + try Task.checkCancellation() + let fields: [String: String] + switch row.type { + case .basic, .basicReverse: + fields = CardFieldsBuilder.basic(front: row.front, back: row.back) + case .cloze: + fields = CardFieldsBuilder.cloze(text: row.front) + case .typing: + fields = CardFieldsBuilder.typing(front: row.front, answer: row.back) + case .multipleChoice: + fields = CardFieldsBuilder.multipleChoice(front: row.front, answer: row.back) + case .imageOcclusion, .audioFront: + // Media-Types brauchen Uploads — überspringe in CSV-Import. + csvImportProgress = index + 1 + continue + } + _ = try await api.createCard(CardCreateBody( + deckId: deck.id, + type: row.type, + fields: fields, + mediaRefs: nil + )) + csvImportProgress = index + 1 + } + return deck + } + + private var aiTextBody: DeckGenerateBody { + DeckGenerateBody( + prompt: aiPrompt.trimmingCharacters(in: .whitespaces), + language: aiLanguage, + count: aiCount, + url: DeckEditorHelpers.nonEmpty(aiUrl) + ) + } +} + +// swiftlint:enable type_body_length + +// MARK: - Manual form + +private struct ManualFormSections: View { + @Binding var name: String + @Binding var description: String + @Binding var color: String + @Binding var category: DeckCategory? + @Binding var visibility: DeckVisibility + /// `nil` im Create-Modus — dann wird der Toggle nicht gezeigt. + var archived: Binding? + + var body: some View { + Section("Name") { + TextField("Deck-Name", text: $name) + .textInputAutocapitalization(.sentences) + } + + Section("Beschreibung") { + TextField("optional", text: $description, axis: .vertical) + .lineLimit(2 ... 4) + } + + Section("Farbe") { + ScrollView(.horizontal, showsIndicators: false) { + HStack(spacing: 10) { + ForEach(DeckEditorPresets.colors, id: \.self) { hex in + ColorSwatchButton(hex: hex, isSelected: color == hex) { + color = hex + } + } + } + .padding(.vertical, 4) + } + } + + Section("Kategorie") { + Picker("Kategorie", selection: $category) { + Text("Keine").tag(DeckCategory?.none) + ForEach(DeckCategory.allCases, id: \.self) { cat in + Text(cat.label).tag(DeckCategory?.some(cat)) + } + } + } + + Section("Sichtbarkeit") { + Picker("Sichtbarkeit", selection: $visibility) { + Text("Privat").tag(DeckVisibility.private) + Text("Space").tag(DeckVisibility.space) + Text("Öffentlich").tag(DeckVisibility.public) + } + .pickerStyle(.segmented) + } + + if let archived { + Section { + Toggle("Archiviert", isOn: archived) + } footer: { + Text("Archivierte Decks erscheinen nicht in der Hauptliste. Bestehende FSRS-Reviews bleiben erhalten.") + } + } + } +} + +private struct ColorSwatchButton: View { + let hex: String + let isSelected: Bool + let onTap: () -> Void + + var body: some View { Circle() .fill(Color.swatchFromHex(hex)) .frame(width: 36, height: 36) @@ -127,51 +532,200 @@ struct DeckEditorView: View { Circle() .stroke(isSelected ? CardsTheme.foreground : CardsTheme.border, lineWidth: isSelected ? 3 : 1) ) - .onTapGesture { color = hex } + .onTapGesture(perform: onTap) } +} - private func submit() async { - isSubmitting = true - errorMessage = nil - defer { isSubmitting = false } - let api = CardsAPI(auth: auth) +// MARK: - AI text form - do { - switch mode { - case .create: - let body = DeckCreateBody( - name: name.trimmingCharacters(in: .whitespaces), - description: nonEmpty(description), - color: color, - category: category, - visibility: visibility - ) - let deck = try await api.createDeck(body) - onSaved(deck) - dismiss() - case let .edit(deckId): - let body = DeckUpdateBody( - name: name.trimmingCharacters(in: .whitespaces), - description: nonEmpty(description), - color: color, - category: category, - visibility: visibility - ) - let deck = try await api.updateDeck(id: deckId, body: body) - onSaved(deck) - dismiss() +private struct AITextFormSections: View { + @Binding var prompt: String + + var body: some View { + Section { + TextField( + "z.B. Bodensee-Geographie, französische Verben", + text: $prompt, + axis: .vertical + ) + .lineLimit(3 ... 6) + .textInputAutocapitalization(.sentences) + } header: { + Text("Thema") + } footer: { + Text("3–500 Zeichen. Je präziser, desto besser die Karten.") + } + } +} + +// MARK: - AI media form + +private struct AIMediaFormSections: View { + @Binding var files: [GenerationMediaFile] + @Binding var photoItems: [PhotosPickerItem] + @Binding var showPDFImporter: Bool + + var body: some View { + Section { + mediaPickers + ForEach(files) { file in + MediaFileRow(file: file) { + files.removeAll { $0.id == file.id } + } } - } catch { - errorMessage = (error as? LocalizedError)?.errorDescription ?? String(describing: error) + } header: { + Text("Quellen") + } footer: { + Text("Max. \(DeckEditorPresets.maxMediaFiles) Dateien. Bilder ≤ 10 MB, PDFs ≤ 30 MB.") } } - private func nonEmpty(_ s: String) -> String? { - let trimmed = s.trimmingCharacters(in: .whitespaces) - return trimmed.isEmpty ? nil : trimmed + @ViewBuilder + private var mediaPickers: some View { + let remaining = DeckEditorPresets.maxMediaFiles - files.count + + PhotosPicker( + selection: $photoItems, + maxSelectionCount: max(remaining, 0), + matching: .images + ) { + Label("Fotos hinzufügen", systemImage: "photo.on.rectangle.angled") + } + .disabled(remaining <= 0) + + Button { + showPDFImporter = true + } label: { + Label("PDFs hinzufügen", systemImage: "doc.text") + } + .disabled(remaining <= 0) } } +private struct MediaFileRow: View { + let file: GenerationMediaFile + let onRemove: () -> Void + + var body: some View { + HStack(spacing: 12) { + thumbnail + .frame(width: 40, height: 40) + .clipShape(RoundedRectangle(cornerRadius: 6, style: .continuous)) + VStack(alignment: .leading, spacing: 2) { + Text(file.filename) + .font(.subheadline) + .lineLimit(1) + Text(file.sizeLabel) + .font(.caption) + .foregroundStyle(CardsTheme.mutedForeground) + } + Spacer() + Button(action: onRemove) { + Image(systemName: "xmark.circle.fill") + .foregroundStyle(CardsTheme.mutedForeground) + } + .buttonStyle(.plain) + .accessibilityLabel("Entfernen") + } + } + + @ViewBuilder + private var thumbnail: some View { + if file.isPDF { + ZStack { + CardsTheme.muted + Image(systemName: "doc.text.fill") + .foregroundStyle(CardsTheme.primary) + } + } else if let img = PlatformImage(data: file.data) { + #if canImport(UIKit) + Image(uiImage: img) + .resizable() + .scaledToFill() + #else + Image(nsImage: img) + .resizable() + .scaledToFill() + #endif + } else { + CardsTheme.muted + } + } +} + +// MARK: - Shared AI controls + +private struct AISharedSections: View { + @Binding var count: Int + @Binding var language: GenerationLanguage + @Binding var url: String + + var body: some View { + Section("Anzahl Karten") { + Stepper(value: $count, in: 3 ... 40) { + Text("\(count) Karten") + } + } + + Section("Sprache") { + Picker("Sprache", selection: $language) { + ForEach(GenerationLanguage.allCases, id: \.self) { lang in + Text(lang.label).tag(lang) + } + } + .pickerStyle(.segmented) + } + + Section { + TextField("https://…", text: $url) + .textInputAutocapitalization(.never) + .autocorrectionDisabled(true) + .keyboardType(.URL) + } header: { + Text("Zusätzliche URL (optional)") + } footer: { + Text("KI liest den Inhalt der Seite als zusätzliche Quelle.") + } + } +} + +// MARK: - Generation overlay + +private struct GenerationOverlay: View { + let message: String + let onCancel: () -> Void + + var body: some View { + ZStack { + Color.black.opacity(0.55) + .ignoresSafeArea() + VStack(spacing: 16) { + ProgressView() + .controlSize(.large) + .tint(CardsTheme.primary) + Text(message) + .font(.headline) + .foregroundStyle(CardsTheme.foreground) + .multilineTextAlignment(.center) + Text("Das kann eine Weile dauern.") + .font(.caption) + .foregroundStyle(CardsTheme.mutedForeground) + .multilineTextAlignment(.center) + Button("Abbrechen", action: onCancel) + .buttonStyle(.bordered) + .tint(CardsTheme.mutedForeground) + .padding(.top, 4) + } + .padding(24) + .frame(maxWidth: 320) + .background(.regularMaterial, in: RoundedRectangle(cornerRadius: 16, style: .continuous)) + } + .transition(.opacity) + } +} + +// MARK: - Color helper + extension Color { static func swatchFromHex(_ hex: String) -> Color { var trimmed = hex.trimmingCharacters(in: .whitespacesAndNewlines) @@ -179,9 +733,9 @@ extension Color { guard let rgb = UInt32(trimmed, radix: 16) else { return CardsTheme.primary } - let r = Double((rgb >> 16) & 0xFF) / 255.0 - let g = Double((rgb >> 8) & 0xFF) / 255.0 - let b = Double(rgb & 0xFF) / 255.0 - return Color(red: r, green: g, blue: b) + let red = Double((rgb >> 16) & 0xFF) / 255.0 + let green = Double((rgb >> 8) & 0xFF) / 255.0 + let blue = Double(rgb & 0xFF) / 255.0 + return Color(red: red, green: green, blue: blue) } } diff --git a/Sources/Features/Marketplace/MarketplacePublishView.swift b/Sources/Features/Marketplace/MarketplacePublishView.swift new file mode 100644 index 0000000..b98f7c6 --- /dev/null +++ b/Sources/Features/Marketplace/MarketplacePublishView.swift @@ -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/.") + } + } + + 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 + } +} diff --git a/Sources/Features/Marketplace/PublicDeckView.swift b/Sources/Features/Marketplace/PublicDeckView.swift index 1cf4b54..0dd3bd7 100644 --- a/Sources/Features/Marketplace/PublicDeckView.swift +++ b/Sources/Features/Marketplace/PublicDeckView.swift @@ -1,13 +1,18 @@ +import ManaAuthUI import ManaCore import SwiftData import SwiftUI +// swiftlint:disable type_body_length + /// Detail-View für ein Public-Deck. Subscribe-Button löst Auto-Fork /// serverseitig aus und navigiert anschließend zur eigenen Deck-Detail. +/// Toolbar-Menu („…") hostet Report + Block-Author (App-Review-Pflicht). struct PublicDeckView: View { let slug: String @Environment(AuthClient.self) private var auth + @Environment(ManaAuthGate.self) private var authGate @Environment(\.modelContext) private var context @State private var detail: PublicDeckDetail? @State private var isLoading = false @@ -15,6 +20,11 @@ struct PublicDeckView: View { @State private var errorMessage: String? @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 { ZStack { CardsTheme.background.ignoresSafeArea() @@ -24,9 +34,69 @@ struct PublicDeckView: View { #if os(iOS) .navigationBarTitleDisplayMode(.inline) #endif + .toolbar { + if detail != nil { + ToolbarItem(placement: .topBarTrailing) { + moderationMenu + } + } + } .task(id: slug) { 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 @@ -122,7 +192,6 @@ struct PublicDeckView: View { .padding(.horizontal, 16) } - @ViewBuilder private func subscribeSection(detail: PublicDeckDetail) -> some View { VStack(spacing: 12) { if let subscribed { @@ -147,7 +216,9 @@ struct PublicDeckView: View { .foregroundStyle(CardsTheme.mutedForeground) } else { Button { - Task { await subscribe(detail: detail) } + authGate.require(reason: "marketplace-subscribe") { + Task { await subscribe(detail: detail) } + } } label: { HStack { if isSubscribing { @@ -156,8 +227,8 @@ struct PublicDeckView: View { .tint(CardsTheme.primaryForeground) } Text(detail.deck.priceCredits > 0 - ? "Abonnieren (\(detail.deck.priceCredits) Credits)" - : "Abonnieren") + ? "Abonnieren (\(detail.deck.priceCredits) Credits)" + : "Abonnieren") .fontWeight(.semibold) } .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 errorMessage = nil defer { isSubscribing = false } @@ -199,3 +280,5 @@ struct PublicDeckView: View { } } } + +// swiftlint:enable type_body_length diff --git a/Sources/Features/Marketplace/ReportDeckSheet.swift b/Sources/Features/Marketplace/ReportDeckSheet.swift new file mode 100644 index 0000000..58007e1 --- /dev/null +++ b/Sources/Features/Marketplace/ReportDeckSheet.swift @@ -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)) + } +} diff --git a/Sources/Features/Settings/BlockedAuthorsView.swift b/Sources/Features/Settings/BlockedAuthorsView.swift new file mode 100644 index 0000000..0b70d02 --- /dev/null +++ b/Sources/Features/Settings/BlockedAuthorsView.swift @@ -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) + } + } +} diff --git a/Sources/Features/Settings/SettingsView.swift b/Sources/Features/Settings/SettingsView.swift index e5df7c0..9a36d9c 100644 --- a/Sources/Features/Settings/SettingsView.swift +++ b/Sources/Features/Settings/SettingsView.swift @@ -40,10 +40,20 @@ struct SettingsView: View { } if notifications.authorization == .denied { - Label("Benachrichtigungen sind in den iOS-Einstellungen blockiert.", - systemImage: "exclamationmark.circle") - .font(.caption) - .foregroundStyle(CardsTheme.warning) + Label( + "Benachrichtigungen sind in den iOS-Einstellungen blockiert.", + systemImage: "exclamationmark.circle" + ) + .font(.caption) + .foregroundStyle(CardsTheme.warning) + } + } + + Section("Marketplace") { + NavigationLink { + BlockedAuthorsView() + } label: { + Label("Blockierte Authors", systemImage: "hand.raised") } } diff --git a/Sources/Features/Study/StudySessionView.swift b/Sources/Features/Study/StudySessionView.swift index 822134f..7a31faf 100644 --- a/Sources/Features/Study/StudySessionView.swift +++ b/Sources/Features/Study/StudySessionView.swift @@ -3,7 +3,7 @@ import SwiftData import SwiftUI #if canImport(UIKit) -import UIKit + import UIKit #endif /// Vollbild-Study-View. Wird per Navigation aus DeckListView geöffnet. @@ -73,26 +73,7 @@ struct StudySessionView: View { session.flip() } keyboardShortcuts(session: session) - if session.isFlipped { - 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) - } + bottomBar(session: session) } } .padding(.bottom, 20) @@ -100,6 +81,36 @@ struct StudySessionView: View { .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 { CardSurface(size: .hero, elevation: .raised) { CardRenderer( @@ -155,7 +166,6 @@ struct StudySessionView: View { /// Unsichtbare Buttons mit Keyboard-Shortcuts. Funktionieren auf /// iPad (Magic Keyboard) und macOS. Space = flip, 1-4 = Rating. - @ViewBuilder private func keyboardShortcuts(session: StudySession) -> some View { Group { Button("Flip") { @@ -180,7 +190,7 @@ struct StudySessionView: View { private func flipHaptic() { #if canImport(UIKit) - UIImpactFeedbackGenerator(style: .soft).impactOccurred() + UIImpactFeedbackGenerator(style: .soft).impactOccurred() #endif } }