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>
160 lines
5.7 KiB
Swift
160 lines
5.7 KiB
Swift
import ManaAuthUI
|
|
import ManaCore
|
|
import ManaWebShell
|
|
import SwiftUI
|
|
|
|
@MainActor
|
|
private func makeWebShellConfig() -> WebShellConfig {
|
|
WebShellConfig(
|
|
allowedHosts: [
|
|
"zitare.com",
|
|
"www.zitare.com",
|
|
"*.mana.how",
|
|
],
|
|
userAgent: AppConfig.userAgent,
|
|
backgroundColor: ZitareTheme.background,
|
|
progressTint: ZitareTheme.primary,
|
|
errorBackgroundColor: ZitareTheme.muted,
|
|
errorForegroundColor: ZitareTheme.foreground,
|
|
errorIconColor: ZitareTheme.warning,
|
|
userScripts: [
|
|
// Syncs System-Dark-Mode in den WebView; zitare-web liest
|
|
// `localStorage['zitare-mode']` beim First Paint und toggelt
|
|
// dann `.dark` auf <html>.
|
|
WebShellScripts.syncDarkMode(localStorageKey: "zitare-mode"),
|
|
// Versteckt den zitare-web-Header (Brand-Logo + Nav), weil
|
|
// die native TabBar bereits global navigiert.
|
|
WebShellScripts.hideElements(
|
|
selectors: [
|
|
"header[data-app-nav]",
|
|
"body header:has(a.brand)",
|
|
"body > header:first-of-type",
|
|
"body > div > header:first-of-type",
|
|
],
|
|
tagName: "hide-web-header"
|
|
),
|
|
]
|
|
)
|
|
}
|
|
|
|
/// Top-Level-View: TabView mit drei Tabs.
|
|
///
|
|
/// **Phase ζ-1:** Lesen + Erkunden laden `zitare.com` via `WebShellView`.
|
|
/// Universal-Links auf `zitare.com/q/<slug>` / `/a/<slug>` etc. öffnen
|
|
/// die App und routen in den passenden Tab.
|
|
struct RootView: View {
|
|
@Environment(AuthClient.self) private var auth
|
|
@Environment(ManaAuthGate.self) private var authGate
|
|
@State private var selectedTab: AppTab = .read
|
|
@State private var readTarget = WebTarget(url: AppConfig.webBaseURL)
|
|
@State private var exploreTarget = WebTarget(
|
|
url: AppConfig.webBaseURL.appendingPathComponent("explore")
|
|
)
|
|
@State private var reloadCounter: Int = 0
|
|
@State private var healthStatus: HealthStatus = .unknown
|
|
|
|
private var webShellConfig: WebShellConfig { makeWebShellConfig() }
|
|
|
|
var body: some View {
|
|
TabView(selection: $selectedTab) {
|
|
WebShellView(target: readTarget, config: webShellConfig)
|
|
.tabItem { Label("Lesen", systemImage: "book") }
|
|
.tag(AppTab.read)
|
|
|
|
WebShellView(target: exploreTarget, config: webShellConfig)
|
|
.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)
|
|
}
|
|
// Mac-Window-Hintergrund auf Paper-Theme setzen, damit der
|
|
// TabBar-/Title-Bar-Bereich oben nicht mit dem System-Grau
|
|
// gegen das Paper-Theme ausreißt. `windowToolbar`-Placement
|
|
// ist macOS-only.
|
|
.background(ZitareTheme.background.ignoresSafeArea())
|
|
#if os(macOS)
|
|
.toolbarBackground(ZitareTheme.background, for: .windowToolbar)
|
|
.toolbarBackground(.visible, for: .windowToolbar)
|
|
#endif
|
|
.manaBrand(ZitareBrand.manaBrand)
|
|
.manaAuthGate(authGate) {
|
|
NavigationStack {
|
|
ManaLoginView(
|
|
auth: auth,
|
|
onSignUpTapped: {},
|
|
onForgotTapped: {}
|
|
)
|
|
.manaBrand(ZitareBrand.manaBrand)
|
|
}
|
|
}
|
|
.task {
|
|
await probeHealth()
|
|
}
|
|
.onOpenURL { url in
|
|
handle(url: url)
|
|
}
|
|
.onContinueUserActivity(NSUserActivityTypeBrowsingWeb) { activity in
|
|
if let url = activity.webpageURL { handle(url: url) }
|
|
}
|
|
}
|
|
|
|
private func probeHealth() async {
|
|
let api = ZitareAPI(auth: auth)
|
|
do {
|
|
let ok = try await api.healthCheck()
|
|
healthStatus = ok ? .ok : .down
|
|
Log.api.info("Healthz: \(ok ? "OK" : "DOWN")")
|
|
} catch {
|
|
healthStatus = .down
|
|
Log.api.warning(
|
|
"Healthz fehlgeschlagen: \(String(describing: error), privacy: .public)"
|
|
)
|
|
}
|
|
}
|
|
|
|
/// Universal-Link- und Custom-URL-Routing. Wird sowohl von
|
|
/// `onOpenURL` (Custom-Scheme `zitare://...`) als auch von
|
|
/// `onContinueUserActivity` (Universal-Links auf `zitare.com/...`)
|
|
/// aufgerufen.
|
|
///
|
|
/// Routing-Regeln (gespiegelt zu `app-manifest.json#link_patterns`):
|
|
/// - `/q/<slug>`, `/a/<slug>`, `/c/<slug>` → Lesen-Tab
|
|
/// - `/heute`, `/random`, `/feed.rss` → Lesen-Tab
|
|
/// - `/explore`, `/region/...`, `/thema/...`, `/rolle/...`,
|
|
/// `/epoche/...`, `/sprache/...`, `/search`, `/t/...` → Erkunden-Tab
|
|
/// - alles andere unter `zitare.com` → Lesen-Tab, Root-Pfad
|
|
///
|
|
/// Custom-Scheme `zitare://quote/<slug>` wird auf
|
|
/// `https://zitare.com/q/<slug>` umgemappt.
|
|
private func handle(url: URL) {
|
|
Log.app.info("Deep-Link empfangen: \(url.absoluteString, privacy: .public)")
|
|
let routed = DeepLinkRouter.route(url, base: AppConfig.webBaseURL)
|
|
reloadCounter += 1
|
|
if routed.isExplore {
|
|
exploreTarget = WebTarget(url: routed.url, reloadToken: reloadCounter)
|
|
selectedTab = .explore
|
|
} else {
|
|
readTarget = WebTarget(url: routed.url, reloadToken: reloadCounter)
|
|
selectedTab = .read
|
|
}
|
|
}
|
|
}
|
|
|
|
enum AppTab: Hashable {
|
|
case read
|
|
case explore
|
|
case submit
|
|
case account
|
|
}
|
|
|
|
enum HealthStatus {
|
|
case unknown
|
|
case ok
|
|
case down
|
|
}
|