feat: AppShell, Sidebar, TopBar layout components
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
13
src/design-system/layout/AppShell/AppShell.module.css
Normal file
13
src/design-system/layout/AppShell/AppShell.module.css
Normal 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;
|
||||||
|
}
|
||||||
20
src/design-system/layout/AppShell/AppShell.tsx
Normal file
20
src/design-system/layout/AppShell/AppShell.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
319
src/design-system/layout/Sidebar/Sidebar.module.css
Normal file
319
src/design-system/layout/Sidebar/Sidebar.module.css
Normal 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;
|
||||||
|
}
|
||||||
241
src/design-system/layout/Sidebar/Sidebar.tsx
Normal file
241
src/design-system/layout/Sidebar/Sidebar.tsx
Normal 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}>▸</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}>⚙</span>
|
||||||
|
<div className={styles.itemInfo}>
|
||||||
|
<div className={styles.itemName}>Admin</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className={styles.bottomItem}>
|
||||||
|
<span className={styles.bottomIcon}>☰</span>
|
||||||
|
<div className={styles.itemInfo}>
|
||||||
|
<div className={styles.itemName}>API Docs</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</aside>
|
||||||
|
)
|
||||||
|
}
|
||||||
110
src/design-system/layout/TopBar/TopBar.module.css
Normal file
110
src/design-system/layout/TopBar/TopBar.module.css
Normal 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;
|
||||||
|
}
|
||||||
66
src/design-system/layout/TopBar/TopBar.tsx
Normal file
66
src/design-system/layout/TopBar/TopBar.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
4
src/design-system/layout/index.ts
Normal file
4
src/design-system/layout/index.ts
Normal 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'
|
||||||
Reference in New Issue
Block a user