website refactor

This commit is contained in:
2026-01-17 15:46:55 +01:00
parent 4d5ce9bfd6
commit 72a626ce71
346 changed files with 19308 additions and 8605 deletions

View File

@@ -0,0 +1,17 @@
import React from 'react';
interface AppFooterProps {
children?: React.ReactNode;
className?: string;
}
/**
* AppFooter is the bottom section of the application.
*/
export function AppFooter({ children, className = '' }: AppFooterProps) {
return (
<footer className={`bg-[#141619] border-t border-[#23272B] py-8 px-4 md:px-6 ${className}`}>
{children}
</footer>
);
}

View File

@@ -0,0 +1,20 @@
import React from 'react';
interface AppHeaderProps {
children: React.ReactNode;
className?: string;
}
/**
* AppHeader is the top control bar of the application.
* It follows the "Telemetry Workspace" structure.
*/
export function AppHeader({ children, className = '' }: AppHeaderProps) {
return (
<header
className={`sticky top-0 z-50 h-16 md:h-20 bg-[#0C0D0F]/80 backdrop-blur-md border-b border-[#23272B] flex items-center px-4 md:px-6 ${className}`}
>
{children}
</header>
);
}

View File

@@ -0,0 +1,18 @@
import React from 'react';
interface AppShellProps {
children: React.ReactNode;
className?: string;
}
/**
* AppShell is the root container for the entire application layout.
* It provides the base background and layout structure.
*/
export function AppShell({ children, className = '' }: AppShellProps) {
return (
<div className={`min-h-screen bg-[#0C0D0F] text-gray-100 flex flex-col ${className}`}>
{children}
</div>
);
}

View File

@@ -0,0 +1,20 @@
import React from 'react';
interface AppSidebarProps {
children?: React.ReactNode;
className?: string;
}
/**
* AppSidebar is the "dashboard rail" of the application.
* It provides global navigation and context.
*/
export function AppSidebar({ children, className = '' }: AppSidebarProps) {
return (
<aside
className={`hidden lg:flex flex-col w-64 bg-[#141619] border-r border-[#23272B] overflow-y-auto ${className}`}
>
{children}
</aside>
);
}

View File

@@ -1,16 +0,0 @@
import React from 'react';
import { Box } from './Box';
interface AuthContainerProps {
children: React.ReactNode;
}
export function AuthContainer({ children }: AuthContainerProps) {
return (
<Box style={{ minHeight: '100vh', display: 'flex', alignItems: 'center', justifyContent: 'center', padding: '1rem', backgroundColor: '#0f1115' }}>
{children}
</Box>
);
}

View File

@@ -1,27 +1,51 @@
/**
* Avatar
*
* Pure UI component for displaying driver avatars.
* Renders an image with fallback on error.
*/
import React from 'react';
import { Box } from './Box';
import { Image } from './Image';
import { User } from 'lucide-react';
import { Icon } from './Icon';
export interface AvatarProps {
driverId: string;
driverId?: string;
src?: string;
alt: string;
size?: number;
className?: string;
border?: boolean;
}
export function Avatar({ driverId, alt, className = '' }: AvatarProps) {
export function Avatar({
driverId,
src,
alt,
size = 40,
className = '',
border = true,
}: AvatarProps) {
const avatarSrc = src || (driverId ? `/media/avatar/${driverId}` : undefined);
return (
// eslint-disable-next-line @next/next/no-img-element
<img
src={`/media/avatar/${driverId}`}
alt={alt}
className={`w-10 h-10 rounded-full object-cover ${className}`}
onError={(e) => {
// Fallback to default avatar
(e.target as HTMLImageElement).src = '/default-avatar.png';
}}
/>
<Box
display="flex"
alignItems="center"
justifyContent="center"
rounded="full"
overflow="hidden"
bg="bg-charcoal-outline/20"
border={border}
borderColor="border-charcoal-outline/50"
className={className}
style={{ width: size, height: size, flexShrink: 0 }}
>
{avatarSrc ? (
<Image
src={avatarSrc}
alt={alt}
className="w-full h-full object-cover"
fallbackSrc="/default-avatar.png"
/>
) : (
<Icon icon={User} size={size > 32 ? 5 : 4} color="text-gray-500" />
)}
</Box>
);
}
}

View File

@@ -91,6 +91,7 @@ export interface BoxProps<T extends ElementType> {
groupHoverTextColor?: string;
groupHoverScale?: boolean;
groupHoverOpacity?: number;
groupHoverWidth?: 'full' | '0' | 'auto';
fontSize?: string;
transform?: string;
borderWidth?: string;
@@ -115,6 +116,9 @@ export interface BoxProps<T extends ElementType> {
backgroundPosition?: string;
backgroundColor?: string;
insetY?: Spacing | string;
letterSpacing?: string;
lineHeight?: string;
backgroundImage?: string;
}
type ResponsiveValue<T> = {
@@ -190,6 +194,7 @@ export const Box = forwardRef(<T extends ElementType = 'div'>(
groupHoverTextColor,
groupHoverScale,
groupHoverOpacity,
groupHoverWidth,
fontSize,
transform,
borderWidth,
@@ -213,6 +218,9 @@ export const Box = forwardRef(<T extends ElementType = 'div'>(
backgroundPosition,
backgroundColor,
insetY,
letterSpacing,
lineHeight,
backgroundImage,
...props
}: BoxProps<T> & ComponentPropsWithoutRef<T>,
ref: ForwardedRef<HTMLElement>
@@ -356,6 +364,7 @@ export const Box = forwardRef(<T extends ElementType = 'div'>(
groupHoverTextColor ? `group-hover:text-${groupHoverTextColor}` : '',
groupHoverScale ? 'group-hover:scale-[1.02]' : '',
groupHoverOpacity !== undefined ? `group-hover:opacity-${groupHoverOpacity * 100}` : '',
groupHoverWidth ? `group-hover:w-${groupHoverWidth}` : '',
getResponsiveClasses('', display),
getFlexDirectionClass(flexDirection),
getAlignItemsClass(alignItems),
@@ -399,6 +408,9 @@ export const Box = forwardRef(<T extends ElementType = 'div'>(
...(webkitMaskImage ? { WebkitMaskImage: webkitMaskImage } : {}),
...(backgroundSize ? { backgroundSize } : {}),
...(backgroundPosition ? { backgroundPosition } : {}),
...(backgroundImage ? { backgroundImage } : {}),
...(letterSpacing ? { letterSpacing } : {}),
...(lineHeight ? { lineHeight } : {}),
...(top !== undefined && !spacingMap[top as string | number] ? { top } : {}),
...(bottom !== undefined && !spacingMap[bottom as string | number] ? { bottom } : {}),
...(left !== undefined && !spacingMap[left as string | number] ? { left } : {}),

View File

@@ -0,0 +1,17 @@
import React from 'react';
interface BreadcrumbBarProps {
children: React.ReactNode;
className?: string;
}
/**
* BreadcrumbBar is a container for breadcrumbs, typically placed at the top of the ContentShell.
*/
export function BreadcrumbBar({ children, className = '' }: BreadcrumbBarProps) {
return (
<div className={`mb-6 flex items-center space-x-2 text-sm ${className}`}>
{children}
</div>
);
}

View File

@@ -1,6 +1,8 @@
import React, { ReactNode, MouseEventHandler, ButtonHTMLAttributes, forwardRef } from 'react';
import { Stack } from './Stack';
import { Box, BoxProps } from './Box';
import { Loader2 } from 'lucide-react';
import { Icon } from './Icon';
interface ButtonProps extends Omit<ButtonHTMLAttributes<HTMLButtonElement>, 'as' | 'onMouseEnter' | 'onMouseLeave' | 'onSubmit'>, Omit<BoxProps<'button'>, 'as' | 'onClick' | 'onSubmit'> {
children: ReactNode;
@@ -9,6 +11,7 @@ interface ButtonProps extends Omit<ButtonHTMLAttributes<HTMLButtonElement>, 'as'
variant?: 'primary' | 'secondary' | 'danger' | 'ghost' | 'race-final' | 'discord';
size?: 'sm' | 'md' | 'lg';
disabled?: boolean;
isLoading?: boolean;
type?: 'button' | 'submit' | 'reset';
icon?: ReactNode;
fullWidth?: boolean;
@@ -25,6 +28,7 @@ export const Button = forwardRef<HTMLButtonElement, ButtonProps>(({
variant = 'primary',
size = 'md',
disabled = false,
isLoading = false,
type = 'button',
icon,
fullWidth = false,
@@ -51,7 +55,7 @@ export const Button = forwardRef<HTMLButtonElement, ButtonProps>(({
lg: 'min-h-[48px] px-6 py-3 text-base font-medium'
};
const disabledClasses = disabled ? 'opacity-40 cursor-not-allowed' : 'cursor-pointer';
const disabledClasses = (disabled || isLoading) ? 'opacity-40 cursor-not-allowed' : 'cursor-pointer';
const widthClasses = fullWidth ? 'w-full' : '';
const classes = [
@@ -63,12 +67,13 @@ export const Button = forwardRef<HTMLButtonElement, ButtonProps>(({
className
].filter(Boolean).join(' ');
const content = icon ? (
const content = (
<Stack direction="row" align="center" gap={2} center={fullWidth}>
{icon}
{isLoading && <Icon icon={Loader2} size={size === 'sm' ? 3 : 4} animate="spin" />}
{!isLoading && icon}
{children}
</Stack>
) : children;
);
if (as === 'a') {
return (
@@ -92,7 +97,7 @@ export const Button = forwardRef<HTMLButtonElement, ButtonProps>(({
type={type}
className={classes}
onClick={onClick}
disabled={disabled}
disabled={disabled || isLoading}
{...props}
>
{content}

View File

@@ -1,27 +1,44 @@
/**
* CategoryIcon
*
* Pure UI component for displaying category icons.
* Renders an image with fallback on error.
*/
import React from 'react';
import { Box } from './Box';
import { Image } from './Image';
import { Tag } from 'lucide-react';
import { Icon } from './Icon';
export interface CategoryIconProps {
categoryId: string;
categoryId?: string;
src?: string;
alt: string;
size?: number;
className?: string;
}
export function CategoryIcon({ categoryId, alt, className = '' }: CategoryIconProps) {
export function CategoryIcon({
categoryId,
src,
alt,
size = 24,
className = '',
}: CategoryIconProps) {
const iconSrc = src || (categoryId ? `/media/categories/${categoryId}/icon` : undefined);
return (
// eslint-disable-next-line @next/next/no-img-element
<img
src={`/media/categories/${categoryId}/icon`}
alt={alt}
className={`w-6 h-6 object-contain ${className}`}
onError={(e) => {
// Fallback to default icon
(e.target as HTMLImageElement).src = '/default-category-icon.png';
}}
/>
<Box
display="flex"
alignItems="center"
justifyContent="center"
className={className}
style={{ width: size, height: size, flexShrink: 0 }}
>
{iconSrc ? (
<Image
src={iconSrc}
alt={alt}
className="w-full h-full object-contain"
fallbackSrc="/default-category-icon.png"
/>
) : (
<Icon icon={Tag} size={size > 20 ? 4 : 3} color="text-gray-500" />
)}
</Box>
);
}
}

View File

@@ -0,0 +1,20 @@
import React from 'react';
interface ContentShellProps {
children: React.ReactNode;
className?: string;
}
/**
* ContentShell is the main data zone of the application.
* It houses the primary content and track maps/data tables.
*/
export function ContentShell({ children, className = '' }: ContentShellProps) {
return (
<main className={`flex-1 overflow-y-auto bg-[#0C0D0F] ${className}`}>
<div className="max-w-7xl mx-auto px-4 md:px-6 py-6">
{children}
</div>
</main>
);
}

View File

@@ -0,0 +1,21 @@
import React from 'react';
interface ContentViewportProps {
children: React.ReactNode;
className?: string;
}
/**
* ContentViewport is the main data zone of the "Telemetry Workspace".
* It houses the primary content, track maps, and data tables.
* Aligned with "Precision Racing Minimal" theme.
*/
export function ContentViewport({ children, className = '' }: ContentViewportProps) {
return (
<main className={`flex-1 overflow-y-auto bg-[#0C0D0F] ${className}`}>
<div className="max-w-7xl mx-auto px-4 md:px-6 py-6">
{children}
</div>
</main>
);
}

View File

@@ -0,0 +1,21 @@
import React from 'react';
interface ControlBarProps {
children: React.ReactNode;
className?: string;
}
/**
* ControlBar is the top-level header of the "Telemetry Workspace".
* It provides global controls, navigation, and status information.
* Aligned with "Precision Racing Minimal" theme.
*/
export function ControlBar({ children, className = '' }: ControlBarProps) {
return (
<header
className={`sticky top-0 z-50 h-16 md:h-20 bg-[#0C0D0F]/80 backdrop-blur-md border-b border-[#23272B] flex items-center px-4 md:px-6 ${className}`}
>
{children}
</header>
);
}

View File

@@ -1,12 +1,9 @@
import { ReactNode } from 'react';
import { Badge } from './Badge';
import React, { ReactNode } from 'react';
import { Box } from './Box';
import { Heading } from './Heading';
import { Image } from './Image';
import { Stack } from './Stack';
import { Text } from './Text';
import { Glow } from './Glow';
interface DashboardHeroProps {
driverName: string;
@@ -17,8 +14,15 @@ interface DashboardHeroProps {
totalRaces: string | number;
actions?: ReactNode;
stats?: ReactNode;
className?: string;
}
/**
* DashboardHero
*
* Redesigned for "Precision Racing Minimal" theme.
* Uses subtle accent glows and crisp separators.
*/
export function DashboardHero({
driverName,
avatarUrl,
@@ -28,112 +32,111 @@ export function DashboardHero({
totalRaces,
actions,
stats,
className = '',
}: DashboardHeroProps) {
return (
<Box as="section" position="relative" overflow="hidden">
{/* Background Pattern */}
<Box
position="absolute"
inset="0"
style={{
background: 'linear-gradient(to bottom right, rgba(59, 130, 246, 0.1), #0f1115, rgba(147, 51, 234, 0.05))',
}}
<Box
as="section"
position="relative"
className={`bg-[#0C0D0F] border-b border-[#23272B] overflow-hidden ${className}`}
>
{/* Subtle Accent Glow */}
<Glow
position="top-right"
color="primary"
opacity={0.1}
size="xl"
/>
<Box
position="relative"
maxWidth="80rem"
mx="auto"
px={6}
py={10}
py={8}
position="relative"
zIndex={1}
>
<Stack gap={8}>
<Stack direction="row" align="center" justify="between" wrap gap={8}>
{/* Welcome Message */}
<Stack direction="row" align="start" gap={5}>
<Box display="flex" flexDirection="col" gap={8}>
<Box display="flex" align="center" justify="between" wrap gap={6}>
{/* Driver Identity */}
<Box display="flex" align="center" gap={6}>
<Box position="relative">
<Box
w="20"
h="20"
rounded="xl"
p={0.5}
style={{
background: 'linear-gradient(to bottom right, #3b82f6, #9333ea)',
boxShadow: '0 20px 25px -5px rgba(59, 130, 246, 0.2)',
}}
w="24"
h="24"
className="border border-[#23272B] p-1 bg-[#141619]"
>
<Box
w="full"
h="full"
rounded="lg"
overflow="hidden"
bg="bg-deep-graphite"
>
<Image
src={avatarUrl}
alt={driverName}
width={80}
height={80}
style={{ width: '100%', height: '100%', objectFit: 'cover' }}
/>
</Box>
<Image
src={avatarUrl}
alt={driverName}
width={96}
height={96}
className="w-full h-full object-cover grayscale hover:grayscale-0 transition-all duration-300"
/>
</Box>
<Box
position="absolute"
bottom="-1"
right="-1"
w="5"
h="5"
rounded="full"
bg="bg-performance-green"
border
style={{ borderColor: '#0f1115', borderWidth: '3px' }}
w="4"
h="4"
className="bg-[#4ED4E0] border-2 border-[#0C0D0F]"
/>
</Box>
<Box>
<Text size="sm" color="text-gray-400" block mb={1}>
Good morning,
</Text>
<Heading level={1} mb={2}>
{driverName}
<Text size="2xl" ml={3}>
<Box display="flex" align="center" gap={3} mb={1}>
<Text size="xs" color="text-gray-500" uppercase weight="bold" letterSpacing="widest">
Driver Profile
</Text>
<Text size="xs" color="text-gray-600">
/
</Text>
<Text size="xs" color="text-gray-400">
{country}
</Text>
</Box>
<Heading level={1} className="text-3xl md:text-4xl font-black uppercase tracking-tighter mb-2">
{driverName}
</Heading>
<Stack direction="row" align="center" gap={3} wrap>
<Badge variant="primary">
{rating}
</Badge>
<Badge variant="warning">
#{rank}
</Badge>
<Text size="xs" color="text-gray-500">
{totalRaces} races completed
</Text>
</Stack>
<Box display="flex" align="center" gap={4}>
<Box display="flex" align="center" gap={2}>
<Text size="xs" color="text-gray-500" uppercase>Rating</Text>
<Text size="sm" weight="bold" className="text-[#4ED4E0] font-mono">{rating}</Text>
</Box>
<Box display="flex" align="center" gap={2}>
<Text size="xs" color="text-gray-500" uppercase>Rank</Text>
<Text size="sm" weight="bold" className="text-[#FFBE4D] font-mono">#{rank}</Text>
</Box>
<Box display="flex" align="center" gap={2}>
<Text size="xs" color="text-gray-500" uppercase>Starts</Text>
<Text size="sm" weight="bold" className="text-gray-300 font-mono">{totalRaces}</Text>
</Box>
</Box>
</Box>
</Stack>
</Box>
{/* Quick Actions */}
{/* Actions */}
{actions && (
<Stack direction="row" gap={3} wrap>
<Box display="flex" gap={3}>
{actions}
</Stack>
</Box>
)}
</Stack>
</Box>
{/* Quick Stats Row */}
{/* Stats Grid */}
{stats && (
<Box
display="grid"
gridCols={2}
responsiveGridCols={{ md: 4 }}
<Box
display="grid"
gridCols={2}
responsiveGridCols={{ md: 4 }}
gap={4}
className="border-t border-[#23272B]/50 pt-6"
>
{stats}
</Box>
)}
</Stack>
</Box>
</Box>
</Box>
);

View File

@@ -1,21 +0,0 @@
import { ReactNode } from 'react';
// TODO very useless component
interface DashboardLayoutWrapperProps {
children: ReactNode;
}
/**
* DashboardLayoutWrapper
*
* Full-screen layout wrapper for dashboard pages.
* Provides the base container with background styling.
*/
export function DashboardLayoutWrapper({ children }: DashboardLayoutWrapperProps) {
return (
<div className="min-h-screen bg-deep-graphite">
{children}
</div>
);
}

View File

@@ -0,0 +1,21 @@
import React from 'react';
interface DashboardRailProps {
children?: React.ReactNode;
className?: string;
}
/**
* DashboardRail is the primary sidebar navigation for the "Telemetry Workspace".
* It provides a high-density, instrument-grade navigation experience.
* Aligned with "Precision Racing Minimal" theme.
*/
export function DashboardRail({ children, className = '' }: DashboardRailProps) {
return (
<aside
className={`hidden lg:flex flex-col w-64 bg-[#141619] border-r border-[#23272B] overflow-y-auto ${className}`}
>
{children}
</aside>
);
}

View File

@@ -1,182 +0,0 @@
import { Box } from '@/ui/Box';
import { Button } from '@/ui/Button';
import { Container } from '@/ui/Container';
import { Heading } from '@/ui/Heading';
import { Glow } from '@/ui/Glow';
import { Icon } from '@/ui/Icon';
import { DiscordIcon } from '@/ui/icons/DiscordIcon';
import { Stack } from '@/ui/Stack';
import { Surface } from '@/ui/Surface';
import { Text } from '@/ui/Text';
import { Grid } from '@/ui/Grid';
import { Code, Lightbulb, LucideIcon, MessageSquare, Users } from 'lucide-react';
export function DiscordCTA() {
const discordUrl = process.env.NEXT_PUBLIC_DISCORD_URL || '#';
return (
<Box
as="section"
bg="graphite-black"
position="relative"
py={{ base: 20, md: 32 }}
borderBottom
borderColor="border-gray/50"
overflow="hidden"
>
<Glow color="primary" size="xl" position="center" opacity={0.05} />
<Container size="lg" position="relative" zIndex={10}>
<Surface
variant="default"
padding={12}
border
rounded="none"
position="relative"
className="overflow-hidden bg-panel-gray/40"
>
{/* Discord brand accent */}
<Box
position="absolute"
top={0}
left={0}
right={0}
height="1"
bg="primary-accent"
/>
<Stack align="center" gap={12} center>
{/* Header */}
<Stack align="center" gap={6}>
<Box
display="flex"
center
rounded="none"
w={{ base: "16", md: "20" }}
h={{ base: "16", md: "20" }}
bg="primary-accent/10"
border
borderColor="primary-accent/30"
className="relative"
>
<DiscordIcon color="text-primary-accent" size={40} />
<Box position="absolute" top="-1px" left="-1px" w="2" h="2" borderTop borderLeft borderColor="primary-accent" />
<Box position="absolute" bottom="-1px" right="-1px" w="2" h="2" borderBottom borderRight borderColor="primary-accent" />
</Box>
<Stack gap={4} align="center">
<Heading level={2} weight="bold" color="text-white" fontSize={{ base: '2xl', md: '4xl' }} className="tracking-tight">
Join the Grid on Discord
</Heading>
<Box
w="16"
h="1"
bg="primary-accent"
/>
</Stack>
</Stack>
{/* Personal message */}
<Box maxWidth="2xl" mx="auto" textAlign="center">
<Stack gap={6}>
<Text size="lg" color="text-gray-300" weight="medium" leading="relaxed">
GridPilot is a <span className="text-white font-bold">solo developer project</span> built for the community.
</Text>
<Text size="base" color="text-gray-400" weight="normal" leading="relaxed">
We are in early alpha. Join us to help shape the future of motorsport infrastructure. Your feedback directly influences the roadmap.
</Text>
</Stack>
</Box>
{/* Benefits grid */}
<Box
maxWidth="4xl"
mx="auto"
fullWidth
>
<Grid cols={1} mdCols={2} gap={6}>
<BenefitItem
icon={MessageSquare}
title="Share your pain points"
description="Tell us what frustrates you about league racing today."
/>
<BenefitItem
icon={Lightbulb}
title="Shape the product"
description="Your ideas directly influence our roadmap."
/>
<BenefitItem
icon={Users}
title="Connect with racers"
description="Join a community of like-minded competitive drivers."
/>
<BenefitItem
icon={Code}
title="Early Access"
description="Test new features before they go public."
/>
</Grid>
</Box>
{/* CTA Button */}
<Stack gap={6} pt={4} align="center">
<Button
as="a"
href={discordUrl}
target="_blank"
rel="noopener noreferrer"
variant="primary"
size="lg"
className="px-16 py-4"
icon={<DiscordIcon size={24} />}
>
Join Discord
</Button>
<Box border borderStyle="dashed" borderColor="primary-accent/50" px={4} py={1}>
<Text size="xs" color="text-primary-accent" weight="bold" font="mono" uppercase letterSpacing="widest">
Early Alpha Access Available
</Text>
</Box>
</Stack>
</Stack>
</Surface>
</Container>
</Box>
);
}
function BenefitItem({ icon, title, description }: { icon: LucideIcon, title: string, description: string }) {
return (
<Surface
variant="muted"
border
padding={6}
rounded="none"
display="flex"
gap={5}
className="items-start hover:border-primary-accent/30 transition-all bg-panel-gray/20 group"
>
<Box
display="flex"
center
rounded="none"
flexShrink={0}
w="10"
h="10"
bg="primary-accent/5"
border
borderColor="border-gray/50"
className="group-hover:border-primary-accent/30 transition-colors"
>
<Icon icon={icon} size={5} color="text-primary-accent" />
</Box>
<Stack gap={2}>
<Text size="base" weight="bold" color="text-white" className="tracking-wide">{title}</Text>
<Text size="sm" color="text-gray-400" leading="relaxed">{description}</Text>
</Stack>
</Surface>
);
}

View File

@@ -0,0 +1,109 @@
import React from 'react';
import { Box } from './Box';
import { Text } from './Text';
import { Stack } from './Stack';
import { Image } from './Image';
import { RatingBadge } from './RatingBadge';
interface DriverHeaderPanelProps {
name: string;
avatarUrl?: string;
nationality: string;
rating: number;
globalRank?: number | null;
bio?: string | null;
actions?: React.ReactNode;
}
export function DriverHeaderPanel({
name,
avatarUrl,
nationality,
rating,
globalRank,
bio,
actions
}: DriverHeaderPanelProps) {
const defaultAvatar = 'https://cdn.gridpilot.com/avatars/default.png';
return (
<Box
bg="bg-panel-gray"
rounded="xl"
border
borderColor="border-charcoal-outline"
overflow="hidden"
position="relative"
>
{/* Background Accent */}
<Box
position="absolute"
top={0}
left={0}
right={0}
height="24"
bg="bg-gradient-to-r from-primary-blue/20 to-transparent"
opacity={0.5}
/>
<Box p={6} position="relative">
<Stack direction={{ base: 'col', md: 'row' }} gap={6} align="start" className="md:items-center">
{/* Avatar */}
<Box
width="32"
height="32"
rounded="2xl"
overflow="hidden"
border
borderColor="border-charcoal-outline"
bg="bg-graphite-black"
flexShrink={0}
>
<Image
src={avatarUrl || defaultAvatar}
alt={name}
fill
objectFit="cover"
/>
</Box>
{/* Info */}
<Box flexGrow={1}>
<Stack gap={2}>
<Stack direction="row" align="center" gap={3} wrap>
<Text as="h1" size="3xl" weight="bold" color="text-white">
{name}
</Text>
<RatingBadge rating={rating} size="lg" />
</Stack>
<Stack direction="row" align="center" gap={4} wrap>
<Text size="sm" color="text-gray-400">
{nationality}
</Text>
{globalRank !== undefined && globalRank !== null && (
<Text size="sm" color="text-gray-400">
Global Rank: <Text color="text-warning-amber" weight="semibold">#{globalRank}</Text>
</Text>
)}
</Stack>
{bio && (
<Text size="sm" color="text-gray-400" className="max-w-2xl mt-2" lineClamp={2}>
{bio}
</Text>
)}
</Stack>
</Box>
{/* Actions */}
{actions && (
<Box flexShrink={0}>
{actions}
</Box>
)}
</Stack>
</Box>
</Box>
);
}

View File

@@ -55,7 +55,7 @@ export function DurationField({
disabled={disabled}
min={1}
className="pr-16"
error={!!error}
variant={error ? 'error' : 'default'}
/>
</div>
<span className="text-xs text-gray-400 -ml-14">{unitLabel}</span>

View File

@@ -0,0 +1,33 @@
import React, { ReactNode } from 'react';
import { Stack } from './Stack';
import { Text } from './Text';
interface FormSectionProps {
children: ReactNode;
title?: string;
}
/**
* FormSection
*
* Groups related form fields with an optional title.
*/
export function FormSection({ children, title }: FormSectionProps) {
return (
<Stack gap={4} fullWidth>
{title && (
<Text
size="xs"
weight="bold"
color="text-gray-500"
className="uppercase tracking-widest border-b border-border-gray pb-1"
>
{title}
</Text>
)}
<Stack gap={4} fullWidth>
{children}
</Stack>
</Stack>
);
}

View File

@@ -13,6 +13,8 @@ interface IconButtonProps {
title?: string;
disabled?: boolean;
color?: string;
className?: string;
backgroundColor?: string;
}
export function IconButton({
@@ -23,6 +25,8 @@ export function IconButton({
title,
disabled,
color,
className = '',
backgroundColor,
}: IconButtonProps) {
const sizeMap = {
sm: { btn: 'w-8 h-8 p-0', icon: 4 },
@@ -36,7 +40,8 @@ export function IconButton({
onClick={onClick}
title={title}
disabled={disabled}
className={`${sizeMap[size].btn} rounded-full flex items-center justify-center min-h-0`}
className={`${sizeMap[size].btn} rounded-full flex items-center justify-center min-h-0 ${className}`}
backgroundColor={backgroundColor}
>
<Icon icon={icon} size={sizeMap[size].icon} color={color} />
</Button>

View File

@@ -0,0 +1,86 @@
import React from 'react';
import { Image as ImageIcon, AlertCircle, Loader2 } from 'lucide-react';
import { Box } from './Box';
import { Icon } from './Icon';
import { Text } from './Text';
export interface ImagePlaceholderProps {
size?: number | string;
aspectRatio?: string;
variant?: 'default' | 'error' | 'loading';
message?: string;
className?: string;
rounded?: 'none' | 'sm' | 'md' | 'lg' | 'xl' | '2xl' | 'full';
}
export function ImagePlaceholder({
size = 'full',
aspectRatio = '1/1',
variant = 'default',
message,
className = '',
rounded = 'md',
}: ImagePlaceholderProps) {
const config = {
default: {
icon: ImageIcon,
color: 'text-gray-500',
bg: 'bg-charcoal-outline/20',
borderColor: 'border-charcoal-outline/50',
animate: undefined as 'spin' | 'pulse' | 'bounce' | 'fade-in' | 'none' | undefined,
},
error: {
icon: AlertCircle,
color: 'text-amber-500',
bg: 'bg-amber-500/5',
borderColor: 'border-amber-500/20',
animate: undefined as 'spin' | 'pulse' | 'bounce' | 'fade-in' | 'none' | undefined,
},
loading: {
icon: Loader2,
color: 'text-blue-500',
bg: 'bg-blue-500/5',
borderColor: 'border-blue-500/20',
animate: 'spin' as const,
},
};
const { icon, color, bg, borderColor, animate } = config[variant];
return (
<Box
display="flex"
flexDirection="col"
alignItems="center"
justifyContent="center"
w={typeof size === 'string' ? size : undefined}
h={typeof size === 'string' ? size : undefined}
style={typeof size === 'number' ? { width: size, height: size } : { aspectRatio }}
bg={bg}
border
borderColor={borderColor}
rounded={rounded}
className={`overflow-hidden ${className}`}
gap={2}
p={4}
>
<Icon
icon={icon}
size={6}
color={color}
animate={animate}
/>
{message && (
<Text
size="xs"
color={color}
weight="medium"
align="center"
className="max-w-[80%]"
>
{message}
</Text>
)}
</Box>
);
}

View File

@@ -0,0 +1,88 @@
import React from 'react';
import { Box } from './Box';
import { Stack } from './Stack';
import { Text } from './Text';
import { Heading } from './Heading';
import { Button } from './Button';
import { Check, X, Clock } from 'lucide-react';
import { Icon } from './Icon';
interface JoinRequestsPanelProps {
requests: Array<{
id: string;
driverName: string;
driverAvatar?: string;
message?: string;
requestedAt: string;
}>;
onAccept: (id: string) => void;
onDecline: (id: string) => void;
}
export function JoinRequestsPanel({ requests, onAccept, onDecline }: JoinRequestsPanelProps) {
if (requests.length === 0) {
return (
<Box p={8} border borderDash borderColor="border-steel-grey" bg="surface-charcoal/20" textAlign="center">
<Text color="text-gray-500" size="sm">No pending join requests</Text>
</Box>
);
}
return (
<Box border borderColor="border-steel-grey" bg="surface-charcoal/50" overflow="hidden">
<Box p={4} borderBottom borderColor="border-steel-grey" bg="base-graphite/50">
<Heading level={4} weight="bold" className="uppercase tracking-widest text-gray-400 text-[10px]">
Pending Requests ({requests.length})
</Heading>
</Box>
<Stack gap={0} className="divide-y divide-border-steel-grey/30">
{requests.map((request) => (
<Box key={request.id} p={4} className="hover:bg-white/[0.02] transition-colors">
<Stack direction="row" align="start" justify="between" gap={4}>
<Stack direction="row" align="center" gap={3}>
<Box w="10" h="10" bg="base-graphite" border borderColor="border-steel-grey" display="flex" center>
<Text size="xs" weight="bold" color="text-primary-blue">
{request.driverName.substring(0, 2).toUpperCase()}
</Text>
</Box>
<Box>
<Text weight="bold" size="sm" color="text-white" block>{request.driverName}</Text>
<Stack direction="row" align="center" gap={1.5} mt={0.5}>
<Icon icon={Clock} size={3} color="text-gray-500" />
<Text size="xs" color="text-gray-500" font="mono">{request.requestedAt}</Text>
</Stack>
</Box>
</Stack>
<Stack direction="row" align="center" gap={2}>
<Button
variant="secondary"
size="sm"
onClick={() => onDecline(request.id)}
className="h-8 w-8 p-0 flex items-center justify-center border-red-500/30 hover:bg-red-500/10"
>
<Icon icon={X} size={4} color="text-red-400" />
</Button>
<Button
variant="primary"
size="sm"
onClick={() => onAccept(request.id)}
className="h-8 w-8 p-0 flex items-center justify-center"
>
<Icon icon={Check} size={4} />
</Button>
</Stack>
</Stack>
{request.message && (
<Box mt={3} p={3} bg="base-graphite/30" borderLeft borderPrimary borderColor="primary-blue/40">
<Text size="xs" color="text-gray-400" italic leading="relaxed">
&ldquo;{request.message}&rdquo;
</Text>
</Box>
)}
</Box>
))}
</Stack>
</Box>
);
}

View File

@@ -6,10 +6,10 @@ import { Box } from '@/ui/Box';
export function FeatureItem({ text }: { text: string }) {
return (
<Surface variant="muted" rounded="none" border padding={4} bg="panel-gray/40" borderColor="border-gray/50" className="group hover:border-primary-accent/30 transition-colors">
<Surface variant="muted" rounded="none" border padding={4} bg="panel-gray/10" borderColor="border-gray/10" className="group hover:border-primary-accent/20 transition-colors">
<Stack direction="row" align="center" gap={4}>
<Box w="1" h="4" bg="primary-accent" className="group-hover:h-6 transition-all" />
<Text color="text-gray-300" leading="relaxed" weight="normal" size="sm" className="tracking-wide">
<Box w="0.5" h="3" bg="primary-accent" className="group-hover:h-5 transition-all" />
<Text color="text-gray-500" leading="relaxed" weight="normal" size="sm" className="tracking-wide group-hover:text-gray-300 transition-colors">
{text}
</Text>
</Stack>
@@ -19,10 +19,10 @@ export function FeatureItem({ text }: { text: string }) {
export function ResultItem({ text, color }: { text: string, color: string }) {
return (
<Surface variant="muted" rounded="none" border padding={4} bg="panel-gray/40" borderColor="border-gray/50" className="group hover:border-primary-accent/30 transition-colors">
<Surface variant="muted" rounded="none" border padding={4} bg="panel-gray/10" borderColor="border-gray/10" className="group hover:border-primary-accent/20 transition-colors">
<Stack direction="row" align="center" gap={4}>
<Box w="1" h="4" style={{ backgroundColor: color }} className="group-hover:h-6 transition-all" />
<Text color="text-gray-300" leading="relaxed" weight="normal" size="sm" className="tracking-wide">
<Box w="0.5" h="3" style={{ backgroundColor: color }} className="group-hover:h-5 transition-all" />
<Text color="text-gray-500" leading="relaxed" weight="normal" size="sm" className="tracking-wide group-hover:text-gray-300 transition-colors">
{text}
</Text>
</Stack>
@@ -32,12 +32,12 @@ export function ResultItem({ text, color }: { text: string, color: string }) {
export function StepItem({ step, text }: { step: number, text: string }) {
return (
<Surface variant="muted" rounded="none" border padding={4} bg="panel-gray/40" borderColor="border-gray/50" className="group hover:border-primary-accent/30 transition-colors">
<Surface variant="muted" rounded="none" border padding={4} bg="panel-gray/10" borderColor="border-gray/10" className="group hover:border-primary-accent/20 transition-colors">
<Stack direction="row" align="center" gap={4}>
<Box w="8" h="8" display="flex" center border borderColor="border-gray" className="group-hover:border-primary-accent/50 transition-colors">
<Text weight="bold" size="xs" color="text-primary-accent" font="mono">{step.toString().padStart(2, '0')}</Text>
<Box w="6" h="6" display="flex" center border borderColor="border-gray/20" className="group-hover:border-primary-accent/30 transition-colors">
<Text weight="bold" size="xs" color="text-primary-accent" font="mono" opacity={0.7}>{step.toString().padStart(2, '0')}</Text>
</Box>
<Text color="text-gray-300" leading="relaxed" weight="normal" size="sm" className="tracking-wide">
<Text color="text-gray-500" leading="relaxed" weight="normal" size="sm" className="tracking-wide group-hover:text-gray-300 transition-colors">
{text}
</Text>
</Stack>

View File

@@ -0,0 +1,49 @@
import React from 'react';
import { Box } from './Box';
interface LeaderboardTableShellProps {
columns: {
key: string;
label: string;
align?: 'left' | 'center' | 'right';
width?: string;
}[];
children: React.ReactNode;
className?: string;
}
export function LeaderboardTableShell({ columns, children, className = '' }: LeaderboardTableShellProps) {
return (
<Box
rounded="xl"
bg="bg-iron-gray/30"
border
borderColor="border-charcoal-outline"
overflow="hidden"
className={className}
>
<div className="overflow-x-auto">
<table className="w-full text-left border-collapse">
<thead>
<tr className="border-b border-charcoal-outline/50 bg-graphite-black/50">
{columns.map((col) => (
<th
key={col.key}
className={`px-4 py-3 text-[10px] uppercase tracking-widest font-bold text-gray-500 ${
col.align === 'center' ? 'text-center' : col.align === 'right' ? 'text-right' : 'text-left'
}`}
style={col.width ? { width: col.width } : {}}
>
{col.label}
</th>
))}
</tr>
</thead>
<tbody className="divide-y divide-charcoal-outline/30">
{children}
</tbody>
</table>
</div>
</Box>
);
}

View File

@@ -1,20 +1,45 @@
import React from 'react';
import { Box } from './Box';
import { Image } from './Image';
import { ImagePlaceholder } from './ImagePlaceholder';
export interface LeagueCoverProps {
leagueId: string;
leagueId?: string;
src?: string;
alt: string;
height?: string;
aspectRatio?: string;
className?: string;
}
export function LeagueCover({ leagueId, alt, height = '12rem' }: LeagueCoverProps) {
export function LeagueCover({
leagueId,
src,
alt,
height,
aspectRatio = '21/9',
className = '',
}: LeagueCoverProps) {
const coverSrc = src || (leagueId ? `/media/leagues/${leagueId}/cover` : undefined);
return (
<Image
src={`/media/leagues/${leagueId}/cover`}
alt={alt}
style={{ width: '100%', height, objectFit: 'cover' }}
fallbackSrc="/default-league-cover.png"
/>
<Box
width="full"
overflow="hidden"
bg="bg-charcoal-outline/10"
className={className}
style={{ height, aspectRatio: height ? undefined : aspectRatio }}
>
{coverSrc ? (
<Image
src={coverSrc}
alt={alt}
className="w-full h-full object-cover"
fallbackSrc="/default-league-cover.png"
/>
) : (
<ImagePlaceholder aspectRatio={aspectRatio} rounded="none" />
)}
</Box>
);
}

View File

@@ -1,22 +1,53 @@
import React from 'react';
import { Box } from './Box';
import { Image } from './Image';
import { Trophy } from 'lucide-react';
import { Icon } from './Icon';
export interface LeagueLogoProps {
leagueId: string;
leagueId?: string;
src?: string;
alt: string;
size?: number;
className?: string;
border?: boolean;
rounded?: 'none' | 'sm' | 'md' | 'lg' | 'xl' | 'full';
}
export function LeagueLogo({ leagueId, alt, size = 100 }: LeagueLogoProps) {
export function LeagueLogo({
leagueId,
src,
alt,
size = 64,
className = '',
border = true,
rounded = 'md',
}: LeagueLogoProps) {
const logoSrc = src || (leagueId ? `/media/leagues/${leagueId}/logo` : undefined);
return (
<Image
src={`/media/leagues/${leagueId}/logo`}
alt={alt}
width={size}
height={size}
style={{ objectFit: 'contain' }}
fallbackSrc="/default-league-logo.png"
/>
<Box
display="flex"
alignItems="center"
justifyContent="center"
rounded={rounded}
overflow="hidden"
bg="bg-charcoal-outline/10"
border={border}
borderColor="border-charcoal-outline/50"
className={className}
style={{ width: size, height: size, flexShrink: 0 }}
>
{logoSrc ? (
<Image
src={logoSrc}
alt={alt}
className="w-full h-full object-contain p-1"
fallbackSrc="/default-league-logo.png"
/>
) : (
<Icon icon={Trophy} size={size > 32 ? 5 : 4} color="text-gray-500" />
)}
</Box>
);
}

View File

@@ -7,7 +7,7 @@ import { Card } from './Card';
import { Grid } from './Grid';
import { Heading } from './Heading';
import { Icon } from './Icon';
import { Image } from './Image';
import { LeagueLogo } from './LeagueLogo';
import { Link } from './Link';
import { Stack } from './Stack';
import { Surface } from './Surface';
@@ -34,22 +34,7 @@ export function LeagueSummaryCard({
<Card p={0} style={{ overflow: 'hidden' }}>
<Box p={4}>
<Stack direction="row" align="center" gap={4} mb={4}>
<Box
w="14"
h="14"
rounded="lg"
overflow="hidden"
bg="bg-deep-graphite"
flexShrink={0}
>
<Image
src={`/media/league-logo/${id}`}
alt={name}
width={56}
height={56}
style={{ width: '100%', height: '100%', objectFit: 'cover' }}
/>
</Box>
<LeagueLogo leagueId={id} alt={name} size={56} />
<Box style={{ flex: 1, minWidth: 0 }}>
<Text
size="xs"

View File

@@ -0,0 +1,95 @@
import React from 'react';
import { Info, FileText, Maximize2, Type, Calendar, LucideIcon } from 'lucide-react';
import { Box } from './Box';
import { Text } from './Text';
import { Icon } from './Icon';
export interface MediaMetaItem {
label: string;
value: string | number;
icon?: LucideIcon;
}
export interface MediaMetaPanelProps {
title?: string;
items: MediaMetaItem[];
className?: string;
}
export function MediaMetaPanel({
title = 'Asset Details',
items,
className = '',
}: MediaMetaPanelProps) {
return (
<Box
bg="bg-charcoal-outline/10"
border
borderColor="border-charcoal-outline/50"
rounded="lg"
p={4}
className={className}
>
<Box display="flex" alignItems="center" gap={2} mb={4}>
<Icon icon={Info} size={4} color="text-blue-500" />
<Text weight="semibold" size="sm" uppercase letterSpacing="wider">
{title}
</Text>
</Box>
<Box display="flex" flexDirection="col" gap={3}>
{items.map((item, index) => (
<Box
key={`${item.label}-${index}`}
display="flex"
alignItems="center"
justifyContent="between"
pb={index === items.length - 1 ? 0 : 3}
borderBottom={index === items.length - 1 ? false : true}
borderColor="border-charcoal-outline/20"
>
<Box display="flex" alignItems="center" gap={2}>
{item.icon && <Icon icon={item.icon} size={3.5} color="text-gray-500" />}
<Text size="xs" color="text-gray-500" weight="medium">
{item.label}
</Text>
</Box>
<Text size="xs" weight="semibold" font="mono">
{item.value}
</Text>
</Box>
))}
</Box>
</Box>
);
}
// Helper to map common media metadata
export const mapMediaMetadata = (metadata: {
filename?: string;
size?: number;
dimensions?: string;
contentType?: string;
createdAt?: string | Date;
}): MediaMetaItem[] => {
const items: MediaMetaItem[] = [];
if (metadata.filename) {
items.push({ label: 'Filename', value: metadata.filename, icon: FileText });
}
if (metadata.size) {
items.push({ label: 'Size', value: `${Math.round(metadata.size / 1024)} KB`, icon: Maximize2 });
}
if (metadata.dimensions) {
items.push({ label: 'Dimensions', value: metadata.dimensions, icon: Maximize2 });
}
if (metadata.contentType) {
items.push({ label: 'Type', value: metadata.contentType, icon: Type });
}
if (metadata.createdAt) {
const date = typeof metadata.createdAt === 'string' ? new Date(metadata.createdAt) : metadata.createdAt;
items.push({ label: 'Created', value: date.toLocaleDateString(), icon: Calendar });
}
return items;
};

View File

@@ -0,0 +1,93 @@
import React from 'react';
import { Box } from './Box';
import { Text } from './Text';
import { ImagePlaceholder } from './ImagePlaceholder';
import { Image } from './Image';
export interface MediaPreviewCardProps {
src?: string;
alt?: string;
title?: string;
subtitle?: string;
aspectRatio?: string;
isLoading?: boolean;
error?: string;
onClick?: () => void;
className?: string;
actions?: React.ReactNode;
}
export function MediaPreviewCard({
src,
alt = 'Media preview',
title,
subtitle,
aspectRatio = '16/9',
isLoading,
error,
onClick,
className = '',
actions,
}: MediaPreviewCardProps) {
return (
<Box
display="flex"
flexDirection="col"
bg="bg-charcoal-outline/10"
border
borderColor="border-charcoal-outline/50"
rounded="lg"
overflow="hidden"
transition
hoverScale={!!onClick}
cursor={onClick ? 'pointer' : 'default'}
onClick={onClick}
className={`group ${className}`}
>
<Box position="relative" width="full" style={{ aspectRatio }}>
{isLoading ? (
<ImagePlaceholder variant="loading" aspectRatio={aspectRatio} rounded="none" />
) : error ? (
<ImagePlaceholder variant="error" message={error} aspectRatio={aspectRatio} rounded="none" />
) : src ? (
<Image
src={src}
alt={alt}
className="w-full h-full object-cover"
/>
) : (
<ImagePlaceholder aspectRatio={aspectRatio} rounded="none" />
)}
{actions && (
<Box
position="absolute"
top={2}
right={2}
display="flex"
gap={2}
opacity={0}
className="group-hover:opacity-100 transition-opacity"
>
{actions}
</Box>
)}
</Box>
{(title || subtitle) && (
<Box p={3} borderTop borderColor="border-charcoal-outline/30">
{title && (
<Text block size="sm" weight="semibold" truncate>
{title}
</Text>
)}
{subtitle && (
<Text block size="xs" color="text-gray-500" truncate mt={0.5}>
{subtitle}
</Text>
)}
</Box>
)}
</Box>
);
}

View File

@@ -0,0 +1,64 @@
import React from 'react';
import { LucideIcon } from 'lucide-react';
import { Box } from './Box';
import { Text } from './Text';
import { Icon } from './Icon';
interface MetricCardProps {
label: string;
value: string | number;
icon?: LucideIcon;
color?: string;
trend?: {
value: number;
isPositive: boolean;
};
border?: boolean;
bg?: string;
}
/**
* A semantic component for displaying metrics.
* Instrument-grade typography and dense-but-readable hierarchy.
*/
export function MetricCard({
label,
value,
icon,
color = 'text-primary-accent',
trend,
border = true,
bg = 'panel-gray/40',
}: MetricCardProps) {
return (
<Box
bg={bg}
rounded="none"
p={4}
border={border}
borderColor="border-gray/30"
display="flex"
flexDirection="col"
gap={2}
hoverBg="panel-gray/60"
transition
>
<Box display="flex" alignItems="center" justifyContent="between">
<Box display="flex" alignItems="center" gap={2}>
{icon && <Icon icon={icon} size={4} className={color} />}
<Text size="xs" weight="bold" color="text-gray-500" uppercase letterSpacing="widest">
{label}
</Text>
</Box>
{trend && (
<Text size="xs" color={trend.isPositive ? 'text-success-green' : 'text-red-400'} font="mono">
{trend.isPositive ? '▲' : '▼'} {trend.value}%
</Text>
)}
</Box>
<Text size="2xl" weight="bold" color="text-white" font="mono">
{typeof value === 'number' ? value.toLocaleString() : value}
</Text>
</Box>
);
}

View File

@@ -0,0 +1,59 @@
import { Button } from '@/ui/Button';
import { Stack } from '@/ui/Stack';
interface OnboardingCTAProps {
onBack?: () => void;
onNext?: () => void;
nextLabel?: string;
backLabel?: string;
isLastStep?: boolean;
canNext?: boolean;
isLoading?: boolean;
type?: 'button' | 'submit';
}
export function OnboardingCTA({
onBack,
onNext,
nextLabel = 'Continue',
backLabel = 'Back',
isLastStep = false,
canNext = true,
isLoading = false,
type = 'button',
}: OnboardingCTAProps) {
return (
<Stack direction="row" justify="between" mt={8} gap={4}>
{onBack ? (
<Button
type="button"
variant="secondary"
onClick={onBack}
disabled={isLoading}
className="px-8"
>
{backLabel}
</Button>
) : (
<div />
)}
<Button
type={type}
variant="primary"
onClick={onNext}
disabled={isLoading || !canNext}
className="px-8 min-w-[140px]"
>
{isLoading ? (
<Stack direction="row" gap={2} align="center">
<span className="animate-spin"></span>
<span>Processing...</span>
</Stack>
) : (
isLastStep ? 'Complete Setup' : nextLabel
)}
</Button>
</Stack>
);
}

View File

@@ -0,0 +1,22 @@
import { Stack } from '@/ui/Stack';
import { Text } from '@/ui/Text';
interface OnboardingStepHeaderProps {
title: string;
description?: string;
}
export function OnboardingStepHeader({ title, description }: OnboardingStepHeaderProps) {
return (
<Stack gap={1} mb={6}>
<Text size="2xl" color="text-white" weight="bold" className="tracking-tight" block>
{title}
</Text>
{description && (
<Text size="sm" color="text-gray-400" block>
{description}
</Text>
)}
</Stack>
);
}

View File

@@ -0,0 +1,20 @@
import { Surface } from '@/ui/Surface';
interface OnboardingStepPanelProps {
children: React.ReactNode;
className?: string;
}
export function OnboardingStepPanel({ children, className = '' }: OnboardingStepPanelProps) {
return (
<Surface
variant="dark"
rounded="xl"
border
padding={6}
className={`border-charcoal-outline ${className}`}
>
{children}
</Surface>
);
}

57
apps/website/ui/Panel.tsx Normal file
View File

@@ -0,0 +1,57 @@
import React, { ReactNode } from 'react';
import { Surface } from './Surface';
import { Box, BoxProps } from './Box';
import { Text } from './Text';
interface PanelProps extends Omit<BoxProps<'div'>, 'variant' | 'padding'> {
children: ReactNode;
title?: string;
description?: string;
variant?: 'default' | 'muted' | 'dark' | 'glass';
padding?: 0 | 1 | 2 | 3 | 4 | 6 | 8 | 10 | 12;
border?: boolean;
className?: string;
}
/**
* A semantic wrapper for content panels.
* Follows the "Precision Racing Minimal" theme.
*/
export function Panel({
children,
title,
description,
variant = 'default',
padding = 6,
border = true,
...props
}: PanelProps) {
return (
<Surface
variant={variant}
padding={padding}
border={border}
display="flex"
flexDirection="col"
gap={4}
// eslint-disable-next-line @typescript-eslint/no-explicit-any
{...(props as any)}
>
{(title || description) && (
<Box display="flex" flexDirection="col" gap={1} borderBottom borderStyle="solid" borderColor="border-gray/30" pb={4} mb={2}>
{title && (
<Text as="h3" size="xs" weight="bold" color="text-gray-500" uppercase letterSpacing="widest">
{title}
</Text>
)}
{description && (
<Text size="sm" color="text-gray-400">
{description}
</Text>
)}
</Box>
)}
{children}
</Surface>
);
}

View File

@@ -0,0 +1,42 @@
import React, { ComponentProps } from 'react';
import { Eye, EyeOff, Lock } from 'lucide-react';
import { Input } from './Input';
import { Box } from './Box';
interface PasswordFieldProps extends ComponentProps<typeof Input> {
showPassword?: boolean;
onTogglePassword?: () => void;
}
/**
* PasswordField
*
* A specialized input for passwords with visibility toggling.
* Stateless UI component.
*/
export function PasswordField({ showPassword, onTogglePassword, ...props }: PasswordFieldProps) {
return (
<Box position="relative" fullWidth>
<Input
{...props}
type={showPassword ? 'text' : 'password'}
icon={<Lock size={16} />}
/>
{onTogglePassword && (
<Box
as="button"
type="button"
onClick={onTogglePassword}
position="absolute"
right="3"
top={props.label ? "34px" : "50%"}
style={props.label ? {} : { transform: 'translateY(-50%)' }}
zIndex={10}
className="text-gray-500 hover:text-gray-300 transition-colors"
>
{showPassword ? <EyeOff size={16} /> : <Eye size={16} />}
</Box>
)}
</Box>
);
}

View File

@@ -1,9 +0,0 @@
import type { ReactNode } from 'react';
interface ProfileLayoutShellProps {
children: ReactNode;
}
export function ProfileLayoutShell({ children }: ProfileLayoutShellProps) {
return <div className="min-h-screen bg-deep-graphite">{children}</div>;
}

View File

@@ -0,0 +1,46 @@
import React from 'react';
import { Panel } from './Panel';
import { Box } from './Box';
import { Button } from './Button';
import { Icon } from './Icon';
import { LucideIcon } from 'lucide-react';
interface QuickAction {
label: string;
icon: LucideIcon;
onClick: () => void;
variant?: 'primary' | 'secondary' | 'ghost';
}
interface QuickActionsPanelProps {
actions: QuickAction[];
className?: string;
}
/**
* QuickActionsPanel
*
* Provides fast access to common dashboard tasks.
*/
export function QuickActionsPanel({ actions, className = '' }: QuickActionsPanelProps) {
return (
<Panel title="Quick Actions" className={className}>
<Box display="grid" gridCols={1} gap={2}>
{actions.map((action, index) => (
<Button
key={index}
variant={action.variant || 'secondary'}
onClick={action.onClick}
fullWidth
style={{ justifyContent: 'flex-start', height: 'auto', padding: '12px' }}
>
<Box display="flex" align="center" gap={3}>
<Icon icon={action.icon} size={5} />
<span>{action.label}</span>
</Box>
</Button>
))}
</Box>
</Panel>
);
}

View File

@@ -0,0 +1,146 @@
import React from 'react';
import { Stack } from './Stack';
import { Button } from './Button';
import { Icon } from './Icon';
import { Trophy, Scale, LogOut, CheckCircle, XCircle, PlayCircle } from 'lucide-react';
interface RaceActionBarProps {
status: 'scheduled' | 'running' | 'completed' | 'cancelled' | string;
isUserRegistered: boolean;
canRegister: boolean;
onRegister?: () => void;
onWithdraw?: () => void;
onResultsClick?: () => void;
onStewardingClick?: () => void;
onFileProtest?: () => void;
isAdmin?: boolean;
onCancel?: () => void;
onReopen?: () => void;
onEndRace?: () => void;
isLoading?: {
register?: boolean;
withdraw?: boolean;
cancel?: boolean;
reopen?: boolean;
complete?: boolean;
};
}
export function RaceActionBar({
status,
isUserRegistered,
canRegister,
onRegister,
onWithdraw,
onResultsClick,
onStewardingClick,
onFileProtest,
isAdmin,
onCancel,
onReopen,
onEndRace,
isLoading = {}
}: RaceActionBarProps) {
return (
<Stack direction="row" gap={3} wrap>
{status === 'scheduled' && (
<>
{!isUserRegistered && canRegister && (
<Button
variant="primary"
onClick={onRegister}
disabled={isLoading.register}
icon={<Icon icon={CheckCircle} size={4} />}
>
Register
</Button>
)}
{isUserRegistered && (
<Button
variant="secondary"
onClick={onWithdraw}
disabled={isLoading.withdraw}
icon={<Icon icon={LogOut} size={4} />}
>
Withdraw
</Button>
)}
{isAdmin && (
<Button
variant="danger"
onClick={onCancel}
disabled={isLoading.cancel}
icon={<Icon icon={XCircle} size={4} />}
>
Cancel Race
</Button>
)}
</>
)}
{status === 'running' && (
<>
<Button variant="race-final" disabled icon={<Icon icon={PlayCircle} size={4} />}>
Live Now
</Button>
{isAdmin && (
<Button
variant="primary"
onClick={onEndRace}
disabled={isLoading.complete}
>
End Race
</Button>
)}
</>
)}
{status === 'completed' && (
<>
<Button
variant="primary"
onClick={onResultsClick}
icon={<Icon icon={Trophy} size={4} />}
>
View Results
</Button>
{isUserRegistered && onFileProtest && (
<Button
variant="secondary"
onClick={onFileProtest}
icon={<Icon icon={Scale} size={4} />}
>
File Protest
</Button>
)}
<Button
variant="secondary"
onClick={onStewardingClick}
icon={<Icon icon={Scale} size={4} />}
>
Stewarding
</Button>
{isAdmin && onReopen && (
<Button
variant="secondary"
onClick={onReopen}
disabled={isLoading.reopen}
>
Reopen Race
</Button>
)}
</>
)}
{status === 'cancelled' && isAdmin && onReopen && (
<Button
variant="primary"
onClick={onReopen}
disabled={isLoading.reopen}
>
Reopen Race
</Button>
)}
</Stack>
);
}

View File

@@ -0,0 +1,95 @@
import React from 'react';
import { Box } from './Box';
import { Text } from './Text';
import { Stack } from './Stack';
import { RaceStatusBadge } from './RaceStatusBadge';
import { Icon } from './Icon';
import { Calendar, MapPin, Car } from 'lucide-react';
interface RaceHeaderPanelProps {
track: string;
car: string;
scheduledAt: string;
status: string;
leagueName?: string;
actions?: React.ReactNode;
}
export function RaceHeaderPanel({
track,
car,
scheduledAt,
status,
leagueName,
actions
}: RaceHeaderPanelProps) {
return (
<Box
bg="bg-panel-gray"
rounded="xl"
border
borderColor="border-charcoal-outline"
overflow="hidden"
position="relative"
>
{/* Background Accent */}
<Box
position="absolute"
top={0}
left={0}
right={0}
height="24"
bg="bg-gradient-to-r from-primary-blue/20 to-transparent"
opacity={0.5}
/>
<Box p={6} position="relative">
<Stack direction={{ base: 'col', md: 'row' }} gap={6} align="start" className="md:items-center">
{/* Info */}
<Box flexGrow={1}>
<Stack gap={3}>
<Stack direction="row" align="center" gap={3} wrap>
<Text as="h1" size="3xl" weight="bold" color="text-white">
{track}
</Text>
<RaceStatusBadge status={status} />
</Stack>
<Stack direction="row" align="center" gap={6} wrap>
<Stack direction="row" align="center" gap={2}>
<Icon icon={Car} size={4} color="#9ca3af" />
<Text size="sm" color="text-gray-400">
{car}
</Text>
</Stack>
<Stack direction="row" align="center" gap={2}>
<Icon icon={Calendar} size={4} color="#9ca3af" />
<Text size="sm" color="text-gray-400">
{scheduledAt}
</Text>
</Stack>
{leagueName && (
<Stack direction="row" align="center" gap={2}>
<Icon icon={MapPin} size={4} color="#9ca3af" />
<Text size="sm" color="text-gray-400">
{leagueName}
</Text>
</Stack>
)}
</Stack>
</Stack>
</Box>
{/* Actions */}
{actions && (
<Box flexShrink={0} width={{ base: 'full', md: 'auto' }}>
{actions}
</Box>
)}
</Stack>
</Box>
</Box>
);
}

View File

@@ -1,13 +1,12 @@
import React from 'react';
import { Flag, CalendarDays, Clock, Zap, Trophy } from 'lucide-react';
import { Flag, CalendarDays, Clock, Zap, Trophy, LucideIcon } from 'lucide-react';
import { Heading } from '@/ui/Heading';
import { Text } from '@/ui/Text';
import { Hero } from '@/ui/Hero';
import { Stack } from '@/ui/Stack';
import { Box } from '@/ui/Box';
import { Icon } from '@/ui/Icon';
import { Grid } from '@/ui/Grid';
import { StatBox } from '@/ui/StatBox';
import { Surface } from '@/ui/Surface';
interface RacePageHeaderProps {
totalCount: number;
@@ -23,26 +22,57 @@ export function RacePageHeader({
completedCount,
}: RacePageHeaderProps) {
return (
<Hero variant="primary">
<Stack gap={2}>
<Stack direction="row" align="center" gap={3}>
<Box p={2} bg="bg-primary-blue/10" rounded="lg">
<Icon icon={Flag} size={6} color="rgb(59, 130, 246)" />
</Box>
<Heading level={1}>Race Calendar</Heading>
</Stack>
<Text color="text-gray-400" maxWidth="42rem">
Track upcoming races, view live events, and explore results across all your leagues.
</Text>
</Stack>
<Surface
bg="bg-panel-gray"
rounded="xl"
border
borderColor="border-charcoal-outline"
padding={6}
position="relative"
overflow="hidden"
>
{/* Background Accent */}
<Box
position="absolute"
top={0}
left={0}
right={0}
height="1"
bg="bg-primary-blue"
/>
{/* Quick Stats */}
<Grid cols={2} mdCols={4} gap={4} mt={6}>
<StatBox icon={CalendarDays} label="Total" value={totalCount} />
<StatBox icon={Clock} label="Scheduled" value={scheduledCount} color="rgb(59, 130, 246)" />
<StatBox icon={Zap} label="Live Now" value={runningCount} color="rgb(16, 185, 129)" />
<StatBox icon={Trophy} label="Completed" value={completedCount} />
</Grid>
</Hero>
<Stack gap={6}>
<Stack gap={2}>
<Stack direction="row" align="center" gap={3}>
<Icon icon={Flag} size={6} color="#198CFF" />
<Heading level={1}>RACE DASHBOARD</Heading>
</Stack>
<Text color="text-gray-400" size="sm">
Precision tracking for upcoming sessions and live events.
</Text>
</Stack>
<Grid cols={2} mdCols={4} gap={4}>
<StatItem icon={CalendarDays} label="TOTAL SESSIONS" value={totalCount} />
<StatItem icon={Clock} label="SCHEDULED" value={scheduledCount} color="text-primary-blue" />
<StatItem icon={Zap} label="LIVE NOW" value={runningCount} color="text-performance-green" />
<StatItem icon={Trophy} label="COMPLETED" value={completedCount} color="text-gray-400" />
</Grid>
</Stack>
</Surface>
);
}
function StatItem({ icon, label, value, color = 'text-white' }: { icon: LucideIcon, label: string, value: number, color?: string }) {
return (
<Box p={4} bg="bg-graphite-black/50" border borderColor="border-charcoal-outline">
<Stack gap={1}>
<Stack direction="row" align="center" gap={2}>
<Icon icon={icon} size={3} color={color === 'text-white' ? '#9ca3af' : undefined} className={color !== 'text-white' ? color : ''} />
<Text size="xs" color="text-gray-500" weight="bold" uppercase>{label}</Text>
</Stack>
<Text size="2xl" weight="bold" className={color}>{value}</Text>
</Stack>
</Box>
);
}

View File

@@ -129,7 +129,7 @@ export function RaceResultsTable({
<TableHeader>Points</TableHeader>
<TableHeader>+/-</TableHeader>
<TableHeader>Penalties</TableHeader>
{isAdmin && <TableHeader align="right">Actions</TableHeader>}
{isAdmin && <TableHeader className="text-right">Actions</TableHeader>}
</TableRow>
</TableHead>
<TableBody>
@@ -250,7 +250,7 @@ export function RaceResultsTable({
)}
</TableCell>
{isAdmin && (
<TableCell align="right">
<TableCell className="text-right">
{driver && penaltyButtonRenderer && penaltyButtonRenderer(driver)}
</TableCell>
)}

View File

@@ -0,0 +1,40 @@
import React from 'react';
import { Box } from './Box';
import { Text } from './Text';
import { Stack } from './Stack';
import { Icon } from './Icon';
import { LucideIcon } from 'lucide-react';
interface RaceSidebarPanelProps {
title: string;
icon?: LucideIcon;
children: React.ReactNode;
}
export function RaceSidebarPanel({
title,
icon,
children
}: RaceSidebarPanelProps) {
return (
<Box
bg="bg-panel-gray"
rounded="xl"
border
borderColor="border-charcoal-outline"
overflow="hidden"
>
<Box p={4} borderBottom="1px solid" borderColor="border-charcoal-outline" bg="bg-graphite-black/30">
<Stack direction="row" align="center" gap={2}>
{icon && <Icon icon={icon} size={4} color="#198CFF" />}
<Text weight="bold" size="sm" color="text-white" uppercase>
{title}
</Text>
</Stack>
</Box>
<Box p={4}>
{children}
</Box>
</Box>
);
}

View File

@@ -1,5 +1,5 @@
import React from 'react';
import { StatusBadge } from './StatusBadge';
import { Box } from './Box';
interface RaceStatusBadgeProps {
status: 'scheduled' | 'running' | 'completed' | 'cancelled' | string;
@@ -9,30 +9,52 @@ export function RaceStatusBadge({ status }: RaceStatusBadgeProps) {
const config = {
scheduled: {
variant: 'info' as const,
label: 'Scheduled',
label: 'SCHEDULED',
color: 'text-primary-blue',
bg: 'bg-primary-blue/10',
border: 'border-primary-blue/30'
},
running: {
variant: 'success' as const,
label: 'LIVE',
color: 'text-performance-green',
bg: 'bg-performance-green/10',
border: 'border-performance-green/30'
},
completed: {
variant: 'neutral' as const,
label: 'Completed',
label: 'COMPLETED',
color: 'text-gray-400',
bg: 'bg-gray-400/10',
border: 'border-gray-400/30'
},
cancelled: {
variant: 'warning' as const,
label: 'Cancelled',
label: 'CANCELLED',
color: 'text-warning-amber',
bg: 'bg-warning-amber/10',
border: 'border-warning-amber/30'
},
};
const badgeConfig = config[status as keyof typeof config] || {
variant: 'neutral' as const,
label: status,
label: status.toUpperCase(),
color: 'text-gray-400',
bg: 'bg-gray-400/10',
border: 'border-gray-400/30'
};
return (
<StatusBadge variant={badgeConfig.variant}>
<Box
px={2.5}
py={0.5}
rounded="none"
border
className={`${badgeConfig.bg} ${badgeConfig.color} ${badgeConfig.border}`}
style={{ fontSize: '10px', fontWeight: '800', letterSpacing: '0.05em' }}
>
{badgeConfig.label}
</StatusBadge>
</Box>
);
}

View File

@@ -0,0 +1,29 @@
import React from 'react';
interface RatingBadgeProps {
rating: number;
size?: 'sm' | 'md' | 'lg';
className?: string;
}
export function RatingBadge({ rating, size = 'md', className = '' }: RatingBadgeProps) {
const getColor = (val: number) => {
if (val >= 2500) return 'text-yellow-400 bg-yellow-400/10 border-yellow-400/20';
if (val >= 2000) return 'text-purple-400 bg-purple-400/10 border-purple-400/20';
if (val >= 1500) return 'text-primary-blue bg-primary-blue/10 border-primary-blue/20';
if (val >= 1000) return 'text-performance-green bg-performance-green/10 border-performance-green/20';
return 'text-gray-400 bg-gray-400/10 border-gray-400/20';
};
const sizeMap = {
sm: 'px-1.5 py-0.5 text-[10px]',
md: 'px-2 py-1 text-xs',
lg: 'px-3 py-1.5 text-sm',
};
return (
<div className={`inline-flex items-center justify-center font-mono font-bold rounded border ${sizeMap[size]} ${getColor(rating)} ${className}`}>
{rating.toLocaleString()}
</div>
);
}

View File

@@ -0,0 +1,85 @@
import React, { ReactNode } from 'react';
import { Box } from './Box';
import { Stack } from './Stack';
import { Text } from './Text';
import { Table, TableHead, TableBody, TableRow, TableHeader, TableCell } from './Table';
interface RosterTableProps {
children: ReactNode;
columns?: string[];
}
export function RosterTable({ children, columns = ['Driver', 'Role', 'Joined', 'Rating', 'Rank'] }: RosterTableProps) {
return (
<Box border borderColor="border-steel-grey" bg="surface-charcoal/50" overflow="hidden">
<Table>
<TableHead className="bg-base-graphite/50">
<TableRow>
{columns.map((col) => (
<TableHeader key={col} className="py-3 border-b border-border-steel-grey">
<Text size="xs" weight="bold" color="text-gray-500" className="uppercase tracking-widest" block>
{col}
</Text>
</TableHeader>
))}
<TableHeader className="py-3 border-b border-border-steel-grey">
{null}
</TableHeader>
</TableRow>
</TableHead>
<TableBody>
{children}
</TableBody>
</Table>
</Box>
);
}
interface RosterTableRowProps {
driver: ReactNode;
role: ReactNode;
joined: string;
rating: ReactNode;
rank: ReactNode;
actions?: ReactNode;
onClick?: () => void;
}
export function RosterTableRow({
driver,
role,
joined,
rating,
rank,
actions,
onClick,
}: RosterTableRowProps) {
return (
<TableRow
onClick={onClick}
clickable={!!onClick}
className="group hover:bg-primary-blue/5 transition-colors border-b border-border-steel-grey/30 last:border-0"
>
<TableCell className="py-4">
{driver}
</TableCell>
<TableCell className="py-4">
{role}
</TableCell>
<TableCell className="py-4">
<Text size="sm" color="text-gray-400" font="mono">{joined}</Text>
</TableCell>
<TableCell className="py-4">
{rating}
</TableCell>
<TableCell className="py-4">
{rank}
</TableCell>
<TableCell className="py-4 text-right">
<Stack direction="row" align="center" justify="end" gap={2}>
{actions}
</Stack>
</TableCell>
</TableRow>
);
}

View File

@@ -14,6 +14,10 @@ interface SectionProps {
id?: string;
py?: number;
minHeight?: string;
borderBottom?: boolean;
borderColor?: string;
overflow?: 'hidden' | 'visible' | 'auto' | 'scroll';
position?: 'relative' | 'absolute' | 'fixed' | 'sticky';
}
export function Section({
@@ -24,7 +28,11 @@ export function Section({
variant = 'default',
id,
py = 16,
minHeight
minHeight,
borderBottom,
borderColor,
overflow,
position
}: SectionProps) {
const variantClasses = {
default: '',
@@ -40,7 +48,18 @@ export function Section({
].filter(Boolean).join(' ');
return (
<Box as="section" id={id} className={classes} py={py as 0} px={4} minHeight={minHeight}>
<Box
as="section"
id={id}
className={classes}
py={py as 0}
px={4}
minHeight={minHeight}
borderBottom={borderBottom}
borderColor={borderColor}
overflow={overflow}
position={position}
>
<Box className="mx-auto max-w-7xl">
{(title || description) && (
<Box mb={8}>

View File

@@ -0,0 +1,68 @@
import React from 'react';
import { Panel } from './Panel';
import { Text } from './Text';
import { Box } from './Box';
import { StatusDot } from './StatusDot';
interface SessionSummaryPanelProps {
title: string;
status: 'live' | 'upcoming' | 'completed';
startTime?: string;
trackName?: string;
carName?: string;
className?: string;
}
/**
* SessionSummaryPanel
*
* Displays a dense summary of a racing session.
* Part of the "Telemetry Workspace" layout.
*/
export function SessionSummaryPanel({
title,
status,
startTime,
trackName,
carName,
className = '',
}: SessionSummaryPanelProps) {
const statusColor = status === 'live' ? '#4ED4E0' : status === 'upcoming' ? '#FFBE4D' : '#94a3b8';
return (
<Panel title="Session Summary" className={className}>
<Box display="flex" flexDirection="col" gap={3}>
<Box display="flex" align="center" justify="between">
<Text weight="bold" size="lg">{title}</Text>
<Box display="flex" align="center" gap={2}>
<StatusDot color={statusColor} pulse={status === 'live'} size={2} />
<Text size="xs" uppercase weight="bold" style={{ color: statusColor }}>
{status}
</Text>
</Box>
</Box>
<Box display="grid" gridCols={2} gap={4} borderTop borderStyle="solid" borderColor="border-gray/10" pt={3}>
{startTime && (
<Box>
<Text size="xs" color="text-gray-500" uppercase block mb={1}>Start Time</Text>
<Text size="sm">{startTime}</Text>
</Box>
)}
{trackName && (
<Box>
<Text size="xs" color="text-gray-500" uppercase block mb={1}>Track</Text>
<Text size="sm">{trackName}</Text>
</Box>
)}
{carName && (
<Box colSpan={2}>
<Text size="xs" color="text-gray-500" uppercase block mb={1}>Vehicle</Text>
<Text size="sm">{carName}</Text>
</Box>
)}
</Box>
</Box>
</Panel>
);
}

View File

@@ -0,0 +1,36 @@
'use client';
import React from 'react';
import { Box } from './Box';
interface CheckboxProps {
checked: boolean;
onChange: (checked: boolean) => void;
disabled?: boolean;
'aria-label'?: string;
}
/**
* SimpleCheckbox
*
* A checkbox without a label for use in tables.
*/
export function SimpleCheckbox({ checked, onChange, disabled, 'aria-label': ariaLabel }: CheckboxProps) {
return (
<Box
as="input"
type="checkbox"
checked={checked}
onChange={(e: React.ChangeEvent<HTMLInputElement>) => onChange(e.target.checked)}
disabled={disabled}
w="4"
h="4"
bg="bg-deep-graphite"
border
borderColor="border-charcoal-outline"
rounded="sm"
aria-label={ariaLabel}
className="text-primary-blue focus:ring-primary-blue"
/>
);
}

View File

@@ -1,31 +1,53 @@
/**
* SponsorLogo
*
* Pure UI component for displaying sponsor logos.
* Renders an optimized image with fallback on error.
*/
import React from 'react';
import { Box } from './Box';
import { Image } from './Image';
import { Building2 } from 'lucide-react';
import { Icon } from './Icon';
export interface SponsorLogoProps {
sponsorId: string;
sponsorId?: string;
src?: string;
alt: string;
size?: number;
className?: string;
border?: boolean;
rounded?: 'none' | 'sm' | 'md' | 'lg' | 'xl' | 'full';
}
export function SponsorLogo({ sponsorId, alt, className = '' }: SponsorLogoProps) {
export function SponsorLogo({
sponsorId,
src,
alt,
size = 48,
className = '',
border = true,
rounded = 'md',
}: SponsorLogoProps) {
const logoSrc = src || (sponsorId ? `/media/sponsors/${sponsorId}/logo` : undefined);
return (
<Image
src={`/media/sponsors/${sponsorId}/logo`}
alt={alt}
width={100}
height={100}
className={`object-contain ${className}`}
onError={(e) => {
// Fallback to default logo
(e.target as HTMLImageElement).src = '/default-sponsor-logo.png';
}}
/>
<Box
display="flex"
alignItems="center"
justifyContent="center"
rounded={rounded}
overflow="hidden"
bg="bg-charcoal-outline/10"
border={border}
borderColor="border-charcoal-outline/50"
className={className}
style={{ width: size, height: size, flexShrink: 0 }}
>
{logoSrc ? (
<Image
src={logoSrc}
alt={alt}
className="w-full h-full object-contain p-1"
fallbackSrc="/default-sponsor-logo.png"
/>
) : (
<Icon icon={Building2} size={size > 32 ? 5 : 4} color="text-gray-500" />
)}
</Box>
);
}

View File

@@ -0,0 +1,58 @@
import React from 'react';
import { Grid } from './Grid';
import { GridItem } from './GridItem';
import { Surface } from './Surface';
import { Text } from './Text';
import { Stack } from './Stack';
type GridCols = 1 | 2 | 3 | 4 | 5 | 6 | 12;
interface StatItem {
label: string;
value: string | number;
subValue?: string;
color?: string;
icon?: React.ElementType;
}
interface StatGridProps {
stats: StatItem[];
cols?: GridCols;
mdCols?: GridCols;
lgCols?: GridCols;
className?: string;
}
export function StatGrid({ stats, cols = 2, mdCols = 3, lgCols = 4, className = '' }: StatGridProps) {
return (
<Grid
cols={cols}
mdCols={mdCols}
lgCols={lgCols}
gap={4}
className={className}
>
{stats.map((stat, index) => (
<GridItem key={index}>
<Surface variant="muted" padding={4} rounded="lg" border className="h-full">
<Stack gap={1}>
<Text size="xs" color="text-gray-500" uppercase weight="semibold" letterSpacing="wider">
{stat.label}
</Text>
<Stack direction="row" align="baseline" gap={2}>
<Text size="2xl" weight="bold" font="mono" color={stat.color || 'text-white'}>
{stat.value}
</Text>
{stat.subValue && (
<Text size="xs" color="text-gray-500" font="mono">
{stat.subValue}
</Text>
)}
</Stack>
</Stack>
</Surface>
</GridItem>
))}
</Grid>
);
}

View File

@@ -0,0 +1,43 @@
import React from 'react';
import { Box } from './Box';
interface StatusDotProps {
color?: string;
pulse?: boolean;
size?: number;
className?: string;
}
/**
* StatusDot
*
* A simple status indicator dot with optional pulse effect.
*/
export function StatusDot({
color = '#4ED4E0',
pulse = false,
size = 2,
className = ''
}: StatusDotProps) {
const sizeClass = `w-${size} h-${size}`;
return (
<Box position="relative" className={`${sizeClass} ${className}`}>
<Box
w="full"
h="full"
rounded="full"
style={{ backgroundColor: color }}
/>
{pulse && (
<Box
position="absolute"
inset={0}
rounded="full"
className="animate-ping"
style={{ backgroundColor: color, opacity: 0.75 }}
/>
)}
</Box>
);
}

View File

@@ -0,0 +1,22 @@
import { Box } from '@/ui/Box';
import { motion } from 'framer-motion';
interface StepProgressRailProps {
currentStep: number;
totalSteps: number;
}
export function StepProgressRail({ currentStep, totalSteps }: StepProgressRailProps) {
const progress = (currentStep / totalSteps) * 100;
return (
<Box w="full" h="1" bg="bg-iron-gray" rounded="full" overflow="hidden" mb={8}>
<motion.div
className="h-full bg-primary-blue"
initial={{ width: 0 }}
animate={{ width: `${progress}%` }}
transition={{ duration: 0.5, ease: 'easeOut' }}
/>
</Box>
);
}

View File

@@ -0,0 +1,117 @@
import React, { ReactNode } from 'react';
import { Box } from './Box';
import { Stack } from './Stack';
import { Heading } from './Heading';
import { Text } from './Text';
import { TeamLogo } from './TeamLogo';
import { TeamTag } from './TeamTag';
interface TeamHeaderPanelProps {
teamId: string;
name: string;
tag?: string | null;
description?: string;
memberCount: number;
activeLeaguesCount?: number;
foundedDate?: string;
category?: string | null;
actions?: ReactNode;
}
export function TeamHeaderPanel({
teamId,
name,
tag,
description,
memberCount,
activeLeaguesCount,
foundedDate,
category,
actions,
}: TeamHeaderPanelProps) {
return (
<Box
bg="surface-charcoal"
border
borderColor="border-steel-grey"
p={6}
className="relative overflow-hidden"
>
{/* Instrument-grade accent corner */}
<Box position="absolute" top="-1px" left="-1px" w="4" h="4" borderTop borderLeft borderColor="primary-blue/40" />
<Stack direction="row" align="start" justify="between" wrap gap={6}>
<Stack direction="row" align="start" gap={6} wrap flexGrow={1}>
{/* Logo Container */}
<Box
w="24"
h="24"
bg="base-graphite"
border
borderColor="border-steel-grey"
display="flex"
center
overflow="hidden"
className="relative"
>
<TeamLogo teamId={teamId} alt={name} />
{/* Corner detail */}
<Box position="absolute" bottom="0" right="0" w="2" h="2" bg="primary-blue/20" />
</Box>
<Box flexGrow={1} minWidth="0">
<Stack direction="row" align="center" gap={3} mb={2}>
<Heading level={1} weight="bold" className="tracking-tight">{name}</Heading>
{tag && <TeamTag tag={tag} />}
</Stack>
{description && (
<Text color="text-gray-400" block mb={4} maxWidth="42rem" size="sm" leading="relaxed">
{description}
</Text>
)}
<Stack direction="row" align="center" gap={4} wrap>
<Box display="flex" alignItems="center" gap={1.5}>
<Box w="1.5" h="1.5" bg="primary-blue" />
<Text size="xs" color="text-gray-300" font="mono" className="uppercase tracking-wider">
{memberCount} {memberCount === 1 ? 'Member' : 'Members'}
</Text>
</Box>
{category && (
<Box display="flex" alignItems="center" gap={1.5}>
<Box w="1.5" h="1.5" bg="telemetry-aqua" />
<Text size="xs" color="text-gray-300" font="mono" className="uppercase tracking-wider">
{category}
</Text>
</Box>
)}
{activeLeaguesCount !== undefined && (
<Box display="flex" alignItems="center" gap={1.5}>
<Box w="1.5" h="1.5" bg="warning-amber" />
<Text size="xs" color="text-gray-300" font="mono" className="uppercase tracking-wider">
{activeLeaguesCount} {activeLeaguesCount === 1 ? 'League' : 'Leagues'}
</Text>
</Box>
)}
{foundedDate && (
<Text size="xs" color="text-gray-500" font="mono" className="uppercase tracking-wider">
EST. {foundedDate}
</Text>
)}
</Stack>
</Box>
</Stack>
{actions && (
<Box>
{actions}
</Box>
)}
</Stack>
</Box>
);
}

View File

@@ -0,0 +1,75 @@
import React from 'react';
import { Box } from './Box';
import { Stack } from './Stack';
import { Text } from './Text';
import { Table, TableHead, TableBody, TableRow, TableHeader, TableCell } from './Table';
import { TeamLogo } from './TeamLogo';
import { RankBadge } from './RankBadge';
interface TeamLeaderboardPanelProps {
teams: Array<{
id: string;
name: string;
logoUrl?: string;
rating: number;
wins: number;
races: number;
memberCount: number;
}>;
onTeamClick: (id: string) => void;
}
export function TeamLeaderboardPanel({ teams, onTeamClick }: TeamLeaderboardPanelProps) {
return (
<Box border borderColor="border-steel-grey" bg="surface-charcoal/50" overflow="hidden">
<Table>
<TableHead className="bg-base-graphite/50">
<TableRow>
<TableHeader className="w-16 text-center">Rank</TableHeader>
<TableHeader>Team</TableHeader>
<TableHeader className="text-center">Rating</TableHeader>
<TableHeader className="text-center">Wins</TableHeader>
<TableHeader className="text-center">Races</TableHeader>
<TableHeader className="text-center">Members</TableHeader>
</TableRow>
</TableHead>
<TableBody>
{teams.map((team, index) => (
<TableRow
key={team.id}
onClick={() => onTeamClick(team.id)}
clickable
className="group hover:bg-primary-blue/5 transition-colors border-b border-border-steel-grey/30 last:border-0"
>
<TableCell className="text-center">
<RankBadge rank={index + 1} />
</TableCell>
<TableCell>
<Stack direction="row" align="center" gap={3}>
<Box w="8" h="8" bg="base-graphite" border borderColor="border-steel-grey" display="flex" center overflow="hidden">
<TeamLogo teamId={team.id} alt={team.name} />
</Box>
<Text weight="bold" size="sm" color="text-white" className="group-hover:text-primary-blue transition-colors">
{team.name}
</Text>
</Stack>
</TableCell>
<TableCell className="text-center">
<Text font="mono" weight="bold" color="text-primary-blue">{team.rating}</Text>
</TableCell>
<TableCell className="text-center">
<Text font="mono" color="text-gray-300">{team.wins}</Text>
</TableCell>
<TableCell className="text-center">
<Text font="mono" color="text-gray-300">{team.races}</Text>
</TableCell>
<TableCell className="text-center">
<Text font="mono" color="text-gray-400" size="xs">{team.memberCount}</Text>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</Box>
);
}

View File

@@ -1,31 +1,53 @@
/**
* TeamLogo
*
* Pure UI component for displaying team logos.
* Renders an optimized image with fallback on error.
*/
import React from 'react';
import { Box } from './Box';
import { Image } from './Image';
import { Users } from 'lucide-react';
import { Icon } from './Icon';
export interface TeamLogoProps {
teamId: string;
teamId?: string;
src?: string;
alt: string;
size?: number;
className?: string;
border?: boolean;
rounded?: 'none' | 'sm' | 'md' | 'lg' | 'xl' | 'full';
}
export function TeamLogo({ teamId, alt, className = '' }: TeamLogoProps) {
export function TeamLogo({
teamId,
src,
alt,
size = 48,
className = '',
border = true,
rounded = 'md',
}: TeamLogoProps) {
const logoSrc = src || (teamId ? `/media/teams/${teamId}/logo` : undefined);
return (
<Image
src={`/media/teams/${teamId}/logo`}
alt={alt}
width={100}
height={100}
className={`object-contain ${className}`}
onError={(e) => {
// Fallback to default logo
(e.target as HTMLImageElement).src = '/default-team-logo.png';
}}
/>
<Box
display="flex"
alignItems="center"
justifyContent="center"
rounded={rounded}
overflow="hidden"
bg="bg-charcoal-outline/10"
border={border}
borderColor="border-charcoal-outline/50"
className={className}
style={{ width: size, height: size, flexShrink: 0 }}
>
{logoSrc ? (
<Image
src={logoSrc}
alt={alt}
className="w-full h-full object-contain p-1"
fallbackSrc="/default-team-logo.png"
/>
) : (
<Icon icon={Users} size={size > 32 ? 5 : 4} color="text-gray-500" />
)}
</Box>
);
}

View File

@@ -0,0 +1,50 @@
import React from 'react';
import { Box } from './Box';
import { Text } from './Text';
interface TelemetryItem {
label: string;
value: string | number;
trend?: 'up' | 'down' | 'neutral';
color?: string;
}
interface TelemetryStripProps {
items: TelemetryItem[];
className?: string;
}
/**
* TelemetryStrip
*
* A thin, dense strip showing key telemetry or performance metrics.
* Follows the "Precision Racing Minimal" theme.
*/
export function TelemetryStrip({ items, className = '' }: TelemetryStripProps) {
return (
<Box
className={`bg-graphite-black/80 border-y border-border-gray/30 py-2 px-4 flex items-center gap-8 overflow-x-auto no-scrollbar ${className}`}
>
{items.map((item, index) => (
<Box key={index} display="flex" align="center" gap={2} className="whitespace-nowrap">
<Text size="xs" color="text-gray-500" uppercase weight="bold" letterSpacing="widest">
{item.label}
</Text>
<Text
size="sm"
weight="bold"
style={{ color: item.color || '#4ED4E0' }}
className="font-mono"
>
{item.value}
</Text>
{item.trend && (
<Text size="xs" style={{ color: item.trend === 'up' ? '#10b981' : item.trend === 'down' ? '#ef4444' : '#94a3b8' }}>
{item.trend === 'up' ? '↑' : item.trend === 'down' ? '↓' : '•'}
</Text>
)}
</Box>
))}
</Box>
);
}

View File

@@ -36,7 +36,8 @@ interface TextProps<T extends ElementType = 'span'> extends Omit<BoxProps<T>, 'c
align?: TextAlign | ResponsiveTextAlign;
truncate?: boolean;
uppercase?: boolean;
letterSpacing?: 'tighter' | 'tight' | 'normal' | 'wide' | 'wider' | 'widest' | '0.05em';
capitalize?: boolean;
letterSpacing?: 'tighter' | 'tight' | 'normal' | 'wide' | 'wider' | 'widest' | '0.05em' | string;
leading?: 'none' | 'tight' | 'snug' | 'normal' | 'relaxed' | 'loose';
fontSize?: string;
style?: React.CSSProperties;
@@ -68,6 +69,7 @@ export function Text<T extends ElementType = 'span'>({
align = 'left',
truncate = false,
uppercase = false,
capitalize = false,
letterSpacing,
leading,
fontSize,
@@ -180,6 +182,7 @@ export function Text<T extends ElementType = 'span'>({
color,
truncate ? 'truncate' : '',
uppercase ? 'uppercase' : '',
capitalize ? 'capitalize' : '',
italic ? 'italic' : '',
letterSpacing === '0.05em' ? 'tracking-wider' : letterSpacing ? `tracking-${letterSpacing}` : '',
getSpacingClass('ml', ml),

View File

@@ -0,0 +1,17 @@
import React from 'react';
interface TopNavProps {
children: React.ReactNode;
className?: string;
}
/**
* TopNav is a horizontal navigation container used within the AppHeader.
*/
export function TopNav({ children, className = '' }: TopNavProps) {
return (
<nav className={`flex items-center justify-between w-full ${className}`}>
{children}
</nav>
);
}

View File

@@ -1,31 +1,46 @@
/**
* TrackImage
*
* Pure UI component for displaying track images.
* Renders an optimized image with fallback on error.
*/
import React from 'react';
import { Box } from './Box';
import { Image } from './Image';
import { ImagePlaceholder } from './ImagePlaceholder';
export interface TrackImageProps {
trackId: string;
trackId?: string;
src?: string;
alt: string;
aspectRatio?: string;
className?: string;
rounded?: 'none' | 'sm' | 'md' | 'lg' | 'xl' | '2xl';
}
export function TrackImage({ trackId, alt, className = '' }: TrackImageProps) {
export function TrackImage({
trackId,
src,
alt,
aspectRatio = '16/9',
className = '',
rounded = 'lg',
}: TrackImageProps) {
const imageSrc = src || (trackId ? `/media/tracks/${trackId}/image` : undefined);
return (
<Image
src={`/media/tracks/${trackId}/image`}
alt={alt}
width={800}
height={256}
className={`w-full h-64 object-cover ${className}`}
onError={(e) => {
// Fallback to default track image
(e.target as HTMLImageElement).src = '/default-track-image.png';
}}
/>
<Box
width="full"
overflow="hidden"
bg="bg-charcoal-outline/10"
rounded={rounded}
className={className}
style={{ aspectRatio }}
>
{imageSrc ? (
<Image
src={imageSrc}
alt={alt}
className="w-full h-full object-cover"
fallbackSrc="/default-track-image.png"
/>
) : (
<ImagePlaceholder aspectRatio={aspectRatio} rounded="none" />
)}
</Box>
);
}

View File

@@ -6,7 +6,7 @@ import { LucideIcon } from 'lucide-react';
export interface EmptyStateAction {
label: string;
onClick: () => void;
variant?: 'primary' | 'secondary' | 'danger' | 'race-performance' | 'race-final';
variant?: 'primary' | 'secondary' | 'danger' | 'ghost' | 'race-final' | 'discord';
icon?: LucideIcon;
}