feat(frontend): v3-Umbau P2 — echtes Routing, Unterzustand in der URL
Dritte Etappe. Die Ansicht lag bisher im URL-Hash (`#models`), gespeist aus einem
useState in App.tsx. Das war fuer Reload und Lesezeichen brauchbar — aber nur auf
View-Ebene; fuer den Zustand DARUNTER war kein Platz.
## Was jetzt in der Adresse steht (Befund B-05)
/modelle?reiter=routing der Reiter war lokaler State, nicht teilbar
/ideen?offen=<id> die aufgeklappte Karte; wer waehrend eines
laufenden Workers neu lud, verlor seinen Platz
/wissen?datei=<pfad> jede Vault-Notiz verlinkbar
<ueberall>?system=wartung|logs die System-Schublade inkl. Reiter
Alle Suchparameter werden validiert (app/router.tsx) — ein Link mit Unsinn darin
faellt auf den Normalfall zurueck, statt die Ansicht in einen halben Zustand zu
bringen.
## Alte Lesezeichen brechen nicht
lib/hashUmleitung.ts biegt `#models` per replaceState auf `/modelle`, EINMAL vor
dem Router-Mount. Fuenf Tests decken das ab (jeder NAV-Eintrag, die Schreibweise
`#/models`, erhaltene Suchparameter, unbekannter Hash, kein Hash) — die Bruecke
sieht sonst wie toter Code aus und waere ein naheliegender Kandidat zum Wegraeumen.
Das Backend brauchte KEINE Zeile: der SPA-Catch-all in app.py:148-168 beantwortet
jede unbekannte Route schon immer mit index.html. Vorher geprueft, nicht gehofft.
## Fehlergrenze pro Route (Befund B-13)
Vorher hing eine einzige Grenze in main.tsx um alles — ein Renderfehler im
Modell-Manager nahm Cockpit, Konsole und Chronik mit. Jetzt faengt jede Route fuer
sich (components/RouteFehler.tsx: was passiert ist, was man tun kann, technische
Einzelheiten aufklappbar). AppErrorBoundary bleibt als letztes Netz fuer Fehler
ausserhalb jeder Route. Dazu eine 404-Seite, die Schiene und Kopfzeile stehen
laesst — im Browser geprueft.
## Budget: der Router kostete 29 kB, sie sind wieder drin
TanStack Router hob den Start-Chunk von 111 auf 141 kB gzip und riss damit das in
P0 gesetzte Ampel-Budget (125 kB). Statt das Budget hochzusetzen — was es als
Signal entwertet haette — den Platz zurueckgeholt: SystemDrawer und CommandPalette
sind beim Start UNSICHTBAR und liegen jetzt hinter lazy(). Die Palette laedt beim
ersten Strg+K und bleibt dann gemountet.
Start-Chunk gzip 111 411 -> 114 978 B (Budget 125 000, weiter scharf)
dist gesamt 1 565 521 B (Budget 3 145 728)
## Nebenbei
· Befund B-09 erledigt: Der "Suchen"-Knopf baute ein GEFAELSCHTES KeyboardEvent
(`new KeyboardEvent("keydown", { metaKey: true })`) und feuerte es aufs
Dokument. Jetzt ein eigener Kanal; in P3 loest der Store auch den ab.
· Der Schliessen-Knopf der System-Schublade hatte kein aria-label — ein reiner
Icon-Knopf ohne Namen. Nachgeholt (beim Pruefen aufgefallen).
· nav.ts fuehrt jetzt den Pfad je Eintrag + navFuerPfad() mit Laengen-Sortierung,
damit /ideen/<id> die Sidebar bei "Ideen" markiert. Mit Tests.
## Verifiziert im Browser (lokales Backend, echtes Produktions-Buendel)
/ -> /cockpit, Sidebar markiert
/#models -> /modelle, Sidebar markiert
/modelle?reiter=routing -> Reiter "Routing & Warm-Set" aktiv
/cockpit?system=logs -> Schublade offen, Reiter "System-Logs" aktiv;
Schliessen entfernt den Parameter wieder
/gibtsnicht -> 404-Seite, Schiene + Kopfzeile intakt
Zurueck-Taste -> voriger Pfad
26/26 Tests gruen · ESLint 0 Fehler · tsc sauber.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
ae69c5ce31
commit
464b0d20b8
+88
-74
@@ -1,29 +1,22 @@
|
||||
import { useEffect, useState, useCallback, lazy, Suspense } from "react"
|
||||
import { useEffect, useState, useCallback, Suspense, lazy } from "react"
|
||||
import { Link, Outlet, useNavigate, useRouterState } from "@tanstack/react-router"
|
||||
import { Command as CommandIcon, ChevronLeft, ChevronRight, Loader2, Menu, X } from "lucide-react"
|
||||
import { NAV, type ViewId } from "@/nav"
|
||||
import { CommandPalette } from "@/components/CommandPalette"
|
||||
import { NAV, navFuerPfad } from "@/nav"
|
||||
import { ExpertToggle } from "@/components/ExpertToggle"
|
||||
import { SystemDrawer } from "@/components/SystemDrawer"
|
||||
|
||||
import { useHealth, useSystemStatus } from "@/lib/queries"
|
||||
import type { Health, SystemStatus } from "@/lib/api"
|
||||
import { useMetricsFeeder } from "@/lib/metricsStore"
|
||||
import { useEventStream } from "@/lib/events"
|
||||
import { cn } from "@/lib/utils"
|
||||
import { CockpitView } from "@/views/cockpit/CockpitView"
|
||||
|
||||
// Code-Splitting: nur das Cockpit (Startseite) lädt sofort, jede weitere View
|
||||
// kommt als eigenes Chunk erst beim ersten Aufruf — kleineres Start-Bundle.
|
||||
const ModelsView = lazy(() => import("@/views/ModelsView").then((m) => ({ default: m.ModelsView })))
|
||||
const ConnectView = lazy(() => import("@/views/ConnectView").then((m) => ({ default: m.ConnectView })))
|
||||
const AgentView = lazy(() => import("@/views/AgentView").then((m) => ({ default: m.AgentView })))
|
||||
const KonsoleView = lazy(() => import("@/views/KonsoleView").then((m) => ({ default: m.KonsoleView })))
|
||||
const GuideView = lazy(() => import("@/views/GuideView").then((m) => ({ default: m.GuideView })))
|
||||
const AuftragsbuchView = lazy(() => import("@/views/AuftragsbuchView").then((m) => ({ default: m.AuftragsbuchView })))
|
||||
const IdeenView = lazy(() => import("@/views/IdeenView").then((m) => ({ default: m.IdeenView })))
|
||||
const SkillsView = lazy(() => import("@/views/SkillsView").then((m) => ({ default: m.SkillsView })))
|
||||
const ChronikView = lazy(() => import("@/views/ChronikView").then((m) => ({ default: m.ChronikView })))
|
||||
|
||||
const WissenView = lazy(() => import("@/views/WissenView").then((m) => ({ default: m.WissenView })))
|
||||
// Beide sind beim Start UNSICHTBAR und gehoeren deshalb nicht ins Start-Buendel (P2):
|
||||
// die Schublade zieht Wartung, Logs, Einstellungen und den Update-Dialog mit, die Palette
|
||||
// bringt cmdk mit. Zusammen waren das rund 30 kB gzip, die niemand beim ersten Bild braucht.
|
||||
// Der Router kostete P2 ungefaehr dasselbe — so bleibt das Buendel-Budget der Ampel scharf,
|
||||
// statt es einfach hochzusetzen.
|
||||
const SystemDrawer = lazy(() => import("@/components/SystemDrawer").then((m) => ({ default: m.SystemDrawer })))
|
||||
const CommandPalette = lazy(() => import("@/components/CommandPalette").then((m) => ({ default: m.CommandPalette })))
|
||||
|
||||
// Dezenter Lade-Zustand während eine View nachgeladen wird (lokales Netz: kaum sichtbar).
|
||||
function ViewLoading() {
|
||||
@@ -37,22 +30,50 @@ function ViewLoading() {
|
||||
// Tastatur-Hinweis passend zum Betriebssystem (Palette hört auf Ctrl UND Cmd).
|
||||
const IS_MAC = /Mac|iPhone|iPad/.test(navigator.platform)
|
||||
|
||||
export default function App() {
|
||||
/**
|
||||
* Die Shell: Schiene, Kopfzeile, System-Schublade — alles, was über JEDER Ansicht liegt.
|
||||
*
|
||||
* v3-Umbau P2 (28.08.2026): Hier stand vorher der Ansichts-Umschalter
|
||||
* (`{view === "ideen" && <IdeenView />}`), gespeist aus einem `useState`, das am URL-Hash
|
||||
* hing. Das funktionierte für Reload und Lesezeichen — aber nur auf View-Ebene. Jetzt
|
||||
* rendert der Router in den `<Outlet/>`, und der Zustand DARUNTER (offene Idee, Reiter,
|
||||
* Schublade) liegt in echten Suchparametern. Siehe app/router.tsx.
|
||||
*/
|
||||
export function Shell() {
|
||||
useMetricsFeeder() // sammelt Live-Verlauf global, unabhängig vom aktiven Tab
|
||||
useEventStream() // SSE-Invalidation-Bus (P3a): sofortige Updates, Poller nur Netz
|
||||
// View ist im URL-Hash (#models) verankert → Reload/Teilen/Cmd-Klick funktionieren.
|
||||
const [view, setView] = useState<ViewId>(() => {
|
||||
const h = window.location.hash.slice(1) as ViewId
|
||||
return NAV.some((n) => n.id === h) ? h : "dashboard"
|
||||
|
||||
const navigate = useNavigate()
|
||||
const pfad = useRouterState({ select: (s) => s.location.pathname })
|
||||
// Die Schublade liegt über allem und gehört deshalb in die Wurzel-Suche (?system=).
|
||||
// Vorher war sie lokaler State und wurde per CustomEvent von außen aufgestoßen —
|
||||
// nicht teilbar, nicht reload-fest.
|
||||
const drawerTab = useRouterState({
|
||||
select: (s) => (s.location.search as { system?: "wartung" | "logs" }).system,
|
||||
})
|
||||
const navigate = useCallback((v: ViewId) => {
|
||||
if (window.location.hash.slice(1) === v) setView(v)
|
||||
else window.location.hash = v
|
||||
}, [])
|
||||
|
||||
const [sidebarCollapsed, setSidebarCollapsed] = useState(() => localStorage.getItem("mc_sidebar_collapsed") === "true")
|
||||
const [mobileNavOpen, setMobileNavOpen] = useState(false)
|
||||
const [drawerOpen, setDrawerOpen] = useState(false)
|
||||
const [drawerTab, setDrawerTab] = useState<"maintenance" | "logs">("maintenance")
|
||||
// Die Palette wird beim ERSTEN Ruf geladen und bleibt dann gemountet — sie haelt ihren
|
||||
// eigenen Offen-Zustand und soll nicht bei jedem Strg+K neu nachgeladen werden.
|
||||
const [paletteBereit, setPaletteBereit] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
const wecken = (e: KeyboardEvent) => {
|
||||
if ((e.metaKey || e.ctrlKey) && e.key.toLowerCase() === "k") setPaletteBereit(true)
|
||||
}
|
||||
const rufen = () => setPaletteBereit(true)
|
||||
document.addEventListener("keydown", wecken)
|
||||
window.addEventListener("mc-palette-open", rufen)
|
||||
return () => {
|
||||
document.removeEventListener("keydown", wecken)
|
||||
window.removeEventListener("mc-palette-open", rufen)
|
||||
}
|
||||
}, [])
|
||||
|
||||
const schubladeSetzen = useCallback((tab: "wartung" | "logs" | undefined) => {
|
||||
navigate({ to: ".", search: (alt: Record<string, unknown>) => ({ ...alt, system: tab }), replace: true })
|
||||
}, [navigate])
|
||||
|
||||
// isError mitnehmen: React Query behält bei Fehlern die letzten Daten — ohne den
|
||||
// Fehler-Blick zeigte die Sidebar bei totem Backend ewig den letzten grünen Stand.
|
||||
@@ -63,38 +84,33 @@ export default function App() {
|
||||
document.documentElement.classList.add("dark")
|
||||
}, [])
|
||||
|
||||
// Zwei Alt-Kanäle aus der Zeit vor dem Router. Sie bleiben in P2 bestehen, damit die
|
||||
// Aufrufer (Cockpit-Kacheln, Agent-Ansicht, Auftragsbuch) unverändert weiterlaufen —
|
||||
// gefüttert wird jetzt aber der Router, nicht mehr ein lokales useState.
|
||||
// In P3 lösen Store und Palette diese Kanäle ganz ab (Befund B-08).
|
||||
useEffect(() => {
|
||||
const handleOpen = (e: Event) => {
|
||||
const customEvent = e as CustomEvent
|
||||
setDrawerTab(customEvent.detail?.tab || "maintenance")
|
||||
setDrawerOpen(true)
|
||||
const tab = (e as CustomEvent).detail?.tab
|
||||
schubladeSetzen(tab === "logs" ? "logs" : "wartung")
|
||||
}
|
||||
window.addEventListener("open-system-drawer", handleOpen)
|
||||
return () => window.removeEventListener("open-system-drawer", handleOpen)
|
||||
}, [])
|
||||
}, [schubladeSetzen])
|
||||
|
||||
useEffect(() => {
|
||||
const handleNav = (e: Event) => {
|
||||
const v = (e as CustomEvent).detail?.view as ViewId | undefined
|
||||
if (v) navigate(v)
|
||||
const v = (e as CustomEvent).detail?.view as string | undefined
|
||||
const ziel = NAV.find((n) => n.id === v)
|
||||
if (ziel) navigate({ to: ziel.pfad })
|
||||
}
|
||||
window.addEventListener("mc-navigate", handleNav)
|
||||
return () => window.removeEventListener("mc-navigate", handleNav)
|
||||
}, [navigate])
|
||||
|
||||
useEffect(() => {
|
||||
const onHash = () => {
|
||||
const h = window.location.hash.slice(1) as ViewId
|
||||
if (NAV.some((n) => n.id === h)) setView(h)
|
||||
}
|
||||
window.addEventListener("hashchange", onHash)
|
||||
return () => window.removeEventListener("hashchange", onHash)
|
||||
}, [])
|
||||
|
||||
// Mobile-Navigation schließt nach jedem View-Wechsel (ein Tipp = ein Ziel).
|
||||
useEffect(() => { setMobileNavOpen(false) }, [view])
|
||||
useEffect(() => { setMobileNavOpen(false) }, [pfad])
|
||||
|
||||
const active = NAV.find((n) => n.id === view)!
|
||||
const active = navFuerPfad(pfad)
|
||||
|
||||
return (
|
||||
<div className="flex h-full relative">
|
||||
@@ -112,8 +128,17 @@ export default function App() {
|
||||
}}
|
||||
/>
|
||||
|
||||
<CommandPalette onNavigate={navigate} />
|
||||
<SystemDrawer open={drawerOpen} onClose={() => setDrawerOpen(false)} defaultTab={drawerTab} />
|
||||
{/* Erst laden, wenn wirklich gebraucht — ohne Platzhalter, beide sind Overlays. */}
|
||||
<Suspense fallback={null}>
|
||||
{paletteBereit && <CommandPalette />}
|
||||
{drawerTab && (
|
||||
<SystemDrawer
|
||||
open
|
||||
onClose={() => schubladeSetzen(undefined)}
|
||||
defaultTab={drawerTab === "logs" ? "logs" : "maintenance"}
|
||||
/>
|
||||
)}
|
||||
</Suspense>
|
||||
|
||||
{/* Sidebar (Desktop, ab md) */}
|
||||
<aside className={cn(
|
||||
@@ -129,7 +154,7 @@ export default function App() {
|
||||
return next
|
||||
})
|
||||
}}
|
||||
view={view}
|
||||
pfad={pfad}
|
||||
health={health}
|
||||
backendDown={backendDown}
|
||||
sysStatus={sysStatus}
|
||||
@@ -152,7 +177,7 @@ export default function App() {
|
||||
<SidebarContent
|
||||
collapsed={false}
|
||||
onClose={() => setMobileNavOpen(false)}
|
||||
view={view}
|
||||
pfad={pfad}
|
||||
health={health}
|
||||
backendDown={backendDown}
|
||||
sysStatus={sysStatus}
|
||||
@@ -171,7 +196,7 @@ export default function App() {
|
||||
>
|
||||
<Menu className="h-5 w-5" aria-hidden="true" />
|
||||
</button>
|
||||
<div className="truncate text-xs font-medium text-muted-foreground tracking-wide uppercase font-sans">{active.hint}</div>
|
||||
<div className="truncate text-xs font-medium text-muted-foreground tracking-wide uppercase font-sans">{active?.hint}</div>
|
||||
</div>
|
||||
<div className="flex shrink-0 items-center gap-2">
|
||||
<ExpertToggle />
|
||||
@@ -186,10 +211,7 @@ export default function App() {
|
||||
</a>
|
||||
|
||||
<button
|
||||
onClick={() => {
|
||||
const ev = new KeyboardEvent("keydown", { key: "k", metaKey: true })
|
||||
document.dispatchEvent(ev)
|
||||
}}
|
||||
onClick={() => window.dispatchEvent(new CustomEvent("mc-palette-open"))}
|
||||
className="flex items-center gap-2 rounded-md border border-border/40 bg-background/40 px-2.5 py-1.5 text-xs text-muted-foreground hover:bg-accent hover:text-accent-foreground transition-all cursor-pointer"
|
||||
>
|
||||
<CommandIcon className="h-3.5 w-3.5" />
|
||||
@@ -201,18 +223,7 @@ export default function App() {
|
||||
|
||||
<main className="flex-1 overflow-y-auto p-4 md:p-6 scrollbar-thin">
|
||||
<Suspense fallback={<ViewLoading />}>
|
||||
{view === "dashboard" && <CockpitView onNavigate={(v) => navigate(v as ViewId)} />}
|
||||
{view === "ideen" && <IdeenView />}
|
||||
{view === "auftraege" && <AuftragsbuchView />}
|
||||
{view === "skills" && <SkillsView />}
|
||||
{view === "models" && <ModelsView />}
|
||||
{view === "connect" && <ConnectView />}
|
||||
{view === "wissen" && <WissenView />}
|
||||
{view === "chronik" && <ChronikView />}
|
||||
|
||||
{view === "agent" && <AgentView />}
|
||||
{view === "konsole" && <KonsoleView />}
|
||||
{view === "guide" && <GuideView />}
|
||||
<Outlet />
|
||||
</Suspense>
|
||||
</main>
|
||||
</div>
|
||||
@@ -223,16 +234,18 @@ export default function App() {
|
||||
// Sidebar-Inhalt (Logo, Navigation, Status-Fuß) — geteilt zwischen Desktop-Leiste
|
||||
// und Mobile-Off-Canvas, damit beide immer identisch bleiben.
|
||||
function SidebarContent({
|
||||
collapsed, onToggleCollapse, onClose, view, health, backendDown, sysStatus,
|
||||
collapsed, onToggleCollapse, onClose, pfad, health, backendDown, sysStatus,
|
||||
}: {
|
||||
collapsed: boolean
|
||||
onToggleCollapse?: () => void
|
||||
onClose?: () => void
|
||||
view: ViewId
|
||||
pfad: string
|
||||
health: Health | undefined
|
||||
backendDown: boolean
|
||||
sysStatus: SystemStatus | undefined
|
||||
}) {
|
||||
const aktiv = navFuerPfad(pfad)
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className={cn("flex items-center py-4 border-b border-border/40 shrink-0", collapsed ? "flex-col gap-3 px-2" : "justify-between px-5")}>
|
||||
@@ -272,6 +285,7 @@ function SidebarContent({
|
||||
return NAV.map((item) => {
|
||||
const showGroupHeader = item.group !== lastGroup
|
||||
lastGroup = item.group
|
||||
const istAktiv = aktiv?.id === item.id
|
||||
return (
|
||||
<div key={item.id} className="space-y-1">
|
||||
{showGroupHeader && (
|
||||
@@ -283,14 +297,14 @@ function SidebarContent({
|
||||
</div>
|
||||
)
|
||||
)}
|
||||
<a
|
||||
href={`#${item.id}`}
|
||||
<Link
|
||||
to={item.pfad}
|
||||
onClick={onClose}
|
||||
aria-current={view === item.id ? "page" : undefined}
|
||||
aria-current={istAktiv ? "page" : undefined}
|
||||
className={cn(
|
||||
"flex w-full items-center rounded-md text-sm transition-all cursor-pointer",
|
||||
collapsed ? "justify-center p-2.5" : "gap-3 px-3 py-2",
|
||||
view === item.id
|
||||
istAktiv
|
||||
? (collapsed ? "nav-active-collapsed" : "nav-active")
|
||||
: "text-muted-foreground hover:bg-accent hover:text-accent-foreground",
|
||||
)}
|
||||
@@ -299,7 +313,7 @@ function SidebarContent({
|
||||
>
|
||||
<item.icon className="h-4.5 w-4.5 shrink-0" aria-hidden="true" />
|
||||
{!collapsed && <span className="truncate">{item.label}</span>}
|
||||
</a>
|
||||
</Link>
|
||||
</div>
|
||||
)
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user