ζ-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>
This commit is contained in:
Till JS 2026-05-19 15:49:51 +02:00
parent 7ba8684074
commit 127c81b74c
3 changed files with 323 additions and 33 deletions

View file

@ -65,6 +65,10 @@ struct RootView: View {
.tabItem { Label("Erkunden", systemImage: "sparkle.magnifyingglass") }
.tag(AppTab.explore)
SubmitQuoteView()
.tabItem { Label("Einreichen", systemImage: "square.and.pencil") }
.tag(AppTab.submit)
AccountView(healthStatus: healthStatus)
.tabItem { Label("Konto", systemImage: "person.circle") }
.tag(AppTab.account)
@ -145,6 +149,7 @@ struct RootView: View {
enum AppTab: Hashable {
case read
case explore
case submit
case account
}

View file

@ -3,19 +3,15 @@ import ManaCore
/// Zitare-spezifischer API-Client. Wrapper um `AuthenticatedTransport`
/// aus ManaCore, der die zitare-api-Endpoints kennt.
///
/// Phase ζ-0: nur Health-Probe. Endpoints für Submit, Share-Receive,
/// Quote-Lookup folgen in ζ-3 / ζ-4.
actor ZitareAPI {
let transport: AuthenticatedTransport
let decoder: JSONDecoder
let encoder: JSONEncoder
init(auth: AuthClient) {
transport = AuthenticatedTransport(baseURL: AppConfig.apiBaseURL, auth: auth)
decoder = JSONDecoder()
// ζ-3 TODO: bei echten DTOs `.iso8601withFractional`-Extension
// aus cards-native portieren (Server liefert ISO8601 mit
// Fractional-Seconds, Standard `.iso8601` schluckt das nicht).
encoder = JSONEncoder()
}
/// `GET /healthz` verifiziert dass zitare-api erreichbar ist.
@ -29,11 +25,82 @@ actor ZitareAPI {
return http.statusCode == 200
}
// MARK: - Phase ζ-3: Submit
/// `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)
}
// func submitQuote(_ draft: QuoteDraft) async throws -> SubmittedQuote { ... }
// MARK: - Helpers
// MARK: - Phase ζ-4: Share-Receive
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)
}
// func receiveShare(_ envelope: ShareEnvelope) async throws -> ShareReceipt { ... }
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)"
}
}

View file

@ -1,32 +1,250 @@
import ManaAuthUI
import ManaCore
import SwiftUI
/// Phase ζ-3 Placeholder native Submit-View für Quote-Drafts.
/// SwiftUI-Form für Quote-Submission. Sendet via `POST /api/v1/quotes`
/// gegen zitare-api; landet als `status='draft'` zur Moderation.
///
/// Aufgabenliste in ζ-3:
/// **Flow:**
/// 1. User füllt Text + Author + (optional) Source + Lizenz-Zustimmung
/// 2. Tap "Einreichen" `authGate.require(reason: "submit")`
/// 3. Bei `.guest`/`.signedOut` öffnet ManaLoginView. Nach Login feuert
/// die merged-Action automatisch.
/// 4. Erfolg: Toast + Form-Reset; bei Network-Failure Fehler-Banner mit
/// lokalisierter API-Error-Message.
///
/// - SwiftUI-Form mit Feldern: text (TextEditor), attribution (Author-
/// Name mit Auto-Complete aus lokalem Snapshot), language (Picker),
/// optional source (Werk, Jahr, URL), optional theme-Chips.
/// - `ManaAuthGate`-Wrap: nicht-eingeloggter Tap auf Einreichen"
/// öffnet `ManaAuthUI`-Login-Sheet.
/// - `POST /api/v1/quotes` mit `status: 'draft'` (Endpoint existiert
/// schon, Phase 2.A im Web-Repo).
/// - Offline-Queue: bei Network-Failure Draft in SwiftData
/// `PendingSubmission` persistieren, beim Reconnect retry.
/// - Erfolg: Toast + Link Im Web ansehen" (öffnet WebView auf
/// `zitare.mana.how/me` bzw. `/admin/queue` wenn Moderator).
/// **Offline-Queue (ζ-3-extended):** noch nicht implementiert bei
/// Network-Failure bleibt der Draft im Form-State, User kann manuell
/// retry. PendingSubmission/SwiftData-Queue kommt mit ζ-3.5.
struct SubmitQuoteView: View {
@Environment(AuthClient.self) private var auth
@Environment(ManaAuthGate.self) private var authGate
@State private var draft = QuoteDraft.empty
@State private var includesSource = false
@State private var submitting = false
@State private var lastError: String?
@State private var lastSuccessSlug: String?
@FocusState private var textFocused: Bool
var body: some View {
VStack(spacing: 12) {
Image(systemName: "square.and.pencil")
.font(.system(size: 36))
.foregroundStyle(ZitareTheme.primary)
Text("Quote vorschlagen")
.font(.headline)
Text("ζ-3 — TODO: SwiftUI-Form + ManaAuthGate")
.font(.caption)
.foregroundStyle(.secondary)
NavigationStack {
Form {
quoteSection
authorSection
sourceSection
licenseSection
if let lastError {
Section { errorBanner(lastError) }
}
if let lastSuccessSlug {
Section { successBanner(slug: lastSuccessSlug) }
}
Section {
Button {
triggerSubmit()
} label: {
if submitting {
ProgressView()
.frame(maxWidth: .infinity)
} else {
Label("Einreichen", systemImage: "paperplane.fill")
.frame(maxWidth: .infinity)
}
}
.buttonStyle(.borderedProminent)
.tint(ZitareTheme.primary)
.disabled(!canSubmit || submitting)
}
}
.scrollContentBackground(.hidden)
.background(ZitareTheme.background)
.navigationTitle("Quote vorschlagen")
#if os(iOS)
.navigationBarTitleDisplayMode(.inline)
#endif
}
}
// MARK: - Sections
private var quoteSection: some View {
Section("Zitat") {
TextEditor(text: $draft.text)
.font(.body)
.frame(minHeight: 120)
.focused($textFocused)
HStack {
Text("\(draft.text.count)/1000")
.font(.caption)
.foregroundStyle(textCountColor)
Spacer()
Picker("Sprache", selection: $draft.language) {
ForEach(supportedLanguages, id: \.self) { code in
Text(code.uppercased()).tag(code)
}
}
.pickerStyle(.menu)
}
}
}
private var authorSection: some View {
Section("Wer hat das gesagt?") {
TextField("Name (z.B. Mark Twain)", text: Binding(
get: { draft.authorName ?? "" },
set: { draft.authorName = $0.isEmpty ? nil : $0 }
))
#if os(iOS)
.textInputAutocapitalization(.words)
#endif
}
}
@ViewBuilder
private var sourceSection: some View {
Section {
Toggle("Quelle angeben (optional)", isOn: $includesSource)
if includesSource {
TextField("Titel (Werk, Vortrag, …)", text: Binding(
get: { draft.sourceTitle ?? "" },
set: { draft.sourceTitle = $0.isEmpty ? nil : $0 }
))
Picker("Art", selection: Binding(
get: { draft.sourceKind ?? .book },
set: { draft.sourceKind = $0 }
)) {
ForEach(QuoteDraft.SourceKind.allCases) { kind in
Text(label(for: kind)).tag(kind)
}
}
TextField("Jahr (z.B. 1885)", value: $draft.sourceYear, format: .number.grouping(.never))
#if os(iOS)
.keyboardType(.numberPad)
#endif
}
}
}
private var licenseSection: some View {
Section {
Toggle(isOn: $draft.acceptedTos) {
VStack(alignment: .leading, spacing: 4) {
Text("CC-BY-SA-4.0 zustimmen")
.fontWeight(.medium)
Text("Mit Einreichen veröffentlichst du das Zitat unter CC-BY-SA-4.0. Andere dürfen es teilen und remixen, solange sie dich nennen und das Ergebnis ebenfalls frei teilen.")
.font(.caption)
.foregroundStyle(ZitareTheme.mutedForeground)
}
}
} header: {
Text("Lizenz")
}
}
private func errorBanner(_ message: String) -> some View {
Label(message, systemImage: "exclamationmark.triangle")
.foregroundStyle(ZitareTheme.error)
.font(.callout)
}
private func successBanner(slug: String) -> some View {
VStack(alignment: .leading, spacing: 4) {
Label("Eingereicht — wartet auf Moderation", systemImage: "checkmark.seal.fill")
.foregroundStyle(ZitareTheme.success)
.font(.callout)
.fontWeight(.medium)
Text("Slug: \(slug)")
.font(.caption.monospaced())
.foregroundStyle(ZitareTheme.mutedForeground)
}
}
// MARK: - State
private var trimmedText: String {
draft.text.trimmingCharacters(in: .whitespacesAndNewlines)
}
private var hasAuthor: Bool {
let name = draft.authorName?.trimmingCharacters(in: .whitespaces) ?? ""
let slug = draft.authorSlug?.trimmingCharacters(in: .whitespaces) ?? ""
return !name.isEmpty || !slug.isEmpty
}
private var canSubmit: Bool {
trimmedText.count >= 10
&& trimmedText.count <= 1000
&& hasAuthor
&& draft.acceptedTos
}
private var textCountColor: Color {
let n = draft.text.count
if n < 10 { return ZitareTheme.mutedForeground }
if n > 1000 { return ZitareTheme.error }
return ZitareTheme.foreground
}
private let supportedLanguages = ["de", "en", "fr", "es", "it"]
// MARK: - Submit
private func triggerSubmit() {
textFocused = false
lastError = nil
lastSuccessSlug = nil
authGate.require(reason: "submit") {
Task { await self.performSubmit() }
}
}
@MainActor
private func performSubmit() async {
submitting = true
defer { submitting = false }
var payload = draft
payload.text = trimmedText
if !includesSource {
payload.sourceTitle = nil
payload.sourceKind = nil
payload.sourceYear = nil
}
let api = ZitareAPI(auth: auth)
do {
let result = try await api.submitQuote(payload)
Log.app.info("Submit erfolgreich: slug=\(result.slug, privacy: .public)")
lastSuccessSlug = result.slug
draft = .empty
includesSource = false
} catch let error as LocalizedError {
lastError = error.errorDescription ?? "Fehler"
} catch {
lastError = String(describing: error)
}
}
private func label(for kind: QuoteDraft.SourceKind) -> LocalizedStringKey {
switch kind {
case .book: "Buch"
case .article: "Artikel"
case .talk: "Vortrag"
case .film: "Film"
case .other: "Anderes"
}
.frame(maxWidth: .infinity, maxHeight: .infinity)
}
}
extension QuoteDraft {
static let empty = QuoteDraft(
text: "",
language: "de",
authorName: nil,
authorSlug: nil,
sourceTitle: nil,
sourceKind: nil,
sourceYear: nil,
editReason: nil,
acceptedTos: false
)
}