website refactor
This commit is contained in:
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
}
|
||||||
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
}
|
||||||
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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',
|
||||||
|
],
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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>
|
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -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>
|
||||||
)}
|
)}
|
||||||
|
|||||||
76
apps/website/ui/LeaderboardRow.tsx
Normal file
76
apps/website/ui/LeaderboardRow.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 && (
|
||||||
|
|||||||
70
apps/website/ui/RankMedal.tsx
Normal file
70
apps/website/ui/RankMedal.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -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` } : {}),
|
||||||
|
|||||||
Reference in New Issue
Block a user