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>
106 lines
3.8 KiB
Swift
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)"
|
|
}
|
|
}
|