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:
114
ui/src/components/EnvironmentTree.tsx
Normal file
114
ui/src/components/EnvironmentTree.tsx
Normal 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}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
166
ui/src/components/Layout.tsx
Normal file
166
ui/src/components/Layout.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>,
|
||||||
);
|
);
|
||||||
|
|||||||
3
ui/src/pages/AppDetailPage.tsx
Normal file
3
ui/src/pages/AppDetailPage.tsx
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
export function AppDetailPage() {
|
||||||
|
return <div>App Detail</div>;
|
||||||
|
}
|
||||||
3
ui/src/pages/DashboardPage.tsx
Normal file
3
ui/src/pages/DashboardPage.tsx
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
export function DashboardPage() {
|
||||||
|
return <div>Dashboard</div>;
|
||||||
|
}
|
||||||
3
ui/src/pages/EnvironmentDetailPage.tsx
Normal file
3
ui/src/pages/EnvironmentDetailPage.tsx
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
export function EnvironmentDetailPage() {
|
||||||
|
return <div>Environment Detail</div>;
|
||||||
|
}
|
||||||
3
ui/src/pages/EnvironmentsPage.tsx
Normal file
3
ui/src/pages/EnvironmentsPage.tsx
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
export function EnvironmentsPage() {
|
||||||
|
return <div>Environments</div>;
|
||||||
|
}
|
||||||
3
ui/src/pages/LicensePage.tsx
Normal file
3
ui/src/pages/LicensePage.tsx
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
export function LicensePage() {
|
||||||
|
return <div>License</div>;
|
||||||
|
}
|
||||||
39
ui/src/router.tsx
Normal file
39
ui/src/router.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user