feat: AppShell, Sidebar, TopBar layout components

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
hsiegeln
2026-03-18 10:06:32 +01:00
parent a44e93383c
commit 332cf18d1d
7 changed files with 773 additions and 0 deletions

View File

@@ -0,0 +1,13 @@
.app {
display: flex;
height: 100vh;
overflow: hidden;
}
.main {
flex: 1;
display: flex;
flex-direction: column;
overflow: hidden;
min-width: 0;
}

View File

@@ -0,0 +1,20 @@
import styles from './AppShell.module.css'
import type { ReactNode } from 'react'
interface AppShellProps {
sidebar: ReactNode
children: ReactNode
detail?: ReactNode
}
export function AppShell({ sidebar, children, detail }: AppShellProps) {
return (
<div className={styles.app}>
{sidebar}
<div className={styles.main}>
{children}
</div>
{detail}
</div>
)
}

View File

@@ -0,0 +1,319 @@
.sidebar {
width: 220px;
flex-shrink: 0;
background: var(--sidebar-bg);
display: flex;
flex-direction: column;
overflow: hidden;
}
/* Logo */
.logo {
padding: 16px 18px;
display: flex;
align-items: center;
gap: 10px;
border-bottom: 1px solid rgba(255, 255, 255, 0.06);
flex-shrink: 0;
}
.brand {
font-family: var(--font-mono);
font-weight: 600;
font-size: 15px;
color: var(--amber-light);
letter-spacing: -0.3px;
}
.version {
font-family: var(--font-mono);
font-size: 10px;
color: var(--sidebar-muted);
margin-left: 4px;
}
/* Search */
.searchWrap {
padding: 10px 12px;
flex-shrink: 0;
}
.searchInner {
position: relative;
}
.searchInput {
width: 100%;
background: rgba(255, 255, 255, 0.06);
border: 1px solid rgba(255, 255, 255, 0.08);
border-radius: var(--radius-sm);
padding: 6px 10px 6px 28px;
color: var(--sidebar-text);
font-family: var(--font-body);
font-size: 12px;
outline: none;
transition: border-color 0.15s;
}
.searchInput::placeholder {
color: var(--sidebar-muted);
}
.searchInput:focus {
border-color: rgba(198, 130, 14, 0.4);
}
.searchIcon {
position: absolute;
left: 9px;
top: 50%;
transform: translateY(-50%);
color: var(--sidebar-muted);
display: flex;
align-items: center;
}
/* Scrollable nav area */
.navArea {
flex: 1;
overflow-y: auto;
min-height: 0;
}
/* Section headers */
.section {
padding: 14px 12px 5px;
font-size: 10px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 1.2px;
color: var(--sidebar-muted);
}
/* Items container */
.items {
padding: 0 6px;
}
/* Nav item */
.item {
display: flex;
align-items: center;
gap: 10px;
padding: 7px 12px;
border-radius: var(--radius-sm);
color: var(--sidebar-text);
font-size: 13px;
cursor: pointer;
transition: all 0.12s;
border-left: 3px solid transparent;
margin-bottom: 1px;
user-select: none;
}
.item:hover {
background: var(--sidebar-hover);
color: #e8dfd4;
}
.item.active {
background: var(--sidebar-active);
color: var(--amber-light);
border-left-color: var(--amber);
}
.item.active .itemCount {
background: rgba(198, 130, 14, 0.2);
color: var(--amber-light);
}
/* Indented route items */
.indented {
padding-left: 22px;
}
.routeArrow {
color: var(--sidebar-muted);
font-size: 10px;
}
/* Item sub-elements */
.itemInfo {
flex: 1;
min-width: 0;
}
.itemName {
font-weight: 500;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.itemMeta {
font-size: 11px;
color: var(--sidebar-muted);
font-family: var(--font-mono);
}
.itemCount {
font-family: var(--font-mono);
font-size: 11px;
color: var(--sidebar-muted);
background: rgba(255, 255, 255, 0.06);
padding: 1px 6px;
border-radius: 10px;
flex-shrink: 0;
}
/* Health dots */
.healthDot {
width: 7px;
height: 7px;
border-radius: 50%;
flex-shrink: 0;
}
.healthLive {
background: #5db866;
box-shadow: 0 0 6px rgba(93, 184, 102, 0.4);
}
.healthStale {
background: var(--warning);
}
.healthDead {
background: var(--sidebar-muted);
}
/* Divider */
.divider {
height: 1px;
background: rgba(255, 255, 255, 0.06);
margin: 6px 12px;
}
/* Agents header */
.agentsHeader {
padding: 14px 12px 6px;
font-size: 10px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 1.2px;
color: var(--sidebar-muted);
display: flex;
align-items: center;
justify-content: space-between;
flex-shrink: 0;
}
.agentBadge {
font-family: var(--font-mono);
font-size: 10px;
padding: 1px 6px;
border-radius: 10px;
background: rgba(93, 184, 102, 0.15);
color: #5db866;
}
/* Agents list */
.agentsList {
padding: 0 0 6px;
overflow-y: auto;
max-height: 180px;
flex-shrink: 0;
}
.agentItem {
display: flex;
align-items: center;
gap: 8px;
padding: 6px 12px;
margin: 0 6px 2px;
border-radius: var(--radius-sm);
font-size: 11px;
color: var(--sidebar-text);
transition: background 0.1s;
}
.agentItem:hover {
background: var(--sidebar-hover);
}
.agentDot {
width: 6px;
height: 6px;
border-radius: 50%;
flex-shrink: 0;
}
.agentInfo {
flex: 1;
min-width: 0;
}
.agentName {
font-family: var(--font-mono);
font-size: 11px;
font-weight: 500;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.agentDetail {
font-size: 10px;
color: var(--sidebar-muted);
}
.agentStats {
text-align: right;
font-family: var(--font-mono);
font-size: 10px;
color: var(--sidebar-muted);
}
.agentTps {
color: var(--sidebar-text);
}
.agentLastSeen {
color: var(--sidebar-muted);
}
.agentError {
color: var(--error);
}
/* Bottom links */
.bottom {
border-top: 1px solid rgba(255, 255, 255, 0.06);
padding: 6px;
flex-shrink: 0;
}
.bottomItem {
display: flex;
align-items: center;
gap: 10px;
padding: 7px 12px;
border-radius: var(--radius-sm);
color: var(--sidebar-muted);
font-size: 12px;
cursor: pointer;
transition: all 0.12s;
border-left: 3px solid transparent;
margin-bottom: 1px;
}
.bottomItem:hover {
background: var(--sidebar-hover);
color: var(--sidebar-text);
}
.bottomIcon {
font-size: 13px;
width: 18px;
text-align: center;
}

View File

@@ -0,0 +1,241 @@
import { useState } from 'react'
import styles from './Sidebar.module.css'
export interface App {
id: string
name: string
agentCount: number
health: 'live' | 'stale' | 'dead'
execCount: number
}
export interface Route {
id: string
name: string
execCount: number
}
export interface Agent {
id: string
name: string
service: string
version: string
tps: string
lastSeen: string
status: 'live' | 'stale' | 'dead'
errorRate?: string
}
interface SidebarProps {
apps: App[]
routes: Route[]
agents: Agent[]
activeItem?: string
onItemClick?: (id: string) => void
}
function HealthDot({ status }: { status: 'live' | 'stale' | 'dead' }) {
return (
<span
className={[
styles.healthDot,
status === 'live' ? styles.healthLive : '',
status === 'stale' ? styles.healthStale : '',
status === 'dead' ? styles.healthDead : '',
]
.filter(Boolean)
.join(' ')}
/>
)
}
// Camel SVG silhouette
function CamelIcon() {
return (
<svg
width="28"
height="24"
viewBox="0 0 28 24"
fill="none"
xmlns="http://www.w3.org/2000/svg"
aria-hidden="true"
>
{/* Simple camel silhouette */}
<path
d="M4 20 L4 16 L6 14 L6 12 C6 10 7 9 9 9 L10 8 C10 6 11 5 12 5 C12 3 13 2 14 2 C15 2 15.5 3 15 5 C16 5 17 6 17 8 L18 9 C20 9 21 10 21 12 L21 14 L22 14 L22 12 C22 10 23 9 24 9 L24 20"
fill="var(--amber-light)"
stroke="var(--amber)"
strokeWidth="0.5"
/>
<path
d="M4 20 L24 20"
stroke="var(--amber)"
strokeWidth="1"
strokeLinecap="round"
/>
</svg>
)
}
export function Sidebar({
apps,
routes,
agents,
activeItem,
onItemClick,
}: SidebarProps) {
const [search, setSearch] = useState('')
const liveCount = agents.filter((a) => a.status === 'live').length
const agentBadge = `${liveCount}/${agents.length} live`
const filteredApps = search
? apps.filter((a) => a.name.toLowerCase().includes(search.toLowerCase()))
: apps
return (
<aside className={styles.sidebar}>
{/* Logo */}
<div className={styles.logo}>
<CamelIcon />
<div>
<span className={styles.brand}>cameleer</span>
<span className={styles.version}>v3.2.1</span>
</div>
</div>
{/* Search */}
<div className={styles.searchWrap}>
<div className={styles.searchInner}>
<span className={styles.searchIcon} aria-hidden="true">
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<circle cx="11" cy="11" r="8" />
<line x1="21" y1="21" x2="16.65" y2="16.65" />
</svg>
</span>
<input
className={styles.searchInput}
type="text"
placeholder="Filter apps..."
value={search}
onChange={(e) => setSearch(e.target.value)}
/>
</div>
</div>
{/* Scrollable nav area */}
<div className={styles.navArea}>
{/* Applications section */}
<div className={styles.section}>Applications</div>
<div className={styles.items}>
{filteredApps.map((app) => (
<div
key={app.id}
className={[
styles.item,
activeItem === app.id ? styles.active : '',
]
.filter(Boolean)
.join(' ')}
onClick={() => onItemClick?.(app.id)}
role="button"
tabIndex={0}
onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ' ') onItemClick?.(app.id)
}}
>
<HealthDot status={app.health} />
<div className={styles.itemInfo}>
<div className={styles.itemName}>{app.name}</div>
<div className={styles.itemMeta}>{app.agentCount} agent{app.agentCount !== 1 ? 's' : ''}</div>
</div>
<span className={styles.itemCount}>{app.execCount.toLocaleString()}</span>
</div>
))}
</div>
{/* Divider */}
<div className={styles.divider} />
{/* Routes section */}
<div className={styles.section}>Routes</div>
<div className={styles.items}>
{routes.map((route) => (
<div
key={route.id}
className={[
styles.item,
styles.indented,
activeItem === route.id ? styles.active : '',
]
.filter(Boolean)
.join(' ')}
onClick={() => onItemClick?.(route.id)}
role="button"
tabIndex={0}
onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ' ') onItemClick?.(route.id)
}}
>
<span className={styles.routeArrow}>&#9656;</span>
<div className={styles.itemInfo}>
<div className={styles.itemName}>{route.name}</div>
</div>
<span className={styles.itemCount}>{route.execCount.toLocaleString()}</span>
</div>
))}
</div>
</div>
{/* Agent health section */}
<div className={styles.agentsHeader}>
<span>Agents</span>
<span className={styles.agentBadge}>{agentBadge}</span>
</div>
<div className={styles.agentsList}>
{agents.map((agent) => (
<div key={agent.id} className={styles.agentItem}>
<span
className={[
styles.agentDot,
agent.status === 'live' ? styles.healthLive : '',
agent.status === 'stale' ? styles.healthStale : '',
agent.status === 'dead' ? styles.healthDead : '',
]
.filter(Boolean)
.join(' ')}
/>
<div className={styles.agentInfo}>
<div className={styles.agentName}>{agent.name}</div>
<div className={styles.agentDetail}>{agent.service} {agent.version}</div>
</div>
<div className={styles.agentStats}>
<div className={styles.agentTps}>{agent.tps}</div>
<div
className={agent.errorRate ? styles.agentError : styles.agentLastSeen}
>
{agent.errorRate ?? agent.lastSeen}
</div>
</div>
</div>
))}
</div>
{/* Bottom links */}
<div className={styles.bottom}>
<div className={styles.bottomItem}>
<span className={styles.bottomIcon}>&#9881;</span>
<div className={styles.itemInfo}>
<div className={styles.itemName}>Admin</div>
</div>
</div>
<div className={styles.bottomItem}>
<span className={styles.bottomIcon}>&#9776;</span>
<div className={styles.itemInfo}>
<div className={styles.itemName}>API Docs</div>
</div>
</div>
</div>
</aside>
)
}

View File

@@ -0,0 +1,110 @@
.topbar {
display: flex;
align-items: center;
gap: 12px;
padding: 0 24px;
height: 48px;
flex-shrink: 0;
background: var(--bg-surface);
border-bottom: 1px solid var(--border);
}
/* Breadcrumb area - left side */
.breadcrumb {
flex-shrink: 0;
}
/* Center search trigger */
.search {
display: flex;
align-items: center;
gap: 6px;
padding: 5px 12px;
border: 1px solid var(--border);
border-radius: var(--radius-sm);
background: var(--bg-raised);
color: var(--text-muted);
font-size: 12px;
font-family: var(--font-body);
cursor: pointer;
transition: border-color 0.15s;
min-width: 280px;
flex: 1;
max-width: 400px;
text-align: left;
}
.search:hover {
border-color: var(--text-faint);
}
.searchIcon {
display: flex;
align-items: center;
color: var(--text-faint);
flex-shrink: 0;
}
.searchPlaceholder {
flex: 1;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.kbd {
font-family: var(--font-mono);
font-size: 10px;
background: var(--bg-surface);
border: 1px solid var(--border);
padding: 0 4px;
border-radius: 3px;
color: var(--text-faint);
flex-shrink: 0;
line-height: 1.6;
}
/* Right section */
.right {
margin-left: auto;
display: flex;
align-items: center;
gap: 12px;
flex-shrink: 0;
}
.env {
font-family: var(--font-mono);
font-size: 10px;
font-weight: 600;
padding: 3px 10px;
border-radius: 10px;
background: var(--success-bg);
color: var(--success);
border: 1px solid var(--success-border);
text-transform: uppercase;
letter-spacing: 0.5px;
}
.shift {
font-family: var(--font-mono);
font-size: 10px;
padding: 3px 10px;
border-radius: 10px;
background: var(--running-bg);
color: var(--running);
border: 1px solid var(--running-border);
}
.user {
display: flex;
align-items: center;
gap: 8px;
font-size: 12px;
color: var(--text-secondary);
}
.userName {
color: var(--text-secondary);
font-size: 12px;
}

View File

@@ -0,0 +1,66 @@
import styles from './TopBar.module.css'
import { Breadcrumb } from '../../composites/Breadcrumb/Breadcrumb'
import { Avatar } from '../../primitives/Avatar/Avatar'
interface BreadcrumbItem {
label: string
href?: string
}
interface TopBarProps {
breadcrumb: BreadcrumbItem[]
environment?: string
shift?: string
user?: { name: string }
onSearchClick?: () => void
className?: string
}
export function TopBar({
breadcrumb,
environment,
shift,
user,
onSearchClick,
className,
}: TopBarProps) {
return (
<header className={`${styles.topbar} ${className ?? ''}`}>
{/* Left: Breadcrumb */}
<Breadcrumb items={breadcrumb} className={styles.breadcrumb} />
{/* Center: Search trigger */}
<button
className={styles.search}
onClick={onSearchClick}
type="button"
aria-label="Open search"
>
<span className={styles.searchIcon} aria-hidden="true">
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<circle cx="11" cy="11" r="8" />
<line x1="21" y1="21" x2="16.65" y2="16.65" />
</svg>
</span>
<span className={styles.searchPlaceholder}>Search by Order ID, route, error...</span>
<span className={styles.kbd}>Ctrl+K</span>
</button>
{/* Right: env badge, shift, user */}
<div className={styles.right}>
{environment && (
<span className={styles.env}>{environment}</span>
)}
{shift && (
<span className={styles.shift}>Shift: {shift}</span>
)}
{user && (
<div className={styles.user}>
<span className={styles.userName}>{user.name}</span>
<Avatar name={user.name} size="md" />
</div>
)}
</div>
</header>
)
}

View File

@@ -0,0 +1,4 @@
export { AppShell } from './AppShell/AppShell'
export { Sidebar } from './Sidebar/Sidebar'
export type { App, Route, Agent } from './Sidebar/Sidebar'
export { TopBar } from './TopBar/TopBar'