feat: add sidebar layout, environment tree, and router

Wires up AppShell + Sidebar compound component, a per-environment
SidebarTree that lazy-fetches apps, React Router nested routes, and
provider-wrapped main.tsx with ThemeProvider/ToastProvider/BreadcrumbProvider.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
hsiegeln
2026-04-04 21:55:21 +02:00
parent e725669aef
commit 91a4235223
9 changed files with 360 additions and 2 deletions

View File

@@ -0,0 +1,114 @@
import { useState, useCallback } from 'react';
import { useNavigate, useLocation } from 'react-router';
import { SidebarTree, type SidebarTreeNode } from '@cameleer/design-system';
import { useAuthStore } from '../auth/auth-store';
import { useEnvironments, useApps } from '../api/hooks';
import type { EnvironmentResponse } from '../types/api';
/**
* Renders one environment entry as a SidebarTreeNode.
* This is a "render nothing, report data" component: it fetches apps for
* the given environment and invokes `onNode` with the assembled tree node
* whenever the data changes.
*
* Using a dedicated component per env is the idiomatic way to call a hook
* for each item in a dynamic list without violating Rules of Hooks.
*/
function EnvWithApps({
env,
onNode,
}: {
env: EnvironmentResponse;
onNode: (node: SidebarTreeNode) => void;
}) {
const { data: apps } = useApps(env.id);
const children: SidebarTreeNode[] = (apps ?? []).map((app) => ({
id: app.id,
label: app.displayName,
path: `/environments/${env.id}/apps/${app.id}`,
}));
const node: SidebarTreeNode = {
id: env.id,
label: env.displayName,
path: `/environments/${env.id}`,
children: children.length > 0 ? children : undefined,
};
// Calling onNode during render is intentional here: we want the parent to
// collect the latest node on every render. The parent guards against
// infinite loops by doing a shallow equality check before updating state.
onNode(node);
return null;
}
export function EnvironmentTree() {
const tenantId = useAuthStore((s) => s.tenantId);
const { data: environments } = useEnvironments(tenantId ?? '');
const navigate = useNavigate();
const location = useLocation();
const [starred, setStarred] = useState<Set<string>>(new Set());
const [envNodes, setEnvNodes] = useState<Map<string, SidebarTreeNode>>(new Map());
const handleToggleStar = useCallback((id: string) => {
setStarred((prev) => {
const next = new Set(prev);
if (next.has(id)) {
next.delete(id);
} else {
next.add(id);
}
return next;
});
}, []);
const handleNode = useCallback((node: SidebarTreeNode) => {
setEnvNodes((prev) => {
const existing = prev.get(node.id);
// Avoid infinite re-renders: only update when something meaningful changed.
if (
existing &&
existing.label === node.label &&
existing.path === node.path &&
existing.children?.length === node.children?.length
) {
return prev;
}
return new Map(prev).set(node.id, node);
});
}, []);
const envs = environments ?? [];
// Build the final node list, falling back to env-only nodes until apps load.
const nodes: SidebarTreeNode[] = envs.map(
(env) =>
envNodes.get(env.id) ?? {
id: env.id,
label: env.displayName,
path: `/environments/${env.id}`,
},
);
return (
<>
{/* Invisible data-fetchers: one per environment */}
{envs.map((env) => (
<EnvWithApps key={env.id} env={env} onNode={handleNode} />
))}
<SidebarTree
nodes={nodes}
selectedPath={location.pathname}
isStarred={(id) => starred.has(id)}
onToggleStar={handleToggleStar}
onNavigate={(path) => navigate(path)}
persistKey="env-tree"
autoRevealPath={location.pathname}
/>
</>
);
}

View File

@@ -0,0 +1,166 @@
import { useState } from 'react';
import { Outlet, useNavigate } from 'react-router';
import {
AppShell,
Sidebar,
TopBar,
} from '@cameleer/design-system';
import { useAuthStore } from '../auth/auth-store';
import { EnvironmentTree } from './EnvironmentTree';
// Simple SVG logo mark for the sidebar header
function CameleerLogo() {
return (
<svg
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
xmlns="http://www.w3.org/2000/svg"
aria-hidden="true"
>
<circle cx="12" cy="12" r="10" fill="currentColor" opacity="0.15" />
<path
d="M7 14c0-2.5 2-4.5 4.5-4.5S16 11.5 16 14"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
/>
<circle cx="12" cy="8" r="2" fill="currentColor" />
</svg>
);
}
// Nav icon helpers
function DashboardIcon() {
return (
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" aria-hidden="true">
<rect x="1" y="1" width="6" height="6" rx="1" fill="currentColor" />
<rect x="9" y="1" width="6" height="6" rx="1" fill="currentColor" />
<rect x="1" y="9" width="6" height="6" rx="1" fill="currentColor" />
<rect x="9" y="9" width="6" height="6" rx="1" fill="currentColor" />
</svg>
);
}
function EnvIcon() {
return (
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" aria-hidden="true">
<path
d="M2 4h12M2 8h12M2 12h12"
stroke="currentColor"
strokeWidth="1.5"
strokeLinecap="round"
/>
</svg>
);
}
function LicenseIcon() {
return (
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" aria-hidden="true">
<rect x="2" y="2" width="12" height="12" rx="2" stroke="currentColor" strokeWidth="1.5" />
<path d="M5 8h6M5 5h6M5 11h4" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" />
</svg>
);
}
function ObsIcon() {
return (
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" aria-hidden="true">
<circle cx="8" cy="8" r="6" stroke="currentColor" strokeWidth="1.5" />
<path d="M4 8h8" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" />
<path d="M8 4v8" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" />
</svg>
);
}
function UserIcon() {
return (
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" aria-hidden="true">
<circle cx="8" cy="5" r="3" stroke="currentColor" strokeWidth="1.5" />
<path
d="M2 13c0-3 2.7-5 6-5s6 2 6 5"
stroke="currentColor"
strokeWidth="1.5"
strokeLinecap="round"
/>
</svg>
);
}
export function Layout() {
const navigate = useNavigate();
const username = useAuthStore((s) => s.username);
const logout = useAuthStore((s) => s.logout);
const [envSectionOpen, setEnvSectionOpen] = useState(true);
const [collapsed, setCollapsed] = useState(false);
const sidebar = (
<Sidebar collapsed={collapsed} onCollapseToggle={() => setCollapsed((c) => !c)}>
<Sidebar.Header
logo={<CameleerLogo />}
title="Cameleer SaaS"
onClick={() => navigate('/')}
/>
{/* Dashboard */}
<Sidebar.Section
icon={<DashboardIcon />}
label="Dashboard"
open={false}
onToggle={() => navigate('/')}
>
{null}
</Sidebar.Section>
{/* Environments — expandable tree */}
<Sidebar.Section
icon={<EnvIcon />}
label="Environments"
open={envSectionOpen}
onToggle={() => setEnvSectionOpen((o) => !o)}
>
<EnvironmentTree />
</Sidebar.Section>
{/* License */}
<Sidebar.Section
icon={<LicenseIcon />}
label="License"
open={false}
onToggle={() => navigate('/license')}
>
{null}
</Sidebar.Section>
<Sidebar.Footer>
{/* Link to the observability SPA (external) */}
<Sidebar.FooterLink
icon={<ObsIcon />}
label="View Dashboard"
onClick={() => window.open('/dashboard', '_blank', 'noopener')}
/>
{/* User info + logout */}
<Sidebar.FooterLink
icon={<UserIcon />}
label={username ?? 'Account'}
onClick={logout}
/>
</Sidebar.Footer>
</Sidebar>
);
return (
<AppShell sidebar={sidebar}>
<TopBar
breadcrumb={[]}
user={username ? { name: username } : undefined}
onLogout={logout}
/>
<Outlet />
</AppShell>
);
}

View File

@@ -1,8 +1,32 @@
import React from 'react'; import React from 'react';
import ReactDOM from 'react-dom/client'; import ReactDOM from 'react-dom/client';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { BrowserRouter } from 'react-router';
import { ThemeProvider, ToastProvider, BreadcrumbProvider } from '@cameleer/design-system';
import '@cameleer/design-system/style.css';
import { AppRouter } from './router';
const queryClient = new QueryClient({
defaultOptions: {
queries: {
retry: 1,
staleTime: 10_000,
},
},
});
ReactDOM.createRoot(document.getElementById('root')!).render( ReactDOM.createRoot(document.getElementById('root')!).render(
<React.StrictMode> <React.StrictMode>
<div>Cameleer SaaS</div> <ThemeProvider>
</React.StrictMode> <ToastProvider>
<BreadcrumbProvider>
<QueryClientProvider client={queryClient}>
<BrowserRouter>
<AppRouter />
</BrowserRouter>
</QueryClientProvider>
</BreadcrumbProvider>
</ToastProvider>
</ThemeProvider>
</React.StrictMode>,
); );

View File

@@ -0,0 +1,3 @@
export function AppDetailPage() {
return <div>App Detail</div>;
}

View File

@@ -0,0 +1,3 @@
export function DashboardPage() {
return <div>Dashboard</div>;
}

View File

@@ -0,0 +1,3 @@
export function EnvironmentDetailPage() {
return <div>Environment Detail</div>;
}

View File

@@ -0,0 +1,3 @@
export function EnvironmentsPage() {
return <div>Environments</div>;
}

View File

@@ -0,0 +1,3 @@
export function LicensePage() {
return <div>License</div>;
}

39
ui/src/router.tsx Normal file
View File

@@ -0,0 +1,39 @@
import { Routes, Route } from 'react-router';
import { useEffect } from 'react';
import { useAuthStore } from './auth/auth-store';
import { LoginPage } from './auth/LoginPage';
import { CallbackPage } from './auth/CallbackPage';
import { ProtectedRoute } from './auth/ProtectedRoute';
import { Layout } from './components/Layout';
import { DashboardPage } from './pages/DashboardPage';
import { EnvironmentsPage } from './pages/EnvironmentsPage';
import { EnvironmentDetailPage } from './pages/EnvironmentDetailPage';
import { AppDetailPage } from './pages/AppDetailPage';
import { LicensePage } from './pages/LicensePage';
export function AppRouter() {
const loadFromStorage = useAuthStore((s) => s.loadFromStorage);
useEffect(() => {
loadFromStorage();
}, [loadFromStorage]);
return (
<Routes>
<Route path="/login" element={<LoginPage />} />
<Route path="/callback" element={<CallbackPage />} />
<Route
element={
<ProtectedRoute>
<Layout />
</ProtectedRoute>
}
>
<Route index element={<DashboardPage />} />
<Route path="environments" element={<EnvironmentsPage />} />
<Route path="environments/:envId" element={<EnvironmentDetailPage />} />
<Route path="environments/:envId/apps/:appId" element={<AppDetailPage />} />
<Route path="license" element={<LicensePage />} />
</Route>
</Routes>
);
}