website refactor

This commit is contained in:
2026-01-20 01:30:17 +01:00
parent 30a31dc44f
commit 3556db494f
6 changed files with 210 additions and 162 deletions

View File

@@ -24,37 +24,35 @@ const CATEGORIES: Category[] = [
id: 'all', id: 'all',
label: 'All', label: 'All',
icon: Globe, icon: Globe,
description: 'Browse all available leagues', description: 'All available competition infrastructure.',
filter: () => true, filter: () => true,
}, },
{ {
id: 'popular', id: 'popular',
label: 'Popular', label: 'Popular',
icon: Flame, icon: Flame,
description: 'Most active leagues right now', description: 'High utilization infrastructure.',
filter: (league) => { filter: (league) => {
const fillRate = (league.usedDriverSlots ?? 0) / (league.maxDrivers ?? 1); const fillRate = (league.usedDriverSlots ?? 0) / (league.maxDrivers ?? 1);
return fillRate > 0.7; return fillRate > 0.7;
}, },
color: 'text-orange-400',
}, },
{ {
id: 'new', id: 'new',
label: 'New', label: 'New',
icon: Sparkles, icon: Sparkles,
description: 'Fresh leagues looking for members', description: 'Recently deployed infrastructure.',
filter: (league) => { filter: (league) => {
const oneWeekAgo = new Date(); const oneWeekAgo = new Date();
oneWeekAgo.setDate(oneWeekAgo.getDate() - 7); oneWeekAgo.setDate(oneWeekAgo.getDate() - 7);
return new Date(league.createdAt) > oneWeekAgo; return new Date(league.createdAt) > oneWeekAgo;
}, },
color: 'text-green-500',
}, },
{ {
id: 'openSlots', id: 'openSlots',
label: 'Open Slots', label: 'Open',
icon: Target, icon: Target,
description: 'Leagues with available spots', description: 'Infrastructure with available capacity.',
filter: (league) => { filter: (league) => {
if (league.maxTeams && league.maxTeams > 0) { if (league.maxTeams && league.maxTeams > 0) {
const usedTeams = league.usedTeamSlots ?? 0; const usedTeams = league.usedTeamSlots ?? 0;
@@ -64,41 +62,40 @@ const CATEGORIES: Category[] = [
const max = league.maxDrivers ?? 0; const max = league.maxDrivers ?? 0;
return max > 0 && used < max; return max > 0 && used < max;
}, },
color: 'text-cyan-400',
}, },
{ {
id: 'driver', id: 'driver',
label: 'Driver', label: 'Driver',
icon: Trophy, icon: Trophy,
description: 'Compete as an individual', description: 'Individual competition format.',
filter: (league) => league.scoring?.primaryChampionshipType === 'driver', filter: (league) => league.scoring?.primaryChampionshipType === 'driver',
}, },
{ {
id: 'team', id: 'team',
label: 'Team', label: 'Team',
icon: Users, icon: Users,
description: 'Race together as a team', description: 'Team-based competition format.',
filter: (league) => league.scoring?.primaryChampionshipType === 'team', filter: (league) => league.scoring?.primaryChampionshipType === 'team',
}, },
{ {
id: 'nations', id: 'nations',
label: 'Nations', label: 'Nations',
icon: Flag, icon: Flag,
description: 'Represent your country', description: 'National representation format.',
filter: (league) => league.scoring?.primaryChampionshipType === 'nations', filter: (league) => league.scoring?.primaryChampionshipType === 'nations',
}, },
{ {
id: 'trophy', id: 'trophy',
label: 'Trophy', label: 'Trophy',
icon: Award, icon: Award,
description: 'Special championship events', description: 'Special event infrastructure.',
filter: (league) => league.scoring?.primaryChampionshipType === 'trophy', filter: (league) => league.scoring?.primaryChampionshipType === 'trophy',
}, },
{ {
id: 'endurance', id: 'endurance',
label: 'Endurance', label: 'Endurance',
icon: Timer, icon: Timer,
description: 'Long-distance racing', description: 'Long-duration competition.',
filter: (league) => filter: (league) =>
league.scoring?.scoringPresetId?.includes('endurance') ?? league.scoring?.scoringPresetId?.includes('endurance') ??
league.timingSummary?.includes('h Race') ?? league.timingSummary?.includes('h Race') ??
@@ -108,7 +105,7 @@ const CATEGORIES: Category[] = [
id: 'sprint', id: 'sprint',
label: 'Sprint', label: 'Sprint',
icon: Clock, icon: Clock,
description: 'Quick, intense races', description: 'Short-duration competition.',
filter: (league) => filter: (league) =>
(league.scoring?.scoringPresetId?.includes('sprint') ?? false) && (league.scoring?.scoringPresetId?.includes('sprint') ?? false) &&
!(league.scoring?.scoringPresetId?.includes('endurance') ?? false), !(league.scoring?.scoringPresetId?.includes('endurance') ?? false),

View File

@@ -1,5 +1,5 @@
import { notFound } from 'next/navigation'; import { notFound } from 'next/navigation';
import { LeaguesPageClient } from '@/client-wrapper/LeaguesPageClient'; import { LeaguesPageClient } from './LeaguesPageClient';
import { LeaguesPageQuery } from '@/lib/page-queries/LeaguesPageQuery'; import { LeaguesPageQuery } from '@/lib/page-queries/LeaguesPageQuery';
export default async function Page() { export default async function Page() {

View File

@@ -8,8 +8,9 @@ import { Text } from '@/ui/Text';
import { Box } from '@/ui/Box'; import { Box } from '@/ui/Box';
import { Group } from '@/ui/Group'; import { Group } from '@/ui/Group';
import { Surface } from '@/ui/Surface'; import { Surface } from '@/ui/Surface';
import { Stack } from '@/ui/Stack';
import { LeagueCard as UILeagueCard, LeagueCardStats, LeagueCardFooter } from '@/ui/LeagueCard'; import { LeagueCard as UILeagueCard, LeagueCardStats, LeagueCardFooter } from '@/ui/LeagueCard';
import { Calendar as LucideCalendar } from 'lucide-react'; import { Calendar, Users, Activity } from 'lucide-react';
import React, { ReactNode } from 'react'; import React, { ReactNode } from 'react';
interface LeagueCardProps { interface LeagueCardProps {
@@ -54,80 +55,81 @@ export function LeagueCard({
coverUrl={coverUrl} coverUrl={coverUrl}
logo={ logo={
<Surface <Surface
width="3rem" width="3.5rem"
height="3rem" height="3.5rem"
rounded="md" rounded="lg"
overflow="hidden" overflow="hidden"
border border
variant="dark" variant="precision"
> >
{logoUrl ? ( {logoUrl ? (
<Image <Image
src={logoUrl} src={logoUrl}
alt={`${name} logo`} alt={`${name} logo`}
width={48} width={56}
height={48} height={56}
objectFit="cover" objectFit="cover"
/> />
) : ( ) : (
<PlaceholderImage size={48} /> <PlaceholderImage size={56} />
)} )}
</Surface> </Surface>
} }
badges={ badges={
<React.Fragment> <Group gap={2}>
{badges} {badges}
{championshipBadge} {championshipBadge}
</React.Fragment> </Group>
} }
> >
<Box marginBottom={1}> <Stack gap={4} fullHeight>
<Group gap={2}> <Stack gap={1}>
<Box width="0.25rem" height="1rem" bg="var(--ui-color-intent-primary)" /> <Group gap={2} align="center">
<Heading level={3} weight="bold" truncate>{name}</Heading> <Box width="2px" height="1rem" bg="var(--ui-color-intent-telemetry)" />
</Group> <Heading level={3} weight="bold" truncate>{name}</Heading>
</Box>
<Text size="xs" variant="low" lineClamp={2} style={{ height: '2.5rem' }} block leading="relaxed" marginBottom={4}>
{description || 'No description available'}
</Text>
<LeagueCardStats
label={slotLabel}
value={`${usedSlots}/${maxSlots || '∞'}`}
percentage={fillPercentage}
intent={fillPercentage >= 90 ? 'warning' : fillPercentage >= 70 ? 'primary' : 'success'}
/>
{hasOpenSlots && (
<Box marginBottom={4}>
<Surface
display="flex"
alignItems="center"
gap={1.5}
paddingX={2}
paddingY={1}
bg="rgba(25, 140, 255, 0.05)"
border="1px solid rgba(25, 140, 255, 0.2)"
rounded="sm"
width="fit-content"
>
<Box width="0.375rem" height="0.375rem" rounded="full" bg="var(--ui-color-intent-primary)" />
<Text size="xs" variant="primary" weight="bold" uppercase>{openSlotsCount} OPEN</Text>
</Surface>
</Box>
)}
<LeagueCardFooter>
{timingSummary && (
<Group gap={2}>
<Icon icon={LucideCalendar} size={3} intent="low" />
<Text size="xs" variant="low" font="mono">
{timingSummary.split('•')[1]?.trim() || timingSummary}
</Text>
</Group> </Group>
)} <Text size="xs" variant="low" lineClamp={2} block leading="relaxed">
</LeagueCardFooter> {description || 'No infrastructure description provided.'}
</Text>
</Stack>
<Box>
<LeagueCardStats
label={slotLabel}
value={`${usedSlots} / ${maxSlots || '∞'}`}
percentage={fillPercentage}
intent={fillPercentage >= 90 ? 'warning' : fillPercentage >= 70 ? 'primary' : 'success'}
/>
{hasOpenSlots && (
<Group gap={2} align="center">
<Icon icon={Activity} size={3} intent="success" />
<Text size="xs" variant="success" weight="bold" uppercase font="mono">
{openSlotsCount} slots available
</Text>
</Group>
)}
</Box>
<LeagueCardFooter>
<Stack gap={2}>
{timingSummary && (
<Group gap={2}>
<Icon icon={Calendar} size={3} intent="low" />
<Text size="xs" variant="low" font="mono">
{timingSummary.split('•')[1]?.trim() || timingSummary}
</Text>
</Group>
)}
<Group gap={2}>
<Icon icon={Users} size={3} intent="low" />
<Text size="xs" variant="low" font="mono">
{usedSlots} active participants
</Text>
</Group>
</Stack>
</LeagueCardFooter>
</Stack>
</UILeagueCard> </UILeagueCard>
); );
} }

View File

@@ -5,6 +5,8 @@ import {
Flag, Flag,
Award, Award,
Sparkles, Sparkles,
Gamepad2,
Layers,
} from 'lucide-react'; } from 'lucide-react';
import type { LeagueSummaryViewModel } from '@/lib/view-models/LeagueSummaryViewModel'; import type { LeagueSummaryViewModel } from '@/lib/view-models/LeagueSummaryViewModel';
import { getMediaUrl } from '@/lib/utilities/media'; import { getMediaUrl } from '@/lib/utilities/media';
@@ -152,24 +154,24 @@ export function LeagueCard({ league, onClick }: LeagueCardProps) {
badges={ badges={
<> <>
{isNew && ( {isNew && (
<Badge variant="success" icon={Sparkles}> <Badge variant="success" icon={Sparkles} size="sm">
NEW NEW
</Badge> </Badge>
)} )}
{league.scoring?.gameName && ( {league.scoring?.gameName && (
<Badge variant={gameVariant}> <Badge variant={gameVariant} icon={Gamepad2} size="sm">
{league.scoring.gameName} {league.scoring.gameName}
</Badge> </Badge>
)} )}
{league.category && ( {league.category && (
<Badge variant={categoryVariant}> <Badge variant={categoryVariant} icon={Layers} size="sm">
{categoryLabel} {categoryLabel}
</Badge> </Badge>
)} )}
</> </>
} }
championshipBadge={ championshipBadge={
<Badge variant="default" icon={ChampionshipIcon}> <Badge variant="outline" icon={ChampionshipIcon} size="sm">
{championshipLabel} {championshipLabel}
</Badge> </Badge>
} }

View File

@@ -3,7 +3,7 @@
import { LeagueCard } from '@/components/leagues/LeagueCardWrapper'; import { LeagueCard } from '@/components/leagues/LeagueCardWrapper';
import type { LeaguesViewData } from '@/lib/view-data/LeaguesViewData'; import type { LeaguesViewData } from '@/lib/view-data/LeaguesViewData';
import { LeagueSummaryViewModel } from '@/lib/view-models/LeagueSummaryViewModel'; import { LeagueSummaryViewModel } from '@/lib/view-models/LeagueSummaryViewModel';
import { Heading } from '@/ui/Heading'; import { PageHeader } from '@/ui/PageHeader';
import { Input } from '@/ui/Input'; import { Input } from '@/ui/Input';
import { Button } from '@/ui/Button'; import { Button } from '@/ui/Button';
import { Group } from '@/ui/Group'; import { Group } from '@/ui/Group';
@@ -12,11 +12,16 @@ import { Container } from '@/ui/Container';
import { Text } from '@/ui/Text'; import { Text } from '@/ui/Text';
import { Icon } from '@/ui/Icon'; import { Icon } from '@/ui/Icon';
import { Section } from '@/ui/Section'; import { Section } from '@/ui/Section';
import { StatusDot } from '@/ui/StatusDot'; import { ControlBar } from '@/ui/ControlBar';
import { SegmentedControl } from '@/ui/SegmentedControl';
import { MetricCard } from '@/ui/MetricCard';
import { Stack } from '@/ui/Stack';
import { Box } from '@/ui/Box';
import { import {
Plus, Plus,
Search, Search,
Trophy, Trophy,
Filter,
type LucideIcon, type LucideIcon,
} from 'lucide-react'; } from 'lucide-react';
import React from 'react'; import React from 'react';
@@ -68,29 +73,14 @@ export function LeaguesTemplate({
onClearFilters, onClearFilters,
}: LeaguesTemplateProps) { }: LeaguesTemplateProps) {
return ( return (
<Container size="xl" py={12}> <Container size="xl" py={8}>
<Group direction="column" gap={16} fullWidth> <Stack gap={8}>
{/* Hero */} {/* Header Section */}
<Group direction={{ base: 'column', md: 'row' } as any} align={{ base: 'start', md: 'end' } as any} justify="between" gap={8} fullWidth> <PageHeader
<Group direction="column" gap={4}> icon={Trophy}
<Group direction="row" align="center" gap={3}> title="Leagues"
<Icon icon={Trophy} size={6} intent="primary" /> description="Infrastructure for competitive sim racing."
<Text size="xs" weight="bold" uppercase letterSpacing="widest" color="text-primary-accent">Competition Hub</Text> action={
</Group>
<Heading level={1} size="5xl" weight="bold">
Find Your <Text as="span" color="text-primary-accent">Grid</Text>
</Heading>
<Text variant="low" maxWidth="md">
From casual sprints to epic endurance battles discover the perfect league for your racing style.
</Text>
</Group>
<Group direction="row" align="center" gap={4}>
<Group direction="column" align="end">
<Text size="2xl" weight="bold" font="mono">{viewData.leagues.length}</Text>
<Text weight="bold" variant="low" uppercase letterSpacing="widest" size="xs">Active Leagues</Text>
</Group>
<StatusDot intent="telemetry" size="lg" />
<Button <Button
onClick={onCreateLeague} onClick={onCreateLeague}
variant="primary" variant="primary"
@@ -99,40 +89,62 @@ export function LeaguesTemplate({
> >
Create League Create League
</Button> </Button>
</Group> }
</Group> />
{/* Search & Filters */} {/* Stats Overview */}
<Group direction="column" gap={8} fullWidth> <Grid cols={{ base: 1, md: 3 }} gap={4}>
<Input <MetricCard
type="text" label="Active Leagues"
placeholder="Search leagues by name, description, or game..." value={viewData.leagues.length}
value={searchQuery} icon={Trophy}
onChange={(e: React.ChangeEvent<HTMLInputElement>) => onSearchChange(e.target.value)} intent="telemetry"
icon={<Search size={20} />}
/> />
<MetricCard
label="Total Drivers"
value={viewData.leagues.reduce((acc, l) => acc + (l.usedDriverSlots ?? 0), 0)}
icon={Trophy}
intent="primary"
/>
<MetricCard
label="Open Slots"
value={viewData.leagues.reduce((acc, l) => acc + Math.max(0, (l.maxDrivers ?? 0) - (l.usedDriverSlots ?? 0)), 0)}
icon={Trophy}
intent="success"
/>
</Grid>
<Group direction="row" wrap gap={2} fullWidth> {/* Control Bar */}
{categories.map((category) => { <ControlBar
const isActive = activeCategory === category.id; leftContent={
const CategoryIcon = category.icon; <Group gap={4} align="center">
return ( <Icon icon={Filter} size={4} intent="low" />
<Button <SegmentedControl
key={category.id} options={categories.map(c => ({
onClick={() => onCategoryChange(category.id)} id: c.id,
variant={isActive ? 'primary' : 'secondary'} label: c.label,
size="sm" icon: <Icon icon={c.icon} size={3} />
icon={<CategoryIcon size={14} />} }))}
> activeId={activeCategory}
{category.label} onChange={(id) => onCategoryChange(id as CategoryId)}
</Button> />
); </Group>
})} }
</Group> >
</Group> <Box width="300px">
<Input
type="text"
placeholder="Search infrastructure..."
value={searchQuery}
onChange={(e: React.ChangeEvent<HTMLInputElement>) => onSearchChange(e.target.value)}
icon={<Search size={16} />}
size="sm"
/>
</Box>
</ControlBar>
{/* Grid */} {/* Results */}
<Group direction="column" fullWidth> <Stack gap={6}>
{filteredLeagues.length > 0 ? ( {filteredLeagues.length > 0 ? (
<Grid cols={{ base: 1, md: 2, lg: 3 }} gap={6}> <Grid cols={{ base: 1, md: 2, lg: 3 }} gap={6}>
{filteredLeagues.map((league) => ( {filteredLeagues.map((league) => (
@@ -145,22 +157,23 @@ export function LeaguesTemplate({
</Grid> </Grid>
) : ( ) : (
<Section variant="dark" padding="lg"> <Section variant="dark" padding="lg">
<Group direction="column" align="center" justify="center" fullWidth> <Stack align="center" justify="center" gap={4}>
<Icon icon={Search} size={12} intent="low" /> <Icon icon={Search} size={12} intent="low" />
<Heading level={3} weight="bold">No Leagues Found</Heading> <Stack align="center" gap={1}>
<Text variant="low" size="sm">Try adjusting your search or filters</Text> <Text size="lg" weight="bold">No results found</Text>
<Text variant="low" size="sm">Adjust filters to find matching infrastructure.</Text>
</Stack>
<Button <Button
variant="secondary" variant="secondary"
onClick={onClearFilters} onClick={onClearFilters}
style={{ marginTop: '1.5rem' }}
> >
Clear All Filters Reset Filters
</Button> </Button>
</Group> </Stack>
</Section> </Section>
)} )}
</Group> </Stack>
</Group> </Stack>
</Container> </Container>
); );
} }

View File

@@ -5,6 +5,8 @@ import { Card } from './Card';
import { Icon } from './Icon'; import { Icon } from './Icon';
import { Image } from './Image'; import { Image } from './Image';
import { Text } from './Text'; import { Text } from './Text';
import { Stack } from './Stack';
import { Group } from './Group';
export interface LeagueCardProps { export interface LeagueCardProps {
children: ReactNode; children: ReactNode;
@@ -17,23 +19,37 @@ export interface LeagueCardProps {
export const LeagueCard = ({ children, onClick, coverUrl, logo, badges }: LeagueCardProps) => { export const LeagueCard = ({ children, onClick, coverUrl, logo, badges }: LeagueCardProps) => {
return ( return (
<Card <Card
variant="dark" variant="precision"
onClick={onClick} onClick={onClick}
style={{ position: 'relative', cursor: onClick ? 'pointer' : 'default', overflow: 'hidden', height: '100%' }} style={{ height: '100%', display: 'flex', flexDirection: 'column' }}
padding="none"
> >
<Box height="8rem" position="relative" style={{ overflow: 'hidden' }}> <Box height="10rem" position="relative" overflow="hidden">
<Image src={coverUrl} alt="Cover" fullWidth fullHeight objectFit="cover" style={{ opacity: 0.6 }} /> <Image
<Box position="absolute" inset={0} style={{ background: 'linear-gradient(to top, var(--ui-color-bg-base), transparent)' }} /> src={coverUrl}
<Box position="absolute" top={3} left={3} display="flex" gap={2}> alt="Cover"
{badges} fullWidth
fullHeight
objectFit="cover"
style={{ opacity: 0.4, filter: 'grayscale(0.2)' }}
/>
<Box
position="absolute"
inset={0}
style={{ background: 'linear-gradient(to top, var(--ui-color-bg-base), transparent)' }}
/>
<Box position="absolute" top={4} left={4}>
<Group gap={2}>
{badges}
</Group>
</Box> </Box>
{logo && ( {logo && (
<Box position="absolute" left={4} bottom={-6} zIndex={10}> <Box position="absolute" left={6} bottom={-4} zIndex={10}>
{logo} {logo}
</Box> </Box>
)} )}
</Box> </Box>
<Box padding={4} paddingTop={8} display="flex" flexDirection="col" fullHeight> <Box padding={6} paddingTop={10} display="flex" flexDirection="col" flex={1}>
{children} {children}
</Box> </Box>
</Card> </Card>
@@ -44,7 +60,7 @@ export interface LeagueCardStatsProps {
label: string; label: string;
value: string; value: string;
percentage: number; percentage: number;
intent?: 'primary' | 'success' | 'warning'; intent?: 'primary' | 'success' | 'warning' | 'telemetry';
} }
export const LeagueCardStats = ({ label, value, percentage, intent = 'primary' }: LeagueCardStatsProps) => { export const LeagueCardStats = ({ label, value, percentage, intent = 'primary' }: LeagueCardStatsProps) => {
@@ -52,17 +68,27 @@ export const LeagueCardStats = ({ label, value, percentage, intent = 'primary' }
primary: 'var(--ui-color-intent-primary)', primary: 'var(--ui-color-intent-primary)',
success: 'var(--ui-color-intent-success)', success: 'var(--ui-color-intent-success)',
warning: 'var(--ui-color-intent-warning)', warning: 'var(--ui-color-intent-warning)',
telemetry: 'var(--ui-color-intent-telemetry)',
}; };
return ( return (
<Box marginBottom={4}> <Box marginBottom={6}>
<Box display="flex" alignItems="center" justifyContent="between" marginBottom={1.5}> <Stack gap={2}>
<Text size="xs" variant="low" weight="bold" uppercase>{label}</Text> <Group justify="between" align="end">
<Text size="xs" variant="med" font="mono">{value}</Text> <Text size="xs" variant="low" weight="bold" uppercase letterSpacing="widest">{label}</Text>
</Box> <Text size="sm" variant="high" font="mono" weight="bold">{value}</Text>
<Box height="4px" bg="var(--ui-color-bg-surface-muted)" rounded="full" style={{ overflow: 'hidden' }}> </Group>
<Box height="100%" bg={intentColors[intent]} style={{ width: `${Math.min(percentage, 100)}%` }} /> <Box height="2px" bg="var(--ui-color-bg-surface-muted)" rounded="full" overflow="hidden">
</Box> <Box
height="100%"
bg={intentColors[intent]}
style={{
width: `${Math.min(percentage, 100)}%`,
boxShadow: `0 0 8px ${intentColors[intent]}44`
}}
/>
</Box>
</Stack>
</Box> </Box>
); );
}; };
@@ -72,11 +98,19 @@ export interface LeagueCardFooterProps {
} }
export const LeagueCardFooter = ({ children }: LeagueCardFooterProps) => ( export const LeagueCardFooter = ({ children }: LeagueCardFooterProps) => (
<Box marginTop="auto" paddingTop={3} style={{ borderTop: '1px solid var(--ui-color-border-muted)', opacity: 0.5 }} display="flex" alignItems="center" justifyContent="between"> <Box
{children} marginTop="auto"
<Box display="flex" alignItems="center" gap={1}> paddingTop={4}
<Text size="xs" variant="low" weight="bold" uppercase>VIEW</Text> style={{ borderTop: '1px solid var(--ui-color-border-muted)' }}
<Icon icon={ChevronRight} size={3} intent="low" /> >
</Box> <Group justify="between" align="center">
<Box flex={1}>
{children}
</Box>
<Group gap={1} align="center">
<Text size="xs" variant="low" weight="bold" uppercase letterSpacing="widest">ACCESS</Text>
<Icon icon={ChevronRight} size={3} intent="low" />
</Group>
</Group>
</Box> </Box>
); );