M31: Handbuch in der App (Textanker, DE+EN)
"?"-Hilfe-Buttons in allen 6 Haupt-Tabs, allen 6 Wizard-Schritten und allen 45 Experte-Menüs öffnen ein Handbuch-Fenster (WKWebView) und springen per Textanker direkt zur passenden Manual-Stelle. build-manual.py generalisiert auf beliebig viele Sprachen (LANGUAGES- Dict) statt hart DE/EN. Manual.en.md: komplette Handübersetzung aller Fließtext-Kapitel. Kapitel 5 (Experte-Referenz) wird pro Sprache automatisch übersetzt, indem L10n.swifts eigenes App-Übersetzungs- Dictionary wiederverwendet wird (714 Einträge geparst) statt einer zweiten, separat gepflegten Übersetzung. Live bestätigt (DE und EN). Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
@@ -3,6 +3,7 @@ import SwiftUI
|
||||
@main
|
||||
struct RouterOSAssistantApp: App {
|
||||
@StateObject private var connectionService = ConnectionService()
|
||||
@StateObject private var manualNavigator = ManualNavigator()
|
||||
/// Manual language override — independent of the system locale, per the user's explicit
|
||||
/// request for an in-app toggle button. `.environment(\.locale, ...)` does NOT make
|
||||
/// `Text(LocalizedStringKey)` re-resolve against Localizable.xcstrings at runtime (confirmed
|
||||
@@ -53,11 +54,18 @@ struct RouterOSAssistantApp: App {
|
||||
// (\.font, ...)` only ever supplies a *default*: anywhere `.appFont(...)` already sets
|
||||
// an explicit font, that still wins.
|
||||
.environment(\.font, .system(size: 13 * ((AppTextSize(rawValue: textSizeRaw) ?? .standard).scale)))
|
||||
.environmentObject(manualNavigator)
|
||||
}
|
||||
.environment(\.locale, Locale(identifier: appLanguage))
|
||||
|
||||
Settings {
|
||||
SettingsView()
|
||||
.environmentObject(manualNavigator)
|
||||
}
|
||||
|
||||
Window(L10n.t("Handbuch", appLanguage), id: "manual") {
|
||||
ManualView()
|
||||
.environmentObject(manualNavigator)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -16,6 +16,8 @@ enum L10n {
|
||||
|
||||
private static let translations: [String: String] = [
|
||||
"Verbinden": "Connect",
|
||||
"Handbuch": "Manual",
|
||||
"Hilfe zu diesem Bereich im Handbuch öffnen": "Open help for this area in the manual",
|
||||
"Einrichten": "Setup",
|
||||
"Übersicht": "Topology",
|
||||
"LAN-Scanner": "LAN Scanner",
|
||||
|
||||
@@ -212,6 +212,7 @@ struct BackupListView: View {
|
||||
}
|
||||
}
|
||||
.navigationTitle(LocalizedStringKey(L10n.t("Sicherungen", appLanguage)))
|
||||
.toolbar { ToolbarItem { ManualHelpButton(anchor: ManualAnchor.tabBackup) } }
|
||||
.toolbar {
|
||||
ToolbarItem {
|
||||
Button {
|
||||
|
||||
@@ -120,6 +120,7 @@ struct DevicesView: View {
|
||||
}
|
||||
}
|
||||
.navigationTitle(LocalizedStringKey(L10n.t("LAN-Scanner", appLanguage)))
|
||||
.toolbar { ToolbarItem { ManualHelpButton(anchor: ManualAnchor.tabDevices) } }
|
||||
.toolbar {
|
||||
ToolbarItem {
|
||||
Button {
|
||||
|
||||
@@ -79,6 +79,7 @@ struct ExpertMenuDetailView: View {
|
||||
// See DevicesView's identical fix: `.formStyle(.grouped)` paints an opaque background
|
||||
// over each Section's rows, hiding `.listRowBackground` (Zebra-Streifen) underneath it.
|
||||
.scrollContentBackground(.hidden)
|
||||
.toolbar { ToolbarItem { ManualHelpButton(anchor: ManualAnchor.schema(schema.menuPath)) } }
|
||||
.navigationTitle(LocalizedStringKey(L10n.t(schema.displayName, appLanguage)))
|
||||
.task(id: schema.id) { await viewModel.reloadItems() }
|
||||
.sheet(item: $viewModel.editingItem) { item in
|
||||
|
||||
@@ -84,6 +84,7 @@ struct ExpertView: View {
|
||||
}
|
||||
.listStyle(.sidebar)
|
||||
.navigationTitle(LocalizedStringKey(L10n.t("Experte", appLanguage)))
|
||||
.toolbar { ToolbarItem { ManualHelpButton(anchor: ManualAnchor.tabExpert) } }
|
||||
} detail: {
|
||||
if let schema = viewModel.selectedSchema {
|
||||
ExpertMenuDetailView(
|
||||
|
||||
@@ -0,0 +1,118 @@
|
||||
import SwiftUI
|
||||
import WebKit
|
||||
|
||||
/// Static/stable anchor ids matching the `<a id="...">` markers hand-placed in `Manual.md`
|
||||
/// (tab-/step-anchors) and auto-generated by `build-manual.py`'s `schema_anchor()` (Experte
|
||||
/// menus) — the two sides are kept in sync by construction: this is the exact same string
|
||||
/// transform as the Python function, not a generated file, so there's nothing to regenerate
|
||||
/// when a schema's `menuPath` doesn't change.
|
||||
enum ManualAnchor {
|
||||
static let tabConnect = "tab-connect"
|
||||
static let stepWAN = "step-wan"
|
||||
static let stepLAN = "step-lan"
|
||||
static let stepVLAN = "step-vlan"
|
||||
static let stepWifi = "step-wifi"
|
||||
static let stepFirewall = "step-firewall"
|
||||
static let stepReview = "step-review"
|
||||
static let tabOverview = "tab-overview"
|
||||
static let tabDevices = "tab-devices"
|
||||
static let tabExpert = "tab-expert"
|
||||
static let tabBackup = "tab-backup"
|
||||
static let tabSettings = "tab-settings"
|
||||
|
||||
/// Mirrors `build-manual.py`'s `schema_anchor(menu_path)` exactly.
|
||||
static func schema(_ menuPath: String) -> String {
|
||||
let trimmed = menuPath.trimmingCharacters(in: CharacterSet(charactersIn: "/"))
|
||||
return "schema-" + trimmed.replacingOccurrences(of: " ", with: "-")
|
||||
}
|
||||
}
|
||||
|
||||
/// Shared across the app (one instance, injected as an `@StateObject` in
|
||||
/// `RouterOSAssistantApp`) so any "?"-help button anywhere can request the Handbuch window
|
||||
/// jump to a specific section, whether or not that window is already open.
|
||||
@MainActor
|
||||
final class ManualNavigator: ObservableObject {
|
||||
@Published var pendingAnchor: String?
|
||||
|
||||
func go(to anchor: String) {
|
||||
// Reassigning the same value wouldn't trigger `didSet`/a fresh `onChange` in
|
||||
// `ManualView` if the user clicks the same help button twice in a row — nil the
|
||||
// slot first so every request, including a repeat, actually re-scrolls.
|
||||
pendingAnchor = nil
|
||||
DispatchQueue.main.async { self.pendingAnchor = anchor }
|
||||
}
|
||||
}
|
||||
|
||||
private struct ManualWebView: NSViewRepresentable {
|
||||
@ObservedObject var navigator: ManualNavigator
|
||||
let language: String
|
||||
|
||||
/// One HTML file per language (`Manual.html` for "de", `Manual_en.html`, later
|
||||
/// `Manual_es.html`, ...) — `build-manual.py` names them the same way (`suffix = ""
|
||||
/// if lang == "de" else f"_{lang}"`), so a new language needs no Swift change here,
|
||||
/// only a new `Manual.<lang>.md` + an entry in `LANGUAGES` in that script.
|
||||
final class Coordinator {
|
||||
var loadedLanguage: String?
|
||||
}
|
||||
|
||||
func makeCoordinator() -> Coordinator { Coordinator() }
|
||||
|
||||
func makeNSView(context: Context) -> WKWebView {
|
||||
let webView = WKWebView()
|
||||
load(language, into: webView, coordinator: context.coordinator)
|
||||
return webView
|
||||
}
|
||||
|
||||
func updateNSView(_ webView: WKWebView, context: Context) {
|
||||
if context.coordinator.loadedLanguage != language {
|
||||
load(language, into: webView, coordinator: context.coordinator)
|
||||
}
|
||||
guard let anchor = navigator.pendingAnchor else { return }
|
||||
// The page is already loaded — scrolling via JS instead of re-navigating to
|
||||
// "Manual.html#anchor" avoids a full page reload/flash on every help-button click.
|
||||
webView.evaluateJavaScript(
|
||||
"document.getElementById(\(String(reflecting: anchor)))?.scrollIntoView({behavior: 'smooth', block: 'start'});"
|
||||
)
|
||||
}
|
||||
|
||||
private func load(_ language: String, into webView: WKWebView, coordinator: Coordinator) {
|
||||
let resourceName = language == "de" ? "Manual" : "Manual_\(language)"
|
||||
// Falls back to the German file if this language has no translated manual yet
|
||||
// (e.g. a language the app UI supports but the manual hasn't been translated
|
||||
// into) rather than showing a blank window.
|
||||
guard let url = Bundle.main.url(forResource: resourceName, withExtension: "html")
|
||||
?? Bundle.main.url(forResource: "Manual", withExtension: "html") else { return }
|
||||
webView.loadFileURL(url, allowingReadAccessTo: url)
|
||||
coordinator.loadedLanguage = language
|
||||
}
|
||||
}
|
||||
|
||||
struct ManualView: View {
|
||||
@EnvironmentObject private var navigator: ManualNavigator
|
||||
@AppStorage("appLanguage") private var appLanguage: String = "de"
|
||||
|
||||
var body: some View {
|
||||
ManualWebView(navigator: navigator, language: appLanguage)
|
||||
.frame(minWidth: 640, minHeight: 480)
|
||||
}
|
||||
}
|
||||
|
||||
/// Drop-in "?" toolbar button for any screen — opens (or focuses, if already open) the
|
||||
/// Handbuch window scrolled straight to `anchor`. One shared implementation so every tab/
|
||||
/// wizard-step/Experte-schema screen wires help the same way.
|
||||
struct ManualHelpButton: View {
|
||||
let anchor: String
|
||||
@AppStorage("appLanguage") private var appLanguage: String = "de"
|
||||
@EnvironmentObject private var navigator: ManualNavigator
|
||||
@Environment(\.openWindow) private var openWindow
|
||||
|
||||
var body: some View {
|
||||
Button {
|
||||
navigator.go(to: anchor)
|
||||
openWindow(id: "manual")
|
||||
} label: {
|
||||
Image(systemName: "questionmark.circle")
|
||||
}
|
||||
.help(L10n.t("Hilfe zu diesem Bereich im Handbuch öffnen", appLanguage))
|
||||
}
|
||||
}
|
||||
@@ -166,6 +166,7 @@ struct OverviewView: View {
|
||||
}
|
||||
}
|
||||
.navigationTitle(LocalizedStringKey(L10n.t("Übersicht", appLanguage)))
|
||||
.toolbar { ToolbarItem { ManualHelpButton(anchor: ManualAnchor.tabOverview) } }
|
||||
.toolbar {
|
||||
ToolbarItemGroup {
|
||||
if viewModel.isLoading {
|
||||
|
||||
@@ -107,6 +107,7 @@ struct ConnectView: View {
|
||||
statusSection
|
||||
}
|
||||
.formStyle(.grouped)
|
||||
.toolbar { ToolbarItem { ManualHelpButton(anchor: ManualAnchor.tabConnect) } }
|
||||
.frame(minWidth: 320)
|
||||
} detail: {
|
||||
deviceDetail
|
||||
@@ -368,6 +369,7 @@ struct ConnectView: View {
|
||||
}
|
||||
}
|
||||
.formStyle(.grouped)
|
||||
.toolbar { ToolbarItem { ManualHelpButton(anchor: ManualAnchor.tabConnect) } }
|
||||
.scrollContentBackground(.hidden)
|
||||
} else {
|
||||
ContentUnavailableView(
|
||||
|
||||
@@ -61,6 +61,7 @@ struct FirewallStepView: View {
|
||||
}
|
||||
.formStyle(.grouped)
|
||||
.navigationTitle(LocalizedStringKey(L10n.t("Firewall (optional)", appLanguage)))
|
||||
.toolbar { ToolbarItem { ManualHelpButton(anchor: ManualAnchor.stepFirewall) } }
|
||||
.onAppear {
|
||||
if viewModel.firewallSectionEnabled {
|
||||
viewModel.loadExistingFirewallRuleCounts()
|
||||
|
||||
@@ -85,6 +85,7 @@ struct LanStepView: View {
|
||||
}
|
||||
.formStyle(.grouped)
|
||||
.navigationTitle(LocalizedStringKey(L10n.t("Heimnetzwerk einrichten", appLanguage)))
|
||||
.toolbar { ToolbarItem { ManualHelpButton(anchor: ManualAnchor.stepLAN) } }
|
||||
}
|
||||
|
||||
private var isStepValid: Bool {
|
||||
|
||||
@@ -74,6 +74,7 @@ struct ReviewApplyView: View {
|
||||
.formStyle(.grouped)
|
||||
.scrollContentBackground(.hidden)
|
||||
.navigationTitle(LocalizedStringKey(L10n.t("Übersicht & Anwenden", appLanguage)))
|
||||
.toolbar { ToolbarItem { ManualHelpButton(anchor: ManualAnchor.stepReview) } }
|
||||
.alert(
|
||||
L10n.t("Anwenden fehlgeschlagen", appLanguage),
|
||||
isPresented: Binding(
|
||||
|
||||
@@ -68,6 +68,7 @@ struct VlanStepView: View {
|
||||
}
|
||||
.formStyle(.grouped)
|
||||
.navigationTitle(LocalizedStringKey(L10n.t("Zusätzliche Netzwerke (VLAN)", appLanguage)))
|
||||
.toolbar { ToolbarItem { ManualHelpButton(anchor: ManualAnchor.stepVLAN) } }
|
||||
}
|
||||
|
||||
/// Only gates on filled-in fields while the VLAN section is actually enabled — with it off,
|
||||
|
||||
@@ -60,6 +60,7 @@ struct WanStepView: View {
|
||||
}
|
||||
.formStyle(.grouped)
|
||||
.navigationTitle(LocalizedStringKey(L10n.t("Internet einrichten", appLanguage)))
|
||||
.toolbar { ToolbarItem { ManualHelpButton(anchor: ManualAnchor.stepWAN) } }
|
||||
}
|
||||
|
||||
private var isStepValid: Bool {
|
||||
|
||||
@@ -49,6 +49,7 @@ struct WifiStepView: View {
|
||||
}
|
||||
.formStyle(.grouped)
|
||||
.navigationTitle(LocalizedStringKey(L10n.t("WLAN (falls vorhanden)", appLanguage)))
|
||||
.toolbar { ToolbarItem { ManualHelpButton(anchor: ManualAnchor.stepWifi) } }
|
||||
}
|
||||
|
||||
private var isStepValid: Bool {
|
||||
|
||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
Reference in New Issue
Block a user