diff --git a/ui/src/components/EnvironmentTree.tsx b/ui/src/components/EnvironmentTree.tsx new file mode 100644 index 0000000..a0db25b --- /dev/null +++ b/ui/src/components/EnvironmentTree.tsx @@ -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>(new Set()); + const [envNodes, setEnvNodes] = useState>(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) => ( + + ))} + + starred.has(id)} + onToggleStar={handleToggleStar} + onNavigate={(path) => navigate(path)} + persistKey="env-tree" + autoRevealPath={location.pathname} + /> + + ); +} diff --git a/ui/src/components/Layout.tsx b/ui/src/components/Layout.tsx new file mode 100644 index 0000000..9df1430 --- /dev/null +++ b/ui/src/components/Layout.tsx @@ -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 ( + + ); +} + +// Nav icon helpers +function DashboardIcon() { + return ( + + ); +} + +function EnvIcon() { + return ( + + ); +} + +function LicenseIcon() { + return ( + + ); +} + +function ObsIcon() { + return ( + + ); +} + +function UserIcon() { + return ( + + ); +} + +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 = ( + setCollapsed((c) => !c)}> + } + title="Cameleer SaaS" + onClick={() => navigate('/')} + /> + + {/* Dashboard */} + } + label="Dashboard" + open={false} + onToggle={() => navigate('/')} + > + {null} + + + {/* Environments — expandable tree */} + } + label="Environments" + open={envSectionOpen} + onToggle={() => setEnvSectionOpen((o) => !o)} + > + + + + {/* License */} + } + label="License" + open={false} + onToggle={() => navigate('/license')} + > + {null} + + + + {/* Link to the observability SPA (external) */} + } + label="View Dashboard" + onClick={() => window.open('/dashboard', '_blank', 'noopener')} + /> + + {/* User info + logout */} + } + label={username ?? 'Account'} + onClick={logout} + /> + + + ); + + return ( + + + + + ); +} diff --git a/ui/src/main.tsx b/ui/src/main.tsx index ff6b27e..a255f95 100644 --- a/ui/src/main.tsx +++ b/ui/src/main.tsx @@ -1,8 +1,32 @@ import React from 'react'; 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( -
Cameleer SaaS
-
+ + + + + + + + + + + + , ); diff --git a/ui/src/pages/AppDetailPage.tsx b/ui/src/pages/AppDetailPage.tsx new file mode 100644 index 0000000..a5e44ad --- /dev/null +++ b/ui/src/pages/AppDetailPage.tsx @@ -0,0 +1,3 @@ +export function AppDetailPage() { + return
App Detail
; +} diff --git a/ui/src/pages/DashboardPage.tsx b/ui/src/pages/DashboardPage.tsx new file mode 100644 index 0000000..00ff2a0 --- /dev/null +++ b/ui/src/pages/DashboardPage.tsx @@ -0,0 +1,3 @@ +export function DashboardPage() { + return
Dashboard
; +} diff --git a/ui/src/pages/EnvironmentDetailPage.tsx b/ui/src/pages/EnvironmentDetailPage.tsx new file mode 100644 index 0000000..ac61aff --- /dev/null +++ b/ui/src/pages/EnvironmentDetailPage.tsx @@ -0,0 +1,3 @@ +export function EnvironmentDetailPage() { + return
Environment Detail
; +} diff --git a/ui/src/pages/EnvironmentsPage.tsx b/ui/src/pages/EnvironmentsPage.tsx new file mode 100644 index 0000000..8e19427 --- /dev/null +++ b/ui/src/pages/EnvironmentsPage.tsx @@ -0,0 +1,3 @@ +export function EnvironmentsPage() { + return
Environments
; +} diff --git a/ui/src/pages/LicensePage.tsx b/ui/src/pages/LicensePage.tsx new file mode 100644 index 0000000..47f25bd --- /dev/null +++ b/ui/src/pages/LicensePage.tsx @@ -0,0 +1,3 @@ +export function LicensePage() { + return
License
; +} diff --git a/ui/src/router.tsx b/ui/src/router.tsx new file mode 100644 index 0000000..e6b625a --- /dev/null +++ b/ui/src/router.tsx @@ -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 ( + + } /> + } /> + + + + } + > + } /> + } /> + } /> + } /> + } /> + + + ); +}