ζ-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:
parent
7ba8684074
commit
127c81b74c
3 changed files with 323 additions and 33 deletions
|
|
@ -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
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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)"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
)
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue