Files
gridpilot.gg/apps/website/components/leagues/StandingsTable.tsx
2026-01-19 14:07:49 +01:00

684 lines
23 KiB
TypeScript
Raw Permalink 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 { routes } from '@/lib/routing/RouteConfig';
import { Badge } from '@/ui/Badge';
import { CountryFlag } from '@/ui/CountryFlag';
import { Icon } from '@/ui/Icon';
import { Image } from '@/ui/Image';
import { Link } from '@/ui/Link';
import { PlaceholderImage } from '@/ui/PlaceholderImage';
import { Stack } from '@/ui/Stack';
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/ui/Table';
import { Text } from '@/ui/Text';
import { Edit, User } from 'lucide-react';
import { useEffect, useRef, useState } from '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;
positionLabel: string;
totalPointsLabel: string;
racesLabel: string;
avgFinishLabel: string;
penaltyPointsLabel: string;
bonusPointsLabel: string;
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 (
<Stack
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' && (
<Stack
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>
</Stack>
)}
{membership!.role === 'admin' && (
<Stack
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>
</Stack>
)}
{membership!.role === 'member' && (
<Stack
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>
</Stack>
)}
{membership!.role === 'steward' && (
<Stack
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>
</Stack>
)}
<Stack borderTop borderColor="border-charcoal-outline" my={1} />
<Stack
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>
</Stack>
</>
) : (
<>
{/* Options for drivers without membership (participating but not formal members) */}
<Stack 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>
</Stack>
<Stack
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>
</Stack>
<Stack
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>
</Stack>
</>
)}
</Stack>
</Stack>
);
};
const PointsActionMenu = () => {
return (
<Stack
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}>
<Stack
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>
</Stack>
<Stack
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>
</Stack>
<Stack
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>
</Stack>
</Stack>
</Stack>
);
};
if (standings.length === 0) {
return (
<Stack textAlign="center" py={8}>
<Text color="text-gray-400">No standings available</Text>
</Stack>
);
}
return (
<Stack 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">
<Stack
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.positionLabel}
</Stack>
</TableCell>
{/* Driver with Rating and Nationality */}
<TableCell position="relative">
<Stack display="flex" alignItems="center" gap={3}>
{/* Avatar */}
<Stack position="relative">
<Stack
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} />
)
)}
</Stack>
{/* Nationality flag */}
{driver && driver.country && (
<Stack position="absolute" bottom="-1" right="-1">
<CountryFlag countryCode={driver.country} size="sm" />
</Stack>
)}
</Stack>
{/* Name and Rating */}
<Stack flexGrow={1} minWidth="0">
<Stack 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' && (
<Stack
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}
</Stack>
)}
</Stack>
</Stack>
{/* Hover Actions for Member Management */}
{isAdmin && canModify && (
<Stack
display="flex"
alignItems="center"
gap={1}
opacity={isRowHovered || isMemberMenuOpen ? 1 : 0}
transition
visibility={isRowHovered || isMemberMenuOpen ? 'visible' : 'hidden'}
>
<Stack
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} />
</Stack>
</Stack>
)}
</Stack>
{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">
<Stack display="flex" alignItems="center" justifyContent="end" gap={2}>
<Text color="text-white" weight="bold" size="lg">{row.totalPointsLabel}</Text>
{isAdmin && canModify && (
<Stack
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} />
</Stack>
)}
</Stack>
{isPointsMenuOpen && <PointsActionMenu />}
</TableCell>
{/* Races (Finished/Started) */}
<TableCell textAlign="center">
<Text color="text-white">{row.racesLabel}</Text>
</TableCell>
{/* Avg Finish */}
<TableCell textAlign="right">
<Text color="text-gray-300">
{row.avgFinishLabel}
</Text>
</TableCell>
{/* Penalty */}
<TableCell textAlign="right">
<Text color="text-gray-500">
{row.penaltyPointsLabel}
</Text>
</TableCell>
{/* Bonus */}
<TableCell textAlign="right">
<Text color="text-gray-500">
{row.bonusPointsLabel}
</Text>
</TableCell>
</TableRow>
);
})}
</TableBody>
</Table>
</Stack>
);
}