mirror of
https://github.com/Dvorinka/Containr.git
synced 2026-10-28 15:51:26 +00:00
small fix, don't worry about it
This commit is contained in:
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';
|
||||
Reference in new issue
Block a user