website refactor

This commit is contained in:
2026-01-20 17:49:54 +01:00
parent 94aaaff704
commit b39b098e6b
18 changed files with 437 additions and 491 deletions

View File

@@ -39,10 +39,28 @@ export class RacingTeamFactory {
const teamId = seedId(`team-${i}`, this.persistence);
const racingNames = [
'Apex Performance', 'Velocity Racing', 'Zenith Motorsport', 'Quantum Racing',
'Ignition Racing', 'Precision Dynamics', 'Overdrive Motorsport', 'Apex Predators',
'Gridline Racing', 'Shift Point Motorsport', 'Redline Performance', 'Apex Legends',
'Circuit Breakers', 'Full Throttle Racing', 'Gearhead Motorsport', 'Piston Cup Racing',
'Turbo Titans', 'Nitro Knights', 'Velocity Vanguards', 'Mach One Racing',
'Apex Alliance', 'Elite Endurance', 'Sprint Specialists', 'Grand Prix Group',
'Podium Pursuit', 'Victory Vibe', 'Championship Chase', 'Racing Renegades',
'Track Titans', 'Asphalt Assassins', 'Speed Syndicate', 'Fast Lane Force',
'Apex Architects', 'Velocity Visionaries', 'Zenith Zephyrs', 'Quantum Quicksilver',
'Ignition Iron', 'Precision Pilots', 'Overdrive Outlaws', 'Apex Aces',
'Gridline Guardians', 'Shift Point Sentinels', 'Redline Rebels', 'Apex Avengers',
'Circuit Crusaders', 'Full Throttle Falcons', 'Gearhead Giants', 'Piston Cup Pros',
'Turbo Tigers', 'Nitro Ninjas'
];
const name = racingNames[(i - 1) % racingNames.length]!;
return Team.create({
id: teamId,
name: faker.company.name() + ' Racing',
tag: faker.string.alpha({ length: 4, casing: 'upper' }),
name: name,
tag: name.split(' ').map(w => w[0]).join('').toUpperCase().substring(0, 4),
description: faker.lorem.sentences(2),
ownerId: owner.id,
leagues: teamLeagues,
@@ -203,8 +221,8 @@ export class RacingTeamFactory {
generateTeamStats(teams: Team[]): Map<string, TeamStats> {
const statsMap = new Map<string, TeamStats>();
// Available regions
const regions = ['Europe', 'North America', 'South America', 'Asia', 'Oceania', 'Africa'];
// Available regions (using country codes for flags)
const regions = ['DE', 'GB', 'US', 'FR', 'IT', 'ES', 'BR', 'JP', 'AU', 'NL', 'BE', 'AT', 'CH', 'SE', 'NO', 'FI', 'DK', 'PL', 'CZ', 'HU'];
// Available languages
const allLanguages = ['English', 'German', 'French', 'Spanish', 'Italian', 'Portuguese', 'Japanese', 'Korean', 'Russian', 'Chinese'];

View File

@@ -86,14 +86,18 @@ export class MediaController {
@Res() res: Response,
): Promise<void> {
this.logger.debug('[MediaController] Generating team logo', { teamId });
const svg = this.mediaGenerationService.generateTeamLogo(teamId);
const svgLength = svg.length;
const url = this.mediaGenerationService.generateTeamLogo(teamId);
if (url.startsWith('http')) {
res.redirect(HttpStatus.FOUND, url);
return;
}
res.setHeader('Content-Type', 'image/svg+xml; charset=utf-8');
res.setHeader('Cache-Control', 'public, max-age=86400');
res.status(HttpStatus.OK).send(svg);
res.status(HttpStatus.OK).send(url);
this.logger.info('[MediaController] Team logo generated', { teamId, svgLength });
this.logger.info('[MediaController] Team logo generated', { teamId });
}
@Public()
@@ -105,14 +109,18 @@ export class MediaController {
@Res() res: Response,
): Promise<void> {
this.logger.debug('[MediaController] Generating league logo', { leagueId });
const svg = this.mediaGenerationService.generateLeagueLogo(leagueId);
const svgLength = svg.length;
const url = this.mediaGenerationService.generateLeagueLogo(leagueId);
if (url.startsWith('http')) {
res.redirect(HttpStatus.FOUND, url);
return;
}
res.setHeader('Content-Type', 'image/svg+xml; charset=utf-8');
res.setHeader('Cache-Control', 'public, max-age=86400');
res.status(HttpStatus.OK).send(svg);
res.status(HttpStatus.OK).send(url);
this.logger.info('[MediaController] League logo generated', { leagueId, svgLength });
this.logger.info('[MediaController] League logo generated', { leagueId });
}
@Public()
@@ -124,14 +132,18 @@ export class MediaController {
@Res() res: Response,
): Promise<void> {
this.logger.debug('[MediaController] Generating league cover', { leagueId });
const svg = this.mediaGenerationService.generateLeagueCover(leagueId);
const svgLength = svg.length;
const url = this.mediaGenerationService.generateLeagueCover(leagueId);
if (url.startsWith('http')) {
res.redirect(HttpStatus.FOUND, url);
return;
}
res.setHeader('Content-Type', 'image/svg+xml; charset=utf-8');
res.setHeader('Cache-Control', 'public, max-age=86400');
res.status(HttpStatus.OK).send(svg);
res.status(HttpStatus.OK).send(url);
this.logger.info('[MediaController] League cover generated', { leagueId, svgLength });
this.logger.info('[MediaController] League cover generated', { leagueId });
}
@Public()
@@ -143,14 +155,18 @@ export class MediaController {
@Res() res: Response,
): Promise<void> {
this.logger.debug('[MediaController] Generating driver avatar', { driverId });
const svg = this.mediaGenerationService.generateDriverAvatar(driverId);
const svgLength = svg.length;
const url = this.mediaGenerationService.generateDriverAvatar(driverId);
if (url.startsWith('http')) {
res.redirect(HttpStatus.FOUND, url);
return;
}
res.setHeader('Content-Type', 'image/svg+xml; charset=utf-8');
res.setHeader('Cache-Control', 'public, max-age=86400');
res.status(HttpStatus.OK).send(svg);
res.status(HttpStatus.OK).send(url);
this.logger.info('[MediaController] Driver avatar generated', { driverId, svgLength });
this.logger.info('[MediaController] Driver avatar generated', { driverId });
}
@Public()
@@ -223,27 +239,30 @@ export class MediaController {
@Res() res: Response,
): Promise<void> {
this.logger.debug('[MediaController] Generating media', { type, id });
let svg: string;
let url: string;
// Route to appropriate generator based on type
if (type === 'team') {
svg = this.mediaGenerationService.generateTeamLogo(id);
url = this.mediaGenerationService.generateTeamLogo(id);
} else if (type === 'league') {
svg = this.mediaGenerationService.generateLeagueLogo(id);
url = this.mediaGenerationService.generateLeagueLogo(id);
} else if (type === 'driver') {
svg = this.mediaGenerationService.generateDriverAvatar(id);
url = this.mediaGenerationService.generateDriverAvatar(id);
} else {
// Fallback: generate a generic logo
svg = this.mediaGenerationService.generateLeagueLogo(`${type}-${id}`);
url = this.mediaGenerationService.generateLeagueLogo(`${type}-${id}`);
}
const svgLength = svg.length;
if (url.startsWith('http')) {
res.redirect(HttpStatus.FOUND, url);
return;
}
res.setHeader('Content-Type', 'image/svg+xml; charset=utf-8');
res.setHeader('Cache-Control', 'public, max-age=31536000, immutable');
res.status(HttpStatus.OK).send(svg);
res.status(HttpStatus.OK).send(url);
this.logger.info('[MediaController] Generated media served', { type, id, svgLength });
this.logger.info('[MediaController] Generated media served', { type, id });
}
@Public()

View File

@@ -190,6 +190,15 @@
animation: fade-in-up 0.4s cubic-bezier(0.16, 1, 0.3, 1) forwards;
}
.scrollbar-hide::-webkit-scrollbar {
display: none;
}
.scrollbar-hide {
-ms-overflow-style: none;
scrollbar-width: none;
}
@media (prefers-reduced-motion: reduce) {
.animate-fade-in-up {
animation: none;

View File

@@ -0,0 +1,144 @@
'use client';
import React, { useRef, useState, useEffect } from 'react';
import { ChevronLeft, ChevronRight } from 'lucide-react';
import { IconButton } from '@/ui/IconButton';
import { Box } from '@/ui/Box';
import { Heading } from '@/ui/Heading';
import { Text } from '@/ui/Text';
interface CarouselProps {
children: React.ReactNode;
title?: string;
count?: number;
}
export function Carousel({ children, title, count }: CarouselProps) {
const scrollRef = useRef<HTMLDivElement>(null);
const [showLeft, setShowLeft] = useState(false);
const [showRight, setShowRight] = useState(true);
const checkScroll = () => {
if (scrollRef.current) {
const { scrollLeft, scrollWidth, clientWidth } = scrollRef.current;
setShowLeft(scrollLeft > 10);
setShowRight(scrollLeft < scrollWidth - clientWidth - 10);
}
};
useEffect(() => {
checkScroll();
window.addEventListener('resize', checkScroll);
return () => window.removeEventListener('resize', checkScroll);
}, []);
const scroll = (direction: 'left' | 'right') => {
if (scrollRef.current) {
const { clientWidth } = scrollRef.current;
const scrollAmount = clientWidth * 0.8;
scrollRef.current.scrollBy({
left: direction === 'left' ? -scrollAmount : scrollAmount,
behavior: 'smooth'
});
}
};
return (
<Box position="relative">
{/* Header with Title and Controls */}
<Box display="flex" alignItems="center" justifyContent="between" marginBottom={6} paddingBottom={4} borderBottom="1px solid var(--ui-color-border-muted)">
<Box display="flex" alignItems="center" gap={4}>
{title && (
<Heading level={2} weight="bold" uppercase size="sm" style={{ letterSpacing: '0.2em' }}>
{title}
</Heading>
)}
{count !== undefined && (
<Box paddingX={2} paddingY={0.5} border="1px solid var(--ui-color-border-muted)" rounded="sm">
<Text size="xs" mono variant="low">{count}</Text>
</Box>
)}
</Box>
<Box display="flex" gap={2}>
<IconButton
icon={ChevronLeft}
onClick={() => scroll('left')}
variant="secondary"
size="sm"
disabled={!showLeft}
className={`transition-opacity duration-300 ${showLeft ? 'opacity-100' : 'opacity-30'}`}
/>
<IconButton
icon={ChevronRight}
onClick={() => scroll('right')}
variant="secondary"
size="sm"
disabled={!showRight}
className={`transition-opacity duration-300 ${showRight ? 'opacity-100' : 'opacity-30'}`}
/>
</Box>
</Box>
{/* Scroll Area with Fades */}
<Box position="relative" group>
{/* Left Fade */}
<Box
position="absolute"
left={-4}
top={0}
bottom={0}
width={12}
zIndex={5}
pointerEvents="none"
style={{
background: 'linear-gradient(to right, var(--ui-color-bg-base) 0%, transparent 100%)',
opacity: showLeft ? 1 : 0,
transition: 'opacity 0.3s'
}}
/>
{/* Scroll Container */}
<Box
ref={scrollRef}
onScroll={checkScroll}
display="flex"
alignItems="stretch"
overflowX="auto"
paddingBottom={6}
paddingX={0}
gap={6}
className="scrollbar-hide snap-x snap-mandatory"
>
{React.Children.map(children, (child) => (
<Box
display="flex"
flexDirection="col"
flex="none"
className="snap-start h-full"
style={{ width: '380px' }}
>
{child}
</Box>
))}
</Box>
{/* Right Fade */}
<Box
position="absolute"
right={-4}
top={0}
bottom={0}
width={12}
zIndex={5}
pointerEvents="none"
style={{
background: 'linear-gradient(to left, var(--ui-color-bg-base) 0%, transparent 100%)',
opacity: showRight ? 1 : 0,
transition: 'opacity 0.3s'
}}
/>
</Box>
</Box>
);
}

View File

@@ -1,15 +1,23 @@
'use client';
import React from 'react';
import { TeamCard as UiTeamCard } from '@/ui/TeamCard';
import { ChevronRight, Users, Zap } from 'lucide-react';
import { Card } from '@/ui/Card';
import { CountryFlag } from '@/ui/CountryFlag';
import { Heading } from '@/ui/Heading';
import { Icon } from '@/ui/Icon';
import { Logo } from '@/ui/Logo';
import { Text } from '@/ui/Text';
import { Badge } from '@/ui/Badge';
import { Stack } from '@/ui/Stack';
import { Grid } from '@/ui/Grid';
import { Box } from '@/ui/Box';
import { TeamSummaryData } from '@/lib/view-data/TeamsViewData';
import { Image } from '@/ui/Image';
interface TeamCardProps {
team?: TeamSummaryData;
// Compatibility props
name?: string;
leagueName?: string;
logo?: string;
memberCount?: number;
ratingLabel?: string;
@@ -25,7 +33,6 @@ interface TeamCardProps {
export function TeamCard({
team,
name,
leagueName,
logo,
memberCount,
ratingLabel,
@@ -40,7 +47,6 @@ export function TeamCard({
const data = team || {
teamId: '',
teamName: name || '',
leagueName: leagueName || '',
memberCount: memberCount || 0,
logoUrl: logo,
ratingLabel: ratingLabel || '-',
@@ -50,22 +56,98 @@ export function TeamCard({
isRecruiting: isRecruiting || false,
performanceLevel: performanceLevel,
description: description,
countryCode: region,
};
return (
<UiTeamCard
name={data.teamName}
leagueName={data.leagueName}
logo={data.logoUrl ? <Image src={data.logoUrl} alt={data.teamName} fullWidth fullHeight objectFit="cover" /> : undefined}
memberCount={data.memberCount}
rating={data.ratingLabel}
wins={data.winsLabel}
races={data.racesLabel}
region={data.region}
isRecruiting={data.isRecruiting}
performanceLevel={data.performanceLevel}
description={data.description}
<Card
variant="precision"
padding="none"
onClick={() => onClick?.(data.teamId)}
/>
transition
fullHeight
position="relative"
>
{data.isRecruiting && (
<Box position="absolute" top={0} right={0} zIndex={10}>
<Badge variant="success" size="xs" rounded="none" style={{ borderBottomLeftRadius: '4px' }}>
RECRUITING
</Badge>
</Box>
)}
<Stack padding={6} gap={6} fullHeight>
{/* Header: Logo and Identity */}
<Stack direction="row" align="start" gap={4}>
<Logo
src={data.logoUrl}
alt={data.teamName}
size={40}
rounded="sm"
variant="dark"
icon={Users}
/>
<Stack flex={1} gap={1} paddingTop={1}>
<Heading level={5} weight="bold" uppercase style={{ lineHeight: '1.2' }}>
{data.teamName}
</Heading>
<Stack direction="row" gap={3} wrap>
{data.performanceLevel && (
<Stack direction="row" align="center" gap={1}>
<Icon icon={Zap} size={3} intent="telemetry" />
<Text size="xs" variant="telemetry" mono uppercase>{data.performanceLevel}</Text>
</Stack>
)}
</Stack>
</Stack>
</Stack>
{/* Technical Stats Grid - Engineered Look */}
<Grid cols={3} gap="px" style={{ backgroundColor: 'var(--ui-color-border-muted)', border: '1px solid var(--ui-color-border-muted)' }}>
<Stack padding={3} align="center" style={{ backgroundColor: 'var(--ui-color-bg-surface)' }}>
<Text size="xs" variant="low" uppercase block marginBottom={1} mono>Rating</Text>
<Text size="md" weight="bold" mono variant="primary">{data.ratingLabel}</Text>
</Stack>
<Stack padding={3} align="center" style={{ backgroundColor: 'var(--ui-color-bg-surface)' }}>
<Text size="xs" variant="low" uppercase block marginBottom={1} mono>Wins</Text>
<Text size="md" weight="bold" mono variant="telemetry">{data.winsLabel}</Text>
</Stack>
<Stack padding={3} align="center" style={{ backgroundColor: 'var(--ui-color-bg-surface)' }}>
<Text size="xs" variant="low" uppercase block marginBottom={1} mono>Races</Text>
<Text size="md" weight="bold" mono variant="high">{data.racesLabel}</Text>
</Stack>
</Grid>
{data.description && (
<Text size="xs" variant="low" lineClamp={2} block leading="relaxed">
{data.description}
</Text>
)}
{/* Spacer to push footer down */}
<Box flex={1} />
{/* Footer: Metadata */}
<Stack direction="row" justify="between" paddingTop={4} style={{ borderTop: '1px solid var(--ui-color-border-muted)' }}>
<Stack direction="row" gap={4}>
<Stack direction="row" align="center" gap={1.5}>
<Icon icon={Users} size={3} intent="low" />
<Text size="xs" variant="low" mono>{data.memberCount}</Text>
</Stack>
{data.countryCode && (
<Stack direction="row" align="center" gap={1.5}>
<CountryFlag countryCode={data.countryCode} size="sm" />
<Text size="xs" variant="low" mono uppercase>{data.countryCode}</Text>
</Stack>
)}
</Stack>
<Stack direction="row" align="center" gap={1} style={{ color: 'var(--ui-color-intent-primary)' }}>
<Text size="xs" weight="bold" uppercase mono>Details</Text>
<Icon icon={ChevronRight} size={3} />
</Stack>
</Stack>
</Stack>
</Card>
);
}

View File

@@ -1,74 +1,60 @@
'use client';
import { JoinTeamButton } from '@/components/teams/JoinTeamButton';
import { TeamLogo } from '@/components/teams/TeamLogo';
import { TeamTag } from '@/components/teams/TeamTag';
import { Card } from '@/ui/Card';
import { Group } from '@/ui/Group';
import React, { ReactNode } from 'react';
import { Glow } from '@/ui/Glow';
import { Heading } from '@/ui/Heading';
import { StatGrid } from '@/ui/StatGrid';
import { Text } from '@/ui/Text';
import { Stack } from '@/ui/Stack';
import { Box } from '@/ui/Box';
interface TeamHeroProps {
team: {
id: string;
name: string;
tag: string | null;
description?: string;
category?: string | null;
createdAt?: string;
foundedDateLabel?: string;
leagues: { id: string }[];
};
memberCount: number;
memberCountLabel?: string;
leagueCountLabel?: string;
onUpdate: () => void;
export interface TeamHeroProps {
title: ReactNode;
description: string;
stats?: ReactNode;
actions?: ReactNode;
sideContent?: ReactNode;
}
export function TeamHero({ team, memberCount, memberCountLabel, leagueCountLabel, onUpdate }: TeamHeroProps) {
export function TeamHero({
title,
description,
stats,
actions,
sideContent
}: TeamHeroProps) {
return (
<Card>
<Group align="start" justify="between" wrap gap={6}>
<Group align="start" gap={6} wrap fullWidth>
<TeamLogo teamId={team.id} alt={team.name} size={96} />
<Box
position="relative"
backgroundColor="var(--ui-color-bg-base)"
paddingY={12}
style={{ borderBottom: '1px solid var(--ui-color-border-default)', overflow: 'hidden' }}
>
<Glow color="purple" size="xl" opacity={0.05} position="top-right" />
<Stack direction={{ base: 'col', lg: 'row' }} gap={12} align="start">
<Stack flex={1} gap={4}>
<Heading level={1} size="4xl" weight="bold">
{title}
</Heading>
<Text size="lg" variant="low" block leading="relaxed" marginBottom={4}>
{description}
</Text>
<Group direction="col" align="start" gap={2} fullWidth>
<Group gap={3}>
<Heading level={1}>{team.name}</Heading>
{team.tag && <TeamTag tag={team.tag} />}
</Group>
<Text variant="low" block marginBottom={4}>{team.description}</Text>
<StatGrid
columns={{ base: 2, md: 4 }}
variant="box"
stats={[
{
label: 'Personnel',
value: memberCountLabel || 'Unknown',
},
...(team.category ? [{
label: 'Category',
value: team.category,
intent: 'primary' as const,
}] : []),
...(team.foundedDateLabel ? [{
label: 'Founded',
value: team.foundedDateLabel,
}] : []),
...(team.leagues && team.leagues.length > 0 ? [{
label: 'Activity',
value: leagueCountLabel || 'Unknown',
}] : []),
]}
/>
</Group>
</Group>
<JoinTeamButton teamId={team.id} onUpdate={onUpdate} />
</Group>
</Card>
{stats && <Box marginBottom={4}>{stats}</Box>}
{actions && (
<Stack direction="row" gap={4} wrap>
{actions}
</Stack>
)}
</Stack>
{sideContent && (
<Box width={{ base: '100%', lg: '24rem' }} flexShrink={0}>
{sideContent}
</Box>
)}
</Stack>
</Box>
);
}

View File

@@ -7,7 +7,7 @@ import { TeamHeroStats } from '@/components/teams/TeamHeroStats';
import type { TeamSummaryViewModel } from '@/lib/view-models/TeamSummaryViewModel';
import { Button } from '@/ui/Button';
import { Icon } from '@/ui/Icon';
import { TeamHero } from '@/ui/TeamHero';
import { TeamHero } from '@/components/teams/TeamHero';
import { Text } from '@/ui/Text';
import { Stack } from '@/ui/Stack';
import {

View File

@@ -3,7 +3,7 @@
import React from 'react';
import { Plus } from 'lucide-react';
import { Button } from '@/ui/Button';
import { TeamsHeader } from '@/ui/TeamsHeader';
import { TeamsHeader } from './TeamsHeader';
interface TeamsDirectoryHeaderProps {
onCreateTeam: () => void;
@@ -12,7 +12,7 @@ interface TeamsDirectoryHeaderProps {
export function TeamsDirectoryHeader({ onCreateTeam }: TeamsDirectoryHeaderProps) {
return (
<TeamsHeader
title="Directory"
title="Teams"
subtitle="Professional Racing Rosters"
action={
<Button

View File

@@ -1,6 +1,6 @@
import React, { ReactNode } from 'react';
import { Heading } from './Heading';
import { Text } from './Text';
import { Heading } from '@/ui/Heading';
import { Text } from '@/ui/Text';
interface TeamsHeaderProps {
title: string;

View File

@@ -13,10 +13,10 @@ export class TeamsViewDataBuilder {
const teams: TeamSummaryData[] = apiDto.teams.map((team: TeamListItemDTO): TeamSummaryData => ({
teamId: team.id,
teamName: team.name,
leagueName: team.leagues[0] || '',
memberCount: team.memberCount,
logoUrl: team.logoUrl,
ratingLabel: RatingDisplay.format(team.rating),
ratingValue: team.rating || 0,
winsLabel: NumberDisplay.format(team.totalWins || 0),
racesLabel: NumberDisplay.format(team.totalRaces || 0),
region: team.region,
@@ -24,6 +24,7 @@ export class TeamsViewDataBuilder {
category: team.category,
performanceLevel: team.performanceLevel,
description: team.description,
countryCode: team.region, // Assuming region contains country code for now
}));
return { teams };

View File

@@ -8,10 +8,10 @@ import { ViewData } from '../contracts/view-data/ViewData';
export interface TeamSummaryData {
teamId: string;
teamName: string;
leagueName: string;
memberCount: number;
logoUrl?: string;
ratingLabel: string;
ratingValue: number;
winsLabel: string;
racesLabel: string;
region?: string;
@@ -19,6 +19,7 @@ export interface TeamSummaryData {
category?: string;
performanceLevel?: string;
description?: string;
countryCode?: string;
}
export interface TeamsViewData extends ViewData {

View File

@@ -12,6 +12,8 @@ import { EmptyState } from '@/ui/EmptyState';
import { Container } from '@/ui/Container';
import { Heading } from '@/ui/Heading';
import { Text } from '@/ui/Text';
import { Box } from '@/ui/Box';
import { Carousel } from '@/components/shared/Carousel';
interface TeamsTemplateProps extends TemplateProps<TeamsViewData> {
searchQuery: string;
@@ -34,7 +36,6 @@ export function TeamsTemplate({
const filteredTeams = useMemo(() => {
return teams.filter(team =>
team.teamName.toLowerCase().includes(searchQuery.toLowerCase()) ||
(team.leagueName && team.leagueName.toLowerCase().includes(searchQuery.toLowerCase())) ||
(team.region && team.region.toLowerCase().includes(searchQuery.toLowerCase()))
);
}, [teams, searchQuery]);
@@ -45,7 +46,7 @@ export function TeamsTemplate({
}
const topTeams = [...teams]
.sort((a, b) => parseFloat(b.ratingLabel) - parseFloat(a.ratingLabel))
.sort((a, b) => b.ratingValue - a.ratingValue)
.slice(0, 3);
const recruitingTeams = teams.filter(t => t.isRecruiting && !topTeams.find(top => top.teamId === t.teamId));
@@ -68,35 +69,29 @@ export function TeamsTemplate({
<Container size="xl">
<TeamsDirectoryHeader onCreateTeam={onCreateTeam} />
<TeamSearchBar
searchQuery={searchQuery}
onSearchChange={onSearchChange}
/>
<Box marginBottom={12}>
<TeamSearchBar
searchQuery={searchQuery}
onSearchChange={onSearchChange}
/>
</Box>
{clusters.length > 0 ? (
<div className="space-y-16">
<div className="space-y-20">
{clusters.map((cluster) => (
<div key={cluster.title} className="space-y-8">
<div className="flex items-center gap-4">
<Heading level={2} weight="bold" uppercase tracking-widest size="sm">
{cluster.title}
</Heading>
<div className="flex-1 h-px bg-[var(--ui-color-border-muted)]" />
<div className="px-3 py-1 border border-[var(--ui-color-border-muted)]">
<Text size="xs" mono variant="low">{cluster.teams.length}</Text>
</div>
</div>
<TeamGrid>
{cluster.teams.map((team) => (
<TeamCard
key={team.teamId}
team={team}
onClick={(id) => onTeamClick?.(id)}
/>
))}
</TeamGrid>
</div>
<Carousel
key={cluster.title}
title={cluster.title}
count={cluster.teams.length}
>
{cluster.teams.map((team) => (
<TeamCard
key={team.teamId}
team={team}
onClick={(id) => onTeamClick?.(id)}
/>
))}
</Carousel>
))}
</div>
) : (

View File

@@ -32,6 +32,7 @@ export interface CardProps {
group?: boolean | any;
w?: string | any;
justifyContent?: string | any;
fullHeight?: boolean | any;
}
/**
@@ -65,6 +66,7 @@ export const Card = forwardRef<HTMLDivElement, CardProps>(({
gap,
py,
backgroundColor,
fullHeight,
}, ref) => {
const variantClasses = {
default: 'bg-[var(--ui-color-bg-surface)] border-[var(--ui-color-border-default)] shadow-sm',
@@ -112,6 +114,7 @@ export const Card = forwardRef<HTMLDivElement, CardProps>(({
...(alignItems ? { alignItems } : {}),
...(gap !== undefined ? { gap: `${gap * 0.25}rem` } : {}),
...(border === false ? { border: 'none' } : {}),
...(fullHeight ? { height: '100%' } : {}),
};
return (
@@ -129,7 +132,7 @@ export const Card = forwardRef<HTMLDivElement, CardProps>(({
</div>
)}
<div className={typeof padding === 'number' || p !== undefined ? '' : getPaddingClass(padding)}>
<div className={`${typeof padding === 'number' || p !== undefined ? '' : getPaddingClass(padding)} ${fullHeight ? 'h-full flex flex-col' : ''}`}>
{children}
</div>

View File

@@ -24,29 +24,39 @@ export const Logo = ({
variant = 'muted',
border = true,
}: LogoProps) => {
const finalSize = typeof size === 'number' ? `${size}px` : size;
return (
<Surface
variant={variant}
rounded={rounded}
border={border}
style={{
width: size,
height: size,
width: finalSize,
height: finalSize,
minWidth: finalSize,
minHeight: finalSize,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
overflow: 'hidden',
flexShrink: 0
flexShrink: 0,
position: 'relative',
backgroundColor: 'var(--ui-color-bg-base)',
}}
>
{src ? (
<Image
src={src}
alt={alt}
style={{ width: '100%', height: '100%', objectFit: 'contain', padding: '10%' }}
style={{
width: '100%',
height: '100%',
objectFit: 'cover',
}}
/>
) : icon ? (
<Icon icon={icon} size={typeof size === 'number' ? (size > 32 ? 5 : 4) : 5} intent="low" />
<Icon icon={icon} size={typeof size === 'number' ? (size > 32 ? 6 : 4) : 6} intent="low" />
) : null}
</Surface>
);

View File

@@ -87,6 +87,7 @@ export const Surface = forwardRef(<T extends ElementType = 'div'>(
};
const style: React.CSSProperties = {
...(props.style || {}),
...variantStyles[variant],
borderRadius: rounded !== 'none' ? `var(--ui-radius-${String(rounded)})` : undefined,
boxShadow: shadow !== 'none' ? `var(--ui-shadow-${String(shadow)})` : undefined,

View File

@@ -1,115 +0,0 @@
import { ChevronRight, Globe, Users, Zap } from 'lucide-react';
import { ReactNode } from 'react';
import { Card } from './Card';
import { Heading } from './Heading';
import { Icon } from './Icon';
import { Text } from './Text';
import { Badge } from './Badge';
export interface TeamCardProps {
name: string;
leagueName?: string;
logo?: ReactNode;
memberCount: number;
rating?: string;
wins?: string;
races?: string;
region?: string;
isRecruiting?: boolean;
performanceLevel?: string;
onClick?: () => void;
description?: string;
}
export const TeamCard = ({
name,
leagueName,
logo,
memberCount,
rating,
wins,
races,
region = 'EU',
isRecruiting,
performanceLevel,
description,
onClick
}: TeamCardProps) => {
return (
<Card
variant="precision"
padding="none"
onClick={onClick}
transition
>
<div className="p-6 space-y-6">
{/* Header: Logo and Identity */}
<div className="flex items-center gap-4">
<div className="w-12 h-12 bg-[var(--ui-color-bg-base)] flex items-center justify-center overflow-hidden border border-[var(--ui-color-border-muted)]">
{logo || <Icon icon={Users} size={5} intent="low" />}
</div>
<div className="flex-1 min-w-0">
<div className="flex items-center justify-between gap-2">
<Heading level={4} weight="bold" truncate uppercase>{name}</Heading>
{isRecruiting && (
<Badge variant="success" size="xs">RECRUITING</Badge>
)}
</div>
<div className="flex items-center gap-2">
{leagueName && <Text size="xs" variant="low" truncate uppercase mono>{leagueName}</Text>}
{performanceLevel && (
<div className="flex items-center gap-1">
<Icon icon={Zap} size={3} intent="telemetry" />
<Text size="xs" variant="telemetry" mono uppercase>{performanceLevel}</Text>
</div>
)}
</div>
</div>
</div>
{/* Technical Stats Grid - Engineered Look */}
{(rating || wins || races) && (
<div className="grid grid-cols-3 gap-px bg-[var(--ui-color-border-muted)] border border-[var(--ui-color-border-muted)]">
<div className="p-3 bg-[var(--ui-color-bg-surface)] text-center">
<Text size="xs" variant="low" uppercase block mb={1} mono>Rating</Text>
<Text size="md" weight="bold" mono variant="primary">{rating || '-'}</Text>
</div>
<div className="p-3 bg-[var(--ui-color-bg-surface)] text-center">
<Text size="xs" variant="low" uppercase block mb={1} mono>Wins</Text>
<Text size="md" weight="bold" mono variant="telemetry">{wins || '-'}</Text>
</div>
<div className="p-3 bg-[var(--ui-color-bg-surface)] text-center">
<Text size="xs" variant="low" uppercase block mb={1} mono>Races</Text>
<Text size="md" weight="bold" mono variant="high">{races || '-'}</Text>
</div>
</div>
)}
{description && (
<Text size="xs" variant="low" lineClamp={2} block leading="relaxed">
{description}
</Text>
)}
{/* Footer: Metadata */}
<div className="flex items-center justify-between pt-4 border-t border-[var(--ui-color-border-muted)]">
<div className="flex items-center gap-4">
<div className="flex items-center gap-1.5">
<Icon icon={Users} size={3} intent="low" />
<Text size="xs" variant="low" mono>{memberCount}</Text>
</div>
<div className="flex items-center gap-1.5">
<Icon icon={Globe} size={3} intent="low" />
<Text size="xs" variant="low" mono>{region}</Text>
</div>
</div>
<div className="flex items-center gap-1 text-[var(--ui-color-intent-primary)]">
<Text size="xs" weight="bold" uppercase mono>Details</Text>
<Icon icon={ChevronRight} size={3} />
</div>
</div>
</div>
</Card>
);
};

View File

@@ -1,57 +0,0 @@
import { ReactNode } from 'react';
import { Box } from './Box';
import { Glow } from './Glow';
import { Heading } from './Heading';
import { Text } from './Text';
export interface TeamHeroProps {
title: ReactNode;
description: string;
stats?: ReactNode;
actions?: ReactNode;
sideContent?: ReactNode;
}
export const TeamHero = ({
title,
description,
stats,
actions,
sideContent
}: TeamHeroProps) => {
return (
<Box
position="relative"
bg="var(--ui-color-bg-base)"
paddingY={12}
style={{ borderBottom: '1px solid var(--ui-color-border-default)', overflow: 'hidden' }}
>
<Glow color="purple" size="xl" opacity={0.05} position="top-right" />
<Box display="flex" flexDirection={{ base: 'col', lg: 'row' }} gap={12} alignItems="start">
<Box flex={1}>
<Heading level={1} size="4xl" weight="bold" marginBottom={4}>
{title}
</Heading>
<Text size="lg" variant="low" block marginBottom={8} leading="relaxed">
{description}
</Text>
{stats && <Box marginBottom={8}>{stats}</Box>}
{actions && (
<Box display="flex" gap={4} flexWrap="wrap">
{actions}
</Box>
)}
</Box>
{sideContent && (
<Box width={{ base: '100%', lg: '24rem' }} flexShrink={0}>
{sideContent}
</Box>
)}
</Box>
</Box>
);
};

View File

@@ -8,190 +8,39 @@ import { faker } from '@faker-js/faker';
*/
export class MediaGenerationService {
/**
* Generates a deterministic SVG avatar for a driver
*/
generateDriverAvatar(driverId: string): string {
faker.seed(this.hashCode(driverId));
const firstName = faker.person.firstName();
const lastName = faker.person.lastName();
const initials = ((firstName?.[0] || 'D') + (lastName?.[0] || 'R')).toUpperCase();
const primaryColor = faker.color.rgb({ format: 'hex' });
const secondaryColor = faker.color.rgb({ format: 'hex' });
const patterns = ['gradient', 'stripes', 'circles', 'diamond'];
const pattern = faker.helpers.arrayElement(patterns);
let patternSvg = '';
switch (pattern) {
case 'gradient':
patternSvg = `
<defs>
<linearGradient id="grad-${driverId}" x1="0%" y1="0%" x2="100%" y2="100%">
<stop offset="0%" style="stop-color:${primaryColor};stop-opacity:1" />
<stop offset="100%" style="stop-color:${secondaryColor};stop-opacity:1" />
</linearGradient>
</defs>
<rect width="100" height="100" rx="50" fill="url(#grad-${driverId})"/>
`;
break;
case 'stripes':
patternSvg = `
<rect width="100" height="100" rx="50" fill="${primaryColor}"/>
<rect x="0" y="0" width="50" height="100" rx="50" fill="${secondaryColor}" opacity="0.3"/>
`;
break;
case 'circles':
patternSvg = `
<rect width="100" height="100" rx="50" fill="${primaryColor}"/>
<circle cx="30" cy="30" r="15" fill="${secondaryColor}" opacity="0.4"/>
<circle cx="70" cy="70" r="10" fill="${secondaryColor}" opacity="0.4"/>
`;
break;
case 'diamond':
patternSvg = `
<rect width="100" height="100" rx="50" fill="${primaryColor}"/>
<path d="M50 20 L80 50 L50 80 L20 50 Z" fill="${secondaryColor}" opacity="0.3"/>
`;
break;
}
return `
<svg width="100" height="100" viewBox="0 0 100 100" xmlns="http://www.w3.org/2000/svg">
${patternSvg}
<text x="50" y="58" font-family="Arial, sans-serif" font-size="36" font-weight="bold" fill="white" text-anchor="middle" letter-spacing="2">${initials}</text>
</svg>
`;
}
/**
* Generates a deterministic SVG logo for a team
* Now includes team name initials for better branding
* Generates a deterministic logo URL for a team
* Uses a real placeholder image service for high-quality racing logos.
*/
generateTeamLogo(teamId: string): string {
faker.seed(this.hashCode(teamId));
const primaryColor = faker.color.rgb({ format: 'hex' });
const secondaryColor = faker.color.rgb({ format: 'hex' });
// Generate deterministic initials from seeded faker data
// This creates consistent initials for the same teamId
const adjective = faker.company.buzzAdjective();
const noun = faker.company.catchPhraseNoun();
const initials = ((adjective?.[0] || 'T') + (noun?.[0] || 'M')).toUpperCase();
const shapes = ['circle', 'square', 'triangle', 'hexagon'];
const shape = faker.helpers.arrayElement(shapes);
let shapeSvg = '';
switch (shape) {
case 'circle':
shapeSvg = `<circle cx="20" cy="16" r="10" fill="${primaryColor}" opacity="0.8"/>`;
break;
case 'square':
shapeSvg = `<rect x="10" y="6" width="20" height="20" rx="4" fill="${primaryColor}" opacity="0.8"/>`;
break;
case 'triangle':
shapeSvg = `<path d="M20 6 L30 26 L10 26 Z" fill="${primaryColor}" opacity="0.8"/>`;
break;
case 'hexagon':
shapeSvg = `<path d="M20 6 L28 10 L28 22 L20 26 L12 22 L12 10 Z" fill="${primaryColor}" opacity="0.8"/>`;
break;
}
return `
<svg width="120" height="40" viewBox="0 0 120 40" xmlns="http://www.w3.org/2000/svg">
<defs>
<linearGradient id="grad-${teamId}" x1="0%" y1="0%" x2="100%" y2="100%">
<stop offset="0%" style="stop-color:${primaryColor};stop-opacity:1" />
<stop offset="100%" style="stop-color:${secondaryColor};stop-opacity:1" />
</linearGradient>
</defs>
<rect width="120" height="40" rx="8" fill="#1e293b"/>
${shapeSvg}
<rect x="40" y="12" width="4" height="16" rx="1" fill="${secondaryColor}" opacity="0.6"/>
<rect x="48" y="8" width="4" height="24" rx="1" fill="${primaryColor}" opacity="0.8"/>
<rect x="56" y="12" width="4" height="16" rx="1" fill="${secondaryColor}" opacity="0.6"/>
<text x="85" y="24" font-family="Arial, sans-serif" font-size="12" font-weight="bold" fill="white" text-anchor="middle">${initials}</text>
</svg>
`;
return `https://picsum.photos/seed/${teamId}/200/200`;
}
/**
* Generates a deterministic SVG logo for a league
* Updated to use the same faker style as team logos for consistency
* Generates a deterministic logo URL for a league
* Uses a real placeholder image service for high-quality league logos.
*/
generateLeagueLogo(leagueId: string): string {
faker.seed(this.hashCode(leagueId));
const primaryColor = faker.color.rgb({ format: 'hex' });
const secondaryColor = faker.color.rgb({ format: 'hex' });
// Generate deterministic initials from seeded faker data
// This creates consistent initials for the same leagueId
const adjective = faker.company.buzzAdjective();
const noun = faker.company.catchPhraseNoun();
const initials = ((adjective?.[0] || 'L') + (noun?.[0] || 'G')).toUpperCase();
const shapes = ['circle', 'square', 'triangle', 'hexagon'];
const shape = faker.helpers.arrayElement(shapes);
let shapeSvg = '';
switch (shape) {
case 'circle':
shapeSvg = `<circle cx="20" cy="16" r="10" fill="${primaryColor}" opacity="0.8"/>`;
break;
case 'square':
shapeSvg = `<rect x="10" y="6" width="20" height="20" rx="4" fill="${primaryColor}" opacity="0.8"/>`;
break;
case 'triangle':
shapeSvg = `<path d="M20 6 L30 26 L10 26 Z" fill="${primaryColor}" opacity="0.8"/>`;
break;
case 'hexagon':
shapeSvg = `<path d="M20 6 L28 10 L28 22 L20 26 L12 22 L12 10 Z" fill="${primaryColor}" opacity="0.8"/>`;
break;
}
return `
<svg width="120" height="40" viewBox="0 0 120 40" xmlns="http://www.w3.org/2000/svg">
<defs>
<linearGradient id="grad-${leagueId}" x1="0%" y1="0%" x2="100%" y2="100%">
<stop offset="0%" style="stop-color:${primaryColor};stop-opacity:1" />
<stop offset="100%" style="stop-color:${secondaryColor};stop-opacity:1" />
</linearGradient>
</defs>
<rect width="120" height="40" rx="8" fill="#1e293b"/>
${shapeSvg}
<rect x="40" y="12" width="4" height="16" rx="1" fill="${secondaryColor}" opacity="0.6"/>
<rect x="48" y="8" width="4" height="24" rx="1" fill="${primaryColor}" opacity="0.8"/>
<rect x="56" y="12" width="4" height="16" rx="1" fill="${secondaryColor}" opacity="0.6"/>
<text x="85" y="24" font-family="Arial, sans-serif" font-size="12" font-weight="bold" fill="white" text-anchor="middle">${initials}</text>
</svg>
`;
return `https://picsum.photos/seed/l-${leagueId}/200/200`;
}
/**
* Generates a deterministic SVG cover for a league
* Generates a deterministic avatar URL for a driver
* Uses a real placeholder image service for high-quality driver avatars.
*/
generateDriverAvatar(driverId: string): string {
faker.seed(this.hashCode(driverId));
return `https://i.pravatar.cc/150?u=${driverId}`;
}
/**
* Generates a deterministic cover URL for a league
* Uses a real placeholder image service for high-quality league covers.
*/
generateLeagueCover(leagueId: string): string {
faker.seed(this.hashCode(leagueId));
const primaryColor = faker.color.rgb({ format: 'hex' });
const secondaryColor = faker.color.rgb({ format: 'hex' });
return `
<svg width="800" height="200" viewBox="0 0 800 200" xmlns="http://www.w3.org/2000/svg">
<defs>
<linearGradient id="grad-${leagueId}" x1="0%" y1="0%" x2="100%" y2="0%">
<stop offset="0%" style="stop-color:${primaryColor};stop-opacity:1" />
<stop offset="100%" style="stop-color:${secondaryColor};stop-opacity:1" />
</linearGradient>
</defs>
<rect width="800" height="200" fill="url(#grad-${leagueId})"/>
<rect width="800" height="200" fill="black" opacity="0.2"/>
</svg>
`;
return `https://picsum.photos/seed/c-${leagueId}/800/200`;
}
/**