website refactor
This commit is contained in:
40
apps/website/ui/AccountItem.tsx
Normal file
40
apps/website/ui/AccountItem.tsx
Normal file
@@ -0,0 +1,40 @@
|
||||
import React, { ReactNode } from 'react';
|
||||
import { Box } from './primitives/Box';
|
||||
import { Text } from './Text';
|
||||
import { Icon } from './Icon';
|
||||
import { LucideIcon } from 'lucide-react';
|
||||
|
||||
export interface AccountItemProps {
|
||||
icon: LucideIcon;
|
||||
title: string;
|
||||
description: string;
|
||||
action?: ReactNode;
|
||||
intent?: 'primary' | 'telemetry' | 'success' | 'low';
|
||||
}
|
||||
|
||||
export const AccountItem = ({
|
||||
icon,
|
||||
title,
|
||||
description,
|
||||
action,
|
||||
intent = 'low'
|
||||
}: AccountItemProps) => {
|
||||
return (
|
||||
<Box display="flex" alignItems="center" justifyContent="between" paddingY={4} style={{ borderTop: '1px solid var(--ui-color-border-muted)' }}>
|
||||
<Box display="flex" alignItems="center" gap={3}>
|
||||
<Box padding={2} rounded="md" bg="var(--ui-color-bg-surface-muted)">
|
||||
<Icon icon={icon} size={5} intent={intent} />
|
||||
</Box>
|
||||
<Box>
|
||||
<Text weight="bold" size="sm" variant="high">{title}</Text>
|
||||
<Text size="xs" variant="low">{description}</Text>
|
||||
</Box>
|
||||
</Box>
|
||||
{action && (
|
||||
<Box>
|
||||
{action}
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
65
apps/website/ui/ActiveDriverCard.tsx
Normal file
65
apps/website/ui/ActiveDriverCard.tsx
Normal file
@@ -0,0 +1,65 @@
|
||||
import { Image } from '@/ui/Image';
|
||||
import { Box } from '@/ui/primitives/Box';
|
||||
import { Text } from '@/ui/Text';
|
||||
import { Surface } from '@/ui/primitives/Surface';
|
||||
import React from 'react';
|
||||
|
||||
export interface ActiveDriverCardProps {
|
||||
name: string;
|
||||
avatarUrl: string;
|
||||
categoryLabel?: string;
|
||||
skillLevelLabel?: string;
|
||||
onClick: () => void;
|
||||
}
|
||||
|
||||
export function ActiveDriverCard({
|
||||
name,
|
||||
avatarUrl,
|
||||
categoryLabel,
|
||||
skillLevelLabel,
|
||||
onClick,
|
||||
}: ActiveDriverCardProps) {
|
||||
return (
|
||||
<Surface
|
||||
as="button"
|
||||
onClick={onClick}
|
||||
variant="muted"
|
||||
rounded="xl"
|
||||
padding={3}
|
||||
style={{ border: '1px solid var(--ui-color-border-default)', textAlign: 'center', cursor: 'pointer' }}
|
||||
>
|
||||
<Box position="relative" width={12} height={12} marginX="auto" marginBottom={2}>
|
||||
<Box fullWidth fullHeight rounded="full" style={{ overflow: 'hidden', border: '1px solid var(--ui-color-border-default)' }}>
|
||||
<Image src={avatarUrl} alt={name} objectFit="cover" />
|
||||
</Box>
|
||||
<Box
|
||||
position="absolute"
|
||||
bottom={0}
|
||||
right={0}
|
||||
width={3}
|
||||
height={3}
|
||||
rounded="full"
|
||||
bg="var(--ui-color-intent-success)"
|
||||
style={{ border: '2px solid var(--ui-color-bg-surface)' }}
|
||||
/>
|
||||
</Box>
|
||||
<Text
|
||||
size="sm"
|
||||
weight="medium"
|
||||
variant="high"
|
||||
truncate
|
||||
block
|
||||
>
|
||||
{name}
|
||||
</Text>
|
||||
<Box display="flex" alignItems="center" justifyContent="center" gap={1}>
|
||||
{categoryLabel && (
|
||||
<Text size="xs" variant="primary">{categoryLabel}</Text>
|
||||
)}
|
||||
{skillLevelLabel && (
|
||||
<Text size="xs" variant="telemetry">{skillLevelLabel}</Text>
|
||||
)}
|
||||
</Box>
|
||||
</Surface>
|
||||
);
|
||||
}
|
||||
30
apps/website/ui/AuthLayout.tsx
Normal file
30
apps/website/ui/AuthLayout.tsx
Normal file
@@ -0,0 +1,30 @@
|
||||
import React, { ReactNode } from 'react';
|
||||
import { Box } from './primitives/Box';
|
||||
import { Glow } from './Glow';
|
||||
|
||||
export interface AuthLayoutProps {
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
export const AuthLayout = ({ children }: AuthLayoutProps) => {
|
||||
return (
|
||||
<Box
|
||||
as="main"
|
||||
minHeight="100vh"
|
||||
display="flex"
|
||||
alignItems="center"
|
||||
justifyContent="center"
|
||||
padding={4}
|
||||
bg="var(--ui-color-bg-base)"
|
||||
position="relative"
|
||||
style={{ overflow: 'hidden' }}
|
||||
>
|
||||
<Glow color="primary" size="xl" opacity={0.05} position="top-right" />
|
||||
<Glow color="aqua" size="xl" opacity={0.05} position="bottom-left" />
|
||||
|
||||
<Box width="100%" maxWidth="25rem" position="relative" zIndex={10}>
|
||||
{children}
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
14
apps/website/ui/BadgeGroup.tsx
Normal file
14
apps/website/ui/BadgeGroup.tsx
Normal file
@@ -0,0 +1,14 @@
|
||||
import React, { ReactNode } from 'react';
|
||||
import { Box } from './primitives/Box';
|
||||
|
||||
export interface BadgeGroupProps {
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
export const BadgeGroup = ({ children }: BadgeGroupProps) => {
|
||||
return (
|
||||
<Box display="flex" gap={3} flexWrap="wrap" marginTop={4}>
|
||||
{children}
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
41
apps/website/ui/BrandMark.tsx
Normal file
41
apps/website/ui/BrandMark.tsx
Normal file
@@ -0,0 +1,41 @@
|
||||
import { Box } from '@/ui/primitives/Box';
|
||||
import Image from 'next/image';
|
||||
import { Link } from '@/ui/Link';
|
||||
import React from 'react';
|
||||
|
||||
interface BrandMarkProps {
|
||||
href?: string;
|
||||
priority?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* BrandMark provides the consistent logo/wordmark for the application.
|
||||
* Aligned with "Precision Racing Minimal" theme.
|
||||
*/
|
||||
export function BrandMark({ href = '/', priority = false }: BrandMarkProps) {
|
||||
return (
|
||||
<Link href={href} variant="inherit" underline="none">
|
||||
<Box position="relative" display="inline-flex" alignItems="center">
|
||||
<Box height={{ base: '1.5rem', md: '1.75rem' }} style={{ transition: 'opacity 0.2s' }}>
|
||||
<Image
|
||||
src="/images/logos/wordmark-rectangle-dark.svg"
|
||||
alt="GridPilot"
|
||||
width={160}
|
||||
height={30}
|
||||
priority={priority}
|
||||
style={{ height: '100%', width: 'auto' }}
|
||||
/>
|
||||
</Box>
|
||||
<Box
|
||||
position="absolute"
|
||||
bottom="-4px"
|
||||
left="0"
|
||||
width="0"
|
||||
height="2px"
|
||||
bg="var(--ui-color-intent-primary)"
|
||||
style={{ transition: 'width 0.2s' }}
|
||||
/>
|
||||
</Box>
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
35
apps/website/ui/BulkActions.tsx
Normal file
35
apps/website/ui/BulkActions.tsx
Normal file
@@ -0,0 +1,35 @@
|
||||
import React, { ReactNode } from 'react';
|
||||
import { Box } from './primitives/Box';
|
||||
import { Surface } from './primitives/Surface';
|
||||
import { Text } from './Text';
|
||||
import { Badge } from './Badge';
|
||||
|
||||
export interface BulkActionsProps {
|
||||
children: ReactNode;
|
||||
selectedCount: number;
|
||||
isOpen: boolean;
|
||||
}
|
||||
|
||||
export const BulkActions = ({ children, selectedCount, isOpen }: BulkActionsProps) => {
|
||||
if (!isOpen) return null;
|
||||
|
||||
return (
|
||||
<Box
|
||||
position="fixed"
|
||||
style={{ bottom: '2rem', left: '50%', transform: 'translateX(-50%)', zIndex: 100 }}
|
||||
>
|
||||
<Surface variant="glass" rounded="xl" shadow="xl" padding={4} style={{ border: '1px solid var(--ui-color-intent-primary)' }}>
|
||||
<Box display="flex" alignItems="center" gap={8}>
|
||||
<Box display="flex" alignItems="center" gap={3}>
|
||||
<Badge variant="primary">{selectedCount}</Badge>
|
||||
<Text size="sm" weight="bold" variant="high">Items Selected</Text>
|
||||
</Box>
|
||||
<Box width="1px" height="1.5rem" bg="var(--ui-color-border-default)" />
|
||||
<Box display="flex" alignItems="center" gap={3}>
|
||||
{children}
|
||||
</Box>
|
||||
</Box>
|
||||
</Surface>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
@@ -1,8 +1,7 @@
|
||||
import React, { ReactNode, MouseEventHandler, forwardRef } from 'react';
|
||||
import { Stack } from './primitives/Stack';
|
||||
import { Box } from './primitives/Box';
|
||||
import { Icon } from './Icon';
|
||||
import { Loader2 } from 'lucide-react';
|
||||
import { ResponsiveValue } from './primitives/Box';
|
||||
|
||||
export interface ButtonProps {
|
||||
children: ReactNode;
|
||||
@@ -18,20 +17,9 @@ export interface ButtonProps {
|
||||
href?: string;
|
||||
target?: string;
|
||||
rel?: string;
|
||||
className?: string;
|
||||
title?: string;
|
||||
style?: React.CSSProperties;
|
||||
width?: string | number | ResponsiveValue<string | number>;
|
||||
height?: string | number | ResponsiveValue<string | number>;
|
||||
minWidth?: string | number;
|
||||
px?: number;
|
||||
py?: number;
|
||||
p?: number;
|
||||
rounded?: string;
|
||||
bg?: string;
|
||||
color?: string;
|
||||
fontSize?: string;
|
||||
h?: string;
|
||||
w?: string;
|
||||
rounded?: boolean;
|
||||
}
|
||||
|
||||
export const Button = forwardRef<HTMLButtonElement | HTMLAnchorElement, ButtonProps>(({
|
||||
@@ -48,22 +36,11 @@ export const Button = forwardRef<HTMLButtonElement | HTMLAnchorElement, ButtonPr
|
||||
href,
|
||||
target,
|
||||
rel,
|
||||
className,
|
||||
title,
|
||||
style: styleProp,
|
||||
width,
|
||||
height,
|
||||
minWidth,
|
||||
px,
|
||||
py,
|
||||
p,
|
||||
rounded,
|
||||
bg,
|
||||
color,
|
||||
fontSize,
|
||||
h,
|
||||
w,
|
||||
rounded = false,
|
||||
}, ref) => {
|
||||
const baseClasses = 'inline-flex items-center justify-center rounded-none transition-all duration-150 ease-in-out focus-visible:outline focus-visible:outline-1 focus-visible:outline-offset-2 active:opacity-80 uppercase tracking-widest font-bold';
|
||||
const baseClasses = 'inline-flex items-center justify-center transition-all duration-150 ease-in-out focus-visible:outline focus-visible:outline-1 focus-visible:outline-offset-2 active:opacity-80 uppercase tracking-widest font-bold';
|
||||
|
||||
const variantClasses = {
|
||||
primary: 'bg-[var(--ui-color-intent-primary)] text-white hover:opacity-90 focus-visible:outline-[var(--ui-color-intent-primary)] shadow-[0_0_15px_rgba(25,140,255,0.3)] hover:shadow-[0_0_25px_rgba(25,140,255,0.5)]',
|
||||
@@ -83,6 +60,7 @@ export const Button = forwardRef<HTMLButtonElement | HTMLAnchorElement, ButtonPr
|
||||
|
||||
const disabledClasses = (disabled || isLoading) ? 'opacity-40 cursor-not-allowed' : 'cursor-pointer';
|
||||
const widthClasses = fullWidth ? 'w-full' : '';
|
||||
const roundedClasses = rounded ? 'rounded-full' : 'rounded-none';
|
||||
|
||||
const classes = [
|
||||
baseClasses,
|
||||
@@ -90,25 +68,15 @@ export const Button = forwardRef<HTMLButtonElement | HTMLAnchorElement, ButtonPr
|
||||
sizeClasses[size],
|
||||
disabledClasses,
|
||||
widthClasses,
|
||||
className
|
||||
roundedClasses,
|
||||
].filter(Boolean).join(' ');
|
||||
|
||||
const style: React.CSSProperties = {
|
||||
...(width ? { width: typeof width === 'object' ? undefined : width } : {}),
|
||||
...(height ? { height: typeof height === 'object' ? undefined : height } : {}),
|
||||
...(minWidth ? { minWidth } : {}),
|
||||
...(fontSize ? { fontSize } : {}),
|
||||
...(h ? { height: h } : {}),
|
||||
...(w ? { width: w } : {}),
|
||||
...(styleProp || {})
|
||||
};
|
||||
|
||||
const content = (
|
||||
<Stack direction="row" align="center" gap={2}>
|
||||
<Box display="flex" alignItems="center" gap={2}>
|
||||
{isLoading && <Icon icon={Loader2} size={size === 'sm' ? 3 : 4} animate="spin" />}
|
||||
{!isLoading && icon}
|
||||
{children}
|
||||
</Stack>
|
||||
</Box>
|
||||
);
|
||||
|
||||
if (as === 'a') {
|
||||
@@ -120,7 +88,8 @@ export const Button = forwardRef<HTMLButtonElement | HTMLAnchorElement, ButtonPr
|
||||
rel={rel}
|
||||
className={classes}
|
||||
onClick={onClick as MouseEventHandler<HTMLAnchorElement>}
|
||||
style={style}
|
||||
style={styleProp}
|
||||
title={title}
|
||||
>
|
||||
{content}
|
||||
</a>
|
||||
@@ -134,7 +103,8 @@ export const Button = forwardRef<HTMLButtonElement | HTMLAnchorElement, ButtonPr
|
||||
className={classes}
|
||||
onClick={onClick as MouseEventHandler<HTMLButtonElement>}
|
||||
disabled={disabled || isLoading}
|
||||
style={style}
|
||||
style={styleProp}
|
||||
title={title}
|
||||
>
|
||||
{content}
|
||||
</button>
|
||||
|
||||
@@ -4,7 +4,7 @@ import { Box } from './primitives/Box';
|
||||
|
||||
export interface CardProps extends Omit<SurfaceProps<'div'>, 'children' | 'title' | 'variant'> {
|
||||
children: ReactNode;
|
||||
variant?: 'default' | 'dark' | 'muted' | 'glass' | 'outline';
|
||||
variant?: 'default' | 'dark' | 'muted' | 'glass' | 'outline' | 'rarity-common' | 'rarity-rare' | 'rarity-epic' | 'rarity-legendary';
|
||||
title?: ReactNode;
|
||||
footer?: ReactNode;
|
||||
}
|
||||
|
||||
47
apps/website/ui/ConfirmDialog.tsx
Normal file
47
apps/website/ui/ConfirmDialog.tsx
Normal file
@@ -0,0 +1,47 @@
|
||||
import { Button } from '@/ui/Button';
|
||||
import { Modal } from '@/ui/Modal';
|
||||
import { Text } from '@/ui/Text';
|
||||
import { Icon } from '@/ui/Icon';
|
||||
import { AlertCircle } from 'lucide-react';
|
||||
import React from 'react';
|
||||
|
||||
interface ConfirmDialogProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
onConfirm: () => void;
|
||||
title: string;
|
||||
description: string;
|
||||
confirmLabel?: string;
|
||||
cancelLabel?: string;
|
||||
variant?: 'danger' | 'primary';
|
||||
isLoading?: boolean;
|
||||
}
|
||||
|
||||
export function ConfirmDialog({
|
||||
isOpen,
|
||||
onClose,
|
||||
onConfirm,
|
||||
title,
|
||||
description,
|
||||
confirmLabel = 'Confirm',
|
||||
cancelLabel = 'Cancel',
|
||||
variant = 'primary',
|
||||
isLoading = false,
|
||||
}: ConfirmDialogProps) {
|
||||
return (
|
||||
<Modal
|
||||
isOpen={isOpen}
|
||||
onClose={onClose}
|
||||
title={title}
|
||||
primaryActionLabel={isLoading ? 'Processing...' : confirmLabel}
|
||||
onPrimaryAction={onConfirm}
|
||||
secondaryActionLabel={cancelLabel}
|
||||
onSecondaryAction={onClose}
|
||||
icon={variant === 'danger' ? <Icon icon={AlertCircle} size={5} intent="critical" /> : undefined}
|
||||
>
|
||||
<Text variant="low" size="sm">
|
||||
{description}
|
||||
</Text>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
@@ -4,28 +4,30 @@ import { Surface } from './primitives/Surface';
|
||||
|
||||
export interface ControlBarProps {
|
||||
children: ReactNode;
|
||||
actions?: ReactNode;
|
||||
leftContent?: ReactNode;
|
||||
variant?: 'default' | 'dark';
|
||||
}
|
||||
|
||||
export const ControlBar = ({
|
||||
children,
|
||||
actions
|
||||
leftContent,
|
||||
variant = 'default'
|
||||
}: ControlBarProps) => {
|
||||
return (
|
||||
<Surface
|
||||
variant="muted"
|
||||
variant={variant === 'dark' ? 'dark' : 'muted'}
|
||||
padding={4}
|
||||
style={{ borderBottom: '1px solid var(--ui-color-border-default)' }}
|
||||
>
|
||||
<Box display="flex" alignItems="center" justifyContent="between">
|
||||
<Box display="flex" alignItems="center" gap={4}>
|
||||
{children}
|
||||
</Box>
|
||||
{actions && (
|
||||
<Box display="flex" alignItems="center" gap={4}>
|
||||
{actions}
|
||||
<Box display="flex" alignItems="center" justifyContent="between" flexWrap="wrap" gap={4}>
|
||||
{leftContent && (
|
||||
<Box display="flex" alignItems="center" gap={4} flex={1}>
|
||||
{leftContent}
|
||||
</Box>
|
||||
)}
|
||||
<Box display="flex" alignItems="center" gap={4} justifyContent="end" flex={leftContent ? 0 : 1}>
|
||||
{children}
|
||||
</Box>
|
||||
</Box>
|
||||
</Surface>
|
||||
);
|
||||
|
||||
32
apps/website/ui/DebugPanel.tsx
Normal file
32
apps/website/ui/DebugPanel.tsx
Normal file
@@ -0,0 +1,32 @@
|
||||
import React, { ReactNode } from 'react';
|
||||
import { Card } from './Card';
|
||||
import { Box } from './primitives/Box';
|
||||
import { Heading } from './Heading';
|
||||
import { IconButton } from './IconButton';
|
||||
import { X } from 'lucide-react';
|
||||
|
||||
export interface DebugPanelProps {
|
||||
title: string;
|
||||
children: ReactNode;
|
||||
onClose: () => void;
|
||||
icon?: ReactNode;
|
||||
}
|
||||
|
||||
export const DebugPanel = ({ title, children, onClose, icon }: DebugPanelProps) => {
|
||||
return (
|
||||
<Box position="fixed" style={{ bottom: '1rem', left: '1rem', width: '20rem', zIndex: 100 }}>
|
||||
<Card variant="dark" padding={0}>
|
||||
<Box display="flex" alignItems="center" justifyContent="between" padding={3} bg="var(--ui-color-bg-surface-muted)" style={{ borderBottom: '1px solid var(--ui-color-border-default)' }}>
|
||||
<Box display="flex" alignItems="center" gap={2}>
|
||||
{icon}
|
||||
<Heading level={6}>{title}</Heading>
|
||||
</Box>
|
||||
<IconButton icon={X} size="sm" variant="ghost" onClick={onClose} />
|
||||
</Box>
|
||||
<Box padding={3}>
|
||||
{children}
|
||||
</Box>
|
||||
</Card>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
160
apps/website/ui/DevErrorPanel.tsx
Normal file
160
apps/website/ui/DevErrorPanel.tsx
Normal file
@@ -0,0 +1,160 @@
|
||||
import { connectionMonitor } from '@/lib/api/base/ApiConnectionMonitor';
|
||||
import { ApiError } from '@/lib/api/base/ApiError';
|
||||
import { CircuitBreakerRegistry } from '@/lib/api/base/RetryHandler';
|
||||
import { Badge } from '@/ui/Badge';
|
||||
import { Button } from '@/ui/Button';
|
||||
import { Card } from '@/ui/Card';
|
||||
import { Heading } from '@/ui/Heading';
|
||||
import { Icon } from '@/ui/Icon';
|
||||
import { Box } from '@/ui/primitives/Box';
|
||||
import { Text } from '@/ui/Text';
|
||||
import { StatGrid } from '@/ui/StatGrid';
|
||||
import { Activity, AlertTriangle, Copy, RefreshCw, Terminal, X } from 'lucide-react';
|
||||
import React, { useEffect, useState } from 'react';
|
||||
|
||||
interface DevErrorPanelProps {
|
||||
error: ApiError;
|
||||
onReset: () => void;
|
||||
}
|
||||
|
||||
export function DevErrorPanel({ error, onReset }: DevErrorPanelProps) {
|
||||
const [connectionStatus, setConnectionStatus] = useState(connectionMonitor.getHealth());
|
||||
const [circuitBreakers, setCircuitBreakers] = useState(CircuitBreakerRegistry.getInstance().getStatus());
|
||||
const [copied, setCopied] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
const handleStatusChange = () => {
|
||||
setConnectionStatus(connectionMonitor.getHealth());
|
||||
setCircuitBreakers(CircuitBreakerRegistry.getInstance().getStatus());
|
||||
};
|
||||
|
||||
connectionMonitor.on('success', handleStatusChange);
|
||||
connectionMonitor.on('failure', handleStatusChange);
|
||||
connectionMonitor.on('connected', handleStatusChange);
|
||||
connectionMonitor.on('disconnected', handleStatusChange);
|
||||
connectionMonitor.on('degraded', handleStatusChange);
|
||||
|
||||
return () => {
|
||||
connectionMonitor.off('success', handleStatusChange);
|
||||
connectionMonitor.off('failure', handleStatusChange);
|
||||
connectionMonitor.off('connected', handleStatusChange);
|
||||
connectionMonitor.off('disconnected', handleStatusChange);
|
||||
connectionMonitor.off('degraded', handleStatusChange);
|
||||
};
|
||||
}, []);
|
||||
|
||||
const copyToClipboard = async () => {
|
||||
const debugInfo = {
|
||||
error: {
|
||||
type: error.type,
|
||||
message: error.message,
|
||||
context: error.context,
|
||||
stack: error.stack,
|
||||
},
|
||||
connection: connectionStatus,
|
||||
circuitBreakers,
|
||||
timestamp: new Date().toISOString(),
|
||||
userAgent: navigator.userAgent,
|
||||
url: window.location.href,
|
||||
};
|
||||
|
||||
try {
|
||||
await navigator.clipboard.writeText(JSON.stringify(debugInfo, null, 2));
|
||||
setCopied(true);
|
||||
setTimeout(() => setCopied(false), 2000);
|
||||
} catch (err) {
|
||||
// Silent failure
|
||||
}
|
||||
};
|
||||
|
||||
const getSeverityVariant = (): 'critical' | 'warning' | 'primary' | 'default' => {
|
||||
switch (error.getSeverity()) {
|
||||
case 'error': return 'critical';
|
||||
case 'warn': return 'warning';
|
||||
case 'info': return 'primary';
|
||||
default: return 'default';
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Box position="fixed" inset={0} zIndex={100} bg="var(--ui-color-bg-base)" padding={4} style={{ overflowY: 'auto' }}>
|
||||
<Box maxWidth="80rem" marginX="auto" fullWidth>
|
||||
<Box display="flex" flexDirection="col" gap={4}>
|
||||
{/* Header */}
|
||||
<Card variant="dark" padding={4}>
|
||||
<Box display="flex" alignItems="center" justifyContent="between">
|
||||
<Box display="flex" alignItems="center" gap={3}>
|
||||
<Icon icon={Terminal} size={5} intent="primary" />
|
||||
<Heading level={2}>API Error Debug Panel</Heading>
|
||||
<Badge variant={getSeverityVariant() as any}>
|
||||
{error.type}
|
||||
</Badge>
|
||||
</Box>
|
||||
<Box display="flex" gap={2}>
|
||||
<Button variant="secondary" onClick={copyToClipboard} icon={<Icon icon={Copy} size={4} />}>
|
||||
{copied ? 'Copied!' : 'Copy'}
|
||||
</Button>
|
||||
<Button variant="primary" onClick={onReset} icon={<Icon icon={X} size={4} />}>
|
||||
Close
|
||||
</Button>
|
||||
</Box>
|
||||
</Box>
|
||||
</Card>
|
||||
|
||||
{/* Details Grid */}
|
||||
<Box display="grid" gridCols={{ base: 1, lg: 2 }} gap={4}>
|
||||
<Box display="flex" flexDirection="col" gap={4}>
|
||||
<Card title="Error Details" variant="outline">
|
||||
<Box display="flex" flexDirection="col" gap={2}>
|
||||
<DetailRow label="Type" value={error.type} intent="critical" />
|
||||
<DetailRow label="Message" value={error.message} />
|
||||
<DetailRow label="Endpoint" value={error.context.endpoint || 'N/A'} intent="primary" />
|
||||
<DetailRow label="Method" value={error.context.method || 'N/A'} intent="warning" />
|
||||
<DetailRow label="Status" value={error.context.statusCode || 'N/A'} />
|
||||
</Box>
|
||||
</Card>
|
||||
|
||||
<Card title="Connection Health" variant="outline">
|
||||
<Box display="flex" flexDirection="col" gap={2}>
|
||||
<DetailRow label="Status" value={connectionStatus.status.toUpperCase()} intent={connectionStatus.status === 'connected' ? 'success' : 'critical'} />
|
||||
<DetailRow label="Reliability" value={`${connectionMonitor.getReliability().toFixed(2)}%`} />
|
||||
<DetailRow label="Total Requests" value={connectionStatus.totalRequests} />
|
||||
</Box>
|
||||
</Card>
|
||||
</Box>
|
||||
|
||||
<Box display="flex" flexDirection="col" gap={4}>
|
||||
<Card title="Actions" variant="outline">
|
||||
<Box display="flex" flexDirection="col" gap={2}>
|
||||
<Button variant="primary" onClick={() => connectionMonitor.performHealthCheck()} fullWidth icon={<Icon icon={RefreshCw} size={4} />}>
|
||||
Run Health Check
|
||||
</Button>
|
||||
<Button variant="secondary" onClick={() => CircuitBreakerRegistry.getInstance().resetAll()} fullWidth>
|
||||
Reset Circuit Breakers
|
||||
</Button>
|
||||
</Box>
|
||||
</Card>
|
||||
|
||||
<Card title="Raw Error" variant="outline">
|
||||
<Box padding={2} bg="var(--ui-color-bg-surface-muted)" rounded="md" style={{ maxHeight: '10rem', overflow: 'auto' }}>
|
||||
<Text size="xs" variant="low" font="mono">
|
||||
{JSON.stringify(error.context, null, 2)}
|
||||
</Text>
|
||||
</Box>
|
||||
</Card>
|
||||
</Box>
|
||||
</Box>
|
||||
</Box>
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
const DetailRow = ({ label, value, intent = 'low' }: { label: string; value: any; intent?: any }) => (
|
||||
<Box display="grid" gridCols={3} gap={2}>
|
||||
<Text size="xs" variant="low" weight="bold">{label}:</Text>
|
||||
<Box style={{ gridColumn: 'span 2' }}>
|
||||
<Text size="xs" variant={intent} weight="bold">{String(value)}</Text>
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
62
apps/website/ui/DriverIdentity.tsx
Normal file
62
apps/website/ui/DriverIdentity.tsx
Normal file
@@ -0,0 +1,62 @@
|
||||
import { Badge } from '@/ui/Badge';
|
||||
import { Image } from '@/ui/Image';
|
||||
import { Link } from '@/ui/Link';
|
||||
import { PlaceholderImage } from '@/ui/PlaceholderImage';
|
||||
import { Box } from '@/ui/primitives/Box';
|
||||
import { Text } from '@/ui/Text';
|
||||
import { Avatar } from '@/ui/Avatar';
|
||||
import React from 'react';
|
||||
|
||||
export interface DriverIdentityProps {
|
||||
driver: {
|
||||
id: string;
|
||||
name: string;
|
||||
avatarUrl: string | null;
|
||||
};
|
||||
href?: string;
|
||||
contextLabel?: React.ReactNode;
|
||||
meta?: React.ReactNode;
|
||||
size?: 'sm' | 'md';
|
||||
}
|
||||
|
||||
export function DriverIdentity({ driver, href, contextLabel, meta, size = 'md' }: DriverIdentityProps) {
|
||||
const nameSize = size === 'sm' ? 'sm' : 'base';
|
||||
|
||||
const content = (
|
||||
<Box display="flex" alignItems="center" gap={3} flexGrow={1} minWidth="0">
|
||||
<Avatar
|
||||
src={driver.avatarUrl || undefined}
|
||||
alt={driver.name}
|
||||
size={size === 'sm' ? 'sm' : 'md'}
|
||||
/>
|
||||
|
||||
<Box flex={1} minWidth="0">
|
||||
<Box display="flex" alignItems="center" gap={2} minWidth="0">
|
||||
<Text size={nameSize as any} weight="medium" variant="high" truncate>
|
||||
{driver.name}
|
||||
</Text>
|
||||
{contextLabel && (
|
||||
<Badge variant="default" size="sm">
|
||||
{contextLabel}
|
||||
</Badge>
|
||||
)}
|
||||
</Box>
|
||||
{meta && (
|
||||
<Text size="xs" variant="low" block truncate>
|
||||
{meta}
|
||||
</Text>
|
||||
)}
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
|
||||
if (href) {
|
||||
return (
|
||||
<Link href={href} block variant="inherit" underline="none">
|
||||
{content}
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
|
||||
return <Box display="flex" alignItems="center" gap={3} flexGrow={1} minWidth="0">{content}</Box>;
|
||||
}
|
||||
151
apps/website/ui/EmptyState.tsx
Normal file
151
apps/website/ui/EmptyState.tsx
Normal file
@@ -0,0 +1,151 @@
|
||||
import { Button } from '@/ui/Button';
|
||||
import { Heading } from '@/ui/Heading';
|
||||
import { Link } from '@/ui/Link';
|
||||
import { Box } from '@/ui/primitives/Box';
|
||||
import { EmptyStateProps } from '@/ui/state-types';
|
||||
import { Text } from '@/ui/Text';
|
||||
import { Activity, Lock, Search } from 'lucide-react';
|
||||
import React from 'react';
|
||||
|
||||
// Illustration components (simple SVG representations)
|
||||
const Illustrations = {
|
||||
racing: () => (
|
||||
<svg width="80" height="80" viewBox="0 0 100 100" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M20 70 L80 70 L85 50 L80 30 L20 30 L15 50 Z" fill="currentColor" opacity="0.2"/>
|
||||
<path d="M30 60 L70 60 L75 50 L70 40 L30 40 L25 50 Z" fill="currentColor" opacity="0.4"/>
|
||||
<circle cx="35" cy="65" r="3" fill="currentColor"/>
|
||||
<circle cx="65" cy="65" r="3" fill="currentColor"/>
|
||||
<path d="M50 30 L50 20 M45 25 L50 20 L55 25" stroke="currentColor" strokeWidth="2" strokeLinecap="round"/>
|
||||
</svg>
|
||||
),
|
||||
league: () => (
|
||||
<svg width="80" height="80" viewBox="0 0 100 100" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<circle cx="50" cy="35" r="15" fill="currentColor" opacity="0.3"/>
|
||||
<path d="M35 50 L50 45 L65 50 L65 70 L35 70 Z" fill="currentColor" opacity="0.2"/>
|
||||
<path d="M40 55 L50 52 L60 55" stroke="currentColor" strokeWidth="2" strokeLinecap="round"/>
|
||||
<path d="M40 62 L50 59 L60 62" stroke="currentColor" strokeWidth="2" strokeLinecap="round"/>
|
||||
</svg>
|
||||
),
|
||||
team: () => (
|
||||
<svg width="80" height="80" viewBox="0 0 100 100" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<circle cx="35" cy="35" r="8" fill="currentColor" opacity="0.3"/>
|
||||
<circle cx="65" cy="35" r="8" fill="currentColor" opacity="0.3"/>
|
||||
<circle cx="50" cy="55" r="10" fill="currentColor" opacity="0.2"/>
|
||||
<path d="M35 45 L35 60 M65 45 L65 60 M50 65 L50 80" stroke="currentColor" strokeWidth="3" strokeLinecap="round"/>
|
||||
</svg>
|
||||
),
|
||||
sponsor: () => (
|
||||
<svg width="80" height="80" viewBox="0 0 100 100" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<rect x="25" y="25" width="50" height="50" rx="8" fill="currentColor" opacity="0.2"/>
|
||||
<path d="M35 50 L45 60 L65 40" stroke="currentColor" strokeWidth="3" strokeLinecap="round" strokeLinejoin="round"/>
|
||||
<path d="M50 35 L50 65 M40 50 L60 50" stroke="currentColor" strokeWidth="2" opacity="0.5"/>
|
||||
</svg>
|
||||
),
|
||||
driver: () => (
|
||||
<svg width="80" height="80" viewBox="0 0 100 100" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<circle cx="50" cy="30" r="8" fill="currentColor" opacity="0.3"/>
|
||||
<path d="M42 38 L58 38 L55 55 L45 55 Z" fill="currentColor" opacity="0.2"/>
|
||||
<path d="M45 55 L40 70 M55 55 L60 70" stroke="currentColor" strokeWidth="3" strokeLinecap="round"/>
|
||||
<circle cx="40" cy="72" r="3" fill="currentColor"/>
|
||||
<circle cx="60" cy="72" r="3" fill="currentColor"/>
|
||||
</svg>
|
||||
),
|
||||
} as const;
|
||||
|
||||
export function EmptyState({
|
||||
icon: Icon,
|
||||
title,
|
||||
description,
|
||||
action,
|
||||
variant = 'default',
|
||||
illustration,
|
||||
ariaLabel = 'Empty state',
|
||||
children,
|
||||
}: EmptyStateProps & { children?: React.ReactNode }) {
|
||||
const IllustrationComponent = illustration ? Illustrations[illustration] : null;
|
||||
|
||||
const content = (
|
||||
<Box display="flex" flexDirection="col" alignItems="center" gap={4} textAlign="center">
|
||||
<Box>
|
||||
{IllustrationComponent ? (
|
||||
<Box color="var(--ui-color-text-low)">
|
||||
<IllustrationComponent />
|
||||
</Box>
|
||||
) : Icon ? (
|
||||
<Box padding={4} rounded="xl" bg="var(--ui-color-bg-surface-muted)" style={{ border: '1px solid var(--ui-color-border-default)' }}>
|
||||
<Icon size={32} color="var(--ui-color-text-low)" />
|
||||
</Box>
|
||||
) : null}
|
||||
</Box>
|
||||
|
||||
<Heading level={3} weight="semibold">{title}</Heading>
|
||||
|
||||
{description && (
|
||||
<Text variant="low" leading="relaxed">
|
||||
{description}
|
||||
</Text>
|
||||
)}
|
||||
|
||||
{action && (
|
||||
<Box marginTop={2}>
|
||||
<Button
|
||||
variant={action.variant || 'primary'}
|
||||
onClick={action.onClick}
|
||||
style={{ minWidth: '140px' }}
|
||||
>
|
||||
{action.label}
|
||||
</Button>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{children && (
|
||||
<Box marginTop={4}>
|
||||
{children}
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
|
||||
if (variant === 'full-page') {
|
||||
return (
|
||||
<Box
|
||||
position="fixed"
|
||||
inset={0}
|
||||
bg="var(--ui-color-bg-base)"
|
||||
display="flex"
|
||||
alignItems="center"
|
||||
justifyContent="center"
|
||||
padding={6}
|
||||
role="status"
|
||||
aria-label={ariaLabel}
|
||||
>
|
||||
<Box maxWidth="32rem" fullWidth>
|
||||
{content}
|
||||
<Box marginTop={8} textAlign="center">
|
||||
<Text size="sm" variant="low">
|
||||
Need help? Contact us at{' '}
|
||||
<Link href="mailto:support@gridpilot.com" variant="primary">
|
||||
support@gridpilot.com
|
||||
</Link>
|
||||
</Text>
|
||||
</Box>
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Box
|
||||
paddingY={variant === 'minimal' ? 8 : 12}
|
||||
display="flex"
|
||||
alignItems="center"
|
||||
justifyContent="center"
|
||||
role="status"
|
||||
aria-label={ariaLabel}
|
||||
>
|
||||
<Box maxWidth={variant === 'minimal' ? '24rem' : '32rem'} fullWidth>
|
||||
{content}
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
241
apps/website/ui/ErrorDisplay.tsx
Normal file
241
apps/website/ui/ErrorDisplay.tsx
Normal file
@@ -0,0 +1,241 @@
|
||||
import { ApiError } from '@/lib/api/base/ApiError';
|
||||
import { Button } from '@/ui/Button';
|
||||
import { Heading } from '@/ui/Heading';
|
||||
import { Icon } from '@/ui/Icon';
|
||||
import { Box } from '@/ui/primitives/Box';
|
||||
import { Surface } from '@/ui/primitives/Surface';
|
||||
import { ErrorDisplayAction, ErrorDisplayProps } from '@/ui/state-types';
|
||||
import { Text } from '@/ui/Text';
|
||||
import { AlertCircle, ArrowLeft, Home, RefreshCw } from 'lucide-react';
|
||||
import React from 'react';
|
||||
|
||||
export function ErrorDisplay({
|
||||
error,
|
||||
onRetry,
|
||||
variant = 'full-screen',
|
||||
actions = [],
|
||||
showRetry = true,
|
||||
showNavigation = true,
|
||||
hideTechnicalDetails = false,
|
||||
}: ErrorDisplayProps) {
|
||||
const getErrorInfo = () => {
|
||||
const isApiError = error instanceof ApiError;
|
||||
|
||||
return {
|
||||
title: isApiError ? 'API Error' : 'Unexpected Error',
|
||||
message: error.message || 'Something went wrong',
|
||||
statusCode: isApiError ? error.context.statusCode : undefined,
|
||||
details: isApiError ? error.context.responseText : undefined,
|
||||
isApiError,
|
||||
};
|
||||
};
|
||||
|
||||
const errorInfo = getErrorInfo();
|
||||
|
||||
const defaultActions: ErrorDisplayAction[] = [
|
||||
...(showRetry && onRetry ? [{ label: 'Retry', onClick: onRetry, variant: 'primary' as const, icon: RefreshCw }] : []),
|
||||
...(showNavigation ? [
|
||||
{ label: 'Go Back', onClick: () => window.history.back(), variant: 'secondary' as const, icon: ArrowLeft },
|
||||
{ label: 'Home', onClick: () => window.location.href = '/', variant: 'secondary' as const, icon: Home },
|
||||
] : []),
|
||||
...actions,
|
||||
];
|
||||
|
||||
const ErrorIcon = () => (
|
||||
<Box
|
||||
display="flex"
|
||||
width={20}
|
||||
height={20}
|
||||
alignItems="center"
|
||||
justifyContent="center"
|
||||
rounded="xl"
|
||||
bg="rgba(227, 92, 92, 0.1)"
|
||||
style={{ border: '1px solid rgba(227, 92, 92, 0.3)' }}
|
||||
>
|
||||
<Icon icon={AlertCircle} size={10} intent="critical" />
|
||||
</Box>
|
||||
);
|
||||
|
||||
switch (variant) {
|
||||
case 'full-screen':
|
||||
return (
|
||||
<Box
|
||||
position="fixed"
|
||||
inset={0}
|
||||
zIndex={100}
|
||||
bg="var(--ui-color-bg-base)"
|
||||
display="flex"
|
||||
alignItems="center"
|
||||
justifyContent="center"
|
||||
padding={6}
|
||||
role="alert"
|
||||
>
|
||||
<Box maxWidth="32rem" fullWidth textAlign="center">
|
||||
<Box display="flex" justifyContent="center" marginBottom={6}>
|
||||
<ErrorIcon />
|
||||
</Box>
|
||||
|
||||
<Heading level={2} marginBottom={3}>
|
||||
{errorInfo.title}
|
||||
</Heading>
|
||||
|
||||
<Text size="lg" variant="low" block marginBottom={6} leading="relaxed">
|
||||
{errorInfo.message}
|
||||
</Text>
|
||||
|
||||
{errorInfo.isApiError && errorInfo.statusCode && (
|
||||
<Box marginBottom={6} display="inline-flex" alignItems="center" gap={2} paddingX={4} paddingY={2} bg="var(--ui-color-bg-surface-muted)" rounded="lg">
|
||||
<Text size="sm" variant="med" font="mono">HTTP {errorInfo.statusCode}</Text>
|
||||
{errorInfo.details && !hideTechnicalDetails && (
|
||||
<Text size="sm" variant="low">- {errorInfo.details}</Text>
|
||||
)}
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{defaultActions.length > 0 && (
|
||||
<Box display="flex" flexDirection={{ base: 'col', md: 'row' }} gap={3} justifyContent="center">
|
||||
{defaultActions.map((action, index) => (
|
||||
<Button
|
||||
key={index}
|
||||
onClick={action.onClick}
|
||||
variant={action.variant === 'primary' ? 'danger' : 'secondary'}
|
||||
icon={action.icon && <Icon icon={action.icon} size={4} />}
|
||||
>
|
||||
{action.label}
|
||||
</Button>
|
||||
))}
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{!hideTechnicalDetails && process.env.NODE_ENV === 'development' && error.stack && (
|
||||
<Box marginTop={8} textAlign="left">
|
||||
<details style={{ cursor: 'pointer' }}>
|
||||
<summary>
|
||||
<Text as="span" size="sm" variant="low">Technical Details</Text>
|
||||
</summary>
|
||||
<Box marginTop={2} padding={4} bg="var(--ui-color-bg-surface-muted)" rounded="lg" style={{ overflowX: 'auto' }}>
|
||||
<Text as="pre" size="xs" variant="low" font="mono">
|
||||
{error.stack}
|
||||
</Text>
|
||||
</Box>
|
||||
</details>
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
|
||||
case 'card':
|
||||
return (
|
||||
<Surface
|
||||
variant="muted"
|
||||
rounded="xl"
|
||||
padding={6}
|
||||
style={{ border: '1px solid rgba(227, 92, 92, 0.3)' }}
|
||||
role="alert"
|
||||
>
|
||||
<Box display="flex" gap={4} alignItems="start">
|
||||
<Icon icon={AlertCircle} size={6} intent="critical" />
|
||||
<Box flex={1}>
|
||||
<Heading level={3} marginBottom={1}>
|
||||
{errorInfo.title}
|
||||
</Heading>
|
||||
<Text size="sm" variant="low" block marginBottom={3}>
|
||||
{errorInfo.message}
|
||||
</Text>
|
||||
|
||||
{errorInfo.isApiError && errorInfo.statusCode && (
|
||||
<Text size="xs" variant="low" font="mono" block marginBottom={3}>
|
||||
HTTP {errorInfo.statusCode}
|
||||
{errorInfo.details && !hideTechnicalDetails && ` - ${errorInfo.details}`}
|
||||
</Text>
|
||||
)}
|
||||
|
||||
{defaultActions.length > 0 && (
|
||||
<Box display="flex" gap={2}>
|
||||
{defaultActions.map((action, index) => (
|
||||
<Button
|
||||
key={index}
|
||||
onClick={action.onClick}
|
||||
variant={action.variant === 'primary' ? 'danger' : 'secondary'}
|
||||
size="sm"
|
||||
>
|
||||
{action.label}
|
||||
</Button>
|
||||
))}
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
</Box>
|
||||
</Surface>
|
||||
);
|
||||
|
||||
case 'inline':
|
||||
return (
|
||||
<Box
|
||||
display="inline-flex"
|
||||
alignItems="center"
|
||||
gap={2}
|
||||
paddingX={3}
|
||||
paddingY={2}
|
||||
bg="rgba(227, 92, 92, 0.1)"
|
||||
rounded="lg"
|
||||
style={{ border: '1px solid rgba(227, 92, 92, 0.3)' }}
|
||||
role="alert"
|
||||
>
|
||||
<Icon icon={AlertCircle} size={4} intent="critical" />
|
||||
<Text size="sm" variant="critical">{errorInfo.message}</Text>
|
||||
{onRetry && showRetry && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
onClick={onRetry}
|
||||
size="sm"
|
||||
style={{ marginLeft: '0.5rem', padding: 0, height: 'auto' }}
|
||||
>
|
||||
Retry
|
||||
</Button>
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export function ApiErrorDisplay({
|
||||
error,
|
||||
onRetry,
|
||||
variant = 'full-screen',
|
||||
hideTechnicalDetails = false,
|
||||
}: {
|
||||
error: ApiError;
|
||||
onRetry?: () => void;
|
||||
variant?: 'full-screen' | 'card' | 'inline';
|
||||
hideTechnicalDetails?: boolean;
|
||||
}) {
|
||||
return (
|
||||
<ErrorDisplay
|
||||
error={error}
|
||||
onRetry={onRetry}
|
||||
variant={variant}
|
||||
hideTechnicalDetails={hideTechnicalDetails}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export function NetworkErrorDisplay({
|
||||
onRetry,
|
||||
variant = 'full-screen',
|
||||
}: {
|
||||
onRetry?: () => void;
|
||||
variant?: 'full-screen' | 'card' | 'inline';
|
||||
}) {
|
||||
return (
|
||||
<ErrorDisplay
|
||||
error={new Error('Network connection failed. Please check your internet connection.')}
|
||||
onRetry={onRetry}
|
||||
variant={variant}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -4,9 +4,17 @@ import { Surface } from './primitives/Surface';
|
||||
|
||||
export interface ErrorPageContainerProps {
|
||||
children: ReactNode;
|
||||
size?: 'sm' | 'md' | 'lg';
|
||||
variant?: 'default' | 'glass';
|
||||
}
|
||||
|
||||
export const ErrorPageContainer = ({ children }: ErrorPageContainerProps) => {
|
||||
export const ErrorPageContainer = ({ children, size = 'md', variant = 'default' }: ErrorPageContainerProps) => {
|
||||
const sizeMap = {
|
||||
sm: '24rem',
|
||||
md: '32rem',
|
||||
lg: '42rem',
|
||||
};
|
||||
|
||||
return (
|
||||
<Box
|
||||
minHeight="100vh"
|
||||
@@ -15,8 +23,10 @@ export const ErrorPageContainer = ({ children }: ErrorPageContainerProps) => {
|
||||
justifyContent="center"
|
||||
padding={4}
|
||||
bg="var(--ui-color-bg-base)"
|
||||
position="relative"
|
||||
overflow="hidden"
|
||||
>
|
||||
<Surface variant="default" rounded="xl" padding={8} style={{ maxWidth: '32rem', width: '100%', border: '1px solid var(--ui-color-border-default)' }}>
|
||||
<Surface variant={variant} rounded="xl" padding={8} style={{ maxWidth: sizeMap[size], width: '100%', border: '1px solid var(--ui-color-border-default)', position: 'relative', zIndex: 10 }}>
|
||||
{children}
|
||||
</Surface>
|
||||
</Box>
|
||||
|
||||
27
apps/website/ui/FloatingAction.tsx
Normal file
27
apps/website/ui/FloatingAction.tsx
Normal file
@@ -0,0 +1,27 @@
|
||||
import React, { ReactNode } from 'react';
|
||||
import { Box } from './primitives/Box';
|
||||
import { Button, ButtonProps } from './Button';
|
||||
|
||||
export interface FloatingActionProps extends ButtonProps {
|
||||
position?: 'bottom-left' | 'bottom-right' | 'top-left' | 'top-right';
|
||||
title?: string;
|
||||
}
|
||||
|
||||
export const FloatingAction = ({
|
||||
position = 'bottom-left',
|
||||
title,
|
||||
...props
|
||||
}: FloatingActionProps) => {
|
||||
const positionMap = {
|
||||
'bottom-left': { bottom: '1rem', left: '1rem' },
|
||||
'bottom-right': { bottom: '1rem', right: '1rem' },
|
||||
'top-left': { top: '1rem', left: '1rem' },
|
||||
'top-right': { top: '1rem', right: '1rem' },
|
||||
};
|
||||
|
||||
return (
|
||||
<Box position="fixed" style={{ ...positionMap[position], zIndex: 100 }}>
|
||||
<Button rounded title={title} {...props} />
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
19
apps/website/ui/FooterSection.tsx
Normal file
19
apps/website/ui/FooterSection.tsx
Normal file
@@ -0,0 +1,19 @@
|
||||
import React, { ReactNode } from 'react';
|
||||
import { Box } from './primitives/Box';
|
||||
|
||||
export interface FooterSectionProps {
|
||||
children: ReactNode;
|
||||
border?: boolean;
|
||||
}
|
||||
|
||||
export const FooterSection = ({ children, border = true }: FooterSectionProps) => {
|
||||
return (
|
||||
<Box
|
||||
marginTop={8}
|
||||
paddingTop={8}
|
||||
style={border ? { borderTop: '1px solid var(--ui-color-border-muted)', opacity: 0.8 } : {}}
|
||||
>
|
||||
{children}
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
38
apps/website/ui/Grid.tsx
Normal file
38
apps/website/ui/Grid.tsx
Normal file
@@ -0,0 +1,38 @@
|
||||
import React, { ReactNode } from 'react';
|
||||
import { Box } from './primitives/Box';
|
||||
|
||||
export interface GridProps {
|
||||
children: ReactNode;
|
||||
cols?: number | { base?: number; sm?: number; md?: number; lg?: number; xl?: number };
|
||||
gap?: number;
|
||||
fullWidth?: boolean;
|
||||
}
|
||||
|
||||
export const Grid = ({
|
||||
children,
|
||||
cols = 1,
|
||||
gap = 0,
|
||||
fullWidth = false
|
||||
}: GridProps) => {
|
||||
const getGridColsClass = (value: number | { base?: number; sm?: number; md?: number; lg?: number; xl?: number }) => {
|
||||
if (typeof value === 'number') return `grid-cols-${value}`;
|
||||
const classes = [];
|
||||
if (value.base) classes.push(`grid-cols-${value.base}`);
|
||||
if (value.sm) classes.push(`sm:grid-cols-${value.sm}`);
|
||||
if (value.md) classes.push(`md:grid-cols-${value.md}`);
|
||||
if (value.lg) classes.push(`lg:grid-cols-${value.lg}`);
|
||||
if (value.xl) classes.push(`xl:grid-cols-${value.xl}`);
|
||||
return classes.join(' ');
|
||||
};
|
||||
|
||||
return (
|
||||
<Box
|
||||
display="grid"
|
||||
className={getGridColsClass(cols)}
|
||||
gap={gap}
|
||||
fullWidth={fullWidth}
|
||||
>
|
||||
{children}
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
@@ -6,7 +6,7 @@ export interface IconProps extends Omit<BoxProps<'div'>, 'children'> {
|
||||
icon: LucideIcon | React.ReactNode;
|
||||
size?: 3 | 3.5 | 4 | 5 | 6 | 7 | 8 | 10 | 12 | 16 | 'full' | number;
|
||||
intent?: 'primary' | 'telemetry' | 'warning' | 'success' | 'critical' | 'high' | 'med' | 'low';
|
||||
animate?: 'spin' | 'none';
|
||||
animate?: 'spin' | 'pulse' | 'none';
|
||||
}
|
||||
|
||||
export function Icon({
|
||||
@@ -58,6 +58,8 @@ export function Icon({
|
||||
return IconProp;
|
||||
};
|
||||
|
||||
const animationClass = animate === 'spin' ? 'animate-spin' : (animate === 'pulse' ? 'animate-pulse' : '');
|
||||
|
||||
return (
|
||||
<Box
|
||||
display="inline-flex"
|
||||
@@ -66,7 +68,7 @@ export function Icon({
|
||||
style={style}
|
||||
{...props}
|
||||
>
|
||||
<div className={animate === 'spin' ? 'animate-spin w-full h-full flex items-center justify-center' : 'w-full h-full flex items-center justify-center'}>
|
||||
<div className={`${animationClass} w-full h-full flex items-center justify-center`}>
|
||||
{renderIcon()}
|
||||
</div>
|
||||
</Box>
|
||||
|
||||
@@ -1,18 +1,20 @@
|
||||
import React, { MouseEventHandler } from 'react';
|
||||
import React, { forwardRef } from 'react';
|
||||
import { Button, ButtonProps } from './Button';
|
||||
import { Icon } from './Icon';
|
||||
|
||||
export interface IconButtonProps extends Omit<ButtonProps, 'children' | 'icon'> {
|
||||
icon: any;
|
||||
title?: string;
|
||||
intent?: 'primary' | 'success' | 'warning' | 'critical' | 'telemetry' | 'low' | 'high';
|
||||
}
|
||||
|
||||
export const IconButton = ({
|
||||
export const IconButton = forwardRef<HTMLButtonElement | HTMLAnchorElement, IconButtonProps>(({
|
||||
icon,
|
||||
title,
|
||||
size = 'md',
|
||||
intent,
|
||||
...props
|
||||
}: IconButtonProps) => {
|
||||
}, ref) => {
|
||||
const iconSizeMap = {
|
||||
sm: 3,
|
||||
md: 4,
|
||||
@@ -21,11 +23,14 @@ export const IconButton = ({
|
||||
|
||||
return (
|
||||
<Button
|
||||
ref={ref}
|
||||
size={size}
|
||||
{...props}
|
||||
>
|
||||
<Icon icon={icon} size={iconSizeMap[size]} />
|
||||
<Icon icon={icon} size={iconSizeMap[size]} intent={intent} />
|
||||
{title && <span className="sr-only">{title}</span>}
|
||||
</Button>
|
||||
);
|
||||
};
|
||||
});
|
||||
|
||||
IconButton.displayName = 'IconButton';
|
||||
|
||||
@@ -1,39 +1,57 @@
|
||||
import React from 'react';
|
||||
import { Box } from './primitives/Box';
|
||||
import { Icon } from './Icon';
|
||||
import { Image as ImageIcon } from 'lucide-react';
|
||||
import { Image as ImageIcon, AlertCircle, Loader2 } from 'lucide-react';
|
||||
import { Text } from './Text';
|
||||
|
||||
export interface ImagePlaceholderProps {
|
||||
width?: string | number;
|
||||
height?: string | number;
|
||||
animate?: 'pulse' | 'none' | 'spin';
|
||||
aspectRatio?: string;
|
||||
variant?: 'default' | 'loading' | 'error';
|
||||
message?: string;
|
||||
}
|
||||
|
||||
export const ImagePlaceholder = ({
|
||||
width = '100%',
|
||||
height = '100%',
|
||||
animate = 'pulse',
|
||||
aspectRatio
|
||||
aspectRatio,
|
||||
variant = 'default',
|
||||
message
|
||||
}: ImagePlaceholderProps) => {
|
||||
const icons = {
|
||||
default: ImageIcon,
|
||||
loading: Loader2,
|
||||
error: AlertCircle,
|
||||
};
|
||||
|
||||
return (
|
||||
<Box
|
||||
width={width}
|
||||
height={height}
|
||||
aspectRatio={aspectRatio}
|
||||
display="flex"
|
||||
flexDirection="col"
|
||||
alignItems="center"
|
||||
justifyContent="center"
|
||||
bg="var(--ui-color-bg-surface-muted)"
|
||||
style={{ borderRadius: 'var(--ui-radius-md)' }}
|
||||
gap={2}
|
||||
>
|
||||
<Icon
|
||||
icon={ImageIcon}
|
||||
icon={icons[variant]}
|
||||
size={8}
|
||||
intent="low"
|
||||
animate={animate === 'spin' ? 'spin' : 'none'}
|
||||
className={animate === 'pulse' ? 'animate-pulse' : ''}
|
||||
animate={variant === 'loading' ? 'spin' : (animate === 'spin' ? 'spin' : 'none')}
|
||||
className={animate === 'pulse' && variant !== 'loading' ? 'animate-pulse' : ''}
|
||||
/>
|
||||
{message && (
|
||||
<Text size="xs" variant="low" align="center" paddingX={4}>
|
||||
{message}
|
||||
</Text>
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
64
apps/website/ui/InfoFlyout.tsx
Normal file
64
apps/website/ui/InfoFlyout.tsx
Normal file
@@ -0,0 +1,64 @@
|
||||
import React, { ReactNode, useEffect, useRef, useState } from 'react';
|
||||
import { createPortal } from 'react-dom';
|
||||
import { Box } from './primitives/Box';
|
||||
import { Surface } from './primitives/Surface';
|
||||
import { Text } from './Text';
|
||||
import { Icon } from './Icon';
|
||||
import { IconButton } from './IconButton';
|
||||
import { HelpCircle, X } from 'lucide-react';
|
||||
import { Heading } from './Heading';
|
||||
|
||||
export interface InfoFlyoutProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
title: string;
|
||||
children: ReactNode;
|
||||
anchorRef: React.RefObject<HTMLElement>;
|
||||
}
|
||||
|
||||
export const InfoFlyout = ({ isOpen, onClose, title, children, anchorRef }: InfoFlyoutProps) => {
|
||||
const [position, setPosition] = useState({ top: 0, left: 0 });
|
||||
const flyoutRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (isOpen && anchorRef.current) {
|
||||
const rect = anchorRef.current.getBoundingClientRect();
|
||||
const flyoutWidth = 380;
|
||||
const padding = 16;
|
||||
|
||||
let left = rect.right + 12;
|
||||
let top = rect.top;
|
||||
|
||||
if (left + flyoutWidth > window.innerWidth - padding) {
|
||||
left = rect.left - flyoutWidth - 12;
|
||||
}
|
||||
|
||||
setPosition({ top, left });
|
||||
}
|
||||
}, [isOpen, anchorRef]);
|
||||
|
||||
if (!isOpen) return null;
|
||||
|
||||
return createPortal(
|
||||
<Box
|
||||
ref={flyoutRef as any}
|
||||
position="fixed"
|
||||
zIndex={100}
|
||||
style={{ top: position.top, left: position.left, width: '24rem' }}
|
||||
>
|
||||
<Surface variant="muted" rounded="xl" shadow="xl" style={{ border: '1px solid var(--ui-color-border-default)', overflow: 'hidden' }}>
|
||||
<Box display="flex" alignItems="center" justifyContent="between" padding={4} bg="var(--ui-color-bg-surface-muted)" style={{ borderBottom: '1px solid var(--ui-color-border-default)' }}>
|
||||
<Box display="flex" alignItems="center" gap={2}>
|
||||
<Icon icon={HelpCircle} size={4} intent="primary" />
|
||||
<Heading level={6}>{title}</Heading>
|
||||
</Box>
|
||||
<IconButton icon={X} size="sm" variant="ghost" onClick={onClose} title="Close" />
|
||||
</Box>
|
||||
<Box padding={4} style={{ maxHeight: '20rem', overflowY: 'auto' }}>
|
||||
{children}
|
||||
</Box>
|
||||
</Surface>
|
||||
</Box>,
|
||||
document.body
|
||||
);
|
||||
};
|
||||
61
apps/website/ui/InlineNotice.tsx
Normal file
61
apps/website/ui/InlineNotice.tsx
Normal file
@@ -0,0 +1,61 @@
|
||||
import { Box } from '@/ui/primitives/Box';
|
||||
import { Text } from '@/ui/Text';
|
||||
import { Icon } from '@/ui/Icon';
|
||||
import { AlertCircle, AlertTriangle, CheckCircle, Info } from 'lucide-react';
|
||||
import React from 'react';
|
||||
|
||||
interface InlineNoticeProps {
|
||||
variant?: 'info' | 'success' | 'warning' | 'error';
|
||||
title?: string;
|
||||
message: string;
|
||||
}
|
||||
|
||||
export function InlineNotice({
|
||||
variant = 'info',
|
||||
title,
|
||||
message,
|
||||
}: InlineNoticeProps) {
|
||||
const variants = {
|
||||
info: {
|
||||
intent: 'primary' as const,
|
||||
icon: Info,
|
||||
},
|
||||
success: {
|
||||
intent: 'success' as const,
|
||||
icon: CheckCircle,
|
||||
},
|
||||
warning: {
|
||||
intent: 'warning' as const,
|
||||
icon: AlertTriangle,
|
||||
},
|
||||
error: {
|
||||
intent: 'critical' as const,
|
||||
icon: AlertCircle,
|
||||
},
|
||||
};
|
||||
|
||||
const config = variants[variant];
|
||||
|
||||
return (
|
||||
<Box
|
||||
padding={4}
|
||||
rounded="lg"
|
||||
bg="var(--ui-color-bg-surface-muted)"
|
||||
style={{ border: '1px solid var(--ui-color-border-default)' }}
|
||||
>
|
||||
<Box display="flex" gap={3} alignItems="start">
|
||||
<Icon icon={config.icon} size={5} intent={config.intent} />
|
||||
<Box>
|
||||
{title && (
|
||||
<Text weight="bold" variant="high" block marginBottom={1}>
|
||||
{title}
|
||||
</Text>
|
||||
)}
|
||||
<Text size="sm" variant="med" block>
|
||||
{message}
|
||||
</Text>
|
||||
</Box>
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
@@ -8,6 +8,7 @@ export interface InputProps extends Omit<InputHTMLAttributes<HTMLInputElement>,
|
||||
hint?: string;
|
||||
fullWidth?: boolean;
|
||||
size?: 'sm' | 'md' | 'lg';
|
||||
icon?: React.ReactNode;
|
||||
}
|
||||
|
||||
export const Input = forwardRef<HTMLInputElement, InputProps>(({
|
||||
@@ -16,6 +17,7 @@ export const Input = forwardRef<HTMLInputElement, InputProps>(({
|
||||
hint,
|
||||
fullWidth = false,
|
||||
size = 'md',
|
||||
icon,
|
||||
...props
|
||||
}, ref) => {
|
||||
const sizeClasses = {
|
||||
@@ -33,6 +35,7 @@ export const Input = forwardRef<HTMLInputElement, InputProps>(({
|
||||
sizeClasses[size],
|
||||
errorClasses,
|
||||
widthClasses,
|
||||
icon ? 'pl-10' : '',
|
||||
].filter(Boolean).join(' ');
|
||||
|
||||
return (
|
||||
@@ -44,11 +47,18 @@ export const Input = forwardRef<HTMLInputElement, InputProps>(({
|
||||
</Text>
|
||||
</Box>
|
||||
)}
|
||||
<input
|
||||
ref={ref}
|
||||
className={classes}
|
||||
{...props}
|
||||
/>
|
||||
<Box position="relative">
|
||||
{icon && (
|
||||
<Box position="absolute" left={3} top="50%" style={{ transform: 'translateY(-50%)' }}>
|
||||
{icon}
|
||||
</Box>
|
||||
)}
|
||||
<input
|
||||
ref={ref}
|
||||
className={classes}
|
||||
{...props}
|
||||
/>
|
||||
</Box>
|
||||
{error && (
|
||||
<Box marginTop={1}>
|
||||
<Text size="xs" variant="critical">
|
||||
|
||||
83
apps/website/ui/LeagueCard.tsx
Normal file
83
apps/website/ui/LeagueCard.tsx
Normal file
@@ -0,0 +1,83 @@
|
||||
import React, { ReactNode } from 'react';
|
||||
import { Card } from './Card';
|
||||
import { Box } from './primitives/Box';
|
||||
import { Text } from './Text';
|
||||
import { Heading } from './Heading';
|
||||
import { Icon } from './Icon';
|
||||
import { LucideIcon, ChevronRight } from 'lucide-react';
|
||||
import { Image } from './Image';
|
||||
|
||||
export interface LeagueCardProps {
|
||||
children: ReactNode;
|
||||
onClick?: () => void;
|
||||
coverUrl: string;
|
||||
logo?: ReactNode;
|
||||
badges?: ReactNode;
|
||||
}
|
||||
|
||||
export const LeagueCard = ({ children, onClick, coverUrl, logo, badges }: LeagueCardProps) => {
|
||||
return (
|
||||
<Card
|
||||
variant="dark"
|
||||
onClick={onClick}
|
||||
style={{ position: 'relative', cursor: onClick ? 'pointer' : 'default', overflow: 'hidden', height: '100%' }}
|
||||
>
|
||||
<Box height="8rem" position="relative" style={{ overflow: 'hidden' }}>
|
||||
<Image src={coverUrl} alt="Cover" fullWidth fullHeight objectFit="cover" style={{ opacity: 0.6 }} />
|
||||
<Box position="absolute" inset={0} style={{ background: 'linear-gradient(to top, var(--ui-color-bg-base), transparent)' }} />
|
||||
<Box position="absolute" top={3} left={3} display="flex" gap={2}>
|
||||
{badges}
|
||||
</Box>
|
||||
{logo && (
|
||||
<Box position="absolute" left={4} bottom={-6} zIndex={10}>
|
||||
{logo}
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
<Box padding={4} paddingTop={8} display="flex" flexDirection="col" fullHeight>
|
||||
{children}
|
||||
</Box>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
|
||||
export interface LeagueCardStatsProps {
|
||||
label: string;
|
||||
value: string;
|
||||
percentage: number;
|
||||
intent?: 'primary' | 'success' | 'warning';
|
||||
}
|
||||
|
||||
export const LeagueCardStats = ({ label, value, percentage, intent = 'primary' }: LeagueCardStatsProps) => {
|
||||
const intentColors = {
|
||||
primary: 'var(--ui-color-intent-primary)',
|
||||
success: 'var(--ui-color-intent-success)',
|
||||
warning: 'var(--ui-color-intent-warning)',
|
||||
};
|
||||
|
||||
return (
|
||||
<Box marginBottom={4}>
|
||||
<Box display="flex" alignItems="center" justifyContent="between" marginBottom={1.5}>
|
||||
<Text size="xs" variant="low" weight="bold" uppercase>{label}</Text>
|
||||
<Text size="xs" variant="med" font="mono">{value}</Text>
|
||||
</Box>
|
||||
<Box height="4px" bg="var(--ui-color-bg-surface-muted)" rounded="full" style={{ overflow: 'hidden' }}>
|
||||
<Box height="100%" bg={intentColors[intent]} style={{ width: `${Math.min(percentage, 100)}%` }} />
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
export interface LeagueCardFooterProps {
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
export const LeagueCardFooter = ({ children }: LeagueCardFooterProps) => (
|
||||
<Box marginTop="auto" paddingTop={3} style={{ borderTop: '1px solid var(--ui-color-border-muted)', opacity: 0.5 }} display="flex" alignItems="center" justifyContent="between">
|
||||
{children}
|
||||
<Box display="flex" alignItems="center" gap={1}>
|
||||
<Text size="xs" variant="low" weight="bold" uppercase>VIEW</Text>
|
||||
<Icon icon={ChevronRight} size={3} intent="low" />
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
51
apps/website/ui/ListItem.tsx
Normal file
51
apps/website/ui/ListItem.tsx
Normal file
@@ -0,0 +1,51 @@
|
||||
import React, { ReactNode } from 'react';
|
||||
import { Box } from './primitives/Box';
|
||||
import { Card } from './Card';
|
||||
import { Text } from './Text';
|
||||
|
||||
export interface ListItemProps {
|
||||
children: ReactNode;
|
||||
onClick?: () => void;
|
||||
}
|
||||
|
||||
export const ListItem = ({ children, onClick }: ListItemProps) => {
|
||||
return (
|
||||
<Card
|
||||
variant="dark"
|
||||
onClick={onClick}
|
||||
style={{ cursor: onClick ? 'pointer' : 'default' }}
|
||||
>
|
||||
<Box display="flex" alignItems="center" justifyContent="between" gap={4}>
|
||||
{children}
|
||||
</Box>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
|
||||
export interface ListItemInfoProps {
|
||||
title: string;
|
||||
description?: string;
|
||||
meta?: ReactNode;
|
||||
}
|
||||
|
||||
export const ListItemInfo = ({ title, description, meta }: ListItemInfoProps) => (
|
||||
<Box flex={1} minWidth="0">
|
||||
<Text weight="bold" variant="high" block truncate>{title}</Text>
|
||||
{description && (
|
||||
<Text size="xs" variant="low" block marginTop={1} lineClamp={2}>
|
||||
{description}
|
||||
</Text>
|
||||
)}
|
||||
{meta && <Box marginTop={2}>{meta}</Box>}
|
||||
</Box>
|
||||
);
|
||||
|
||||
export interface ListItemActionsProps {
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
export const ListItemActions = ({ children }: ListItemActionsProps) => (
|
||||
<Box display="flex" alignItems="center" gap={2} flexShrink={0}>
|
||||
{children}
|
||||
</Box>
|
||||
);
|
||||
121
apps/website/ui/LoadingWrapper.tsx
Normal file
121
apps/website/ui/LoadingWrapper.tsx
Normal file
@@ -0,0 +1,121 @@
|
||||
import { Box } from '@/ui/primitives/Box';
|
||||
import { LoadingWrapperProps } from '@/ui/state-types';
|
||||
import { Text } from '@/ui/Text';
|
||||
import React from 'react';
|
||||
|
||||
export function LoadingWrapper({
|
||||
variant = 'spinner',
|
||||
message = 'Loading...',
|
||||
size = 'md',
|
||||
skeletonCount = 3,
|
||||
cardConfig,
|
||||
ariaLabel = 'Loading content',
|
||||
}: LoadingWrapperProps) {
|
||||
const sizeMap = {
|
||||
sm: { spinner: '1rem', inline: 'xs' as const, card: '6rem' },
|
||||
md: { spinner: '2.5rem', inline: 'sm' as const, card: '8rem' },
|
||||
lg: { spinner: '4rem', inline: 'base' as const, card: '10rem' },
|
||||
};
|
||||
|
||||
const spinnerSize = sizeMap[size].spinner;
|
||||
const inlineSize = sizeMap[size].inline;
|
||||
const cardHeight = cardConfig?.height || sizeMap[size].card;
|
||||
|
||||
const Spinner = ({ size: s }: { size: string }) => (
|
||||
<Box
|
||||
style={{
|
||||
width: s,
|
||||
height: s,
|
||||
border: '2px solid var(--ui-color-intent-primary)',
|
||||
borderTopColor: 'transparent',
|
||||
borderRadius: '9999px'
|
||||
}}
|
||||
className="animate-spin"
|
||||
/>
|
||||
);
|
||||
|
||||
switch (variant) {
|
||||
case 'spinner':
|
||||
return (
|
||||
<Box display="flex" flexDirection="col" alignItems="center" justifyContent="center" minHeight="12rem" role="status" aria-label={ariaLabel}>
|
||||
<Spinner size={spinnerSize} />
|
||||
<Box marginTop={3}>
|
||||
<Text variant="low" size="sm">{message}</Text>
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
|
||||
case 'skeleton':
|
||||
return (
|
||||
<Box display="flex" flexDirection="col" gap={3} role="status" aria-label={ariaLabel}>
|
||||
{Array.from({ length: skeletonCount }).map((_, index) => (
|
||||
<Box
|
||||
key={index}
|
||||
fullWidth
|
||||
bg="var(--ui-color-bg-surface-muted)"
|
||||
rounded="lg"
|
||||
style={{ height: cardHeight, opacity: 0.5 }}
|
||||
className="animate-pulse"
|
||||
/>
|
||||
))}
|
||||
</Box>
|
||||
);
|
||||
|
||||
case 'full-screen':
|
||||
return (
|
||||
<Box position="fixed" inset={0} zIndex={100} bg="var(--ui-color-bg-base)" display="flex" alignItems="center" justifyContent="center" padding={6} role="status" aria-label={ariaLabel}>
|
||||
<Box textAlign="center">
|
||||
<Box display="flex" justifyContent="center" marginBottom={4}>
|
||||
<Spinner size="4rem" />
|
||||
</Box>
|
||||
<Text variant="high" size="lg" weight="medium" block>{message}</Text>
|
||||
<Text variant="low" size="sm" block marginTop={1}>This may take a moment...</Text>
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
|
||||
case 'inline':
|
||||
return (
|
||||
<Box display="inline-flex" alignItems="center" gap={2} role="status" aria-label={ariaLabel}>
|
||||
<Spinner size="1rem" />
|
||||
<Text variant="low" size={inlineSize}>{message}</Text>
|
||||
</Box>
|
||||
);
|
||||
|
||||
case 'card':
|
||||
const cardCount = cardConfig?.count || 3;
|
||||
return (
|
||||
<Box display="grid" gap={4} role="status" aria-label={ariaLabel}>
|
||||
{Array.from({ length: cardCount }).map((_, index) => (
|
||||
<Box
|
||||
key={index}
|
||||
bg="var(--ui-color-bg-surface-muted)"
|
||||
rounded="xl"
|
||||
style={{ height: cardHeight, border: '1px solid var(--ui-color-border-default)', display: 'flex', alignItems: 'center', justifyContent: 'center' }}
|
||||
>
|
||||
<Spinner size="2rem" />
|
||||
</Box>
|
||||
))}
|
||||
</Box>
|
||||
);
|
||||
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export function FullScreenLoading({ message = 'Loading...' }: Pick<LoadingWrapperProps, 'message'>) {
|
||||
return <LoadingWrapper variant="full-screen" message={message} />;
|
||||
}
|
||||
|
||||
export function InlineLoading({ message = 'Loading...', size = 'sm' }: Pick<LoadingWrapperProps, 'message' | 'size'>) {
|
||||
return <LoadingWrapper variant="inline" message={message} size={size} />;
|
||||
}
|
||||
|
||||
export function SkeletonLoading({ skeletonCount = 3 }: Pick<LoadingWrapperProps, 'skeletonCount'>) {
|
||||
return <LoadingWrapper variant="skeleton" skeletonCount={skeletonCount} />;
|
||||
}
|
||||
|
||||
export function CardLoading({ cardConfig }: Pick<LoadingWrapperProps, 'cardConfig'>) {
|
||||
return <LoadingWrapper variant="card" cardConfig={cardConfig} />;
|
||||
}
|
||||
@@ -1,14 +1,20 @@
|
||||
import React, { ReactNode } from 'react';
|
||||
import { Box } from './primitives/Box';
|
||||
import { Container } from './Container';
|
||||
|
||||
export interface MainContentProps {
|
||||
children: ReactNode;
|
||||
maxWidth?: 'sm' | 'md' | 'lg' | 'xl' | 'full' | '7xl';
|
||||
}
|
||||
|
||||
export const MainContent = ({ children }: MainContentProps) => {
|
||||
export const MainContent = ({ children, maxWidth = 'xl' }: MainContentProps) => {
|
||||
return (
|
||||
<Box as="main" flex={1} display="flex" flexDirection="col" minHeight="0">
|
||||
{children}
|
||||
<Box as="main" flex={1} style={{ overflowY: 'auto' }} padding={6}>
|
||||
<Container size={maxWidth === '7xl' ? 'xl' : maxWidth as any}>
|
||||
<Box display="flex" flexDirection="col" gap={6} fullWidth>
|
||||
{children}
|
||||
</Box>
|
||||
</Container>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
93
apps/website/ui/MediaCard.tsx
Normal file
93
apps/website/ui/MediaCard.tsx
Normal file
@@ -0,0 +1,93 @@
|
||||
import { Card } from '@/ui/Card';
|
||||
import { Image } from '@/ui/Image';
|
||||
import { ImagePlaceholder } from '@/ui/ImagePlaceholder';
|
||||
import { Box } from '@/ui/primitives/Box';
|
||||
import { Text } from '@/ui/Text';
|
||||
import { motion } from 'framer-motion';
|
||||
import React from 'react';
|
||||
|
||||
export interface MediaCardProps {
|
||||
src?: string;
|
||||
alt?: string;
|
||||
title?: string;
|
||||
subtitle?: string;
|
||||
aspectRatio?: string;
|
||||
isLoading?: boolean;
|
||||
error?: string;
|
||||
onClick?: () => void;
|
||||
actions?: React.ReactNode;
|
||||
}
|
||||
|
||||
export function MediaCard({
|
||||
src,
|
||||
alt = 'Media asset',
|
||||
title,
|
||||
subtitle,
|
||||
aspectRatio = '16/9',
|
||||
isLoading,
|
||||
error,
|
||||
onClick,
|
||||
actions,
|
||||
}: MediaCardProps) {
|
||||
return (
|
||||
<motion.div
|
||||
whileHover={{ y: -4 }}
|
||||
transition={{ duration: 0.2, ease: [0.25, 0.1, 0.25, 1] }}
|
||||
style={{ height: '100%' }}
|
||||
>
|
||||
<Card
|
||||
variant="dark"
|
||||
onClick={onClick}
|
||||
padding={0}
|
||||
style={{ height: '100%', cursor: onClick ? 'pointer' : 'default', overflow: 'hidden' }}
|
||||
>
|
||||
<Box position="relative" fullWidth style={{ aspectRatio }}>
|
||||
{isLoading ? (
|
||||
<ImagePlaceholder variant="loading" aspectRatio={aspectRatio} />
|
||||
) : error ? (
|
||||
<ImagePlaceholder variant="error" message={error} aspectRatio={aspectRatio} />
|
||||
) : src ? (
|
||||
<Box fullWidth fullHeight style={{ overflow: 'hidden' }}>
|
||||
<Image
|
||||
src={src}
|
||||
alt={alt}
|
||||
objectFit="cover"
|
||||
/>
|
||||
</Box>
|
||||
) : (
|
||||
<ImagePlaceholder aspectRatio={aspectRatio} />
|
||||
)}
|
||||
|
||||
{actions && (
|
||||
<Box
|
||||
position="absolute"
|
||||
top={2}
|
||||
right={2}
|
||||
display="flex"
|
||||
gap={2}
|
||||
style={{ opacity: 0, transition: 'opacity 0.2s' }}
|
||||
className="group-hover:opacity-100"
|
||||
>
|
||||
{actions}
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
|
||||
{(title || subtitle) && (
|
||||
<Box padding={3} style={{ borderTop: '1px solid var(--ui-color-border-muted)' }}>
|
||||
{title && (
|
||||
<Text block size="sm" weight="bold" truncate variant="high">
|
||||
{title}
|
||||
</Text>
|
||||
)}
|
||||
{subtitle && (
|
||||
<Text block size="xs" variant="low" truncate marginTop={0.5}>
|
||||
{subtitle}
|
||||
</Text>
|
||||
)}
|
||||
</Box>
|
||||
)}
|
||||
</Card>
|
||||
</motion.div>
|
||||
);
|
||||
}
|
||||
@@ -22,6 +22,7 @@ export interface ModalProps {
|
||||
footer?: ReactNode;
|
||||
description?: string;
|
||||
icon?: ReactNode;
|
||||
actions?: ReactNode;
|
||||
}
|
||||
|
||||
export const Modal = ({
|
||||
@@ -37,7 +38,8 @@ export const Modal = ({
|
||||
onSecondaryAction,
|
||||
footer,
|
||||
description,
|
||||
icon
|
||||
icon,
|
||||
actions
|
||||
}: ModalProps) => {
|
||||
useEffect(() => {
|
||||
if (isOpen) {
|
||||
@@ -111,7 +113,10 @@ export const Modal = ({
|
||||
{description && <Box marginTop={1}><Text size="sm" variant="low">{description}</Text></Box>}
|
||||
</Box>
|
||||
</Box>
|
||||
<IconButton icon={X} onClick={handleClose} variant="ghost" title="Close modal" />
|
||||
<Box display="flex" alignItems="center" gap={2}>
|
||||
{actions}
|
||||
<IconButton icon={X} onClick={handleClose} variant="ghost" title="Close modal" />
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
<Box flex={1} overflow="auto" padding={6}>
|
||||
|
||||
29
apps/website/ui/NavGroup.tsx
Normal file
29
apps/website/ui/NavGroup.tsx
Normal file
@@ -0,0 +1,29 @@
|
||||
import React, { ReactNode } from 'react';
|
||||
import { Box } from './primitives/Box';
|
||||
|
||||
export interface NavGroupProps {
|
||||
children: ReactNode;
|
||||
direction?: 'horizontal' | 'vertical';
|
||||
gap?: number;
|
||||
align?: 'start' | 'center' | 'end';
|
||||
}
|
||||
|
||||
export const NavGroup = ({
|
||||
children,
|
||||
direction = 'horizontal',
|
||||
gap = 4,
|
||||
align = 'center'
|
||||
}: NavGroupProps) => {
|
||||
return (
|
||||
<Box
|
||||
as="nav"
|
||||
display="flex"
|
||||
flexDirection={direction === 'horizontal' ? 'row' : 'col'}
|
||||
gap={gap}
|
||||
alignItems={align}
|
||||
justifyContent={direction === 'horizontal' ? 'center' : 'start'}
|
||||
>
|
||||
{children}
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
38
apps/website/ui/NavLink.tsx
Normal file
38
apps/website/ui/NavLink.tsx
Normal file
@@ -0,0 +1,38 @@
|
||||
import { Text } from '@/ui/Text';
|
||||
import { Box } from '@/ui/primitives/Box';
|
||||
import { LucideIcon } from 'lucide-react';
|
||||
import { Link } from '@/ui/Link';
|
||||
import { Icon } from '@/ui/Icon';
|
||||
import React from 'react';
|
||||
|
||||
interface NavLinkProps {
|
||||
href: string;
|
||||
label: string;
|
||||
icon?: LucideIcon;
|
||||
isActive?: boolean;
|
||||
variant?: 'sidebar' | 'top';
|
||||
}
|
||||
|
||||
/**
|
||||
* NavLink provides a consistent link component for navigation.
|
||||
* Supports both sidebar and top navigation variants.
|
||||
*/
|
||||
export function NavLink({ href, label, icon, isActive, variant = 'sidebar' }: NavLinkProps) {
|
||||
const content = (
|
||||
<Box display="flex" alignItems="center" gap={variant === 'top' ? 2 : 3} paddingX={3} paddingY={2} rounded={variant === 'sidebar' ? 'md' : undefined} style={{ transition: 'all 0.2s' }}>
|
||||
{icon && <Icon icon={icon} size={variant === 'top' ? 4 : 5} intent={isActive ? 'primary' : 'low'} />}
|
||||
<Text size="sm" weight={isActive ? 'bold' : 'medium'} variant={isActive ? 'primary' : 'med'}>
|
||||
{label}
|
||||
</Text>
|
||||
{variant === 'sidebar' && isActive && (
|
||||
<Box marginLeft="auto" width="4px" height="1rem" bg="var(--ui-color-intent-primary)" rounded="full" />
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
|
||||
return (
|
||||
<Link href={href} variant="inherit" underline="none" block={variant === 'sidebar'}>
|
||||
{content}
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
34
apps/website/ui/NotificationContent.tsx
Normal file
34
apps/website/ui/NotificationContent.tsx
Normal file
@@ -0,0 +1,34 @@
|
||||
import React, { ReactNode } from 'react';
|
||||
import { Box } from './primitives/Box';
|
||||
import { Text } from './Text';
|
||||
import { Icon } from './Icon';
|
||||
import { LucideIcon } from 'lucide-react';
|
||||
|
||||
export interface NotificationStatProps {
|
||||
label: string;
|
||||
value: string | number;
|
||||
intent?: 'primary' | 'success' | 'critical' | 'low';
|
||||
}
|
||||
|
||||
export const NotificationStat = ({ label, value, intent = 'low' }: NotificationStatProps) => (
|
||||
<Box bg="var(--ui-color-bg-base)" padding={4} style={{ border: '1px solid var(--ui-color-border-default)', borderRadius: 'var(--ui-radius-sm)' }}>
|
||||
<Text size="xs" variant="low" weight="bold" uppercase block marginBottom={1}>{label}</Text>
|
||||
<Text size="2xl" weight="bold" variant={intent} block>{value}</Text>
|
||||
</Box>
|
||||
);
|
||||
|
||||
export interface NotificationDeadlineProps {
|
||||
label: string;
|
||||
deadline: string;
|
||||
icon: LucideIcon;
|
||||
}
|
||||
|
||||
export const NotificationDeadline = ({ label, deadline, icon }: NotificationDeadlineProps) => (
|
||||
<Box marginTop={6} display="flex" alignItems="center" gap={3} paddingX={4} paddingY={3} bg="rgba(255, 190, 77, 0.05)" style={{ border: '1px solid rgba(255, 190, 77, 0.2)', borderRadius: 'var(--ui-radius-sm)' }}>
|
||||
<Icon icon={icon} size={5} intent="warning" />
|
||||
<Box>
|
||||
<Text size="sm" weight="bold" variant="warning" block uppercase>{label}</Text>
|
||||
<Text size="xs" variant="low" block marginTop={0.5}>{deadline}</Text>
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
@@ -1,6 +1,6 @@
|
||||
import React, { ReactNode } from 'react';
|
||||
import { Surface } from './primitives/Surface';
|
||||
import { Box } from './primitives/Box';
|
||||
import { Box, Spacing } from './primitives/Box';
|
||||
import { Heading } from './Heading';
|
||||
import { Text } from './Text';
|
||||
|
||||
@@ -10,6 +10,8 @@ export interface PanelProps {
|
||||
children: ReactNode;
|
||||
footer?: ReactNode;
|
||||
variant?: 'default' | 'dark' | 'muted';
|
||||
padding?: Spacing;
|
||||
actions?: ReactNode;
|
||||
}
|
||||
|
||||
export const Panel = ({
|
||||
@@ -17,23 +19,32 @@ export const Panel = ({
|
||||
description,
|
||||
children,
|
||||
footer,
|
||||
variant = 'default'
|
||||
variant = 'default',
|
||||
padding = 6,
|
||||
actions
|
||||
}: PanelProps) => {
|
||||
return (
|
||||
<Surface variant={variant} rounded="lg" style={{ border: '1px solid var(--ui-color-border-default)' }}>
|
||||
{(title || description) && (
|
||||
<Box padding={6} borderBottom>
|
||||
{title && <Heading level={3} marginBottom={1}>{title}</Heading>}
|
||||
{description && <Text size="sm" variant="low">{description}</Text>}
|
||||
{(title || description || actions) && (
|
||||
<Box padding={padding as any} borderBottom display="flex" alignItems="center" justifyContent="between">
|
||||
<Box>
|
||||
{title && <Heading level={3} marginBottom={1}>{title}</Heading>}
|
||||
{description && <Text size="sm" variant="low">{description}</Text>}
|
||||
</Box>
|
||||
{actions && (
|
||||
<Box display="flex" alignItems="center" gap={3}>
|
||||
{actions}
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
)}
|
||||
|
||||
<Box padding={6}>
|
||||
<Box padding={padding as any}>
|
||||
{children}
|
||||
</Box>
|
||||
|
||||
{footer && (
|
||||
<Box padding={4} borderTop bg="rgba(255,255,255,0.02)">
|
||||
<Box padding={padding as any} borderTop bg="rgba(255,255,255,0.02)">
|
||||
{footer}
|
||||
</Box>
|
||||
)}
|
||||
|
||||
88
apps/website/ui/ProfileHero.tsx
Normal file
88
apps/website/ui/ProfileHero.tsx
Normal file
@@ -0,0 +1,88 @@
|
||||
import React, { ReactNode } from 'react';
|
||||
import { Box } from './primitives/Box';
|
||||
import { Surface } from './primitives/Surface';
|
||||
import { Glow } from './Glow';
|
||||
|
||||
export interface ProfileHeroProps {
|
||||
children: ReactNode;
|
||||
glowColor?: 'primary' | 'aqua' | 'purple' | 'amber';
|
||||
variant?: 'default' | 'muted';
|
||||
}
|
||||
|
||||
export const ProfileHero = ({ children, glowColor = 'primary', variant = 'default' }: ProfileHeroProps) => {
|
||||
return (
|
||||
<Box
|
||||
position="relative"
|
||||
bg={variant === 'muted' ? 'var(--ui-color-bg-surface)' : 'var(--ui-color-bg-base)'}
|
||||
style={{ borderBottom: '1px solid var(--ui-color-border-default)', overflow: 'hidden' }}
|
||||
>
|
||||
<Glow color={glowColor} size="xl" opacity={0.1} position="top-right" />
|
||||
<Box padding={{ base: 6, md: 10 }} position="relative" zIndex={10}>
|
||||
{children}
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
export interface ProfileAvatarProps {
|
||||
children: ReactNode;
|
||||
badge?: ReactNode;
|
||||
}
|
||||
|
||||
export const ProfileAvatar = ({ children, badge }: ProfileAvatarProps) => {
|
||||
return (
|
||||
<Box position="relative">
|
||||
<Surface
|
||||
variant="muted"
|
||||
rounded="xl"
|
||||
padding={1}
|
||||
style={{ border: '1px solid var(--ui-color-border-default)' }}
|
||||
>
|
||||
{children}
|
||||
</Surface>
|
||||
{badge && (
|
||||
<Box
|
||||
position="absolute"
|
||||
bottom={-2}
|
||||
right={-2}
|
||||
width={10}
|
||||
height={10}
|
||||
rounded="xl"
|
||||
bg="var(--ui-color-intent-telemetry)"
|
||||
style={{ border: '2px solid var(--ui-color-bg-base)', display: 'flex', alignItems: 'center', justifyContent: 'center' }}
|
||||
>
|
||||
{badge}
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
export interface ProfileStatsGroupProps {
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
export const ProfileStatsGroup = ({ children }: ProfileStatsGroupProps) => {
|
||||
return (
|
||||
<Box display="flex" gap={4} marginTop={4}>
|
||||
{children}
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
export interface ProfileStatProps {
|
||||
label: string;
|
||||
value: string | number;
|
||||
intent?: 'primary' | 'telemetry' | 'warning' | 'low';
|
||||
}
|
||||
|
||||
import { Text } from './Text';
|
||||
|
||||
export const ProfileStat = ({ label, value, intent = 'low' }: ProfileStatProps) => {
|
||||
return (
|
||||
<Box>
|
||||
<Text size="xs" variant="low" uppercase block>{label}</Text>
|
||||
<Text size="sm" weight="bold" variant={intent} font="mono">{value}</Text>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
42
apps/website/ui/ProgressLine.tsx
Normal file
42
apps/website/ui/ProgressLine.tsx
Normal file
@@ -0,0 +1,42 @@
|
||||
'use client';
|
||||
|
||||
import { Box } from '@/ui/primitives/Box';
|
||||
import { motion } from 'framer-motion';
|
||||
import React from 'react';
|
||||
|
||||
interface ProgressLineProps {
|
||||
isLoading: boolean;
|
||||
}
|
||||
|
||||
export function ProgressLine({ isLoading }: ProgressLineProps) {
|
||||
if (!isLoading) return null;
|
||||
|
||||
return (
|
||||
<Box
|
||||
fullWidth
|
||||
height="2px"
|
||||
bg="var(--ui-color-bg-surface-muted)"
|
||||
style={{ overflow: 'hidden', position: 'relative' }}
|
||||
>
|
||||
<motion.div
|
||||
style={{
|
||||
position: 'absolute',
|
||||
top: 0,
|
||||
left: 0,
|
||||
height: '100%',
|
||||
backgroundColor: 'var(--ui-color-intent-primary)'
|
||||
}}
|
||||
initial={{ width: '0%', left: '0%' }}
|
||||
animate={{
|
||||
width: ['20%', '50%', '20%'],
|
||||
left: ['-20%', '100%', '-20%'],
|
||||
}}
|
||||
transition={{
|
||||
duration: 1.5,
|
||||
repeat: Infinity,
|
||||
ease: 'linear',
|
||||
}}
|
||||
/>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
35
apps/website/ui/QuickStatCard.tsx
Normal file
35
apps/website/ui/QuickStatCard.tsx
Normal file
@@ -0,0 +1,35 @@
|
||||
import React, { ReactNode } from 'react';
|
||||
import { Card } from './Card';
|
||||
import { Box } from './primitives/Box';
|
||||
import { Text } from './Text';
|
||||
|
||||
export interface QuickStatItemProps {
|
||||
label: string;
|
||||
value: string | number;
|
||||
}
|
||||
|
||||
export const QuickStatItem = ({ label, value }: QuickStatItemProps) => (
|
||||
<Box textAlign="center" paddingX={4}>
|
||||
<Text size="2xl" weight="bold" variant="high">{value}</Text>
|
||||
<Text size="xs" variant="low" uppercase>{label}</Text>
|
||||
</Box>
|
||||
);
|
||||
|
||||
export interface QuickStatCardProps {
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
export const QuickStatCard = ({ children }: QuickStatCardProps) => {
|
||||
return (
|
||||
<Card variant="glass">
|
||||
<Box display="flex" alignItems="center" padding={6}>
|
||||
{React.Children.map(children, (child, index) => (
|
||||
<React.Fragment key={index}>
|
||||
{index > 0 && <Box width="1px" height="2.5rem" bg="var(--ui-color-border-muted)" style={{ opacity: 0.2 }} />}
|
||||
{child}
|
||||
</React.Fragment>
|
||||
))}
|
||||
</Box>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
76
apps/website/ui/RaceCard.tsx
Normal file
76
apps/website/ui/RaceCard.tsx
Normal file
@@ -0,0 +1,76 @@
|
||||
import React, { ReactNode } from 'react';
|
||||
import { Card } from './Card';
|
||||
import { Box } from './primitives/Box';
|
||||
import { Text } from './Text';
|
||||
import { Heading } from './Heading';
|
||||
import { Icon } from './Icon';
|
||||
import { LucideIcon, ChevronRight } from 'lucide-react';
|
||||
|
||||
export interface RaceCardProps {
|
||||
children: ReactNode;
|
||||
onClick: () => void;
|
||||
isLive?: boolean;
|
||||
}
|
||||
|
||||
export const RaceCard = ({ children, onClick, isLive }: RaceCardProps) => {
|
||||
return (
|
||||
<Card
|
||||
variant="dark"
|
||||
onClick={onClick}
|
||||
style={{ position: 'relative', cursor: 'pointer', overflow: 'hidden' }}
|
||||
>
|
||||
{isLive && (
|
||||
<Box
|
||||
position="absolute"
|
||||
top={0}
|
||||
left={0}
|
||||
right={0}
|
||||
height="2px"
|
||||
bg="var(--ui-color-intent-success)"
|
||||
style={{ opacity: 0.8 }}
|
||||
/>
|
||||
)}
|
||||
<Box display="flex" alignItems="center" gap={4}>
|
||||
{children}
|
||||
<Icon icon={ChevronRight} size={5} intent="low" />
|
||||
</Box>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
|
||||
export interface RaceTimeColumnProps {
|
||||
date?: string;
|
||||
time: string;
|
||||
relativeTime?: string;
|
||||
isLive?: boolean;
|
||||
}
|
||||
|
||||
export const RaceTimeColumn = ({ date, time, relativeTime, isLive }: RaceTimeColumnProps) => (
|
||||
<Box width="5rem" textAlign="center" flexShrink={0}>
|
||||
{date && <Text size="xs" variant="low" uppercase block>{date}</Text>}
|
||||
<Text size="lg" weight="bold" variant="high" block>{time}</Text>
|
||||
<Text size="xs" variant={isLive ? 'success' : 'low'} block uppercase>
|
||||
{isLive ? 'LIVE' : relativeTime}
|
||||
</Text>
|
||||
</Box>
|
||||
);
|
||||
|
||||
export interface RaceInfoProps {
|
||||
title: string;
|
||||
subtitle: string;
|
||||
badge?: ReactNode;
|
||||
meta?: ReactNode;
|
||||
}
|
||||
|
||||
export const RaceInfo = ({ title, subtitle, badge, meta }: RaceInfoProps) => (
|
||||
<Box flex={1} minWidth="0">
|
||||
<Box display="flex" alignItems="start" justifyContent="between" gap={4}>
|
||||
<Box minWidth="0">
|
||||
<Heading level={3} truncate>{title}</Heading>
|
||||
<Text size="sm" variant="low" block marginTop={1}>{subtitle}</Text>
|
||||
{meta && <Box marginTop={2}>{meta}</Box>}
|
||||
</Box>
|
||||
{badge && <Box flexShrink={0}>{badge}</Box>}
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
25
apps/website/ui/RaceSummary.tsx
Normal file
25
apps/website/ui/RaceSummary.tsx
Normal file
@@ -0,0 +1,25 @@
|
||||
import React from 'react';
|
||||
import { Box } from './primitives/Box';
|
||||
import { Text } from './Text';
|
||||
|
||||
export interface RaceSummaryProps {
|
||||
track: string;
|
||||
meta: string;
|
||||
date: string;
|
||||
}
|
||||
|
||||
export const RaceSummary = ({ track, meta, date }: RaceSummaryProps) => {
|
||||
return (
|
||||
<Box display="flex" alignItems="center" justifyContent="between" gap={3} fullWidth>
|
||||
<Box flex={1} minWidth="0">
|
||||
<Text size="xs" variant="high" block truncate>{track}</Text>
|
||||
<Text size="xs" variant="low" block truncate>{meta}</Text>
|
||||
</Box>
|
||||
<Box textAlign="right">
|
||||
<Text size="xs" variant="low" style={{ whiteSpace: 'nowrap' }}>
|
||||
{date}
|
||||
</Text>
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
66
apps/website/ui/ResultRow.tsx
Normal file
66
apps/website/ui/ResultRow.tsx
Normal file
@@ -0,0 +1,66 @@
|
||||
import React, { ReactNode } from 'react';
|
||||
import { Box } from './primitives/Box';
|
||||
import { Surface } from './primitives/Surface';
|
||||
import { Text } from './Text';
|
||||
|
||||
export interface ResultRowProps {
|
||||
children: ReactNode;
|
||||
isHighlighted?: boolean;
|
||||
}
|
||||
|
||||
export const ResultRow = ({ children, isHighlighted }: ResultRowProps) => {
|
||||
return (
|
||||
<Surface
|
||||
variant={isHighlighted ? 'muted' : 'dark'}
|
||||
rounded="xl"
|
||||
padding={3}
|
||||
style={isHighlighted ? {
|
||||
border: '1px solid var(--ui-color-intent-primary)',
|
||||
background: 'linear-gradient(to right, rgba(25, 140, 255, 0.1), transparent)'
|
||||
} : {}}
|
||||
>
|
||||
<Box display="flex" alignItems="center" gap={3}>
|
||||
{children}
|
||||
</Box>
|
||||
</Surface>
|
||||
);
|
||||
};
|
||||
|
||||
export interface PositionBadgeProps {
|
||||
position: number;
|
||||
}
|
||||
|
||||
export const PositionBadge = ({ position }: PositionBadgeProps) => {
|
||||
const getIntent = (pos: number) => {
|
||||
if (pos === 1) return 'warning';
|
||||
if (pos === 2) return 'low';
|
||||
if (pos === 3) return 'warning';
|
||||
return 'low';
|
||||
};
|
||||
|
||||
return (
|
||||
<Box
|
||||
width={10}
|
||||
height={10}
|
||||
rounded="lg"
|
||||
display="flex"
|
||||
alignItems="center"
|
||||
justifyContent="center"
|
||||
bg="var(--ui-color-bg-surface-muted)"
|
||||
style={{ border: '1px solid var(--ui-color-border-default)' }}
|
||||
>
|
||||
<Text weight="bold" variant={getIntent(position) as any}>{position}</Text>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
export interface ResultPointsProps {
|
||||
points: number;
|
||||
}
|
||||
|
||||
export const ResultPoints = ({ points }: ResultPointsProps) => (
|
||||
<Box padding={2} rounded="lg" bg="rgba(255, 190, 77, 0.05)" style={{ border: '1px solid rgba(255, 190, 77, 0.2)', minWidth: '3.5rem', textAlign: 'center' }}>
|
||||
<Text size="xs" variant="low" block>PTS</Text>
|
||||
<Text size="sm" weight="bold" variant="warning">{points}</Text>
|
||||
</Box>
|
||||
);
|
||||
@@ -1,48 +1,40 @@
|
||||
import React, { ReactNode } from 'react';
|
||||
import { Box } from './primitives/Box';
|
||||
import { Heading } from './Heading';
|
||||
import { Text } from './Text';
|
||||
import { Icon } from './Icon';
|
||||
import { LucideIcon } from 'lucide-react';
|
||||
import { Surface } from './primitives/Surface';
|
||||
|
||||
export interface SectionHeaderProps {
|
||||
title: string;
|
||||
description?: string;
|
||||
icon?: LucideIcon;
|
||||
intent?: 'primary' | 'success' | 'warning' | 'critical' | 'telemetry';
|
||||
actions?: ReactNode;
|
||||
loading?: ReactNode;
|
||||
variant?: 'default' | 'minimal';
|
||||
}
|
||||
|
||||
export const SectionHeader = ({
|
||||
title,
|
||||
description,
|
||||
icon,
|
||||
intent = 'primary',
|
||||
actions
|
||||
actions,
|
||||
loading,
|
||||
variant = 'default'
|
||||
}: SectionHeaderProps) => {
|
||||
const isMinimal = variant === 'minimal';
|
||||
|
||||
return (
|
||||
<Box
|
||||
padding={5}
|
||||
borderBottom
|
||||
style={{ background: 'linear-gradient(to right, var(--ui-color-bg-surface), transparent)' }}
|
||||
position="relative"
|
||||
paddingBottom={isMinimal ? 0 : 4}
|
||||
style={isMinimal ? {} : { borderBottom: '1px solid var(--ui-color-border-muted)' }}
|
||||
marginBottom={isMinimal ? 4 : 6}
|
||||
>
|
||||
<Box display="flex" alignItems="center" justifyContent="between">
|
||||
<Box display="flex" alignItems="center" gap={4}>
|
||||
{icon && (
|
||||
<Surface variant="muted" rounded="lg" padding={2} style={{ backgroundColor: 'rgba(255,255,255,0.05)' }}>
|
||||
<Icon icon={icon} size={5} intent={intent} />
|
||||
</Surface>
|
||||
)}
|
||||
<Box>
|
||||
<Text size="lg" weight="bold" variant="high" block>
|
||||
{title}
|
||||
<Box>
|
||||
<Heading level={isMinimal ? 3 : 2} weight="bold">{title}</Heading>
|
||||
{description && (
|
||||
<Text size={isMinimal ? 'xs' : 'sm'} variant="low" block marginTop={1}>
|
||||
{description}
|
||||
</Text>
|
||||
{description && (
|
||||
<Text size="sm" variant="low" block>
|
||||
{description}
|
||||
</Text>
|
||||
)}
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
{actions && (
|
||||
<Box display="flex" alignItems="center" gap={3}>
|
||||
@@ -50,6 +42,11 @@ export const SectionHeader = ({
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
{loading && (
|
||||
<Box position="absolute" bottom={0} left={0} fullWidth>
|
||||
{loading}
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
21
apps/website/ui/Sidebar.tsx
Normal file
21
apps/website/ui/Sidebar.tsx
Normal file
@@ -0,0 +1,21 @@
|
||||
import React, { ReactNode } from 'react';
|
||||
import { Box } from './primitives/Box';
|
||||
|
||||
export interface SidebarProps {
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
export const Sidebar = ({ children }: SidebarProps) => {
|
||||
return (
|
||||
<Box
|
||||
as="aside"
|
||||
display={{ base: 'none', lg: 'flex' }}
|
||||
flexDirection="col"
|
||||
width="16rem"
|
||||
bg="var(--ui-color-bg-surface)"
|
||||
style={{ borderRight: '1px solid var(--ui-color-border-default)', overflowY: 'auto' }}
|
||||
>
|
||||
{children}
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
36
apps/website/ui/SidebarItem.tsx
Normal file
36
apps/website/ui/SidebarItem.tsx
Normal file
@@ -0,0 +1,36 @@
|
||||
import React, { ReactNode } from 'react';
|
||||
import { Box } from './primitives/Box';
|
||||
import { Text } from './Text';
|
||||
import { Icon } from './Icon';
|
||||
import { LucideIcon, ChevronRight } from 'lucide-react';
|
||||
|
||||
export interface SidebarItemProps {
|
||||
children: ReactNode;
|
||||
onClick?: () => void;
|
||||
icon?: ReactNode;
|
||||
}
|
||||
|
||||
export const SidebarItem = ({ children, onClick, icon }: SidebarItemProps) => {
|
||||
return (
|
||||
<Box
|
||||
onClick={onClick}
|
||||
display="flex"
|
||||
alignItems="center"
|
||||
gap={3}
|
||||
padding={2}
|
||||
rounded="lg"
|
||||
style={{ cursor: onClick ? 'pointer' : 'default', transition: 'background-color 0.2s' }}
|
||||
className="hover:bg-[var(--ui-color-bg-surface-muted)]"
|
||||
>
|
||||
{icon && (
|
||||
<Box flexShrink={0} width={10} height={10} bg="var(--ui-color-bg-surface-muted)" rounded="lg" display="flex" alignItems="center" justifyContent="center">
|
||||
{icon}
|
||||
</Box>
|
||||
)}
|
||||
<Box flex={1} minWidth="0">
|
||||
{children}
|
||||
</Box>
|
||||
<Icon icon={ChevronRight} size={4} intent="low" />
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
52
apps/website/ui/SponsorshipCard.tsx
Normal file
52
apps/website/ui/SponsorshipCard.tsx
Normal file
@@ -0,0 +1,52 @@
|
||||
import React, { ReactNode } from 'react';
|
||||
import { Card } from './Card';
|
||||
import { Box } from './primitives/Box';
|
||||
import { Text } from './Text';
|
||||
import { Heading } from './Heading';
|
||||
import { Badge } from './Badge';
|
||||
import { Icon } from './Icon';
|
||||
import { DollarSign } from 'lucide-react';
|
||||
|
||||
export interface SponsorshipCardProps {
|
||||
name: string;
|
||||
description: string;
|
||||
price: string;
|
||||
isAvailable: boolean;
|
||||
sponsoredBy?: string;
|
||||
}
|
||||
|
||||
export const SponsorshipCard = ({
|
||||
name,
|
||||
description,
|
||||
price,
|
||||
isAvailable,
|
||||
sponsoredBy
|
||||
}: SponsorshipCardProps) => {
|
||||
return (
|
||||
<Card
|
||||
variant={isAvailable ? 'default' : 'dark'}
|
||||
style={isAvailable ? { border: '1px solid var(--ui-color-intent-success)' } : {}}
|
||||
>
|
||||
<Box display="flex" alignItems="start" justifyContent="between" marginBottom={4}>
|
||||
<Heading level={4}>{name}</Heading>
|
||||
<Badge variant={isAvailable ? 'success' : 'default'}>
|
||||
{isAvailable ? 'Available' : 'Taken'}
|
||||
</Badge>
|
||||
</Box>
|
||||
|
||||
<Text size="sm" variant="med" block marginBottom={4}>{description}</Text>
|
||||
|
||||
<Box display="flex" alignItems="center" gap={2} marginBottom={sponsoredBy ? 4 : 0}>
|
||||
<Icon icon={DollarSign} size={4} intent="low" />
|
||||
<Text weight="bold" variant="high">{price}</Text>
|
||||
</Box>
|
||||
|
||||
{sponsoredBy && (
|
||||
<Box paddingTop={3} style={{ borderTop: '1px solid var(--ui-color-border-muted)' }}>
|
||||
<Text size="xs" variant="low" block marginBottom={1}>Sponsored by</Text>
|
||||
<Text size="sm" weight="medium" variant="high">{sponsoredBy}</Text>
|
||||
</Box>
|
||||
)}
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
@@ -9,7 +9,7 @@ export interface StatBoxProps {
|
||||
label: string;
|
||||
value: string | number;
|
||||
icon: LucideIcon;
|
||||
intent?: 'primary' | 'success' | 'warning' | 'critical' | 'telemetry';
|
||||
intent?: 'primary' | 'success' | 'warning' | 'critical' | 'telemetry' | 'low';
|
||||
}
|
||||
|
||||
export const StatBox = ({
|
||||
|
||||
@@ -9,7 +9,7 @@ export interface StatCardProps {
|
||||
label: string;
|
||||
value: string | number;
|
||||
icon?: LucideIcon;
|
||||
intent?: 'primary' | 'success' | 'warning' | 'critical' | 'telemetry';
|
||||
intent?: 'primary' | 'success' | 'warning' | 'critical' | 'telemetry' | 'low';
|
||||
trend?: {
|
||||
value: number;
|
||||
isPositive: boolean;
|
||||
|
||||
@@ -1,20 +1,27 @@
|
||||
import React from 'react';
|
||||
import { Grid } from './primitives/Grid';
|
||||
import { StatBox, StatBoxProps } from './StatBox';
|
||||
import { StatCard, StatCardProps } from './StatCard';
|
||||
|
||||
export interface StatGridProps {
|
||||
stats: StatBoxProps[];
|
||||
columns?: number;
|
||||
stats: (StatBoxProps | StatCardProps)[];
|
||||
columns?: number | { base?: number; sm?: number; md?: number; lg?: number; xl?: number };
|
||||
variant?: 'box' | 'card';
|
||||
}
|
||||
|
||||
export const StatGrid = ({
|
||||
stats,
|
||||
columns = 3
|
||||
columns = 3,
|
||||
variant = 'box'
|
||||
}: StatGridProps) => {
|
||||
return (
|
||||
<Grid columns={columns} gap={4}>
|
||||
{stats.map((stat, index) => (
|
||||
<StatBox key={index} {...stat} />
|
||||
variant === 'box' ? (
|
||||
<StatBox key={index} {...(stat as StatBoxProps)} />
|
||||
) : (
|
||||
<StatCard key={index} {...(stat as StatCardProps)} />
|
||||
)
|
||||
))}
|
||||
</Grid>
|
||||
);
|
||||
|
||||
51
apps/website/ui/Stepper.tsx
Normal file
51
apps/website/ui/Stepper.tsx
Normal file
@@ -0,0 +1,51 @@
|
||||
import React from 'react';
|
||||
import { Box } from './primitives/Box';
|
||||
import { Text } from './Text';
|
||||
import { Button } from './Button';
|
||||
|
||||
export interface StepperProps {
|
||||
value: number;
|
||||
onChange: (value: number) => void;
|
||||
min?: number;
|
||||
max?: number;
|
||||
label?: string;
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
export const Stepper = ({
|
||||
value,
|
||||
onChange,
|
||||
min = 1,
|
||||
max,
|
||||
label,
|
||||
disabled = false
|
||||
}: StepperProps) => {
|
||||
return (
|
||||
<Box display="flex" alignItems="center" gap={2}>
|
||||
{label && <Text size="xs" variant="low" weight="bold" uppercase>{label}</Text>}
|
||||
<Box display="flex" alignItems="center" gap={1}>
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
onClick={() => onChange(value - 1)}
|
||||
disabled={disabled || value <= min}
|
||||
style={{ width: '2rem', height: '2rem', padding: 0 }}
|
||||
>
|
||||
−
|
||||
</Button>
|
||||
<Box width={10} height={8} display="flex" alignItems="center" justifyContent="center" bg="var(--ui-color-bg-surface-muted)" style={{ border: '1px solid var(--ui-color-border-default)', borderRadius: 'var(--ui-radius-md)' }}>
|
||||
<Text size="sm" weight="bold" variant="high">{value}</Text>
|
||||
</Box>
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
onClick={() => onChange(value + 1)}
|
||||
disabled={disabled || (max !== undefined && value >= max)}
|
||||
style={{ width: '2rem', height: '2rem', padding: 0 }}
|
||||
>
|
||||
+
|
||||
</Button>
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
82
apps/website/ui/TeamCard.tsx
Normal file
82
apps/website/ui/TeamCard.tsx
Normal file
@@ -0,0 +1,82 @@
|
||||
import React, { ReactNode } from 'react';
|
||||
import { Card } from './Card';
|
||||
import { Box } from './primitives/Box';
|
||||
import { Text } from './Text';
|
||||
import { Heading } from './Heading';
|
||||
import { Icon } from './Icon';
|
||||
import { LucideIcon, ChevronRight, Users, Globe } from 'lucide-react';
|
||||
import { Image } from './Image';
|
||||
import { Badge } from './Badge';
|
||||
|
||||
export interface TeamCardProps {
|
||||
name: string;
|
||||
description?: string;
|
||||
logo?: ReactNode;
|
||||
memberCount: number;
|
||||
isRecruiting?: boolean;
|
||||
badges?: ReactNode;
|
||||
region?: string;
|
||||
onClick?: () => void;
|
||||
}
|
||||
|
||||
export const TeamCard = ({
|
||||
name,
|
||||
description,
|
||||
logo,
|
||||
memberCount,
|
||||
isRecruiting,
|
||||
badges,
|
||||
region,
|
||||
onClick
|
||||
}: TeamCardProps) => {
|
||||
return (
|
||||
<Card
|
||||
variant="dark"
|
||||
onClick={onClick}
|
||||
style={{ cursor: onClick ? 'pointer' : 'default', height: '100%', display: 'flex', flexDirection: 'column' }}
|
||||
>
|
||||
<Box display="flex" gap={4} marginBottom={4}>
|
||||
<Box width={16} height={16} rounded="lg" bg="var(--ui-color-bg-surface-muted)" style={{ border: '1px solid var(--ui-color-border-default)', overflow: 'hidden', flexShrink: 0 }}>
|
||||
{logo}
|
||||
</Box>
|
||||
<Box flex={1} minWidth="0">
|
||||
<Box display="flex" alignItems="start" justifyContent="between" gap={2}>
|
||||
<Heading level={4} weight="bold" truncate>{name}</Heading>
|
||||
{isRecruiting && <Badge variant="success" size="sm">RECRUITING</Badge>}
|
||||
</Box>
|
||||
<Box display="flex" gap={2} flexWrap="wrap" marginTop={2}>
|
||||
{badges}
|
||||
</Box>
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
<Text size="xs" variant="low" lineClamp={2} style={{ height: '2.5rem', marginBottom: '1rem' }} block leading="relaxed">
|
||||
{description || 'No description available'}
|
||||
</Text>
|
||||
|
||||
{region && (
|
||||
<Box marginBottom={4}>
|
||||
<Badge variant="default" size="sm">
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '0.25rem' }}>
|
||||
<Icon icon={Globe} size={3} intent="primary" />
|
||||
<Text size="xs" weight="bold">{region}</Text>
|
||||
</div>
|
||||
</Badge>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
<Box marginTop="auto" paddingTop={4} style={{ borderTop: '1px solid var(--ui-color-border-muted)', opacity: 0.5 }} display="flex" alignItems="center" justifyContent="between">
|
||||
<Box display="flex" alignItems="center" gap={2}>
|
||||
<Icon icon={Users} size={3} intent="low" />
|
||||
<Text size="xs" variant="low" font="mono">
|
||||
{memberCount} {memberCount === 1 ? 'MEMBER' : 'MEMBERS'}
|
||||
</Text>
|
||||
</Box>
|
||||
<Box display="flex" alignItems="center" gap={1}>
|
||||
<Text size="xs" variant="low" weight="bold" uppercase>VIEW</Text>
|
||||
<Icon icon={ChevronRight} size={3} intent="low" />
|
||||
</Box>
|
||||
</Box>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
57
apps/website/ui/TeamHero.tsx
Normal file
57
apps/website/ui/TeamHero.tsx
Normal file
@@ -0,0 +1,57 @@
|
||||
import React, { ReactNode } from 'react';
|
||||
import { Box } from './primitives/Box';
|
||||
import { Text } from './Text';
|
||||
import { Heading } from './Heading';
|
||||
import { Glow } from './Glow';
|
||||
|
||||
export interface TeamHeroProps {
|
||||
title: ReactNode;
|
||||
description: string;
|
||||
stats?: ReactNode;
|
||||
actions?: ReactNode;
|
||||
sideContent?: ReactNode;
|
||||
}
|
||||
|
||||
export const TeamHero = ({
|
||||
title,
|
||||
description,
|
||||
stats,
|
||||
actions,
|
||||
sideContent
|
||||
}: TeamHeroProps) => {
|
||||
return (
|
||||
<Box
|
||||
position="relative"
|
||||
bg="var(--ui-color-bg-base)"
|
||||
paddingY={12}
|
||||
style={{ borderBottom: '1px solid var(--ui-color-border-default)', overflow: 'hidden' }}
|
||||
>
|
||||
<Glow color="purple" size="xl" opacity={0.05} position="top-right" />
|
||||
|
||||
<Box display="flex" flexDirection={{ base: 'col', lg: 'row' }} gap={12} alignItems="start">
|
||||
<Box flex={1}>
|
||||
<Heading level={1} size="4xl" weight="bold" marginBottom={4}>
|
||||
{title}
|
||||
</Heading>
|
||||
<Text size="lg" variant="low" block marginBottom={8} leading="relaxed">
|
||||
{description}
|
||||
</Text>
|
||||
|
||||
{stats && <Box marginBottom={8}>{stats}</Box>}
|
||||
|
||||
{actions && (
|
||||
<Box display="flex" gap={4} flexWrap="wrap">
|
||||
{actions}
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
|
||||
{sideContent && (
|
||||
<Box width={{ base: '100%', lg: '24rem' }} flexShrink={0}>
|
||||
{sideContent}
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
@@ -5,7 +5,7 @@ export type TextSize = 'xs' | 'sm' | 'md' | 'lg' | 'xl' | '2xl' | '3xl' | '4xl'
|
||||
|
||||
export interface TextProps extends BoxProps<any> {
|
||||
children: ReactNode;
|
||||
variant?: 'high' | 'med' | 'low' | 'primary' | 'success' | 'warning' | 'critical' | 'inherit';
|
||||
variant?: 'high' | 'med' | 'low' | 'primary' | 'success' | 'warning' | 'critical' | 'telemetry' | 'inherit';
|
||||
size?: TextSize | ResponsiveValue<TextSize>;
|
||||
weight?: 'light' | 'normal' | 'medium' | 'semibold' | 'bold';
|
||||
as?: ElementType;
|
||||
@@ -19,7 +19,7 @@ export interface TextProps extends BoxProps<any> {
|
||||
truncate?: boolean;
|
||||
lineHeight?: string | number;
|
||||
font?: 'sans' | 'mono';
|
||||
hoverTextColor?: string;
|
||||
hoverVariant?: 'high' | 'med' | 'low' | 'primary' | 'success' | 'warning' | 'critical';
|
||||
}
|
||||
|
||||
export const Text = forwardRef<HTMLElement, TextProps>(({
|
||||
@@ -38,7 +38,7 @@ export const Text = forwardRef<HTMLElement, TextProps>(({
|
||||
truncate = false,
|
||||
lineHeight,
|
||||
font,
|
||||
hoverTextColor,
|
||||
hoverVariant,
|
||||
...props
|
||||
}, ref) => {
|
||||
const variantClasses = {
|
||||
@@ -49,8 +49,19 @@ export const Text = forwardRef<HTMLElement, TextProps>(({
|
||||
success: 'text-[var(--ui-color-intent-success)]',
|
||||
warning: 'text-[var(--ui-color-intent-warning)]',
|
||||
critical: 'text-[var(--ui-color-intent-critical)]',
|
||||
telemetry: 'text-[var(--ui-color-intent-telemetry)]',
|
||||
inherit: 'text-inherit',
|
||||
};
|
||||
|
||||
const hoverVariantClasses = {
|
||||
high: 'hover:text-[var(--ui-color-text-high)]',
|
||||
med: 'hover:text-[var(--ui-color-text-med)]',
|
||||
low: 'hover:text-[var(--ui-color-text-low)]',
|
||||
primary: 'hover:text-[var(--ui-color-intent-primary)]',
|
||||
success: 'hover:text-[var(--ui-color-intent-success)]',
|
||||
warning: 'hover:text-[var(--ui-color-intent-warning)]',
|
||||
critical: 'hover:text-[var(--ui-color-intent-critical)]',
|
||||
};
|
||||
|
||||
const sizeMap: Record<TextSize, string> = {
|
||||
xs: 'text-xs',
|
||||
@@ -103,7 +114,7 @@ export const Text = forwardRef<HTMLElement, TextProps>(({
|
||||
uppercase ? 'uppercase tracking-wider' : '',
|
||||
leading ? leadingClasses[leading] : '',
|
||||
truncate ? 'truncate' : '',
|
||||
hoverTextColor ? `hover:text-${hoverTextColor}` : '',
|
||||
hoverVariant ? hoverVariantClasses[hoverVariant] : '',
|
||||
].join(' ');
|
||||
|
||||
const style: React.CSSProperties = {
|
||||
|
||||
70
apps/website/ui/Toast.tsx
Normal file
70
apps/website/ui/Toast.tsx
Normal file
@@ -0,0 +1,70 @@
|
||||
import React, { ReactNode } from 'react';
|
||||
import { Box } from './primitives/Box';
|
||||
import { Surface } from './primitives/Surface';
|
||||
import { IconButton } from './IconButton';
|
||||
import { X } from 'lucide-react';
|
||||
import { Text } from './Text';
|
||||
|
||||
export interface ToastProps {
|
||||
children: ReactNode;
|
||||
title?: string;
|
||||
onClose: () => void;
|
||||
icon?: ReactNode;
|
||||
intent?: 'primary' | 'success' | 'warning' | 'critical';
|
||||
isVisible: boolean;
|
||||
isExiting: boolean;
|
||||
progress?: number;
|
||||
}
|
||||
|
||||
export const Toast = ({
|
||||
children,
|
||||
title,
|
||||
onClose,
|
||||
icon,
|
||||
intent = 'primary',
|
||||
isVisible,
|
||||
isExiting,
|
||||
progress
|
||||
}: ToastProps) => {
|
||||
const intentColors = {
|
||||
primary: 'var(--ui-color-intent-primary)',
|
||||
success: 'var(--ui-color-intent-success)',
|
||||
warning: 'var(--ui-color-intent-warning)',
|
||||
critical: 'var(--ui-color-intent-critical)',
|
||||
};
|
||||
|
||||
return (
|
||||
<Box
|
||||
style={{
|
||||
transform: isVisible && !isExiting ? 'translateX(0)' : 'translateX(100%)',
|
||||
opacity: isVisible && !isExiting ? 1 : 0,
|
||||
transition: 'all 0.3s ease-in-out',
|
||||
width: '24rem'
|
||||
}}
|
||||
>
|
||||
<Surface variant="muted" rounded="xl" shadow="xl" style={{ border: `1px solid ${intentColors[intent]}33`, overflow: 'hidden' }}>
|
||||
{progress !== undefined && (
|
||||
<Box height="1px" bg="rgba(255,255,255,0.1)">
|
||||
<Box height="100%" bg={intentColors[intent]} style={{ width: `${progress}%`, transition: 'width 0.1s linear' }} />
|
||||
</Box>
|
||||
)}
|
||||
<Box padding={4} display="flex" gap={3}>
|
||||
{icon && (
|
||||
<Box padding={2} rounded="lg" bg="var(--ui-color-bg-surface-muted)" flexShrink={0}>
|
||||
{icon}
|
||||
</Box>
|
||||
)}
|
||||
<Box flex={1} minWidth="0">
|
||||
<Box display="flex" alignItems="start" justifyContent="between" gap={2}>
|
||||
{title && <Text size="sm" weight="bold" variant="high" truncate>{title}</Text>}
|
||||
<IconButton icon={X} size="sm" variant="ghost" onClick={onClose} title="Close" />
|
||||
</Box>
|
||||
<Box marginTop={1}>
|
||||
{children}
|
||||
</Box>
|
||||
</Box>
|
||||
</Box>
|
||||
</Surface>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
174
apps/website/ui/UploadDropzone.tsx
Normal file
174
apps/website/ui/UploadDropzone.tsx
Normal file
@@ -0,0 +1,174 @@
|
||||
import { Button } from '@/ui/Button';
|
||||
import { Icon } from '@/ui/Icon';
|
||||
import { Box } from '@/ui/primitives/Box';
|
||||
import { Text } from '@/ui/Text';
|
||||
import { Surface } from '@/ui/primitives/Surface';
|
||||
import { IconButton } from '@/ui/IconButton';
|
||||
import { ListItem, ListItemInfo, ListItemActions } from '@/ui/ListItem';
|
||||
import { AlertCircle, CheckCircle2, File, Upload, X } from 'lucide-react';
|
||||
import React, { useRef, useState } from 'react';
|
||||
|
||||
export interface UploadDropzoneProps {
|
||||
onFilesSelected: (files: File[]) => void;
|
||||
accept?: string;
|
||||
multiple?: boolean;
|
||||
maxSize?: number; // in bytes
|
||||
isLoading?: boolean;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
export function UploadDropzone({
|
||||
onFilesSelected,
|
||||
accept,
|
||||
multiple = false,
|
||||
maxSize,
|
||||
isLoading,
|
||||
error,
|
||||
}: UploadDropzoneProps) {
|
||||
const [isDragging, setIsDragging] = useState(false);
|
||||
const [selectedFiles, setSelectedFiles] = useState<File[]>([]);
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
const handleDragOver = (e: React.DragEvent) => {
|
||||
e.preventDefault();
|
||||
setIsDragging(true);
|
||||
};
|
||||
|
||||
const handleDragLeave = () => {
|
||||
setIsDragging(false);
|
||||
};
|
||||
|
||||
const handleDrop = (e: React.DragEvent) => {
|
||||
e.preventDefault();
|
||||
setIsDragging(false);
|
||||
const files = Array.from(e.dataTransfer.files);
|
||||
validateAndSelectFiles(files);
|
||||
};
|
||||
|
||||
const handleFileSelect = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
if (e.target.files) {
|
||||
const files = Array.from(e.target.files);
|
||||
validateAndSelectFiles(files);
|
||||
}
|
||||
};
|
||||
|
||||
const validateAndSelectFiles = (files: File[]) => {
|
||||
let filteredFiles = files;
|
||||
|
||||
if (accept) {
|
||||
const acceptedTypes = accept.split(',').map(t => t.trim());
|
||||
filteredFiles = filteredFiles.filter(file => {
|
||||
return acceptedTypes.some(type => {
|
||||
if (type.startsWith('.')) {
|
||||
return file.name.endsWith(type);
|
||||
}
|
||||
if (type.endsWith('/*')) {
|
||||
return file.type.startsWith(type.replace('/*', ''));
|
||||
}
|
||||
return file.type === type;
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
if (maxSize) {
|
||||
filteredFiles = filteredFiles.filter(file => file.size <= maxSize);
|
||||
}
|
||||
|
||||
if (!multiple) {
|
||||
filteredFiles = filteredFiles.slice(0, 1);
|
||||
}
|
||||
|
||||
setSelectedFiles(filteredFiles);
|
||||
onFilesSelected(filteredFiles);
|
||||
};
|
||||
|
||||
const removeFile = (index: number) => {
|
||||
const newFiles = [...selectedFiles];
|
||||
newFiles.splice(index, 1);
|
||||
setSelectedFiles(newFiles);
|
||||
onFilesSelected(newFiles);
|
||||
};
|
||||
|
||||
return (
|
||||
<Box
|
||||
fullWidth
|
||||
onDragOver={handleDragOver}
|
||||
onDragLeave={handleDragLeave}
|
||||
onDrop={handleDrop}
|
||||
>
|
||||
<Surface
|
||||
variant="muted"
|
||||
rounded="xl"
|
||||
padding={8}
|
||||
onClick={() => fileInputRef.current?.click()}
|
||||
style={{
|
||||
border: `2px dashed ${isDragging ? 'var(--ui-color-intent-primary)' : (error ? 'var(--ui-color-intent-critical)' : 'var(--ui-color-border-default)')}`,
|
||||
backgroundColor: isDragging ? 'rgba(25, 140, 255, 0.05)' : 'var(--ui-color-bg-surface-muted)',
|
||||
textAlign: 'center',
|
||||
cursor: 'pointer'
|
||||
}}
|
||||
>
|
||||
<input
|
||||
type="file"
|
||||
ref={fileInputRef}
|
||||
onChange={handleFileSelect}
|
||||
accept={accept}
|
||||
multiple={multiple}
|
||||
style={{ display: 'none' }}
|
||||
/>
|
||||
|
||||
<Box display="flex" flexDirection="col" alignItems="center" gap={4}>
|
||||
<Icon
|
||||
icon={isLoading ? Upload : (selectedFiles.length > 0 ? CheckCircle2 : Upload)}
|
||||
size={10}
|
||||
intent={isDragging ? 'primary' : (error ? 'critical' : 'low')}
|
||||
animate={isLoading ? 'pulse' : 'none'}
|
||||
/>
|
||||
|
||||
<Box>
|
||||
<Text weight="bold" variant="high" size="lg" block marginBottom={1}>
|
||||
{isDragging ? 'Drop files here' : 'Click or drag to upload'}
|
||||
</Text>
|
||||
|
||||
<Text size="sm" variant="low" block>
|
||||
{accept ? `Accepted formats: ${accept}` : 'All file types accepted'}
|
||||
{maxSize && ` (Max ${Math.round(maxSize / 1024 / 1024)}MB)`}
|
||||
</Text>
|
||||
</Box>
|
||||
|
||||
{error && (
|
||||
<Box display="flex" alignItems="center" gap={2} marginTop={2}>
|
||||
<Icon icon={AlertCircle} size={4} intent="warning" />
|
||||
<Text size="sm" variant="warning" weight="medium">{error}</Text>
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
</Surface>
|
||||
|
||||
{selectedFiles.length > 0 && (
|
||||
<Box marginTop={4} display="flex" flexDirection="col" gap={2}>
|
||||
{selectedFiles.map((file, index) => (
|
||||
<ListItem key={`${file.name}-${index}`}>
|
||||
<ListItemInfo
|
||||
title={file.name}
|
||||
description={`${Math.round(file.size / 1024)} KB`}
|
||||
/>
|
||||
<ListItemActions>
|
||||
<IconButton
|
||||
icon={X}
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
removeFile(index);
|
||||
}}
|
||||
title="Remove"
|
||||
/>
|
||||
</ListItemActions>
|
||||
</ListItem>
|
||||
))}
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
85
apps/website/ui/UserDropdown.tsx
Normal file
85
apps/website/ui/UserDropdown.tsx
Normal file
@@ -0,0 +1,85 @@
|
||||
import React, { ReactNode } from 'react';
|
||||
import { Box } from './primitives/Box';
|
||||
import { Surface } from './primitives/Surface';
|
||||
import { Text } from './Text';
|
||||
import { Icon } from './Icon';
|
||||
import { LucideIcon } from 'lucide-react';
|
||||
import { Link } from './Link';
|
||||
|
||||
export interface UserDropdownProps {
|
||||
children: ReactNode;
|
||||
isOpen: boolean;
|
||||
}
|
||||
|
||||
export const UserDropdown = ({ children, isOpen }: UserDropdownProps) => {
|
||||
if (!isOpen) return null;
|
||||
|
||||
return (
|
||||
<Box
|
||||
position="absolute"
|
||||
style={{ right: 0, top: '100%', marginTop: '0.5rem', width: '14rem', zIndex: 50 }}
|
||||
>
|
||||
<Surface variant="muted" rounded="xl" shadow="xl" style={{ border: '1px solid var(--ui-color-border-default)', overflow: 'hidden' }}>
|
||||
{children}
|
||||
</Surface>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
export interface UserDropdownHeaderProps {
|
||||
children: ReactNode;
|
||||
variant?: 'default' | 'demo';
|
||||
}
|
||||
|
||||
export const UserDropdownHeader = ({ children, variant = 'default' }: UserDropdownHeaderProps) => (
|
||||
<Box
|
||||
padding={4}
|
||||
style={{
|
||||
borderBottom: '1px solid var(--ui-color-border-default)',
|
||||
background: variant === 'demo' ? 'rgba(25, 140, 255, 0.05)' : 'transparent'
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</Box>
|
||||
);
|
||||
|
||||
export interface UserDropdownItemProps {
|
||||
icon?: LucideIcon;
|
||||
label: string;
|
||||
href?: string;
|
||||
onClick?: () => void;
|
||||
intent?: 'primary' | 'low' | 'critical' | 'success' | 'telemetry' | 'warning';
|
||||
}
|
||||
|
||||
export const UserDropdownItem = ({ icon, label, href, onClick, intent = 'low' }: UserDropdownItemProps) => {
|
||||
const content = (
|
||||
<Box display="flex" alignItems="center" gap={3} paddingX={4} paddingY={2.5} style={{ cursor: 'pointer' }}>
|
||||
{icon && <Icon icon={icon} size={4} intent={intent as any} />}
|
||||
<Text size="sm" variant={intent === 'critical' ? 'critical' : (intent === 'low' ? 'med' : intent as any)}>{label}</Text>
|
||||
</Box>
|
||||
);
|
||||
|
||||
if (href) {
|
||||
return (
|
||||
<Link href={href} block underline="none" onClick={onClick}>
|
||||
{content}
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Box as="button" onClick={onClick} fullWidth style={{ textAlign: 'left', background: 'transparent', border: 'none' }}>
|
||||
{content}
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
export interface UserDropdownFooterProps {
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
export const UserDropdownFooter = ({ children }: UserDropdownFooterProps) => (
|
||||
<Box style={{ borderTop: '1px solid var(--ui-color-border-default)' }}>
|
||||
{children}
|
||||
</Box>
|
||||
);
|
||||
174
apps/website/ui/WorkflowMockup.tsx
Normal file
174
apps/website/ui/WorkflowMockup.tsx
Normal file
@@ -0,0 +1,174 @@
|
||||
'use client';
|
||||
|
||||
import { Icon } from '@/ui/Icon';
|
||||
import { Box } from '@/ui/primitives/Box';
|
||||
import { Surface } from '@/ui/primitives/Surface';
|
||||
import { Text } from '@/ui/Text';
|
||||
import { AnimatePresence, motion, useReducedMotion } from 'framer-motion';
|
||||
import { CheckCircle2, LucideIcon } from 'lucide-react';
|
||||
import React, { useEffect, useState } from 'react';
|
||||
|
||||
export interface WorkflowStep {
|
||||
id: number;
|
||||
icon: LucideIcon;
|
||||
title: string;
|
||||
description: string;
|
||||
intent: 'primary' | 'success' | 'warning' | 'critical' | 'telemetry';
|
||||
}
|
||||
|
||||
interface WorkflowMockupProps {
|
||||
steps: WorkflowStep[];
|
||||
}
|
||||
|
||||
export function WorkflowMockup({ steps }: WorkflowMockupProps) {
|
||||
const shouldReduceMotion = useReducedMotion();
|
||||
const [isMounted, setIsMounted] = useState(false);
|
||||
const [activeStep, setActiveStep] = useState(0);
|
||||
|
||||
useEffect(() => {
|
||||
setIsMounted(true);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isMounted) return;
|
||||
|
||||
const interval = setInterval(() => {
|
||||
setActiveStep((prev) => (prev + 1) % steps.length);
|
||||
}, 3000);
|
||||
|
||||
return () => clearInterval(interval);
|
||||
}, [isMounted, steps.length]);
|
||||
|
||||
if (!isMounted) {
|
||||
return (
|
||||
<Box position="relative" fullWidth>
|
||||
<Surface variant="muted" rounded="none" padding={6}>
|
||||
<Box display="flex" justifyContent="between" gap={2}>
|
||||
{steps.map((step) => (
|
||||
<Box key={step.id} display="flex" alignItems="center" justifyContent="center" flexDirection="col">
|
||||
<Box width="10" height="10" rounded="none" bg="var(--ui-color-bg-base)" style={{ border: '1px solid var(--ui-color-border-default)' }} display="flex" alignItems="center" justifyContent="center" mb={2}>
|
||||
<Text variant={step.intent}>
|
||||
<Icon icon={step.icon} size={4} />
|
||||
</Text>
|
||||
</Box>
|
||||
<Text size="xs" weight="bold" variant="high" uppercase>{step.title}</Text>
|
||||
</Box>
|
||||
))}
|
||||
</Box>
|
||||
</Surface>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Box position="relative" fullWidth>
|
||||
<Surface variant="muted" rounded="none" padding={6} overflow="hidden">
|
||||
{/* Connection Lines */}
|
||||
<Box position="absolute" top="3.5rem" left="8%" right="8%" display={{ base: 'none', sm: 'block' }}>
|
||||
<Box height="0.5" bg="rgba(255,255,255,0.05)" position="relative">
|
||||
<Box
|
||||
as={motion.div}
|
||||
position="absolute"
|
||||
fullHeight
|
||||
bg="var(--ui-color-intent-primary)"
|
||||
initial={{ width: '0%' }}
|
||||
animate={{ width: `${(activeStep / (steps.length - 1)) * 100}%` }}
|
||||
transition={{ duration: 0.5, ease: 'easeInOut' }}
|
||||
/>
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
{/* Steps */}
|
||||
<Box display="flex" justifyContent="between" gap={2} position="relative">
|
||||
{steps.map((step, index) => {
|
||||
const isActive = index === activeStep;
|
||||
const isCompleted = index < activeStep;
|
||||
const StepIcon = step.icon;
|
||||
|
||||
return (
|
||||
<Box
|
||||
as={motion.div}
|
||||
key={step.id}
|
||||
display="flex"
|
||||
flexDirection="col"
|
||||
alignItems="center"
|
||||
justifyContent="center"
|
||||
cursor="pointer"
|
||||
flexGrow={1}
|
||||
onClick={() => setActiveStep(index)}
|
||||
whileHover={{ scale: 1.05 }}
|
||||
whileTap={{ scale: 0.95 }}
|
||||
>
|
||||
<Box
|
||||
as={motion.div}
|
||||
w={{ base: '10', sm: '12' }}
|
||||
h={{ base: '10', sm: '12' }}
|
||||
display="flex"
|
||||
alignItems="center"
|
||||
justifyContent="center"
|
||||
mb={2}
|
||||
transition
|
||||
style={{
|
||||
backgroundColor: isActive ? 'rgba(25, 140, 255, 0.1)' : isCompleted ? 'rgba(16, 185, 129, 0.1)' : 'var(--ui-color-bg-base)',
|
||||
opacity: isActive ? 1 : isCompleted ? 0.8 : 0.5,
|
||||
border: `1px solid ${isActive ? 'var(--ui-color-intent-primary)' : isCompleted ? 'var(--ui-color-intent-success)' : 'var(--ui-color-border-default)'}`
|
||||
}}
|
||||
animate={isActive && !shouldReduceMotion ? {
|
||||
opacity: [0.7, 1, 0.7],
|
||||
transition: { duration: 1.5, repeat: Infinity }
|
||||
} : {}}
|
||||
className="relative"
|
||||
>
|
||||
{isActive && (
|
||||
<Box position="absolute" top="-1px" left="-1px" w="2" h="2" style={{ borderTop: '1px solid var(--ui-color-intent-primary)', borderLeft: '1px solid var(--ui-color-intent-primary)' }} />
|
||||
)}
|
||||
{isCompleted ? (
|
||||
<Icon icon={CheckCircle2} size={5} intent="success" />
|
||||
) : (
|
||||
<Text variant={isActive ? 'primary' : 'low'}>
|
||||
<Icon icon={StepIcon} size={5} />
|
||||
</Text>
|
||||
)}
|
||||
</Box>
|
||||
<Text
|
||||
size="xs"
|
||||
weight="bold"
|
||||
variant={isActive ? 'high' : 'low'}
|
||||
display={{ base: 'none', sm: 'block' }}
|
||||
uppercase
|
||||
>
|
||||
{step.title}
|
||||
</Text>
|
||||
</Box>
|
||||
);
|
||||
})}
|
||||
</Box>
|
||||
|
||||
{/* Active Step Preview - Mobile */}
|
||||
<AnimatePresence mode="wait">
|
||||
<Box
|
||||
as={motion.div}
|
||||
key={activeStep}
|
||||
initial={{ opacity: 0, y: 5 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
exit={{ opacity: 0, y: -5 }}
|
||||
transition={{ duration: 0.2 }}
|
||||
marginTop={4}
|
||||
paddingTop={4}
|
||||
style={{ borderTop: '1px solid var(--ui-color-border-muted)' }}
|
||||
display={{ base: 'block', sm: 'none' }}
|
||||
>
|
||||
<Box textAlign="center">
|
||||
<Text size="xs" variant="med" block marginBottom={1} font="mono" weight="bold" uppercase>
|
||||
STEP {activeStep + 1}: {steps[activeStep]?.title || ''}
|
||||
</Text>
|
||||
<Text size="xs" variant="low" block font="mono" uppercase>
|
||||
{steps[activeStep]?.description || ''}
|
||||
</Text>
|
||||
</Box>
|
||||
</Box>
|
||||
</AnimatePresence>
|
||||
</Surface>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
@@ -12,7 +12,7 @@ import React, { forwardRef, ForwardedRef, ElementType } from 'react';
|
||||
* If you need more complex behavior, create a specific component in apps/website/components.
|
||||
*/
|
||||
|
||||
type Spacing = 0 | 0.5 | 1 | 1.5 | 2 | 2.5 | 3 | 3.5 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 14 | 16 | 20 | 24 | 28 | 32 | 36 | 40 | 44 | 48 | 52 | 56 | 60 | 64 | 72 | 80 | 96;
|
||||
export type Spacing = 0 | 0.5 | 1 | 1.5 | 2 | 2.5 | 3 | 3.5 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 14 | 16 | 20 | 24 | 28 | 32 | 36 | 40 | 44 | 48 | 52 | 56 | 60 | 64 | 72 | 80 | 96;
|
||||
|
||||
interface ResponsiveSpacing {
|
||||
base?: Spacing;
|
||||
@@ -36,12 +36,12 @@ export interface BoxProps<T extends ElementType> {
|
||||
children?: React.ReactNode;
|
||||
// Spacing
|
||||
margin?: Spacing | ResponsiveSpacing;
|
||||
marginTop?: Spacing | ResponsiveSpacing;
|
||||
marginBottom?: Spacing | ResponsiveSpacing;
|
||||
marginTop?: Spacing | 'auto' | ResponsiveSpacing;
|
||||
marginBottom?: Spacing | 'auto' | ResponsiveSpacing;
|
||||
marginLeft?: Spacing | 'auto' | ResponsiveSpacing;
|
||||
marginRight?: Spacing | 'auto' | ResponsiveSpacing;
|
||||
marginX?: Spacing | 'auto' | ResponsiveSpacing;
|
||||
marginY?: Spacing | ResponsiveSpacing;
|
||||
marginY?: Spacing | 'auto' | ResponsiveSpacing;
|
||||
padding?: Spacing | ResponsiveSpacing;
|
||||
paddingTop?: Spacing | ResponsiveSpacing;
|
||||
paddingBottom?: Spacing | ResponsiveSpacing;
|
||||
@@ -176,6 +176,10 @@ export interface BoxProps<T extends ElementType> {
|
||||
value?: string | number;
|
||||
onChange?: React.ChangeEventHandler<any>;
|
||||
onError?: React.ReactEventHandler<any>;
|
||||
onScroll?: React.UIEventHandler<any>;
|
||||
onDragOver?: React.DragEventHandler<any>;
|
||||
onDragLeave?: React.DragEventHandler<any>;
|
||||
onDrop?: React.DragEventHandler<any>;
|
||||
placeholder?: string;
|
||||
title?: string;
|
||||
size?: string | number | ResponsiveValue<string | number>;
|
||||
@@ -194,7 +198,6 @@ export interface BoxProps<T extends ElementType> {
|
||||
onMouseDown?: React.MouseEventHandler<any>;
|
||||
onMouseUp?: React.MouseEventHandler<any>;
|
||||
onMouseMove?: React.MouseEventHandler<any>;
|
||||
onScroll?: React.UIEventHandler<any>;
|
||||
responsiveColSpan?: number | ResponsiveValue<number>;
|
||||
responsiveGridCols?: number | ResponsiveValue<number>;
|
||||
clickable?: boolean;
|
||||
@@ -311,6 +314,10 @@ export const Box = forwardRef(<T extends ElementType = 'div'>(
|
||||
value,
|
||||
onChange,
|
||||
onError,
|
||||
onScroll,
|
||||
onDragOver,
|
||||
onDragLeave,
|
||||
onDrop,
|
||||
placeholder,
|
||||
title,
|
||||
size,
|
||||
@@ -329,7 +336,6 @@ export const Box = forwardRef(<T extends ElementType = 'div'>(
|
||||
onMouseDown,
|
||||
onMouseUp,
|
||||
onMouseMove,
|
||||
onScroll,
|
||||
responsiveColSpan,
|
||||
responsiveGridCols,
|
||||
clickable,
|
||||
@@ -436,7 +442,7 @@ export const Box = forwardRef(<T extends ElementType = 'div'>(
|
||||
getResponsiveClasses('items', alignItems),
|
||||
getResponsiveClasses('justify', justifyContent),
|
||||
alignSelf !== undefined ? `self-${alignSelf}` : '',
|
||||
getResponsiveClasses('gap', gap),
|
||||
gap ? getResponsiveClasses('gap', gap) : '',
|
||||
getResponsiveClasses('grid-cols', gridCols || responsiveGridCols),
|
||||
getResponsiveClasses('col-span', colSpan || responsiveColSpan),
|
||||
getResponsiveClasses('order', order),
|
||||
@@ -501,6 +507,9 @@ export const Box = forwardRef(<T extends ElementType = 'div'>(
|
||||
onBlur={onBlur}
|
||||
onSubmit={onSubmit}
|
||||
onScroll={onScroll}
|
||||
onDragOver={onDragOver}
|
||||
onDragLeave={onDragLeave}
|
||||
onDrop={onDrop}
|
||||
onError={onError}
|
||||
style={Object.keys(style).length > 0 ? style : undefined}
|
||||
id={id}
|
||||
|
||||
@@ -16,7 +16,7 @@ import { ThemeRadii, ThemeShadows } from '../theme/Theme';
|
||||
export interface SurfaceProps<T extends ElementType = 'div'> extends BoxProps<T> {
|
||||
as?: T;
|
||||
children?: ReactNode;
|
||||
variant?: 'default' | 'dark' | 'muted' | 'glass' | 'discord' | 'gradient-blue' | 'gradient-gold' | 'gradient-purple' | 'gradient-green' | 'discord-inner' | 'outline';
|
||||
variant?: 'default' | 'dark' | 'muted' | 'glass' | 'discord' | 'gradient-blue' | 'gradient-gold' | 'gradient-purple' | 'gradient-green' | 'discord-inner' | 'outline' | 'rarity-common' | 'rarity-rare' | 'rarity-epic' | 'rarity-legendary';
|
||||
rounded?: keyof ThemeRadii | 'none';
|
||||
shadow?: keyof ThemeShadows | 'none';
|
||||
}
|
||||
@@ -62,6 +62,22 @@ export const Surface = forwardRef(<T extends ElementType = 'div'>(
|
||||
outline: {
|
||||
backgroundColor: 'transparent',
|
||||
border: '1px solid var(--ui-color-border-default)'
|
||||
},
|
||||
'rarity-common': {
|
||||
backgroundColor: 'rgba(107, 114, 128, 0.1)',
|
||||
border: '1px solid rgba(107, 114, 128, 0.5)'
|
||||
},
|
||||
'rarity-rare': {
|
||||
backgroundColor: 'rgba(96, 165, 250, 0.1)',
|
||||
border: '1px solid rgba(96, 165, 250, 0.5)'
|
||||
},
|
||||
'rarity-epic': {
|
||||
backgroundColor: 'rgba(192, 132, 252, 0.1)',
|
||||
border: '1px solid rgba(192, 132, 252, 0.5)'
|
||||
},
|
||||
'rarity-legendary': {
|
||||
backgroundColor: 'rgba(255, 190, 77, 0.1)',
|
||||
border: '1px solid rgba(255, 190, 77, 0.5)'
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
Reference in New Issue
Block a user