feat(ui): full frontend redesign to the Sentry-style design system

Replace the legacy UI with the dark, mono-accent design language from
design/23-sentry.html across every non-canvas surface.

- PlatformShell: fixed 252px sidebar with grouped nav (Platform /
  Operate / Workspace), brand mark, user card, and a topbar with
  search (Cmd+K palette), env badge, upgrade, notifications, and
  theme toggle; mobile collapses to a top bar with horizontal nav
- New shared component kit in shared/components/sentry.tsx:
  SPageHead, SStat, SCard, STable, SSection, SPill, Ticks, Seg,
  GhostBtn/QuietBtn/IconBtn, status tone helpers
- New DashboardPage at / — stats row, per-node CPU chart with
  1h/24h/7d ranges, resource usage tick strips, anomaly box,
  node summary table, recent deployments and project cards;
  real API data with demo-mode fixtures
- Landing page moved to /landing and rebuilt: live host telemetry
  window, install strip, feature grid, template catalog, docs
- Every page (Projects, Templates, Builds, Databases, HA,
  Operations, Activity, Security, Usage, Docs, People, Settings,
  Audit Logs, Webhooks, Admin, NodeDetail, ServiceDetail, auth,
  setup, not-found) migrated to the new kit and tokens
- Nav active state resolves by longest matching href
- Canvas internals intentionally untouched — separate pass

Verified: tsc --noEmit, eslint, vite build, Playwright walkthrough
of every route at desktop and mobile widths (demo + live session).

Generated with [Devin](https://devin.ai)

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
This commit is contained in:
Tomas DvorakandDevin committed 2026-10-10 16:42:26 +02:00
1 parent a05d225016
commit 24b9b1267e
42 files changed
+4077 -3018

No files matched your search

+2 -2
View File
@@ -77,12 +77,12 @@ export function CardHeader({ title, description, icon, action, className = '', .
<div className={`flex items-start justify-between gap-4 ${className}`} {...props}>
<div className="flex items-start gap-3">
{icon && (
<div className="w-10 h-10 rounded-xl flex items-center justify-center flex-shrink-0 ring-1 ring-white/5" style={{ background: 'rgba(255,255,255,0.07)' }}>
<div className="w-10 h-10 rounded-xl flex items-center justify-center flex-shrink-0 ring-1 ring-[var(--tint-05)]" style={{ background: 'var(--tint-07)' }}>
{icon}
</div>
)}
<div>
<h3 className="text-lg font-semibold text-[var(--text-primary)] tracking-tight">{title}</h3>
<h3 className="font-headline text-base font-semibold text-[var(--text-primary)] tracking-tight">{title}</h3>
{description && (
<p className="text-sm text-[var(--text-secondary)] mt-0.5">{description}</p>
)}
@@ -68,7 +68,7 @@ function gradientId(prefix: string, color: string, index = 0) {
export function LineChart({
data,
color = '#b4e34a',
color = '#38bdf8',
fillColor = 'transparent',
height = 76,
showDots = true,
@@ -181,7 +181,7 @@ export function MultiLineChart({
export function LineMetricChart({
data,
color = '#b4e34a',
color = '#38bdf8',
fillOpacity = 0.15,
height = 76,
showArea = false,
@@ -204,8 +204,8 @@ export function DualLineChart({
data1,
data2,
height = 58,
color1 = '#7ab8ff',
color2 = '#f2c94c',
color1 = 'var(--accent-primary)',
color2 = 'var(--warning)',
fillOpacity1 = 0.15,
fillOpacity2 = 0.15,
}: DualLineChartProps) {
@@ -134,6 +134,17 @@ function CommandPaletteContent({
onClose();
},
},
{
id: 'goto-dashboard',
label: 'Go to Dashboard',
icon: LayoutGrid,
shortcut: 'H',
category: 'Navigate',
action: () => {
onNavigate?.('dashboard');
onClose();
},
},
{
id: 'goto-canvas',
label: 'Go to Canvas',
@@ -205,7 +216,7 @@ function CommandPaletteContent({
/>
<div className="absolute left-1/2 top-[20%] -translate-x-1/2 w-full max-w-xl">
<Command
className="panel overflow-hidden"
className="s-card !p-0 overflow-hidden shadow-2xl shadow-black/50"
loop
>
<div className="flex items-center gap-3 px-4 py-3 border-b border-[var(--border-subtle)]">
@@ -228,7 +239,7 @@ function CommandPaletteContent({
{Object.entries(groupedCommands).map(([category, items]) => (
<Command.Group key={category} heading={category} className="mb-2">
<div className="px-2 py-1.5 text-[10px] font-semibold uppercase tracking-widest text-[var(--text-muted)]">
<div className="px-2 py-1.5 v-mono text-[10px] uppercase tracking-[0.14em] text-[var(--text-muted)]">
{category}
</div>
{items.map((item) => {
@@ -238,9 +249,9 @@ function CommandPaletteContent({
key={item.id}
value={item.label}
onSelect={item.action}
className="flex items-center gap-3 px-3 py-2.5 rounded-[var(--radius-md)] cursor-pointer text-[var(--text-secondary)] hover:bg-[var(--surface-muted)] hover:text-[var(--text-primary)] aria-selected:bg-[var(--accent-primary-soft)] aria-selected:text-[var(--accent-primary)] transition-colors"
className="flex items-center gap-3 px-3 py-2 rounded-[var(--radius-md)] cursor-pointer text-[var(--text-secondary)] hover:bg-[var(--surface-muted)] hover:text-[var(--text-primary)] aria-selected:bg-[var(--accent-primary-soft)] aria-selected:text-[var(--accent-primary)] transition-colors"
>
<Icon size={18} className="text-[var(--text-muted)]" />
<span className="s-ibox !h-7 !w-7"><Icon /></span>
<div className="flex-1">
<div className="text-sm font-medium">{item.label}</div>
{item.description && (
@@ -8,15 +8,15 @@ import { Sparkles } from 'lucide-react';
export function DemoRestricted({ feature }: { feature: string }) {
return (
<div className="flex min-h-[60vh] items-center justify-center p-8">
<div className="panel max-w-md p-8 text-center">
<div className="mx-auto mb-4 flex h-12 w-12 items-center justify-center rounded-full bg-[var(--accent-primary-soft)]">
<Sparkles size={22} className="text-[var(--accent-primary)]" />
<div className="s-card max-w-md text-center">
<div className="s-ibox mx-auto mb-4 !h-10 !w-10">
<Sparkles size={18} />
</div>
<p className="text-lg font-semibold text-[var(--text-primary)]">Not part of the demo</p>
<p className="font-headline text-[17px] font-semibold text-[var(--text-primary)]">Not part of the demo</p>
<p className="mt-2 text-sm text-[var(--text-secondary)]">
{feature} needs a live Containr session. The demo covers sample projects, services, and platform views only.
</p>
<Link to="/projects?demo=1" className="v-btn mt-5 inline-flex">
<Link to="/projects?demo=1" className="s-btn-accent mt-5 inline-flex">
Back to demo projects
</Link>
</div>
@@ -17,8 +17,8 @@ export function DonutChart({
percentage,
size = 160,
thickness = 16,
color = '#b4e34a',
trackColor = 'rgba(255, 255, 255, 0.07)',
color = '#38bdf8',
trackColor = 'var(--tint-07)',
segments = 28,
gap = 0.048,
animated = true,
@@ -1,4 +1,5 @@
import { type ReactNode } from 'react';
import { ChevronRight, MoreHorizontal } from 'lucide-react';
interface EnhancedMetricCardProps {
title: string;
@@ -15,6 +16,42 @@ interface EnhancedMetricCardProps {
horizontal?: boolean;
}
const statusColors: Record<string, string> = {
good: 'var(--success)',
average: 'var(--warning)',
warning: 'var(--warning)',
};
function CardHead({ icon, title }: { icon: ReactNode; title: string }) {
return (
<div className="s-cardhead">
<span className="s-ibox">{icon}</span>
<span className="text-[11.5px] font-medium text-[var(--text-secondary)] whitespace-nowrap">{title}</span>
<span className="ml-auto text-[var(--text-tertiary)]">
<MoreHorizontal size={14} />
</span>
</div>
);
}
function CardFoot({ label, onClick }: { label: string; onClick?: () => void }) {
return (
<div className="s-stat-foot mt-auto">
<span
onClick={onClick}
className="cursor-pointer hover:text-[var(--text-secondary)] transition-colors"
>
{label}
</span>
{onClick && (
<button className="s-icon-btn !w-[26px] !h-[26px]" onClick={onClick} title={label}>
<ChevronRight size={12} />
</button>
)}
</div>
);
}
export function EnhancedMetricCard({
title,
value,
@@ -29,75 +66,23 @@ export function EnhancedMetricCard({
animationDelay = 0,
horizontal = false,
}: EnhancedMetricCardProps) {
const statusColors: Record<string, string> = {
good: '#5ee6a0',
average: '#f2c94c',
warning: '#f2c94c',
};
// Horizontal layout for cards like Active User
if (horizontal) {
return (
<div
className={`card ${className}`}
style={{
display: 'flex',
flexDirection: 'row',
padding: 0,
overflow: 'hidden',
animationDelay: `${animationDelay}s`,
}}
>
<div style={{
flex: 1,
padding: '20px 18px 18px 20px',
display: 'flex',
flexDirection: 'column',
}}>
<div style={{ display: 'flex', alignItems: 'center', gap: 10, marginBottom: 12 }}>
<div className="card-icon">{icon}</div>
<span style={{ fontSize: 14, fontWeight: 600, color: '#e8e9f0' }}>{title}</span>
</div>
<div style={{
fontSize: 36,
fontWeight: 900,
letterSpacing: '-1.5px',
lineHeight: 1,
color: '#e8e9f0',
}}>
{value}
</div>
{subtitle && (
<div style={{ fontSize: 12, color: '#6b6e7d', marginTop: 4 }}>{subtitle}</div>
)}
{details && <div style={{ marginTop: 12 }}>{details}</div>}
<div style={{
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
marginTop: 'auto',
paddingTop: 14,
}}>
<span
style={{ fontSize: 13, color: '#6b6e7d', fontWeight: 500, cursor: 'pointer' }}
onClick={onClick}
>
Details
</span>
<div className="arrow-btn" onClick={onClick}>
<svg viewBox="0 0 24 24" fill="none" stroke="white" strokeWidth={2.5} strokeLinecap="round" strokeLinejoin="round">
<polyline points="9 18 15 12 9 6" />
</svg>
<div className={`s-card flex flex-row overflow-hidden ${className}`} style={{ animationDelay: `${animationDelay}s` }}>
<div className="flex flex-1 flex-col">
<CardHead icon={icon} title={title} />
<div className="px-4">
<div className="font-headline text-[32px] font-bold leading-none tracking-[-0.025em] text-[var(--text-primary)]">
{value}
</div>
{subtitle && <div className="mt-1 text-[11.5px] text-[var(--text-tertiary)]">{subtitle}</div>}
{details && <div className="mt-3">{details}</div>}
</div>
<div className="mt-4" />
<CardFoot label="Details" onClick={onClick} />
</div>
{chart && (
<div style={{
width: '50%',
padding: '16px 14px 46px 0',
display: 'flex',
alignItems: 'flex-end',
}}>
<div className="flex w-1/2 items-end py-3 pr-3.5">
{chart}
</div>
)}
@@ -105,64 +90,25 @@ export function EnhancedMetricCard({
);
}
// Standard vertical card layout - matching self.html exactly
return (
<div
className={`card ${className}`}
style={{
display: 'flex',
flexDirection: 'column',
padding: 20,
animationDelay: `${animationDelay}s`,
}}
>
<div style={{ display: 'flex', alignItems: 'center', gap: 10, marginBottom: 12 }}>
<div className="card-icon">{icon}</div>
<span style={{ fontSize: 14, fontWeight: 600, color: '#e8e9f0' }}>{title}</span>
</div>
<div style={{
fontSize: 38,
fontWeight: 900,
letterSpacing: '-1.5px',
lineHeight: 1,
color: '#e8e9f0',
}}>
{value}
</div>
{(subtitle || statusText) && (
<div style={{ fontSize: 12, color: '#6b6e7d', marginTop: 4 }}>
{statusText && status && (
<span style={{ color: statusColors[status], fontWeight: 700 }}>{statusText}</span>
)}{' '}
{subtitle}
</div>
)}
{chart && <div style={{ marginTop: 12, marginBottom: 6 }}>{chart}</div>}
{details && <div style={{ marginTop: 16 }}>{details}</div>}
<div style={{
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
paddingTop: 4,
marginTop: 'auto'
}}>
<span
onClick={onClick}
style={{ fontSize: 13, color: '#6b6e7d', fontWeight: 500, cursor: 'pointer' }}
>
Details
</span>
<div className="arrow-btn" onClick={onClick}>
<svg viewBox="0 0 24 24" fill="none" stroke="white" strokeWidth={2.5} strokeLinecap="round" strokeLinejoin="round">
<polyline points="9 18 15 12 9 6" />
</svg>
<div className={`s-card flex flex-col ${className}`} style={{ animationDelay: `${animationDelay}s` }}>
<CardHead icon={icon} title={title} />
<div className="px-4 pb-1">
<div className="font-headline text-[32px] font-bold leading-none tracking-[-0.025em] text-[var(--text-primary)]">
{value}
</div>
{(subtitle || statusText) && (
<div className="mt-1.5 text-[11.5px] text-[var(--text-tertiary)]">
{statusText && status && (
<span className="font-semibold" style={{ color: statusColors[status] }}>{statusText}</span>
)}{' '}
{subtitle}
</div>
)}
{chart && <div className="mt-3 mb-1">{chart}</div>}
{details && <div className="mt-3">{details}</div>}
</div>
<CardFoot label="Details" onClick={onClick} />
</div>
);
}
@@ -186,118 +132,56 @@ export function CacheMetricCard({
const nonCachePercent = Math.round((nonCacheMB / totalMB) * 100);
return (
<div
className="card"
style={{
display: 'flex',
flexDirection: 'column',
padding: 20,
animationDelay: `${animationDelay}s`,
}}
>
<div style={{ display: 'flex', alignItems: 'center', gap: 10, marginBottom: 12 }}>
<div className="card-icon">
<svg viewBox="0 0 24 24" fill="none" stroke="#9295a4" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round">
<polyline points="3 6 5 6 21 6"/>
<path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"/>
<div className="s-card flex flex-col" style={{ animationDelay: `${animationDelay}s` }}>
<CardHead
icon={
<svg viewBox="0 0 24 24" width="13.5" height="13.5" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round">
<polyline points="3 6 5 6 21 6" />
<path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2" />
</svg>
}
title="Cache"
/>
<div className="px-4">
<div className="font-headline text-[32px] font-bold leading-none tracking-[-0.025em] text-[var(--text-primary)]">
{totalMB} <span className="text-[12px] font-medium text-[var(--text-tertiary)]">MB</span>
</div>
<span style={{ fontSize: 14, fontWeight: 600, color: '#e8e9f0' }}>Cache</span>
</div>
<div style={{
fontSize: 38,
fontWeight: 900,
letterSpacing: '-1.5px',
lineHeight: 1,
color: '#e8e9f0',
}}>
{totalMB} MB
</div>
<div style={{ fontSize: 12, color: '#6b6e7d', marginTop: 4 }}>
<span style={{ color: '#f2c94c', fontWeight: 700 }}>{Math.round(totalMB * 0.625)}MB Average</span> cached images and files
</div>
{/* Segmented Bar */}
<div style={{ display: 'flex', alignItems: 'center', gap: 5, margin: '16px 0 15px', height: 32 }}>
<div
className="cache-seg"
style={{
width: `${cachePercent}%`,
background: '#ff6b5b',
borderRadius: '10px 4px 4px 10px'
}}
/>
<div
className="cache-seg"
style={{
width: `${nonCachePercent}%`,
background: '#8c6ef0',
borderRadius: '5px'
}}
/>
<div
className="cache-seg"
style={{
flex: 1,
background: 'rgba(255,255,255,0.07)',
borderRadius: '4px 10px 10px 4px'
}}
/>
</div>
{/* Stats Row */}
<div style={{
display: 'grid',
gridTemplateColumns: '1fr auto 1fr auto 1fr',
gap: 0,
alignItems: 'stretch'
}}>
<div>
<div style={{ display: 'flex', alignItems: 'center', gap: 5, fontSize: 11, color: '#6b6e7d', marginBottom: 5 }}>
<div className="stat-dot" style={{ background: '#ff6b5b' }} /> Cache
</div>
<div style={{ display: 'flex', alignItems: 'baseline', gap: 4 }}>
<span style={{ fontSize: 15, fontWeight: 800 }}>{cacheMB} MB</span>
<span style={{ fontSize: 11, color: '#6b6e7d' }}>{cachePercent}%</span>
</div>
<div className="mt-1.5 text-[11.5px] text-[var(--text-tertiary)]">
<span className="font-semibold text-[var(--warning)]">{Math.round(totalMB * 0.625)}MB average</span> cached images and files
</div>
<div style={{ width: 1, background: 'rgba(255,255,255,0.08)', margin: '0 16px' }} />
<div>
<div style={{ display: 'flex', alignItems: 'center', gap: 5, fontSize: 11, color: '#6b6e7d', marginBottom: 5 }}>
<div className="stat-dot" style={{ background: '#8c6ef0' }} /> Non-Cache
</div>
<div style={{ display: 'flex', alignItems: 'baseline', gap: 4 }}>
<span style={{ fontSize: 15, fontWeight: 800 }}>{nonCacheMB} MB</span>
<span style={{ fontSize: 11, color: '#6b6e7d' }}>{nonCachePercent}%</span>
</div>
{/* Segmented bar */}
<div className="my-4 flex h-8 items-center gap-1">
<div className="h-full rounded-l-md" style={{ width: `${cachePercent}%`, background: 'var(--error)' }} />
<div className="h-full" style={{ width: `${nonCachePercent}%`, background: 'var(--accent-tertiary)' }} />
<div className="h-full flex-1 rounded-r-md bg-[var(--tint-07)]" />
</div>
<div style={{ width: 1, background: 'rgba(255,255,255,0.08)', margin: '0 16px' }} />
<div>
<div style={{ fontSize: 11, color: '#6b6e7d', marginBottom: 5 }}>Total</div>
<div style={{ fontSize: 15, fontWeight: 800 }}>{totalMB * 5} GB</div>
</div>
</div>
<div style={{
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
paddingTop: 16
}}>
<span
onClick={onClick}
style={{ fontSize: 13, color: '#6b6e7d', fontWeight: 500, cursor: 'pointer' }}
>
Details
</span>
<div className="arrow-btn" onClick={onClick}>
<svg viewBox="0 0 24 24" fill="none" stroke="white" strokeWidth={2.5} strokeLinecap="round" strokeLinejoin="round">
<polyline points="9 18 15 12 9 6" />
</svg>
{/* Stats row */}
<div className="grid grid-cols-[1fr_auto_1fr_auto_1fr] items-stretch">
{[
{ dot: 'var(--error)', label: 'Cache', value: `${cacheMB} MB`, pct: `${cachePercent}%` },
{ dot: 'var(--accent-tertiary)', label: 'Non-Cache', value: `${nonCacheMB} MB`, pct: `${nonCachePercent}%` },
{ dot: null, label: 'Total', value: `${totalMB * 5} GB`, pct: '' },
].map((s, i) => (
<div key={s.label} className="contents">
{i > 0 && <div className="mx-4 w-px bg-[var(--border-subtle)]" />}
<div>
<div className="mb-1 flex items-center gap-1.5 text-[10.5px] text-[var(--text-tertiary)]">
{s.dot && <i className="h-1.5 w-1.5 rounded-full" style={{ background: s.dot }} />}
{s.label}
</div>
<div className="flex items-baseline gap-1">
<span className="text-[14px] font-bold text-[var(--text-primary)]">{s.value}</span>
{s.pct && <span className="text-[10.5px] text-[var(--text-tertiary)]">{s.pct}</span>}
</div>
</div>
</div>
))}
</div>
</div>
<div className="mt-4" />
<CardFoot label="Details" onClick={onClick} />
</div>
);
}
@@ -319,86 +203,47 @@ export function PerformanceMetricCard({
animationDelay = 0,
}: PerformanceMetricCardProps) {
const status = percentage >= 85 ? 'Good' : percentage >= 70 ? 'Average' : 'Warning';
const statusColor = percentage >= 85 ? '#5ee6a0' : percentage >= 70 ? '#f2c94c' : '#ff6f5c';
const statusColor = percentage >= 85 ? 'var(--success)' : percentage >= 70 ? 'var(--warning)' : 'var(--error)';
return (
<div
className="card"
style={{
display: 'flex',
flexDirection: 'column',
padding: 20,
animationDelay: `${animationDelay}s`,
}}
>
<div style={{ display: 'flex', alignItems: 'center', gap: 10, marginBottom: 12 }}>
<div className="card-icon">
<svg viewBox="0 0 24 24" fill="none" stroke="#9295a4" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round">
<circle cx="12" cy="12" r="10"/>
<line x1="2" y1="12" x2="22" y2="12"/>
<path d="M12 2a15.3 15.3 0 0 1 4 10 15.3 15.3 0 0 1-4 10 15.3 15.3 0 0 1-4-10 15.3 15.3 0 0 1 4-10z"/>
<div className="s-card flex flex-col" style={{ animationDelay: `${animationDelay}s` }}>
<CardHead
icon={
<svg viewBox="0 0 24 24" width="13.5" height="13.5" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round">
<circle cx="12" cy="12" r="10" />
<line x1="2" y1="12" x2="22" y2="12" />
<path d="M12 2a15.3 15.3 0 0 1 4 10 15.3 15.3 0 0 1-4 10 15.3 15.3 0 0 1-4-10 15.3 15.3 0 0 1 4-10z" />
</svg>
</div>
<span style={{ fontSize: 14, fontWeight: 600, color: '#e8e9f0' }}>Performance</span>
</div>
<div style={{ display: 'flex', alignItems: 'flex-start', gap: 16, flex: 1 }}>
<div style={{ flex: 1 }}>
<div style={{
fontSize: 36,
fontWeight: 900,
letterSpacing: '-1.5px',
lineHeight: 1,
color: '#e8e9f0',
}}>
}
title="Performance"
/>
<div className="flex flex-1 items-start gap-4 px-4">
<div className="flex-1">
<div className="font-headline text-[32px] font-bold leading-none tracking-[-0.025em] text-[var(--text-primary)]">
{percentage}%
</div>
<div style={{ fontSize: 12, color: '#6b6e7d', marginTop: 4 }}>
<span style={{ color: statusColor, fontWeight: 700 }}>{status}</span> Last scan on {new Date().toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' })}
<div className="mt-1.5 text-[11.5px] text-[var(--text-tertiary)]">
<span className="font-semibold" style={{ color: statusColor }}>{status}</span>
{' '}Last scan {new Date().toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' })}
</div>
</div>
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'flex-end', gap: 8 }}>
{/* Chart placeholder - will be replaced with actual chart */}
<div style={{ width: 134, height: 58, background: 'rgba(255,255,255,0.03)', borderRadius: 8 }} />
<div style={{ display: 'flex', flexDirection: 'column', gap: 4, alignItems: 'flex-end' }}>
<div className="speed-row" style={{ color: '#7ab8ff' }}>
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round">
<line x1="12" y1="19" x2="12" y2="5"/>
<polyline points="5 12 12 19 19 12"/>
</svg>
<span>{upSpeed}</span> Mbps
</div>
<div className="speed-row" style={{ color: 'var(--accent-primary)' }}>
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round">
<line x1="12" y1="5" x2="12" y2="19"/>
<polyline points="19 12 12 5 5 12"/>
</svg>
<span>{downSpeed}</span> Mbps
</div>
<div className="flex flex-col items-end gap-2">
<div className="h-[58px] w-[134px] rounded-lg bg-[var(--tint-03)]" />
<div className="flex flex-col items-end gap-1">
{[{ v: upSpeed, up: false }, { v: downSpeed, up: true }].map((s, i) => (
<div key={i} className="flex items-center gap-1 text-[11px] font-medium text-[var(--accent-primary)]">
<svg viewBox="0 0 24 24" width="12" height="12" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round">
<line x1="12" y1={s.up ? 5 : 19} x2="12" y2={s.up ? 19 : 5} />
{s.up ? <polyline points="19 12 12 5 5 12" /> : <polyline points="5 12 12 19 19 12" />}
</svg>
<span>{s.v}</span> Mbps
</div>
))}
</div>
</div>
</div>
<div style={{
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
paddingTop: 14
}}>
<span
onClick={onClick}
style={{ fontSize: 13, color: '#6b6e7d', fontWeight: 500, cursor: 'pointer' }}
>
Check Speed
</span>
<div className="arrow-btn" onClick={onClick}>
<svg viewBox="0 0 24 24" fill="none" stroke="white" strokeWidth={2.5} strokeLinecap="round" strokeLinejoin="round">
<polyline points="9 18 15 12 9 6" />
</svg>
</div>
</div>
<div className="mt-4" />
<CardFoot label="Check Speed" onClick={onClick} />
</div>
);
}
@@ -61,9 +61,9 @@ export class ErrorBoundary extends Component<Props, State> {
return (
<div className="min-h-screen bg-[var(--bg-void)] flex items-center justify-center p-4">
<div className="max-w-md w-full bg-[var(--bg-primary)] border border-[var(--border-subtle)] rounded-lg p-6 space-y-4">
<div className="s-card max-w-md w-full space-y-4">
<div className="space-y-2">
<h2 className="text-xl font-semibold text-[var(--text-primary)]">
<h2 className="font-headline text-xl font-semibold text-[var(--text-primary)]">
Something went wrong
</h2>
<p className="text-sm text-[var(--text-secondary)]">
@@ -72,24 +72,18 @@ export class ErrorBoundary extends Component<Props, State> {
</div>
{import.meta.env.DEV && this.state.error && (
<div className="bg-[var(--surface-muted)] border border-[var(--border-subtle)] rounded p-3">
<p className="text-xs font-mono text-[var(--error)] break-all">
<div className="s-inset">
<p className="v-mono text-[11px] text-[var(--error)] break-all">
{this.state.error.toString()}
</p>
</div>
)}
<div className="flex gap-2">
<button
onClick={this.handleReset}
className="flex-1 px-4 py-2 bg-[var(--accent-primary-soft)] text-[var(--text-primary)] rounded hover:opacity-90 transition-opacity"
>
<button onClick={this.handleReset} className="s-btn-accent flex-1 justify-center">
Try Again
</button>
<button
onClick={() => window.location.reload()}
className="flex-1 px-4 py-2 bg-[var(--surface-muted)] text-[var(--text-primary)] border border-[var(--border-subtle)] rounded hover:bg-[var(--surface-soft)] transition-colors"
>
<button onClick={() => window.location.reload()} className="s-btn-quiet flex-1 justify-center">
Reload Page
</button>
</div>
+50 -119
View File
@@ -1,4 +1,5 @@
import { type ReactNode, useEffect, useRef, useState } from 'react';
import { ChevronRight } from 'lucide-react';
interface MetricCardProps {
title: string;
@@ -15,6 +16,12 @@ interface MetricCardProps {
horizontal?: boolean;
}
const statusColors: Record<string, string> = {
good: 'var(--success)',
average: 'var(--warning)',
warning: 'var(--warning)',
};
export function MetricCard({
title,
value,
@@ -49,140 +56,64 @@ export function MetricCard({
return () => observer.disconnect();
}, [animationDelay]);
const statusColors: Record<string, string> = {
good: '#5ee6a0',
average: '#f2c94c',
warning: '#f2c94c',
};
const reveal = {
opacity: isVisible ? 1 : 0,
transform: isVisible ? 'translateY(0)' : 'translateY(12px)',
transition: 'all 0.5s ease',
} as const;
const footer = (
<div className="s-stat-foot">
<span
onClick={onClick}
style={{ cursor: onClick ? 'pointer' : 'default' }}
>
{statusText && status ? (
<b style={{ color: statusColors[status], fontWeight: 600 }}>{statusText}</b>
) : null}{' '}
{subtitle ?? 'Details'}
</span>
{onClick ? (
<button className="s-icon-btn !h-6 !w-6" onClick={onClick} title="Details">
<ChevronRight size={11} />
</button>
) : null}
</div>
);
// Horizontal layout for cards like Active User
if (horizontal) {
return (
<div
ref={cardRef}
className={`panel ${className}`}
style={{
display: 'flex',
flexDirection: 'row',
padding: 0,
overflow: 'hidden',
opacity: isVisible ? 1 : 0,
transform: isVisible ? 'translateY(0)' : 'translateY(12px)',
transition: 'all 0.5s ease',
}}
>
<div style={{
flex: 1,
padding: '20px 18px 18px 20px',
display: 'flex',
flexDirection: 'column',
}}>
<div style={{ display: 'flex', alignItems: 'center', gap: 10, marginBottom: 12 }}>
<div className="card-icon">{icon}</div>
<span style={{ fontSize: 14, fontWeight: 600, color: '#e8e9f0' }}>{title}</span>
<div ref={cardRef} className={`s-stat ${className}`} style={{ ...reveal, display: 'flex' }}>
<div className="flex-1 px-4 pt-4 pb-2 flex flex-col">
<div className="flex items-center gap-2.5">
<span className="s-ibox">{icon}</span>
<span className="text-[11.5px] font-medium text-[var(--text-secondary)]">{title}</span>
</div>
<div style={{
fontSize: 36,
fontWeight: 900,
letterSpacing: '-1.5px',
lineHeight: 1,
color: '#e8e9f0',
}}>
<div className="mt-4 font-headline text-[30px] font-bold leading-none tracking-[-0.025em] text-[var(--text-primary)]">
{value}
</div>
{subtitle && (
<div style={{ fontSize: 12, color: '#6b6e7d', marginTop: 4 }}>{subtitle}</div>
)}
{details && <div style={{ marginTop: 12 }}>{details}</div>}
<div style={{
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
marginTop: 'auto',
paddingTop: 14,
}}>
<span
style={{ fontSize: 13, color: '#6b6e7d', fontWeight: 500, cursor: 'pointer' }}
onClick={onClick}
>
Details
</span>
<div className="arrow-btn" onClick={onClick}>
<svg viewBox="0 0 24 24" fill="none" stroke="white" strokeWidth={2.5} strokeLinecap="round" strokeLinejoin="round">
<polyline points="9 18 15 12 9 6" />
</svg>
</div>
</div>
{details ? <div className="mt-3">{details}</div> : null}
<div className="mt-auto">{footer}</div>
</div>
{chart && (
<div style={{
width: '50%',
padding: '16px 14px 46px 0',
display: 'flex',
alignItems: 'flex-end',
}}>
{chart}
</div>
)}
{chart ? (
<div className="w-1/2 flex items-end pr-3 pb-10 pt-3">{chart}</div>
) : null}
</div>
);
}
// Standard vertical card layout - matching self.html exactly
return (
<div
ref={cardRef}
className={`panel ${className}`}
style={{
display: 'flex',
flexDirection: 'column',
padding: 20,
opacity: isVisible ? 1 : 0,
transform: isVisible ? 'translateY(0)' : 'translateY(12px)',
transition: 'all 0.5s ease',
}}
>
<div style={{ display: 'flex', alignItems: 'center', gap: 10, marginBottom: 12 }}>
<div className="card-icon">{icon}</div>
<span style={{ fontSize: 14, fontWeight: 600, color: '#e8e9f0' }}>{title}</span>
<div ref={cardRef} className={`s-stat ${className}`} style={reveal}>
<div className="flex items-center gap-2.5 px-4 pt-4">
<span className="s-ibox">{icon}</span>
<span className="text-[11.5px] font-medium text-[var(--text-secondary)]">{title}</span>
</div>
<div style={{
fontSize: 38,
fontWeight: 900,
letterSpacing: '-1.5px',
lineHeight: 1,
color: '#e8e9f0',
}}>
<div className="px-4 mt-4 font-headline text-[32px] font-bold leading-none tracking-[-0.025em] text-[var(--text-primary)]">
{value}
</div>
{(subtitle || statusText) && (
<div style={{ fontSize: 12, color: '#6b6e7d', marginTop: 4 }}>
{statusText && status && (
<span style={{ color: statusColors[status], fontWeight: 700 }}>{statusText}</span>
)}{' '}
{subtitle}
</div>
)}
{chart && <div style={{ marginTop: 12, marginBottom: 6 }}>{chart}</div>}
{details && <div style={{ marginTop: 16 }}>{details}</div>}
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', paddingTop: 4, marginTop: 'auto' }}>
<span
onClick={onClick}
style={{ fontSize: 13, color: '#6b6e7d', fontWeight: 500, cursor: 'pointer' }}
>
Details
</span>
<div className="arrow-btn" onClick={onClick}>
<svg viewBox="0 0 24 24" fill="none" stroke="white" strokeWidth={2.5} strokeLinecap="round" strokeLinejoin="round">
<polyline points="9 18 15 12 9 6" />
</svg>
</div>
</div>
{chart ? <div className="px-4 mt-3">{chart}</div> : null}
{details ? <div className="px-4 mt-3">{details}</div> : null}
<div className="mt-4">{footer}</div>
</div>
);
}
@@ -1,4 +1,5 @@
import { Check, X, Loader2, Box, HelpCircle, type LucideIcon } from 'lucide-react';
import type { PillTone } from './sentry-utils';
type ServiceStatus = 'running' | 'building' | 'failed' | 'stopped' | 'unknown';
@@ -12,43 +13,29 @@ interface StatusBadgeProps {
const statusConfig: Record<
ServiceStatus,
{ color: string; bg: string; Icon: LucideIcon; label: string; animate?: boolean; glow?: string }
{ tone: PillTone; Icon: LucideIcon; label: string; animate?: boolean }
> = {
running: {
color: 'var(--success)',
bg: 'var(--success-soft)',
Icon: Check,
label: 'Active',
animate: true,
glow: 'rgba(61, 214, 140, 0.4)',
},
building: {
color: 'var(--warning)',
bg: 'var(--warning-soft)',
Icon: Loader2,
label: 'Building',
animate: true,
glow: 'rgba(255, 112, 67, 0.4)',
},
failed: {
color: 'var(--error)',
bg: 'var(--error-soft)',
Icon: X,
label: 'Failed',
glow: 'rgba(255, 107, 91, 0.4)',
},
stopped: {
color: 'var(--text-muted)',
bg: 'var(--surface-muted)',
Icon: Box,
label: 'Stopped',
},
unknown: {
color: 'var(--text-muted)',
bg: 'var(--surface-muted)',
Icon: HelpCircle,
label: 'Unknown',
},
running: { tone: 'ok', Icon: Check, label: 'Active' },
building: { tone: 'warn', Icon: Loader2, label: 'Building', animate: true },
failed: { tone: 'err', Icon: X, label: 'Failed' },
stopped: { tone: 'off', Icon: Box, label: 'Stopped' },
unknown: { tone: 'off', Icon: HelpCircle, label: 'Unknown' },
};
const TONE_CLS: Record<PillTone, string> = {
ok: 'text-[var(--success)] bg-[var(--success-soft)] border-[color-mix(in_srgb,var(--success)_16%,transparent)]',
warn: 'text-[var(--warning)] bg-[var(--warning-soft)] border-[color-mix(in_srgb,var(--warning)_16%,transparent)]',
err: 'text-[var(--error)] bg-[var(--error-soft)] border-[color-mix(in_srgb,var(--error)_16%,transparent)]',
info: 'text-[var(--info)] bg-[var(--info-soft)] border-[color-mix(in_srgb,var(--info)_16%,transparent)]',
off: 'text-[var(--text-tertiary)] bg-[var(--tint-06)] border-[var(--border-subtle)]',
};
const TONE_DOT: Record<PillTone, string> = {
ok: 'var(--success)',
warn: 'var(--warning)',
err: 'var(--error)',
info: 'var(--info)',
off: 'var(--text-tertiary)',
};
export function StatusBadge({
@@ -62,36 +49,26 @@ export function StatusBadge({
const { Icon } = config;
const sizeClasses = {
sm: 'px-2 py-0.5 text-[10px] gap-1.5',
md: 'px-2.5 py-1 text-xs gap-1.5',
lg: 'px-3 py-1.5 text-sm gap-2',
sm: 'px-2 py-[2px] text-[10px] gap-1.5',
md: 'px-2 py-[3.5px] text-[10.5px] gap-1.5',
lg: 'px-2.5 py-1 text-[11.5px] gap-1.5',
};
const iconSizes = {
sm: 10,
md: 12,
lg: 14,
};
const iconSizes = { sm: 10, md: 11, lg: 12 };
return (
<div
className={`inline-flex items-center rounded-full font-bold uppercase tracking-wider ring-1 ring-inset transition-all duration-200 ${sizeClasses[size]} ${className}`}
style={{
background: config.bg,
color: config.color,
borderColor: `${config.color}20`,
boxShadow: status === 'running' && pulse ? `0 0 12px ${config.glow}` : 'none',
}}
className={`inline-flex items-center rounded-[5px] border font-medium transition-colors ${sizeClasses[size]} ${TONE_CLS[config.tone]} ${className}`}
>
{status === 'running' && pulse && (
<span
className="w-1.5 h-1.5 rounded-full live-pulse"
style={{ background: config.color, boxShadow: `0 0 6px ${config.color}` }}
style={{ background: TONE_DOT[config.tone] }}
/>
)}
<Icon
size={iconSizes[size]}
className={config.animate && status === 'building' ? 'animate-spin' : ''}
className={config.animate ? 'animate-spin' : ''}
/>
{showLabel && <span>{config.label}</span>}
</div>
@@ -106,27 +83,19 @@ interface LiveIndicatorProps {
export function LiveIndicator({ isLive, label, size = 'md' }: LiveIndicatorProps) {
const sizeClasses = {
sm: 'px-2 py-0.5 text-[10px] gap-1.5',
md: 'px-2.5 py-1 text-xs gap-2',
lg: 'px-3 py-1.5 text-sm gap-2',
sm: 'px-2 py-[2px] text-[10px] gap-1.5',
md: 'px-2 py-[3.5px] text-[10.5px] gap-1.5',
lg: 'px-2.5 py-1 text-[11.5px] gap-1.5',
};
const tone: PillTone = isLive ? 'ok' : 'off';
return (
<div
className={`inline-flex items-center rounded-full font-bold uppercase tracking-wider ring-1 ring-inset transition-all duration-300 ${sizeClasses[size]}`}
style={{
background: isLive ? 'var(--success-soft)' : 'var(--surface-muted)',
color: isLive ? 'var(--success)' : 'var(--text-muted)',
borderColor: isLive ? 'rgba(61, 214, 140, 0.2)' : 'rgba(255, 255, 255, 0.05)',
boxShadow: isLive ? '0 0 12px rgba(61, 214, 140, 0.3)' : 'none',
}}
<div
className={`inline-flex items-center rounded-[5px] border font-medium transition-colors ${sizeClasses[size]} ${TONE_CLS[tone]}`}
>
<span
className={`w-1.5 h-1.5 rounded-full ${isLive ? 'live-pulse' : ''}`}
style={{
background: isLive ? 'var(--success)' : 'var(--text-muted)',
boxShadow: isLive ? '0 0 6px var(--success)' : 'none',
}}
style={{ background: TONE_DOT[tone] }}
/>
{label || (isLive ? 'Active' : 'Stopped')}
</div>
@@ -12,3 +12,4 @@ export { useToast } from '../hooks/use-toast';
export type { ToastType, ToastContextValue } from '../hooks/use-toast';
export * from './BrandWordmark';
export * from './FilterBar';
export * from './sentry';
@@ -0,0 +1,18 @@
/* Non-component helpers for the Sentry kit — kept out of sentry.tsx so the
component file stays fast-refresh clean. */
export type PillTone = 'ok' | 'warn' | 'err' | 'off' | 'info';
export function statusTone(status: string): PillTone {
const s = status.toLowerCase();
if (['running', 'healthy', 'online', 'active', 'deployed', 'succeeded', 'success', 'live'].includes(s)) return 'ok';
if (['degraded', 'warning', 'building', 'deploying', 'pending', 'scheduled', 'queued', 'sleeping'].includes(s)) return 'warn';
if (['failed', 'critical', 'error', 'offline', 'stopped'].includes(s)) return s === 'stopped' || s === 'offline' ? 'off' : 'err';
return 'info';
}
/* CSS var color helper for charts. */
export function cssVar(name: string, fallback = ''): string {
if (typeof window === 'undefined') return fallback;
return getComputedStyle(document.documentElement).getPropertyValue(name).trim() || fallback;
}
@@ -0,0 +1,372 @@
import { useState, type ReactNode } from 'react';
import { ChevronDown, MoreHorizontal } from 'lucide-react';
import type { PillTone } from './sentry-utils';
export type { PillTone };
/* ============================================================
Sentry kit — React bindings for the 23-sentry.html language.
Every page composes these; no page invents its own chrome.
============================================================ */
export function Ibox({ children }: { children: ReactNode }) {
return <span className="s-ibox">{children}</span>;
}
/* ---------- buttons ---------- */
type BtnProps = {
children: ReactNode;
onClick?: () => void;
disabled?: boolean;
title?: string;
className?: string;
type?: 'button' | 'submit';
};
export function GhostBtn({ children, onClick, disabled, title, className = '', type = 'button' }: BtnProps) {
return (
<button type={type} className={`s-btn-accent ${className}`} onClick={onClick} disabled={disabled} title={title}>
{children}
</button>
);
}
export function QuietBtn({ children, onClick, disabled, title, className = '', type = 'button' }: BtnProps) {
return (
<button type={type} className={`s-btn-quiet ${className}`} onClick={onClick} disabled={disabled} title={title}>
{children}
</button>
);
}
export function IconBtn({ children, onClick, disabled, title, className = '', type = 'button' }: BtnProps) {
return (
<button type={type} className={`s-icon-btn ${className}`} onClick={onClick} disabled={disabled} title={title}>
{children}
</button>
);
}
/* Filter/sort chip with trailing chevron — the `.sel` in sentry. */
export function SelectChip({ children, onClick, title, icon }: BtnProps & { icon?: ReactNode }) {
return (
<button className="s-chip" onClick={onClick} title={title}>
{icon}
{children}
<ChevronDown size={10} style={{ opacity: 0.55 }} />
</button>
);
}
/* Segmented icon buttons — chart-type switchers. */
export function Seg({ options, value, onChange }: {
options: { key: string; icon: ReactNode; title?: string }[];
value: string;
onChange: (key: string) => void;
}) {
return (
<span className="s-seg">
{options.map((o) => (
<button
key={o.key}
className={o.key === value ? 'on' : ''}
onClick={() => onChange(o.key)}
title={o.title}
>
{o.icon}
</button>
))}
</span>
);
}
/* ---------- stat card ---------- */
export function SStat({ icon, label, value, unit, foot, delta, extra, onMenu }: {
icon: ReactNode;
label: string;
value: ReactNode;
unit?: string;
foot: ReactNode;
delta?: { dir: 'up' | 'down'; text: string };
extra?: ReactNode;
onMenu?: () => void;
}) {
return (
<div className="s-stat">
<div className="flex items-center gap-2.5 px-4 pt-4">
<Ibox>{icon}</Ibox>
<span className="text-[11.5px] font-medium text-[var(--text-secondary)] whitespace-nowrap">{label}</span>
<button
className="ml-auto text-[var(--text-tertiary)] hover:text-[var(--text-primary)] leading-none"
onClick={onMenu}
title="More"
>
<MoreHorizontal size={14} />
</button>
</div>
<div className="flex items-baseline gap-1.5 px-4 mt-4">
<span className="font-headline text-[34px] font-bold leading-none tracking-[-0.025em] text-[var(--text-primary)]">
{value}
</span>
{unit && <span className="text-[12px] font-medium text-[var(--text-tertiary)]">{unit}</span>}
</div>
{extra && <div className="px-4 mt-3">{extra}</div>}
<div className="s-stat-foot mt-3.5">
<span>{foot}</span>
{delta && <SDelta dir={delta.dir}>{delta.text}</SDelta>}
</div>
</div>
);
}
export function SDelta({ dir, children }: { dir: 'up' | 'down'; children: ReactNode }) {
return (
<span className={`s-delta ${dir}`}>
{dir === 'up' ? '▲' : '▼'}{children}
</span>
);
}
/* ---------- card ---------- */
export function SCard({ icon, title, accent, trail, children, pad = true, className = '' }: {
icon?: ReactNode;
title?: ReactNode;
accent?: ReactNode;
trail?: ReactNode;
children: ReactNode;
pad?: boolean;
className?: string;
}) {
return (
<div className={`s-card ${className}`}>
{(title !== undefined || icon) && (
<div className="s-cardhead">
{icon && <Ibox>{icon}</Ibox>}
<span className="s-t">{title}{accent && <> <span className="s-ta">{accent}</span></>}</span>
{trail && <span className="s-trail">{trail}</span>}
</div>
)}
<div className={pad ? 'px-4 pb-4' : ''}>{children}</div>
</div>
);
}
/* ---------- ticks / bars ---------- */
export function Ticks({ pct, count = 56, warn = false }: { pct: number; count?: number; warn?: boolean }) {
const on = Math.round((Math.max(0, Math.min(100, pct)) / 100) * count);
return (
<span className="s-ticks" role="img" aria-label={`${pct}%`}>
{Array.from({ length: count }, (_, i) => (
<i key={i} className={i < on ? (warn ? 'warn' : 'on') : ''} />
))}
</span>
);
}
/* Ascending mini bar glyph used inside table metric cells. */
export function MiniBars({ tone = 'var(--accent-primary)' }: { tone?: string }) {
return (
<span className="inline-flex items-end gap-[1.5px] h-3" aria-hidden>
{[9, 5, 12, 7].map((h, i) => (
<i key={i} className="w-[2.5px] rounded-[0.5px]" style={{ height: h, background: tone }} />
))}
</span>
);
}
/* ---------- status pill ---------- */
const PILL_CLS: Record<PillTone, string> = {
ok: 'text-[var(--success)] bg-[var(--success-soft)] border-[color-mix(in_srgb,var(--success)_16%,transparent)]',
warn: 'text-[var(--warning)] bg-[var(--warning-soft)] border-[color-mix(in_srgb,var(--warning)_16%,transparent)]',
err: 'text-[var(--error)] bg-[var(--error-soft)] border-[color-mix(in_srgb,var(--error)_16%,transparent)]',
info: 'text-[var(--info)] bg-[var(--info-soft)] border-[color-mix(in_srgb,var(--info)_16%,transparent)]',
off: 'text-[var(--text-tertiary)] bg-[var(--tint-06)] border-[var(--border-subtle)]',
};
export function SPill({ tone, children }: { tone: PillTone; children: ReactNode }) {
return (
<span className={`inline-block text-[10.5px] font-medium rounded-[5px] px-2 py-[3.5px] border ${PILL_CLS[tone]}`}>
{children}
</span>
);
}
/* ---------- inset callout ---------- */
export function SAnomaly({ icon, title, items }: { icon?: ReactNode; title: ReactNode; items: ReactNode[] }) {
return (
<div className="s-inset">
<div className="flex items-center gap-2 pb-2 mb-2 border-b border-[var(--border-subtle)] text-[11.5px] text-[var(--text-secondary)]">
{icon}
<span>{title}</span>
</div>
{items.map((it, i) => (
<div key={i} className="s-anomaly-item">{it}</div>
))}
</div>
);
}
/* ---------- data table (inset header bar, sortable) ---------- */
export type SCol<T> = {
key: string;
label: ReactNode;
width?: string;
sortable?: boolean;
sortValue?: (row: T) => string | number;
render: (row: T) => ReactNode;
};
const CARET = (
<svg viewBox="0 0 7 10" width="7" height="10" className="shrink-0">
<path d="M3.5 .8 5.8 3.6H1.2zM3.5 9.2 1.2 6.4h4.6z" fill="currentColor" opacity="0.45" />
</svg>
);
export function STable<T extends { id?: string | number }>({ cols, rows, rowKey, selectable, onRowClick }: {
cols: SCol<T>[];
rows: T[];
rowKey?: (row: T, i: number) => string | number;
selectable?: boolean;
onRowClick?: (row: T) => void;
}) {
const [sortKey, setSortKey] = useState<string | null>(null);
const [sortDir, setSortDir] = useState<1 | -1>(1);
const [checked, setChecked] = useState<Set<number>>(new Set());
const allCols: SCol<T>[] = selectable
? [{ key: '$sel', label: '', width: '30px', render: () => null }, ...cols]
: cols;
const template = allCols.map((c) => c.width ?? '1fr').join(' ');
const sorted = [...rows];
const sc = cols.find((c) => c.key === sortKey);
if (sc?.sortValue) {
sorted.sort((a, b) => {
const x = sc.sortValue!(a); const y = sc.sortValue!(b);
return (x > y ? 1 : x < y ? -1 : 0) * sortDir;
});
}
const toggleAll = () =>
setChecked(checked.size === rows.length ? new Set() : new Set(rows.map((_, i) => i)));
return (
<div className="s-dt pb-2.5">
<div className="s-dt-head" style={{ gridTemplateColumns: template }}>
{allCols.map((c) =>
c.key === '$sel' ? (
<span key="$sel" className="s-dt-h">
<span className={`s-ck ${checked.size === rows.length && rows.length > 0 ? 'on' : ''}`} onClick={toggleAll} />
</span>
) : (
<span
key={c.key}
className={`s-dt-h ${c.sortable !== false ? 'sortable' : ''} ${sortKey === c.key ? 'on' : ''}`}
onClick={() => {
if (c.sortable === false) return;
setSortDir(sortKey === c.key ? -sortDir : 1);
setSortKey(c.key);
}}
>
{c.label}{c.sortable !== false && CARET}
</span>
),
)}
</div>
{sorted.map((row, i) => {
const key = rowKey ? rowKey(row, i) : row.id ?? i;
const ri = rows.indexOf(row);
return (
<div
key={key}
className={`s-dt-row ${onRowClick ? 'clickable' : ''}`}
style={{ gridTemplateColumns: template }}
onClick={() => onRowClick?.(row)}
>
{allCols.map((c) =>
c.key === '$sel' ? (
<span key="$sel">
<span
className={`s-ck ${checked.has(ri) ? 'on' : ''}`}
onClick={(e) => {
e.stopPropagation();
setChecked((prev) => {
const n = new Set(prev);
if (n.has(ri)) n.delete(ri); else n.add(ri);
return n;
});
}}
/>
</span>
) : (
<span key={c.key} className="min-w-0">{c.render(row)}</span>
),
)}
</div>
);
})}
</div>
);
}
/* ---------- page header (Welcome back … + actions) ---------- */
export function SPageHead({ title, titleAccent, sub, subAccent, actions }: {
title: ReactNode;
titleAccent?: ReactNode;
sub?: ReactNode;
subAccent?: ReactNode;
actions?: ReactNode;
}) {
return (
<div className="flex items-center gap-4 mb-5 flex-wrap">
<div>
<h1 className="font-headline text-[24px] font-bold tracking-[-0.02em] text-[var(--text-primary)]">
{title}{titleAccent && <> <em className="not-italic text-[var(--accent-primary)]">{titleAccent}</em></>}
</h1>
{sub && (
<div className="text-[12px] text-[var(--text-tertiary)] mt-[3px]">
{sub}{subAccent && <> <b className="text-[var(--accent-primary)] font-semibold">{subAccent}</b></>}
</div>
)}
</div>
{actions && <div className="ml-auto flex items-center gap-2.5">{actions}</div>}
</div>
);
}
/* Section label — small caps with accent square. */
export function SSection({ children, right }: { children: ReactNode; right?: ReactNode }) {
return (
<div className="flex items-center justify-between mb-3 mt-1">
<div className="flex items-center gap-2 text-[10.5px] font-medium uppercase tracking-[0.1em] text-[var(--text-tertiary)]">
<span className="w-[5px] h-[5px] rounded-[1px] bg-[var(--accent-primary)]" />
{children}
</div>
{right && <span className="v-mono text-[10.5px] text-[var(--text-muted)]">{right}</span>}
</div>
);
}
/* Legend row for charts. */
export function SLegend({ items }: { items: { color: string; label: string }[] }) {
return (
<div className="flex gap-5 px-4 pb-2.5 pt-0.5 text-[11.5px] font-medium text-[var(--text-secondary)]">
{items.map((it) => (
<span key={it.label}>
<i className="inline-block w-[7px] h-[7px] rounded-[1.5px] mr-2" style={{ background: it.color }} />
{it.label}
</span>
))}
</div>
);
}