ζ-1: WebShellView + Universal-Link-Routing
- WebShellView (UIViewRepresentable + NSViewRepresentable) wrapt WKWebView, KVO-Observation für Loading/Progress/canGoBack/URL, Pull-to-Refresh via UIRefreshControl - WebShellCoordinator (MainActor) hält WKNavigationDelegate + WKUIDelegate, externe Links via openURL aus dem Environment in System-Browser, Host-Whitelist auf zitare.com + .mana.how - RootView refactored: Lesen-Tab lädt webBaseURL/, Erkunden-Tab /explore. Universal-Links zitare.com/q|a|c/<slug>, /search, /region/*, /thema/* etc. routen in den passenden Tab, reloadToken zwingt Re-Navigation auch bei selber URL - AppConfig.webBaseURL = appBaseURL (zitare.mana.how) bis Cloudflare-Zone für zitare.com live ist; publicWebURL als Konstante schon eingetragen - CookieBridge-Skeleton für mana.access auf .mana.how — scharfgeschaltet erst in ζ-3 nach Live-Auth-Smoke - iPhone 16e Simulator: zitare.mana.how lädt, Carl-Spitteler-Quote rendert, Healthz weiter 200 - 16 Files swiftlint-grün, alle Tests grün Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
0bd59ed148
commit
75b5e7113f
5 changed files with 467 additions and 78 deletions
|
|
@ -3,31 +3,28 @@ import SwiftUI
|
|||
|
||||
/// Top-Level-View: TabView mit drei Tabs.
|
||||
///
|
||||
/// **Phase ζ-0 — Setup.** Tabs zeigen aktuell nur Placeholder-Views.
|
||||
/// Ab Phase ζ-1 wird der Lesen-Tab durch `WebShellView` ersetzt,
|
||||
/// der `zitare.com` im `WKWebView` rendert.
|
||||
/// **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
|
||||
@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
|
||||
|
||||
var body: some View {
|
||||
TabView(selection: $selectedTab) {
|
||||
placeholderView(
|
||||
title: "Lesen",
|
||||
subtitle: "ζ-1: WebShellView gegen zitare.com",
|
||||
systemImage: "book"
|
||||
)
|
||||
.tabItem { Label("Lesen", systemImage: "book") }
|
||||
.tag(AppTab.read)
|
||||
WebShellView(target: readTarget)
|
||||
.tabItem { Label("Lesen", systemImage: "book") }
|
||||
.tag(AppTab.read)
|
||||
|
||||
placeholderView(
|
||||
title: "Erkunden",
|
||||
subtitle: "ζ-1: WebShell auf zitare.com/explore",
|
||||
systemImage: "sparkle.magnifyingglass"
|
||||
)
|
||||
.tabItem { Label("Erkunden", systemImage: "sparkle.magnifyingglass") }
|
||||
.tag(AppTab.explore)
|
||||
WebShellView(target: exploreTarget)
|
||||
.tabItem { Label("Erkunden", systemImage: "sparkle.magnifyingglass") }
|
||||
.tag(AppTab.explore)
|
||||
|
||||
AccountView(healthStatus: healthStatus)
|
||||
.tabItem { Label("Konto", systemImage: "person.circle") }
|
||||
|
|
@ -44,9 +41,6 @@ struct RootView: View {
|
|||
}
|
||||
}
|
||||
|
||||
/// Phase ζ-0 Probe: ein Aufruf gegen `/healthz` bei App-Start.
|
||||
/// Ab Phase ζ-1 wandert das in einen Service, der den Status global
|
||||
/// trackt und im AccountView anzeigt.
|
||||
private func probeHealth() async {
|
||||
let api = ZitareAPI(auth: auth)
|
||||
do {
|
||||
|
|
@ -61,34 +55,61 @@ struct RootView: View {
|
|||
}
|
||||
}
|
||||
|
||||
/// Deep-Link-Routing. Phase ζ-0: nur loggen. Phase ζ-1: in den
|
||||
/// passenden Tab + Pfad routen.
|
||||
/// 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)")
|
||||
// ζ-1 TODO: parse zitare.com/q/<slug>, /a/<slug>, /c/<slug>
|
||||
// und in WebShellView mit entsprechender URL laden.
|
||||
|
||||
let resolved = resolveToWebURL(url)
|
||||
let path = resolved.path
|
||||
reloadCounter += 1
|
||||
|
||||
if isExplorePath(path) {
|
||||
exploreTarget = WebTarget(url: resolved, reloadToken: reloadCounter)
|
||||
selectedTab = .explore
|
||||
} else {
|
||||
readTarget = WebTarget(url: resolved, reloadToken: reloadCounter)
|
||||
selectedTab = .read
|
||||
}
|
||||
}
|
||||
|
||||
private func placeholderView(
|
||||
title: String,
|
||||
subtitle: String,
|
||||
systemImage: String
|
||||
) -> some View {
|
||||
VStack(spacing: 16) {
|
||||
Image(systemName: systemImage)
|
||||
.font(.system(size: 48))
|
||||
.foregroundStyle(ZitareTheme.primary)
|
||||
Text(title)
|
||||
.font(.title2)
|
||||
.fontWeight(.semibold)
|
||||
Text(subtitle)
|
||||
.font(.callout)
|
||||
.foregroundStyle(ZitareTheme.mutedForeground)
|
||||
.multilineTextAlignment(.center)
|
||||
.padding(.horizontal, 32)
|
||||
/// `zitare://quote/spitteler-...` → `https://zitare.com/q/spitteler-...`.
|
||||
/// `zitare://author/x` → `https://zitare.com/a/x`.
|
||||
/// `zitare://collection/x` → `https://zitare.com/c/x`.
|
||||
/// `https://zitare.com/<anything>` bleibt wie es ist.
|
||||
private func resolveToWebURL(_ url: URL) -> URL {
|
||||
if url.scheme == "zitare" {
|
||||
let host = url.host ?? ""
|
||||
let path = url.path
|
||||
switch host {
|
||||
case "quote":
|
||||
return AppConfig.webBaseURL.appendingPathComponent("q\(path)")
|
||||
case "author":
|
||||
return AppConfig.webBaseURL.appendingPathComponent("a\(path)")
|
||||
case "collection":
|
||||
return AppConfig.webBaseURL.appendingPathComponent("c\(path)")
|
||||
default:
|
||||
return AppConfig.webBaseURL
|
||||
}
|
||||
}
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||
.background(ZitareTheme.background)
|
||||
return url
|
||||
}
|
||||
|
||||
private func isExplorePath(_ path: String) -> Bool {
|
||||
let prefixes = ["/explore", "/region", "/thema", "/rolle", "/epoche", "/sprache", "/search", "/t/"]
|
||||
return prefixes.contains { path == $0 || path.hasPrefix($0 + "/") }
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue