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>
|
||||
)
|
||||
})
|
||||
|
||||
@@ -0,0 +1,187 @@
|
||||
import { lazy } from "react"
|
||||
import {
|
||||
createRootRoute,
|
||||
createRoute,
|
||||
createRouter,
|
||||
redirect,
|
||||
useNavigate,
|
||||
} from "@tanstack/react-router"
|
||||
import { Shell } from "@/App"
|
||||
import { NAV } from "@/nav"
|
||||
import { RouteFehler } from "@/components/RouteFehler"
|
||||
import { CockpitView } from "@/views/cockpit/CockpitView"
|
||||
|
||||
// Routenbaum (v3-Umbau P2).
|
||||
//
|
||||
// Bewusst code-based statt dateibasiert: Es sind zwölf Routen, und sie stehen dadurch
|
||||
// alle an EINER Stelle — inklusive dem, was in den Suchparametern erlaubt ist. Eine
|
||||
// Datei-Konvention würde diese Übersicht auf zwölf Dateien verteilen, ohne dass jemand
|
||||
// etwas davon hätte.
|
||||
//
|
||||
// Code-Splitting bleibt exakt wie bisher: die Views hängen an React.lazy, das Cockpit als
|
||||
// Startseite lädt eifrig. Kein Chunk verschiebt sich durch den Router.
|
||||
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 })))
|
||||
|
||||
// ── Suchparameter ───────────────────────────────────────────────────────────
|
||||
// Regel für das, was hier steht: Alles, was ein Kollege per Link teilen können soll und
|
||||
// was einen Reload überleben muss. Nichts Flüchtiges (Hover, Entwürfe im Eingabefeld).
|
||||
|
||||
/** Wurzel: die System-Schublade. Gehört hierher, weil sie über JEDER Ansicht liegt. */
|
||||
export interface WurzelSuche {
|
||||
system?: "wartung" | "logs"
|
||||
}
|
||||
|
||||
const wurzelSuche = (roh: Record<string, unknown>): WurzelSuche => {
|
||||
const s = roh.system
|
||||
return s === "wartung" || s === "logs" ? { system: s } : {}
|
||||
}
|
||||
|
||||
export const rootRoute = createRootRoute({
|
||||
component: Shell,
|
||||
validateSearch: wurzelSuche,
|
||||
notFoundComponent: () => (
|
||||
<RouteFehler
|
||||
titel="Diese Seite gibt es nicht"
|
||||
text="Der Link zeigt auf eine Ansicht, die Mission Control nicht kennt. Vielleicht stammt er aus einer älteren Fassung."
|
||||
/>
|
||||
),
|
||||
})
|
||||
|
||||
// Wurzelpfad → Cockpit. `redirect` statt einer Kopie der Startseite: sonst gäbe es
|
||||
// zwei URLs für denselben Inhalt und die aktive Markierung in der Sidebar flackert.
|
||||
const indexRoute = createRoute({
|
||||
getParentRoute: () => rootRoute,
|
||||
path: "/",
|
||||
beforeLoad: () => {
|
||||
throw redirect({ to: "/cockpit" })
|
||||
},
|
||||
})
|
||||
|
||||
/** Eine Route je Ansicht — kurze Schreibweise, weil sich nur Pfad und Bauteil ändern.
|
||||
* Generisch über den Pfad, damit TanStack Router den Literal-Typ behält: mit
|
||||
* `path: string` verlöre `navigate({ to: "/cockpit" })` seine Typprüfung. */
|
||||
function ansicht<P extends string>(path: P, component: () => React.ReactNode) {
|
||||
return createRoute({
|
||||
getParentRoute: () => rootRoute,
|
||||
path,
|
||||
component,
|
||||
// Fehlergrenze PRO Route (Befund B-13): Vorher hing eine einzige Grenze in main.tsx
|
||||
// um die ganze App — ein Renderfehler in den Modellen nahm Cockpit, Konsole und
|
||||
// Chronik mit. Jetzt bleiben Schiene, Kopfzeile und Statusanzeige stehen.
|
||||
errorComponent: ({ error, reset }) => (
|
||||
<RouteFehler
|
||||
titel="Diese Ansicht ist abgestürzt"
|
||||
text="Der Rest von Mission Control läuft weiter. Erneut versuchen — oder in eine andere Ansicht wechseln."
|
||||
fehler={error}
|
||||
onErneut={reset}
|
||||
/>
|
||||
),
|
||||
})
|
||||
}
|
||||
|
||||
// Das Cockpit verlinkt in andere Bereiche und bekam den Sprung bisher als Prop von der
|
||||
// App gereicht. Jetzt kommt er aus dem Router — die Ansicht selbst bleibt unverändert.
|
||||
function CockpitSeite() {
|
||||
const navigate = useNavigate()
|
||||
return <CockpitView onNavigate={(v) => {
|
||||
const ziel = NAV.find((n) => n.id === v)
|
||||
if (ziel) navigate({ to: ziel.pfad })
|
||||
}} />
|
||||
}
|
||||
|
||||
const cockpitRoute = ansicht("/cockpit", () => <CockpitSeite />)
|
||||
const auftraegeRoute = ansicht("/auftraege", () => <AuftragsbuchView />)
|
||||
const skillsRoute = ansicht("/skills", () => <SkillsView />)
|
||||
const chronikRoute = ansicht("/chronik", () => <ChronikView />)
|
||||
const verbindenRoute = ansicht("/verbinden", () => <ConnectView />)
|
||||
const konsoleRoute = ansicht("/konsole", () => <KonsoleView />)
|
||||
const anleitungRoute = ansicht("/anleitung", () => <GuideView />)
|
||||
const agentRoute = ansicht("/agent", () => <AgentView />)
|
||||
|
||||
/** Ideen: `?offen=<id>` hält die aufgeklappte Karte fest (Befund B-05).
|
||||
* Vorher lag das in `useState` — wer während eines laufenden Workers neu lud,
|
||||
* verlor seinen Platz in der Liste. */
|
||||
export interface IdeenSuche {
|
||||
offen?: string
|
||||
}
|
||||
const ideenRoute = createRoute({
|
||||
getParentRoute: () => rootRoute,
|
||||
path: "/ideen",
|
||||
validateSearch: (roh: Record<string, unknown>): IdeenSuche =>
|
||||
typeof roh.offen === "string" && roh.offen ? { offen: roh.offen } : {},
|
||||
component: () => <IdeenView />,
|
||||
errorComponent: ({ error, reset }) => (
|
||||
<RouteFehler titel="Die Ideen-Ansicht ist abgestürzt" text="Der Rest läuft weiter." fehler={error} onErneut={reset} />
|
||||
),
|
||||
})
|
||||
|
||||
/** Wissen: `?datei=<pfad>` macht jede Vault-Notiz verlinkbar. */
|
||||
export interface WissenSuche {
|
||||
datei?: string
|
||||
}
|
||||
const wissenRoute = createRoute({
|
||||
getParentRoute: () => rootRoute,
|
||||
path: "/wissen",
|
||||
validateSearch: (roh: Record<string, unknown>): WissenSuche =>
|
||||
typeof roh.datei === "string" && roh.datei ? { datei: roh.datei } : {},
|
||||
component: () => <WissenView />,
|
||||
errorComponent: ({ error, reset }) => (
|
||||
<RouteFehler titel="Die Wissens-Ansicht ist abgestürzt" text="Der Rest läuft weiter." fehler={error} onErneut={reset} />
|
||||
),
|
||||
})
|
||||
|
||||
/** Modelle: `?reiter=` — der Reiter war lokaler State und damit nicht teilbar. */
|
||||
export type ModellReiter = "werkbank" | "routing" | "discover"
|
||||
export interface ModelleSuche {
|
||||
reiter?: ModellReiter
|
||||
}
|
||||
const modelleRoute = createRoute({
|
||||
getParentRoute: () => rootRoute,
|
||||
path: "/modelle",
|
||||
validateSearch: (roh: Record<string, unknown>): ModelleSuche => {
|
||||
const r = roh.reiter
|
||||
return r === "werkbank" || r === "routing" || r === "discover" ? { reiter: r } : {}
|
||||
},
|
||||
component: () => <ModelsView />,
|
||||
errorComponent: ({ error, reset }) => (
|
||||
<RouteFehler titel="Der Modell-Manager ist abgestürzt" text="Der Rest läuft weiter." fehler={error} onErneut={reset} />
|
||||
),
|
||||
})
|
||||
|
||||
export const routeTree = rootRoute.addChildren([
|
||||
indexRoute,
|
||||
cockpitRoute,
|
||||
ideenRoute,
|
||||
auftraegeRoute,
|
||||
skillsRoute,
|
||||
chronikRoute,
|
||||
wissenRoute,
|
||||
verbindenRoute,
|
||||
konsoleRoute,
|
||||
anleitungRoute,
|
||||
modelleRoute,
|
||||
agentRoute,
|
||||
])
|
||||
|
||||
export const router = createRouter({
|
||||
routeTree,
|
||||
// Ein LAN-Gerät: der Verlauf muss nicht scrollen-restaurieren, aber die Position
|
||||
// oben zu haben ist beim Ansichtswechsel richtig.
|
||||
scrollRestoration: true,
|
||||
defaultPreload: false,
|
||||
})
|
||||
|
||||
declare module "@tanstack/react-router" {
|
||||
interface Register {
|
||||
router: typeof router
|
||||
}
|
||||
}
|
||||
@@ -1,9 +1,11 @@
|
||||
import { useEffect, useState } from "react"
|
||||
import { Command } from "cmdk"
|
||||
import { NAV, type ViewId } from "@/nav"
|
||||
import { useNavigate } from "@tanstack/react-router"
|
||||
import { NAV } from "@/nav"
|
||||
|
||||
export function CommandPalette({ onNavigate }: { onNavigate: (v: ViewId) => void }) {
|
||||
export function CommandPalette() {
|
||||
const [open, setOpen] = useState(false)
|
||||
const navigate = useNavigate()
|
||||
|
||||
useEffect(() => {
|
||||
const onKey = (e: KeyboardEvent) => {
|
||||
@@ -16,6 +18,16 @@ export function CommandPalette({ onNavigate }: { onNavigate: (v: ViewId) => void
|
||||
return () => document.removeEventListener("keydown", onKey)
|
||||
}, [])
|
||||
|
||||
// Der „Suchen"-Knopf in der Kopfzeile (v3-Umbau P2, Befund B-09).
|
||||
// Er baute vorher ein GEFÄLSCHTES Tastatur-Ereignis (`new KeyboardEvent("keydown",
|
||||
// { metaKey: true })`) und feuerte es aufs Dokument, damit der Zuhörer oben anspringt.
|
||||
// Jetzt ein eigener, ehrlicher Kanal. In P3 löst der Store auch den ab.
|
||||
useEffect(() => {
|
||||
const onOpen = () => setOpen(true)
|
||||
window.addEventListener("mc-palette-open", onOpen)
|
||||
return () => window.removeEventListener("mc-palette-open", onOpen)
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<Command.Dialog
|
||||
open={open}
|
||||
@@ -43,7 +55,7 @@ export function CommandPalette({ onNavigate }: { onNavigate: (v: ViewId) => void
|
||||
key={item.id}
|
||||
value={`${item.label} ${item.hint}`}
|
||||
onSelect={() => {
|
||||
onNavigate(item.id)
|
||||
navigate({ to: item.pfad })
|
||||
setOpen(false)
|
||||
}}
|
||||
className="flex cursor-pointer items-center gap-3 rounded-md px-3 py-2 text-sm text-foreground aria-selected:bg-accent aria-selected:text-accent-foreground"
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
import { AlertTriangle, RotateCcw } from "lucide-react"
|
||||
|
||||
// Fehlerfläche EINER Ansicht (v3-Umbau P2, Befund B-13).
|
||||
//
|
||||
// Vorher hing genau eine Fehlergrenze in main.tsx um die gesamte App: Ein Renderfehler
|
||||
// im Modell-Manager nahm Cockpit, Konsole und Chronik gleich mit — die Seite war weiß.
|
||||
// Jetzt fängt jede Route für sich; Schiene, Kopfzeile und Statusanzeige bleiben stehen,
|
||||
// und der Nutzer kann einfach woanders hin.
|
||||
//
|
||||
// Die Meldung sagt, was passiert ist und was man tun kann. Der technische Text steht
|
||||
// aufklappbar darunter — nicht versteckt (man braucht ihn für einen Fehlerbericht),
|
||||
// aber auch nicht als Erstes im Gesicht.
|
||||
export function RouteFehler({ titel, text, fehler, onErneut }: {
|
||||
titel: string
|
||||
text: string
|
||||
fehler?: unknown
|
||||
onErneut?: () => void
|
||||
}) {
|
||||
const detail = fehler instanceof Error ? (fehler.stack || fehler.message) : fehler ? String(fehler) : null
|
||||
|
||||
return (
|
||||
<div className="flex min-h-[50vh] items-center justify-center p-6">
|
||||
<div className="mc-card max-w-lg space-y-4 p-6">
|
||||
<div className="flex items-center gap-2.5">
|
||||
<AlertTriangle className="h-5 w-5 shrink-0 text-amber-400" aria-hidden="true" />
|
||||
<h1 className="font-space text-lg font-bold tracking-tight">{titel}</h1>
|
||||
</div>
|
||||
|
||||
<p className="text-sm leading-relaxed text-muted-foreground">{text}</p>
|
||||
|
||||
<div className="flex flex-wrap gap-2 pt-1">
|
||||
{onErneut && (
|
||||
<button
|
||||
onClick={onErneut}
|
||||
className="flex h-9 cursor-pointer items-center gap-1.5 rounded-lg bg-primary px-4 text-xs font-semibold text-primary-foreground transition-colors hover:bg-primary/90"
|
||||
>
|
||||
<RotateCcw className="h-3.5 w-3.5" aria-hidden="true" /> Erneut versuchen
|
||||
</button>
|
||||
)}
|
||||
<a
|
||||
href="/cockpit"
|
||||
className="flex h-9 items-center rounded-lg border border-border/60 bg-background/20 px-4 text-xs font-semibold text-muted-foreground transition-colors hover:bg-accent"
|
||||
>
|
||||
Zum Cockpit
|
||||
</a>
|
||||
</div>
|
||||
|
||||
{detail && (
|
||||
<details className="pt-1">
|
||||
<summary className="cursor-pointer text-[11px] font-semibold uppercase tracking-wider text-muted-foreground/70 hover:text-foreground">
|
||||
Technische Einzelheiten
|
||||
</summary>
|
||||
<pre className="scrollbar-thin mt-2 max-h-52 overflow-auto rounded-lg border border-border/40 bg-background/40 p-3 font-mono text-[10px] leading-relaxed text-muted-foreground">
|
||||
{detail}
|
||||
</pre>
|
||||
</details>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -311,9 +311,10 @@ export function SystemDrawer({ open, onClose, defaultTab = "maintenance" }: Syst
|
||||
</div>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="flex h-8 w-8 items-center justify-center rounded-lg border border-border/40 text-muted-foreground hover:bg-accent transition-colors"
|
||||
aria-label="System-Schublade schließen"
|
||||
className="flex h-8 w-8 items-center justify-center rounded-lg border border-border/40 text-muted-foreground hover:bg-accent transition-colors cursor-pointer"
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
<X className="h-4 w-4" aria-hidden="true" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -0,0 +1,70 @@
|
||||
import { beforeEach, describe, expect, it } from "vitest"
|
||||
import { hashUmleiten } from "./hashUmleitung"
|
||||
import { NAV, navFuerPfad } from "@/nav"
|
||||
|
||||
function adresseSetzen(url: string) {
|
||||
window.history.replaceState(null, "", url)
|
||||
}
|
||||
|
||||
// Diese Brücke ist die einzige Verbindung zu Lesezeichen aus der Hash-Zeit. Sie sieht
|
||||
// nach totem Code aus und wäre damit ein naheliegender Kandidat zum Wegräumen — die
|
||||
// Tests machen sichtbar, was dabei kaputtginge.
|
||||
describe("hashUmleiten()", () => {
|
||||
beforeEach(() => adresseSetzen("/"))
|
||||
|
||||
it("biegt jedes alte Hash-Lesezeichen auf seinen neuen Pfad", () => {
|
||||
for (const eintrag of NAV) {
|
||||
adresseSetzen("/#" + eintrag.id)
|
||||
hashUmleiten()
|
||||
expect(window.location.pathname, `#${eintrag.id}`).toBe(eintrag.pfad)
|
||||
}
|
||||
})
|
||||
|
||||
it("verträgt die Schreibweise mit Schrägstrich (#/models)", () => {
|
||||
adresseSetzen("/#/models")
|
||||
hashUmleiten()
|
||||
expect(window.location.pathname).toBe("/modelle")
|
||||
})
|
||||
|
||||
it("behält vorhandene Suchparameter", () => {
|
||||
adresseSetzen("/?system=logs#models")
|
||||
hashUmleiten()
|
||||
expect(window.location.pathname).toBe("/modelle")
|
||||
expect(window.location.search).toBe("?system=logs")
|
||||
})
|
||||
|
||||
it("fasst einen unbekannten Hash nicht an", () => {
|
||||
adresseSetzen("/cockpit#irgendwas")
|
||||
hashUmleiten()
|
||||
expect(window.location.pathname).toBe("/cockpit")
|
||||
})
|
||||
|
||||
it("tut ohne Hash gar nichts", () => {
|
||||
adresseSetzen("/modelle?reiter=routing")
|
||||
hashUmleiten()
|
||||
expect(window.location.pathname + window.location.search).toBe("/modelle?reiter=routing")
|
||||
})
|
||||
})
|
||||
|
||||
describe("navFuerPfad()", () => {
|
||||
it("findet den Eintrag zum exakten Pfad", () => {
|
||||
expect(navFuerPfad("/modelle")?.id).toBe("models")
|
||||
expect(navFuerPfad("/cockpit")?.id).toBe("dashboard")
|
||||
})
|
||||
|
||||
// Damit /ideen/<id> die Sidebar weiterhin bei „Ideen" markiert und nicht ins Leere fällt.
|
||||
it("ordnet Unterpfade dem übergeordneten Eintrag zu", () => {
|
||||
expect(navFuerPfad("/ideen/id_9f3c")?.id).toBe("ideen")
|
||||
})
|
||||
|
||||
it("liefert nichts für einen unbekannten Pfad", () => {
|
||||
expect(navFuerPfad("/gibtsnicht")).toBeUndefined()
|
||||
})
|
||||
|
||||
// Der Grund für die Längen-Sortierung: ein kurzer Pfad darf einen längeren nicht
|
||||
// überstimmen, sonst gewinnt der erstbeste Präfix-Treffer.
|
||||
it("nimmt bei mehreren Treffern den längsten Pfad", () => {
|
||||
const treffer = navFuerPfad("/modelle")
|
||||
expect(treffer?.pfad).toBe("/modelle")
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,26 @@
|
||||
// Alte Hash-Lesezeichen auf die neuen Pfade umbiegen (v3-Umbau P2).
|
||||
//
|
||||
// Bis zum 28.08.2026 lebte die Ansicht im URL-Hash (`#models`). Das war teilbar und
|
||||
// reload-fest — aber nur auf View-Ebene; für den Zustand DARUNTER (offene Idee, Reiter,
|
||||
// Drawer) war kein Platz. Mit dem Router zieht das in echte Pfade um.
|
||||
//
|
||||
// Wer ein altes Lesezeichen hat, soll nichts merken: Diese Funktion läuft EINMAL vor dem
|
||||
// Router-Mount und ersetzt `#models` durch `/modelle` — per `replaceState`, damit die
|
||||
// Zurück-Taste nicht in einer Umleitungs-Schleife landet.
|
||||
//
|
||||
// Die Tabelle darf mit der Zeit schrumpfen, aber nicht kommentarlos: Solange jemand ein
|
||||
// Lesezeichen von früher haben könnte, ist sie die einzige Brücke dorthin.
|
||||
|
||||
import { NAV, type ViewId } from "@/nav"
|
||||
|
||||
const ALT_ZU_PFAD: Record<ViewId, string> = Object.fromEntries(
|
||||
NAV.map((n) => [n.id, n.pfad]),
|
||||
) as Record<ViewId, string>
|
||||
|
||||
export function hashUmleiten(): void {
|
||||
const hash = window.location.hash.replace(/^#\/?/, "")
|
||||
if (!hash) return
|
||||
const ziel = ALT_ZU_PFAD[hash as ViewId]
|
||||
if (!ziel) return
|
||||
window.history.replaceState(null, "", ziel + window.location.search)
|
||||
}
|
||||
+13
-4
@@ -1,13 +1,19 @@
|
||||
import React from "react"
|
||||
import ReactDOM from "react-dom/client"
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query"
|
||||
import App from "./App"
|
||||
import { RouterProvider } from "@tanstack/react-router"
|
||||
import { router } from "./app/router"
|
||||
import { AppErrorBoundary } from "./components/AppErrorBoundary"
|
||||
import { hashUmleiten } from "./lib/hashUmleitung"
|
||||
import { veralteteVersionAbfangen } from "./lib/veralteteVersion"
|
||||
import "./index.css"
|
||||
|
||||
// Muss VOR dem ersten Rendern hängen: Nach einem Deploy sucht ein offener Tab
|
||||
// Nachlade-Chunks des alten Builds, die es nicht mehr gibt (siehe lib/veralteteVersion.ts).
|
||||
// Beides MUSS vor dem ersten Rendern laufen:
|
||||
// · hashUmleiten — alte Lesezeichen (#models) landen auf dem neuen Pfad (/modelle),
|
||||
// bevor der Router die Adresse zum ersten Mal liest (P2).
|
||||
// · veralteteVersionAbfangen — nach einem Deploy sucht ein offener Tab Nachlade-Chunks
|
||||
// des alten Builds, die es nicht mehr gibt (P1).
|
||||
hashUmleiten()
|
||||
veralteteVersionAbfangen()
|
||||
|
||||
// Zentraler Daten-Layer: Caching, Dedup, Retry, Polling pro Query-Hook.
|
||||
@@ -21,11 +27,14 @@ const queryClient = new QueryClient({
|
||||
},
|
||||
})
|
||||
|
||||
// AppErrorBoundary bleibt als LETZTES Netz stehen — für Fehler außerhalb jeder Route
|
||||
// (Provider, Router selbst). Innerhalb der Routen fängt seit P2 jede Ansicht für sich
|
||||
// (app/router.tsx → errorComponent), damit ein Absturz nicht die ganze Shell mitnimmt.
|
||||
ReactDOM.createRoot(document.getElementById("root")!).render(
|
||||
<React.StrictMode>
|
||||
<AppErrorBoundary>
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<App />
|
||||
<RouterProvider router={router} />
|
||||
</QueryClientProvider>
|
||||
</AppErrorBoundary>
|
||||
</React.StrictMode>,
|
||||
|
||||
+55
-46
@@ -1,46 +1,55 @@
|
||||
import {
|
||||
LayoutDashboard,
|
||||
Boxes,
|
||||
Inbox,
|
||||
History,
|
||||
Library,
|
||||
Plug,
|
||||
Bot,
|
||||
SquareTerminal,
|
||||
HelpCircle,
|
||||
Lightbulb,
|
||||
type LucideIcon,
|
||||
} from "lucide-react"
|
||||
|
||||
export type ViewId = "dashboard" | "ideen" | "auftraege" | "skills" | "models" | "wissen" | "chronik" | "connect" | "agent" | "konsole" | "guide"
|
||||
|
||||
export type NavGroup = "Operativ" | "Wissen" | "Werkzeuge" | "Wartung"
|
||||
|
||||
export interface NavItem {
|
||||
id: ViewId
|
||||
label: string
|
||||
hint: string
|
||||
icon: LucideIcon
|
||||
group: NavGroup
|
||||
}
|
||||
|
||||
// Eine Quelle der Wahrheit für Sidebar UND Command-Palette.
|
||||
export const NAV: NavItem[] = [
|
||||
{ id: "dashboard", label: "Cockpit", hint: "Deine Box auf einen Blick", icon: LayoutDashboard, group: "Operativ" },
|
||||
// Der Haupteingang: Idee → Konzept → Repo → Zed. Steht bewusst direkt unter dem
|
||||
// Cockpit — hier fängt jede Arbeit an (eigene Seite seit 25.07.2026).
|
||||
// Hinweis bewusst kurz: die App zeigt ihn oben in Großbuchstaben, direkt über der
|
||||
// Seitenüberschrift „Ideen" — ein langer Satz stand dort dreifach gedoppelt.
|
||||
{ id: "ideen", label: "Ideen", hint: "Schreib hin, was entstehen soll", icon: Lightbulb, group: "Operativ" },
|
||||
{ id: "auftraege", label: "Auftragsbuch", hint: "Vorschläge der Box — annehmen oder ablehnen", icon: Inbox, group: "Operativ" },
|
||||
{ id: "skills", label: "Skills & Jobs", hint: "Autonome Skills manuell auslösen", icon: Bot, group: "Operativ" },
|
||||
{ id: "chronik", label: "Chronik", hint: "Was die Box von allein getan hat + Zeitmaschine", icon: History, group: "Operativ" },
|
||||
{ id: "wissen", label: "Wissen", hint: "Lucys Wissens-Vault (Traum-Notizen)", icon: Library, group: "Wissen" },
|
||||
{ id: "connect", label: "Verbinden", hint: "IDE-/Agent-Configs erzeugen", icon: Plug, group: "Werkzeuge" },
|
||||
{ id: "konsole", label: "Konsole", hint: "Direkte Box-Shell (SSH-artig)", icon: SquareTerminal, group: "Werkzeuge" },
|
||||
{ id: "guide", label: "Anleitung", hint: "Einrichten & Vibe-Coding", icon: HelpCircle, group: "Werkzeuge" },
|
||||
{ id: "models", label: "Modelle", hint: "Speicher, laden & Rollen", icon: Boxes, group: "Wartung" },
|
||||
{ id: "agent", label: "Hermes", hint: "Agent-Status & Verdrahtung", icon: Bot, group: "Wartung" },
|
||||
]
|
||||
|
||||
|
||||
import {
|
||||
LayoutDashboard,
|
||||
Boxes,
|
||||
Inbox,
|
||||
History,
|
||||
Library,
|
||||
Plug,
|
||||
Bot,
|
||||
SquareTerminal,
|
||||
HelpCircle,
|
||||
Lightbulb,
|
||||
type LucideIcon,
|
||||
} from "lucide-react"
|
||||
|
||||
export type ViewId = "dashboard" | "ideen" | "auftraege" | "skills" | "models" | "wissen" | "chronik" | "connect" | "agent" | "konsole" | "guide"
|
||||
|
||||
export type NavGroup = "Operativ" | "Wissen" | "Werkzeuge" | "Wartung"
|
||||
|
||||
export interface NavItem {
|
||||
id: ViewId
|
||||
/** Echter URL-Pfad (v3-Umbau P2). Vorher war die Ansicht nur ein Hash (#models) —
|
||||
* teilbar auf View-Ebene, aber ohne Platz für den Zustand DARUNTER. */
|
||||
pfad: string
|
||||
label: string
|
||||
hint: string
|
||||
icon: LucideIcon
|
||||
group: NavGroup
|
||||
}
|
||||
|
||||
// Eine Quelle der Wahrheit für Sidebar UND Command-Palette.
|
||||
export const NAV: NavItem[] = [
|
||||
{ id: "dashboard", pfad: "/cockpit", label: "Cockpit", hint: "Deine Box auf einen Blick", icon: LayoutDashboard, group: "Operativ" },
|
||||
// Der Haupteingang: Idee → Konzept → Repo → IDE. Steht bewusst direkt unter dem
|
||||
// Cockpit — hier fängt jede Arbeit an (eigene Seite seit 25.07.2026).
|
||||
// Hinweis bewusst kurz: die App zeigt ihn oben in Großbuchstaben, direkt über der
|
||||
// Seitenüberschrift „Ideen" — ein langer Satz stand dort dreifach gedoppelt.
|
||||
{ id: "ideen", pfad: "/ideen", label: "Ideen", hint: "Schreib hin, was entstehen soll", icon: Lightbulb, group: "Operativ" },
|
||||
{ id: "auftraege", pfad: "/auftraege", label: "Auftragsbuch", hint: "Vorschläge der Box — annehmen oder ablehnen", icon: Inbox, group: "Operativ" },
|
||||
{ id: "skills", pfad: "/skills", label: "Skills & Jobs", hint: "Autonome Skills manuell auslösen", icon: Bot, group: "Operativ" },
|
||||
{ id: "chronik", pfad: "/chronik", label: "Chronik", hint: "Was die Box von allein getan hat + Zeitmaschine", icon: History, group: "Operativ" },
|
||||
{ id: "wissen", pfad: "/wissen", label: "Wissen", hint: "Lucys Wissens-Vault (Traum-Notizen)", icon: Library, group: "Wissen" },
|
||||
{ id: "connect", pfad: "/verbinden", label: "Verbinden", hint: "IDE-/Agent-Configs erzeugen", icon: Plug, group: "Werkzeuge" },
|
||||
{ id: "konsole", pfad: "/konsole", label: "Konsole", hint: "Direkte Box-Shell (SSH-artig)", icon: SquareTerminal, group: "Werkzeuge" },
|
||||
{ id: "guide", pfad: "/anleitung", label: "Anleitung", hint: "Einrichten & Vibe-Coding", icon: HelpCircle, group: "Werkzeuge" },
|
||||
{ id: "models", pfad: "/modelle", label: "Modelle", hint: "Speicher, laden & Rollen", icon: Boxes, group: "Wartung" },
|
||||
{ id: "agent", pfad: "/agent", label: "Hermes", hint: "Agent-Status & Verdrahtung", icon: Bot, group: "Wartung" },
|
||||
]
|
||||
|
||||
/** Nav-Eintrag zu einem Pfad — für Kopfzeile und aktive Markierung.
|
||||
* Längster Treffer gewinnt, damit /ideen/<id> beim Eintrag „Ideen" landet. */
|
||||
export function navFuerPfad(pfad: string): NavItem | undefined {
|
||||
return [...NAV]
|
||||
.sort((a, b) => b.pfad.length - a.pfad.length)
|
||||
.find((n) => pfad === n.pfad || pfad.startsWith(n.pfad + "/"))
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { useEffect, useState } from "react"
|
||||
import { useNavigate, useSearch } from "@tanstack/react-router"
|
||||
import {
|
||||
Loader2, AlertTriangle, ChevronDown, ChevronRight, Clock, Hammer, Lightbulb, Send,
|
||||
HelpCircle, RotateCcw, Square, ArrowUp, ArrowDown, Code2, FileText, GitFork, X, Layers,
|
||||
@@ -114,7 +115,14 @@ function IdeenSection() {
|
||||
const [ziel, setZiel] = useState<"lxc" | "docker">("lxc")
|
||||
const [sending, setSending] = useState(false)
|
||||
const [busy, setBusy] = useState<Record<string, boolean>>({})
|
||||
const [openLog, setOpenLog] = useState<string | null>(null)
|
||||
// v3-Umbau P2 (Befund B-05): Die aufgeklappte Karte lag in useState. Wer waehrend
|
||||
// eines laufenden Workers neu lud — und genau dann schaut man hin — verlor seinen
|
||||
// Platz in der Liste. Jetzt steht sie in der URL (/ideen?offen=<id>) und ist teilbar.
|
||||
const navigate = useNavigate()
|
||||
const suche = useSearch({ from: "/ideen" })
|
||||
const openLog = suche.offen ?? null
|
||||
const setOpenLog = (id: string | null) =>
|
||||
navigate({ to: "/ideen", search: (alt) => ({ ...alt, offen: id ?? undefined }), replace: true })
|
||||
const [openKonzept, setOpenKonzept] = useState<string | null>(null)
|
||||
const [antworten, setAntworten] = useState<Record<string, string>>({})
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useState } from "react"
|
||||
import { useNavigate, useSearch } from "@tanstack/react-router"
|
||||
import { cn } from "@/lib/utils"
|
||||
import { JobsBar } from "@/components/models/JobsBar"
|
||||
import { LaneEditor } from "@/components/models/LaneEditor"
|
||||
@@ -16,7 +16,14 @@ const TABS = [
|
||||
type TabId = (typeof TABS)[number]["id"]
|
||||
|
||||
export function ModelsView() {
|
||||
const [tab, setTab] = useState<TabId>("werkbank")
|
||||
// v3-Umbau P2: Der Reiter lag in useState und war damit nicht teilbar und nicht
|
||||
// reload-fest — wer jemandem „schau dir das Routing an" schicken wollte, schickte
|
||||
// die Werkbank. Jetzt steht er in der URL (/modelle?reiter=routing).
|
||||
const navigate = useNavigate()
|
||||
const { reiter } = useSearch({ from: "/modelle" })
|
||||
const tab: TabId = reiter ?? "werkbank"
|
||||
const setTab = (t: TabId) =>
|
||||
navigate({ to: "/modelle", search: (alt) => ({ ...alt, reiter: t }), replace: true })
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex flex-col sm:flex-row justify-between sm:items-center gap-4">
|
||||
|
||||
Reference in New Issue
Block a user