"?"-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>
147 lines
8.4 KiB
Swift
147 lines
8.4 KiB
Swift
import SwiftUI
|
|
|
|
struct ExpertView: View {
|
|
@ObservedObject var connectionService: ConnectionService
|
|
@StateObject private var viewModel: ExpertViewModel
|
|
@AppStorage("appLanguage") private var appLanguage: String = "de"
|
|
@AppStorage(AppPreferences.colorThemeKey) private var colorThemeRaw: String = ColorTheme.standard.rawValue
|
|
private var colorTheme: ColorTheme { ColorTheme(rawValue: colorThemeRaw) ?? .standard }
|
|
/// A category's presence in this set means "collapsed". Starts with every category
|
|
/// collapsed (Nutzerwunsch: "standardmäßig sollten beim Öffnen des Experte-Tab alle
|
|
/// Sektion zugeklappt sein") — click a header to fold its menu list open again.
|
|
@State private var collapsedCategories: Set<RouterOSMenuCategory> = Set(RouterOSMenuCategory.allCases)
|
|
@State private var isCustomPathExpanded = false
|
|
|
|
init(connectionService: ConnectionService) {
|
|
self.connectionService = connectionService
|
|
_viewModel = StateObject(wrappedValue: ExpertViewModel(connectionService: connectionService))
|
|
}
|
|
|
|
var body: some View {
|
|
Group {
|
|
if connectionService.credentials == nil {
|
|
ContentUnavailableView(
|
|
LocalizedStringKey(L10n.t("Nicht verbunden", appLanguage)),
|
|
systemImage: "network.slash",
|
|
description: Text(L10n.t("Verbinde dich zuerst im Tab \"Verbinden\" mit deinem Router.", appLanguage))
|
|
)
|
|
} else {
|
|
NavigationSplitView {
|
|
List {
|
|
ForEach(RouterOSMenuCategory.allCases) { category in
|
|
let schemas = RouterOSSchemaCatalog.schemas(in: category)
|
|
if !schemas.isEmpty {
|
|
let isExpanded = !collapsedCategories.contains(category)
|
|
Section {
|
|
if isExpanded {
|
|
ForEach(schemas) { schema in
|
|
let isSelected = viewModel.selectedSchema?.menuPath == schema.menuPath
|
|
Button {
|
|
viewModel.open(schema)
|
|
} label: {
|
|
Text(L10n.t(schema.displayName, appLanguage))
|
|
.frame(maxWidth: .infinity, alignment: .leading)
|
|
.padding(.vertical, 4)
|
|
.padding(.horizontal, 6)
|
|
.background(
|
|
RoundedRectangle(cornerRadius: 6)
|
|
.fill(isSelected ? Color.blue.opacity(0.25) : Color.clear)
|
|
)
|
|
.contentShape(Rectangle())
|
|
}
|
|
.buttonStyle(.plain)
|
|
}
|
|
}
|
|
} header: {
|
|
sectionHeader(L10n.t(category.rawValue, appLanguage), isExpanded: isExpanded, categoryColor: colorTheme.color(for: category)) {
|
|
if isExpanded {
|
|
collapsedCategories.insert(category)
|
|
} else {
|
|
collapsedCategories.remove(category)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
Section {
|
|
if isCustomPathExpanded {
|
|
Text(L10n.t("Jeder RouterOS-Menüpfad ist erreichbar, auch wenn er oben nicht gelistet ist — die Felder erscheinen dann generisch (Schlüssel/Wert), ohne kuratierte Erklärung.", appLanguage))
|
|
.appFont(.caption)
|
|
.foregroundStyle(.secondary)
|
|
TextField(L10n.t("Menüpfad, z.B. /ip firewall filter", appLanguage), text: $viewModel.customMenuPath)
|
|
.help(L10n.t("Der RouterOS-CLI-Pfad, z.B. \"/interface wireguard\" oder \"/routing ospf instance\".", appLanguage))
|
|
TextField(L10n.t("REST-Pfad, z.B. ip/firewall/filter", appLanguage), text: $viewModel.customRestPath)
|
|
.help(L10n.t("Derselbe Pfad in REST-API-Schreibweise: führende \"/\" weg, Leerzeichen zu \"/\".", appLanguage))
|
|
Button(L10n.t("Öffnen", appLanguage)) { viewModel.openCustomPath() }
|
|
.disabled(viewModel.customMenuPath.isEmpty || viewModel.customRestPath.isEmpty)
|
|
}
|
|
} header: {
|
|
sectionHeader(L10n.t("Eigener Menüpfad", appLanguage), isExpanded: isCustomPathExpanded) {
|
|
isCustomPathExpanded.toggle()
|
|
}
|
|
}
|
|
}
|
|
.listStyle(.sidebar)
|
|
.navigationTitle(LocalizedStringKey(L10n.t("Experte", appLanguage)))
|
|
.toolbar { ToolbarItem { ManualHelpButton(anchor: ManualAnchor.tabExpert) } }
|
|
} detail: {
|
|
if let schema = viewModel.selectedSchema {
|
|
ExpertMenuDetailView(
|
|
viewModel: viewModel,
|
|
schema: schema
|
|
)
|
|
} else {
|
|
ContentUnavailableView(
|
|
LocalizedStringKey(L10n.t("Kein Menü ausgewählt", appLanguage)),
|
|
systemImage: "list.bullet.rectangle",
|
|
description: Text(L10n.t("Wähle links einen RouterOS-Bereich aus.", appLanguage))
|
|
)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Custom Section header — plain `Section("title")` rendered as macOS' default tiny
|
|
/// gray-caps sidebar label, too easy to miss when scanning nine menu categories (Nutzerwunsch:
|
|
/// "müssen prominenter sein und eingefärbt"). `.textCase(nil)` is required here, or macOS
|
|
/// forces its own uppercase-small-caps styling back on top of this custom view regardless of
|
|
/// the font/background set below. `Color.accentColor` (not a fixed RGB value) so the tint
|
|
/// stays visible and correctly contrasted in both light and dark mode automatically.
|
|
///
|
|
/// The chevron is drawn explicitly here rather than relying on `Section(isExpanded:)`'s
|
|
/// built-in disclosure triangle — that one is macOS sidebar-standard hover-only (matches
|
|
/// Finder), which the user explicitly didn't want ("der Pfeil sollte immer zu sehen sein und
|
|
/// nicht erst beim Hovern"). The whole header is a plain `Button` toggling `onToggle`, with
|
|
/// the section's own content shown/hidden by the caller via a plain `if isExpanded` instead
|
|
/// of the `Section(isExpanded:)` API, since that API owns its own (hover-only) chevron.
|
|
private func sectionHeader(_ title: String, isExpanded: Bool, categoryColor: Color? = nil, onToggle: @escaping () -> Void) -> some View {
|
|
Button(action: onToggle) {
|
|
HStack(spacing: 6) {
|
|
Image(systemName: "chevron.right")
|
|
.appFont(.caption, bold: true)
|
|
.foregroundStyle(.secondary)
|
|
.rotationEffect(.degrees(isExpanded ? 90 : 0))
|
|
if let categoryColor {
|
|
Circle().fill(categoryColor).frame(width: 8, height: 8)
|
|
}
|
|
Text(title)
|
|
.appFont(.title3, bold: true)
|
|
.foregroundStyle(.primary)
|
|
}
|
|
.padding(.vertical, 6)
|
|
.padding(.horizontal, 8)
|
|
.frame(maxWidth: .infinity, alignment: .leading)
|
|
.background(RoundedRectangle(cornerRadius: 6).fill(Color.accentColor.opacity(0.18)))
|
|
.contentShape(Rectangle())
|
|
}
|
|
.buttonStyle(.plain)
|
|
.textCase(nil)
|
|
}
|
|
}
|
|
|
|
#Preview {
|
|
ExpertView(connectionService: ConnectionService())
|
|
}
|