This commit is contained in:
2025-12-03 16:33:12 +01:00
parent a572e6edce
commit c0fdae3d3c
157 changed files with 7824 additions and 1042 deletions

View File

@@ -5,10 +5,20 @@ import { useRouter, useParams } from 'next/navigation';
import Button from '@/components/ui/Button';
import Card from '@/components/ui/Card';
import FeatureLimitationTooltip from '@/components/alpha/FeatureLimitationTooltip';
import JoinLeagueButton from '@/components/alpha/JoinLeagueButton';
import MembershipStatus from '@/components/alpha/MembershipStatus';
import LeagueMembers from '@/components/alpha/LeagueMembers';
import LeagueSchedule from '@/components/alpha/LeagueSchedule';
import LeagueAdmin from '@/components/alpha/LeagueAdmin';
import StandingsTable from '@/components/alpha/StandingsTable';
import DataWarning from '@/components/alpha/DataWarning';
import Breadcrumbs from '@/components/alpha/Breadcrumbs';
import { League } from '@gridpilot/racing-domain/entities/League';
import { Standing } from '@gridpilot/racing-domain/entities/Standing';
import { Race } from '@gridpilot/racing-domain/entities/Race';
import { Driver } from '@gridpilot/racing-domain/entities/Driver';
import { getLeagueRepository, getRaceRepository, getDriverRepository } from '@/lib/di-container';
import { getLeagueRepository, getRaceRepository, getDriverRepository, getStandingRepository } from '@/lib/di-container';
import { getMembership, isOwnerOrAdmin, getCurrentDriverId } from '@/lib/membership-data';
export default function LeagueDetailPage() {
const router = useRouter();
@@ -17,9 +27,16 @@ export default function LeagueDetailPage() {
const [league, setLeague] = useState<League | null>(null);
const [owner, setOwner] = useState<Driver | null>(null);
const [races, setRaces] = useState<Race[]>([]);
const [standings, setStandings] = useState<Standing[]>([]);
const [drivers, setDrivers] = useState<Driver[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [activeTab, setActiveTab] = useState<'overview' | 'schedule' | 'standings' | 'members' | 'admin'>('overview');
const [refreshKey, setRefreshKey] = useState(0);
const currentDriverId = getCurrentDriverId();
const membership = getMembership(leagueId, currentDriverId);
const isAdmin = isOwnerOrAdmin(leagueId, currentDriverId);
const loadLeagueData = async () => {
try {
@@ -41,13 +58,15 @@ export default function LeagueDetailPage() {
const ownerData = await driverRepo.findById(leagueData.ownerId);
setOwner(ownerData);
// Load races for this league
const allRaces = await raceRepo.findAll();
const leagueRaces = allRaces
.filter(race => race.leagueId === leagueId)
.sort((a, b) => new Date(a.scheduledAt).getTime() - new Date(b.scheduledAt).getTime());
setRaces(leagueRaces);
// Load standings
const standingRepo = getStandingRepository();
const allStandings = await standingRepo.findAll();
const leagueStandings = allStandings.filter(s => s.leagueId === leagueId);
setStandings(leagueStandings);
// Load all drivers for standings
const allDrivers = await driverRepo.findAll();
setDrivers(allDrivers);
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to load league');
} finally {
@@ -90,28 +109,30 @@ export default function LeagueDetailPage() {
);
}
const upcomingRaces = races.filter(race => race.status === 'scheduled');
const handleMembershipChange = () => {
setRefreshKey(prev => prev + 1);
loadLeagueData();
};
return (
<div className="min-h-screen bg-deep-graphite py-12 px-4 sm:px-6 lg:px-8">
<div className="max-w-6xl mx-auto">
{/* Breadcrumb */}
<div className="mb-6">
<button
onClick={() => router.push('/leagues')}
className="text-gray-400 hover:text-primary-blue transition-colors text-sm flex items-center gap-2"
>
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 19l-7-7 7-7" />
</svg>
Back to Leagues
</button>
</div>
<Breadcrumbs
items={[
{ label: 'Home', href: '/' },
{ label: 'Leagues', href: '/leagues' },
{ label: league.name }
]}
/>
{/* League Header */}
<div className="mb-8">
<div className="flex items-center gap-3 mb-2">
<h1 className="text-3xl font-bold text-white">{league.name}</h1>
<div className="flex items-center justify-between mb-4">
<div className="flex items-center gap-3">
<h1 className="text-3xl font-bold text-white">{league.name}</h1>
<MembershipStatus leagueId={leagueId} />
</div>
<FeatureLimitationTooltip message="Multi-league memberships coming in production">
<span className="px-2 py-1 text-xs font-medium bg-primary-blue/10 text-primary-blue rounded border border-primary-blue/30">
Alpha: Single League
@@ -121,115 +142,195 @@ export default function LeagueDetailPage() {
<p className="text-gray-400">{league.description}</p>
</div>
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6 mb-8">
{/* League Info */}
<Card className="lg:col-span-2">
<h2 className="text-xl font-semibold text-white mb-4">League Information</h2>
<div className="space-y-4">
<DataWarning />
{/* Action Card */}
{!membership && (
<Card className="mb-6">
<div className="flex items-center justify-between">
<div>
<label className="text-sm text-gray-500">Owner</label>
<p className="text-white">{owner ? owner.name : `ID: ${league.ownerId.slice(0, 8)}...`}</p>
<h3 className="text-lg font-semibold text-white mb-2">Join This League</h3>
<p className="text-gray-400 text-sm">Become a member to participate in races and track your progress</p>
</div>
<div>
<label className="text-sm text-gray-500">Created</label>
<p className="text-white">
{new Date(league.createdAt).toLocaleDateString('en-US', {
year: 'numeric',
month: 'long',
day: 'numeric'
})}
</p>
</div>
<div className="pt-4 border-t border-charcoal-outline">
<h3 className="text-white font-medium mb-3">League Settings</h3>
<div className="grid grid-cols-2 gap-4">
<div>
<label className="text-sm text-gray-500">Points System</label>
<p className="text-white">{league.settings.pointsSystem.toUpperCase()}</p>
</div>
<div>
<label className="text-sm text-gray-500">Session Duration</label>
<p className="text-white">{league.settings.sessionDuration} minutes</p>
</div>
<div>
<label className="text-sm text-gray-500">Qualifying Format</label>
<p className="text-white capitalize">{league.settings.qualifyingFormat}</p>
</div>
</div>
<div className="w-48">
<JoinLeagueButton
leagueId={leagueId}
onMembershipChange={handleMembershipChange}
/>
</div>
</div>
</Card>
)}
{/* Quick Actions */}
<Card>
<h2 className="text-xl font-semibold text-white mb-4">Quick Actions</h2>
<div className="space-y-3">
<Button
variant="primary"
className="w-full"
onClick={() => router.push(`/races?leagueId=${leagueId}`)}
{/* Tabs Navigation */}
<div className="mb-6 border-b border-charcoal-outline">
<div className="flex gap-4 overflow-x-auto">
<button
onClick={() => setActiveTab('overview')}
className={`pb-3 px-1 font-medium whitespace-nowrap transition-colors ${
activeTab === 'overview'
? 'text-primary-blue border-b-2 border-primary-blue'
: 'text-gray-400 hover:text-white'
}`}
>
Overview
</button>
<button
onClick={() => setActiveTab('schedule')}
className={`pb-3 px-1 font-medium whitespace-nowrap transition-colors ${
activeTab === 'schedule'
? 'text-primary-blue border-b-2 border-primary-blue'
: 'text-gray-400 hover:text-white'
}`}
>
Schedule
</button>
<button
onClick={() => setActiveTab('standings')}
className={`pb-3 px-1 font-medium whitespace-nowrap transition-colors ${
activeTab === 'standings'
? 'text-primary-blue border-b-2 border-primary-blue'
: 'text-gray-400 hover:text-white'
}`}
>
Standings
</button>
<button
onClick={() => setActiveTab('members')}
className={`pb-3 px-1 font-medium whitespace-nowrap transition-colors ${
activeTab === 'members'
? 'text-primary-blue border-b-2 border-primary-blue'
: 'text-gray-400 hover:text-white'
}`}
>
Members
</button>
{isAdmin && (
<button
onClick={() => setActiveTab('admin')}
className={`pb-3 px-1 font-medium whitespace-nowrap transition-colors ${
activeTab === 'admin'
? 'text-primary-blue border-b-2 border-primary-blue'
: 'text-gray-400 hover:text-white'
}`}
>
Schedule Race
</Button>
<Button
variant="secondary"
className="w-full"
onClick={() => router.push(`/leagues/${leagueId}/standings`)}
>
View Standings
</Button>
</div>
</Card>
Admin
</button>
)}
</div>
</div>
{/* Upcoming Races */}
<Card>
<h2 className="text-xl font-semibold text-white mb-4">Upcoming Races</h2>
{upcomingRaces.length === 0 ? (
<div className="text-center py-8 text-gray-400">
<p className="mb-2">No upcoming races scheduled</p>
<p className="text-sm text-gray-500">Click &ldquo;Schedule Race&rdquo; to create your first race</p>
</div>
) : (
<div className="space-y-3">
{upcomingRaces.map((race) => (
<div
key={race.id}
className="p-4 rounded-lg bg-deep-graphite border border-charcoal-outline hover:border-primary-blue transition-all duration-200 cursor-pointer hover:scale-[1.02]"
onClick={() => router.push(`/races/${race.id}`)}
>
<div className="flex items-center justify-between">
{/* Tab Content */}
{activeTab === 'overview' && (
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
{/* League Info */}
<Card className="lg:col-span-2">
<h2 className="text-xl font-semibold text-white mb-4">League Information</h2>
<div className="space-y-4">
<div>
<label className="text-sm text-gray-500">Owner</label>
<p className="text-white">{owner ? owner.name : `ID: ${league.ownerId.slice(0, 8)}...`}</p>
</div>
<div>
<label className="text-sm text-gray-500">Created</label>
<p className="text-white">
{new Date(league.createdAt).toLocaleDateString('en-US', {
year: 'numeric',
month: 'long',
day: 'numeric'
})}
</p>
</div>
<div className="pt-4 border-t border-charcoal-outline">
<h3 className="text-white font-medium mb-3">League Settings</h3>
<div className="grid grid-cols-2 gap-4">
<div>
<h3 className="text-white font-medium">{race.track}</h3>
<p className="text-sm text-gray-400">{race.car}</p>
<p className="text-xs text-gray-500 mt-1 uppercase">{race.sessionType}</p>
<label className="text-sm text-gray-500">Points System</label>
<p className="text-white">{league.settings.pointsSystem.toUpperCase()}</p>
</div>
<div className="text-right">
<p className="text-white text-sm">
{new Date(race.scheduledAt).toLocaleDateString()}
</p>
<p className="text-xs text-gray-500">
{new Date(race.scheduledAt).toLocaleTimeString([], {
hour: '2-digit',
minute: '2-digit'
})}
</p>
<div>
<label className="text-sm text-gray-500">Session Duration</label>
<p className="text-white">{league.settings.sessionDuration} minutes</p>
</div>
<div>
<label className="text-sm text-gray-500">Qualifying Format</label>
<p className="text-white capitalize">{league.settings.qualifyingFormat}</p>
</div>
</div>
</div>
))}
</div>
)}
</Card>
</div>
</Card>
{/* Quick Actions */}
<Card>
<h2 className="text-xl font-semibold text-white mb-4">Quick Actions</h2>
<div className="space-y-3">
{membership ? (
<>
<Button
variant="primary"
className="w-full"
onClick={() => setActiveTab('schedule')}
>
View Schedule
</Button>
<Button
variant="secondary"
className="w-full"
onClick={() => setActiveTab('standings')}
>
View Standings
</Button>
<JoinLeagueButton
leagueId={leagueId}
onMembershipChange={handleMembershipChange}
/>
</>
) : (
<JoinLeagueButton
leagueId={leagueId}
onMembershipChange={handleMembershipChange}
/>
)}
</div>
</Card>
</div>
)}
{activeTab === 'schedule' && (
<Card>
<LeagueSchedule leagueId={leagueId} key={refreshKey} />
</Card>
)}
{activeTab === 'standings' && (
<Card>
<h2 className="text-xl font-semibold text-white mb-4">Standings</h2>
<StandingsTable standings={standings} drivers={drivers} />
</Card>
)}
{activeTab === 'members' && (
<Card>
<h2 className="text-xl font-semibold text-white mb-4">League Members</h2>
<LeagueMembers leagueId={leagueId} key={refreshKey} />
</Card>
)}
{activeTab === 'admin' && isAdmin && (
<LeagueAdmin
league={league}
onLeagueUpdate={handleMembershipChange}
key={refreshKey}
/>
)}
</div>
</div>
);

View File

@@ -6,6 +6,7 @@ import LeagueCard from '@/components/alpha/LeagueCard';
import CreateLeagueForm from '@/components/alpha/CreateLeagueForm';
import Button from '@/components/ui/Button';
import Card from '@/components/ui/Card';
import Input from '@/components/ui/Input';
import { League } from '@gridpilot/racing-domain/entities/League';
import { getLeagueRepository } from '@/lib/di-container';
@@ -14,6 +15,8 @@ export default function LeaguesPage() {
const [leagues, setLeagues] = useState<League[]>([]);
const [loading, setLoading] = useState(true);
const [showCreateForm, setShowCreateForm] = useState(false);
const [searchQuery, setSearchQuery] = useState('');
const [sortBy, setSortBy] = useState('name');
useEffect(() => {
loadLeagues();
@@ -35,6 +38,24 @@ export default function LeaguesPage() {
router.push(`/leagues/${leagueId}`);
};
const filteredLeagues = leagues
.filter((league) => {
const matchesSearch =
league.name.toLowerCase().includes(searchQuery.toLowerCase()) ||
league.description.toLowerCase().includes(searchQuery.toLowerCase());
return matchesSearch;
})
.sort((a, b) => {
switch (sortBy) {
case 'name':
return a.name.localeCompare(b.name);
case 'recent':
return new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime();
default:
return 0;
}
});
if (loading) {
return (
<div className="max-w-6xl mx-auto">
@@ -49,8 +70,8 @@ export default function LeaguesPage() {
<div>
<h1 className="text-3xl font-bold text-white mb-2">Leagues</h1>
<p className="text-gray-400">
{leagues.length === 0
? 'Create your first league to get started'
{leagues.length === 0
? 'Create your first league to get started'
: `${leagues.length} ${leagues.length === 1 ? 'league' : 'leagues'} available`}
</p>
</div>
@@ -75,6 +96,38 @@ export default function LeaguesPage() {
</Card>
)}
{leagues.length > 0 && (
<Card className="mb-8">
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium text-gray-400 mb-2">
Search Leagues
</label>
<Input
type="text"
placeholder="Search by name or description..."
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-400 mb-2">
Sort By
</label>
<select
className="w-full px-3 py-3 bg-iron-gray border-0 rounded-md text-white ring-1 ring-inset ring-charcoal-outline focus:ring-2 focus:ring-primary-blue transition-all duration-150 text-sm"
value={sortBy}
onChange={(e) => setSortBy(e.target.value)}
>
<option value="name">Name</option>
<option value="recent">Most Recent</option>
</select>
</div>
</div>
</Card>
)}
{leagues.length === 0 ? (
<Card className="text-center py-12">
<div className="text-gray-400">
@@ -105,16 +158,28 @@ export default function LeaguesPage() {
</div>
</Card>
) : (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
{leagues.map((league) => (
<LeagueCard
key={league.id}
league={league}
onClick={() => handleLeagueClick(league.id)}
/>
))}
</div>
)}
<>
<div className="mb-4">
<p className="text-sm text-gray-400">
{filteredLeagues.length} {filteredLeagues.length === 1 ? 'league' : 'leagues'} found
</p>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
{filteredLeagues.map((league) => (
<LeagueCard
key={league.id}
league={league}
onClick={() => handleLeagueClick(league.id)}
/>
))}
</div>
{filteredLeagues.length === 0 && (
<div className="text-center py-12">
<p className="text-gray-400">No leagues found matching your search.</p>
</div>
)}
</>
)}
</div>
);
}