small fix, don't worry about it

This commit is contained in:
Tomas Dvorak committed 2026-04-10 12:02:36 +02:00
1 parent 08bd0c6e5c
commit 08cb5754f3
638 files changed
+57330 -34704

No files matched your search

@@ -0,0 +1,109 @@
import { forwardRef, type HTMLAttributes, type ReactNode } from 'react';
type BadgeVariant = 'default' | 'success' | 'warning' | 'error' | 'info' | 'neutral';
type BadgeSize = 'sm' | 'md' | 'lg';
type BadgeProps = HTMLAttributes<HTMLSpanElement> & {
variant?: BadgeVariant;
size?: BadgeSize;
icon?: ReactNode;
dot?: boolean;
}
const variantStyles: Record<BadgeVariant, { bg: string; color: string; dot: string }> = {
default: {
bg: 'bg-[var(--accent-primary-soft)]',
color: 'text-[var(--accent-primary)]',
dot: 'bg-[var(--accent-primary)]',
},
success: {
bg: 'bg-[var(--success-soft)]',
color: 'text-[var(--success)]',
dot: 'bg-[var(--success)]',
},
warning: {
bg: 'bg-[var(--warning-soft)]',
color: 'text-[var(--warning)]',
dot: 'bg-[var(--warning)]',
},
error: {
bg: 'bg-[var(--error-soft)]',
color: 'text-[var(--error)]',
dot: 'bg-[var(--error)]',
},
info: {
bg: 'bg-[var(--info-soft)]',
color: 'text-[var(--info)]',
dot: 'bg-[var(--info)]',
},
neutral: {
bg: 'bg-[var(--surface-muted)]',
color: 'text-[var(--text-secondary)]',
dot: 'bg-[var(--text-tertiary)]',
},
};
const sizeStyles: Record<BadgeSize, string> = {
sm: 'px-2 py-0.5 text-[10px] gap-1',
md: 'px-2.5 py-1 text-xs gap-1.5',
lg: 'px-3 py-1.5 text-sm gap-2',
};
export const Badge = forwardRef<HTMLSpanElement, BadgeProps>(
(
{
variant = 'default',
size = 'md',
icon,
dot = false,
className = '',
children,
...props
},
ref
) => {
const styles = variantStyles[variant];
return (
<span
ref={ref}
className={`
inline-flex items-center font-medium rounded-full
${styles.bg} ${styles.color} ${sizeStyles[size]}
${className}
`}
{...props}
>
{dot && (
<span className={`w-1.5 h-1.5 rounded-full ${styles.dot}`} />
)}
{icon}
{children}
</span>
);
}
);
Badge.displayName = 'Badge';
type StatusBadgeProps = HTMLAttributes<HTMLSpanElement> & {
status: 'running' | 'success' | 'failed' | 'pending' | 'cancelled';
size?: BadgeSize;
}
const statusConfig: Record<StatusBadgeProps['status'], { variant: BadgeVariant; label: string }> = {
running: { variant: 'warning', label: 'Running' },
success: { variant: 'success', label: 'Success' },
failed: { variant: 'error', label: 'Failed' },
pending: { variant: 'neutral', label: 'Pending' },
cancelled: { variant: 'neutral', label: 'Cancelled' },
};
export function StatusBadge({ status, size = 'md', className = '', ...props }: StatusBadgeProps) {
const config = statusConfig[status];
return (
<Badge variant={config.variant} size={size} dot className={className} {...props}>
{config.label}
</Badge>
);
}
@@ -0,0 +1,76 @@
import { forwardRef, type ButtonHTMLAttributes, type ReactNode } from 'react';
import { Loader2 } from 'lucide-react';
type ButtonVariant = 'primary' | 'secondary' | 'ghost' | 'danger' | 'success';
type ButtonSize = 'sm' | 'md' | 'lg';
type ButtonProps = ButtonHTMLAttributes<HTMLButtonElement> & {
variant?: ButtonVariant;
size?: ButtonSize;
loading?: boolean;
icon?: ReactNode;
iconPosition?: 'left' | 'right';
};
const variantStyles: Record<ButtonVariant, string> = {
primary: 'text-white shadow-lg hover:shadow-xl hover:scale-[1.02] active:scale-[0.98]',
secondary: 'border border-[var(--border-subtle)] bg-[var(--surface-card)] text-[var(--text-primary)] hover:border-[var(--border-default)] hover:bg-[var(--surface-card-hover)] active:scale-[0.98]',
ghost: 'text-[var(--text-secondary)] hover:text-[var(--text-primary)] hover:bg-[var(--surface-muted)] active:scale-[0.98]',
danger: 'border border-[var(--error-soft)] text-[var(--error)] hover:bg-[var(--error-soft)] hover:shadow-lg hover:shadow-[var(--error-glow)] active:scale-[0.98]',
success: 'border border-[var(--success-soft)] text-[var(--success)] hover:bg-[var(--success-soft)] hover:shadow-lg hover:shadow-[var(--success-glow)] active:scale-[0.98]',
};
const sizeStyles: Record<ButtonSize, string> = {
sm: 'h-8 px-3 text-xs gap-1.5 rounded-lg',
md: 'h-10 px-4 text-sm gap-2 rounded-[var(--radius-md)]',
lg: 'h-12 px-6 text-base gap-2.5 rounded-xl',
};
export const Button = forwardRef<HTMLButtonElement, ButtonProps>(
(
{
variant = 'primary',
size = 'md',
loading = false,
icon,
iconPosition = 'left',
className = '',
disabled,
children,
...props
},
ref
) => {
const isDisabled = disabled || loading;
return (
<button
ref={ref}
disabled={isDisabled}
className={`
inline-flex items-center justify-center font-semibold
transition-all duration-200 ease-out
disabled:opacity-50 disabled:cursor-not-allowed disabled:hover:scale-100
focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--accent-primary)] focus-visible:ring-offset-2 focus-visible:ring-offset-[var(--bg-base)]
${variantStyles[variant]}
${sizeStyles[size]}
${className}
`}
style={variant === 'primary' ? { background: '#e8316a' } : undefined}
{...props}
>
{loading ? (
<Loader2 size={size === 'sm' ? 14 : size === 'md' ? 16 : 18} className="animate-spin" />
) : (
<>
{icon && iconPosition === 'left' && icon}
{children}
{icon && iconPosition === 'right' && icon}
</>
)}
</button>
);
}
);
Button.displayName = 'Button';
@@ -0,0 +1,94 @@
import { forwardRef, type HTMLAttributes, type ReactNode } from 'react';
type CardVariant = 'default' | 'elevated' | 'bordered' | 'ghost' | 'glass';
type CardProps = HTMLAttributes<HTMLDivElement> & {
variant?: CardVariant;
padding?: 'none' | 'sm' | 'md' | 'lg';
header?: ReactNode;
footer?: ReactNode;
hoverable?: boolean;
};
const variantStyles: Record<CardVariant, string> = {
default: 'bg-[var(--surface-card)] border border-[var(--border-subtle)]',
elevated: 'bg-[var(--surface-card)] shadow-lg shadow-black/40',
bordered: 'bg-transparent border border-[var(--border-default)]',
ghost: 'bg-[var(--surface-muted)]/50',
glass: 'bg-[var(--surface-card)]/80 backdrop-blur-xl border border-[var(--border-subtle)]',
};
const paddingStyles: Record<'none' | 'sm' | 'md' | 'lg', string> = {
none: '',
sm: 'p-3',
md: 'p-4 md:p-5',
lg: 'p-6 md:p-8',
};
export const Card = forwardRef<HTMLDivElement, CardProps>(
(
{
variant = 'default',
padding = 'md',
header,
footer,
hoverable = false,
className = '',
children,
...props
},
ref
) => {
return (
<div
ref={ref}
className={`
rounded-[var(--radius-lg)]
${variantStyles[variant]}
${paddingStyles[padding]}
${hoverable ? 'transition-all duration-200 hover:border-[var(--border-default)] hover:shadow-xl hover:shadow-black/30 hover:-translate-y-0.5' : ''}
${className}
`}
{...props}
>
{header && (
<div className="mb-4 pb-4 border-b border-[var(--border-subtle)]">{header}</div>
)}
{children}
{footer && (
<div className="mt-4 pt-4 border-t border-[var(--border-subtle)]">{footer}</div>
)}
</div>
);
}
);
Card.displayName = 'Card';
type CardHeaderProps = HTMLAttributes<HTMLDivElement> & {
title: string;
description?: string;
icon?: ReactNode;
action?: ReactNode;
};
export function CardHeader({ title, description, icon, action, className = '', ...props }: CardHeaderProps) {
return (
<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)' }}>
{icon}
</div>
)}
<div>
<h3 className="text-lg font-semibold text-[var(--text-primary)] tracking-tight">{title}</h3>
{description && (
<p className="text-sm text-[var(--text-secondary)] mt-0.5">{description}</p>
)}
</div>
</div>
{action && <div className="flex-shrink-0">{action}</div>}
</div>
);
}
@@ -0,0 +1,244 @@
import {
Area,
AreaChart,
Line,
LineChart as RechartsLineChart,
ResponsiveContainer,
} from 'recharts';
interface LineChartProps {
data: number[];
color?: string;
fillColor?: string;
height?: number;
showDots?: boolean;
smooth?: boolean;
}
interface LineAreaChartProps {
data: number[];
color?: string;
fillOpacity?: number;
height?: number;
}
interface MultiLineChartDataset {
data: number[];
color: string;
fillOpacity?: number;
}
interface MultiLineChartProps {
datasets: MultiLineChartDataset[];
height?: number;
}
interface LineMetricChartProps {
data: number[];
color?: string;
fillOpacity?: number;
height?: number;
showArea?: boolean;
}
interface DualLineChartProps {
data1: number[];
data2: number[];
height?: number;
color1?: string;
color2?: string;
fillOpacity1?: number;
fillOpacity2?: number;
}
interface BarChartProps {
data: number[];
color?: string;
height?: number;
gap?: number;
}
function toLineData(data: number[]) {
return data.map((value, index) => ({ index, value }));
}
function gradientId(prefix: string, color: string, index = 0) {
return `${prefix}-${color.replace(/[^a-zA-Z0-9]/g, '') || 'chart'}-${index}`;
}
export function LineChart({
data,
color = '#ff7043',
fillColor = 'transparent',
height = 76,
showDots = true,
smooth = true,
}: LineChartProps) {
const chartData = toLineData(data);
return (
<div className="chart-wrap" style={{ height: `${height}px`, width: '100%' }}>
<ResponsiveContainer width="100%" height="100%">
<RechartsLineChart data={chartData}>
<Line
type={smooth ? 'monotone' : 'linear'}
dataKey="value"
stroke={color}
strokeWidth={2}
dot={showDots ? { r: 2, fill: color } : false}
fill={fillColor}
/>
</RechartsLineChart>
</ResponsiveContainer>
</div>
);
}
export function LineAreaChart({
data,
color = '#e8316a',
fillOpacity = 0.15,
height = 130,
}: LineAreaChartProps) {
const chartData = toLineData(data);
const id = gradientId('gradient', color);
return (
<div className="chart-wrap" style={{ height: `${height}px`, width: '100%' }}>
<ResponsiveContainer width="100%" height="100%">
<AreaChart data={chartData}>
<defs>
<linearGradient id={id} x1="0" y1="0" x2="0" y2="1">
<stop offset="0%" stopColor={color} stopOpacity={fillOpacity} />
<stop offset="100%" stopColor={color} stopOpacity={0} />
</linearGradient>
</defs>
<Area
type="monotone"
dataKey="value"
stroke={color}
strokeWidth={2}
fill={`url(#${id})`}
dot={false}
/>
</AreaChart>
</ResponsiveContainer>
</div>
);
}
export function MultiLineChart({
datasets,
height = 58,
}: MultiLineChartProps) {
const maxLength = Math.max(...datasets.map((dataset) => dataset.data.length));
const chartData = Array.from({ length: maxLength }, (_, index) => {
const point: Record<string, number> = { index };
datasets.forEach((dataset, datasetIndex) => {
point[`value${datasetIndex}`] = dataset.data[index] ?? 0;
});
return point;
});
return (
<div className="chart-wrap" style={{ height: `${height}px`, width: '100%' }}>
<ResponsiveContainer width="100%" height="100%">
<AreaChart data={chartData}>
<defs>
{datasets.map((dataset, index) => {
const id = gradientId('gradient-multi', dataset.color, index);
return (
<linearGradient key={id} id={id} x1="0" y1="0" x2="0" y2="1">
<stop
offset="0%"
stopColor={dataset.color}
stopOpacity={dataset.fillOpacity ?? 0.15}
/>
<stop offset="100%" stopColor={dataset.color} stopOpacity={0} />
</linearGradient>
);
})}
</defs>
{datasets.map((dataset, index) => {
const id = gradientId('gradient-multi', dataset.color, index);
return (
<Area
key={id}
type="monotone"
dataKey={`value${index}`}
stroke={dataset.color}
strokeWidth={2}
fill={`url(#${id})`}
dot={false}
/>
);
})}
</AreaChart>
</ResponsiveContainer>
</div>
);
}
export function LineMetricChart({
data,
color = '#ff7043',
fillOpacity = 0.15,
height = 76,
showArea = false,
}: LineMetricChartProps) {
if (showArea) {
return (
<LineAreaChart
data={data}
color={color}
fillOpacity={fillOpacity}
height={height}
/>
);
}
return <LineChart data={data} color={color} height={height} showDots={false} />;
}
export function DualLineChart({
data1,
data2,
height = 58,
color1 = '#6c8ef0',
color2 = '#9c7ef0',
fillOpacity1 = 0.15,
fillOpacity2 = 0.15,
}: DualLineChartProps) {
return (
<MultiLineChart
datasets={[
{ data: data1, color: color1, fillOpacity: fillOpacity1 },
{ data: data2, color: color2, fillOpacity: fillOpacity2 },
]}
height={height}
/>
);
}
export function BarChart({
data,
color = '#3dd68c',
height = 128,
gap = 4,
}: BarChartProps) {
return (
<div className="flex h-full items-end" style={{ gap: `${gap}px`, height: `${height}px` }}>
{data.map((value, index) => (
<div
key={index}
className="flex-1 cursor-pointer rounded-[var(--radius-xs)] transition-all hover:opacity-80"
style={{
height: `${value}%`,
background: color,
}}
title={`${value}%`}
/>
))}
</div>
);
}
@@ -0,0 +1,280 @@
import { useEffect, useState } from 'react';
import { Command } from 'cmdk';
import {
Box,
Database,
Github,
Image,
Clock,
Search,
Settings,
FileText,
Activity,
LayoutGrid,
} from 'lucide-react';
interface CommandItem {
id: string;
label: string;
description?: string;
icon: typeof Box;
shortcut?: string;
action: () => void;
category?: string;
}
interface CommandPaletteProps {
open: boolean;
onClose: () => void;
onAddService?: (type: string) => void;
onNavigate?: (path: string) => void;
}
export function CommandPalette({
open,
onClose,
onAddService,
onNavigate,
}: CommandPaletteProps) {
if (!open) return null;
return (
<CommandPaletteContent
onClose={onClose}
onAddService={onAddService}
onNavigate={onNavigate}
/>
);
}
function CommandPaletteContent({
onClose,
onAddService,
onNavigate,
}: Omit<CommandPaletteProps, 'open'>) {
const [search, setSearch] = useState('');
useEffect(() => {
const down = (e: KeyboardEvent) => {
if (e.key === 'k' && (e.metaKey || e.ctrlKey)) {
e.preventDefault();
onClose();
}
};
document.addEventListener('keydown', down);
return () => document.removeEventListener('keydown', down);
}, [onClose]);
const commands: CommandItem[] = [
{
id: 'add-web',
label: 'Add Web Service',
description: 'Deploy a web application',
icon: Box,
shortcut: 'W',
category: 'Create',
action: () => {
onAddService?.('web');
onClose();
},
},
{
id: 'add-worker',
label: 'Add Worker Service',
description: 'Deploy a background worker',
icon: Clock,
shortcut: 'K',
category: 'Create',
action: () => {
onAddService?.('worker');
onClose();
},
},
{
id: 'add-database',
label: 'Add Database',
description: 'Provision a PostgreSQL database',
icon: Database,
shortcut: 'D',
category: 'Create',
action: () => {
onAddService?.('database');
onClose();
},
},
{
id: 'add-cron',
label: 'Add Cron Job',
description: 'Schedule a recurring task',
icon: Clock,
category: 'Create',
action: () => {
onAddService?.('cron');
onClose();
},
},
{
id: 'connect-github',
label: 'Connect GitHub Repo',
description: 'Link a repository for auto-deploy',
icon: Github,
category: 'Connect',
action: () => {
onClose();
},
},
{
id: 'deploy-image',
label: 'Deploy Docker Image',
description: 'Deploy from a container registry',
icon: Image,
category: 'Connect',
action: () => {
onClose();
},
},
{
id: 'goto-canvas',
label: 'Go to Canvas',
icon: LayoutGrid,
shortcut: 'C',
category: 'Navigate',
action: () => {
onNavigate?.('canvas');
onClose();
},
},
{
id: 'goto-logs',
label: 'Go to Logs',
icon: FileText,
shortcut: 'L',
category: 'Navigate',
action: () => {
onNavigate?.('logs');
onClose();
},
},
{
id: 'goto-metrics',
label: 'Go to Metrics',
icon: Activity,
shortcut: 'M',
category: 'Navigate',
action: () => {
onNavigate?.('observability');
onClose();
},
},
{
id: 'goto-settings',
label: 'Go to Settings',
icon: Settings,
shortcut: 'S',
category: 'Navigate',
action: () => {
onNavigate?.('settings');
onClose();
},
},
];
const filteredCommands = commands.filter(
(cmd) =>
cmd.label.toLowerCase().includes(search.toLowerCase()) ||
cmd.description?.toLowerCase().includes(search.toLowerCase()) ||
cmd.category?.toLowerCase().includes(search.toLowerCase())
);
const groupedCommands = filteredCommands.reduce(
(acc, cmd) => {
const category = cmd.category || 'Other';
if (!acc[category]) acc[category] = [];
acc[category].push(cmd);
return acc;
},
{} as Record<string, CommandItem[]>
);
return (
<div className="fixed inset-0 z-50">
<div
className="absolute inset-0 bg-[var(--bg-void)]/80 backdrop-blur-sm"
onClick={onClose}
/>
<div className="absolute left-1/2 top-[20%] -translate-x-1/2 w-full max-w-xl">
<Command
className="panel overflow-hidden"
loop
>
<div className="flex items-center gap-3 px-4 py-3 border-b border-[var(--border-subtle)]">
<Search size={18} className="text-[var(--text-muted)]" />
<Command.Input
value={search}
onValueChange={setSearch}
placeholder="What would you like to create?"
className="flex-1 bg-transparent text-[var(--text-primary)] placeholder:text-[var(--text-muted)] text-sm outline-none"
/>
<kbd className="px-2 py-0.5 rounded bg-[var(--surface-muted)] text-[10px] font-mono text-[var(--text-muted)]">
ESC
</kbd>
</div>
<Command.List className="max-h-[320px] overflow-y-auto p-2">
<Command.Empty className="py-6 text-center text-sm text-[var(--text-muted)]">
No results found.
</Command.Empty>
{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)]">
{category}
</div>
{items.map((item) => {
const Icon = item.icon;
return (
<Command.Item
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"
>
<Icon size={18} className="text-[var(--text-muted)]" />
<div className="flex-1">
<div className="text-sm font-medium">{item.label}</div>
{item.description && (
<div className="text-xs text-[var(--text-muted)]">{item.description}</div>
)}
</div>
{item.shortcut && (
<kbd className="px-2 py-0.5 rounded bg-[var(--surface-muted)] text-[10px] font-mono text-[var(--text-muted)]">
{item.shortcut}
</kbd>
)}
</Command.Item>
);
})}
</Command.Group>
))}
</Command.List>
<div className="flex items-center gap-4 px-4 py-2 border-t border-[var(--border-subtle)] text-[10px] text-[var(--text-muted)]">
<div className="flex items-center gap-1">
<kbd className="px-1.5 py-0.5 rounded bg-[var(--surface-muted)] text-[9px]">↑↓</kbd>
<span>Navigate</span>
</div>
<div className="flex items-center gap-1">
<kbd className="px-1.5 py-0.5 rounded bg-[var(--surface-muted)] text-[9px]">↵</kbd>
<span>Select</span>
</div>
<div className="flex items-center gap-1">
<kbd className="px-1.5 py-0.5 rounded bg-[var(--surface-muted)] text-[9px]">⌘K</kbd>
<span>Toggle</span>
</div>
</div>
</Command>
</div>
</div>
);
}
@@ -0,0 +1,143 @@
import { useEffect, useRef, useState } from 'react';
interface DonutChartProps {
percent?: number;
percentage?: number;
size?: number;
thickness?: number;
color?: string;
trackColor?: string;
segments?: number;
gap?: number;
animated?: boolean;
}
export function DonutChart({
percent,
percentage,
size = 160,
thickness = 16,
color = '#9c7ef0',
trackColor = 'rgba(255, 255, 255, 0.07)',
segments = 28,
gap = 0.048,
animated = true,
}: DonutChartProps) {
const resolvedPercent = percentage ?? percent ?? 0;
const [isVisible, setIsVisible] = useState(false);
const [animatedPercent, setAnimatedPercent] = useState(0);
const containerRef = useRef<HTMLDivElement>(null);
const animatedPercentRef = useRef(0);
useEffect(() => {
animatedPercentRef.current = animatedPercent;
}, [animatedPercent]);
useEffect(() => {
const observer = new IntersectionObserver(
([entry]) => {
if (entry.isIntersecting) setIsVisible(true);
},
{ threshold: 0.1 }
);
if (containerRef.current) {
observer.observe(containerRef.current);
}
return () => observer.disconnect();
}, []);
useEffect(() => {
if (!animated) {
const frame = requestAnimationFrame(() => setAnimatedPercent(resolvedPercent));
return () => cancelAnimationFrame(frame);
}
if (isVisible && animated) {
const duration = 800;
const startTime = Date.now();
const startPercent = animatedPercentRef.current;
const targetPercent = resolvedPercent;
const animate = () => {
const elapsed = Date.now() - startTime;
const progress = Math.min(elapsed / duration, 1);
const eased = 1 - Math.pow(1 - progress, 3);
setAnimatedPercent(startPercent + (targetPercent - startPercent) * eased);
if (progress < 1) {
requestAnimationFrame(animate);
}
};
requestAnimationFrame(animate);
}
}, [animated, isVisible, resolvedPercent]);
const R = size / 2 - 4;
const r = R - thickness;
const cx = size / 2;
const cy = size - 8;
const filled = Math.round(segments * (animatedPercent / 100));
const segmentPaths = [];
for (let i = 0; i < segments; i++) {
const a0 = Math.PI + (Math.PI / segments) * i + gap / 2;
const a1 = Math.PI + (Math.PI / segments) * (i + 1) - gap / 2;
const isFilled = i < filled;
const x1Start = cx + R * Math.cos(a0);
const y1Start = cy + R * Math.sin(a0);
const x1End = cx + R * Math.cos(a1);
const y1End = cy + R * Math.sin(a1);
const x2Start = cx + r * Math.cos(a1);
const y2Start = cy + r * Math.sin(a1);
const x2End = cx + r * Math.cos(a0);
const y2End = cy + r * Math.sin(a0);
const path = `M ${x1Start} ${y1Start} A ${R} ${R} 0 0 1 ${x1End} ${y1End} L ${x2Start} ${y2Start} A ${r} ${r} 0 0 0 ${x2End} ${y2End} Z`;
segmentPaths.push(
<path
key={i}
d={path}
fill={isFilled ? color : trackColor}
className="transition-colors duration-300"
/>
);
}
return (
<div ref={containerRef} className="relative" style={{ width: size, height: size * 0.6 }}>
<svg width={size} height={size * 0.6} viewBox={`0 0 ${size} ${size * 0.6}`}>
{segmentPaths}
</svg>
</div>
);
}
interface SegmentedBarProps {
segments: Array<{ width: number; color: string }>;
height?: number;
className?: string;
}
export function SegmentedBar({ segments, height = 32, className = '' }: SegmentedBarProps) {
return (
<div className={`flex items-center gap-[5px] h-[${height}px] ${className}`}>
{segments.map((seg, i) => (
<div
key={i}
className="h-full transition-all duration-200 hover:brightness-110 cursor-pointer"
style={{
width: `${seg.width}%`,
background: seg.color,
borderRadius: i === 0 ? '10px 4px 4px 10px' : i === segments.length - 1 ? '4px 10px 10px 4px' : '5px',
}}
/>
))}
</div>
);
}
@@ -0,0 +1,406 @@
import { type ReactNode } from 'react';
interface EnhancedMetricCardProps {
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;
}
export function EnhancedMetricCard({
title,
value,
subtitle,
status,
statusText,
icon,
chart,
details,
onClick,
className = '',
animationDelay = 0,
horizontal = false,
}: EnhancedMetricCardProps) {
const statusColors: Record<string, string> = {
good: '#3dd68c',
average: '#f0a040',
warning: '#ff7043',
};
// 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>
</div>
</div>
{chart && (
<div style={{
width: '50%',
padding: '16px 14px 46px 0',
display: 'flex',
alignItems: 'flex-end',
}}>
{chart}
</div>
)}
</div>
);
}
// 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>
</div>
</div>
);
}
interface CacheMetricCardProps {
totalMB: number;
cacheMB: number;
nonCacheMB: number;
onClick?: () => void;
animationDelay?: number;
}
export function CacheMetricCard({
totalMB,
cacheMB,
nonCacheMB,
onClick,
animationDelay = 0,
}: CacheMetricCardProps) {
const cachePercent = Math.round((cacheMB / totalMB) * 100);
const nonCachePercent = Math.round((nonCacheMB / totalMB) * 100);
const freePercent = 100 - cachePercent - nonCachePercent;
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"/>
</svg>
</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: '#f0a040', 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>
<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>
</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>
</div>
</div>
</div>
);
}
interface PerformanceMetricCardProps {
percentage: number;
upSpeed: number;
downSpeed: number;
datasets: Array<{ data: number[]; color: string; fillOpacity?: number }>;
onClick?: () => void;
animationDelay?: number;
}
export function PerformanceMetricCard({
percentage,
upSpeed,
downSpeed,
datasets,
onClick,
animationDelay = 0,
}: PerformanceMetricCardProps) {
const status = percentage >= 85 ? 'Good' : percentage >= 70 ? 'Average' : 'Warning';
const statusColor = percentage >= 85 ? '#3dd68c' : percentage >= 70 ? '#f0a040' : '#ff7043';
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"/>
</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',
}}>
{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>
</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: '#6c8ef0' }}>
<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: '#e8316a' }}>
<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>
</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>
);
}
@@ -0,0 +1,102 @@
import { Component, ErrorInfo, ReactNode } from 'react';
interface Props {
children: ReactNode;
fallback?: ReactNode;
onError?: (error: Error, errorInfo: ErrorInfo) => void;
}
interface State {
hasError: boolean;
error: Error | null;
}
export class ErrorBoundary extends Component<Props, State> {
constructor(props: Props) {
super(props);
this.state = {
hasError: false,
error: null,
};
}
static getDerivedStateFromError(error: Error): State {
return {
hasError: true,
error,
};
}
componentDidCatch(error: Error, errorInfo: ErrorInfo) {
console.error('ErrorBoundary caught an error:', error, errorInfo);
// Call optional error handler
if (this.props.onError) {
this.props.onError(error, errorInfo);
}
// Log to error tracking service (e.g., Sentry)
if (import.meta.env.PROD) {
// TODO: Send to error tracking service
console.error('Production error:', {
error: error.toString(),
componentStack: errorInfo.componentStack,
});
}
}
handleReset = () => {
this.setState({
hasError: false,
error: null,
});
};
render() {
if (this.state.hasError) {
if (this.props.fallback) {
return this.props.fallback;
}
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-primary)] rounded-lg p-6 space-y-4">
<div className="space-y-2">
<h2 className="text-xl font-semibold text-[var(--text-primary)]">
Something went wrong
</h2>
<p className="text-sm text-[var(--text-secondary)]">
An unexpected error occurred. Please try refreshing the page.
</p>
</div>
{import.meta.env.DEV && this.state.error && (
<div className="bg-[var(--bg-secondary)] border border-[var(--border-secondary)] rounded p-3">
<p className="text-xs font-mono text-[var(--text-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(--bg-accent)] text-[var(--text-primary)] rounded hover:opacity-90 transition-opacity"
>
Try Again
</button>
<button
onClick={() => window.location.reload()}
className="flex-1 px-4 py-2 bg-[var(--bg-secondary)] text-[var(--text-primary)] border border-[var(--border-primary)] rounded hover:bg-[var(--bg-tertiary)] transition-colors"
>
Reload Page
</button>
</div>
</div>
</div>
);
}
return this.props.children;
}
}
@@ -0,0 +1,170 @@
import { forwardRef, type InputHTMLAttributes, type ReactNode } from 'react';
type InputVariant = 'default' | 'error' | 'success';
type InputProps = InputHTMLAttributes<HTMLInputElement> & {
variant?: InputVariant;
icon?: ReactNode;
iconPosition?: 'left' | 'right';
hint?: string;
label?: string;
}
const variantStyles: Record<InputVariant, string> = {
default: 'border-[var(--border-subtle)] focus:border-[var(--accent-primary)]',
error: 'border-[var(--error)] focus:border-[var(--error)]',
success: 'border-[var(--success)] focus:border-[var(--success)]',
};
export const Input = forwardRef<HTMLInputElement, InputProps>(
(
{
variant = 'default',
icon,
iconPosition = 'left',
hint,
label,
className = '',
...props
},
ref
) => {
return (
<div className={className}>
{label && (
<label className="block text-xs font-medium uppercase tracking-wider text-[var(--text-muted)] mb-2">
{label}
</label>
)}
<div className="relative">
{icon && iconPosition === 'left' && (
<div className="absolute left-3 top-1/2 -translate-y-1/2 text-[var(--text-tertiary)]">
{icon}
</div>
)}
<input
ref={ref}
className={`
w-full h-10 px-3 rounded-[var(--radius-md)]
border bg-[var(--surface-muted)] text-sm
placeholder:text-[var(--text-muted)]
transition-colors duration-200
focus:outline-none
${variantStyles[variant]}
${icon && iconPosition === 'left' ? 'pl-10' : ''}
${icon && iconPosition === 'right' ? 'pr-10' : ''}
`}
{...props}
/>
{icon && iconPosition === 'right' && (
<div className="absolute right-3 top-1/2 -translate-y-1/2 text-[var(--text-tertiary)]">
{icon}
</div>
)}
</div>
{hint && (
<p className={`mt-1.5 text-xs ${variant === 'error' ? 'text-[var(--error)]' : 'text-[var(--text-muted)]'}`}>
{hint}
</p>
)}
</div>
);
}
);
Input.displayName = 'Input';
type TextareaProps = React.TextareaHTMLAttributes<HTMLTextAreaElement> & {
variant?: InputVariant;
hint?: string;
label?: string;
}
export const Textarea = forwardRef<HTMLTextAreaElement, TextareaProps>(
(
{
variant = 'default',
hint,
label,
className = '',
...props
},
ref
) => {
return (
<div className={className}>
{label && (
<label className="block text-xs font-medium uppercase tracking-wider text-[var(--text-muted)] mb-2">
{label}
</label>
)}
<textarea
ref={ref}
className={`
w-full px-3 py-2 rounded-[var(--radius-md)]
border bg-[var(--surface-muted)] text-sm
placeholder:text-[var(--text-muted)]
transition-colors duration-200
focus:outline-none
${variantStyles[variant]}
`}
{...props}
/>
{hint && (
<p className={`mt-1.5 text-xs ${variant === 'error' ? 'text-[var(--error)]' : 'text-[var(--text-muted)]'}`}>
{hint}
</p>
)}
</div>
);
}
);
Textarea.displayName = 'Textarea';
type SelectProps = React.SelectHTMLAttributes<HTMLSelectElement> & {
variant?: 'default' | 'error';
hint?: string;
label?: string;
}
export const Select = forwardRef<HTMLSelectElement, SelectProps>(
(
{
variant = 'default',
hint,
label,
className = '',
...props
},
ref
) => {
return (
<div className={className}>
{label && (
<label className="block text-xs font-medium uppercase tracking-wider text-[var(--text-muted)] mb-2">
{label}
</label>
)}
<select
ref={ref}
className={`
w-full h-10 px-3 rounded-[var(--radius-md)]
border bg-[var(--surface-muted)] text-sm
transition-colors duration-200
focus:outline-none
${variant === 'error' ? 'border-[var(--error)]' : 'border-[var(--border-subtle)] focus:border-[var(--accent-primary)]'}
`}
{...props}
/>
{hint && (
<p className={`mt-1.5 text-xs ${variant === 'error' ? 'text-[var(--error)]' : 'text-[var(--text-muted)]'}`}>
{hint}
</p>
)}
</div>
);
}
);
Select.displayName = 'Select';
@@ -0,0 +1,51 @@
interface LoadingSpinnerProps {
size?: 'sm' | 'md' | 'lg';
className?: string;
}
export function LoadingSpinner({ size = 'md', className = '' }: LoadingSpinnerProps) {
const sizeClasses = {
sm: 'w-4 h-4 border-2',
md: 'w-8 h-8 border-2',
lg: 'w-12 h-12 border-3',
};
return (
<div
className={`${sizeClasses[size]} border-[var(--border-primary)] border-t-[var(--text-accent)] rounded-full animate-spin ${className}`}
role="status"
aria-label="Loading"
>
<span className="sr-only">Loading...</span>
</div>
);
}
interface LoadingOverlayProps {
message?: string;
}
export function LoadingOverlay({ message = 'Loading...' }: LoadingOverlayProps) {
return (
<div className="fixed inset-0 bg-black/50 backdrop-blur-sm flex items-center justify-center z-50">
<div className="bg-[var(--bg-primary)] border border-[var(--border-primary)] rounded-lg p-6 flex flex-col items-center gap-4">
<LoadingSpinner size="lg" />
<p className="text-sm text-[var(--text-secondary)]">{message}</p>
</div>
</div>
);
}
interface LoadingStateProps {
message?: string;
className?: string;
}
export function LoadingState({ message = 'Loading...', className = '' }: LoadingStateProps) {
return (
<div className={`flex flex-col items-center justify-center gap-3 py-12 ${className}`}>
<LoadingSpinner size="md" />
<p className="text-sm text-[var(--text-secondary)]">{message}</p>
</div>
);
}
@@ -0,0 +1,188 @@
import { type ReactNode, useEffect, useRef, useState } from '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;
}
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 statusColors: Record<string, string> = {
good: '#3dd68c',
average: '#f0a040',
warning: '#ff7043',
};
// 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>
<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>
</div>
</div>
{chart && (
<div style={{
width: '50%',
padding: '16px 14px 46px 0',
display: 'flex',
alignItems: 'flex-end',
}}>
{chart}
</div>
)}
</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>
<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>
</div>
</div>
);
}
@@ -0,0 +1,134 @@
import { Check, X, Loader2, Box, HelpCircle, type LucideIcon } from 'lucide-react';
type ServiceStatus = 'running' | 'building' | 'failed' | 'stopped' | 'unknown';
interface StatusBadgeProps {
status: ServiceStatus | string;
size?: 'sm' | 'md' | 'lg';
showLabel?: boolean;
pulse?: boolean;
className?: string;
}
const statusConfig: Record<
ServiceStatus,
{ color: string; bg: string; Icon: LucideIcon; label: string; animate?: boolean; glow?: string }
> = {
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',
},
};
export function StatusBadge({
status,
size = 'md',
showLabel = true,
pulse = true,
className = '',
}: StatusBadgeProps) {
const config = statusConfig[status as ServiceStatus] ?? statusConfig.unknown;
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',
};
const iconSizes = {
sm: 10,
md: 12,
lg: 14,
};
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',
}}
>
{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}` }}
/>
)}
<Icon
size={iconSizes[size]}
className={config.animate && status === 'building' ? 'animate-spin' : ''}
/>
{showLabel && <span>{config.label}</span>}
</div>
);
}
interface LiveIndicatorProps {
isLive: boolean;
label?: string;
size?: 'sm' | 'md' | 'lg';
}
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',
};
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',
}}
>
<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',
}}
/>
{label || (isLive ? 'Active' : 'Stopped')}
</div>
);
}
@@ -0,0 +1,107 @@
import * as ToastPrimitive from '@radix-ui/react-toast';
import { createContext, useContext, useState, ReactNode } from 'react';
import { IconX, IconCheck, IconAlertCircle, IconInfoCircle } from '@tabler/icons-react';
type ToastType = 'success' | 'error' | 'info' | 'warning';
interface Toast {
id: string;
type: ToastType;
title: string;
description?: string;
}
interface ToastContextValue {
showToast: (type: ToastType, title: string, description?: string) => void;
}
const ToastContext = createContext<ToastContextValue | undefined>(undefined);
export function useToast() {
const context = useContext(ToastContext);
if (!context) {
throw new Error('useToast must be used within ToastProvider');
}
return context;
}
interface ToastProviderProps {
children: ReactNode;
}
export function ToastProvider({ children }: ToastProviderProps) {
const [toasts, setToasts] = useState<Toast[]>([]);
const showToast = (type: ToastType, title: string, description?: string) => {
const id = Math.random().toString(36).substring(7);
setToasts((prev) => [...prev, { id, type, title, description }]);
// Auto-dismiss after 5 seconds
setTimeout(() => {
setToasts((prev) => prev.filter((toast) => toast.id !== id));
}, 5000);
};
const removeToast = (id: string) => {
setToasts((prev) => prev.filter((toast) => toast.id !== id));
};
const getIcon = (type: ToastType) => {
switch (type) {
case 'success':
return <IconCheck className="w-5 h-5 text-green-500" />;
case 'error':
return <IconAlertCircle className="w-5 h-5 text-red-500" />;
case 'warning':
return <IconAlertCircle className="w-5 h-5 text-yellow-500" />;
case 'info':
return <IconInfoCircle className="w-5 h-5 text-blue-500" />;
}
};
const getBorderColor = (type: ToastType) => {
switch (type) {
case 'success':
return 'border-green-500/50';
case 'error':
return 'border-red-500/50';
case 'warning':
return 'border-yellow-500/50';
case 'info':
return 'border-blue-500/50';
}
};
return (
<ToastContext.Provider value={{ showToast }}>
<ToastPrimitive.Provider swipeDirection="right">
{children}
{toasts.map((toast) => (
<ToastPrimitive.Root
key={toast.id}
className={`bg-[var(--bg-primary)] border ${getBorderColor(toast.type)} rounded-lg shadow-lg p-4 flex items-start gap-3 min-w-[300px] max-w-[420px] data-[state=open]:animate-in data-[state=closed]:animate-out data-[swipe=end]:animate-out data-[state=closed]:fade-out-80 data-[state=closed]:slide-out-to-right-full data-[state=open]:slide-in-from-top-full data-[state=open]:sm:slide-in-from-bottom-full`}
onOpenChange={(open) => {
if (!open) removeToast(toast.id);
}}
>
{getIcon(toast.type)}
<div className="flex-1 space-y-1">
<ToastPrimitive.Title className="text-sm font-medium text-[var(--text-primary)]">
{toast.title}
</ToastPrimitive.Title>
{toast.description && (
<ToastPrimitive.Description className="text-xs text-[var(--text-secondary)]">
{toast.description}
</ToastPrimitive.Description>
)}
</div>
<ToastPrimitive.Close className="text-[var(--text-secondary)] hover:text-[var(--text-primary)] transition-colors">
<IconX className="w-4 h-4" />
</ToastPrimitive.Close>
</ToastPrimitive.Root>
))}
<ToastPrimitive.Viewport className="fixed top-0 right-0 flex flex-col p-6 gap-2 w-full max-w-[420px] m-0 list-none z-[100] outline-none" />
</ToastPrimitive.Provider>
</ToastContext.Provider>
);
}
@@ -0,0 +1,8 @@
export * from './ErrorBoundary';
export * from './LoadingSpinner';
export * from './Toast';
export * from './Charts';
export * from './DonutChart';
export * from './EnhancedMetricCard';
export * from './CommandPalette';
export * from './StatusBadge';
@@ -0,0 +1,17 @@
import { createContext, useContext } from 'react';
export type ToastType = 'success' | 'info' | 'warning' | 'error';
export interface ToastContextValue {
showToast: (message: string, type?: ToastType) => void;
}
export const ToastContext = createContext<ToastContextValue | null>(null);
export function useToast() {
const context = useContext(ToastContext);
if (!context) {
throw new Error('useToast must be used within a ToastProvider');
}
return context;
}