This commit is contained in:
2025-12-08 23:52:36 +01:00
parent 2d0860d66c
commit 35f988f885
46 changed files with 4624 additions and 1041 deletions

View File

@@ -0,0 +1,439 @@
'use client';
import { useState, useEffect, useMemo } from 'react';
import { useRouter, useSearchParams } from 'next/navigation';
import Link from 'next/link';
import Card from '@/components/ui/Card';
import Button from '@/components/ui/Button';
import Heading from '@/components/ui/Heading';
import Breadcrumbs from '@/components/layout/Breadcrumbs';
import { Race, RaceStatus } from '@gridpilot/racing/domain/entities/Race';
import { League } from '@gridpilot/racing/domain/entities/League';
import { getRaceRepository, getLeagueRepository } from '@/lib/di-container';
import {
Calendar,
Clock,
Flag,
ChevronRight,
ChevronLeft,
Filter,
Car,
Trophy,
Zap,
PlayCircle,
CheckCircle2,
XCircle,
ArrowRight,
Search,
SlidersHorizontal,
} from 'lucide-react';
const ITEMS_PER_PAGE = 10;
export default function AllRacesPage() {
const router = useRouter();
const searchParams = useSearchParams();
const [races, setRaces] = useState<Race[]>([]);
const [leagues, setLeagues] = useState<Map<string, League>>(new Map());
const [loading, setLoading] = useState(true);
// Pagination
const [currentPage, setCurrentPage] = useState(1);
// Filters
const [statusFilter, setStatusFilter] = useState<RaceStatus | 'all'>('all');
const [leagueFilter, setLeagueFilter] = useState<string>('all');
const [searchQuery, setSearchQuery] = useState('');
const [showFilters, setShowFilters] = useState(false);
const loadRaces = async () => {
try {
const raceRepo = getRaceRepository();
const leagueRepo = getLeagueRepository();
const [allRaces, allLeagues] = await Promise.all([
raceRepo.findAll(),
leagueRepo.findAll()
]);
setRaces(allRaces.sort((a, b) => b.scheduledAt.getTime() - a.scheduledAt.getTime()));
const leagueMap = new Map<string, League>();
allLeagues.forEach(league => leagueMap.set(league.id, league));
setLeagues(leagueMap);
} catch (err) {
console.error('Failed to load races:', err);
} finally {
setLoading(false);
}
};
useEffect(() => {
loadRaces();
}, []);
// Filter races
const filteredRaces = useMemo(() => {
return races.filter(race => {
// Status filter
if (statusFilter !== 'all' && race.status !== statusFilter) {
return false;
}
// League filter
if (leagueFilter !== 'all' && race.leagueId !== leagueFilter) {
return false;
}
// Search filter
if (searchQuery) {
const query = searchQuery.toLowerCase();
const league = leagues.get(race.leagueId);
const matchesTrack = race.track.toLowerCase().includes(query);
const matchesCar = race.car.toLowerCase().includes(query);
const matchesLeague = league?.name.toLowerCase().includes(query);
if (!matchesTrack && !matchesCar && !matchesLeague) {
return false;
}
}
return true;
});
}, [races, statusFilter, leagueFilter, searchQuery, leagues]);
// Paginate
const totalPages = Math.ceil(filteredRaces.length / ITEMS_PER_PAGE);
const paginatedRaces = useMemo(() => {
const start = (currentPage - 1) * ITEMS_PER_PAGE;
return filteredRaces.slice(start, start + ITEMS_PER_PAGE);
}, [filteredRaces, currentPage]);
// Reset page when filters change
useEffect(() => {
setCurrentPage(1);
}, [statusFilter, leagueFilter, searchQuery]);
const formatDate = (date: Date) => {
return new Date(date).toLocaleDateString('en-US', {
weekday: 'short',
month: 'short',
day: 'numeric',
year: 'numeric',
});
};
const formatTime = (date: Date) => {
return new Date(date).toLocaleTimeString('en-US', {
hour: '2-digit',
minute: '2-digit',
});
};
const statusConfig = {
scheduled: {
icon: Clock,
color: 'text-primary-blue',
bg: 'bg-primary-blue/10',
border: 'border-primary-blue/30',
label: 'Scheduled',
},
running: {
icon: PlayCircle,
color: 'text-performance-green',
bg: 'bg-performance-green/10',
border: 'border-performance-green/30',
label: 'LIVE',
},
completed: {
icon: CheckCircle2,
color: 'text-gray-400',
bg: 'bg-gray-500/10',
border: 'border-gray-500/30',
label: 'Completed',
},
cancelled: {
icon: XCircle,
color: 'text-warning-amber',
bg: 'bg-warning-amber/10',
border: 'border-warning-amber/30',
label: 'Cancelled',
},
};
const breadcrumbItems = [
{ label: 'Races', href: '/races' },
{ label: 'All Races' },
];
if (loading) {
return (
<div className="min-h-screen bg-deep-graphite py-8 px-4 sm:px-6 lg:px-8">
<div className="max-w-5xl mx-auto">
<div className="animate-pulse space-y-6">
<div className="h-6 bg-iron-gray rounded w-1/4" />
<div className="h-10 bg-iron-gray rounded w-1/3" />
<div className="space-y-4">
{[1, 2, 3, 4, 5].map(i => (
<div key={i} className="h-24 bg-iron-gray rounded-lg" />
))}
</div>
</div>
</div>
</div>
);
}
return (
<div className="min-h-screen bg-deep-graphite py-8 px-4 sm:px-6 lg:px-8">
<div className="max-w-5xl mx-auto space-y-6">
{/* Breadcrumbs */}
<Breadcrumbs items={breadcrumbItems} />
{/* Header */}
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4">
<div>
<Heading level={1} className="text-2xl font-bold text-white flex items-center gap-3">
<Flag className="w-6 h-6 text-primary-blue" />
All Races
</Heading>
<p className="text-gray-400 text-sm mt-1">
{filteredRaces.length} race{filteredRaces.length !== 1 ? 's' : ''} found
</p>
</div>
<Button
variant="secondary"
onClick={() => setShowFilters(!showFilters)}
className="flex items-center gap-2"
>
<SlidersHorizontal className="w-4 h-4" />
Filters
</Button>
</div>
{/* Search & Filters */}
<Card className={`!p-4 ${showFilters ? '' : 'hidden sm:block'}`}>
<div className="space-y-4">
{/* Search */}
<div className="relative">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-gray-500" />
<input
type="text"
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
placeholder="Search by track, car, or league..."
className="w-full pl-10 pr-4 py-2.5 bg-deep-graphite border border-charcoal-outline rounded-lg text-white placeholder-gray-500 focus:outline-none focus:ring-2 focus:ring-primary-blue"
/>
</div>
{/* Filter Row */}
<div className="flex flex-wrap gap-4">
{/* Status Filter */}
<select
value={statusFilter}
onChange={(e) => setStatusFilter(e.target.value as RaceStatus | 'all')}
className="px-4 py-2 bg-deep-graphite border border-charcoal-outline rounded-lg text-white text-sm focus:outline-none focus:ring-2 focus:ring-primary-blue"
>
<option value="all">All Statuses</option>
<option value="scheduled">Scheduled</option>
<option value="running">Live</option>
<option value="completed">Completed</option>
<option value="cancelled">Cancelled</option>
</select>
{/* League Filter */}
<select
value={leagueFilter}
onChange={(e) => setLeagueFilter(e.target.value)}
className="px-4 py-2 bg-deep-graphite border border-charcoal-outline rounded-lg text-white text-sm focus:outline-none focus:ring-2 focus:ring-primary-blue"
>
<option value="all">All Leagues</option>
{Array.from(leagues.values()).map(league => (
<option key={league.id} value={league.id}>
{league.name}
</option>
))}
</select>
{/* Clear Filters */}
{(statusFilter !== 'all' || leagueFilter !== 'all' || searchQuery) && (
<button
onClick={() => {
setStatusFilter('all');
setLeagueFilter('all');
setSearchQuery('');
}}
className="px-4 py-2 text-sm text-primary-blue hover:underline"
>
Clear filters
</button>
)}
</div>
</div>
</Card>
{/* Race List */}
{paginatedRaces.length === 0 ? (
<Card className="text-center py-12">
<div className="flex flex-col items-center gap-4">
<div className="p-4 bg-iron-gray rounded-full">
<Calendar className="w-8 h-8 text-gray-500" />
</div>
<div>
<p className="text-white font-medium mb-1">No races found</p>
<p className="text-sm text-gray-500">
{races.length === 0
? 'No races have been scheduled yet'
: 'Try adjusting your search or filters'}
</p>
</div>
</div>
</Card>
) : (
<div className="space-y-3">
{paginatedRaces.map(race => {
const config = statusConfig[race.status];
const StatusIcon = config.icon;
const league = leagues.get(race.leagueId);
return (
<div
key={race.id}
onClick={() => router.push(`/races/${race.id}`)}
className={`group relative overflow-hidden rounded-xl bg-iron-gray border ${config.border} p-4 cursor-pointer transition-all duration-200 hover:scale-[1.01] hover:border-primary-blue`}
>
{/* Live indicator */}
{race.status === 'running' && (
<div className="absolute top-0 left-0 right-0 h-1 bg-gradient-to-r from-performance-green via-performance-green/50 to-performance-green animate-pulse" />
)}
<div className="flex items-center gap-4">
{/* Date Column */}
<div className="hidden sm:flex flex-col items-center min-w-[80px] text-center">
<p className="text-xs text-gray-500 uppercase">
{new Date(race.scheduledAt).toLocaleDateString('en-US', { month: 'short' })}
</p>
<p className="text-2xl font-bold text-white">
{new Date(race.scheduledAt).getDate()}
</p>
<p className="text-xs text-gray-500">
{formatTime(race.scheduledAt)}
</p>
</div>
{/* Divider */}
<div className="hidden sm:block w-px h-16 bg-charcoal-outline" />
{/* Main Content */}
<div className="flex-1 min-w-0">
<div className="flex items-start justify-between gap-4">
<div className="min-w-0">
<h3 className="font-semibold text-white truncate group-hover:text-primary-blue transition-colors">
{race.track}
</h3>
<div className="flex flex-wrap items-center gap-x-4 gap-y-1 mt-1">
<span className="flex items-center gap-1.5 text-sm text-gray-400">
<Car className="w-3.5 h-3.5" />
{race.car}
</span>
{race.strengthOfField && (
<span className="flex items-center gap-1.5 text-sm text-warning-amber">
<Zap className="w-3.5 h-3.5" />
SOF {race.strengthOfField}
</span>
)}
<span className="sm:hidden text-sm text-gray-500">
{formatDate(race.scheduledAt)}
</span>
</div>
{league && (
<Link
href={`/leagues/${league.id}`}
onClick={(e) => e.stopPropagation()}
className="inline-flex items-center gap-1.5 mt-2 text-sm text-primary-blue hover:underline"
>
<Trophy className="w-3.5 h-3.5" />
{league.name}
</Link>
)}
</div>
{/* Status Badge */}
<div className={`flex items-center gap-1.5 px-2.5 py-1 rounded-full ${config.bg} ${config.border} border flex-shrink-0`}>
<StatusIcon className={`w-3.5 h-3.5 ${config.color}`} />
<span className={`text-xs font-medium ${config.color}`}>
{config.label}
</span>
</div>
</div>
</div>
{/* Arrow */}
<ChevronRight className="w-5 h-5 text-gray-500 group-hover:text-primary-blue transition-colors flex-shrink-0" />
</div>
</div>
);
})}
</div>
)}
{/* Pagination */}
{totalPages > 1 && (
<div className="flex items-center justify-between pt-4">
<p className="text-sm text-gray-500">
Showing {((currentPage - 1) * ITEMS_PER_PAGE) + 1}{Math.min(currentPage * ITEMS_PER_PAGE, filteredRaces.length)} of {filteredRaces.length}
</p>
<div className="flex items-center gap-2">
<button
onClick={() => setCurrentPage(p => Math.max(1, p - 1))}
disabled={currentPage === 1}
className="p-2 rounded-lg border border-charcoal-outline text-gray-400 hover:text-white hover:border-primary-blue disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
>
<ChevronLeft className="w-5 h-5" />
</button>
<div className="flex items-center gap-1">
{Array.from({ length: Math.min(5, totalPages) }, (_, i) => {
let pageNum: number;
if (totalPages <= 5) {
pageNum = i + 1;
} else if (currentPage <= 3) {
pageNum = i + 1;
} else if (currentPage >= totalPages - 2) {
pageNum = totalPages - 4 + i;
} else {
pageNum = currentPage - 2 + i;
}
return (
<button
key={pageNum}
onClick={() => setCurrentPage(pageNum)}
className={`w-10 h-10 rounded-lg text-sm font-medium transition-colors ${
currentPage === pageNum
? 'bg-primary-blue text-white'
: 'text-gray-400 hover:text-white hover:bg-iron-gray'
}`}
>
{pageNum}
</button>
);
})}
</div>
<button
onClick={() => setCurrentPage(p => Math.min(totalPages, p + 1))}
disabled={currentPage === totalPages}
className="p-2 rounded-lg border border-charcoal-outline text-gray-400 hover:text-white hover:border-primary-blue disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
>
<ChevronRight className="w-5 h-5" />
</button>
</div>
</div>
)}
</div>
</div>
);
}