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,51 @@
import React from 'react';
import { ChevronUp, ChevronDown, Minus } from 'lucide-react';
import { Box } from '@/ui/Box';
import { Text } from '@/ui/Text';
import { Icon } from '@/ui/Icon';
interface DeltaChipProps {
value: number;
type?: 'rank' | 'rating';
}
export function DeltaChip({ value, type = 'rank' }: DeltaChipProps) {
if (value === 0) {
return (
<Box display="flex" alignItems="center" gap={1} color="text-gray-600">
<Icon icon={Minus} size={3} />
<Text size="xs" font="mono">0</Text>
</Box>
);
}
const isPositive = value > 0;
const color = isPositive
? (type === 'rank' ? 'text-performance-green' : 'text-performance-green')
: (type === 'rank' ? 'text-error-red' : 'text-error-red');
// For rank, positive delta usually means dropping positions (e.g. +1 rank means 1st -> 2nd)
// But usually "Delta" in leaderboards means "positions gained/lost"
// Let's assume value is "positions gained" (positive = up, negative = down)
const IconComponent = isPositive ? ChevronUp : ChevronDown;
const absoluteValue = Math.abs(value);
return (
<Box
display="flex"
alignItems="center"
gap={0.5}
color={color}
bg={`${color.replace('text-', 'bg-')}/10`}
px={1.5}
py={0.5}
rounded="full"
>
<Icon icon={IconComponent} size={3} />
<Text size="xs" font="mono" weight="bold">
{absoluteValue}
</Text>
</Box>
);
}

View File

@@ -1,5 +1,5 @@
import React from 'react';
import { Trophy, Crown, Flag, ChevronRight } from 'lucide-react';
import { Trophy, ChevronRight } from 'lucide-react';
import { Button } from '@/ui/Button';
import { Box } from '@/ui/Box';
import { Text } from '@/ui/Text';
@@ -8,8 +8,9 @@ import { Icon } from '@/ui/Icon';
import { Stack } from '@/ui/Stack';
import { Image } from '@/ui/Image';
import { SkillLevelDisplay } from '@/lib/display-objects/SkillLevelDisplay';
import { MedalDisplay } from '@/lib/display-objects/MedalDisplay';
import { RatingDisplay } from '@/lib/display-objects/RatingDisplay';
import { RankMedal } from './RankMedal';
import { LeaderboardTableShell } from './LeaderboardTableShell';
interface DriverLeaderboardPreviewProps {
drivers: {
@@ -31,35 +32,54 @@ export function DriverLeaderboardPreview({ drivers, onDriverClick, onNavigateToD
const top10 = drivers; // Already sliced in builder
return (
<Box rounded="xl" bg="bg-iron-gray/30" border borderColor="border-charcoal-outline" overflow="hidden">
<Box display="flex" alignItems="center" justifyContent="between" px={5} py={4} borderBottom borderColor="border-charcoal-outline" bg="bg-iron-gray/20">
<LeaderboardTableShell>
<Box
display="flex"
alignItems="center"
justifyContent="between"
px={5}
py={4}
borderBottom
borderColor="border-charcoal-outline/50"
bg="bg-deep-charcoal/40"
>
<Box display="flex" alignItems="center" gap={3}>
<Box display="flex" h="10" w="10" alignItems="center" justifyContent="center" rounded="xl" bg="bg-gradient-to-br from-primary-blue/20 to-primary-blue/5" border borderColor="border-primary-blue/20">
<Box
display="flex"
h="10"
w="10"
alignItems="center"
justifyContent="center"
rounded="lg"
bg="bg-gradient-to-br from-primary-blue/15 to-primary-blue/5"
border
borderColor="border-primary-blue/20"
>
<Icon icon={Trophy} size={5} color="text-primary-blue" />
</Box>
<Box>
<Heading level={3} fontSize="lg" weight="semibold" color="text-white">Driver Rankings</Heading>
<Text size="xs" color="text-gray-500" block>Top performers across all leagues</Text>
<Heading level={3} fontSize="lg" weight="bold" color="text-white" letterSpacing="tight">Driver Rankings</Heading>
<Text size="xs" color="text-gray-500" block uppercase letterSpacing="wider" weight="bold">Top Performers</Text>
</Box>
</Box>
<Button
variant="secondary"
onClick={onNavigateToDrivers}
size="sm"
hoverBg="bg-primary-blue/10"
transition
>
<Stack direction="row" align="center" gap={2}>
<Text size="sm">View All</Text>
<Text size="sm" weight="medium">View All</Text>
<Icon icon={ChevronRight} size={4} />
</Stack>
</Button>
</Box>
<Stack gap={0}
// eslint-disable-next-line gridpilot-rules/component-classification
className="divide-y divide-charcoal-outline/50"
>
<Stack gap={0}>
{top10.map((driver, index) => {
const position = index + 1;
const isLast = index === top10.length - 1;
return (
<Box
@@ -75,71 +95,64 @@ export function DriverLeaderboardPreview({ drivers, onDriverClick, onNavigateToD
w="full"
textAlign="left"
transition
hoverBg="bg-iron-gray/30"
hoverBg="bg-white/[0.02]"
group
borderBottom={!isLast}
borderColor="border-charcoal-outline/30"
>
<Box
display="flex"
h="8"
w="8"
alignItems="center"
justifyContent="center"
rounded="full"
border
// eslint-disable-next-line gridpilot-rules/component-classification
className={`text-xs font-bold ${MedalDisplay.getBg(position)} ${MedalDisplay.getColor(position)}`}
>
{position <= 3 ? <Icon icon={Crown} size={3.5} /> : <Text weight="bold">{position}</Text>}
<Box w="8" display="flex" justifyContent="center">
<RankMedal rank={position} size="sm" />
</Box>
<Box position="relative" w="9" h="9" rounded="full" overflow="hidden" border borderWidth="2px" borderColor="border-charcoal-outline">
<Box
position="relative"
w="9"
h="9"
rounded="full"
overflow="hidden"
border
borderWidth="1px"
borderColor="border-charcoal-outline"
groupHoverBorderColor="primary-blue/50"
transition
>
<Image src={driver.avatarUrl} alt={driver.name} fullWidth fullHeight objectFit="cover" />
</Box>
<Box flexGrow={1} minWidth="0">
<Text weight="medium" color="text-white" truncate groupHoverTextColor="text-primary-blue" transition block>
<Text
weight="semibold"
color="text-white"
truncate
groupHoverTextColor="text-primary-blue"
transition
block
>
{driver.name}
</Text>
<Box display="flex" alignItems="center" gap={2}>
<Icon icon={Flag} size={3} color="text-gray-500" />
<Text size="xs" color="text-gray-500">{driver.nationality}</Text>
<Box as="span"
// eslint-disable-next-line gridpilot-rules/component-classification
className={SkillLevelDisplay.getColor(driver.skillLevel)}
>
<Text size="xs">{SkillLevelDisplay.getLabel(driver.skillLevel)}</Text>
<Box w="1" h="1" rounded="full" bg="bg-gray-700" />
<Box as="span" color={SkillLevelDisplay.getColor(driver.skillLevel)}>
<Text size="xs" weight="medium">{SkillLevelDisplay.getLabel(driver.skillLevel)}</Text>
</Box>
</Box>
</Box>
<Box display="flex" alignItems="center" gap={4}>
<Box textAlign="center">
<Text color="text-primary-blue" font="mono" weight="semibold" block>{RatingDisplay.format(driver.rating)}</Text>
<Text
// eslint-disable-next-line gridpilot-rules/component-classification
style={{ fontSize: '10px' }}
color="text-gray-500"
block
>
Rating
</Text>
<Box display="flex" alignItems="center" gap={6}>
<Box textAlign="right">
<Text color="text-primary-blue" font="mono" weight="bold" block size="sm">{RatingDisplay.format(driver.rating)}</Text>
<Text fontSize="10px" color="text-gray-500" block uppercase letterSpacing="wider" weight="bold">Rating</Text>
</Box>
<Box textAlign="center">
<Text color="text-performance-green" font="mono" weight="semibold" block>{driver.wins}</Text>
<Text
// eslint-disable-next-line gridpilot-rules/component-classification
style={{ fontSize: '10px' }}
color="text-gray-500"
block
>
Wins
</Text>
<Box textAlign="right" minWidth="12">
<Text color="text-performance-green" font="mono" weight="bold" block size="sm">{driver.wins}</Text>
<Text fontSize="10px" color="text-gray-500" block uppercase letterSpacing="wider" weight="bold">Wins</Text>
</Box>
</Box>
</Box>
);
})}
</Stack>
</Box>
</LeaderboardTableShell>
);
}
}

View File

@@ -0,0 +1,88 @@
import React from 'react';
import { Search, Filter } from 'lucide-react';
import { Box } from '@/ui/Box';
import { Stack } from '@/ui/Stack';
import { Icon } from '@/ui/Icon';
import { Text } from '@/ui/Text';
interface LeaderboardFiltersBarProps {
searchQuery?: string;
onSearchChange?: (query: string) => void;
placeholder?: string;
children?: React.ReactNode;
}
export function LeaderboardFiltersBar({
searchQuery,
onSearchChange,
placeholder = 'Search drivers...',
children,
}: LeaderboardFiltersBarProps) {
return (
<Box
mb={6}
p={3}
bg="bg-deep-charcoal/40"
border
borderColor="border-charcoal-outline/50"
rounded="lg"
blur="sm"
>
<Stack direction="row" align="center" justify="between" gap={4}>
<Box position="relative" flexGrow={1} maxWidth="md">
<Box
position="absolute"
left="3"
top="1/2"
transform="translateY(-50%)"
pointerEvents="none"
zIndex={10}
>
<Icon icon={Search} size={4} color="text-gray-500" />
</Box>
<Box
as="input"
type="text"
value={searchQuery}
onChange={(e: React.ChangeEvent<HTMLInputElement>) => onSearchChange?.(e.target.value)}
placeholder={placeholder}
w="full"
bg="bg-graphite-black/50"
border
borderColor="border-charcoal-outline"
rounded="md"
py={2}
pl={10}
pr={4}
fontSize="0.875rem"
color="text-white"
transition
hoverBorderColor="border-primary-blue/50"
/>
</Box>
<Stack direction="row" align="center" gap={4}>
{children}
<Box
display="flex"
alignItems="center"
gap={2}
px={3}
py={2}
bg="bg-graphite-black/30"
border
borderColor="border-charcoal-outline"
rounded="md"
cursor="pointer"
transition
hoverBg="bg-graphite-black/50"
hoverBorderColor="border-gray-600"
>
<Icon icon={Filter} size={3.5} color="text-gray-400" />
<Text size="xs" weight="bold" color="text-gray-400" uppercase letterSpacing="wider">Filters</Text>
</Box>
</Stack>
</Stack>
</Box>
);
}

View File

@@ -0,0 +1,72 @@
import React from 'react';
import { ArrowLeft, LucideIcon } from 'lucide-react';
import { Box } from '@/ui/Box';
import { Heading } from '@/ui/Heading';
import { Text } from '@/ui/Text';
import { Stack } from '@/ui/Stack';
import { Button } from '@/ui/Button';
import { Icon } from '@/ui/Icon';
interface LeaderboardHeaderProps {
title: string;
description?: string;
icon?: LucideIcon;
onBack?: () => void;
backLabel?: string;
children?: React.ReactNode;
}
export function LeaderboardHeader({
title,
description,
icon,
onBack,
backLabel = 'Back',
children,
}: LeaderboardHeaderProps) {
return (
<Box mb={8}>
{onBack && (
<Box mb={6}>
<Button
variant="secondary"
onClick={onBack}
icon={<Icon icon={ArrowLeft} size={4} />}
>
{backLabel}
</Button>
</Box>
)}
<Stack direction="row" align="center" justify="between" gap={4}>
<Stack direction="row" align="center" gap={4}>
{icon && (
<Box
p={3}
bg="linear-gradient(to bottom right, rgba(25, 140, 255, 0.15), rgba(25, 140, 255, 0.05))"
border
borderColor="border-primary-blue/20"
rounded="xl"
display="flex"
alignItems="center"
justifyContent="center"
>
<Icon icon={icon} size={6} color="text-primary-blue" />
</Box>
)}
<Box>
<Heading level={1} weight="bold" letterSpacing="tight">{title}</Heading>
{description && (
<Text color="text-gray-400" block mt={1} size="sm">
{description}
</Text>
)}
</Box>
</Stack>
<Box>
{children}
</Box>
</Stack>
</Box>
);
}

View File

@@ -0,0 +1,69 @@
import React from 'react';
import { ArrowLeft, LucideIcon } from 'lucide-react';
import { Box } from '@/ui/Box';
import { Heading } from '@/ui/Heading';
import { Text } from '@/ui/Text';
import { Stack } from '@/ui/Stack';
import { Button } from '@/ui/Button';
import { Icon } from '@/ui/Icon';
import { Surface } from '@/ui/Surface';
interface LeaderboardHeaderPanelProps {
title: string;
description?: string;
icon?: LucideIcon;
onBack?: () => void;
backLabel?: string;
children?: React.ReactNode;
}
export function LeaderboardHeaderPanel({
title,
description,
icon,
onBack,
backLabel = 'Back',
children,
}: LeaderboardHeaderPanelProps) {
return (
<Box mb={8}>
{onBack && (
<Box mb={6}>
<Button
variant="secondary"
onClick={onBack}
icon={<Icon icon={ArrowLeft} size={4} />}
>
{backLabel}
</Button>
</Box>
)}
<Stack direction="row" align="center" justify="between" gap={4}>
<Stack direction="row" align="center" gap={4}>
{icon && (
<Surface
variant="muted"
rounded="xl"
padding={3}
bg="linear-gradient(to bottom right, rgba(25, 140, 255, 0.2), rgba(25, 140, 255, 0.05))"
border
borderColor="border-primary-blue/20"
>
<Icon icon={icon} size={7} color="text-primary-blue" />
</Surface>
)}
<Box>
<Heading level={1}>{title}</Heading>
{description && (
<Text color="text-gray-400" block mt={1}>
{description}
</Text>
)}
</Box>
</Stack>
{children}
</Stack>
</Box>
);
}

View File

@@ -1,116 +0,0 @@
import React from 'react';
import { Crown, Flag } from 'lucide-react';
import { Box } from '@/ui/Box';
import { Text } from '@/ui/Text';
import { Stack } from '@/ui/Stack';
import { Image } from '@/ui/Image';
import { mediaConfig } from '@/lib/config/mediaConfig';
interface LeaderboardItemProps {
position: number;
name: string;
avatarUrl?: string;
nationality: string;
rating: number;
wins: number;
skillLevelLabel?: string;
skillLevelColor?: string;
categoryLabel?: string;
categoryColor?: string;
onClick: () => void;
}
export function LeaderboardItem({
position,
name,
avatarUrl,
nationality,
rating,
wins,
skillLevelLabel,
skillLevelColor,
categoryLabel,
categoryColor,
onClick,
}: LeaderboardItemProps) {
const getMedalColor = (pos: number) => {
switch (pos) {
case 1: return 'text-yellow-400';
case 2: return 'text-gray-300';
case 3: return 'text-amber-600';
default: return 'text-gray-500';
}
};
const getMedalBg = (pos: number) => {
switch (pos) {
case 1: return 'bg-yellow-400/10 border-yellow-400/30';
case 2: return 'bg-gray-300/10 border-gray-300/30';
case 3: return 'bg-amber-600/10 border-amber-600/30';
default: return 'bg-iron-gray/50 border-charcoal-outline';
}
};
return (
<Box
as="button"
type="button"
onClick={onClick}
display="flex"
alignItems="center"
gap={4}
px={4}
py={3}
fullWidth
textAlign="left"
className="hover:bg-iron-gray/30 transition-colors group"
>
{/* Position */}
<Box
width="8"
height="8"
display="flex"
center
rounded="full"
border
className={`${getMedalBg(position)} ${getMedalColor(position)} text-xs font-bold`}
>
{position <= 3 ? <Crown className="w-3.5 h-3.5" /> : position}
</Box>
{/* Avatar */}
<Box position="relative" width="9" height="9" rounded="full" overflow="hidden" border={true} borderColor="border-charcoal-outline">
<Image src={avatarUrl || mediaConfig.avatars.defaultFallback} alt={name} fill objectFit="cover" />
</Box>
{/* Info */}
<Box flexGrow={1} minWidth="0">
<Text weight="medium" color="text-white" truncate block className="group-hover:text-primary-blue transition-colors">
{name}
</Text>
<Stack direction="row" align="center" gap={2}>
<Flag className="w-3 h-3 text-gray-500" />
<Text size="xs" color="text-gray-500">{nationality}</Text>
{categoryLabel && (
<Text size="xs" className={categoryColor}>{categoryLabel}</Text>
)}
{skillLevelLabel && (
<Text size="xs" className={skillLevelColor}>{skillLevelLabel}</Text>
)}
</Stack>
</Box>
{/* Stats */}
<Stack direction="row" align="center" gap={4}>
<Box textAlign="center">
<Text color="text-primary-blue" weight="semibold" font="mono" block>{rating.toLocaleString()}</Text>
<Text size="xs" color="text-gray-500" block style={{ fontSize: '10px' }}>Rating</Text>
</Box>
<Box textAlign="center">
<Text color="text-performance-green" weight="semibold" font="mono" block>{wins}</Text>
<Text size="xs" color="text-gray-500" block style={{ fontSize: '10px' }}>Wins</Text>
</Box>
</Stack>
</Box>
);
}

View File

@@ -0,0 +1,173 @@
import React from 'react';
import { Box } from '@/ui/Box';
import { Image } from '@/ui/Image';
import { Stack } from '@/ui/Stack';
import { Text } from '@/ui/Text';
import { RatingDisplay } from '@/lib/display-objects/RatingDisplay';
import { MedalDisplay } from '@/lib/display-objects/MedalDisplay';
interface PodiumDriver {
id: string;
name: string;
avatarUrl: string;
rating: number;
wins: number;
podiums: number;
}
interface LeaderboardPodiumProps {
podium: PodiumDriver[];
onDriverClick?: (id: string) => void;
}
export function LeaderboardPodium({ podium, onDriverClick }: LeaderboardPodiumProps) {
// Order: 2nd, 1st, 3rd
const displayOrder = [1, 0, 2];
return (
<Box mb={12}>
<Box display="flex" alignItems="end" justifyContent="center" gap={4} maxWidth="4xl" mx="auto">
{displayOrder.map((index) => {
const driver = podium[index];
if (!driver) return <Box key={index} flexGrow={1} />;
const position = index + 1;
const isFirst = position === 1;
const config = {
1: { height: '48', scale: '1.1', zIndex: 10, shadow: 'shadow-warning-amber/20' },
2: { height: '36', scale: '1', zIndex: 0, shadow: 'shadow-white/5' },
3: { height: '28', scale: '0.9', zIndex: 0, shadow: 'shadow-white/5' },
}[position as 1 | 2 | 3];
return (
<Box
key={driver.id}
as="button"
type="button"
onClick={() => onDriverClick?.(driver.id)}
display="flex"
flexDirection="col"
alignItems="center"
flexGrow={1}
transition
hoverScale
group
shadow={config.shadow}
zIndex={config.zIndex}
>
<Box position="relative" mb={4} transform={`scale(${config.scale})`}>
<Box
position="relative"
w={isFirst ? '32' : '24'}
h={isFirst ? '32' : '24'}
rounded="full"
overflow="hidden"
border
borderColor={isFirst ? 'border-warning-amber' : 'border-charcoal-outline'}
borderWidth="3px"
transition
groupHoverBorderColor="primary-blue"
shadow="xl"
>
<Image
src={driver.avatarUrl}
alt={driver.name}
width={128}
height={128}
fullWidth
fullHeight
objectFit="cover"
/>
</Box>
<Box
position="absolute"
bottom="-2"
left="50%"
w="10"
h="10"
rounded="full"
display="flex"
alignItems="center"
justifyContent="center"
border
transform="translateX(-50%)"
borderWidth="2px"
bg={MedalDisplay.getBg(position)}
color={MedalDisplay.getColor(position)}
shadow="lg"
>
<Text size="sm" weight="bold">{position}</Text>
</Box>
</Box>
<Text
weight="bold"
color="text-white"
size={isFirst ? 'lg' : 'base'}
mb={1}
block
truncate
align="center"
px={2}
maxWidth="full"
groupHoverTextColor="text-primary-blue"
transition
>
{driver.name}
</Text>
<Text
font="mono"
weight="bold"
size={isFirst ? 'xl' : 'lg'}
block
color={isFirst ? 'text-warning-amber' : 'text-primary-blue'}
>
{RatingDisplay.format(driver.rating)}
</Text>
<Stack direction="row" align="center" gap={3} mt={1}>
<Stack direction="row" align="center" gap={1}>
<Text size="xs" color="text-gray-500" weight="bold" uppercase letterSpacing="wider">Wins</Text>
<Text size="xs" weight="bold" color="text-performance-green">{driver.wins}</Text>
</Stack>
<Box w="1" h="1" rounded="full" bg="bg-gray-700" />
<Stack direction="row" align="center" gap={1}>
<Text size="xs" color="text-gray-500" weight="bold" uppercase letterSpacing="wider">Podiums</Text>
<Text size="xs" weight="bold" color="text-white">{driver.podiums}</Text>
</Stack>
</Stack>
<Box
mt={6}
w="full"
h={config.height}
rounded="lg"
border
borderColor="border-charcoal-outline/50"
bg="bg-deep-charcoal/40"
display="flex"
alignItems="center"
justifyContent="center"
blur="sm"
groupHoverBorderColor="primary-blue/30"
transition
>
<Text
weight="bold"
size="4xl"
color={MedalDisplay.getColor(position)}
opacity={0.1}
fontSize={isFirst ? '5rem' : '3.5rem'}
>
{position}
</Text>
</Box>
</Box>
);
})}
</Box>
</Box>
);
}

View File

@@ -1,105 +0,0 @@
import { routes } from '@/lib/routing/RouteConfig';
import { Box } from '@/ui/Box';
import { Button } from '@/ui/Button';
import { Heading } from '@/ui/Heading';
import { Icon } from '@/ui/Icon';
import { LeaderboardItem } from '@/components/leaderboards/LeaderboardItem';
import { LeaderboardList } from '@/ui/LeaderboardList';
import { Stack } from '@/ui/Stack';
import { Text } from '@/ui/Text';
import { Award, ChevronRight } from 'lucide-react';
const SKILL_LEVELS = [
{ id: 'pro', label: 'Pro', color: 'text-yellow-400' },
{ id: 'advanced', label: 'Advanced', color: 'text-purple-400' },
{ id: 'intermediate', label: 'Intermediate', color: 'text-primary-blue' },
{ id: 'beginner', label: 'Beginner', color: 'text-green-400' },
];
const CATEGORIES = [
{ id: 'beginner', label: 'Beginner', color: 'text-green-400' },
{ id: 'intermediate', label: 'Intermediate', color: 'text-primary-blue' },
{ id: 'advanced', label: 'Advanced', color: 'text-purple-400' },
{ id: 'pro', label: 'Pro', color: 'text-yellow-400' },
{ id: 'endurance', label: 'Endurance', color: 'text-orange-400' },
{ id: 'sprint', label: 'Sprint', color: 'text-red-400' },
];
interface LeaderboardPreviewProps {
drivers: {
id: string;
name: string;
avatarUrl?: string;
nationality: string;
rating: number;
wins: number;
skillLevel?: string;
category?: string;
}[];
onDriverClick: (id: string) => void;
onNavigate: (href: string) => void;
}
export function LeaderboardPreview({ drivers, onDriverClick, onNavigate }: LeaderboardPreviewProps) {
const top5 = drivers.slice(0, 5);
return (
<Stack gap={4} mb={10}>
<Stack direction="row" align="center" justify="between">
<Stack direction="row" align="center" gap={3}>
<Box
display="flex"
center
w="10"
h="10"
rounded="xl"
bg="bg-gradient-to-br from-yellow-400/20 to-amber-600/10"
border
borderColor="border-yellow-400/30"
>
<Icon icon={Award} size={5} color="rgb(250, 204, 21)" />
</Box>
<Box>
<Heading level={2}>Top Drivers</Heading>
<Text size="xs" color="text-gray-500">Highest rated competitors</Text>
</Box>
</Stack>
<Button
variant="secondary"
onClick={() => onNavigate(routes.leaderboards.drivers)}
icon={<Icon icon={ChevronRight} size={4} />}
>
Full Rankings
</Button>
</Stack>
<LeaderboardList>
{top5.map((driver, index) => {
const levelConfig = SKILL_LEVELS.find((l) => l.id === driver.skillLevel);
const categoryConfig = CATEGORIES.find((c) => c.id === driver.category);
const position = index + 1;
return (
<LeaderboardItem
key={driver.id}
position={position}
name={driver.name}
avatarUrl={driver.avatarUrl}
nationality={driver.nationality}
rating={driver.rating}
wins={driver.wins}
skillLevelLabel={levelConfig?.label}
skillLevelColor={levelConfig?.color}
categoryLabel={categoryConfig?.label}
categoryColor={categoryConfig?.color}
onClick={() => onDriverClick(driver.id)}
/>
);
})}
</LeaderboardList>
</Stack>
);
}

View File

@@ -0,0 +1,49 @@
import React from 'react';
import { Table, TableBody, TableHead, TableHeader, TableRow } from '@/ui/Table';
import { RankingRow } from './RankingRow';
import { LeaderboardTableShell } from './LeaderboardTableShell';
interface LeaderboardDriver {
id: string;
name: string;
avatarUrl: string;
rank: number;
rankDelta?: number;
nationality: string;
skillLevel: string;
racesCompleted: number;
rating: number;
wins: number;
}
interface LeaderboardTableProps {
drivers: LeaderboardDriver[];
onDriverClick?: (id: string) => void;
}
export function LeaderboardTable({ drivers, onDriverClick }: LeaderboardTableProps) {
return (
<LeaderboardTableShell isEmpty={drivers.length === 0} emptyMessage="No drivers found">
<Table>
<TableHead>
<TableRow>
<TableHeader w="32">Rank</TableHeader>
<TableHeader>Driver</TableHeader>
<TableHeader textAlign="center">Races</TableHeader>
<TableHeader textAlign="center">Rating</TableHeader>
<TableHeader textAlign="center">Wins</TableHeader>
</TableRow>
</TableHead>
<TableBody>
{drivers.map((driver) => (
<RankingRow
key={driver.id}
{...driver}
onClick={() => onDriverClick?.(driver.id)}
/>
))}
</TableBody>
</Table>
</LeaderboardTableShell>
);
}

View File

@@ -0,0 +1,46 @@
import React from 'react';
import { Box } from '@/ui/Box';
import { Text } from '@/ui/Text';
interface LeaderboardTableShellProps {
children: React.ReactNode;
isEmpty?: boolean;
emptyMessage?: string;
emptyDescription?: string;
}
export function LeaderboardTableShell({
children,
isEmpty,
emptyMessage = 'No data found',
emptyDescription = 'Try adjusting your filters or search query',
}: LeaderboardTableShellProps) {
if (isEmpty) {
return (
<Box
py={16}
textAlign="center"
bg="bg-iron-gray/20"
border
borderColor="border-charcoal-outline"
rounded="xl"
>
<Text size="4xl" block mb={4}>🔍</Text>
<Text color="text-gray-400" block mb={2} weight="semibold">{emptyMessage}</Text>
<Text size="sm" color="text-gray-500">{emptyDescription}</Text>
</Box>
);
}
return (
<Box
rounded="xl"
bg="bg-iron-gray/20"
border
borderColor="border-charcoal-outline"
overflow="hidden"
>
{children}
</Box>
);
}

View File

@@ -25,27 +25,29 @@ export function LeaderboardsHero({ onNavigateToDrivers, onNavigateToTeams }: Lea
padding={8}
position="relative"
overflow="hidden"
bg="bg-gradient-to-br from-yellow-600/20 via-iron-gray to-deep-graphite"
borderColor="border-yellow-500/20"
bg="bg-gradient-to-br from-primary-blue/10 via-deep-charcoal to-graphite-black"
borderColor="border-primary-blue/20"
>
<DecorativeBlur color="yellow" size="lg" position="top-right" opacity={10} />
<DecorativeBlur color="blue" size="md" position="bottom-left" opacity={5} />
<DecorativeBlur color="blue" size="lg" position="top-right" opacity={10} />
<DecorativeBlur color="purple" size="md" position="bottom-left" opacity={5} />
<Box position="relative" zIndex={10}>
<Stack direction="row" align="center" gap={4} mb={4}>
<Surface
variant="muted"
rounded="xl"
padding={3}
bg="bg-gradient-to-br from-yellow-400/20 to-yellow-600/10"
<Box
p={3}
bg="linear-gradient(to bottom right, rgba(25, 140, 255, 0.2), rgba(25, 140, 255, 0.05))"
border
borderColor="border-yellow-400/30"
borderColor="border-primary-blue/30"
rounded="xl"
display="flex"
alignItems="center"
justifyContent="center"
>
<Icon icon={Award} size={7} color="#facc15" />
</Surface>
<Icon icon={Award} size={7} color="text-primary-blue" />
</Box>
<Box>
<Heading level={1}>Leaderboards</Heading>
<Text color="text-gray-400" block mt={1}>Where champions rise and legends are made</Text>
<Heading level={1} weight="bold" letterSpacing="tight">Leaderboards</Heading>
<Text color="text-gray-400" block mt={1} size="sm" uppercase letterSpacing="widest" weight="bold">Precision Performance Tracking</Text>
</Box>
</Stack>
@@ -53,25 +55,27 @@ export function LeaderboardsHero({ onNavigateToDrivers, onNavigateToTeams }: Lea
size="lg"
color="text-gray-400"
block
mb={6}
mb={8}
leading="relaxed"
maxWidth="42rem"
>
Track the best drivers and teams across all competitions. Every race counts. Every position matters. Who will claim the throne?
Track the best drivers and teams across all competitions. Every race counts. Every position matters. Analyze telemetry-grade rankings and performance metrics.
</Text>
<Stack direction="row" gap={3} wrap>
<Stack direction="row" gap={4} wrap>
<Button
variant="secondary"
variant="primary"
onClick={onNavigateToDrivers}
icon={<Icon icon={Trophy} size={4} color="#3b82f6" />}
icon={<Icon icon={Trophy} size={4} />}
shadow="shadow-lg shadow-primary-blue/20"
>
Driver Rankings
</Button>
<Button
variant="secondary"
onClick={onNavigateToTeams}
icon={<Icon icon={Users} size={4} color="#a855f7" />}
icon={<Icon icon={Users} size={4} />}
hoverBg="bg-white/5"
>
Team Rankings
</Button>

View File

@@ -0,0 +1,54 @@
import React from 'react';
import { Crown, Medal } from 'lucide-react';
import { Box } from '@/ui/Box';
import { Text } from '@/ui/Text';
import { Icon } from '@/ui/Icon';
import { MedalDisplay } from '@/lib/display-objects/MedalDisplay';
interface RankMedalProps {
rank: number;
size?: 'sm' | 'md' | 'lg';
showIcon?: boolean;
}
export function RankMedal({ rank, size = 'md', showIcon = true }: RankMedalProps) {
const isTop3 = rank <= 3;
const sizeMap = {
sm: '7',
md: '8',
lg: '10',
};
const textSizeMap = {
sm: 'xs',
md: 'xs',
lg: 'sm',
} as const;
const iconSize = {
sm: 3,
md: 3.5,
lg: 4.5,
};
return (
<Box
display="flex"
alignItems="center"
justifyContent="center"
rounded="full"
border
h={sizeMap[size]}
w={sizeMap[size]}
bg={MedalDisplay.getBg(rank)}
color={MedalDisplay.getColor(rank)}
>
{isTop3 && showIcon ? (
<Icon icon={rank === 1 ? Crown : Medal} size={iconSize[size]} />
) : (
<Text weight="bold" size={textSizeMap[size]}>{rank}</Text>
)}
</Box>
);
}

View File

@@ -0,0 +1,116 @@
import React from 'react';
import { Box } from '@/ui/Box';
import { Image } from '@/ui/Image';
import { Stack } from '@/ui/Stack';
import { TableCell, TableRow } from '@/ui/Table';
import { Text } from '@/ui/Text';
import { RankMedal } from './RankMedal';
import { DeltaChip } from './DeltaChip';
import { RatingDisplay } from '@/lib/display-objects/RatingDisplay';
interface RankingRowProps {
id: string;
rank: number;
rankDelta?: number;
name: string;
avatarUrl: string;
nationality: string;
skillLevel: string;
racesCompleted: number;
rating: number;
wins: number;
onClick?: () => void;
}
export function RankingRow({
rank,
rankDelta,
name,
avatarUrl,
nationality,
skillLevel,
racesCompleted,
rating,
wins,
onClick,
}: RankingRowProps) {
return (
<TableRow
clickable={!!onClick}
onClick={onClick}
group
>
<TableCell>
<Stack direction="row" align="center" gap={4}>
<Box w="8" display="flex" justifyContent="center">
<RankMedal rank={rank} size="md" />
</Box>
{rankDelta !== undefined && (
<Box w="10">
<DeltaChip value={rankDelta} type="rank" />
</Box>
)}
</Stack>
</TableCell>
<TableCell>
<Box display="flex" alignItems="center" gap={3}>
<Box
position="relative"
w="10"
h="10"
rounded="full"
overflow="hidden"
border
borderColor="border-charcoal-outline"
groupHoverBorderColor="primary-blue/50"
transition
>
<Image
src={avatarUrl}
alt={name}
width={40}
height={40}
fullWidth
fullHeight
objectFit="cover"
/>
</Box>
<Box minWidth="0">
<Text
weight="semibold"
color="text-white"
block
truncate
groupHoverTextColor="text-primary-blue"
transition
>
{name}
</Text>
<Stack direction="row" align="center" gap={2} mt={0.5}>
<Text size="xs" color="text-gray-500">{nationality}</Text>
<Box w="1" h="1" rounded="full" bg="bg-gray-700" />
<Text size="xs" color="text-gray-500">{skillLevel}</Text>
</Stack>
</Box>
</Box>
</TableCell>
<TableCell textAlign="center">
<Text color="text-gray-400" font="mono">{racesCompleted}</Text>
</TableCell>
<TableCell textAlign="center">
<Text font="mono" weight="bold" color="text-primary-blue">
{RatingDisplay.format(rating)}
</Text>
</TableCell>
<TableCell textAlign="center">
<Text font="mono" weight="bold" color="text-performance-green">
{wins}
</Text>
</TableCell>
</TableRow>
);
}

View File

@@ -0,0 +1,42 @@
import React from 'react';
import { Calendar } from 'lucide-react';
import { Box } from '@/ui/Box';
import { Text } from '@/ui/Text';
import { Icon } from '@/ui/Icon';
import { Select } from '@/ui/Select';
interface Season {
id: string;
name: string;
isActive?: boolean;
}
interface SeasonSelectorProps {
seasons: Season[];
selectedSeasonId: string;
onSeasonChange: (id: string) => void;
}
export function SeasonSelector({ seasons, selectedSeasonId, onSeasonChange }: SeasonSelectorProps) {
const options = seasons.map(season => ({
value: season.id,
label: `${season.name}${season.isActive ? ' (Active)' : ''}`
}));
return (
<Box display="flex" alignItems="center" gap={3}>
<Box display="flex" alignItems="center" gap={2} color="text-gray-500">
<Icon icon={Calendar} size={4} />
<Text size="xs" weight="bold" uppercase letterSpacing="wider">Season</Text>
</Box>
<Box width="48">
<Select
options={options}
value={selectedSeasonId}
onChange={(e) => onSeasonChange(e.target.value)}
fullWidth={true}
/>
</Box>
</Box>
);
}

View File

@@ -1,5 +1,5 @@
import React from 'react';
import { Users, Crown, ChevronRight } from 'lucide-react';
import { Users, ChevronRight } from 'lucide-react';
import { Button } from '@/ui/Button';
import { Box } from '@/ui/Box';
import { Text } from '@/ui/Text';
@@ -8,8 +8,8 @@ import { Icon } from '@/ui/Icon';
import { Stack } from '@/ui/Stack';
import { Image } from '@/ui/Image';
import { getMediaUrl } from '@/lib/utilities/media';
import { SkillLevelDisplay } from '@/lib/display-objects/SkillLevelDisplay';
import { MedalDisplay } from '@/lib/display-objects/MedalDisplay';
import { RankMedal } from './RankMedal';
import { LeaderboardTableShell } from './LeaderboardTableShell';
interface TeamLeaderboardPreviewProps {
teams: {
@@ -27,38 +27,57 @@ interface TeamLeaderboardPreviewProps {
}
export function TeamLeaderboardPreview({ teams, onTeamClick, onNavigateToTeams }: TeamLeaderboardPreviewProps) {
const top5 = teams; // Already sliced in builder when implemented
const top5 = teams;
return (
<Box rounded="xl" bg="bg-iron-gray/30" border borderColor="border-charcoal-outline" overflow="hidden">
<Box display="flex" alignItems="center" justifyContent="between" px={5} py={4} borderBottom borderColor="border-charcoal-outline" bg="bg-iron-gray/20">
<LeaderboardTableShell>
<Box
display="flex"
alignItems="center"
justifyContent="between"
px={5}
py={4}
borderBottom
borderColor="border-charcoal-outline/50"
bg="bg-deep-charcoal/40"
>
<Box display="flex" alignItems="center" gap={3}>
<Box display="flex" h="10" w="10" alignItems="center" justifyContent="center" rounded="xl" bg="bg-gradient-to-br from-purple-500/20 to-purple-500/5" border borderColor="border-purple-500/20">
<Box
display="flex"
h="10"
w="10"
alignItems="center"
justifyContent="center"
rounded="lg"
bg="bg-gradient-to-br from-purple-500/15 to-purple-500/5"
border
borderColor="border-purple-500/20"
>
<Icon icon={Users} size={5} color="text-purple-400" />
</Box>
<Box>
<Heading level={3} fontSize="lg" weight="semibold" color="text-white">Team Rankings</Heading>
<Text size="xs" color="text-gray-500" block>Top performing racing teams</Text>
<Heading level={3} fontSize="lg" weight="bold" color="text-white" letterSpacing="tight">Team Rankings</Heading>
<Text size="xs" color="text-gray-500" block uppercase letterSpacing="wider" weight="bold">Top Performing Teams</Text>
</Box>
</Box>
<Button
variant="secondary"
onClick={onNavigateToTeams}
size="sm"
hoverBg="bg-purple-500/10"
transition
>
<Stack direction="row" align="center" gap={2}>
<Text size="sm">View All</Text>
<Text size="sm" weight="medium">View All</Text>
<Icon icon={ChevronRight} size={4} />
</Stack>
</Button>
</Box>
<Stack gap={0}
// eslint-disable-next-line gridpilot-rules/component-classification
className="divide-y divide-charcoal-outline/50"
>
{top5.map((team) => {
<Stack gap={0}>
{top5.map((team, index) => {
const position = team.position;
const isLast = index === top5.length - 1;
return (
<Box
@@ -74,24 +93,29 @@ export function TeamLeaderboardPreview({ teams, onTeamClick, onNavigateToTeams }
w="full"
textAlign="left"
transition
hoverBg="bg-iron-gray/30"
hoverBg="bg-white/[0.02]"
group
borderBottom={!isLast}
borderColor="border-charcoal-outline/30"
>
<Box
display="flex"
h="8"
w="8"
alignItems="center"
justifyContent="center"
rounded="full"
border
// eslint-disable-next-line gridpilot-rules/component-classification
className={`text-xs font-bold ${MedalDisplay.getBg(position)} ${MedalDisplay.getColor(position)}`}
>
{position <= 3 ? <Icon icon={Crown} size={3.5} /> : <Text weight="bold">{position}</Text>}
<Box w="8" display="flex" justifyContent="center">
<RankMedal rank={position} size="sm" />
</Box>
<Box display="flex" h="9" w="9" alignItems="center" justifyContent="center" rounded="lg" bg="bg-charcoal-outline" border borderColor="border-charcoal-outline" overflow="hidden">
<Box
display="flex"
h="9"
w="9"
alignItems="center"
justifyContent="center"
rounded="lg"
bg="bg-graphite-black/50"
border
borderColor="border-charcoal-outline"
overflow="hidden"
groupHoverBorderColor="purple-400/50"
transition
>
<Image
src={team.logoUrl || getMediaUrl('team-logo', team.id)}
alt={team.name}
@@ -104,57 +128,45 @@ export function TeamLeaderboardPreview({ teams, onTeamClick, onNavigateToTeams }
</Box>
<Box flexGrow={1} minWidth="0">
<Text weight="medium" color="text-white" truncate groupHoverTextColor="text-purple-400" transition block>
<Text
weight="semibold"
color="text-white"
truncate
groupHoverTextColor="text-purple-400"
transition
block
>
{team.name}
</Text>
<Box display="flex" alignItems="center" gap={2} flexWrap="wrap">
{team.category && (
<Box display="flex" alignItems="center" gap={1} color="text-purple-400">
<Box w="1.5" h="1.5" rounded="full" bg="bg-purple-400" />
<Text size="xs">{team.category}</Text>
<Text size="xs" weight="medium">{team.category}</Text>
</Box>
)}
<Box w="1" h="1" rounded="full" bg="bg-gray-700" />
<Box display="flex" alignItems="center" gap={1}>
<Icon icon={Users} size={3} color="text-gray-500" />
<Text size="xs" color="text-gray-500">{team.memberCount} members</Text>
</Box>
<Box as="span"
// eslint-disable-next-line gridpilot-rules/component-classification
className={SkillLevelDisplay.getColor(team.category || '')}
>
<Text size="xs">{SkillLevelDisplay.getLabel(team.category || '')}</Text>
</Box>
</Box>
</Box>
<Box display="flex" alignItems="center" gap={4}>
<Box textAlign="center">
<Text color="text-purple-400" font="mono" weight="semibold" block>{team.memberCount}</Text>
<Text
// eslint-disable-next-line gridpilot-rules/component-classification
style={{ fontSize: '10px' }}
color="text-gray-500"
block
>
Members
</Text>
<Box display="flex" alignItems="center" gap={6}>
<Box textAlign="right">
<Text color="text-purple-400" font="mono" weight="bold" block size="sm">{team.memberCount}</Text>
<Text fontSize="10px" color="text-gray-500" block uppercase letterSpacing="wider" weight="bold">Members</Text>
</Box>
<Box textAlign="center">
<Text color="text-performance-green" font="mono" weight="semibold" block>{team.totalWins}</Text>
<Text
// eslint-disable-next-line gridpilot-rules/component-classification
style={{ fontSize: '10px' }}
color="text-gray-500"
block
>
Wins
</Text>
<Box textAlign="right" minWidth="12">
<Text color="text-performance-green" font="mono" weight="bold" block size="sm">{team.totalWins}</Text>
<Text fontSize="10px" color="text-gray-500" block uppercase letterSpacing="wider" weight="bold">Wins</Text>
</Box>
</Box>
</Box>
);
})}
</Stack>
</Box>
</LeaderboardTableShell>
);
}
}

View File

@@ -0,0 +1,103 @@
import React from 'react';
import { Box } from '@/ui/Box';
import { Image } from '@/ui/Image';
import { TableCell, TableRow } from '@/ui/Table';
import { Text } from '@/ui/Text';
import { RankMedal } from './RankMedal';
import { getMediaUrl } from '@/lib/utilities/media';
interface TeamRankingRowProps {
id: string;
rank: number;
name: string;
logoUrl?: string;
rating: number;
wins: number;
races: number;
memberCount: number;
onClick?: () => void;
}
export function TeamRankingRow({
id,
rank,
name,
logoUrl,
rating,
wins,
races,
memberCount,
onClick,
}: TeamRankingRowProps) {
return (
<TableRow
clickable={!!onClick}
onClick={onClick}
group
>
<TableCell>
<Box w="8" display="flex" justifyContent="center">
<RankMedal rank={rank} size="md" />
</Box>
</TableCell>
<TableCell>
<Box display="flex" alignItems="center" gap={3}>
<Box
position="relative"
w="10"
h="10"
rounded="lg"
overflow="hidden"
border
borderColor="border-charcoal-outline"
bg="bg-graphite-black/50"
groupHoverBorderColor="purple-400/50"
transition
>
<Image
src={logoUrl || getMediaUrl('team-logo', id)}
alt={name}
width={40}
height={40}
fullWidth
fullHeight
objectFit="cover"
/>
</Box>
<Box minWidth="0">
<Text
weight="semibold"
color="text-white"
block
truncate
groupHoverTextColor="text-purple-400"
transition
>
{name}
</Text>
<Text size="xs" color="text-gray-500" block mt={0.5}>
{memberCount} Members
</Text>
</Box>
</Box>
</TableCell>
<TableCell textAlign="center">
<Text font="mono" weight="bold" color="text-purple-400">
{rating}
</Text>
</TableCell>
<TableCell textAlign="center">
<Text font="mono" weight="bold" color="text-performance-green">
{wins}
</Text>
</TableCell>
<TableCell textAlign="center">
<Text color="text-gray-400" font="mono">{races}</Text>
</TableCell>
</TableRow>
);
}