zitare-native/Sources/Core/API/ZitareAPI.swift
Till JS 127c81b74c ζ-3: SubmitQuoteView nativ (Form + authGate + POST /quotes)
Native Submit-Flow gegen zitare-api. SwiftUI-Form mit:
- TextEditor mit 10-1000-Zeichen-Validation + Counter
- Sprache (de/en/fr/es/it Picker)
- Author-Name (mandatory)
- Optional Source-Section (Toggle): Titel + Art (book/article/talk/film/other) + Jahr
- CC-BY-SA-4.0-Zustimmung als Pflicht-Toggle
- Submit-Button erst aktiv wenn alle 3 Bedingungen erfüllt
- authGate.require(reason: "submit") öffnet Login-Sheet wenn nötig;
  Submit feuert auto nach signedIn
- Error-Banner mit lokalisiertem API-Code (api.error.<code> wird
  in xcstrings nachgeschlagen)
- Success-Banner mit Slug + "wartet auf Moderation"-Hinweis

Neu in Submit-Tab als 4. Tab (Lesen / Erkunden / Einreichen / Konto).

- ZitareAPI: submitQuote(_:), QuoteDraft, SubmittedQuote, ZitareAPIError
- SubmitQuoteView ersetzt Placeholder-Stub
- RootView: AppTab.submit ergänzt

Offen: Offline-Queue (PendingSubmission via SwiftData) — bei Network-
Failure bleibt der Draft im Form-State und User retried manuell.
Nicht in ζ-3 abgeschlossen, gehört in ζ-3.5.

Offen: api.error.*-Keys in zitare-native Localizable.xcstrings —
aktuell nur DE-Source. EN/FR/ES/IT folgen separat.

iOS + macOS BUILD SUCCEEDED.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-19 15:49:51 +02:00

106 lines
3.8 KiB
Swift

import Foundation
import ManaCore
/// Zitare-spezifischer API-Client. Wrapper um `AuthenticatedTransport`
/// aus ManaCore, der die zitare-api-Endpoints kennt.
actor ZitareAPI {
let transport: AuthenticatedTransport
let decoder: JSONDecoder
let encoder: JSONEncoder
init(auth: AuthClient) {
transport = AuthenticatedTransport(baseURL: AppConfig.apiBaseURL, auth: auth)
decoder = JSONDecoder()
encoder = JSONEncoder()
}
/// `GET /healthz` verifiziert dass zitare-api erreichbar ist.
/// Öffentlicher Endpoint, läuft direkt via `URLSession` (nicht
/// `AuthenticatedTransport`), damit auch nicht-eingeloggte Apps
/// die API-Erreichbarkeit prüfen können.
func healthCheck() async throws -> Bool {
let url = AppConfig.apiBaseURL.appendingPathComponent("healthz")
let (_, response) = try await URLSession.shared.data(from: url)
guard let http = response as? HTTPURLResponse else { return false }
return http.statusCode == 200
}
/// `POST /api/v1/quotes` Quote-Draft einreichen. Server-Schema:
/// `zitare/apps/api/src/routes/quotes.ts:submissionSchema`. Pflicht:
/// text + acceptedTos:true + (authorName ODER authorSlug). Optionale
/// Source-Felder kommen mit, wenn der User sie ausgefüllt hat.
func submitQuote(_ draft: QuoteDraft) async throws -> SubmittedQuote {
let payload = try encoder.encode(draft)
let (data, http) = try await transport.request(
path: "/api/v1/quotes",
method: "POST",
body: payload
)
try ensureOK(http, data: data)
return try decoder.decode(SubmittedQuote.self, from: data)
}
// MARK: - Helpers
private func ensureOK(_ http: HTTPURLResponse, data: Data) throws {
guard http.statusCode >= 400 else { return }
let body = String(data: data, encoding: .utf8) ?? ""
let code = Self.parseErrorCode(from: body)
throw ZitareAPIError(status: http.statusCode, code: code, body: body)
}
private static func parseErrorCode(from body: String) -> String? {
guard let data = body.data(using: .utf8),
let obj = try? JSONSerialization.jsonObject(with: data) as? [String: Any],
let code = obj["error"] as? String
else { return nil }
return code
}
}
// MARK: - DTOs
/// Submit-Payload für `POST /api/v1/quotes`. JSON-Keys matchen
/// `submissionSchema` aus `zitare/apps/api/src/routes/quotes.ts`.
struct QuoteDraft: Codable, Sendable, Equatable {
var text: String
var language: String
var authorName: String?
var authorSlug: String?
var sourceTitle: String?
var sourceKind: SourceKind?
var sourceYear: Int?
var editReason: String?
var acceptedTos: Bool
enum SourceKind: String, Codable, Sendable, CaseIterable, Identifiable {
case book, article, talk, film, other
var id: String { rawValue }
}
}
/// Response von `POST /api/v1/quotes`. Server gibt das volle
/// QuoteRow zurück wir brauchen für die UI nur slug + status.
struct SubmittedQuote: Codable, Sendable {
let slug: String
let status: String
}
/// Lokalisierter API-Fehler. `code` ist der server-seitige Code aus
/// `{ "error": "<code>" }`, der via `api.error.<code>` im
/// Localizable.xcstrings nachgeschlagen wird.
struct ZitareAPIError: LocalizedError {
let status: Int
let code: String?
let body: String
var errorDescription: String? {
if let code, !code.isEmpty {
let key = "api.error.\(code)"
let localized = Bundle.main.localizedString(forKey: key, value: nil, table: nil)
if localized != key { return localized }
return "Fehler \(status): \(code)"
}
return "HTTP \(status)"
}
}