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 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({ return Team.create({
id: teamId, id: teamId,
name: faker.company.name() + ' Racing', name: name,
tag: faker.string.alpha({ length: 4, casing: 'upper' }), tag: name.split(' ').map(w => w[0]).join('').toUpperCase().substring(0, 4),
description: faker.lorem.sentences(2), description: faker.lorem.sentences(2),
ownerId: owner.id, ownerId: owner.id,
leagues: teamLeagues, leagues: teamLeagues,
@@ -203,8 +221,8 @@ export class RacingTeamFactory {
generateTeamStats(teams: Team[]): Map<string, TeamStats> { generateTeamStats(teams: Team[]): Map<string, TeamStats> {
const statsMap = new Map<string, TeamStats>(); const statsMap = new Map<string, TeamStats>();
// Available regions // Available regions (using country codes for flags)
const regions = ['Europe', 'North America', 'South America', 'Asia', 'Oceania', 'Africa']; const regions = ['DE', 'GB', 'US', 'FR', 'IT', 'ES', 'BR', 'JP', 'AU', 'NL', 'BE', 'AT', 'CH', 'SE', 'NO', 'FI', 'DK', 'PL', 'CZ', 'HU'];
// Available languages // Available languages
const allLanguages = ['English', 'German', 'French', 'Spanish', 'Italian', 'Portuguese', 'Japanese', 'Korean', 'Russian', 'Chinese']; const allLanguages = ['English', 'German', 'French', 'Spanish', 'Italian', 'Portuguese', 'Japanese', 'Korean', 'Russian', 'Chinese'];

View File

@@ -86,14 +86,18 @@ export class MediaController {
@Res() res: Response, @Res() res: Response,
): Promise<void> { ): Promise<void> {
this.logger.debug('[MediaController] Generating team logo', { teamId }); this.logger.debug('[MediaController] Generating team logo', { teamId });
const svg = this.mediaGenerationService.generateTeamLogo(teamId); const url = this.mediaGenerationService.generateTeamLogo(teamId);
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('Content-Type', 'image/svg+xml; charset=utf-8');
res.setHeader('Cache-Control', 'public, max-age=86400'); 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() @Public()
@@ -105,14 +109,18 @@ export class MediaController {
@Res() res: Response, @Res() res: Response,
): Promise<void> { ): Promise<void> {
this.logger.debug('[MediaController] Generating league logo', { leagueId }); this.logger.debug('[MediaController] Generating league logo', { leagueId });
const svg = this.mediaGenerationService.generateLeagueLogo(leagueId); const url = this.mediaGenerationService.generateLeagueLogo(leagueId);
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('Content-Type', 'image/svg+xml; charset=utf-8');
res.setHeader('Cache-Control', 'public, max-age=86400'); 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() @Public()
@@ -124,14 +132,18 @@ export class MediaController {
@Res() res: Response, @Res() res: Response,
): Promise<void> { ): Promise<void> {
this.logger.debug('[MediaController] Generating league cover', { leagueId }); this.logger.debug('[MediaController] Generating league cover', { leagueId });
const svg = this.mediaGenerationService.generateLeagueCover(leagueId); const url = this.mediaGenerationService.generateLeagueCover(leagueId);
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('Content-Type', 'image/svg+xml; charset=utf-8');
res.setHeader('Cache-Control', 'public, max-age=86400'); 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() @Public()
@@ -143,14 +155,18 @@ export class MediaController {
@Res() res: Response, @Res() res: Response,
): Promise<void> { ): Promise<void> {
this.logger.debug('[MediaController] Generating driver avatar', { driverId }); this.logger.debug('[MediaController] Generating driver avatar', { driverId });
const svg = this.mediaGenerationService.generateDriverAvatar(driverId); const url = this.mediaGenerationService.generateDriverAvatar(driverId);
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('Content-Type', 'image/svg+xml; charset=utf-8');
res.setHeader('Cache-Control', 'public, max-age=86400'); 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() @Public()
@@ -223,27 +239,30 @@ export class MediaController {
@Res() res: Response, @Res() res: Response,
): Promise<void> { ): Promise<void> {
this.logger.debug('[MediaController] Generating media', { type, id }); this.logger.debug('[MediaController] Generating media', { type, id });
let svg: string; let url: string;
// Route to appropriate generator based on type // Route to appropriate generator based on type
if (type === 'team') { if (type === 'team') {
svg = this.mediaGenerationService.generateTeamLogo(id); url = this.mediaGenerationService.generateTeamLogo(id);
} else if (type === 'league') { } else if (type === 'league') {
svg = this.mediaGenerationService.generateLeagueLogo(id); url = this.mediaGenerationService.generateLeagueLogo(id);
} else if (type === 'driver') { } else if (type === 'driver') {
svg = this.mediaGenerationService.generateDriverAvatar(id); url = this.mediaGenerationService.generateDriverAvatar(id);
} else { } else {
// Fallback: generate a generic logo // 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('Content-Type', 'image/svg+xml; charset=utf-8');
res.setHeader('Cache-Control', 'public, max-age=31536000, immutable'); 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() @Public()

View File

@@ -190,6 +190,15 @@
animation: fade-in-up 0.4s cubic-bezier(0.16, 1, 0.3, 1) forwards; 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) { @media (prefers-reduced-motion: reduce) {
.animate-fade-in-up { .animate-fade-in-up {
animation: none; 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'; 'use client';
import React from 'react'; 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 { TeamSummaryData } from '@/lib/view-data/TeamsViewData';
import { Image } from '@/ui/Image';
interface TeamCardProps { interface TeamCardProps {
team?: TeamSummaryData; team?: TeamSummaryData;
// Compatibility props // Compatibility props
name?: string; name?: string;
leagueName?: string;
logo?: string; logo?: string;
memberCount?: number; memberCount?: number;
ratingLabel?: string; ratingLabel?: string;
@@ -25,7 +33,6 @@ interface TeamCardProps {
export function TeamCard({ export function TeamCard({
team, team,
name, name,
leagueName,
logo, logo,
memberCount, memberCount,
ratingLabel, ratingLabel,
@@ -40,7 +47,6 @@ export function TeamCard({
const data = team || { const data = team || {
teamId: '', teamId: '',
teamName: name || '', teamName: name || '',
leagueName: leagueName || '',
memberCount: memberCount || 0, memberCount: memberCount || 0,
logoUrl: logo, logoUrl: logo,
ratingLabel: ratingLabel || '-', ratingLabel: ratingLabel || '-',
@@ -50,22 +56,98 @@ export function TeamCard({
isRecruiting: isRecruiting || false, isRecruiting: isRecruiting || false,
performanceLevel: performanceLevel, performanceLevel: performanceLevel,
description: description, description: description,
countryCode: region,
}; };
return ( return (
<UiTeamCard <Card
name={data.teamName} variant="precision"
leagueName={data.leagueName} padding="none"
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}
onClick={() => onClick?.(data.teamId)} 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'; 'use client';
import { JoinTeamButton } from '@/components/teams/JoinTeamButton'; import React, { ReactNode } from 'react';
import { TeamLogo } from '@/components/teams/TeamLogo'; import { Glow } from '@/ui/Glow';
import { TeamTag } from '@/components/teams/TeamTag';
import { Card } from '@/ui/Card';
import { Group } from '@/ui/Group';
import { Heading } from '@/ui/Heading'; import { Heading } from '@/ui/Heading';
import { StatGrid } from '@/ui/StatGrid';
import { Text } from '@/ui/Text'; import { Text } from '@/ui/Text';
import { Stack } from '@/ui/Stack';
import { Box } from '@/ui/Box';
interface TeamHeroProps { export interface TeamHeroProps {
team: { title: ReactNode;
id: string; description: string;
name: string; stats?: ReactNode;
tag: string | null; actions?: ReactNode;
description?: string; sideContent?: ReactNode;
category?: string | null;
createdAt?: string;
foundedDateLabel?: string;
leagues: { id: string }[];
};
memberCount: number;
memberCountLabel?: string;
leagueCountLabel?: string;
onUpdate: () => void;
} }
export function TeamHero({ team, memberCount, memberCountLabel, leagueCountLabel, onUpdate }: TeamHeroProps) { export function TeamHero({
title,
description,
stats,
actions,
sideContent
}: TeamHeroProps) {
return ( return (
<Card> <Box
<Group align="start" justify="between" wrap gap={6}> position="relative"
<Group align="start" gap={6} wrap fullWidth> backgroundColor="var(--ui-color-bg-base)"
<TeamLogo teamId={team.id} alt={team.name} size={96} /> 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> {stats && <Box marginBottom={4}>{stats}</Box>}
<Group gap={3}>
<Heading level={1}>{team.name}</Heading> {actions && (
{team.tag && <TeamTag tag={team.tag} />} <Stack direction="row" gap={4} wrap>
</Group> {actions}
</Stack>
<Text variant="low" block marginBottom={4}>{team.description}</Text> )}
</Stack>
<StatGrid
columns={{ base: 2, md: 4 }} {sideContent && (
variant="box" <Box width={{ base: '100%', lg: '24rem' }} flexShrink={0}>
stats={[ {sideContent}
{ </Box>
label: 'Personnel', )}
value: memberCountLabel || 'Unknown', </Stack>
}, </Box>
...(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>
); );
} }

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -87,6 +87,7 @@ export const Surface = forwardRef(<T extends ElementType = 'div'>(
}; };
const style: React.CSSProperties = { const style: React.CSSProperties = {
...(props.style || {}),
...variantStyles[variant], ...variantStyles[variant],
borderRadius: rounded !== 'none' ? `var(--ui-radius-${String(rounded)})` : undefined, borderRadius: rounded !== 'none' ? `var(--ui-radius-${String(rounded)})` : undefined,
boxShadow: shadow !== 'none' ? `var(--ui-shadow-${String(shadow)})` : 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 { export class MediaGenerationService {
/** /**
* Generates a deterministic SVG avatar for a driver * Generates a deterministic logo URL for a team
*/ * Uses a real placeholder image service for high-quality racing logos.
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
*/ */
generateTeamLogo(teamId: string): string { generateTeamLogo(teamId: string): string {
faker.seed(this.hashCode(teamId)); faker.seed(this.hashCode(teamId));
return `https://picsum.photos/seed/${teamId}/200/200`;
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>
`;
} }
/** /**
* Generates a deterministic SVG logo for a league * Generates a deterministic logo URL for a league
* Updated to use the same faker style as team logos for consistency * Uses a real placeholder image service for high-quality league logos.
*/ */
generateLeagueLogo(leagueId: string): string { generateLeagueLogo(leagueId: string): string {
faker.seed(this.hashCode(leagueId)); faker.seed(this.hashCode(leagueId));
return `https://picsum.photos/seed/l-${leagueId}/200/200`;
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>
`;
} }
/** /**
* 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 { generateLeagueCover(leagueId: string): string {
faker.seed(this.hashCode(leagueId)); faker.seed(this.hashCode(leagueId));
return `https://picsum.photos/seed/c-${leagueId}/800/200`;
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>
`;
} }
/** /**