Files
gridpilot.gg/apps/website/components/leagues/StandingsTable.tsx
2026-01-15 17:12:24 +01:00

686 lines
24 KiB
TypeScript
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
'use client';
import { useState, useRef, useEffect } from 'react';
import { Link } from '@/ui/Link';
import { Image } from '@/ui/Image';
import { CountryFlag } from '@/ui/CountryFlag';
import { PlaceholderImage } from '@/ui/PlaceholderImage';
import { Box } from '@/ui/Box';
import { Text } from '@/ui/Text';
import { Table, TableHead, TableBody, TableRow, TableHeader, TableCell } from '@/ui/Table';
import { Badge } from '@/ui/Badge';
import { Stack } from '@/ui/Stack';
import { routes } from '@/lib/routing/RouteConfig';
import { Icon } from '@/ui/Icon';
import { User, Edit } from 'lucide-react';
// League role display data
const leagueRoleDisplay = {
owner: {
text: 'Owner',
bg: 'bg-yellow-500/10',
color: 'text-yellow-500',
borderColor: 'border-yellow-500/30',
},
admin: {
text: 'Admin',
bg: 'bg-purple-500/10',
color: 'text-purple-400',
borderColor: 'border-purple-500/30',
},
steward: {
text: 'Steward',
bg: 'bg-blue-500/10',
color: 'text-blue-400',
borderColor: 'border-blue-500/30',
},
member: {
text: 'Member',
bg: 'bg-primary-blue/10',
color: 'text-primary-blue',
borderColor: 'border-primary-blue/30',
},
} as const;
// Position background colors
const getPositionBgColor = (position: number) => {
switch (position) {
case 1: return { bg: 'bg-yellow-500/10', borderLeft: true, borderColor: 'border-l-yellow-500' };
case 2: return { bg: 'bg-gray-300/10', borderLeft: true, borderColor: 'border-l-gray-400' };
case 3: return { bg: 'bg-amber-600/10', borderLeft: true, borderColor: 'border-l-amber-600' };
default: return { borderLeft: true, borderColor: 'border-l-transparent' };
}
};
interface StandingsTableProps {
standings: Array<{
driverId: string;
position: number;
totalPoints: number;
racesFinished: number;
racesStarted: number;
avgFinish: number | null;
penaltyPoints: number;
bonusPoints: number;
teamName?: string;
}>;
drivers: Array<{
id: string;
name: string;
avatarUrl: string | null;
iracingId?: string;
rating?: number;
country?: string;
}>;
memberships?: Array<{
driverId: string;
role: 'owner' | 'admin' | 'steward' | 'member';
joinedAt: string;
status: 'active' | 'pending' | 'banned';
}>;
currentDriverId?: string;
isAdmin?: boolean;
onRemoveMember?: (driverId: string) => void;
onUpdateRole?: (driverId: string, role: string) => void;
}
export function StandingsTable({
standings,
drivers,
memberships = [],
currentDriverId,
isAdmin = false,
onRemoveMember,
onUpdateRole
}: StandingsTableProps) {
const [hoveredRow, setHoveredRow] = useState<string | null>(null);
const [activeMenu, setActiveMenu] = useState<{ driverId: string; type: 'member' | 'points' } | null>(null);
const menuRef = useRef<HTMLDivElement>(null);
// Close menu when clicking outside
useEffect(() => {
const handleClickOutside = (event: MouseEvent) => {
if (menuRef.current && !menuRef.current.contains(event.target as Node)) {
setActiveMenu(null);
}
};
document.addEventListener('mousedown', handleClickOutside);
return () => document.removeEventListener('mousedown', handleClickOutside);
}, []);
const getDriver = (driverId: string) => {
return drivers.find((d) => d.id === driverId);
};
const getMembership = (driverId: string) => {
return memberships.find((m) => m.driverId === driverId);
};
const canModifyMember = (driverId: string): boolean => {
if (!isAdmin) return false;
if (driverId === currentDriverId) return false;
const membership = getMembership(driverId);
// Allow managing drivers even without formal membership (they have standings = they're participating)
// But don't allow modifying the owner
if (membership && membership.role === 'owner') return false;
return true;
};
const isCurrentUser = (driverId: string): boolean => {
return driverId === currentDriverId;
};
type MembershipRole = string;
const handleRoleChange = (driverId: string, newRole: MembershipRole) => {
if (!onUpdateRole) return;
const membership = getMembership(driverId);
if (!membership) return;
const confirmationMessages: Record<string, string> = {
owner: 'Cannot promote to owner',
admin: 'Promote this member to Admin? They will have full management permissions.',
steward: 'Assign Steward role? They will be able to manage protests and penalties.',
member: 'Demote to regular Member? They will lose elevated permissions.'
};
if (newRole === 'owner') {
alert(confirmationMessages.owner);
return;
}
if (newRole !== membership.role && confirm(confirmationMessages[newRole])) {
onUpdateRole(driverId, newRole);
setActiveMenu(null);
}
};
const handleRemove = (driverId: string) => {
if (!onRemoveMember) return;
const driver = getDriver(driverId);
const driverName = driver?.name || 'this member';
if (confirm(`Remove ${driverName} from the league? This action cannot be undone.`)) {
onRemoveMember(driverId);
setActiveMenu(null);
}
};
const MemberActionMenu = ({ driverId }: { driverId: string }) => {
const membership = getMembership(driverId);
// For drivers without membership, show limited options (add as member, remove from standings)
const hasMembership = !!membership;
return (
<Box
ref={menuRef}
position="absolute"
right="0"
top="full"
mt={1}
zIndex={50}
bg="bg-deep-graphite"
border
borderColor="border-charcoal-outline"
rounded="lg"
shadow="xl"
p={2}
minWidth="200px"
onClick={(e: React.MouseEvent) => e.stopPropagation()}
>
<Text size="xs" color="text-gray-400" px={2} py={1} mb={1} block>
Member Management
</Text>
<Stack gap={1}>
{hasMembership ? (
<>
{/* Role Management for existing members */}
{membership!.role !== 'admin' && membership!.role !== 'owner' && (
<Box
as="button"
onClick={(e: React.MouseEvent) => { e.stopPropagation(); handleRoleChange(driverId, 'admin'); }}
display="flex"
alignItems="center"
gap={2}
w="full"
textAlign="left"
px={3}
py={2}
rounded="md"
fontSize="14px"
color="text-white"
hoverBg="bg-purple-500/10"
transition
>
<Text>🛡</Text>
<Text>Promote to Admin</Text>
</Box>
)}
{membership!.role === 'admin' && (
<Box
as="button"
onClick={(e: React.MouseEvent) => { e.stopPropagation(); handleRoleChange(driverId, 'member'); }}
display="flex"
alignItems="center"
gap={2}
w="full"
textAlign="left"
px={3}
py={2}
rounded="md"
fontSize="14px"
color="text-white"
hoverBg="bg-iron-gray/20"
transition
>
<Text></Text>
<Text>Demote to Member</Text>
</Box>
)}
{membership!.role === 'member' && (
<Box
as="button"
onClick={(e: React.MouseEvent) => { e.stopPropagation(); handleRoleChange(driverId, 'steward'); }}
display="flex"
alignItems="center"
gap={2}
w="full"
textAlign="left"
px={3}
py={2}
rounded="md"
fontSize="14px"
color="text-white"
hoverBg="bg-blue-500/10"
transition
>
<Text>🏁</Text>
<Text>Make Steward</Text>
</Box>
)}
{membership!.role === 'steward' && (
<Box
as="button"
onClick={(e: React.MouseEvent) => { e.stopPropagation(); handleRoleChange(driverId, 'member'); }}
display="flex"
alignItems="center"
gap={2}
w="full"
textAlign="left"
px={3}
py={2}
rounded="md"
fontSize="14px"
color="text-white"
hoverBg="bg-iron-gray/20"
transition
>
<Text>🏁</Text>
<Text>Remove Steward</Text>
</Box>
)}
<Box borderTop borderColor="border-charcoal-outline" my={1} />
<Box
as="button"
onClick={(e: React.MouseEvent) => { e.stopPropagation(); handleRemove(driverId); }}
display="flex"
alignItems="center"
gap={2}
w="full"
textAlign="left"
px={3}
py={2}
rounded="md"
fontSize="14px"
color="text-red-400"
hoverBg="bg-red-500/10"
transition
>
<Text>🚫</Text>
<Text>Remove from League</Text>
</Box>
</>
) : (
<>
{/* Options for drivers without membership (participating but not formal members) */}
<Box bg="bg-yellow-500/10" rounded px={2} py={1} mb={1}>
<Text size="xs" color="text-yellow-400/80">Driver not a formal member</Text>
</Box>
<Box
as="button"
onClick={(e: React.MouseEvent) => { e.stopPropagation(); alert('Add as member - feature coming soon'); }}
display="flex"
alignItems="center"
gap={2}
w="full"
textAlign="left"
px={3}
py={2}
rounded="md"
fontSize="14px"
color="text-white"
hoverBg="bg-green-500/10"
transition
>
<Text></Text>
<Text>Add as Member</Text>
</Box>
<Box
as="button"
onClick={(e: React.MouseEvent) => { e.stopPropagation(); handleRemove(driverId); }}
display="flex"
alignItems="center"
gap={2}
w="full"
textAlign="left"
px={3}
py={2}
rounded="md"
fontSize="14px"
color="text-red-400"
hoverBg="bg-red-500/10"
transition
>
<Text>🚫</Text>
<Text>Remove from Standings</Text>
</Box>
</>
)}
</Stack>
</Box>
);
};
const PointsActionMenu = () => {
return (
<Box
ref={menuRef}
position="absolute"
right="0"
top="full"
mt={1}
zIndex={50}
bg="bg-deep-graphite"
border
borderColor="border-charcoal-outline"
rounded="lg"
shadow="xl"
p={2}
minWidth="180px"
onClick={(e: React.MouseEvent) => e.stopPropagation()}
>
<Text size="xs" color="text-gray-400" px={2} py={1} mb={1} block>
Score Actions
</Text>
<Stack gap={1}>
<Box
as="button"
onClick={(e: React.MouseEvent) => { e.stopPropagation(); alert('View detailed stats - feature coming soon'); }}
display="flex"
alignItems="center"
gap={2}
w="full"
textAlign="left"
px={3}
py={2}
rounded="md"
color="text-white"
hoverBg="bg-iron-gray/20"
transition
fontSize="14px"
>
<Text>📊</Text>
<Text>View Details</Text>
</Box>
<Box
as="button"
onClick={(e: React.MouseEvent) => { e.stopPropagation(); alert('Manual adjustment - feature coming soon'); }}
display="flex"
alignItems="center"
gap={2}
w="full"
textAlign="left"
px={3}
py={2}
rounded="md"
color="text-white"
hoverBg="bg-iron-gray/20"
transition
fontSize="14px"
>
<Text></Text>
<Text>Adjust Points</Text>
</Box>
<Box
as="button"
onClick={(e: React.MouseEvent) => { e.stopPropagation(); alert('Race history - feature coming soon'); }}
display="flex"
alignItems="center"
gap={2}
w="full"
textAlign="left"
px={3}
py={2}
rounded="md"
color="text-white"
hoverBg="bg-iron-gray/20"
transition
fontSize="14px"
>
<Text>📝</Text>
<Text>Race History</Text>
</Box>
</Stack>
</Box>
);
};
if (standings.length === 0) {
return (
<Box textAlign="center" py={8}>
<Text color="text-gray-400">No standings available</Text>
</Box>
);
}
return (
<Box overflow="auto">
<Table>
<TableHead>
<TableRow>
<TableHeader textAlign="center" w="14">Pos</TableHeader>
<TableHeader>Driver</TableHeader>
<TableHeader>Team</TableHeader>
<TableHeader textAlign="right">Points</TableHeader>
<TableHeader textAlign="center">Races</TableHeader>
<TableHeader textAlign="right">Avg Finish</TableHeader>
<TableHeader textAlign="right">Penalty</TableHeader>
<TableHeader textAlign="right">Bonus</TableHeader>
</TableRow>
</TableHead>
<TableBody>
{standings.map((row) => {
const driver = getDriver(row.driverId);
const membership = getMembership(row.driverId);
const roleDisplay = membership ? leagueRoleDisplay[membership.role] : null;
const canModify = canModifyMember(row.driverId);
const isRowHovered = hoveredRow === row.driverId;
const isMemberMenuOpen = activeMenu?.driverId === row.driverId && activeMenu?.type === 'member';
const isPointsMenuOpen = activeMenu?.driverId === row.driverId && activeMenu?.type === 'points';
const isMe = isCurrentUser(row.driverId);
const posConfig = getPositionBgColor(row.position);
return (
<TableRow
key={row.driverId}
bg={isMe ? 'bg-primary-blue/5' : posConfig.bg}
borderLeft={posConfig.borderLeft}
borderColor={posConfig.borderColor}
hoverBg="bg-iron-gray/10"
ring={isMe ? 'ring-2 ring-primary-blue/50 ring-inset' : ''}
onMouseEnter={() => setHoveredRow(row.driverId)}
onMouseLeave={() => {
setHoveredRow(null);
if (!isMemberMenuOpen && !isPointsMenuOpen) {
setActiveMenu(null);
}
}}
>
{/* Position */}
<TableCell textAlign="center" w="14">
<Box
display="inline-flex"
alignItems="center"
justifyContent="center"
w="8"
h="8"
rounded="full"
weight="bold"
bg={
row.position === 1 ? 'bg-yellow-500' :
row.position === 2 ? 'bg-gray-400' :
row.position === 3 ? 'bg-amber-600' :
'bg-charcoal-outline'
}
color={
row.position === 1 ? 'text-black' :
row.position === 2 ? 'text-black' :
'text-white'
}
>
{row.position}
</Box>
</TableCell>
{/* Driver with Rating and Nationality */}
<TableCell position="relative">
<Box display="flex" alignItems="center" gap={3}>
{/* Avatar */}
<Box position="relative">
<Box
w="10"
h="10"
rounded="full"
bg="bg-primary-blue/20"
overflow="hidden"
display="flex"
alignItems="center"
justifyContent="center"
flexShrink={0}
>
{driver && (
driver.avatarUrl ? (
<Image
src={driver.avatarUrl}
alt={driver.name}
width={40}
height={40}
fullWidth
fullHeight
objectFit="cover"
/>
) : (
<PlaceholderImage size={40} />
)
)}
</Box>
{/* Nationality flag */}
{driver && driver.country && (
<Box position="absolute" bottom="-1" right="-1">
<CountryFlag countryCode={driver.country} size="sm" />
</Box>
)}
</Box>
{/* Name and Rating */}
<Box flexGrow={1} minWidth="0">
<Box display="flex" alignItems="center" gap={2}>
<Link
href={routes.driver.detail(row.driverId)}
weight="medium"
color="text-white"
truncate
hoverTextColor="text-primary-blue"
transition
>
{driver?.name || 'Unknown Driver'}
</Link>
{isMe && (
<Badge variant="primary">You</Badge>
)}
{roleDisplay && roleDisplay.text !== 'Member' && (
<Box
as="span"
px={2}
py={0.5}
fontSize="12px"
weight="medium"
rounded="md"
border
bg={roleDisplay.bg}
color={roleDisplay.color}
borderColor={roleDisplay.borderColor}
>
{roleDisplay.text}
</Box>
)}
</Box>
</Box>
{/* Hover Actions for Member Management */}
{isAdmin && canModify && (
<Box
display="flex"
alignItems="center"
gap={1}
opacity={isRowHovered || isMemberMenuOpen ? 1 : 0}
transition
visibility={isRowHovered || isMemberMenuOpen ? 'visible' : 'hidden'}
>
<Box
as="button"
onClick={(e: React.MouseEvent) => { e.stopPropagation(); setActiveMenu(isMemberMenuOpen ? null : { driverId: row.driverId, type: 'member' }); }}
p={1.5}
rounded="md"
bg={isMemberMenuOpen ? 'bg-primary-blue/20' : ''}
color={isMemberMenuOpen ? 'text-primary-blue' : 'text-gray-400'}
hoverColor={!isMemberMenuOpen ? 'text-white' : ''}
hoverBg={!isMemberMenuOpen ? 'bg-iron-gray/30' : ''}
transition
title="Manage member"
>
<Icon icon={User} size={4} />
</Box>
</Box>
)}
</Box>
{isMemberMenuOpen && <MemberActionMenu driverId={row.driverId} />}
</TableCell>
{/* Team */}
<TableCell>
<Text color="text-gray-300">{row.teamName ?? '—'}</Text>
</TableCell>
{/* Total Points with Hover Action */}
<TableCell textAlign="right" position="relative">
<Box display="flex" alignItems="center" justifyContent="end" gap={2}>
<Text color="text-white" weight="bold" size="lg">{row.totalPoints}</Text>
{isAdmin && canModify && (
<Box
as="button"
onClick={(e: React.MouseEvent) => { e.stopPropagation(); setActiveMenu(isPointsMenuOpen ? null : { driverId: row.driverId, type: 'points' }); }}
p={1}
rounded="md"
bg={isPointsMenuOpen ? 'bg-primary-blue/20' : ''}
color={isPointsMenuOpen ? 'text-primary-blue' : 'text-gray-400'}
hoverColor={!isPointsMenuOpen ? 'text-white' : ''}
hoverBg={!isPointsMenuOpen ? 'bg-iron-gray/30' : ''}
transition
opacity={isRowHovered || isPointsMenuOpen ? 1 : 0}
visibility={isRowHovered || isPointsMenuOpen ? 'visible' : 'hidden'}
title="Score actions"
>
<Icon icon={Edit} size={3} />
</Box>
)}
</Box>
{isPointsMenuOpen && <PointsActionMenu />}
</TableCell>
{/* Races (Finished/Started) */}
<TableCell textAlign="center">
<Text color="text-white">{row.racesFinished}</Text>
<Text color="text-gray-500">/{row.racesStarted}</Text>
</TableCell>
{/* Avg Finish */}
<TableCell textAlign="right">
<Text color="text-gray-300">
{row.avgFinish !== null ? row.avgFinish.toFixed(1) : '—'}
</Text>
</TableCell>
{/* Penalty */}
<TableCell textAlign="right">
<Text color={row.penaltyPoints > 0 ? 'text-red-400' : 'text-gray-500'} weight={row.penaltyPoints > 0 ? 'medium' : 'normal'}>
{row.penaltyPoints > 0 ? `-${row.penaltyPoints}` : '—'}
</Text>
</TableCell>
{/* Bonus */}
<TableCell textAlign="right">
<Text color={row.bonusPoints !== 0 ? 'text-green-400' : 'text-gray-500'} weight={row.bonusPoints !== 0 ? 'medium' : 'normal'}>
{row.bonusPoints !== 0 ? `+${row.bonusPoints}` : '—'}
</Text>
</TableCell>
</TableRow>
);
})}
</TableBody>
</Table>
</Box>
);
}