website refactor
This commit is contained in:
51
apps/website/components/leaderboards/DeltaChip.tsx
Normal file
51
apps/website/components/leaderboards/DeltaChip.tsx
Normal file
@@ -0,0 +1,51 @@
|
||||
import React from 'react';
|
||||
import { ChevronUp, ChevronDown, Minus } from 'lucide-react';
|
||||
import { Box } from '@/ui/Box';
|
||||
import { Text } from '@/ui/Text';
|
||||
import { Icon } from '@/ui/Icon';
|
||||
|
||||
interface DeltaChipProps {
|
||||
value: number;
|
||||
type?: 'rank' | 'rating';
|
||||
}
|
||||
|
||||
export function DeltaChip({ value, type = 'rank' }: DeltaChipProps) {
|
||||
if (value === 0) {
|
||||
return (
|
||||
<Box display="flex" alignItems="center" gap={1} color="text-gray-600">
|
||||
<Icon icon={Minus} size={3} />
|
||||
<Text size="xs" font="mono">0</Text>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
const isPositive = value > 0;
|
||||
const color = isPositive
|
||||
? (type === 'rank' ? 'text-performance-green' : 'text-performance-green')
|
||||
: (type === 'rank' ? 'text-error-red' : 'text-error-red');
|
||||
|
||||
// For rank, positive delta usually means dropping positions (e.g. +1 rank means 1st -> 2nd)
|
||||
// But usually "Delta" in leaderboards means "positions gained/lost"
|
||||
// Let's assume value is "positions gained" (positive = up, negative = down)
|
||||
|
||||
const IconComponent = isPositive ? ChevronUp : ChevronDown;
|
||||
const absoluteValue = Math.abs(value);
|
||||
|
||||
return (
|
||||
<Box
|
||||
display="flex"
|
||||
alignItems="center"
|
||||
gap={0.5}
|
||||
color={color}
|
||||
bg={`${color.replace('text-', 'bg-')}/10`}
|
||||
px={1.5}
|
||||
py={0.5}
|
||||
rounded="full"
|
||||
>
|
||||
<Icon icon={IconComponent} size={3} />
|
||||
<Text size="xs" font="mono" weight="bold">
|
||||
{absoluteValue}
|
||||
</Text>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
import React from 'react';
|
||||
import { Trophy, Crown, Flag, ChevronRight } from 'lucide-react';
|
||||
import { Trophy, ChevronRight } from 'lucide-react';
|
||||
import { Button } from '@/ui/Button';
|
||||
import { Box } from '@/ui/Box';
|
||||
import { Text } from '@/ui/Text';
|
||||
@@ -8,8 +8,9 @@ import { Icon } from '@/ui/Icon';
|
||||
import { Stack } from '@/ui/Stack';
|
||||
import { Image } from '@/ui/Image';
|
||||
import { SkillLevelDisplay } from '@/lib/display-objects/SkillLevelDisplay';
|
||||
import { MedalDisplay } from '@/lib/display-objects/MedalDisplay';
|
||||
import { RatingDisplay } from '@/lib/display-objects/RatingDisplay';
|
||||
import { RankMedal } from './RankMedal';
|
||||
import { LeaderboardTableShell } from './LeaderboardTableShell';
|
||||
|
||||
interface DriverLeaderboardPreviewProps {
|
||||
drivers: {
|
||||
@@ -31,35 +32,54 @@ export function DriverLeaderboardPreview({ drivers, onDriverClick, onNavigateToD
|
||||
const top10 = drivers; // Already sliced in builder
|
||||
|
||||
return (
|
||||
<Box rounded="xl" bg="bg-iron-gray/30" border borderColor="border-charcoal-outline" overflow="hidden">
|
||||
<Box display="flex" alignItems="center" justifyContent="between" px={5} py={4} borderBottom borderColor="border-charcoal-outline" bg="bg-iron-gray/20">
|
||||
<LeaderboardTableShell>
|
||||
<Box
|
||||
display="flex"
|
||||
alignItems="center"
|
||||
justifyContent="between"
|
||||
px={5}
|
||||
py={4}
|
||||
borderBottom
|
||||
borderColor="border-charcoal-outline/50"
|
||||
bg="bg-deep-charcoal/40"
|
||||
>
|
||||
<Box display="flex" alignItems="center" gap={3}>
|
||||
<Box display="flex" h="10" w="10" alignItems="center" justifyContent="center" rounded="xl" bg="bg-gradient-to-br from-primary-blue/20 to-primary-blue/5" border borderColor="border-primary-blue/20">
|
||||
<Box
|
||||
display="flex"
|
||||
h="10"
|
||||
w="10"
|
||||
alignItems="center"
|
||||
justifyContent="center"
|
||||
rounded="lg"
|
||||
bg="bg-gradient-to-br from-primary-blue/15 to-primary-blue/5"
|
||||
border
|
||||
borderColor="border-primary-blue/20"
|
||||
>
|
||||
<Icon icon={Trophy} size={5} color="text-primary-blue" />
|
||||
</Box>
|
||||
<Box>
|
||||
<Heading level={3} fontSize="lg" weight="semibold" color="text-white">Driver Rankings</Heading>
|
||||
<Text size="xs" color="text-gray-500" block>Top performers across all leagues</Text>
|
||||
<Heading level={3} fontSize="lg" weight="bold" color="text-white" letterSpacing="tight">Driver Rankings</Heading>
|
||||
<Text size="xs" color="text-gray-500" block uppercase letterSpacing="wider" weight="bold">Top Performers</Text>
|
||||
</Box>
|
||||
</Box>
|
||||
<Button
|
||||
variant="secondary"
|
||||
onClick={onNavigateToDrivers}
|
||||
size="sm"
|
||||
hoverBg="bg-primary-blue/10"
|
||||
transition
|
||||
>
|
||||
<Stack direction="row" align="center" gap={2}>
|
||||
<Text size="sm">View All</Text>
|
||||
<Text size="sm" weight="medium">View All</Text>
|
||||
<Icon icon={ChevronRight} size={4} />
|
||||
</Stack>
|
||||
</Button>
|
||||
</Box>
|
||||
|
||||
<Stack gap={0}
|
||||
// eslint-disable-next-line gridpilot-rules/component-classification
|
||||
className="divide-y divide-charcoal-outline/50"
|
||||
>
|
||||
<Stack gap={0}>
|
||||
{top10.map((driver, index) => {
|
||||
const position = index + 1;
|
||||
const isLast = index === top10.length - 1;
|
||||
|
||||
return (
|
||||
<Box
|
||||
@@ -75,71 +95,64 @@ export function DriverLeaderboardPreview({ drivers, onDriverClick, onNavigateToD
|
||||
w="full"
|
||||
textAlign="left"
|
||||
transition
|
||||
hoverBg="bg-iron-gray/30"
|
||||
hoverBg="bg-white/[0.02]"
|
||||
group
|
||||
borderBottom={!isLast}
|
||||
borderColor="border-charcoal-outline/30"
|
||||
>
|
||||
<Box
|
||||
display="flex"
|
||||
h="8"
|
||||
w="8"
|
||||
alignItems="center"
|
||||
justifyContent="center"
|
||||
rounded="full"
|
||||
border
|
||||
// eslint-disable-next-line gridpilot-rules/component-classification
|
||||
className={`text-xs font-bold ${MedalDisplay.getBg(position)} ${MedalDisplay.getColor(position)}`}
|
||||
>
|
||||
{position <= 3 ? <Icon icon={Crown} size={3.5} /> : <Text weight="bold">{position}</Text>}
|
||||
<Box w="8" display="flex" justifyContent="center">
|
||||
<RankMedal rank={position} size="sm" />
|
||||
</Box>
|
||||
|
||||
<Box position="relative" w="9" h="9" rounded="full" overflow="hidden" border borderWidth="2px" borderColor="border-charcoal-outline">
|
||||
<Box
|
||||
position="relative"
|
||||
w="9"
|
||||
h="9"
|
||||
rounded="full"
|
||||
overflow="hidden"
|
||||
border
|
||||
borderWidth="1px"
|
||||
borderColor="border-charcoal-outline"
|
||||
groupHoverBorderColor="primary-blue/50"
|
||||
transition
|
||||
>
|
||||
<Image src={driver.avatarUrl} alt={driver.name} fullWidth fullHeight objectFit="cover" />
|
||||
</Box>
|
||||
|
||||
<Box flexGrow={1} minWidth="0">
|
||||
<Text weight="medium" color="text-white" truncate groupHoverTextColor="text-primary-blue" transition block>
|
||||
<Text
|
||||
weight="semibold"
|
||||
color="text-white"
|
||||
truncate
|
||||
groupHoverTextColor="text-primary-blue"
|
||||
transition
|
||||
block
|
||||
>
|
||||
{driver.name}
|
||||
</Text>
|
||||
<Box display="flex" alignItems="center" gap={2}>
|
||||
<Icon icon={Flag} size={3} color="text-gray-500" />
|
||||
<Text size="xs" color="text-gray-500">{driver.nationality}</Text>
|
||||
<Box as="span"
|
||||
// eslint-disable-next-line gridpilot-rules/component-classification
|
||||
className={SkillLevelDisplay.getColor(driver.skillLevel)}
|
||||
>
|
||||
<Text size="xs">{SkillLevelDisplay.getLabel(driver.skillLevel)}</Text>
|
||||
<Box w="1" h="1" rounded="full" bg="bg-gray-700" />
|
||||
<Box as="span" color={SkillLevelDisplay.getColor(driver.skillLevel)}>
|
||||
<Text size="xs" weight="medium">{SkillLevelDisplay.getLabel(driver.skillLevel)}</Text>
|
||||
</Box>
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
<Box display="flex" alignItems="center" gap={4}>
|
||||
<Box textAlign="center">
|
||||
<Text color="text-primary-blue" font="mono" weight="semibold" block>{RatingDisplay.format(driver.rating)}</Text>
|
||||
<Text
|
||||
// eslint-disable-next-line gridpilot-rules/component-classification
|
||||
style={{ fontSize: '10px' }}
|
||||
color="text-gray-500"
|
||||
block
|
||||
>
|
||||
Rating
|
||||
</Text>
|
||||
<Box display="flex" alignItems="center" gap={6}>
|
||||
<Box textAlign="right">
|
||||
<Text color="text-primary-blue" font="mono" weight="bold" block size="sm">{RatingDisplay.format(driver.rating)}</Text>
|
||||
<Text fontSize="10px" color="text-gray-500" block uppercase letterSpacing="wider" weight="bold">Rating</Text>
|
||||
</Box>
|
||||
<Box textAlign="center">
|
||||
<Text color="text-performance-green" font="mono" weight="semibold" block>{driver.wins}</Text>
|
||||
<Text
|
||||
// eslint-disable-next-line gridpilot-rules/component-classification
|
||||
style={{ fontSize: '10px' }}
|
||||
color="text-gray-500"
|
||||
block
|
||||
>
|
||||
Wins
|
||||
</Text>
|
||||
<Box textAlign="right" minWidth="12">
|
||||
<Text color="text-performance-green" font="mono" weight="bold" block size="sm">{driver.wins}</Text>
|
||||
<Text fontSize="10px" color="text-gray-500" block uppercase letterSpacing="wider" weight="bold">Wins</Text>
|
||||
</Box>
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
})}
|
||||
</Stack>
|
||||
</Box>
|
||||
</LeaderboardTableShell>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,88 @@
|
||||
import React from 'react';
|
||||
import { Search, Filter } from 'lucide-react';
|
||||
import { Box } from '@/ui/Box';
|
||||
import { Stack } from '@/ui/Stack';
|
||||
import { Icon } from '@/ui/Icon';
|
||||
import { Text } from '@/ui/Text';
|
||||
|
||||
interface LeaderboardFiltersBarProps {
|
||||
searchQuery?: string;
|
||||
onSearchChange?: (query: string) => void;
|
||||
placeholder?: string;
|
||||
children?: React.ReactNode;
|
||||
}
|
||||
|
||||
export function LeaderboardFiltersBar({
|
||||
searchQuery,
|
||||
onSearchChange,
|
||||
placeholder = 'Search drivers...',
|
||||
children,
|
||||
}: LeaderboardFiltersBarProps) {
|
||||
return (
|
||||
<Box
|
||||
mb={6}
|
||||
p={3}
|
||||
bg="bg-deep-charcoal/40"
|
||||
border
|
||||
borderColor="border-charcoal-outline/50"
|
||||
rounded="lg"
|
||||
blur="sm"
|
||||
>
|
||||
<Stack direction="row" align="center" justify="between" gap={4}>
|
||||
<Box position="relative" flexGrow={1} maxWidth="md">
|
||||
<Box
|
||||
position="absolute"
|
||||
left="3"
|
||||
top="1/2"
|
||||
transform="translateY(-50%)"
|
||||
pointerEvents="none"
|
||||
zIndex={10}
|
||||
>
|
||||
<Icon icon={Search} size={4} color="text-gray-500" />
|
||||
</Box>
|
||||
<Box
|
||||
as="input"
|
||||
type="text"
|
||||
value={searchQuery}
|
||||
onChange={(e: React.ChangeEvent<HTMLInputElement>) => onSearchChange?.(e.target.value)}
|
||||
placeholder={placeholder}
|
||||
w="full"
|
||||
bg="bg-graphite-black/50"
|
||||
border
|
||||
borderColor="border-charcoal-outline"
|
||||
rounded="md"
|
||||
py={2}
|
||||
pl={10}
|
||||
pr={4}
|
||||
fontSize="0.875rem"
|
||||
color="text-white"
|
||||
transition
|
||||
hoverBorderColor="border-primary-blue/50"
|
||||
/>
|
||||
</Box>
|
||||
|
||||
<Stack direction="row" align="center" gap={4}>
|
||||
{children}
|
||||
<Box
|
||||
display="flex"
|
||||
alignItems="center"
|
||||
gap={2}
|
||||
px={3}
|
||||
py={2}
|
||||
bg="bg-graphite-black/30"
|
||||
border
|
||||
borderColor="border-charcoal-outline"
|
||||
rounded="md"
|
||||
cursor="pointer"
|
||||
transition
|
||||
hoverBg="bg-graphite-black/50"
|
||||
hoverBorderColor="border-gray-600"
|
||||
>
|
||||
<Icon icon={Filter} size={3.5} color="text-gray-400" />
|
||||
<Text size="xs" weight="bold" color="text-gray-400" uppercase letterSpacing="wider">Filters</Text>
|
||||
</Box>
|
||||
</Stack>
|
||||
</Stack>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
72
apps/website/components/leaderboards/LeaderboardHeader.tsx
Normal file
72
apps/website/components/leaderboards/LeaderboardHeader.tsx
Normal file
@@ -0,0 +1,72 @@
|
||||
import React from 'react';
|
||||
import { ArrowLeft, LucideIcon } from 'lucide-react';
|
||||
import { Box } from '@/ui/Box';
|
||||
import { Heading } from '@/ui/Heading';
|
||||
import { Text } from '@/ui/Text';
|
||||
import { Stack } from '@/ui/Stack';
|
||||
import { Button } from '@/ui/Button';
|
||||
import { Icon } from '@/ui/Icon';
|
||||
|
||||
interface LeaderboardHeaderProps {
|
||||
title: string;
|
||||
description?: string;
|
||||
icon?: LucideIcon;
|
||||
onBack?: () => void;
|
||||
backLabel?: string;
|
||||
children?: React.ReactNode;
|
||||
}
|
||||
|
||||
export function LeaderboardHeader({
|
||||
title,
|
||||
description,
|
||||
icon,
|
||||
onBack,
|
||||
backLabel = 'Back',
|
||||
children,
|
||||
}: LeaderboardHeaderProps) {
|
||||
return (
|
||||
<Box mb={8}>
|
||||
{onBack && (
|
||||
<Box mb={6}>
|
||||
<Button
|
||||
variant="secondary"
|
||||
onClick={onBack}
|
||||
icon={<Icon icon={ArrowLeft} size={4} />}
|
||||
>
|
||||
{backLabel}
|
||||
</Button>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
<Stack direction="row" align="center" justify="between" gap={4}>
|
||||
<Stack direction="row" align="center" gap={4}>
|
||||
{icon && (
|
||||
<Box
|
||||
p={3}
|
||||
bg="linear-gradient(to bottom right, rgba(25, 140, 255, 0.15), rgba(25, 140, 255, 0.05))"
|
||||
border
|
||||
borderColor="border-primary-blue/20"
|
||||
rounded="xl"
|
||||
display="flex"
|
||||
alignItems="center"
|
||||
justifyContent="center"
|
||||
>
|
||||
<Icon icon={icon} size={6} color="text-primary-blue" />
|
||||
</Box>
|
||||
)}
|
||||
<Box>
|
||||
<Heading level={1} weight="bold" letterSpacing="tight">{title}</Heading>
|
||||
{description && (
|
||||
<Text color="text-gray-400" block mt={1} size="sm">
|
||||
{description}
|
||||
</Text>
|
||||
)}
|
||||
</Box>
|
||||
</Stack>
|
||||
<Box>
|
||||
{children}
|
||||
</Box>
|
||||
</Stack>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
import React from 'react';
|
||||
import { ArrowLeft, LucideIcon } from 'lucide-react';
|
||||
import { Box } from '@/ui/Box';
|
||||
import { Heading } from '@/ui/Heading';
|
||||
import { Text } from '@/ui/Text';
|
||||
import { Stack } from '@/ui/Stack';
|
||||
import { Button } from '@/ui/Button';
|
||||
import { Icon } from '@/ui/Icon';
|
||||
import { Surface } from '@/ui/Surface';
|
||||
|
||||
interface LeaderboardHeaderPanelProps {
|
||||
title: string;
|
||||
description?: string;
|
||||
icon?: LucideIcon;
|
||||
onBack?: () => void;
|
||||
backLabel?: string;
|
||||
children?: React.ReactNode;
|
||||
}
|
||||
|
||||
export function LeaderboardHeaderPanel({
|
||||
title,
|
||||
description,
|
||||
icon,
|
||||
onBack,
|
||||
backLabel = 'Back',
|
||||
children,
|
||||
}: LeaderboardHeaderPanelProps) {
|
||||
return (
|
||||
<Box mb={8}>
|
||||
{onBack && (
|
||||
<Box mb={6}>
|
||||
<Button
|
||||
variant="secondary"
|
||||
onClick={onBack}
|
||||
icon={<Icon icon={ArrowLeft} size={4} />}
|
||||
>
|
||||
{backLabel}
|
||||
</Button>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
<Stack direction="row" align="center" justify="between" gap={4}>
|
||||
<Stack direction="row" align="center" gap={4}>
|
||||
{icon && (
|
||||
<Surface
|
||||
variant="muted"
|
||||
rounded="xl"
|
||||
padding={3}
|
||||
bg="linear-gradient(to bottom right, rgba(25, 140, 255, 0.2), rgba(25, 140, 255, 0.05))"
|
||||
border
|
||||
borderColor="border-primary-blue/20"
|
||||
>
|
||||
<Icon icon={icon} size={7} color="text-primary-blue" />
|
||||
</Surface>
|
||||
)}
|
||||
<Box>
|
||||
<Heading level={1}>{title}</Heading>
|
||||
{description && (
|
||||
<Text color="text-gray-400" block mt={1}>
|
||||
{description}
|
||||
</Text>
|
||||
)}
|
||||
</Box>
|
||||
</Stack>
|
||||
{children}
|
||||
</Stack>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
@@ -1,116 +0,0 @@
|
||||
import React from 'react';
|
||||
import { Crown, Flag } from 'lucide-react';
|
||||
import { Box } from '@/ui/Box';
|
||||
import { Text } from '@/ui/Text';
|
||||
import { Stack } from '@/ui/Stack';
|
||||
import { Image } from '@/ui/Image';
|
||||
import { mediaConfig } from '@/lib/config/mediaConfig';
|
||||
|
||||
interface LeaderboardItemProps {
|
||||
position: number;
|
||||
name: string;
|
||||
avatarUrl?: string;
|
||||
nationality: string;
|
||||
rating: number;
|
||||
wins: number;
|
||||
skillLevelLabel?: string;
|
||||
skillLevelColor?: string;
|
||||
categoryLabel?: string;
|
||||
categoryColor?: string;
|
||||
onClick: () => void;
|
||||
}
|
||||
|
||||
export function LeaderboardItem({
|
||||
position,
|
||||
name,
|
||||
avatarUrl,
|
||||
nationality,
|
||||
rating,
|
||||
wins,
|
||||
skillLevelLabel,
|
||||
skillLevelColor,
|
||||
categoryLabel,
|
||||
categoryColor,
|
||||
onClick,
|
||||
}: LeaderboardItemProps) {
|
||||
const getMedalColor = (pos: number) => {
|
||||
switch (pos) {
|
||||
case 1: return 'text-yellow-400';
|
||||
case 2: return 'text-gray-300';
|
||||
case 3: return 'text-amber-600';
|
||||
default: return 'text-gray-500';
|
||||
}
|
||||
};
|
||||
|
||||
const getMedalBg = (pos: number) => {
|
||||
switch (pos) {
|
||||
case 1: return 'bg-yellow-400/10 border-yellow-400/30';
|
||||
case 2: return 'bg-gray-300/10 border-gray-300/30';
|
||||
case 3: return 'bg-amber-600/10 border-amber-600/30';
|
||||
default: return 'bg-iron-gray/50 border-charcoal-outline';
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Box
|
||||
as="button"
|
||||
type="button"
|
||||
onClick={onClick}
|
||||
display="flex"
|
||||
alignItems="center"
|
||||
gap={4}
|
||||
px={4}
|
||||
py={3}
|
||||
fullWidth
|
||||
textAlign="left"
|
||||
className="hover:bg-iron-gray/30 transition-colors group"
|
||||
>
|
||||
{/* Position */}
|
||||
<Box
|
||||
width="8"
|
||||
height="8"
|
||||
display="flex"
|
||||
center
|
||||
rounded="full"
|
||||
border
|
||||
className={`${getMedalBg(position)} ${getMedalColor(position)} text-xs font-bold`}
|
||||
>
|
||||
{position <= 3 ? <Crown className="w-3.5 h-3.5" /> : position}
|
||||
</Box>
|
||||
|
||||
{/* Avatar */}
|
||||
<Box position="relative" width="9" height="9" rounded="full" overflow="hidden" border={true} borderColor="border-charcoal-outline">
|
||||
<Image src={avatarUrl || mediaConfig.avatars.defaultFallback} alt={name} fill objectFit="cover" />
|
||||
</Box>
|
||||
|
||||
{/* Info */}
|
||||
<Box flexGrow={1} minWidth="0">
|
||||
<Text weight="medium" color="text-white" truncate block className="group-hover:text-primary-blue transition-colors">
|
||||
{name}
|
||||
</Text>
|
||||
<Stack direction="row" align="center" gap={2}>
|
||||
<Flag className="w-3 h-3 text-gray-500" />
|
||||
<Text size="xs" color="text-gray-500">{nationality}</Text>
|
||||
{categoryLabel && (
|
||||
<Text size="xs" className={categoryColor}>{categoryLabel}</Text>
|
||||
)}
|
||||
{skillLevelLabel && (
|
||||
<Text size="xs" className={skillLevelColor}>{skillLevelLabel}</Text>
|
||||
)}
|
||||
</Stack>
|
||||
</Box>
|
||||
|
||||
{/* Stats */}
|
||||
<Stack direction="row" align="center" gap={4}>
|
||||
<Box textAlign="center">
|
||||
<Text color="text-primary-blue" weight="semibold" font="mono" block>{rating.toLocaleString()}</Text>
|
||||
<Text size="xs" color="text-gray-500" block style={{ fontSize: '10px' }}>Rating</Text>
|
||||
</Box>
|
||||
<Box textAlign="center">
|
||||
<Text color="text-performance-green" weight="semibold" font="mono" block>{wins}</Text>
|
||||
<Text size="xs" color="text-gray-500" block style={{ fontSize: '10px' }}>Wins</Text>
|
||||
</Box>
|
||||
</Stack>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
173
apps/website/components/leaderboards/LeaderboardPodium.tsx
Normal file
173
apps/website/components/leaderboards/LeaderboardPodium.tsx
Normal file
@@ -0,0 +1,173 @@
|
||||
import React from 'react';
|
||||
import { Box } from '@/ui/Box';
|
||||
import { Image } from '@/ui/Image';
|
||||
import { Stack } from '@/ui/Stack';
|
||||
import { Text } from '@/ui/Text';
|
||||
import { RatingDisplay } from '@/lib/display-objects/RatingDisplay';
|
||||
import { MedalDisplay } from '@/lib/display-objects/MedalDisplay';
|
||||
|
||||
interface PodiumDriver {
|
||||
id: string;
|
||||
name: string;
|
||||
avatarUrl: string;
|
||||
rating: number;
|
||||
wins: number;
|
||||
podiums: number;
|
||||
}
|
||||
|
||||
interface LeaderboardPodiumProps {
|
||||
podium: PodiumDriver[];
|
||||
onDriverClick?: (id: string) => void;
|
||||
}
|
||||
|
||||
export function LeaderboardPodium({ podium, onDriverClick }: LeaderboardPodiumProps) {
|
||||
// Order: 2nd, 1st, 3rd
|
||||
const displayOrder = [1, 0, 2];
|
||||
|
||||
return (
|
||||
<Box mb={12}>
|
||||
<Box display="flex" alignItems="end" justifyContent="center" gap={4} maxWidth="4xl" mx="auto">
|
||||
{displayOrder.map((index) => {
|
||||
const driver = podium[index];
|
||||
if (!driver) return <Box key={index} flexGrow={1} />;
|
||||
|
||||
const position = index + 1;
|
||||
const isFirst = position === 1;
|
||||
|
||||
const config = {
|
||||
1: { height: '48', scale: '1.1', zIndex: 10, shadow: 'shadow-warning-amber/20' },
|
||||
2: { height: '36', scale: '1', zIndex: 0, shadow: 'shadow-white/5' },
|
||||
3: { height: '28', scale: '0.9', zIndex: 0, shadow: 'shadow-white/5' },
|
||||
}[position as 1 | 2 | 3];
|
||||
|
||||
return (
|
||||
<Box
|
||||
key={driver.id}
|
||||
as="button"
|
||||
type="button"
|
||||
onClick={() => onDriverClick?.(driver.id)}
|
||||
display="flex"
|
||||
flexDirection="col"
|
||||
alignItems="center"
|
||||
flexGrow={1}
|
||||
transition
|
||||
hoverScale
|
||||
group
|
||||
shadow={config.shadow}
|
||||
zIndex={config.zIndex}
|
||||
>
|
||||
<Box position="relative" mb={4} transform={`scale(${config.scale})`}>
|
||||
<Box
|
||||
position="relative"
|
||||
w={isFirst ? '32' : '24'}
|
||||
h={isFirst ? '32' : '24'}
|
||||
rounded="full"
|
||||
overflow="hidden"
|
||||
border
|
||||
borderColor={isFirst ? 'border-warning-amber' : 'border-charcoal-outline'}
|
||||
borderWidth="3px"
|
||||
transition
|
||||
groupHoverBorderColor="primary-blue"
|
||||
shadow="xl"
|
||||
>
|
||||
<Image
|
||||
src={driver.avatarUrl}
|
||||
alt={driver.name}
|
||||
width={128}
|
||||
height={128}
|
||||
fullWidth
|
||||
fullHeight
|
||||
objectFit="cover"
|
||||
/>
|
||||
</Box>
|
||||
<Box
|
||||
position="absolute"
|
||||
bottom="-2"
|
||||
left="50%"
|
||||
w="10"
|
||||
h="10"
|
||||
rounded="full"
|
||||
display="flex"
|
||||
alignItems="center"
|
||||
justifyContent="center"
|
||||
border
|
||||
transform="translateX(-50%)"
|
||||
borderWidth="2px"
|
||||
bg={MedalDisplay.getBg(position)}
|
||||
color={MedalDisplay.getColor(position)}
|
||||
shadow="lg"
|
||||
>
|
||||
<Text size="sm" weight="bold">{position}</Text>
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
<Text
|
||||
weight="bold"
|
||||
color="text-white"
|
||||
size={isFirst ? 'lg' : 'base'}
|
||||
mb={1}
|
||||
block
|
||||
truncate
|
||||
align="center"
|
||||
px={2}
|
||||
maxWidth="full"
|
||||
groupHoverTextColor="text-primary-blue"
|
||||
transition
|
||||
>
|
||||
{driver.name}
|
||||
</Text>
|
||||
|
||||
<Text
|
||||
font="mono"
|
||||
weight="bold"
|
||||
size={isFirst ? 'xl' : 'lg'}
|
||||
block
|
||||
color={isFirst ? 'text-warning-amber' : 'text-primary-blue'}
|
||||
>
|
||||
{RatingDisplay.format(driver.rating)}
|
||||
</Text>
|
||||
|
||||
<Stack direction="row" align="center" gap={3} mt={1}>
|
||||
<Stack direction="row" align="center" gap={1}>
|
||||
<Text size="xs" color="text-gray-500" weight="bold" uppercase letterSpacing="wider">Wins</Text>
|
||||
<Text size="xs" weight="bold" color="text-performance-green">{driver.wins}</Text>
|
||||
</Stack>
|
||||
<Box w="1" h="1" rounded="full" bg="bg-gray-700" />
|
||||
<Stack direction="row" align="center" gap={1}>
|
||||
<Text size="xs" color="text-gray-500" weight="bold" uppercase letterSpacing="wider">Podiums</Text>
|
||||
<Text size="xs" weight="bold" color="text-white">{driver.podiums}</Text>
|
||||
</Stack>
|
||||
</Stack>
|
||||
|
||||
<Box
|
||||
mt={6}
|
||||
w="full"
|
||||
h={config.height}
|
||||
rounded="lg"
|
||||
border
|
||||
borderColor="border-charcoal-outline/50"
|
||||
bg="bg-deep-charcoal/40"
|
||||
display="flex"
|
||||
alignItems="center"
|
||||
justifyContent="center"
|
||||
blur="sm"
|
||||
groupHoverBorderColor="primary-blue/30"
|
||||
transition
|
||||
>
|
||||
<Text
|
||||
weight="bold"
|
||||
size="4xl"
|
||||
color={MedalDisplay.getColor(position)}
|
||||
opacity={0.1}
|
||||
fontSize={isFirst ? '5rem' : '3.5rem'}
|
||||
>
|
||||
{position}
|
||||
</Text>
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
})}
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
@@ -1,105 +0,0 @@
|
||||
|
||||
|
||||
import { routes } from '@/lib/routing/RouteConfig';
|
||||
import { Box } from '@/ui/Box';
|
||||
import { Button } from '@/ui/Button';
|
||||
import { Heading } from '@/ui/Heading';
|
||||
import { Icon } from '@/ui/Icon';
|
||||
import { LeaderboardItem } from '@/components/leaderboards/LeaderboardItem';
|
||||
import { LeaderboardList } from '@/ui/LeaderboardList';
|
||||
import { Stack } from '@/ui/Stack';
|
||||
import { Text } from '@/ui/Text';
|
||||
import { Award, ChevronRight } from 'lucide-react';
|
||||
|
||||
const SKILL_LEVELS = [
|
||||
{ id: 'pro', label: 'Pro', color: 'text-yellow-400' },
|
||||
{ id: 'advanced', label: 'Advanced', color: 'text-purple-400' },
|
||||
{ id: 'intermediate', label: 'Intermediate', color: 'text-primary-blue' },
|
||||
{ id: 'beginner', label: 'Beginner', color: 'text-green-400' },
|
||||
];
|
||||
|
||||
const CATEGORIES = [
|
||||
{ id: 'beginner', label: 'Beginner', color: 'text-green-400' },
|
||||
{ id: 'intermediate', label: 'Intermediate', color: 'text-primary-blue' },
|
||||
{ id: 'advanced', label: 'Advanced', color: 'text-purple-400' },
|
||||
{ id: 'pro', label: 'Pro', color: 'text-yellow-400' },
|
||||
{ id: 'endurance', label: 'Endurance', color: 'text-orange-400' },
|
||||
{ id: 'sprint', label: 'Sprint', color: 'text-red-400' },
|
||||
];
|
||||
|
||||
interface LeaderboardPreviewProps {
|
||||
drivers: {
|
||||
id: string;
|
||||
name: string;
|
||||
avatarUrl?: string;
|
||||
nationality: string;
|
||||
rating: number;
|
||||
wins: number;
|
||||
skillLevel?: string;
|
||||
category?: string;
|
||||
}[];
|
||||
onDriverClick: (id: string) => void;
|
||||
onNavigate: (href: string) => void;
|
||||
}
|
||||
|
||||
export function LeaderboardPreview({ drivers, onDriverClick, onNavigate }: LeaderboardPreviewProps) {
|
||||
const top5 = drivers.slice(0, 5);
|
||||
|
||||
return (
|
||||
<Stack gap={4} mb={10}>
|
||||
<Stack direction="row" align="center" justify="between">
|
||||
<Stack direction="row" align="center" gap={3}>
|
||||
<Box
|
||||
display="flex"
|
||||
center
|
||||
w="10"
|
||||
h="10"
|
||||
rounded="xl"
|
||||
bg="bg-gradient-to-br from-yellow-400/20 to-amber-600/10"
|
||||
border
|
||||
borderColor="border-yellow-400/30"
|
||||
>
|
||||
<Icon icon={Award} size={5} color="rgb(250, 204, 21)" />
|
||||
</Box>
|
||||
<Box>
|
||||
<Heading level={2}>Top Drivers</Heading>
|
||||
<Text size="xs" color="text-gray-500">Highest rated competitors</Text>
|
||||
</Box>
|
||||
</Stack>
|
||||
|
||||
<Button
|
||||
variant="secondary"
|
||||
onClick={() => onNavigate(routes.leaderboards.drivers)}
|
||||
icon={<Icon icon={ChevronRight} size={4} />}
|
||||
>
|
||||
Full Rankings
|
||||
</Button>
|
||||
</Stack>
|
||||
|
||||
<LeaderboardList>
|
||||
{top5.map((driver, index) => {
|
||||
const levelConfig = SKILL_LEVELS.find((l) => l.id === driver.skillLevel);
|
||||
const categoryConfig = CATEGORIES.find((c) => c.id === driver.category);
|
||||
const position = index + 1;
|
||||
|
||||
return (
|
||||
<LeaderboardItem
|
||||
key={driver.id}
|
||||
position={position}
|
||||
name={driver.name}
|
||||
avatarUrl={driver.avatarUrl}
|
||||
nationality={driver.nationality}
|
||||
rating={driver.rating}
|
||||
wins={driver.wins}
|
||||
skillLevelLabel={levelConfig?.label}
|
||||
skillLevelColor={levelConfig?.color}
|
||||
categoryLabel={categoryConfig?.label}
|
||||
categoryColor={categoryConfig?.color}
|
||||
onClick={() => onDriverClick(driver.id)}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</LeaderboardList>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
49
apps/website/components/leaderboards/LeaderboardTable.tsx
Normal file
49
apps/website/components/leaderboards/LeaderboardTable.tsx
Normal file
@@ -0,0 +1,49 @@
|
||||
import React from 'react';
|
||||
import { Table, TableBody, TableHead, TableHeader, TableRow } from '@/ui/Table';
|
||||
import { RankingRow } from './RankingRow';
|
||||
import { LeaderboardTableShell } from './LeaderboardTableShell';
|
||||
|
||||
interface LeaderboardDriver {
|
||||
id: string;
|
||||
name: string;
|
||||
avatarUrl: string;
|
||||
rank: number;
|
||||
rankDelta?: number;
|
||||
nationality: string;
|
||||
skillLevel: string;
|
||||
racesCompleted: number;
|
||||
rating: number;
|
||||
wins: number;
|
||||
}
|
||||
|
||||
interface LeaderboardTableProps {
|
||||
drivers: LeaderboardDriver[];
|
||||
onDriverClick?: (id: string) => void;
|
||||
}
|
||||
|
||||
export function LeaderboardTable({ drivers, onDriverClick }: LeaderboardTableProps) {
|
||||
return (
|
||||
<LeaderboardTableShell isEmpty={drivers.length === 0} emptyMessage="No drivers found">
|
||||
<Table>
|
||||
<TableHead>
|
||||
<TableRow>
|
||||
<TableHeader w="32">Rank</TableHeader>
|
||||
<TableHeader>Driver</TableHeader>
|
||||
<TableHeader textAlign="center">Races</TableHeader>
|
||||
<TableHeader textAlign="center">Rating</TableHeader>
|
||||
<TableHeader textAlign="center">Wins</TableHeader>
|
||||
</TableRow>
|
||||
</TableHead>
|
||||
<TableBody>
|
||||
{drivers.map((driver) => (
|
||||
<RankingRow
|
||||
key={driver.id}
|
||||
{...driver}
|
||||
onClick={() => onDriverClick?.(driver.id)}
|
||||
/>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</LeaderboardTableShell>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
import React from 'react';
|
||||
import { Box } from '@/ui/Box';
|
||||
import { Text } from '@/ui/Text';
|
||||
|
||||
interface LeaderboardTableShellProps {
|
||||
children: React.ReactNode;
|
||||
isEmpty?: boolean;
|
||||
emptyMessage?: string;
|
||||
emptyDescription?: string;
|
||||
}
|
||||
|
||||
export function LeaderboardTableShell({
|
||||
children,
|
||||
isEmpty,
|
||||
emptyMessage = 'No data found',
|
||||
emptyDescription = 'Try adjusting your filters or search query',
|
||||
}: LeaderboardTableShellProps) {
|
||||
if (isEmpty) {
|
||||
return (
|
||||
<Box
|
||||
py={16}
|
||||
textAlign="center"
|
||||
bg="bg-iron-gray/20"
|
||||
border
|
||||
borderColor="border-charcoal-outline"
|
||||
rounded="xl"
|
||||
>
|
||||
<Text size="4xl" block mb={4}>🔍</Text>
|
||||
<Text color="text-gray-400" block mb={2} weight="semibold">{emptyMessage}</Text>
|
||||
<Text size="sm" color="text-gray-500">{emptyDescription}</Text>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Box
|
||||
rounded="xl"
|
||||
bg="bg-iron-gray/20"
|
||||
border
|
||||
borderColor="border-charcoal-outline"
|
||||
overflow="hidden"
|
||||
>
|
||||
{children}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
@@ -25,27 +25,29 @@ export function LeaderboardsHero({ onNavigateToDrivers, onNavigateToTeams }: Lea
|
||||
padding={8}
|
||||
position="relative"
|
||||
overflow="hidden"
|
||||
bg="bg-gradient-to-br from-yellow-600/20 via-iron-gray to-deep-graphite"
|
||||
borderColor="border-yellow-500/20"
|
||||
bg="bg-gradient-to-br from-primary-blue/10 via-deep-charcoal to-graphite-black"
|
||||
borderColor="border-primary-blue/20"
|
||||
>
|
||||
<DecorativeBlur color="yellow" size="lg" position="top-right" opacity={10} />
|
||||
<DecorativeBlur color="blue" size="md" position="bottom-left" opacity={5} />
|
||||
<DecorativeBlur color="blue" size="lg" position="top-right" opacity={10} />
|
||||
<DecorativeBlur color="purple" size="md" position="bottom-left" opacity={5} />
|
||||
|
||||
<Box position="relative" zIndex={10}>
|
||||
<Stack direction="row" align="center" gap={4} mb={4}>
|
||||
<Surface
|
||||
variant="muted"
|
||||
rounded="xl"
|
||||
padding={3}
|
||||
bg="bg-gradient-to-br from-yellow-400/20 to-yellow-600/10"
|
||||
<Box
|
||||
p={3}
|
||||
bg="linear-gradient(to bottom right, rgba(25, 140, 255, 0.2), rgba(25, 140, 255, 0.05))"
|
||||
border
|
||||
borderColor="border-yellow-400/30"
|
||||
borderColor="border-primary-blue/30"
|
||||
rounded="xl"
|
||||
display="flex"
|
||||
alignItems="center"
|
||||
justifyContent="center"
|
||||
>
|
||||
<Icon icon={Award} size={7} color="#facc15" />
|
||||
</Surface>
|
||||
<Icon icon={Award} size={7} color="text-primary-blue" />
|
||||
</Box>
|
||||
<Box>
|
||||
<Heading level={1}>Leaderboards</Heading>
|
||||
<Text color="text-gray-400" block mt={1}>Where champions rise and legends are made</Text>
|
||||
<Heading level={1} weight="bold" letterSpacing="tight">Leaderboards</Heading>
|
||||
<Text color="text-gray-400" block mt={1} size="sm" uppercase letterSpacing="widest" weight="bold">Precision Performance Tracking</Text>
|
||||
</Box>
|
||||
</Stack>
|
||||
|
||||
@@ -53,25 +55,27 @@ export function LeaderboardsHero({ onNavigateToDrivers, onNavigateToTeams }: Lea
|
||||
size="lg"
|
||||
color="text-gray-400"
|
||||
block
|
||||
mb={6}
|
||||
mb={8}
|
||||
leading="relaxed"
|
||||
maxWidth="42rem"
|
||||
>
|
||||
Track the best drivers and teams across all competitions. Every race counts. Every position matters. Who will claim the throne?
|
||||
Track the best drivers and teams across all competitions. Every race counts. Every position matters. Analyze telemetry-grade rankings and performance metrics.
|
||||
</Text>
|
||||
|
||||
<Stack direction="row" gap={3} wrap>
|
||||
<Stack direction="row" gap={4} wrap>
|
||||
<Button
|
||||
variant="secondary"
|
||||
variant="primary"
|
||||
onClick={onNavigateToDrivers}
|
||||
icon={<Icon icon={Trophy} size={4} color="#3b82f6" />}
|
||||
icon={<Icon icon={Trophy} size={4} />}
|
||||
shadow="shadow-lg shadow-primary-blue/20"
|
||||
>
|
||||
Driver Rankings
|
||||
</Button>
|
||||
<Button
|
||||
variant="secondary"
|
||||
onClick={onNavigateToTeams}
|
||||
icon={<Icon icon={Users} size={4} color="#a855f7" />}
|
||||
icon={<Icon icon={Users} size={4} />}
|
||||
hoverBg="bg-white/5"
|
||||
>
|
||||
Team Rankings
|
||||
</Button>
|
||||
|
||||
54
apps/website/components/leaderboards/RankMedal.tsx
Normal file
54
apps/website/components/leaderboards/RankMedal.tsx
Normal file
@@ -0,0 +1,54 @@
|
||||
import React from 'react';
|
||||
import { Crown, Medal } from 'lucide-react';
|
||||
import { Box } from '@/ui/Box';
|
||||
import { Text } from '@/ui/Text';
|
||||
import { Icon } from '@/ui/Icon';
|
||||
import { MedalDisplay } from '@/lib/display-objects/MedalDisplay';
|
||||
|
||||
interface RankMedalProps {
|
||||
rank: number;
|
||||
size?: 'sm' | 'md' | 'lg';
|
||||
showIcon?: boolean;
|
||||
}
|
||||
|
||||
export function RankMedal({ rank, size = 'md', showIcon = true }: RankMedalProps) {
|
||||
const isTop3 = rank <= 3;
|
||||
|
||||
const sizeMap = {
|
||||
sm: '7',
|
||||
md: '8',
|
||||
lg: '10',
|
||||
};
|
||||
|
||||
const textSizeMap = {
|
||||
sm: 'xs',
|
||||
md: 'xs',
|
||||
lg: 'sm',
|
||||
} as const;
|
||||
|
||||
const iconSize = {
|
||||
sm: 3,
|
||||
md: 3.5,
|
||||
lg: 4.5,
|
||||
};
|
||||
|
||||
return (
|
||||
<Box
|
||||
display="flex"
|
||||
alignItems="center"
|
||||
justifyContent="center"
|
||||
rounded="full"
|
||||
border
|
||||
h={sizeMap[size]}
|
||||
w={sizeMap[size]}
|
||||
bg={MedalDisplay.getBg(rank)}
|
||||
color={MedalDisplay.getColor(rank)}
|
||||
>
|
||||
{isTop3 && showIcon ? (
|
||||
<Icon icon={rank === 1 ? Crown : Medal} size={iconSize[size]} />
|
||||
) : (
|
||||
<Text weight="bold" size={textSizeMap[size]}>{rank}</Text>
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
116
apps/website/components/leaderboards/RankingRow.tsx
Normal file
116
apps/website/components/leaderboards/RankingRow.tsx
Normal file
@@ -0,0 +1,116 @@
|
||||
import React from 'react';
|
||||
import { Box } from '@/ui/Box';
|
||||
import { Image } from '@/ui/Image';
|
||||
import { Stack } from '@/ui/Stack';
|
||||
import { TableCell, TableRow } from '@/ui/Table';
|
||||
import { Text } from '@/ui/Text';
|
||||
import { RankMedal } from './RankMedal';
|
||||
import { DeltaChip } from './DeltaChip';
|
||||
import { RatingDisplay } from '@/lib/display-objects/RatingDisplay';
|
||||
|
||||
interface RankingRowProps {
|
||||
id: string;
|
||||
rank: number;
|
||||
rankDelta?: number;
|
||||
name: string;
|
||||
avatarUrl: string;
|
||||
nationality: string;
|
||||
skillLevel: string;
|
||||
racesCompleted: number;
|
||||
rating: number;
|
||||
wins: number;
|
||||
onClick?: () => void;
|
||||
}
|
||||
|
||||
export function RankingRow({
|
||||
rank,
|
||||
rankDelta,
|
||||
name,
|
||||
avatarUrl,
|
||||
nationality,
|
||||
skillLevel,
|
||||
racesCompleted,
|
||||
rating,
|
||||
wins,
|
||||
onClick,
|
||||
}: RankingRowProps) {
|
||||
return (
|
||||
<TableRow
|
||||
clickable={!!onClick}
|
||||
onClick={onClick}
|
||||
group
|
||||
>
|
||||
<TableCell>
|
||||
<Stack direction="row" align="center" gap={4}>
|
||||
<Box w="8" display="flex" justifyContent="center">
|
||||
<RankMedal rank={rank} size="md" />
|
||||
</Box>
|
||||
{rankDelta !== undefined && (
|
||||
<Box w="10">
|
||||
<DeltaChip value={rankDelta} type="rank" />
|
||||
</Box>
|
||||
)}
|
||||
</Stack>
|
||||
</TableCell>
|
||||
|
||||
<TableCell>
|
||||
<Box display="flex" alignItems="center" gap={3}>
|
||||
<Box
|
||||
position="relative"
|
||||
w="10"
|
||||
h="10"
|
||||
rounded="full"
|
||||
overflow="hidden"
|
||||
border
|
||||
borderColor="border-charcoal-outline"
|
||||
groupHoverBorderColor="primary-blue/50"
|
||||
transition
|
||||
>
|
||||
<Image
|
||||
src={avatarUrl}
|
||||
alt={name}
|
||||
width={40}
|
||||
height={40}
|
||||
fullWidth
|
||||
fullHeight
|
||||
objectFit="cover"
|
||||
/>
|
||||
</Box>
|
||||
<Box minWidth="0">
|
||||
<Text
|
||||
weight="semibold"
|
||||
color="text-white"
|
||||
block
|
||||
truncate
|
||||
groupHoverTextColor="text-primary-blue"
|
||||
transition
|
||||
>
|
||||
{name}
|
||||
</Text>
|
||||
<Stack direction="row" align="center" gap={2} mt={0.5}>
|
||||
<Text size="xs" color="text-gray-500">{nationality}</Text>
|
||||
<Box w="1" h="1" rounded="full" bg="bg-gray-700" />
|
||||
<Text size="xs" color="text-gray-500">{skillLevel}</Text>
|
||||
</Stack>
|
||||
</Box>
|
||||
</Box>
|
||||
</TableCell>
|
||||
|
||||
<TableCell textAlign="center">
|
||||
<Text color="text-gray-400" font="mono">{racesCompleted}</Text>
|
||||
</TableCell>
|
||||
|
||||
<TableCell textAlign="center">
|
||||
<Text font="mono" weight="bold" color="text-primary-blue">
|
||||
{RatingDisplay.format(rating)}
|
||||
</Text>
|
||||
</TableCell>
|
||||
|
||||
<TableCell textAlign="center">
|
||||
<Text font="mono" weight="bold" color="text-performance-green">
|
||||
{wins}
|
||||
</Text>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
);
|
||||
}
|
||||
42
apps/website/components/leaderboards/SeasonSelector.tsx
Normal file
42
apps/website/components/leaderboards/SeasonSelector.tsx
Normal file
@@ -0,0 +1,42 @@
|
||||
import React from 'react';
|
||||
import { Calendar } from 'lucide-react';
|
||||
import { Box } from '@/ui/Box';
|
||||
import { Text } from '@/ui/Text';
|
||||
import { Icon } from '@/ui/Icon';
|
||||
import { Select } from '@/ui/Select';
|
||||
|
||||
interface Season {
|
||||
id: string;
|
||||
name: string;
|
||||
isActive?: boolean;
|
||||
}
|
||||
|
||||
interface SeasonSelectorProps {
|
||||
seasons: Season[];
|
||||
selectedSeasonId: string;
|
||||
onSeasonChange: (id: string) => void;
|
||||
}
|
||||
|
||||
export function SeasonSelector({ seasons, selectedSeasonId, onSeasonChange }: SeasonSelectorProps) {
|
||||
const options = seasons.map(season => ({
|
||||
value: season.id,
|
||||
label: `${season.name}${season.isActive ? ' (Active)' : ''}`
|
||||
}));
|
||||
|
||||
return (
|
||||
<Box display="flex" alignItems="center" gap={3}>
|
||||
<Box display="flex" alignItems="center" gap={2} color="text-gray-500">
|
||||
<Icon icon={Calendar} size={4} />
|
||||
<Text size="xs" weight="bold" uppercase letterSpacing="wider">Season</Text>
|
||||
</Box>
|
||||
<Box width="48">
|
||||
<Select
|
||||
options={options}
|
||||
value={selectedSeasonId}
|
||||
onChange={(e) => onSeasonChange(e.target.value)}
|
||||
fullWidth={true}
|
||||
/>
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
import React from 'react';
|
||||
import { Users, Crown, ChevronRight } from 'lucide-react';
|
||||
import { Users, ChevronRight } from 'lucide-react';
|
||||
import { Button } from '@/ui/Button';
|
||||
import { Box } from '@/ui/Box';
|
||||
import { Text } from '@/ui/Text';
|
||||
@@ -8,8 +8,8 @@ import { Icon } from '@/ui/Icon';
|
||||
import { Stack } from '@/ui/Stack';
|
||||
import { Image } from '@/ui/Image';
|
||||
import { getMediaUrl } from '@/lib/utilities/media';
|
||||
import { SkillLevelDisplay } from '@/lib/display-objects/SkillLevelDisplay';
|
||||
import { MedalDisplay } from '@/lib/display-objects/MedalDisplay';
|
||||
import { RankMedal } from './RankMedal';
|
||||
import { LeaderboardTableShell } from './LeaderboardTableShell';
|
||||
|
||||
interface TeamLeaderboardPreviewProps {
|
||||
teams: {
|
||||
@@ -27,38 +27,57 @@ interface TeamLeaderboardPreviewProps {
|
||||
}
|
||||
|
||||
export function TeamLeaderboardPreview({ teams, onTeamClick, onNavigateToTeams }: TeamLeaderboardPreviewProps) {
|
||||
const top5 = teams; // Already sliced in builder when implemented
|
||||
const top5 = teams;
|
||||
|
||||
return (
|
||||
<Box rounded="xl" bg="bg-iron-gray/30" border borderColor="border-charcoal-outline" overflow="hidden">
|
||||
<Box display="flex" alignItems="center" justifyContent="between" px={5} py={4} borderBottom borderColor="border-charcoal-outline" bg="bg-iron-gray/20">
|
||||
<LeaderboardTableShell>
|
||||
<Box
|
||||
display="flex"
|
||||
alignItems="center"
|
||||
justifyContent="between"
|
||||
px={5}
|
||||
py={4}
|
||||
borderBottom
|
||||
borderColor="border-charcoal-outline/50"
|
||||
bg="bg-deep-charcoal/40"
|
||||
>
|
||||
<Box display="flex" alignItems="center" gap={3}>
|
||||
<Box display="flex" h="10" w="10" alignItems="center" justifyContent="center" rounded="xl" bg="bg-gradient-to-br from-purple-500/20 to-purple-500/5" border borderColor="border-purple-500/20">
|
||||
<Box
|
||||
display="flex"
|
||||
h="10"
|
||||
w="10"
|
||||
alignItems="center"
|
||||
justifyContent="center"
|
||||
rounded="lg"
|
||||
bg="bg-gradient-to-br from-purple-500/15 to-purple-500/5"
|
||||
border
|
||||
borderColor="border-purple-500/20"
|
||||
>
|
||||
<Icon icon={Users} size={5} color="text-purple-400" />
|
||||
</Box>
|
||||
<Box>
|
||||
<Heading level={3} fontSize="lg" weight="semibold" color="text-white">Team Rankings</Heading>
|
||||
<Text size="xs" color="text-gray-500" block>Top performing racing teams</Text>
|
||||
<Heading level={3} fontSize="lg" weight="bold" color="text-white" letterSpacing="tight">Team Rankings</Heading>
|
||||
<Text size="xs" color="text-gray-500" block uppercase letterSpacing="wider" weight="bold">Top Performing Teams</Text>
|
||||
</Box>
|
||||
</Box>
|
||||
<Button
|
||||
variant="secondary"
|
||||
onClick={onNavigateToTeams}
|
||||
size="sm"
|
||||
hoverBg="bg-purple-500/10"
|
||||
transition
|
||||
>
|
||||
<Stack direction="row" align="center" gap={2}>
|
||||
<Text size="sm">View All</Text>
|
||||
<Text size="sm" weight="medium">View All</Text>
|
||||
<Icon icon={ChevronRight} size={4} />
|
||||
</Stack>
|
||||
</Button>
|
||||
</Box>
|
||||
|
||||
<Stack gap={0}
|
||||
// eslint-disable-next-line gridpilot-rules/component-classification
|
||||
className="divide-y divide-charcoal-outline/50"
|
||||
>
|
||||
{top5.map((team) => {
|
||||
<Stack gap={0}>
|
||||
{top5.map((team, index) => {
|
||||
const position = team.position;
|
||||
const isLast = index === top5.length - 1;
|
||||
|
||||
return (
|
||||
<Box
|
||||
@@ -74,24 +93,29 @@ export function TeamLeaderboardPreview({ teams, onTeamClick, onNavigateToTeams }
|
||||
w="full"
|
||||
textAlign="left"
|
||||
transition
|
||||
hoverBg="bg-iron-gray/30"
|
||||
hoverBg="bg-white/[0.02]"
|
||||
group
|
||||
borderBottom={!isLast}
|
||||
borderColor="border-charcoal-outline/30"
|
||||
>
|
||||
<Box
|
||||
display="flex"
|
||||
h="8"
|
||||
w="8"
|
||||
alignItems="center"
|
||||
justifyContent="center"
|
||||
rounded="full"
|
||||
border
|
||||
// eslint-disable-next-line gridpilot-rules/component-classification
|
||||
className={`text-xs font-bold ${MedalDisplay.getBg(position)} ${MedalDisplay.getColor(position)}`}
|
||||
>
|
||||
{position <= 3 ? <Icon icon={Crown} size={3.5} /> : <Text weight="bold">{position}</Text>}
|
||||
<Box w="8" display="flex" justifyContent="center">
|
||||
<RankMedal rank={position} size="sm" />
|
||||
</Box>
|
||||
|
||||
<Box display="flex" h="9" w="9" alignItems="center" justifyContent="center" rounded="lg" bg="bg-charcoal-outline" border borderColor="border-charcoal-outline" overflow="hidden">
|
||||
<Box
|
||||
display="flex"
|
||||
h="9"
|
||||
w="9"
|
||||
alignItems="center"
|
||||
justifyContent="center"
|
||||
rounded="lg"
|
||||
bg="bg-graphite-black/50"
|
||||
border
|
||||
borderColor="border-charcoal-outline"
|
||||
overflow="hidden"
|
||||
groupHoverBorderColor="purple-400/50"
|
||||
transition
|
||||
>
|
||||
<Image
|
||||
src={team.logoUrl || getMediaUrl('team-logo', team.id)}
|
||||
alt={team.name}
|
||||
@@ -104,57 +128,45 @@ export function TeamLeaderboardPreview({ teams, onTeamClick, onNavigateToTeams }
|
||||
</Box>
|
||||
|
||||
<Box flexGrow={1} minWidth="0">
|
||||
<Text weight="medium" color="text-white" truncate groupHoverTextColor="text-purple-400" transition block>
|
||||
<Text
|
||||
weight="semibold"
|
||||
color="text-white"
|
||||
truncate
|
||||
groupHoverTextColor="text-purple-400"
|
||||
transition
|
||||
block
|
||||
>
|
||||
{team.name}
|
||||
</Text>
|
||||
<Box display="flex" alignItems="center" gap={2} flexWrap="wrap">
|
||||
{team.category && (
|
||||
<Box display="flex" alignItems="center" gap={1} color="text-purple-400">
|
||||
<Box w="1.5" h="1.5" rounded="full" bg="bg-purple-400" />
|
||||
<Text size="xs">{team.category}</Text>
|
||||
<Text size="xs" weight="medium">{team.category}</Text>
|
||||
</Box>
|
||||
)}
|
||||
<Box w="1" h="1" rounded="full" bg="bg-gray-700" />
|
||||
<Box display="flex" alignItems="center" gap={1}>
|
||||
<Icon icon={Users} size={3} color="text-gray-500" />
|
||||
<Text size="xs" color="text-gray-500">{team.memberCount} members</Text>
|
||||
</Box>
|
||||
<Box as="span"
|
||||
// eslint-disable-next-line gridpilot-rules/component-classification
|
||||
className={SkillLevelDisplay.getColor(team.category || '')}
|
||||
>
|
||||
<Text size="xs">{SkillLevelDisplay.getLabel(team.category || '')}</Text>
|
||||
</Box>
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
<Box display="flex" alignItems="center" gap={4}>
|
||||
<Box textAlign="center">
|
||||
<Text color="text-purple-400" font="mono" weight="semibold" block>{team.memberCount}</Text>
|
||||
<Text
|
||||
// eslint-disable-next-line gridpilot-rules/component-classification
|
||||
style={{ fontSize: '10px' }}
|
||||
color="text-gray-500"
|
||||
block
|
||||
>
|
||||
Members
|
||||
</Text>
|
||||
<Box display="flex" alignItems="center" gap={6}>
|
||||
<Box textAlign="right">
|
||||
<Text color="text-purple-400" font="mono" weight="bold" block size="sm">{team.memberCount}</Text>
|
||||
<Text fontSize="10px" color="text-gray-500" block uppercase letterSpacing="wider" weight="bold">Members</Text>
|
||||
</Box>
|
||||
<Box textAlign="center">
|
||||
<Text color="text-performance-green" font="mono" weight="semibold" block>{team.totalWins}</Text>
|
||||
<Text
|
||||
// eslint-disable-next-line gridpilot-rules/component-classification
|
||||
style={{ fontSize: '10px' }}
|
||||
color="text-gray-500"
|
||||
block
|
||||
>
|
||||
Wins
|
||||
</Text>
|
||||
<Box textAlign="right" minWidth="12">
|
||||
<Text color="text-performance-green" font="mono" weight="bold" block size="sm">{team.totalWins}</Text>
|
||||
<Text fontSize="10px" color="text-gray-500" block uppercase letterSpacing="wider" weight="bold">Wins</Text>
|
||||
</Box>
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
})}
|
||||
</Stack>
|
||||
</Box>
|
||||
</LeaderboardTableShell>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
103
apps/website/components/leaderboards/TeamRankingRow.tsx
Normal file
103
apps/website/components/leaderboards/TeamRankingRow.tsx
Normal file
@@ -0,0 +1,103 @@
|
||||
import React from 'react';
|
||||
import { Box } from '@/ui/Box';
|
||||
import { Image } from '@/ui/Image';
|
||||
import { TableCell, TableRow } from '@/ui/Table';
|
||||
import { Text } from '@/ui/Text';
|
||||
import { RankMedal } from './RankMedal';
|
||||
import { getMediaUrl } from '@/lib/utilities/media';
|
||||
|
||||
interface TeamRankingRowProps {
|
||||
id: string;
|
||||
rank: number;
|
||||
name: string;
|
||||
logoUrl?: string;
|
||||
rating: number;
|
||||
wins: number;
|
||||
races: number;
|
||||
memberCount: number;
|
||||
onClick?: () => void;
|
||||
}
|
||||
|
||||
export function TeamRankingRow({
|
||||
id,
|
||||
rank,
|
||||
name,
|
||||
logoUrl,
|
||||
rating,
|
||||
wins,
|
||||
races,
|
||||
memberCount,
|
||||
onClick,
|
||||
}: TeamRankingRowProps) {
|
||||
return (
|
||||
<TableRow
|
||||
clickable={!!onClick}
|
||||
onClick={onClick}
|
||||
group
|
||||
>
|
||||
<TableCell>
|
||||
<Box w="8" display="flex" justifyContent="center">
|
||||
<RankMedal rank={rank} size="md" />
|
||||
</Box>
|
||||
</TableCell>
|
||||
|
||||
<TableCell>
|
||||
<Box display="flex" alignItems="center" gap={3}>
|
||||
<Box
|
||||
position="relative"
|
||||
w="10"
|
||||
h="10"
|
||||
rounded="lg"
|
||||
overflow="hidden"
|
||||
border
|
||||
borderColor="border-charcoal-outline"
|
||||
bg="bg-graphite-black/50"
|
||||
groupHoverBorderColor="purple-400/50"
|
||||
transition
|
||||
>
|
||||
<Image
|
||||
src={logoUrl || getMediaUrl('team-logo', id)}
|
||||
alt={name}
|
||||
width={40}
|
||||
height={40}
|
||||
fullWidth
|
||||
fullHeight
|
||||
objectFit="cover"
|
||||
/>
|
||||
</Box>
|
||||
<Box minWidth="0">
|
||||
<Text
|
||||
weight="semibold"
|
||||
color="text-white"
|
||||
block
|
||||
truncate
|
||||
groupHoverTextColor="text-purple-400"
|
||||
transition
|
||||
>
|
||||
{name}
|
||||
</Text>
|
||||
<Text size="xs" color="text-gray-500" block mt={0.5}>
|
||||
{memberCount} Members
|
||||
</Text>
|
||||
</Box>
|
||||
</Box>
|
||||
</TableCell>
|
||||
|
||||
<TableCell textAlign="center">
|
||||
<Text font="mono" weight="bold" color="text-purple-400">
|
||||
{rating}
|
||||
</Text>
|
||||
</TableCell>
|
||||
|
||||
<TableCell textAlign="center">
|
||||
<Text font="mono" weight="bold" color="text-performance-green">
|
||||
{wins}
|
||||
</Text>
|
||||
</TableCell>
|
||||
|
||||
<TableCell textAlign="center">
|
||||
<Text color="text-gray-400" font="mono">{races}</Text>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user