website refactor

This commit is contained in:
2026-01-15 19:55:46 +01:00
parent 5ef149b782
commit ce7be39155
154 changed files with 436 additions and 356 deletions

View File

@@ -0,0 +1,56 @@
import { routes } from '@/lib/routing/RouteConfig';
import { Card } from '@/ui/Card';
import { ChampionshipStandingsList } from '@/components/leagues/ChampionshipStandingsList';
import { Heading } from '@/ui/Heading';
import { Icon } from '@/ui/Icon';
import { Link } from '@/ui/Link';
import { Stack } from '@/ui/Stack';
import { SummaryItem } from '@/ui/SummaryItem';
import { Text } from '@/ui/Text';
import { Award, ChevronRight } from 'lucide-react';
interface Standing {
leagueId: string;
leagueName: string;
position: string;
points: string;
totalDrivers: string;
}
interface ChampionshipStandingsProps {
standings: Standing[];
}
export function ChampionshipStandings({ standings }: ChampionshipStandingsProps) {
return (
<Card>
<Stack direction="row" align="center" justify="between" mb={4}>
<Heading level={2} icon={<Icon icon={Award} size={5} color="var(--warning-amber)" />}>
Your Championship Standings
</Heading>
<Link href={routes.protected.profileLeagues} variant="primary">
<Stack direction="row" align="center" gap={1}>
<Text size="sm">View all</Text>
<Icon icon={ChevronRight} size={4} />
</Stack>
</Link>
</Stack>
<ChampionshipStandingsList>
{standings.map((summary) => (
<SummaryItem
key={summary.leagueId}
title={summary.leagueName}
subtitle={`Position ${summary.position}${summary.points} points`}
rightContent={
<Text size="xs" color="text-gray-400">
{summary.totalDrivers} drivers
</Text>
}
/>
))}
</ChampionshipStandingsList>
</Card>
);
}

View File

@@ -0,0 +1,14 @@
import React, { ReactNode } from 'react';
import { Stack } from '@/ui/Stack';
interface ChampionshipStandingsListProps {
children: ReactNode;
}
export function ChampionshipStandingsList({ children }: ChampionshipStandingsListProps) {
return (
<Stack gap={3}>
{children}
</Stack>
);
}

View File

@@ -1,6 +1,6 @@
import { Trophy, Sparkles, LucideIcon } from 'lucide-react';
import { Card } from '@/ui/Card';
import { EmptyState as UiEmptyState } from '@/ui/EmptyState';
import { EmptyState as UiEmptyState } from '@/components/shared/state/EmptyState';
interface EmptyStateProps {
title: string;

View File

@@ -1,7 +1,7 @@
import React, { useMemo } from 'react';
import { Calendar, UserPlus, UserMinus, Shield, Flag, AlertTriangle } from 'lucide-react';
import { useLeagueRaces } from "@/hooks/league/useLeagueRaces";
import { ActivityFeedItem } from '@/ui/ActivityFeedItem';
import { ActivityFeedItem } from '@/components/feed/ActivityFeedItem';
import { Icon } from '@/ui/Icon';
import { Text } from '@/ui/Text';
import { Stack } from '@/ui/Stack';

View File

@@ -0,0 +1,178 @@
import React from 'react';
import {
Trophy,
Users,
Flag,
Award,
Sparkles,
} from 'lucide-react';
import type { LeagueSummaryViewModel } from '@/lib/view-models/LeagueSummaryViewModel';
import { getMediaUrl } from '@/lib/utilities/media';
import { Badge } from '@/ui/Badge';
import { LeagueCard as UiLeagueCard } from '@/ui/LeagueCard';
interface LeagueCardProps {
league: LeagueSummaryViewModel;
onClick?: () => void;
}
function getChampionshipIcon(type?: string) {
switch (type) {
case 'driver':
return Trophy;
case 'team':
return Users;
case 'nations':
return Flag;
case 'trophy':
return Award;
default:
return Trophy;
}
}
function getChampionshipLabel(type?: string) {
switch (type) {
case 'driver':
return 'Driver';
case 'team':
return 'Team';
case 'nations':
return 'Nations';
case 'trophy':
return 'Trophy';
default:
return 'Championship';
}
}
function getCategoryLabel(category?: string): string {
if (!category) return '';
switch (category) {
case 'driver':
return 'Driver';
case 'team':
return 'Team';
case 'nations':
return 'Nations';
case 'trophy':
return 'Trophy';
case 'endurance':
return 'Endurance';
case 'sprint':
return 'Sprint';
default:
return category.charAt(0).toUpperCase() + category.slice(1);
}
}
function getCategoryVariant(category?: string): 'default' | 'primary' | 'success' | 'warning' | 'danger' | 'info' {
if (!category) return 'default';
switch (category) {
case 'driver':
return 'primary';
case 'team':
return 'info';
case 'nations':
return 'success';
case 'trophy':
return 'warning';
case 'endurance':
return 'warning';
case 'sprint':
return 'danger';
default:
return 'default';
}
}
function getGameVariant(gameId?: string): 'default' | 'primary' | 'success' | 'warning' | 'danger' | 'info' {
switch (gameId) {
case 'iracing':
return 'warning';
case 'acc':
return 'success';
case 'f1-23':
case 'f1-24':
return 'danger';
default:
return 'primary';
}
}
function isNewLeague(createdAt: string | Date): boolean {
const oneWeekAgo = new Date();
oneWeekAgo.setDate(oneWeekAgo.getDate() - 7);
return new Date(createdAt) > oneWeekAgo;
}
export function LeagueCard({ league, onClick }: LeagueCardProps) {
const coverUrl = getMediaUrl('league-cover', league.id);
const logoUrl = league.logoUrl;
const ChampionshipIcon = getChampionshipIcon(league.scoring?.primaryChampionshipType);
const championshipLabel = getChampionshipLabel(league.scoring?.primaryChampionshipType);
const gameVariant = getGameVariant(league.scoring?.gameId);
const isNew = isNewLeague(league.createdAt);
const isTeamLeague = league.maxTeams && league.maxTeams > 0;
const categoryLabel = getCategoryLabel(league.category);
const categoryVariant = getCategoryVariant(league.category);
const usedSlots = isTeamLeague ? (league.usedTeamSlots ?? 0) : (league.usedDriverSlots ?? 0);
const maxSlots = isTeamLeague ? (league.maxTeams ?? 0) : (league.maxDrivers ?? 0);
const fillPercentage = maxSlots > 0 ? (usedSlots / maxSlots) * 100 : 0;
const hasOpenSlots = maxSlots > 0 && usedSlots < maxSlots;
const getSlotLabel = () => {
if (isTeamLeague) return 'Teams';
if (league.scoring?.primaryChampionshipType === 'nations') return 'Nations';
return 'Drivers';
};
const slotLabel = getSlotLabel();
return (
<UiLeagueCard
name={league.name}
description={league.description}
coverUrl={coverUrl}
logoUrl={logoUrl || undefined}
slotLabel={slotLabel}
usedSlots={usedSlots}
maxSlots={maxSlots || '∞'}
fillPercentage={fillPercentage}
hasOpenSlots={hasOpenSlots}
openSlotsCount={maxSlots > 0 ? (maxSlots as number) - usedSlots : 0}
isTeamLeague={!!isTeamLeague}
usedDriverSlots={league.usedDriverSlots}
maxDrivers={league.maxDrivers}
timingSummary={league.timingSummary}
onClick={onClick}
badges={
<>
{isNew && (
<Badge variant="success" icon={Sparkles}>
NEW
</Badge>
)}
{league.scoring?.gameName && (
<Badge variant={gameVariant}>
{league.scoring.gameName}
</Badge>
)}
{league.category && (
<Badge variant={categoryVariant}>
{categoryLabel}
</Badge>
)}
</>
}
championshipBadge={
<Badge variant="default" icon={ChampionshipIcon}>
{championshipLabel}
</Badge>
}
/>
);
}

View File

@@ -0,0 +1,101 @@
import React, { ReactNode } from 'react';
import { TableRow, TableCell } from '@/ui/Table';
import { Box } from '@/ui/Box';
import { Text } from '@/ui/Text';
import { Badge } from '@/ui/Badge';
import { DriverIdentity } from '@/components/drivers/DriverIdentity';
import { DriverViewModel } from '@/lib/view-models/DriverViewModel';
interface LeagueMemberRowProps {
driver?: DriverViewModel;
driverId: string;
isCurrentUser: boolean;
isTopPerformer: boolean;
role: string;
roleVariant: 'default' | 'primary' | 'success' | 'warning' | 'danger' | 'info';
joinedAt: string | Date;
rating?: number | string;
rank?: number | string;
wins?: number;
actions?: ReactNode;
href: string;
meta?: string | null;
}
export function LeagueMemberRow({
driver,
driverId,
isCurrentUser,
isTopPerformer,
role,
roleVariant,
joinedAt,
rating,
rank,
wins,
actions,
href,
meta,
}: LeagueMemberRowProps) {
const roleLabel = role.charAt(0).toUpperCase() + role.slice(1);
return (
<TableRow variant={isTopPerformer ? 'highlight' : 'default'}>
<TableCell>
<Box display="flex" alignItems="center" gap={2}>
{driver ? (
<DriverIdentity
driver={driver}
href={href}
contextLabel={roleLabel}
meta={meta}
size="md"
/>
) : (
<Text color="text-white">Unknown Driver</Text>
)}
{isCurrentUser && (
<Text size="xs" color="text-gray-500">(You)</Text>
)}
{isTopPerformer && (
<Text size="xs"></Text>
)}
</Box>
</TableCell>
<TableCell>
<Text color="text-primary-blue" weight="medium">
{rating || '—'}
</Text>
</TableCell>
<TableCell>
<Text color="text-gray-300">
#{rank || '—'}
</Text>
</TableCell>
<TableCell>
<Text color="text-green-400" weight="medium">
{wins || 0}
</Text>
</TableCell>
<TableCell>
<Badge variant={roleVariant}>
{roleLabel}
</Badge>
</TableCell>
<TableCell>
<Text color="text-white" size="sm">
{new Date(joinedAt).toLocaleDateString('en-US', {
year: 'numeric',
month: 'short',
day: 'numeric',
})}
</Text>
</TableCell>
{actions && (
<TableCell textAlign="right">
{actions}
</TableCell>
)}
</TableRow>
);
}

View File

@@ -13,8 +13,8 @@ import { Text } from '@/ui/Text';
import { Select } from '@/ui/Select';
import { Button } from '@/ui/Button';
import { LeagueMemberTable } from '@/ui/LeagueMemberTable';
import { LeagueMemberRow } from '@/ui/LeagueMemberRow';
import { MinimalEmptyState } from '@/ui/EmptyState';
import { LeagueMemberRow } from '@/components/leagues/LeagueMemberRow';
import { MinimalEmptyState } from '@/components/shared/state/EmptyState';
interface LeagueMembersProps {
leagueId: string;

View File

@@ -1,5 +1,5 @@
import React, { useState } from 'react';
import { DriverSummaryPill } from '@/ui/DriverSummaryPillWrapper';
import { DriverSummaryPill } from '@/components/drivers/DriverSummaryPillWrapper';
import { Button } from '@/ui/Button';
import { UserCog } from 'lucide-react';
import { LeagueSettingsViewModel } from '@/lib/view-models/LeagueSettingsViewModel';

View File

@@ -7,7 +7,7 @@ import { useState } from 'react';
import type { LeagueScheduleRaceViewModel } from '@/lib/view-models/LeagueScheduleViewModel';
// Shared state components
import { StateContainer } from '@/ui/StateContainer';
import { StateContainer } from '@/components/shared/state/StateContainer';
import { useLeagueSchedule } from "@/hooks/league/useLeagueSchedule";
import { Calendar } from 'lucide-react';
import { Box } from '@/ui/Box';

View File

@@ -0,0 +1,28 @@
import React from 'react';
import { LeagueSummaryCard as UiLeagueSummaryCard } from '@/ui/LeagueSummaryCard';
import { routes } from '@/lib/routing/RouteConfig';
interface LeagueSummaryCardProps {
league: {
id: string;
name: string;
description?: string;
settings: {
maxDrivers: number;
qualifyingFormat: string;
};
};
}
export function LeagueSummaryCard({ league }: LeagueSummaryCardProps) {
return (
<UiLeagueSummaryCard
id={league.id}
name={league.name}
description={league.description}
maxDrivers={league.settings.maxDrivers}
qualifyingFormat={league.settings.qualifyingFormat}
href={routes.league.detail(league.id)}
/>
);
}

View File

@@ -21,7 +21,7 @@ import {
import type { LeagueConfigFormModel } from '@/lib/types/LeagueConfigFormModel';
import type { Weekday } from '@/lib/types/Weekday';
import { Input } from '@/ui/Input';
import RangeField from '@/ui/RangeField';
import { RangeField } from '@/components/shared/RangeField';
import { Box } from '@/ui/Box';
import { Text } from '@/ui/Text';
import { Stack } from '@/ui/Stack';

View File

@@ -0,0 +1,78 @@
import { DriverViewModel } from "@/lib/view-models/DriverViewModel";
import { ProtestViewModel } from "@/lib/view-models/ProtestViewModel";
import { RaceViewModel } from "@/lib/view-models/RaceViewModel";
import { Box } from "@/ui/Box";
import { Card } from "@/ui/Card";
import { ProtestListItem } from "./ProtestListItem";
import { Stack } from "@/ui/Stack";
import { Text } from "@/ui/Text";
import { Flag } from "lucide-react";
interface PendingProtestsListProps {
protests: ProtestViewModel[];
races: Record<string, RaceViewModel>;
drivers: Record<string, DriverViewModel>;
leagueId: string;
onReviewProtest: (protest: ProtestViewModel) => void;
onProtestReviewed: () => void;
}
export function PendingProtestsList({
protests,
drivers,
leagueId,
onReviewProtest,
}: PendingProtestsListProps) {
if (protests.length === 0) {
return (
<Card>
<Box p={12} textAlign="center">
<Stack align="center" gap={4}>
<Box w="16" h="16" rounded="full" bg="bg-performance-green/10" display="flex" alignItems="center" justifyContent="center">
<Flag className="h-8 w-8 text-performance-green" />
</Box>
<Box>
<Text weight="semibold" size="lg" color="text-white" block mb={2}>All Clear! 🏁</Text>
<Text size="sm" color="text-gray-400">No pending protests to review</Text>
</Box>
</Stack>
</Box>
</Card>
);
}
return (
<Stack gap={4}>
{protests.map((protest) => {
const filedAt = protest.filedAt || protest.submittedAt;
const daysSinceFiled = Math.floor((Date.now() - new Date(filedAt).getTime()) / (1000 * 60 * 60 * 24));
const isUrgent = daysSinceFiled > 2;
const protester = drivers[protest.protestingDriverId];
const accused = drivers[protest.accusedDriverId];
return (
<ProtestListItem
key={protest.id}
protesterName={protester?.name || 'Unknown'}
protesterHref={`/drivers/${protest.protestingDriverId}`}
accusedName={accused?.name || 'Unknown'}
accusedHref={`/drivers/${protest.accusedDriverId}`}
status={protest.status}
isUrgent={isUrgent}
daysOld={daysSinceFiled}
lap={protest.incident?.lap ?? 0}
filedAtLabel={new Date(filedAt).toLocaleDateString()}
description={protest.incident?.description || protest.description}
proofVideoUrl={protest.proofVideoUrl || undefined}
isAdmin={true}
onReview={() => onReviewProtest(protest)}
/>
);
})}
</Stack>
);
}

View File

@@ -0,0 +1,58 @@
import { routes } from '@/lib/routing/RouteConfig';
import { ProtestListItem } from './ProtestListItem';
interface Protest {
id: string;
status: string;
protestingDriverId: string;
accusedDriverId: string;
filedAt: string;
incident: {
lap: number;
description: string;
};
proofVideoUrl?: string;
decisionNotes?: string;
}
interface Driver {
id: string;
name: string;
}
interface ProtestCardProps {
protest: Protest;
protester?: Driver;
accused?: Driver;
isAdmin: boolean;
onReview: (id: string) => void;
formatDate: (date: string) => string;
}
export function ProtestCard({ protest, protester, accused, isAdmin, onReview, formatDate }: ProtestCardProps) {
const daysSinceFiled = Math.floor(
(Date.now() - new Date(protest.filedAt).getTime()) / (1000 * 60 * 60 * 24)
);
const isUrgent = daysSinceFiled > 2 && protest.status === 'pending';
return (
<ProtestListItem
protesterName={protester?.name || 'Unknown'}
protesterHref={routes.driver.detail(protest.protestingDriverId)}
accusedName={accused?.name || 'Unknown'}
accusedHref={routes.driver.detail(protest.accusedDriverId)}
status={protest.status}
isUrgent={isUrgent}
daysOld={daysSinceFiled}
lap={protest.incident.lap}
filedAtLabel={formatDate(protest.filedAt)}
description={protest.incident.description}
proofVideoUrl={protest.proofVideoUrl}
decisionNotes={protest.decisionNotes}
isAdmin={isAdmin}
onReview={() => onReview(protest.id)}
/>
);
}

View File

@@ -0,0 +1,118 @@
import { AlertCircle, AlertTriangle, Video } from 'lucide-react';
import { Badge } from '@/ui/Badge';
import { Box } from '@/ui/Box';
import { Button } from '@/ui/Button';
import { Card } from '@/ui/Card';
import { Icon } from '@/ui/Icon';
import { Link } from '@/ui/Link';
import { Stack } from '@/ui/Stack';
import { Text } from '@/ui/Text';
interface ProtestListItemProps {
protesterName: string;
protesterHref: string;
accusedName: string;
accusedHref: string;
status: string;
isUrgent: boolean;
daysOld: number;
lap: number;
filedAtLabel: string;
description: string;
proofVideoUrl?: string;
decisionNotes?: string;
isAdmin: boolean;
onReview?: () => void;
}
export function ProtestListItem({
protesterName,
protesterHref,
accusedName,
accusedHref,
status,
isUrgent,
daysOld,
lap,
filedAtLabel,
description,
proofVideoUrl,
decisionNotes,
isAdmin,
onReview,
}: ProtestListItemProps) {
const getStatusVariant = (s: string): any => {
switch (s) {
case 'pending':
case 'under_review': return 'warning';
case 'upheld': return 'danger';
case 'dismissed': return 'default';
case 'withdrawn': return 'primary';
default: return 'warning';
}
};
return (
<Card
borderLeft={isUrgent}
borderColor={isUrgent ? 'border-red-500' : 'border-charcoal-outline'}
style={isUrgent ? { borderLeftWidth: '4px' } : undefined}
>
<Stack direction="row" align="start" justify="between" gap={4}>
<Box flexGrow={1} minWidth="0">
<Stack direction="row" align="center" gap={2} mb={2} wrap>
<Icon icon={AlertCircle} size={4} color="rgb(156, 163, 175)" />
<Link href={protesterHref}>
<Text weight="medium" color="text-white">{protesterName}</Text>
</Link>
<Text size="sm" color="text-gray-500">vs</Text>
<Link href={accusedHref}>
<Text weight="medium" color="text-white">{accusedName}</Text>
</Link>
<Badge variant={getStatusVariant(status)}>
{status.replace('_', ' ')}
</Badge>
{isUrgent && (
<Badge variant="danger" icon={AlertTriangle}>
{daysOld}d old
</Badge>
)}
</Stack>
<Stack direction="row" align="center" gap={4} mb={2} wrap>
<Text size="sm" color="text-gray-400">Lap {lap}</Text>
<Text size="sm" color="text-gray-400"></Text>
<Text size="sm" color="text-gray-400">Filed {filedAtLabel}</Text>
{proofVideoUrl && (
<>
<Text size="sm" color="text-gray-400"></Text>
<Link href={proofVideoUrl} target="_blank">
<Icon icon={Video} size={3.5} mr={1.5} />
<Text size="sm">Video Evidence</Text>
</Link>
</>
)}
</Stack>
<Text size="sm" color="text-gray-300" block>{description}</Text>
{decisionNotes && (
<Box mt={4} p={3} bg="bg-charcoal-outline/30" rounded="lg" border borderColor="border-charcoal-outline/50">
<Text size="xs" color="text-gray-500" uppercase letterSpacing="0.05em" block mb={1}>Steward Decision</Text>
<Text size="sm" color="text-gray-300">{decisionNotes}</Text>
</Box>
)}
</Box>
{isAdmin && status === 'pending' && onReview && (
<Button
variant="primary"
onClick={onReview}
size="sm"
>
Review
</Button>
)}
</Stack>
</Card>
);
}