Server UI assets also use absolute /assets/* paths that conflict with the SPA catch-all. Same fix as Logto: Host-based routing at server.localhost gives it its own namespace. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
189 lines
5.1 KiB
TypeScript
189 lines
5.1 KiB
TypeScript
import { useState } from 'react';
|
|
import { Outlet, useNavigate } from 'react-router';
|
|
import {
|
|
AppShell,
|
|
Sidebar,
|
|
TopBar,
|
|
} from '@cameleer/design-system';
|
|
import { useAuth } from '../auth/useAuth';
|
|
import { useScopes } from '../auth/useScopes';
|
|
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>
|
|
);
|
|
}
|
|
|
|
function PlatformIcon() {
|
|
return (
|
|
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" aria-hidden="true">
|
|
<path d="M8 1l6 3.5v7L8 15l-6-3.5v-7L8 1z" stroke="currentColor" strokeWidth="1.5" />
|
|
<path d="M8 1v14M2 4.5L14 4.5M2 11.5L14 11.5" stroke="currentColor" strokeWidth="1" opacity="0.4" />
|
|
</svg>
|
|
);
|
|
}
|
|
|
|
export function Layout() {
|
|
const navigate = useNavigate();
|
|
const { logout } = useAuth();
|
|
const scopes = useScopes();
|
|
|
|
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>
|
|
|
|
{/* Platform Admin section */}
|
|
{scopes.has('platform:admin') && (
|
|
<Sidebar.Section
|
|
icon={<PlatformIcon />}
|
|
label="Platform"
|
|
open={false}
|
|
onToggle={() => navigate('/admin/tenants')}
|
|
>
|
|
{null}
|
|
</Sidebar.Section>
|
|
)}
|
|
|
|
<Sidebar.Footer>
|
|
{/* Link to the observability SPA (direct port, not via Traefik prefix) */}
|
|
<Sidebar.FooterLink
|
|
icon={<ObsIcon />}
|
|
label="View Dashboard"
|
|
onClick={() => window.open(`http://server.${window.location.hostname}`, '_blank', 'noopener')}
|
|
/>
|
|
|
|
{/* User info + logout */}
|
|
<Sidebar.FooterLink
|
|
icon={<UserIcon />}
|
|
label="Account"
|
|
onClick={logout}
|
|
/>
|
|
</Sidebar.Footer>
|
|
</Sidebar>
|
|
);
|
|
|
|
return (
|
|
<AppShell sidebar={sidebar}>
|
|
<TopBar
|
|
breadcrumb={[]}
|
|
user={undefined}
|
|
onLogout={logout}
|
|
/>
|
|
<Outlet />
|
|
</AppShell>
|
|
);
|
|
}
|