website refactor

This commit is contained in:
2026-01-20 23:50:29 +01:00
parent 7cbec00474
commit 4516427a19
30 changed files with 735 additions and 772 deletions

View File

@@ -1,14 +1,17 @@
import { RankBadge } from '@/components/leaderboards/RankBadge'; import { RankBadge } from '@/components/leaderboards/RankBadge';
import { RatingDisplay } from '@/lib/display-objects/RatingDisplay'; import { RatingDisplay } from '@/lib/display-objects/RatingDisplay';
import { SkillLevelDisplay } from '@/lib/display-objects/SkillLevelDisplay'; import { SkillLevelDisplay } from '@/lib/display-objects/SkillLevelDisplay';
import { Box } from '@/ui/Box'; import { Avatar } from '@/ui/Avatar';
import { Image } from '@/ui/Image';
import { LeaderboardList } from '@/ui/LeaderboardList'; import { LeaderboardList } from '@/ui/LeaderboardList';
import { LeaderboardPreviewShell } from '@/ui/LeaderboardPreviewShell'; import { LeaderboardPreviewShell } from '@/ui/LeaderboardPreviewShell';
import { LeaderboardRow } from '@/ui/LeaderboardRow';
import { Group } from '@/ui/Group';
import { Text } from '@/ui/Text'; import { Text } from '@/ui/Text';
import { Trophy } from 'lucide-react'; import { Trophy } from 'lucide-react';
interface DriverLeaderboardPreviewProps { interface DriverLeaderboardPreviewProps {
title?: string;
subtitle?: string;
drivers: { drivers: {
id: string; id: string;
name: string; name: string;
@@ -24,16 +27,22 @@ interface DriverLeaderboardPreviewProps {
onNavigateToDrivers: () => void; onNavigateToDrivers: () => void;
} }
export function DriverLeaderboardPreview({ drivers, onDriverClick, onNavigateToDrivers }: DriverLeaderboardPreviewProps) { export function DriverLeaderboardPreview({
title = "Driver Rankings",
subtitle = "Top Performers",
drivers,
onDriverClick,
onNavigateToDrivers
}: DriverLeaderboardPreviewProps) {
const top10 = drivers; // Already sliced in builder const top10 = drivers; // Already sliced in builder
return ( return (
<LeaderboardPreviewShell <LeaderboardPreviewShell
title="Driver Rankings" title={title}
subtitle="Top Performers" subtitle={subtitle}
onViewFull={onNavigateToDrivers} onViewFull={onNavigateToDrivers}
icon={Trophy} icon={Trophy}
iconColor="var(--primary-blue)" iconColor="var(--ui-color-intent-primary)"
iconBgGradient="linear-gradient(to bottom right, rgba(25, 140, 255, 0.2), rgba(25, 140, 255, 0.1))" iconBgGradient="linear-gradient(to bottom right, rgba(25, 140, 255, 0.2), rgba(25, 140, 255, 0.1))"
viewFullLabel="View All" viewFullLabel="View All"
> >
@@ -42,71 +51,52 @@ export function DriverLeaderboardPreview({ drivers, onDriverClick, onNavigateToD
const position = index + 1; const position = index + 1;
return ( return (
<Box <LeaderboardRow
key={driver.id} key={driver.id}
as="button"
type="button"
onClick={() => onDriverClick(driver.id)} onClick={() => onDriverClick(driver.id)}
display="flex" rank={<RankBadge rank={position} />}
alignItems="center" identity={
gap={4} <Group gap={4}>
px={5} <Avatar src={driver.avatarUrl} alt={driver.name} size="sm" />
py={3} <Group direction="column" align="start" gap={0}>
w="full" <Text
textAlign="left" weight="bold"
transition variant="high"
hoverBg="bg-white/[0.02]" truncate
group block
> >
<Box w="8" display="flex" justifyContent="center"> {driver.name}
<RankBadge rank={position} /> </Text>
</Box> <Group gap={2}>
<Text size="xs" variant="low" uppercase weight="bold" letterSpacing="wider">{driver.nationality}</Text>
<Box <Text size="xs" weight="bold" color={SkillLevelDisplay.getColor(driver.skillLevel)} uppercase letterSpacing="wider">
position="relative" {SkillLevelDisplay.getLabel(driver.skillLevel)}
w="9" </Text>
h="9" </Group>
rounded="full" </Group>
overflow="hidden" </Group>
border }
borderColor="border-charcoal-outline" stats={
groupHoverBorderColor="primary-blue/50" <Group gap={8}>
transition <Group direction="column" align="end" gap={0}>
> <Text variant="primary" font="mono" weight="bold" block size="md" align="right">
<Image src={driver.avatarUrl} alt={driver.name} fullWidth fullHeight objectFit="cover" /> {RatingDisplay.format(driver.rating)}
</Box> </Text>
<Text size="xs" variant="low" block uppercase letterSpacing="widest" weight="bold" fontSize="9px" align="right">
<Box flexGrow={1} minWidth="0"> Rating
<Text </Text>
weight="semibold" </Group>
color="text-white" <Group direction="column" align="end" gap={0}>
truncate <Text variant="success" font="mono" weight="bold" block size="md" align="right">
groupHoverTextColor="text-primary-blue" {driver.wins}
transition </Text>
block <Text size="xs" variant="low" block uppercase letterSpacing="widest" weight="bold" fontSize="9px" align="right">
> Wins
{driver.name} </Text>
</Text> </Group>
<Box display="flex" alignItems="center" gap={2}> </Group>
<Text size="xs" color="text-gray-500">{driver.nationality}</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={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="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>
); );
})} })}
</LeaderboardList> </LeaderboardList>

View File

@@ -2,7 +2,6 @@ import { Icon } from '@/ui/Icon';
import { Input } from '@/ui/Input'; import { Input } from '@/ui/Input';
import { ControlBar } from '@/ui/ControlBar'; import { ControlBar } from '@/ui/ControlBar';
import { Button } from '@/ui/Button'; import { Button } from '@/ui/Button';
import { Box } from '@/ui/Box';
import { Group } from '@/ui/Group'; import { Group } from '@/ui/Group';
import { Filter, Search } from 'lucide-react'; import { Filter, Search } from 'lucide-react';
import React from 'react'; import React from 'react';
@@ -21,32 +20,30 @@ export function LeaderboardFiltersBar({
children, children,
}: LeaderboardFiltersBarProps) { }: LeaderboardFiltersBarProps) {
return ( return (
<Box marginBottom={6}> <ControlBar
<ControlBar leftContent={
leftContent={ <Group fullWidth>
<Box maxWidth="32rem" fullWidth> <Input
<Input type="text"
type="text" value={searchQuery}
value={searchQuery} onChange={(e) => onSearchChange?.(e.target.value)}
onChange={(e) => onSearchChange?.(e.target.value)} placeholder={placeholder}
placeholder={placeholder} icon={<Icon icon={Search} size={4} intent="low" />}
icon={<Icon icon={Search} size={4} intent="low" />} fullWidth
fullWidth />
/>
</Box>
}
>
<Group gap={4}>
{children}
<Button
variant="secondary"
size="sm"
icon={<Icon icon={Filter} size={3.5} intent="low" />}
>
Filters
</Button>
</Group> </Group>
</ControlBar> }
</Box> >
<Group gap={4}>
{children}
<Button
variant="secondary"
size="sm"
icon={<Icon icon={Filter} size={3.5} intent="low" />}
>
Filters
</Button>
</Group>
</ControlBar>
); );
} }

View File

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

View File

@@ -1,8 +1,7 @@
import { Button } from '@/ui/Button'; import { Button } from '@/ui/Button';
import { Heading } from '@/ui/Heading'; import { Heading } from '@/ui/Heading';
import { Icon } from '@/ui/Icon'; import { Icon } from '@/ui/Icon';
import { Stack } from '@/ui/Stack'; import { Group } from '@/ui/Group';
import { Surface } from '@/ui/Surface';
import { Text } from '@/ui/Text'; import { Text } from '@/ui/Text';
import { ArrowLeft, LucideIcon } from 'lucide-react'; import { ArrowLeft, LucideIcon } from 'lucide-react';
import React from 'react'; import React from 'react';
@@ -25,9 +24,9 @@ export function LeaderboardHeaderPanel({
children, children,
}: LeaderboardHeaderPanelProps) { }: LeaderboardHeaderPanelProps) {
return ( return (
<Stack mb={8}> <Group direction="column" align="stretch" gap={8}>
{onBack && ( {onBack && (
<Stack mb={6}> <Group>
<Button <Button
variant="secondary" variant="secondary"
onClick={onBack} onClick={onBack}
@@ -35,34 +34,29 @@ export function LeaderboardHeaderPanel({
> >
{backLabel} {backLabel}
</Button> </Button>
</Stack> </Group>
)} )}
<Stack direction="row" align="center" justify="between" gap={4}> <Group justify="between" gap={4}>
<Stack direction="row" align="center" gap={4}> <Group gap={4}>
{icon && ( {icon && (
<Surface <Group
variant="muted" justify="center"
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" /> <Icon icon={icon} size={7} intent="primary" />
</Surface> </Group>
)} )}
<Stack> <Group direction="column" align="start" gap={1}>
<Heading level={1}>{title}</Heading> <Heading level={1}>{title}</Heading>
{description && ( {description && (
<Text color="text-gray-400" block mt={1}> <Text variant="low" block>
{description} {description}
</Text> </Text>
)} )}
</Stack> </Group>
</Stack> </Group>
{children} {children}
</Stack> </Group>
</Stack> </Group>
); );
} }

View File

@@ -1,6 +1,6 @@
import React from 'react'; import React from 'react';
import { Table, TableBody, TableHead, TableHeader, TableRow } from '@/ui/Table';
import { RankingRow } from './RankingRow'; import { RankingRow } from './RankingRow';
import { LeaderboardList } from '@/ui/LeaderboardList';
import { LeaderboardTableShell } from '@/ui/LeaderboardTableShell'; import { LeaderboardTableShell } from '@/ui/LeaderboardTableShell';
interface LeaderboardDriver { interface LeaderboardDriver {
@@ -22,23 +22,17 @@ interface LeaderboardTableProps {
} }
export function LeaderboardTable({ drivers, onDriverClick }: LeaderboardTableProps) { export function LeaderboardTable({ drivers, onDriverClick }: LeaderboardTableProps) {
const columns = [
{ key: 'rank', label: 'Rank', width: '8rem' },
{ key: 'driver', label: 'Driver' },
{ key: 'races', label: 'Races', align: 'center' as const },
{ key: 'rating', label: 'Rating', align: 'center' as const },
{ key: 'wins', label: 'Wins', align: 'center' as const },
];
return ( return (
<LeaderboardTableShell columns={columns}> <LeaderboardTableShell>
{drivers.map((driver) => ( <LeaderboardList>
<RankingRow {drivers.map((driver) => (
key={driver.id} <RankingRow
{...driver} key={driver.id}
onClick={() => onDriverClick?.(driver.id)} {...driver}
/> onClick={() => onDriverClick?.(driver.id)}
))} />
))}
</LeaderboardList>
</LeaderboardTableShell> </LeaderboardTableShell>
); );
} }

View File

@@ -1,38 +1,10 @@
import { Icon } from '@/ui/Icon'; import { RankMedal } from '@/ui/RankMedal';
import { Stack } from '@/ui/Stack'; import React from 'react';
import { Text } from '@/ui/Text';
import { Crown } from 'lucide-react';
interface MedalBadgeProps { interface MedalBadgeProps {
position: number; position: number;
} }
export function MedalBadge({ position }: MedalBadgeProps) { export function MedalBadge({ position }: MedalBadgeProps) {
const getMedalColor = (pos: number) => { return <RankMedal rank={position} size="md" />;
switch (pos) {
case 1: return 'var(--warning-amber)';
case 2: return 'var(--iron-gray)';
case 3: return 'var(--amber-600)';
default: return 'var(--charcoal-outline)';
}
};
const isMedal = position <= 3;
return (
<Stack
h="10"
w="10"
align="center"
justify="center"
rounded="full"
bg={isMedal ? 'bg-gradient-to-br from-yellow-400/20 to-amber-600/10' : 'bg-iron-gray'}
>
{isMedal ? (
<Icon icon={Crown} size={5} color={getMedalColor(position)} />
) : (
<Text size="lg" weight="bold" color="text-gray-400">#{position}</Text>
)}
</Stack>
);
} }

View File

@@ -1,4 +1,4 @@
import { Badge } from '@/ui/Badge'; import { RankMedal } from '@/ui/RankMedal';
import { Text } from '@/ui/Text'; import { Text } from '@/ui/Text';
import { Group } from '@/ui/Group'; import { Group } from '@/ui/Group';
import React from 'react'; import React from 'react';
@@ -9,32 +9,15 @@ interface RankBadgeProps {
} }
export function RankBadge({ rank, size = 'md' }: RankBadgeProps) { export function RankBadge({ rank, size = 'md' }: RankBadgeProps) {
const badgeSize = size === 'lg' ? 'md' : size; if (rank <= 3) {
return <RankMedal rank={rank} size={size} />;
const getVariant = (rank: number): 'warning' | 'primary' | 'info' | 'default' => { }
if (rank <= 3) return 'warning';
if (rank <= 10) return 'primary';
if (rank <= 50) return 'info';
return 'default';
};
const getMedalEmoji = (rank: number) => {
switch (rank) {
case 1: return '🥇';
case 2: return '🥈';
case 3: return '🥉';
default: return null;
}
};
const medal = getMedalEmoji(rank);
return ( return (
<Badge variant={getVariant(rank)} size={badgeSize}> <Group justify="center" align="center">
<Group gap={1}> <Text size={size === 'lg' ? 'md' : 'xs'} weight="bold" variant="low">
{medal && <Text size="xs">{medal}</Text>} #{rank}
<Text size="xs" weight="bold">#{rank}</Text> </Text>
</Group> </Group>
</Badge>
); );
} }

View File

@@ -1,54 +1,18 @@
import { MedalDisplay } from '@/lib/display-objects/MedalDisplay'; import { MedalDisplay } from '@/lib/display-objects/MedalDisplay';
import { Icon } from '@/ui/Icon'; import { RankMedal as UiRankMedal, RankMedalProps } from '@/ui/RankMedal';
import { Text } from '@/ui/Text';
import { Surface } from '@/ui/Surface';
import { Crown, Medal } from 'lucide-react';
import React from 'react'; import React from 'react';
interface RankMedalProps { export function RankMedal(props: RankMedalProps) {
rank: number; const variant = MedalDisplay.getVariant(props.rank);
size?: 'sm' | 'md' | 'lg'; const bg = MedalDisplay.getBg(props.rank);
showIcon?: boolean; const color = MedalDisplay.getColor(props.rank);
}
export function RankMedal({ rank, size = 'md', showIcon = true }: RankMedalProps) {
const isTop3 = rank <= 3;
const variant = MedalDisplay.getVariant(rank);
const sizePx = {
sm: '1.75rem',
md: '2rem',
lg: '2.5rem',
};
const textSizeMap = {
sm: 'xs',
md: 'xs',
lg: 'sm',
} as const;
const iconSize = {
sm: 3,
md: 3.5,
lg: 4.5,
};
return ( return (
<Surface <UiRankMedal
variant="muted" {...props}
rounded="full" variant={variant as any}
border bg={bg}
height={sizePx[size]} color={color}
width={sizePx[size]} />
display="flex"
alignItems="center"
justifyContent="center"
>
{isTop3 && showIcon ? (
<Icon icon={rank === 1 ? Crown : Medal} size={iconSize[size]} intent={variant as any} />
) : (
<Text weight="bold" size={textSizeMap[size]} variant={variant as any}>{rank}</Text>
)}
</Surface>
); );
} }

View File

@@ -1,10 +1,12 @@
import { RatingDisplay } from '@/lib/display-objects/RatingDisplay'; import { RatingDisplay } from '@/lib/display-objects/RatingDisplay';
import { Image } from '@/ui/Image'; import { Avatar } from '@/ui/Avatar';
import { Stack } from '@/ui/Stack'; import { Group } from '@/ui/Group';
import { TableCell, TableRow } from '@/ui/Table';
import { Text } from '@/ui/Text'; import { Text } from '@/ui/Text';
import { DeltaChip } from './DeltaChip'; import { DeltaChip } from './DeltaChip';
import { RankMedal } from './RankMedal'; import { RankBadge } from './RankBadge';
import { LeaderboardRow } from '@/ui/LeaderboardRow';
import { SkillLevelDisplay } from '@/lib/display-objects/SkillLevelDisplay';
import React from 'react';
interface RankingRowProps { interface RankingRowProps {
id: string; id: string;
@@ -33,82 +35,69 @@ export function RankingRow({
onClick, onClick,
}: RankingRowProps) { }: RankingRowProps) {
return ( return (
<TableRow <LeaderboardRow
clickable={!!onClick}
onClick={onClick} onClick={onClick}
group rank={
> <Group gap={4}>
<TableCell> <RankBadge rank={rank} />
<Stack direction="row" align="center" gap={4}>
<Stack w="8" display="flex" justifyContent="center">
<RankMedal rank={rank} size="md" />
</Stack>
{rankDelta !== undefined && ( {rankDelta !== undefined && (
<Stack w="10"> <DeltaChip value={rankDelta} type="rank" />
<DeltaChip value={rankDelta} type="rank" />
</Stack>
)} )}
</Stack> </Group>
</TableCell> }
identity={
<TableCell> <Group gap={4}>
<Stack display="flex" alignItems="center" gap={3}> <Avatar
<Stack src={avatarUrl}
position="relative" alt={name}
w="10" size="md"
h="10" />
rounded="full" <Group direction="column" align="start" gap={0}>
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"
/>
</Stack>
<Stack minWidth="0">
<Text <Text
weight="semibold" weight="bold"
color="text-white" variant="high"
block block
truncate truncate
groupHoverTextColor="text-primary-blue"
transition
> >
{name} {name}
</Text> </Text>
<Stack direction="row" align="center" gap={2} mt={0.5}> <Group gap={2}>
<Text size="xs" color="text-gray-500">{nationality}</Text> <Text size="xs" variant="low" uppercase weight="bold" letterSpacing="wider">{nationality}</Text>
<Stack w="1" h="1" rounded="full" bg="bg-gray-700" /> <Text size="xs" weight="bold" color={SkillLevelDisplay.getColor(skillLevel)} uppercase letterSpacing="wider">
<Text size="xs" color="text-gray-500">{skillLevel}</Text> {SkillLevelDisplay.getLabel(skillLevel)}
</Stack> </Text>
</Stack> </Group>
</Stack> </Group>
</TableCell> </Group>
}
<TableCell textAlign="center"> stats={
<Text color="text-gray-400" font="mono">{racesCompleted}</Text> <Group gap={8}>
</TableCell> <Group direction="column" align="end" gap={0}>
<Text variant="low" font="mono" weight="bold" block size="md">
<TableCell textAlign="center"> {racesCompleted}
<Text font="mono" weight="bold" color="text-primary-blue"> </Text>
{RatingDisplay.format(rating)} <Text size="xs" variant="low" block uppercase letterSpacing="widest" weight="bold" fontSize="9px">
</Text> Races
</TableCell> </Text>
</Group>
<TableCell textAlign="center"> <Group direction="column" align="end" gap={0}>
<Text font="mono" weight="bold" color="text-performance-green"> <Text variant="primary" font="mono" weight="bold" block size="md">
{wins} {RatingDisplay.format(rating)}
</Text> </Text>
</TableCell> <Text size="xs" variant="low" block uppercase letterSpacing="widest" weight="bold" fontSize="9px">
</TableRow> Rating
</Text>
</Group>
<Group direction="column" align="end" gap={0}>
<Text variant="success" font="mono" weight="bold" block size="md">
{wins}
</Text>
<Text size="xs" variant="low" block uppercase letterSpacing="widest" weight="bold" fontSize="9px">
Wins
</Text>
</Group>
</Group>
}
/>
); );
} }

View File

@@ -1,8 +1,10 @@
import { Avatar } from '@/ui/Avatar';
import { Group } from '@/ui/Group';
import { Image } from '@/ui/Image';
import { Stack } from '@/ui/Stack';
import { Text } from '@/ui/Text'; import { Text } from '@/ui/Text';
import { RatingDisplay } from '@/lib/display-objects/RatingDisplay';
import { MedalDisplay } from '@/lib/display-objects/MedalDisplay';
import { Surface } from '@/ui/Surface';
import React from 'react';
interface PodiumDriver { interface PodiumDriver {
id: string; id: string;
@@ -20,121 +22,67 @@ interface RankingsPodiumProps {
export function RankingsPodium({ podium, onDriverClick }: RankingsPodiumProps) { export function RankingsPodium({ podium, onDriverClick }: RankingsPodiumProps) {
return ( return (
<Stack mb={10}> <Group justify="center" align="end" gap={4}>
<Stack display="flex" alignItems="end" justifyContent="center" gap={4}> {[1, 0, 2].map((index) => {
{[1, 0, 2].map((index) => { const driver = podium[index];
const driver = podium[index]; if (!driver) return null;
if (!driver) return null;
const position = index === 1 ? 1 : index === 0 ? 2 : 3;
const position = index === 1 ? 1 : index === 0 ? 2 : 3; const isFirst = position === 1;
const config = {
1: { height: '10rem', color: 'rgba(250, 204, 21, 0.2)', borderColor: 'rgba(250, 204, 21, 0.4)', crown: '#facc15' }, const config = {
2: { height: '8rem', color: 'rgba(209, 213, 219, 0.2)', borderColor: 'rgba(209, 213, 219, 0.4)', crown: '#d1d5db' }, 1: { height: '10rem', variant: 'precision' },
3: { height: '6rem', color: 'rgba(217, 119, 6, 0.2)', borderColor: 'rgba(217, 119, 6, 0.4)', crown: '#d97706' }, 2: { height: '8rem', variant: 'muted' },
}[position] || { height: '6rem', color: 'rgba(217, 119, 6, 0.2)', borderColor: 'rgba(217, 119, 6, 0.4)', crown: '#d97706' }; 3: { height: '6rem', variant: 'muted' },
}[position as 1 | 2 | 3];
return ( return (
<Stack <Group
key={driver.id} key={driver.id}
as="button" direction="column"
type="button" align="center"
onClick={() => onDriverClick?.(driver.id)} gap={4}
display="flex" >
flexDirection="col" <Group direction="column" align="center" gap={2}>
alignItems="center" <Group
bg="transparent" justify="center"
border={false} align="center"
cursor="pointer"
>
<Stack position="relative" mb={4}>
<Stack
position="relative"
w={position === 1 ? '24' : '20'}
h={position === 1 ? '24' : '20'}
rounded="full"
overflow="hidden"
border
borderColor={config.crown === '#facc15' ? 'border-warning-amber' : config.crown === '#d1d5db' ? 'border-gray-300' : 'border-orange-600'}
style={{ borderWidth: '4px', boxShadow: position === 1 ? '0 0 30px rgba(250, 204, 21, 0.3)' : 'none' }}
>
<Image
src={driver.avatarUrl}
alt={driver.name}
width={112}
height={112}
objectFit="cover"
/>
</Stack>
<Stack
position="absolute"
bottom="-2"
left="50%"
w="8"
h="8"
rounded="full"
display="flex"
alignItems="center"
justifyContent="center"
border
weight="bold"
size="sm"
borderColor={config.crown === '#facc15' ? 'border-warning-amber' : config.crown === '#d1d5db' ? 'border-gray-300' : 'border-orange-600'}
color={config.crown === '#facc15' ? 'text-warning-amber' : config.crown === '#d1d5db' ? 'text-gray-300' : 'text-orange-600'}
style={{
transform: 'translateX(-50%)',
borderWidth: '2px',
background: `linear-gradient(to bottom right, ${config.color}, transparent)`
}}
>
{position}
</Stack>
</Stack>
<Text weight="semibold" color="text-white" size={position === 1 ? 'lg' : 'base'} mb={1} block>
{driver.name}
</Text>
<Text font="mono" weight="bold" size={position === 1 ? 'xl' : 'lg'} block color={position === 1 ? 'text-warning-amber' : 'text-primary-blue'}>
{driver.rating.toString()}
</Text>
<Stack direction="row" align="center" gap={2} mt={1}>
<Stack direction="row" align="center" gap={1}>
<Text>🏆</Text>
<Text color="text-gray-400">{driver.wins}</Text>
</Stack>
<Text color="text-gray-500"></Text>
<Stack direction="row" align="center" gap={1}>
<Text>🏅</Text>
<Text color="text-gray-400">{driver.podiums}</Text>
</Stack>
</Stack>
<Stack
mt={4}
w={position === 1 ? '28' : '24'}
h={config.height}
rounded="lg"
display="flex"
alignItems="end"
justifyContent="center"
pb={4}
style={{
borderRadius: '0.5rem 0.5rem 0 0',
background: `linear-gradient(to top, ${config.color}, transparent)`,
borderTop: `1px solid ${config.borderColor}`,
borderLeft: `1px solid ${config.borderColor}`,
borderRight: `1px solid ${config.borderColor}`
}}
> >
<Text weight="bold" size={position === 1 ? '4xl' : '3xl'} color={config.crown === '#facc15' ? 'text-warning-amber' : config.crown === '#d1d5db' ? 'text-gray-300' : 'text-orange-600'}> <Avatar
{position} src={driver.avatarUrl}
</Text> alt={driver.name}
</Stack> size={isFirst ? 'lg' : 'md'}
</Stack> />
); </Group>
})}
</Stack> <Text weight="bold" variant="high" size={isFirst ? 'md' : 'sm'}>{driver.name}</Text>
</Stack> <Text font="mono" weight="bold" variant={isFirst ? 'warning' : 'primary'}>
{RatingDisplay.format(driver.rating)}
</Text>
</Group>
<Surface
variant={config.variant as any}
rounded="lg"
style={{
width: '6rem',
height: config.height,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
border: '1px solid var(--ui-color-border-default)',
borderBottom: 'none',
borderBottomLeftRadius: 0,
borderBottomRightRadius: 0
}}
>
<Text size="3xl" weight="bold" variant="low" opacity={0.2}>
{position}
</Text>
</Surface>
</Group>
);
})}
</Group>
); );
} }

View File

@@ -1,11 +1,9 @@
import { Group } from '@/ui/Group';
import { Icon } from '@/ui/Icon';
import { Image } from '@/ui/Image';
import { Stack } from '@/ui/Stack';
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/ui/Table'; import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/ui/Table';
import { Text } from '@/ui/Text'; import { Text } from '@/ui/Text';
import { Medal } from 'lucide-react'; import { RankingRow } from './RankingRow';
import { EmptyState } from '@/ui/EmptyState';
import { Trophy } from 'lucide-react';
interface Driver { interface Driver {
id: string; id: string;
@@ -29,88 +27,34 @@ interface RankingsTableProps {
export function RankingsTable({ drivers, onDriverClick }: RankingsTableProps) { export function RankingsTable({ drivers, onDriverClick }: RankingsTableProps) {
if (drivers.length === 0) { if (drivers.length === 0) {
return ( return (
<Stack py={16} align="center" bg="bg-iron-gray/30" border borderColor="border-charcoal-outline" rounded="xl"> <EmptyState
<Text color="text-gray-400" block mb={2}>No drivers found</Text> title="No drivers found"
<Text size="sm" color="text-gray-500">There are no drivers in the system yet</Text> description="There are no drivers in the system yet"
</Stack> icon={Trophy}
/>
); );
} }
return ( return (
<Stack rounded="xl" bg="bg-iron-gray/30" border borderColor="border-charcoal-outline" overflow="hidden" gap={0}> <Table>
<Table> <TableHead>
<TableHead> <TableRow>
<TableRow> <TableHeader className="text-center w-16">Rank</TableHeader>
<TableHeader className="text-center w-16">Rank</TableHeader> <TableHeader>Driver</TableHeader>
<TableHeader>Driver</TableHeader> <TableHeader className="text-center">Races</TableHeader>
<TableHeader className="text-center">Races</TableHeader> <TableHeader className="text-center">Rating</TableHeader>
<TableHeader className="text-center">Rating</TableHeader> <TableHeader className="text-center">Wins</TableHeader>
<TableHeader className="text-center">Wins</TableHeader> </TableRow>
</TableRow> </TableHead>
</TableHead> <TableBody>
<TableBody> {drivers.map((driver) => (
{drivers.map((driver) => ( <RankingRow
<TableRow key={driver.id}
key={driver.id} {...driver}
clickable onClick={() => onDriverClick?.(driver.id)}
onClick={() => onDriverClick?.(driver.id)} />
> ))}
<TableCell className="text-center"> </TableBody>
<Stack </Table>
direction="row"
h="9"
w="9"
align="center"
justify="center"
rounded="full"
border
borderColor="border-charcoal-outline"
bg={driver.medalBg}
fontSize="sm"
fontWeight="bold"
>
{driver.rank <= 3 ? <Icon icon={Medal} size={4} color={driver.medalColor} /> : (
<Text color={driver.medalColor}>{driver.rank}</Text>
)}
</Stack>
</TableCell>
<TableCell>
<Stack direction="row" align="center" gap={3}>
<Stack position="relative" w="10" h="10" rounded="full" overflow="hidden" border borderColor="border-charcoal-outline" gap={0}>
<Image src={driver.avatarUrl} alt={driver.name} width={40} height={40} objectFit="cover" fullWidth fullHeight />
</Stack>
<Stack minWidth="0" gap={0}>
<Text weight="semibold" color="text-white" block truncate>
{driver.name}
</Text>
<Stack direction="row" align="center" gap={2} mt={1}>
<Text size="xs" color="text-gray-500">{driver.nationality}</Text>
<Text size="xs" color="text-gray-500">{driver.skillLevel}</Text>
</Stack>
</Stack>
</Stack>
</TableCell>
<TableCell className="text-center">
<Text color="text-gray-400">{driver.racesCompleted}</Text>
</TableCell>
<TableCell className="text-center">
<Text font="mono" weight="semibold" color="text-white">
{driver.rating.toString()}
</Text>
</TableCell>
<TableCell className="text-center">
<Text font="mono" weight="semibold" color="text-performance-green">
{driver.wins}
</Text>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</Stack>
); );
} }

View File

@@ -1,4 +1,4 @@
import { Box } from '@/ui/Box'; import { Group } from '@/ui/Group';
import { Icon } from '@/ui/Icon'; import { Icon } from '@/ui/Icon';
import { Select } from '@/ui/Select'; import { Select } from '@/ui/Select';
import { Text } from '@/ui/Text'; import { Text } from '@/ui/Text';
@@ -23,19 +23,18 @@ export function SeasonSelector({ seasons, selectedSeasonId, onSeasonChange }: Se
})); }));
return ( return (
<Box display="flex" alignItems="center" gap={3}> <Group gap={3}>
<Box display="flex" alignItems="center" gap={2} color="text-gray-500"> <Group gap={2}>
<Icon icon={Calendar} size={4} /> <Icon icon={Calendar} size={4} intent="low" />
<Text size="xs" weight="bold" uppercase letterSpacing="wider">Season</Text> <Text size="xs" weight="bold" uppercase letterSpacing="wider" variant="low">Season</Text>
</Box> </Group>
<Box width="48"> <Group>
<Select <Select
options={options} options={options}
value={selectedSeasonId} value={selectedSeasonId}
onChange={(e) => onSeasonChange(e.target.value)} onChange={(e) => onSeasonChange(e.target.value)}
fullWidth={true}
/> />
</Box> </Group>
</Box> </Group>
); );
} }

View File

@@ -1,10 +1,11 @@
import { RankBadge } from '@/components/leaderboards/RankBadge'; import { RankBadge } from '@/components/leaderboards/RankBadge';
import { getMediaUrl } from '@/lib/utilities/media'; import { getMediaUrl } from '@/lib/utilities/media';
import { Box } from '@/ui/Box'; import { Avatar } from '@/ui/Avatar';
import { Icon } from '@/ui/Icon'; import { Icon } from '@/ui/Icon';
import { Image } from '@/ui/Image';
import { LeaderboardList } from '@/ui/LeaderboardList'; import { LeaderboardList } from '@/ui/LeaderboardList';
import { LeaderboardPreviewShell } from '@/ui/LeaderboardPreviewShell'; import { LeaderboardPreviewShell } from '@/ui/LeaderboardPreviewShell';
import { LeaderboardRow } from '@/ui/LeaderboardRow';
import { Group } from '@/ui/Group';
import { Text } from '@/ui/Text'; import { Text } from '@/ui/Text';
import { Users } from 'lucide-react'; import { Users } from 'lucide-react';
@@ -43,83 +44,57 @@ export function TeamLeaderboardPreview({ teams, onTeamClick, onNavigateToTeams }
const position = team.position; const position = team.position;
return ( return (
<Box <LeaderboardRow
key={team.id} key={team.id}
as="button"
type="button"
onClick={() => onTeamClick(team.id)} onClick={() => onTeamClick(team.id)}
display="flex" rank={<RankBadge rank={position} />}
alignItems="center" identity={
gap={4} <Group gap={4}>
px={5} <Avatar
py={3} src={team.logoUrl || getMediaUrl('team-logo', team.id)}
w="full" alt={team.name}
textAlign="left" size="sm"
transition />
hoverBg="bg-white/[0.02]" <Group direction="column" align="start" gap={0}>
group <Text
> weight="bold"
<Box w="8" display="flex" justifyContent="center"> variant="high"
<RankBadge rank={position} /> truncate
</Box> block
>
<Box {team.name}
display="flex" </Text>
h="9" <Group gap={2} wrap>
w="9" <Text size="xs" variant="low" uppercase weight="bold" letterSpacing="wider" font="mono">{team.performanceLevel}</Text>
alignItems="center" <Group gap={1}>
justifyContent="center" <Icon icon={Users} size={3} intent="low" />
rounded="lg" <Text size="xs" variant="low" weight="bold">{team.memberCount}</Text>
bg="bg-graphite-black/50" </Group>
border </Group>
borderColor="border-charcoal-outline" </Group>
overflow="hidden" </Group>
groupHoverBorderColor="primary-blue/50" }
transition stats={
> <Group gap={8}>
<Image <Group direction="column" align="end" gap={0}>
src={team.logoUrl || getMediaUrl('team-logo', team.id)} <Text variant="primary" font="mono" weight="bold" block size="md" align="right">
alt={team.name} {team.rating?.toFixed(0) || '1000'}
width={36} </Text>
height={36} <Text size="xs" variant="low" block uppercase letterSpacing="widest" weight="bold" fontSize="9px" align="right">
fullWidth Rating
fullHeight </Text>
objectFit="cover" </Group>
/> <Group direction="column" align="end" gap={0}>
</Box> <Text variant="success" font="mono" weight="bold" block size="md" align="right">
{team.totalWins}
<Box flexGrow={1} minWidth="0"> </Text>
<Text <Text size="xs" variant="low" block uppercase letterSpacing="widest" weight="bold" fontSize="9px" align="right">
weight="semibold" Wins
color="text-white" </Text>
truncate </Group>
groupHoverTextColor="text-primary-blue" </Group>
transition }
block />
>
{team.name}
</Text>
<Box display="flex" alignItems="center" gap={2} flexWrap="wrap">
<Text size="xs" variant="low" uppercase font="mono">{team.performanceLevel}</Text>
<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}</Text>
</Box>
</Box>
</Box>
<Box display="flex" alignItems="center" gap={6}>
<Box textAlign="right">
<Text color="text-primary-blue" font="mono" weight="bold" block size="sm">{team.rating?.toFixed(0) || '1000'}</Text>
<Text fontSize="10px" color="text-gray-500" block uppercase letterSpacing="wider" weight="bold">Rating</Text>
</Box>
<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>
); );
})} })}
</LeaderboardList> </LeaderboardList>

View File

@@ -1,5 +1,6 @@
import React from 'react'; import React from 'react';
import { TeamRankingRow } from './TeamRankingRow'; import { TeamRankingRow } from './TeamRankingRow';
import { LeaderboardList } from '@/ui/LeaderboardList';
import { LeaderboardTableShell } from '@/ui/LeaderboardTableShell'; import { LeaderboardTableShell } from '@/ui/LeaderboardTableShell';
interface LeaderboardTeam { interface LeaderboardTeam {
@@ -19,30 +20,24 @@ interface TeamLeaderboardTableProps {
} }
export function TeamLeaderboardTable({ teams, onTeamClick }: TeamLeaderboardTableProps) { export function TeamLeaderboardTable({ teams, onTeamClick }: TeamLeaderboardTableProps) {
const columns = [
{ key: 'rank', label: 'Rank', width: '8rem' },
{ key: 'team', label: 'Team' },
{ key: 'rating', label: 'Rating', align: 'center' as const },
{ key: 'wins', label: 'Wins', align: 'center' as const },
{ key: 'races', label: 'Races', align: 'center' as const },
];
return ( return (
<LeaderboardTableShell columns={columns}> <LeaderboardTableShell>
{teams.map((team) => ( <LeaderboardList>
<TeamRankingRow {teams.map((team) => (
key={team.id} <TeamRankingRow
rank={team.position} key={team.id}
id={team.id} rank={team.position}
name={team.name} id={team.id}
logoUrl={team.logoUrl} name={team.name}
rating={team.rating} logoUrl={team.logoUrl}
wins={team.totalWins} rating={team.rating}
races={team.totalRaces} wins={team.totalWins}
memberCount={team.memberCount} races={team.totalRaces}
onClick={() => onTeamClick?.(team.id)} memberCount={team.memberCount}
/> onClick={() => onTeamClick?.(team.id)}
))} />
))}
</LeaderboardList>
</LeaderboardTableShell> </LeaderboardTableShell>
); );
} }

View File

@@ -1,12 +1,9 @@
import { getMediaUrl } from '@/lib/utilities/media'; import { getMediaUrl } from '@/lib/utilities/media';
import { Image } from '@/ui/Image'; import { Avatar } from '@/ui/Avatar';
import { TableCell, TableRow } from '@/ui/Table';
import { Text } from '@/ui/Text'; import { Text } from '@/ui/Text';
import { Box } from '@/ui/Box';
import { Group } from '@/ui/Group'; import { Group } from '@/ui/Group';
import { Stack } from '@/ui/Stack'; import { RankBadge } from './RankBadge';
import { Surface } from '@/ui/Surface'; import { LeaderboardRow } from '@/ui/LeaderboardRow';
import { RankMedal } from './RankMedal';
import React from 'react'; import React from 'react';
interface TeamRankingRowProps { interface TeamRankingRowProps {
@@ -33,66 +30,59 @@ export function TeamRankingRow({
onClick, onClick,
}: TeamRankingRowProps) { }: TeamRankingRowProps) {
return ( return (
<TableRow <LeaderboardRow
clickable={!!onClick}
onClick={onClick} onClick={onClick}
> rank={<RankBadge rank={rank} />}
<TableCell> identity={
<Box width="2rem" display="flex" justifyContent="center"> <Group gap={4}>
<RankMedal rank={rank} size="md" /> <Avatar
</Box> src={logoUrl || getMediaUrl('team-logo', id)}
</TableCell> alt={name}
size="md"
<TableCell> />
<Group gap={3}> <Group direction="column" align="start" gap={0}>
<Surface
position="relative"
width="2.5rem"
height="2.5rem"
rounded="md"
overflow="hidden"
border
variant="muted"
>
<Image
src={logoUrl || getMediaUrl('team-logo', id)}
alt={name}
width={40}
height={40}
objectFit="cover"
/>
</Surface>
<Stack gap={0} flex={1} minWidth="0">
<Text <Text
weight="semibold" weight="bold"
variant="high" variant="high"
block block
truncate truncate
> >
{name} {name}
</Text> </Text>
<Text size="xs" variant="low" block> <Text size="xs" variant="low" uppercase weight="bold" letterSpacing="wider">
{memberCount} Members {memberCount} Members
</Text> </Text>
</Stack> </Group>
</Group> </Group>
</TableCell> }
stats={
<TableCell textAlign="center"> <Group gap={8}>
<Text font="mono" weight="bold" variant="primary"> <Group direction="column" align="end" gap={0}>
{rating} <Text variant="low" font="mono" weight="bold" block size="md">
</Text> {races}
</TableCell> </Text>
<Text size="xs" variant="low" block uppercase letterSpacing="widest" weight="bold" fontSize="9px">
<TableCell textAlign="center"> Races
<Text font="mono" weight="bold" variant="success"> </Text>
{wins} </Group>
</Text> <Group direction="column" align="end" gap={0}>
</TableCell> <Text variant="primary" font="mono" weight="bold" block size="md">
{rating}
<TableCell textAlign="center"> </Text>
<Text variant="low" font="mono">{races}</Text> <Text size="xs" variant="low" block uppercase letterSpacing="widest" weight="bold" fontSize="9px">
</TableCell> Rating
</TableRow> </Text>
</Group>
<Group direction="column" align="end" gap={0}>
<Text variant="success" font="mono" weight="bold" block size="md">
{wins}
</Text>
<Text size="xs" variant="low" block uppercase letterSpacing="widest" weight="bold" fontSize="9px">
Wins
</Text>
</Group>
</Group>
}
/>
); );
} }

View File

@@ -7,13 +7,15 @@ export class LeaderboardsViewDataBuilder {
apiDto: { drivers: { drivers: DriverLeaderboardItemDTO[] }; teams: GetTeamsLeaderboardOutputDTO } apiDto: { drivers: { drivers: DriverLeaderboardItemDTO[] }; teams: GetTeamsLeaderboardOutputDTO }
): LeaderboardsViewData { ): LeaderboardsViewData {
return { return {
drivers: apiDto.drivers.drivers.slice(0, 10).map(driver => ({ drivers: apiDto.drivers.drivers.map(driver => ({
id: driver.id, id: driver.id,
name: driver.name, name: driver.name,
rating: driver.rating, rating: driver.rating,
skillLevel: driver.skillLevel, skillLevel: driver.skillLevel,
nationality: driver.nationality, nationality: driver.nationality,
wins: driver.wins, wins: driver.wins,
podiums: driver.podiums,
racesCompleted: driver.racesCompleted,
rank: driver.rank, rank: driver.rank,
avatarUrl: driver.avatarUrl || '', avatarUrl: driver.avatarUrl || '',
position: driver.rank, position: driver.rank,
@@ -25,6 +27,7 @@ export class LeaderboardsViewDataBuilder {
memberCount: team.memberCount, memberCount: team.memberCount,
category: undefined, category: undefined,
totalWins: team.totalWins || 0, totalWins: team.totalWins || 0,
totalRaces: team.totalRaces || 0,
logoUrl: team.logoUrl || '', logoUrl: team.logoUrl || '',
position: index + 1, position: index + 1,
isRecruiting: team.isRecruiting, isRecruiting: team.isRecruiting,

View File

@@ -252,6 +252,8 @@ export const routeMatchers = {
routes.public.drivers, routes.public.drivers,
routes.public.teams, routes.public.teams,
routes.public.leaderboards, routes.public.leaderboards,
routes.leaderboards.drivers,
routes.leaderboards.teams,
routes.public.races, routes.public.races,
routes.public.sponsorSignup, routes.public.sponsorSignup,
routes.auth.login, routes.auth.login,

View File

@@ -5,7 +5,9 @@ export interface LeaderboardDriverItem {
skillLevel: string; skillLevel: string;
nationality: string; nationality: string;
wins: number; wins: number;
podiums: number;
racesCompleted: number;
rank: number; rank: number;
avatarUrl: string; avatarUrl: string;
position: number; position: number;
} }

View File

@@ -5,7 +5,7 @@ export interface LeaderboardTeamItem {
memberCount: number; memberCount: number;
category?: string; category?: string;
totalWins: number; totalWins: number;
totalRaces?: number; totalRaces: number;
logoUrl: string; logoUrl: string;
position: number; position: number;
isRecruiting: boolean; isRecruiting: boolean;

View File

@@ -46,4 +46,23 @@ module.exports = {
}, },
}, },
plugins: [], plugins: [],
safelist: [
{
pattern: /^(grid-cols|gap|p|px|py|pt|pb|pl|pr|m|mx|my|mt|mb|ml|mr)-/,
variants: ['sm', 'md', 'lg', 'xl', '2xl'],
},
{
pattern: /^(w|h|max-w|min-w|max-h|min-h)-/,
variants: ['sm', 'md', 'lg', 'xl', '2xl'],
},
{
pattern: /^(flex|items|justify|self)-/,
variants: ['sm', 'md', 'lg', 'xl', '2xl'],
},
'grid',
'flex',
'block',
'inline-block',
'none',
],
} }

View File

@@ -1,11 +1,14 @@
'use client';
import { DriverLeaderboardPreview } from '@/components/leaderboards/DriverLeaderboardPreview'; import { DriverLeaderboardPreview } from '@/components/leaderboards/DriverLeaderboardPreview';
import { TeamLeaderboardPreview } from '@/components/leaderboards/TeamLeaderboardPreview'; import { TeamLeaderboardPreview } from '@/components/leaderboards/TeamLeaderboardPreview';
import type { LeaderboardsViewData } from '@/lib/view-data/LeaderboardsViewData'; import type { LeaderboardsViewData } from '@/lib/view-data/LeaderboardsViewData';
import { Section } from '@/ui/Section'; import { Section } from '@/ui/Section';
import { PageHero } from '@/ui/PageHero'; import { PageHeader } from '@/ui/PageHeader';
import { FeatureGrid } from '@/ui/FeatureGrid'; import { FeatureGrid } from '@/ui/FeatureGrid';
import { Container } from '@/ui/Container';
import { Stack } from '@/ui/Stack';
import { Group } from '@/ui/Group';
import { Button } from '@/ui/Button';
import { Icon } from '@/ui/Icon';
import { Trophy, Users, Activity } from 'lucide-react'; import { Trophy, Users, Activity } from 'lucide-react';
import React from 'react'; import React from 'react';
@@ -24,43 +27,86 @@ export function LeaderboardsTemplate({
onNavigateToDrivers, onNavigateToDrivers,
onNavigateToTeams onNavigateToTeams
}: LeaderboardsTemplateProps) { }: LeaderboardsTemplateProps) {
return ( const top10Drivers = viewData.drivers.slice(0, 10);
<Section variant="default" padding="lg"> const top5Teams = viewData.teams.slice(0, 5);
<PageHero
title="Global Standings" // Newcomers: less than 10 races, sorted by rating
description="Performance metrics for drivers and teams. Rankings are calculated based on competitive results and consistency across all events." const topNewcomers = [...viewData.drivers]
icon={Activity} .filter(d => d.racesCompleted < 10)
actions={[ .sort((a, b) => b.rating - a.rating)
{ .slice(0, 5);
label: 'Driver Rankings',
onClick: onNavigateToDrivers, // All time: sorted by wins
icon: Trophy, const topAllTime = [...viewData.drivers]
variant: 'primary' .sort((a, b) => b.wins - a.wins)
}, .slice(0, 5);
{
label: 'Team Standings',
onClick: onNavigateToTeams,
icon: Users,
variant: 'secondary'
}
]}
/>
<FeatureGrid columns={{ base: 1, lg: 2 }} gap={8}> return (
<DriverLeaderboardPreview <Section variant="default" padding="none" py={12}>
drivers={viewData.drivers} <Container size="full" padding="lg">
onDriverClick={onDriverClick} <Stack gap={16}>
onNavigateToDrivers={onNavigateToDrivers} <PageHeader
/> title="Leaderboards"
<TeamLeaderboardPreview description="Global Performance Standings"
teams={viewData.teams.map(t => ({ icon={Activity}
...t, action={
logoUrl: t.logoUrl || '' <Group gap={4}>
}))} <Button
onTeamClick={onTeamClick} variant="secondary"
onNavigateToTeams={onNavigateToTeams} onClick={onNavigateToDrivers}
/> icon={<Icon icon={Trophy} size={4} />}
</FeatureGrid> >
Drivers
</Button>
<Button
variant="secondary"
onClick={onNavigateToTeams}
icon={<Icon icon={Users} size={4} />}
>
Teams
</Button>
</Group>
}
/>
{/* Top 10 2026 up top */}
<DriverLeaderboardPreview
title="Top 10 Drivers 2026"
subtitle="Current Season Standings"
drivers={top10Drivers}
onDriverClick={onDriverClick}
onNavigateToDrivers={onNavigateToDrivers}
/>
<FeatureGrid columns={{ base: 1, lg: 2 }} gap={12}>
<TeamLeaderboardPreview
teams={top5Teams.map(t => ({
...t,
logoUrl: t.logoUrl || ''
}))}
onTeamClick={onTeamClick}
onNavigateToTeams={onNavigateToTeams}
/>
<Stack gap={12}>
<DriverLeaderboardPreview
title="Top Newcomers"
subtitle="Rising Stars (< 10 Races)"
drivers={topNewcomers}
onDriverClick={onDriverClick}
onNavigateToDrivers={onNavigateToDrivers}
/>
<DriverLeaderboardPreview
title="Top All Time"
subtitle="Most Wins"
drivers={topAllTime}
onDriverClick={onDriverClick}
onNavigateToDrivers={onNavigateToDrivers}
/>
</Stack>
</FeatureGrid>
</Stack>
</Container>
</Section> </Section>
); );
} }

View File

@@ -3,7 +3,7 @@ import { Grid } from './Grid';
interface FeatureGridProps { interface FeatureGridProps {
children: ReactNode; children: ReactNode;
columns?: number | { base: number; md?: number; lg?: number }; columns?: number | { base: number; md?: number; lg?: number; xl?: number };
gap?: number; gap?: number;
} }

View File

@@ -8,11 +8,9 @@ export interface LeaderboardListProps {
export const LeaderboardList = ({ children }: LeaderboardListProps) => { export const LeaderboardList = ({ children }: LeaderboardListProps) => {
return ( return (
<Surface variant="muted" rounded="xl" style={{ border: '1px solid var(--ui-color-border-default)', overflow: 'hidden' }}> <Box display="flex" flexDirection="col">
<Box display="flex" flexDirection="col"> {children}
{children} </Box>
</Box>
</Surface>
); );
}; };

View File

@@ -32,19 +32,19 @@ export const LeaderboardPreviewShell = ({
viewFullLabel viewFullLabel
}: LeaderboardPreviewShellProps) => { }: LeaderboardPreviewShellProps) => {
return ( return (
<Surface variant="default" rounded="xl" style={{ border: '1px solid var(--ui-color-border-default)', overflow: 'hidden' }}> <Surface variant="precision" rounded="xl" style={{ overflow: 'hidden' }}>
<Box padding={6} borderBottom> <Box padding={6} borderBottom borderColor="var(--ui-color-border-muted)">
<Box display="flex" alignItems="center" justifyContent="between" marginBottom={4}> <Box display="flex" alignItems="center" justifyContent="between" marginBottom={6}>
<Box display="flex" alignItems="center" gap={4}> <Box display="flex" alignItems="center" gap={5}>
<Box padding={2} rounded="lg" bg={iconBgGradient || "var(--ui-color-bg-surface-muted)"} style={iconColor ? { color: iconColor } : undefined}> <Box padding={3} rounded="xl" bg={iconBgGradient || "var(--ui-color-bg-surface-muted)"} style={iconColor ? { color: iconColor } : undefined}>
<Icon icon={icon} size={5} intent={iconColor ? undefined : intent} /> <Icon icon={icon} size={6} intent={iconColor ? undefined : intent} />
</Box> </Box>
<Box> <Box>
<Text size="lg" weight="bold" variant="high" block> <Text size="xl" weight="bold" variant="high" block>
{title} {title}
</Text> </Text>
{subtitle && ( {subtitle && (
<Text size="sm" variant="low"> <Text size="sm" variant="low" uppercase weight="bold" letterSpacing="widest">
{subtitle} {subtitle}
</Text> </Text>
)} )}
@@ -63,7 +63,7 @@ export const LeaderboardPreviewShell = ({
</Box> </Box>
{footer && ( {footer && (
<Box padding={4} borderTop bg="rgba(255,255,255,0.02)"> <Box padding={4} borderTop borderColor="var(--ui-color-border-muted)" bg="rgba(255,255,255,0.02)">
{footer} {footer}
</Box> </Box>
)} )}

View File

@@ -0,0 +1,76 @@
import { ReactNode } from 'react';
import { Box } from './Box';
import { Surface } from './Surface';
import { Group } from './Group';
export interface LeaderboardRowProps {
rank: ReactNode;
identity: ReactNode;
stats: ReactNode;
onClick?: () => void;
}
/**
* LeaderboardRow is a semantic UI component for displaying an entry in a leaderboard.
* It follows the "Modern Precision" theme with obsessive detail.
*/
export const LeaderboardRow = ({
rank,
identity,
stats,
onClick
}: LeaderboardRowProps) => {
return (
<Surface
as={onClick ? 'button' : 'div'}
variant="precision"
onClick={onClick}
padding="none"
cursor={onClick ? 'pointer' : 'default'}
width="full"
textAlign="left"
transition="all 0.2s cubic-bezier(0.4, 0, 0.2, 1)"
hoverBg="rgba(25, 140, 255, 0.04)"
display="block"
style={{
border: 'none',
borderBottom: '1px solid var(--ui-color-border-muted)',
background: 'transparent',
position: 'relative'
}}
className="group"
>
<Box
display="flex"
alignItems="center"
gap={6}
paddingX={6}
paddingY={4}
className="transition-transform duration-200 group-hover:translate-x-1"
>
<Box width="10" display="flex" justifyContent="center" flexShrink={0}>
{rank}
</Box>
<Box flexGrow={1} minWidth="0">
{identity}
</Box>
<Group gap={8}>
{stats}
</Group>
</Box>
{/* Hover indicator */}
<Box
position="absolute"
left={0}
top={0}
bottom={0}
width={1}
bg="var(--ui-color-intent-primary)"
className="opacity-0 transition-opacity duration-200 group-hover:opacity-100"
/>
</Surface>
);
};

View File

@@ -9,7 +9,7 @@ export interface LeaderboardTableShellProps {
export const LeaderboardTableShell = ({ children, columns }: LeaderboardTableShellProps) => { export const LeaderboardTableShell = ({ children, columns }: LeaderboardTableShellProps) => {
return ( return (
<Surface variant="default" rounded="xl" style={{ border: '1px solid var(--ui-color-border-default)', overflow: 'hidden' }}> <Surface variant="precision" rounded="xl" style={{ overflow: 'hidden' }} marginBottom={8}>
<Box> <Box>
{children} {children}
</Box> </Box>

View File

@@ -1,7 +1,6 @@
import { Box } from '@/ui/Box'; import { Box } from '@/ui/Box';
import { Icon } from '@/ui/Icon'; import { Icon } from '@/ui/Icon';
import { Text } from '@/ui/Text'; import { Text } from '@/ui/Text';
import Link from 'next/link';
import { LucideIcon, ChevronRight } from 'lucide-react'; import { LucideIcon, ChevronRight } from 'lucide-react';
interface NavLinkProps { interface NavLinkProps {
@@ -14,7 +13,7 @@ interface NavLinkProps {
LinkComponent?: React.ComponentType<{ href: string; children: React.ReactNode; className?: string }>; LinkComponent?: React.ComponentType<{ href: string; children: React.ReactNode; className?: string }>;
} }
export function NavLink({ href, label, icon, isActive, variant = 'sidebar', collapsed = false, LinkComponent = Link as any }: NavLinkProps) { export function NavLink({ href, label, icon, isActive, variant = 'sidebar', collapsed = false, LinkComponent }: NavLinkProps) {
const isTop = variant === 'top'; const isTop = variant === 'top';
// Radical "Game Menu" Style // Radical "Game Menu" Style
@@ -72,12 +71,23 @@ export function NavLink({ href, label, icon, isActive, variant = 'sidebar', coll
</Box> </Box>
); );
if (LinkComponent) {
return (
<LinkComponent
href={href}
className={`w-full group block ${!isTop ? '' : ''}`}
>
{content}
</LinkComponent>
);
}
return ( return (
<LinkComponent <a
href={href} href={href}
className={`w-full group block ${!isTop ? '' : ''}`} className={`w-full group block ${!isTop ? '' : ''}`}
> >
{content} {content}
</LinkComponent> </a>
); );
} }

View File

@@ -13,12 +13,14 @@ interface PageHeaderProps {
title: string; title: string;
description?: string; description?: string;
action?: React.ReactNode; action?: React.ReactNode;
icon?: LucideIcon;
} }
export function PageHeader({ export function PageHeader({
title, title,
description, description,
action, action,
icon,
}: PageHeaderProps) { }: PageHeaderProps) {
return ( return (
<Box <Box
@@ -33,7 +35,11 @@ export function PageHeader({
> >
<Box> <Box>
<Box display="flex" alignItems="center" gap={3} marginBottom={2}> <Box display="flex" alignItems="center" gap={3} marginBottom={2}>
<Box width={1} height={8} backgroundColor="var(--ui-color-intent-primary)" /> {icon ? (
<Icon icon={icon} size={8} intent="primary" />
) : (
<Box width={1} height={8} backgroundColor="var(--ui-color-intent-primary)" />
)}
<Heading level={1} weight="bold" uppercase>{title}</Heading> <Heading level={1} weight="bold" uppercase>{title}</Heading>
</Box> </Box>
{description && ( {description && (

View File

@@ -0,0 +1,70 @@
import { Icon } from './Icon';
import { Text } from './Text';
import { Surface } from './Surface';
import { Crown, Medal } from 'lucide-react';
import React from 'react';
export interface RankMedalProps {
rank: number;
size?: 'sm' | 'md' | 'lg';
showIcon?: boolean;
variant?: 'primary' | 'success' | 'warning' | 'critical' | 'telemetry' | 'default';
bg?: string;
color?: string;
}
/**
* RankMedal is a semantic UI component for displaying a rank with a medal icon or number.
* It follows the "Modern Precision" theme.
*/
export const RankMedal = ({
rank,
size = 'md',
showIcon = true,
variant,
bg,
color
}: RankMedalProps) => {
const isTop3 = rank <= 3;
const sizePx = {
sm: '1.75rem',
md: '2rem',
lg: '2.5rem',
};
const textSizeMap = {
sm: 'xs',
md: 'xs',
lg: 'sm',
} as const;
const iconSize = {
sm: 3,
md: 3.5,
lg: 4.5,
};
return (
<Surface
variant="muted"
rounded="full"
style={{
height: sizePx[size],
width: sizePx[size],
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
border: '1px solid var(--ui-color-border-default)',
backgroundColor: bg,
color: color
}}
>
{isTop3 && showIcon ? (
<Icon icon={rank === 1 ? Crown : Medal} size={iconSize[size] as any} intent={variant as any} />
) : (
<Text weight="bold" size={textSizeMap[size]} variant={variant as any}>{rank}</Text>
)}
</Surface>
);
};

View File

@@ -73,8 +73,11 @@ export const TableHeaderCell = ({ children, textAlign, w, className }: TableHead
const alignClass = textAlign === 'center' ? 'text-center' : (textAlign === 'right' ? 'text-right' : 'text-left'); const alignClass = textAlign === 'center' ? 'text-center' : (textAlign === 'right' ? 'text-right' : 'text-left');
return ( return (
<th <th
className={`px-4 py-3 text-xs font-bold uppercase tracking-wider text-[var(--ui-color-text-low)] ${alignClass} ${className || ''}`} className={`px-6 py-4 text-[10px] font-bold uppercase tracking-[0.15em] text-[var(--ui-color-text-low)] border-b border-[var(--ui-color-border-muted)] ${alignClass} ${className || ''}`}
style={w ? { width: w } : undefined} style={{
width: w,
background: 'rgba(255, 255, 255, 0.01)'
}}
> >
{children} {children}
</th> </th>
@@ -96,7 +99,7 @@ export const TableCell = ({ children, textAlign, className, py, colSpan, w, posi
const alignClass = textAlign === 'center' ? 'text-center' : (textAlign === 'right' ? 'text-right' : 'text-left'); const alignClass = textAlign === 'center' ? 'text-center' : (textAlign === 'right' ? 'text-right' : 'text-left');
return ( return (
<td <td
className={`px-4 py-3 text-sm text-[var(--ui-color-text-high)] ${alignClass} ${className || ''}`} className={`px-6 py-4 text-sm text-[var(--ui-color-text-high)] ${alignClass} ${className || ''}`}
colSpan={colSpan} colSpan={colSpan}
style={{ style={{
...(py !== undefined ? { paddingTop: `${py * 0.25}rem`, paddingBottom: `${py * 0.25}rem` } : {}), ...(py !== undefined ? { paddingTop: `${py * 0.25}rem`, paddingBottom: `${py * 0.25}rem` } : {}),