From 127c81b74c0b09750ad653428601ce1017a91cfe Mon Sep 17 00:00:00 2001 From: Till JS Date: Tue, 19 May 2026 15:49:51 +0200 Subject: [PATCH] =?UTF-8?q?=CE=B6-3:=20SubmitQuoteView=20nativ=20(Form=20+?= =?UTF-8?q?=20authGate=20+=20POST=20/quotes)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. 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) --- Sources/App/RootView.swift | 5 + Sources/Core/API/ZitareAPI.swift | 87 +++++- Sources/Features/Submit/SubmitQuoteView.swift | 264 ++++++++++++++++-- 3 files changed, 323 insertions(+), 33 deletions(-) diff --git a/Sources/App/RootView.swift b/Sources/App/RootView.swift index 6f7672c..d5a6f6f 100644 --- a/Sources/App/RootView.swift +++ b/Sources/App/RootView.swift @@ -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 } diff --git a/Sources/Core/API/ZitareAPI.swift b/Sources/Core/API/ZitareAPI.swift index d55dbf1..44ad828 100644 --- a/Sources/Core/API/ZitareAPI.swift +++ b/Sources/Core/API/ZitareAPI.swift @@ -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": "" }`, der via `api.error.` 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)" + } } diff --git a/Sources/Features/Submit/SubmitQuoteView.swift b/Sources/Features/Submit/SubmitQuoteView.swift index e5d5997..a473a44 100644 --- a/Sources/Features/Submit/SubmitQuoteView.swift +++ b/Sources/Features/Submit/SubmitQuoteView.swift @@ -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 + ) +}