Files
Containr/app/frontend/src/shared/components/MetricCard.tsx
T
Tomas DvorakandDevin 24b9b1267e 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>
2026-10-10 16:42:26 +02:00

120 lines
3.4 KiB
TypeScript

import { type ReactNode, useEffect, useRef, useState } from 'react';
import { ChevronRight } from 'lucide-react';
interface MetricCardProps {
title: string;
value: string | number;
subtitle?: string;
status?: 'good' | 'average' | 'warning';
statusText?: string;
icon: ReactNode;
chart?: ReactNode;
details?: ReactNode;
onClick?: () => void;
className?: string;
animationDelay?: number;
horizontal?: boolean;
}
const statusColors: Record<string, string> = {
good: 'var(--success)',
average: 'var(--warning)',
warning: 'var(--warning)',
};
export function MetricCard({
title,
value,
subtitle,
status,
statusText,
icon,
chart,
details,
onClick,
className = '',
animationDelay = 0,
horizontal = false,
}: MetricCardProps) {
const [isVisible, setIsVisible] = useState(false);
const cardRef = useRef<HTMLDivElement>(null);
useEffect(() => {
const observer = new IntersectionObserver(
([entry]) => {
if (entry.isIntersecting) {
setTimeout(() => setIsVisible(true), animationDelay * 1000);
}
},
{ threshold: 0.1 }
);
if (cardRef.current) {
observer.observe(cardRef.current);
}
return () => observer.disconnect();
}, [animationDelay]);
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>
);
if (horizontal) {
return (
<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 className="mt-4 font-headline text-[30px] font-bold leading-none tracking-[-0.025em] text-[var(--text-primary)]">
{value}
</div>
{details ? <div className="mt-3">{details}</div> : null}
<div className="mt-auto">{footer}</div>
</div>
{chart ? (
<div className="w-1/2 flex items-end pr-3 pb-10 pt-3">{chart}</div>
) : null}
</div>
);
}
return (
<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 className="px-4 mt-4 font-headline text-[32px] font-bold leading-none tracking-[-0.025em] text-[var(--text-primary)]">
{value}
</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>
);
}