website refactor
This commit is contained in:
@@ -1,6 +1,7 @@
|
||||
import { Card } from '@/ui/Card';
|
||||
import { Text } from '@/ui/Text';
|
||||
import { Stack } from '@/ui/primitives/Stack';
|
||||
import { Group } from '@/ui/Group';
|
||||
import { DateDisplay } from '@/lib/display-objects/DateDisplay';
|
||||
|
||||
interface AchievementCardProps {
|
||||
title: string;
|
||||
@@ -10,13 +11,6 @@ interface AchievementCardProps {
|
||||
rarity: 'common' | 'rare' | 'epic' | 'legendary';
|
||||
}
|
||||
|
||||
const rarityColors = {
|
||||
common: 'border-gray-500 bg-gray-500/10',
|
||||
rare: 'border-blue-400 bg-blue-400/10',
|
||||
epic: 'border-purple-400 bg-purple-400/10',
|
||||
legendary: 'border-warning-amber bg-warning-amber/10'
|
||||
};
|
||||
|
||||
export function AchievementCard({
|
||||
title,
|
||||
description,
|
||||
@@ -24,27 +18,27 @@ export function AchievementCard({
|
||||
unlockedAt,
|
||||
rarity,
|
||||
}: AchievementCardProps) {
|
||||
const rarityVariantMap = {
|
||||
common: 'rarity-common',
|
||||
rare: 'rarity-rare',
|
||||
epic: 'rarity-epic',
|
||||
legendary: 'rarity-legendary'
|
||||
} as const;
|
||||
|
||||
return (
|
||||
<Card
|
||||
p={4}
|
||||
rounded="lg"
|
||||
variant="outline"
|
||||
className={rarityColors[rarity]}
|
||||
variant={rarityVariantMap[rarity]}
|
||||
>
|
||||
<Stack direction="row" align="start" gap={3}>
|
||||
<Group direction="row" align="start" gap={3}>
|
||||
<Text size="3xl">{icon}</Text>
|
||||
<Stack gap={1} flexGrow={1}>
|
||||
<Text weight="medium" color="text-white">{title}</Text>
|
||||
<Text size="xs" color="text-gray-400">{description}</Text>
|
||||
<Text size="xs" color="text-gray-500">
|
||||
{new Date(unlockedAt).toLocaleDateString('en-US', {
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
year: 'numeric'
|
||||
})}
|
||||
<Group direction="column" gap={1}>
|
||||
<Text weight="medium" variant="high">{title}</Text>
|
||||
<Text size="xs" variant="med">{description}</Text>
|
||||
<Text size="xs" variant="low">
|
||||
{DateDisplay.formatShort(unlockedAt)}
|
||||
</Text>
|
||||
</Stack>
|
||||
</Stack>
|
||||
</Group>
|
||||
</Group>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
import { AchievementDisplay } from '@/lib/display-objects/AchievementDisplay';
|
||||
import { Card } from '@/ui/Card';
|
||||
import { Grid } from '@/ui/primitives/Grid';
|
||||
import { Grid } from '@/ui/Grid';
|
||||
import { Heading } from '@/ui/Heading';
|
||||
import { Icon } from '@/ui/Icon';
|
||||
import { Stack } from '@/ui/primitives/Stack';
|
||||
import { Group } from '@/ui/Group';
|
||||
import { Text } from '@/ui/Text';
|
||||
import { Award, Crown, Medal, Star, Target, Trophy, Zap } from 'lucide-react';
|
||||
import React from 'react';
|
||||
|
||||
interface Achievement {
|
||||
id: string;
|
||||
@@ -35,44 +36,44 @@ function getAchievementIcon(icon: string) {
|
||||
export function AchievementGrid({ achievements }: AchievementGridProps) {
|
||||
return (
|
||||
<Card>
|
||||
<Stack mb={4}>
|
||||
<Stack direction="row" align="center" justify="between">
|
||||
<Heading level={2} icon={<Icon icon={Award} size={5} color="#facc15" />}>
|
||||
Achievements
|
||||
</Heading>
|
||||
<Text size="sm" color="text-gray-500" weight="normal">{achievements.length} earned</Text>
|
||||
</Stack>
|
||||
</Stack>
|
||||
<Group direction="column" gap={4} fullWidth>
|
||||
<Group direction="row" align="center" justify="between" fullWidth>
|
||||
<Group direction="row" align="center" gap={2}>
|
||||
<Icon icon={Award} size={5} intent="warning" />
|
||||
<Heading level={2}>
|
||||
Achievements
|
||||
</Heading>
|
||||
</Group>
|
||||
<Text size="sm" variant="low" weight="normal">{achievements.length} earned</Text>
|
||||
</Group>
|
||||
</Group>
|
||||
<Grid cols={1} gap={4}>
|
||||
{achievements.map((achievement) => {
|
||||
const AchievementIcon = getAchievementIcon(achievement.icon);
|
||||
const rarity = AchievementDisplay.getRarityColor(achievement.rarity);
|
||||
const rarity = AchievementDisplay.getRarityVariant(achievement.rarity);
|
||||
return (
|
||||
<Card
|
||||
key={achievement.id}
|
||||
variant="outline"
|
||||
p={4}
|
||||
rounded="xl"
|
||||
className={rarity.surface === 'muted' ? 'bg-panel-gray/40' : ''}
|
||||
variant={rarity.surface}
|
||||
>
|
||||
<Stack direction="row" align="start" gap={3}>
|
||||
<Stack bg="panel-gray/40" rounded="lg" p={3} align="center" justify="center">
|
||||
<Icon icon={AchievementIcon} size={5} color={rarity.icon} />
|
||||
</Stack>
|
||||
<Stack flex={1}>
|
||||
<Text weight="semibold" size="sm" color="text-white" block>{achievement.title}</Text>
|
||||
<Text size="xs" color="text-gray-400" block mt={1}>{achievement.description}</Text>
|
||||
<Stack direction="row" align="center" gap={2} mt={2}>
|
||||
<Text size="xs" color={rarity.text} weight="medium">
|
||||
{achievement.rarity.toUpperCase()}
|
||||
<Group direction="row" align="start" gap={3}>
|
||||
<Card variant="default" style={{ padding: '0.75rem' }}>
|
||||
<Icon icon={AchievementIcon} size={5} intent={rarity.iconIntent} />
|
||||
</Card>
|
||||
<Group direction="column" fullWidth>
|
||||
<Text weight="semibold" size="sm" variant="high" block>{achievement.title}</Text>
|
||||
<Text size="xs" variant="med" block>{achievement.description}</Text>
|
||||
<Group direction="row" align="center" gap={2}>
|
||||
<Text size="xs" variant={rarity.text} weight="medium" uppercase>
|
||||
{achievement.rarity}
|
||||
</Text>
|
||||
<Text size="xs" color="text-gray-500">•</Text>
|
||||
<Text size="xs" color="text-gray-500">
|
||||
<Text size="xs" variant="low">•</Text>
|
||||
<Text size="xs" variant="low">
|
||||
{AchievementDisplay.formatDate(achievement.earnedAt)}
|
||||
</Text>
|
||||
</Stack>
|
||||
</Stack>
|
||||
</Stack>
|
||||
</Group>
|
||||
</Group>
|
||||
</Group>
|
||||
</Card>
|
||||
);
|
||||
})}
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import { Text } from '@/ui/Text';
|
||||
import { Stack } from '@/ui/primitives/Stack';
|
||||
import { Group } from '@/ui/Group';
|
||||
import { Card } from '@/ui/Card';
|
||||
import React from 'react';
|
||||
|
||||
interface MilestoneItemProps {
|
||||
label: string;
|
||||
@@ -9,12 +11,14 @@ interface MilestoneItemProps {
|
||||
|
||||
export function MilestoneItem({ label, value, icon }: MilestoneItemProps) {
|
||||
return (
|
||||
<Stack direction="row" align="center" justify="between" p={3} rounded="md" bg="bg-deep-graphite" border borderColor="border-charcoal-outline">
|
||||
<Stack direction="row" align="center" gap={3}>
|
||||
<Text size="xl">{icon}</Text>
|
||||
<Text size="sm" color="text-gray-400">{label}</Text>
|
||||
</Stack>
|
||||
<Text size="sm" weight="medium" color="text-white">{value}</Text>
|
||||
</Stack>
|
||||
<Card variant="dark">
|
||||
<Group direction="row" align="center" justify="between" fullWidth>
|
||||
<Group direction="row" align="center" gap={3}>
|
||||
<Text size="xl">{icon}</Text>
|
||||
<Text size="sm" variant="low">{label}</Text>
|
||||
</Group>
|
||||
<Text size="sm" weight="medium" variant="high">{value}</Text>
|
||||
</Group>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,27 +1,25 @@
|
||||
'use client';
|
||||
|
||||
import { Input } from '@/ui/Input';
|
||||
import { Stack } from '@/ui/primitives/Stack';
|
||||
import { ControlBar } from '@/ui/ControlBar';
|
||||
import { Select } from '@/ui/Select';
|
||||
import { Text } from '@/ui/Text';
|
||||
import { Group } from '@/ui/Group';
|
||||
import { useState } from 'react';
|
||||
|
||||
export function ActionFiltersBar() {
|
||||
const [filter, setFilter] = useState('all');
|
||||
|
||||
return (
|
||||
<Stack
|
||||
direction="row"
|
||||
h="12"
|
||||
borderBottom
|
||||
borderColor="border-[#23272B]"
|
||||
align="center"
|
||||
px={6}
|
||||
bg="bg-[#0C0D0F]"
|
||||
gap={6}
|
||||
<ControlBar
|
||||
actions={
|
||||
<Input
|
||||
placeholder="SEARCH_ID..."
|
||||
/>
|
||||
}
|
||||
>
|
||||
<Stack direction="row" align="center" gap={2}>
|
||||
<Text size="xs" color="text-gray-500" weight="bold" uppercase>Filter:</Text>
|
||||
<Group direction="row" align="center" gap={2}>
|
||||
<Text size="xs" variant="low" weight="bold" uppercase>Filter:</Text>
|
||||
<Select
|
||||
options={[
|
||||
{ label: 'All Types', value: 'all' },
|
||||
@@ -32,9 +30,9 @@ export function ActionFiltersBar() {
|
||||
onChange={(e) => setFilter(e.target.value)}
|
||||
fullWidth={false}
|
||||
/>
|
||||
</Stack>
|
||||
<Stack direction="row" align="center" gap={2}>
|
||||
<Text size="xs" color="text-gray-500" weight="bold" uppercase>Status:</Text>
|
||||
</Group>
|
||||
<Group direction="row" align="center" gap={2}>
|
||||
<Text size="xs" variant="low" weight="bold" uppercase>Status:</Text>
|
||||
<Select
|
||||
options={[
|
||||
{ label: 'All Status', value: 'all' },
|
||||
@@ -46,12 +44,7 @@ export function ActionFiltersBar() {
|
||||
onChange={() => {}}
|
||||
fullWidth={false}
|
||||
/>
|
||||
</Stack>
|
||||
<Stack className="ml-auto">
|
||||
<Input
|
||||
placeholder="SEARCH_ID..."
|
||||
/>
|
||||
</Stack>
|
||||
</Stack>
|
||||
</Group>
|
||||
</ControlBar>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -28,19 +28,19 @@ export function ActionList({ actions }: ActionListProps) {
|
||||
clickable
|
||||
>
|
||||
<TableCell>
|
||||
<Text font="mono" size="xs" color="text-gray-400">{action.timestamp}</Text>
|
||||
<Text font="mono" size="xs" variant="low">{action.timestamp}</Text>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Text size="xs" weight="medium" color="text-gray-200">{action.type}</Text>
|
||||
<Text size="xs" weight="medium" variant="med">{action.type}</Text>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Text size="xs" color="text-gray-400">{action.initiator}</Text>
|
||||
<Text size="xs" variant="low">{action.initiator}</Text>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<ActionStatusBadge status={action.status} />
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Text size="xs" color="text-gray-400">
|
||||
<Text size="xs" variant="low">
|
||||
{action.details}
|
||||
</Text>
|
||||
</TableCell>
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
'use client';
|
||||
|
||||
import { Stack } from '@/ui/primitives/Stack';
|
||||
import { Header } from '@/ui/Header';
|
||||
import { StatusIndicator } from '@/ui/StatusIndicator';
|
||||
import { Text } from '@/ui/Text';
|
||||
import { Activity } from 'lucide-react';
|
||||
import React from 'react';
|
||||
|
||||
interface ActionsHeaderProps {
|
||||
title: string;
|
||||
@@ -11,31 +12,15 @@ interface ActionsHeaderProps {
|
||||
|
||||
export function ActionsHeader({ title }: ActionsHeaderProps) {
|
||||
return (
|
||||
<Stack
|
||||
as="header"
|
||||
direction="row"
|
||||
h="16"
|
||||
borderBottom
|
||||
borderColor="border-[#23272B]"
|
||||
align="center"
|
||||
px={6}
|
||||
bg="bg-[#141619]"
|
||||
<Header
|
||||
actions={<StatusIndicator icon={Activity} variant="info" label="SYSTEM_READY" />}
|
||||
>
|
||||
<Stack direction="row" align="center" gap={4}>
|
||||
<Stack
|
||||
w="2"
|
||||
h="6"
|
||||
bg="bg-[#198CFF]"
|
||||
rounded="sm"
|
||||
shadow="shadow-[0_0_8px_rgba(25,140,255,0.5)]"
|
||||
>{null}</Stack>
|
||||
<Text as="h1" size="xl" weight="medium" letterSpacing="tight" uppercase>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '1rem' }}>
|
||||
<div style={{ width: '0.5rem', height: '1.5rem', backgroundColor: 'var(--ui-color-intent-primary)', borderRadius: 'var(--ui-radius-sm)' }} />
|
||||
<Text as="h1" size="xl" weight="medium" variant="high" uppercase>
|
||||
{title}
|
||||
</Text>
|
||||
</Stack>
|
||||
<Stack className="ml-auto" direction="row" align="center" gap={4}>
|
||||
<StatusIndicator icon={Activity} variant="info" label="SYSTEM_READY" />
|
||||
</Stack>
|
||||
</Stack>
|
||||
</div>
|
||||
</Header>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,9 +1,6 @@
|
||||
'use client';
|
||||
|
||||
import { Card } from '@/ui/Card';
|
||||
import { Heading } from '@/ui/Heading';
|
||||
import { Stack } from '@/ui/primitives/Stack';
|
||||
import { Text } from '@/ui/Text';
|
||||
import { DangerZone } from '@/ui/DangerZone';
|
||||
import React from 'react';
|
||||
|
||||
interface AdminDangerZonePanelProps {
|
||||
@@ -24,20 +21,11 @@ export function AdminDangerZonePanel({
|
||||
children
|
||||
}: AdminDangerZonePanelProps) {
|
||||
return (
|
||||
<Card borderColor="border-error-red/30" bg="bg-error-red/5">
|
||||
<Stack direction={{ base: 'col', md: 'row' }} align="center" justify="between" gap={6}>
|
||||
<Stack>
|
||||
<Heading level={4} weight="bold" color="text-error-red">
|
||||
{title}
|
||||
</Heading>
|
||||
<Text size="sm" color="text-gray-400" block mt={1}>
|
||||
{description}
|
||||
</Text>
|
||||
</Stack>
|
||||
<Stack>
|
||||
{children}
|
||||
</Stack>
|
||||
</Stack>
|
||||
</Card>
|
||||
<DangerZone
|
||||
title={title}
|
||||
description={description}
|
||||
>
|
||||
{children}
|
||||
</DangerZone>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
'use client';
|
||||
|
||||
import { Card } from '@/ui/Card';
|
||||
import { Stack } from '@/ui/primitives/Stack';
|
||||
import React from 'react';
|
||||
|
||||
interface AdminDataTableProps {
|
||||
@@ -20,13 +19,15 @@ export function AdminDataTable({
|
||||
maxHeight
|
||||
}: AdminDataTableProps) {
|
||||
return (
|
||||
<Card p={0} overflow="hidden">
|
||||
<Stack
|
||||
overflow="auto"
|
||||
maxHeight={typeof maxHeight === 'number' ? `${maxHeight}px` : maxHeight}
|
||||
<Card padding={0}>
|
||||
<div
|
||||
style={{
|
||||
overflow: 'auto',
|
||||
maxHeight: typeof maxHeight === 'number' ? `${maxHeight}px` : maxHeight
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</Stack>
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,8 +1,6 @@
|
||||
'use client';
|
||||
|
||||
import { Icon } from '@/ui/Icon';
|
||||
import { Stack } from '@/ui/primitives/Stack';
|
||||
import { Text } from '@/ui/Text';
|
||||
import { EmptyState } from '@/ui/EmptyState';
|
||||
import { LucideIcon } from 'lucide-react';
|
||||
import React from 'react';
|
||||
|
||||
@@ -26,23 +24,13 @@ export function AdminEmptyState({
|
||||
action
|
||||
}: AdminEmptyStateProps) {
|
||||
return (
|
||||
<Stack center py={20} gap={4}>
|
||||
<Icon icon={icon} size={12} color="#23272B" />
|
||||
<Stack align="center">
|
||||
<Text size="lg" weight="bold" color="text-white" block textAlign="center">
|
||||
{title}
|
||||
</Text>
|
||||
{description && (
|
||||
<Text size="sm" color="text-gray-500" block mt={1} textAlign="center">
|
||||
{description}
|
||||
</Text>
|
||||
)}
|
||||
</Stack>
|
||||
{action && (
|
||||
<Stack mt={2}>
|
||||
{action}
|
||||
</Stack>
|
||||
)}
|
||||
</Stack>
|
||||
<EmptyState
|
||||
icon={icon}
|
||||
title={title}
|
||||
description={description}
|
||||
variant="minimal"
|
||||
>
|
||||
{action}
|
||||
</EmptyState>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,9 +1,7 @@
|
||||
'use client';
|
||||
|
||||
import { ProgressLine } from '@/components/shared/ux/ProgressLine';
|
||||
import { Heading } from '@/ui/Heading';
|
||||
import { Stack } from '@/ui/primitives/Stack';
|
||||
import { Text } from '@/ui/Text';
|
||||
import { ProgressLine } from '@/ui/ProgressLine';
|
||||
import { SectionHeader } from '@/ui/SectionHeader';
|
||||
import React from 'react';
|
||||
|
||||
interface AdminHeaderPanelProps {
|
||||
@@ -26,27 +24,11 @@ export function AdminHeaderPanel({
|
||||
isLoading = false
|
||||
}: AdminHeaderPanelProps) {
|
||||
return (
|
||||
<Stack position="relative" pb={4} borderBottom borderColor="border-charcoal-outline">
|
||||
<Stack direction="row" align="center" justify="between">
|
||||
<Stack>
|
||||
<Heading level={1} weight="bold" color="text-white">
|
||||
{title}
|
||||
</Heading>
|
||||
{description && (
|
||||
<Text size="sm" color="text-gray-400" block mt={1}>
|
||||
{description}
|
||||
</Text>
|
||||
)}
|
||||
</Stack>
|
||||
{actions && (
|
||||
<Stack direction="row" align="center" gap={3}>
|
||||
{actions}
|
||||
</Stack>
|
||||
)}
|
||||
</Stack>
|
||||
<Stack position="absolute" bottom="0" left="0" w="full">
|
||||
<ProgressLine isLoading={isLoading} />
|
||||
</Stack>
|
||||
</Stack>
|
||||
<SectionHeader
|
||||
title={title}
|
||||
description={description}
|
||||
actions={actions}
|
||||
loading={<ProgressLine isLoading={isLoading} />}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,8 +1,6 @@
|
||||
'use client';
|
||||
|
||||
import { Heading } from '@/ui/Heading';
|
||||
import { Stack } from '@/ui/primitives/Stack';
|
||||
import { Text } from '@/ui/Text';
|
||||
import { SectionHeader } from '@/ui/SectionHeader';
|
||||
import React from 'react';
|
||||
|
||||
interface AdminSectionHeaderProps {
|
||||
@@ -23,22 +21,11 @@ export function AdminSectionHeader({
|
||||
actions
|
||||
}: AdminSectionHeaderProps) {
|
||||
return (
|
||||
<Stack direction="row" align="center" justify="between" mb={4}>
|
||||
<Stack>
|
||||
<Heading level={3} weight="bold" color="text-white">
|
||||
{title}
|
||||
</Heading>
|
||||
{description && (
|
||||
<Text size="xs" color="text-gray-500" block mt={0.5}>
|
||||
{description}
|
||||
</Text>
|
||||
)}
|
||||
</Stack>
|
||||
{actions && (
|
||||
<Stack direction="row" align="center" gap={2}>
|
||||
{actions}
|
||||
</Stack>
|
||||
)}
|
||||
</Stack>
|
||||
<SectionHeader
|
||||
title={title}
|
||||
description={description}
|
||||
actions={actions}
|
||||
variant="minimal"
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,14 +1,14 @@
|
||||
'use client';
|
||||
|
||||
import { Grid } from '@/ui/primitives/Grid';
|
||||
import { StatCard } from '@/ui/StatCard';
|
||||
import { StatGrid } from '@/ui/StatGrid';
|
||||
import { LucideIcon } from 'lucide-react';
|
||||
import React from 'react';
|
||||
|
||||
interface AdminStat {
|
||||
label: string;
|
||||
value: string | number;
|
||||
icon: LucideIcon;
|
||||
variant?: 'blue' | 'purple' | 'green' | 'orange';
|
||||
intent?: 'primary' | 'success' | 'warning' | 'critical' | 'telemetry';
|
||||
trend?: {
|
||||
value: number;
|
||||
isPositive: boolean;
|
||||
@@ -27,18 +27,10 @@ interface AdminStatsPanelProps {
|
||||
*/
|
||||
export function AdminStatsPanel({ stats }: AdminStatsPanelProps) {
|
||||
return (
|
||||
<Grid cols={1} mdCols={2} lgCols={4} gap={4}>
|
||||
{stats.map((stat, index) => (
|
||||
<StatCard
|
||||
key={stat.label}
|
||||
label={stat.label}
|
||||
value={stat.value}
|
||||
icon={stat.icon}
|
||||
variant={stat.variant}
|
||||
trend={stat.trend}
|
||||
delay={index * 0.05}
|
||||
/>
|
||||
))}
|
||||
</Grid>
|
||||
<StatGrid
|
||||
stats={stats}
|
||||
columns={{ base: 1, md: 2, lg: 4 }}
|
||||
variant="card"
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
'use client';
|
||||
|
||||
import { Card } from '@/ui/Card';
|
||||
import { Stack } from '@/ui/primitives/Stack';
|
||||
import { ControlBar } from '@/ui/ControlBar';
|
||||
import React from 'react';
|
||||
|
||||
interface AdminToolbarProps {
|
||||
@@ -20,17 +19,11 @@ export function AdminToolbar({
|
||||
leftContent
|
||||
}: AdminToolbarProps) {
|
||||
return (
|
||||
<Card p={3} bg="bg-charcoal/50" borderColor="border-charcoal-outline">
|
||||
<Stack direction="row" align="center" justify="between" gap={4} wrap>
|
||||
{leftContent && (
|
||||
<Stack flexGrow={1}>
|
||||
{leftContent}
|
||||
</Stack>
|
||||
)}
|
||||
<Stack direction="row" align="center" gap={3} flexGrow={leftContent ? 0 : 1} wrap>
|
||||
{children}
|
||||
</Stack>
|
||||
</Stack>
|
||||
</Card>
|
||||
<ControlBar
|
||||
leftContent={leftContent}
|
||||
variant="dark"
|
||||
>
|
||||
{children}
|
||||
</ControlBar>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -3,9 +3,10 @@
|
||||
import { DateDisplay } from '@/lib/display-objects/DateDisplay';
|
||||
import { AdminUsersViewData } from '@/lib/view-data/AdminUsersViewData';
|
||||
import { Button } from '@/ui/Button';
|
||||
import { Icon } from '@/ui/Icon';
|
||||
import { Stack } from '@/ui/primitives/Stack';
|
||||
import { IconButton } from '@/ui/IconButton';
|
||||
import { SimpleCheckbox } from '@/ui/SimpleCheckbox';
|
||||
import { Badge } from '@/ui/Badge';
|
||||
import { DriverIdentity } from '@/ui/DriverIdentity';
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
@@ -15,8 +16,9 @@ import {
|
||||
TableRow
|
||||
} from '@/ui/Table';
|
||||
import { Text } from '@/ui/Text';
|
||||
import { MoreVertical, Shield, Trash2 } from 'lucide-react';
|
||||
import { MoreVertical, Trash2 } from 'lucide-react';
|
||||
import { UserStatusTag } from './UserStatusTag';
|
||||
import React from 'react';
|
||||
|
||||
interface AdminUsersTableProps {
|
||||
users: AdminUsersViewData['users'];
|
||||
@@ -49,7 +51,7 @@ export function AdminUsersTable({
|
||||
<Table>
|
||||
<TableHead>
|
||||
<TableRow>
|
||||
<TableHeader width="10">
|
||||
<TableHeader w="2.5rem">
|
||||
<SimpleCheckbox
|
||||
checked={allSelected}
|
||||
onChange={onSelectAll}
|
||||
@@ -74,55 +76,35 @@ export function AdminUsersTable({
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Stack direction="row" align="center" gap={3}>
|
||||
<Stack
|
||||
bg="bg-primary-blue/10"
|
||||
rounded="full"
|
||||
p={2}
|
||||
border
|
||||
borderColor="border-primary-blue/20"
|
||||
>
|
||||
<Icon icon={Shield} size={4} color="#198CFF" />
|
||||
</Stack>
|
||||
<Stack>
|
||||
<Text weight="semibold" color="text-white" block>
|
||||
{user.displayName}
|
||||
</Text>
|
||||
<Text size="xs" color="text-gray-500" block>
|
||||
{user.email}
|
||||
</Text>
|
||||
</Stack>
|
||||
</Stack>
|
||||
<DriverIdentity
|
||||
driver={{
|
||||
id: user.id,
|
||||
name: user.displayName,
|
||||
avatarUrl: null
|
||||
}}
|
||||
meta={user.email}
|
||||
size="sm"
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Stack direction="row" gap={1.5} wrap>
|
||||
<div style={{ display: 'flex', gap: '0.375rem', flexWrap: 'wrap' }}>
|
||||
{user.roles.map((role) => (
|
||||
<Stack
|
||||
key={role}
|
||||
px={2}
|
||||
py={0.5}
|
||||
rounded="full"
|
||||
bg="bg-charcoal-outline/30"
|
||||
border
|
||||
borderColor="border-charcoal-outline"
|
||||
>
|
||||
<Text size="xs" weight="medium" color="text-gray-300">
|
||||
{role}
|
||||
</Text>
|
||||
</Stack>
|
||||
<Badge key={role} variant="default" size="sm">
|
||||
{role}
|
||||
</Badge>
|
||||
))}
|
||||
</Stack>
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<UserStatusTag status={user.status} />
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Text size="sm" color="text-gray-400">
|
||||
<Text size="sm" variant="low">
|
||||
{user.lastLoginAt ? DateDisplay.formatShort(user.lastLoginAt) : 'Never'}
|
||||
</Text>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Stack direction="row" align="center" justify="end" gap={2}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'flex-end', gap: '0.5rem' }}>
|
||||
{user.status === 'active' ? (
|
||||
<Button
|
||||
size="sm"
|
||||
@@ -141,24 +123,22 @@ export function AdminUsersTable({
|
||||
</Button>
|
||||
) : null}
|
||||
|
||||
<Button
|
||||
<IconButton
|
||||
size="sm"
|
||||
variant="secondary"
|
||||
onClick={() => onDeleteUser(user.id)}
|
||||
disabled={deletingUserId === user.id}
|
||||
icon={<Icon icon={Trash2} size={3} />}
|
||||
>
|
||||
{deletingUserId === user.id ? '...' : ''}
|
||||
</Button>
|
||||
icon={Trash2}
|
||||
title="Delete"
|
||||
/>
|
||||
|
||||
<Button
|
||||
<IconButton
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
icon={<Icon icon={MoreVertical} size={4} />}
|
||||
>
|
||||
{''}
|
||||
</Button>
|
||||
</Stack>
|
||||
icon={MoreVertical}
|
||||
title="More"
|
||||
/>
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
|
||||
@@ -1,9 +1,7 @@
|
||||
'use client';
|
||||
|
||||
import { Button } from '@/ui/Button';
|
||||
import { Stack } from '@/ui/primitives/Stack';
|
||||
import { Text } from '@/ui/Text';
|
||||
import { AnimatePresence, motion } from 'framer-motion';
|
||||
import { BulkActions } from '@/ui/BulkActions';
|
||||
import React from 'react';
|
||||
|
||||
interface BulkActionBarProps {
|
||||
@@ -28,65 +26,28 @@ export function BulkActionBar({
|
||||
onClearSelection
|
||||
}: BulkActionBarProps) {
|
||||
return (
|
||||
<AnimatePresence>
|
||||
{selectedCount > 0 && (
|
||||
<Stack
|
||||
as={motion.div}
|
||||
initial={{ y: 100, opacity: 0 }}
|
||||
animate={{ y: 0, opacity: 1 }}
|
||||
exit={{ y: 100, opacity: 0 }}
|
||||
position="fixed"
|
||||
bottom="8"
|
||||
left="1/2"
|
||||
translateX="-1/2"
|
||||
zIndex={50}
|
||||
bg="bg-surface-charcoal"
|
||||
border
|
||||
borderColor="border-primary-blue/50"
|
||||
rounded="xl"
|
||||
shadow="xl"
|
||||
px={6}
|
||||
py={4}
|
||||
bgOpacity={0.9}
|
||||
blur="md"
|
||||
<BulkActions
|
||||
selectedCount={selectedCount}
|
||||
isOpen={selectedCount > 0}
|
||||
>
|
||||
{actions.map((action) => (
|
||||
<Button
|
||||
key={action.label}
|
||||
size="sm"
|
||||
variant={action.variant === 'danger' ? 'danger' : (action.variant || 'primary')}
|
||||
onClick={action.onClick}
|
||||
icon={action.icon}
|
||||
>
|
||||
<Stack direction="row" align="center" gap={8}>
|
||||
<Stack direction="row" align="center" gap={3}>
|
||||
<Stack bg="bg-primary-blue" rounded="full" px={2} py={0.5}>
|
||||
<Text size="xs" weight="bold" color="text-white">
|
||||
{selectedCount}
|
||||
</Text>
|
||||
</Stack>
|
||||
<Text size="sm" weight="medium" color="text-white">
|
||||
Items Selected
|
||||
</Text>
|
||||
</Stack>
|
||||
|
||||
<Stack w="px" h="6" bg="bg-charcoal-outline">{null}</Stack>
|
||||
|
||||
<Stack direction="row" align="center" gap={3}>
|
||||
{actions.map((action) => (
|
||||
<Button
|
||||
key={action.label}
|
||||
size="sm"
|
||||
variant={action.variant === 'danger' ? 'secondary' : (action.variant || 'primary')}
|
||||
onClick={action.onClick}
|
||||
icon={action.icon}
|
||||
>
|
||||
{action.label}
|
||||
</Button>
|
||||
))}
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
onClick={onClearSelection}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
</Stack>
|
||||
</Stack>
|
||||
</Stack>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
{action.label}
|
||||
</Button>
|
||||
))}
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
onClick={onClearSelection}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
</BulkActions>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -3,11 +3,11 @@
|
||||
import { Button } from '@/ui/Button';
|
||||
import { Icon } from '@/ui/Icon';
|
||||
import { Input } from '@/ui/Input';
|
||||
import { Stack } from '@/ui/primitives/Stack';
|
||||
import { Select } from '@/ui/Select';
|
||||
import { Text } from '@/ui/Text';
|
||||
import { Filter, Search } from 'lucide-react';
|
||||
import { AdminToolbar } from './AdminToolbar';
|
||||
import React from 'react';
|
||||
|
||||
interface UserFiltersProps {
|
||||
search: string;
|
||||
@@ -31,9 +31,9 @@ export function UserFilters({
|
||||
return (
|
||||
<AdminToolbar
|
||||
leftContent={
|
||||
<Stack direction="row" align="center" gap={2}>
|
||||
<Icon icon={Filter} size={4} color="#9ca3af" />
|
||||
<Text weight="medium" color="text-white">Filters</Text>
|
||||
<React.Fragment>
|
||||
<Icon icon={Filter} size={4} intent="low" />
|
||||
<Text weight="medium" variant="high">Filters</Text>
|
||||
{(search || roleFilter || statusFilter) && (
|
||||
<Button
|
||||
onClick={onClearFilters}
|
||||
@@ -43,7 +43,7 @@ export function UserFilters({
|
||||
Clear all
|
||||
</Button>
|
||||
)}
|
||||
</Stack>
|
||||
</React.Fragment>
|
||||
}
|
||||
>
|
||||
<Input
|
||||
@@ -51,8 +51,7 @@ export function UserFilters({
|
||||
placeholder="Search by email or name..."
|
||||
value={search}
|
||||
onChange={(e) => onSearch(e.target.value)}
|
||||
icon={<Icon icon={Search} size={4} color="#9ca3af" />}
|
||||
width="300px"
|
||||
style={{ width: '300px' }}
|
||||
/>
|
||||
|
||||
<Select
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
'use client';
|
||||
|
||||
import { MetricCard } from '@/ui/MetricCard';
|
||||
import { Grid } from '@/ui/primitives/Grid';
|
||||
import { StatGrid } from '@/ui/StatGrid';
|
||||
import { Shield, Users } from 'lucide-react';
|
||||
import React from 'react';
|
||||
|
||||
interface UserStatsSummaryProps {
|
||||
total: number;
|
||||
@@ -11,25 +12,16 @@ interface UserStatsSummaryProps {
|
||||
}
|
||||
|
||||
export function UserStatsSummary({ total, activeCount, adminCount }: UserStatsSummaryProps) {
|
||||
const stats = [
|
||||
{ label: 'Total Users', value: total, icon: Users, intent: 'primary' as const },
|
||||
{ label: 'Active', value: activeCount, intent: 'success' as const },
|
||||
{ label: 'Admins', value: adminCount, icon: Shield, intent: 'telemetry' as const },
|
||||
];
|
||||
|
||||
return (
|
||||
<Grid cols={3} gap={4}>
|
||||
<MetricCard
|
||||
label="Total Users"
|
||||
value={total}
|
||||
icon={Users}
|
||||
color="text-blue-400"
|
||||
/>
|
||||
<MetricCard
|
||||
label="Active"
|
||||
value={activeCount}
|
||||
color="text-performance-green"
|
||||
/>
|
||||
<MetricCard
|
||||
label="Admins"
|
||||
value={adminCount}
|
||||
icon={Shield}
|
||||
color="text-purple-400"
|
||||
/>
|
||||
</Grid>
|
||||
<StatGrid
|
||||
stats={stats}
|
||||
columns={3}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,17 +1,15 @@
|
||||
import React from 'react';
|
||||
import { Footer } from '@/ui/Footer';
|
||||
|
||||
interface AppFooterProps {
|
||||
children?: React.ReactNode;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* AppFooter is the bottom section of the application.
|
||||
*/
|
||||
export function AppFooter({ children, className = '' }: AppFooterProps) {
|
||||
export function AppFooter({ children }: AppFooterProps) {
|
||||
return (
|
||||
<footer className={`bg-[#141619] border-t border-[#23272B] py-8 px-4 md:px-6 ${className}`}>
|
||||
{children}
|
||||
</footer>
|
||||
<Footer />
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,20 +1,18 @@
|
||||
import React from 'react';
|
||||
import { Header } from '@/ui/Header';
|
||||
|
||||
interface AppHeaderProps {
|
||||
children: React.ReactNode;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* AppHeader is the top control bar of the application.
|
||||
* It follows the "Telemetry Workspace" structure.
|
||||
*/
|
||||
export function AppHeader({ children, className = '' }: AppHeaderProps) {
|
||||
export function AppHeader({ children }: AppHeaderProps) {
|
||||
return (
|
||||
<header
|
||||
className={`sticky top-0 z-50 h-16 md:h-20 bg-[#0C0D0F]/80 backdrop-blur-md border-b border-[#23272B] flex items-center px-4 md:px-6 ${className}`}
|
||||
>
|
||||
<Header>
|
||||
{children}
|
||||
</header>
|
||||
</Header>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,20 +1,18 @@
|
||||
import React from 'react';
|
||||
import { Sidebar } from '@/ui/Sidebar';
|
||||
|
||||
interface AppSidebarProps {
|
||||
children?: React.ReactNode;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* AppSidebar is the "dashboard rail" of the application.
|
||||
* It provides global navigation and context.
|
||||
*/
|
||||
export function AppSidebar({ children, className = '' }: AppSidebarProps) {
|
||||
export function AppSidebar({ children }: AppSidebarProps) {
|
||||
return (
|
||||
<aside
|
||||
className={`hidden lg:flex flex-col w-64 bg-[#141619] border-r border-[#23272B] overflow-y-auto ${className}`}
|
||||
>
|
||||
<Sidebar>
|
||||
{children}
|
||||
</aside>
|
||||
</Sidebar>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
'use client';
|
||||
|
||||
import { Card } from '@/ui/Card';
|
||||
import { Stack } from '@/ui/primitives/Stack';
|
||||
import { Text } from '@/ui/Text';
|
||||
import { SectionHeader } from '@/ui/SectionHeader';
|
||||
import React from 'react';
|
||||
|
||||
interface AuthCardProps {
|
||||
@@ -18,31 +18,15 @@ interface AuthCardProps {
|
||||
*/
|
||||
export function AuthCard({ children, title, description }: AuthCardProps) {
|
||||
return (
|
||||
<Card bg="surface-charcoal" borderColor="outline-steel" rounded="lg" position="relative" overflow="hidden">
|
||||
{/* Subtle top accent line */}
|
||||
<Stack
|
||||
position="absolute"
|
||||
top="0"
|
||||
left="0"
|
||||
w="full"
|
||||
h="1px"
|
||||
bg="linear-gradient(to right, transparent, rgba(25, 140, 255, 0.3), transparent)"
|
||||
>{null}</Stack>
|
||||
|
||||
<Stack p={{ base: 6, md: 8 }}>
|
||||
<Stack as="header" mb={8} align="center">
|
||||
<Text as="h1" size="xl" weight="semibold" color="text-white" letterSpacing="tight" mb={2} block textAlign="center">
|
||||
{title}
|
||||
</Text>
|
||||
{description && (
|
||||
<Text size="sm" color="text-med" block textAlign="center">
|
||||
{description}
|
||||
</Text>
|
||||
)}
|
||||
</Stack>
|
||||
|
||||
<Card variant="dark">
|
||||
<SectionHeader
|
||||
title={title}
|
||||
description={description}
|
||||
variant="minimal"
|
||||
/>
|
||||
<div>
|
||||
{children}
|
||||
</Stack>
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
'use client';
|
||||
|
||||
import { Grid } from '@/ui/primitives/Grid';
|
||||
import { Grid } from '@/ui/Grid';
|
||||
import React from 'react';
|
||||
|
||||
interface AuthProviderButtonsProps {
|
||||
@@ -14,8 +14,10 @@ interface AuthProviderButtonsProps {
|
||||
*/
|
||||
export function AuthProviderButtons({ children }: AuthProviderButtonsProps) {
|
||||
return (
|
||||
<Grid cols={1} gap={3} mb={6}>
|
||||
{children}
|
||||
</Grid>
|
||||
<div style={{ marginBottom: '1.5rem' }}>
|
||||
<Grid cols={1} gap={3}>
|
||||
{children}
|
||||
</Grid>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
'use client';
|
||||
|
||||
import { Stack } from '@/ui/primitives/Stack';
|
||||
import { AuthLayout } from '@/ui/AuthLayout';
|
||||
import React from 'react';
|
||||
|
||||
interface AuthShellProps {
|
||||
@@ -15,37 +15,8 @@ interface AuthShellProps {
|
||||
*/
|
||||
export function AuthShell({ children }: AuthShellProps) {
|
||||
return (
|
||||
<Stack as="main" minHeight="screen" align="center" justify="center" p={4} bg="base-black" position="relative" overflow="hidden">
|
||||
{/* Subtle background glow - top right */}
|
||||
<Stack
|
||||
position="absolute"
|
||||
top="-10%"
|
||||
right="-10%"
|
||||
w="40%"
|
||||
h="40%"
|
||||
rounded="full"
|
||||
bg="rgba(25, 140, 255, 0.05)"
|
||||
blur="xl"
|
||||
pointerEvents="none"
|
||||
aria-hidden="true"
|
||||
>{null}</Stack>
|
||||
{/* Subtle background glow - bottom left */}
|
||||
<Stack
|
||||
position="absolute"
|
||||
bottom="-10%"
|
||||
left="-10%"
|
||||
w="40%"
|
||||
h="40%"
|
||||
rounded="full"
|
||||
bg="rgba(78, 212, 224, 0.05)"
|
||||
blur="xl"
|
||||
pointerEvents="none"
|
||||
aria-hidden="true"
|
||||
>{null}</Stack>
|
||||
|
||||
<Stack w="full" maxWidth="400px" position="relative" zIndex={10} {...({ animate: "fade-in" } as any)}>
|
||||
{children}
|
||||
</Stack>
|
||||
</Stack>
|
||||
<AuthLayout>
|
||||
{children}
|
||||
</AuthLayout>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -8,7 +8,7 @@ import {
|
||||
Trophy,
|
||||
Car,
|
||||
} from 'lucide-react';
|
||||
import { WorkflowMockup, WorkflowStep } from '@/components/mockups/WorkflowMockup';
|
||||
import { WorkflowMockup, WorkflowStep } from '@/ui/WorkflowMockup';
|
||||
|
||||
const WORKFLOW_STEPS: WorkflowStep[] = [
|
||||
{
|
||||
@@ -16,35 +16,35 @@ const WORKFLOW_STEPS: WorkflowStep[] = [
|
||||
icon: UserPlus,
|
||||
title: 'Create Account',
|
||||
description: 'Sign up with email or connect iRacing',
|
||||
color: 'text-primary-blue',
|
||||
intent: 'primary',
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
icon: LinkIcon,
|
||||
title: 'Link iRacing',
|
||||
description: 'Connect your iRacing profile for stats',
|
||||
color: 'text-purple-400',
|
||||
intent: 'telemetry',
|
||||
},
|
||||
{
|
||||
id: 3,
|
||||
icon: Settings,
|
||||
title: 'Configure Profile',
|
||||
description: 'Set up your racing preferences',
|
||||
color: 'text-warning-amber',
|
||||
intent: 'warning',
|
||||
},
|
||||
{
|
||||
id: 4,
|
||||
icon: Trophy,
|
||||
title: 'Join Leagues',
|
||||
description: 'Find and join competitive leagues',
|
||||
color: 'text-performance-green',
|
||||
intent: 'success',
|
||||
},
|
||||
{
|
||||
id: 5,
|
||||
icon: Car,
|
||||
title: 'Start Racing',
|
||||
description: 'Compete and track your progress',
|
||||
color: 'text-primary-blue',
|
||||
intent: 'primary',
|
||||
},
|
||||
];
|
||||
|
||||
|
||||
@@ -1,28 +1,29 @@
|
||||
import { Heading } from '@/ui/Heading';
|
||||
import { Icon } from '@/ui/Icon';
|
||||
import { Stack } from '@/ui/primitives/Stack';
|
||||
import { Text } from '@/ui/Text';
|
||||
import { ListItem, ListItemInfo } from '@/ui/ListItem';
|
||||
import { motion } from 'framer-motion';
|
||||
import { Car, Trophy, Users } from 'lucide-react';
|
||||
import React from 'react';
|
||||
|
||||
const USER_ROLES = [
|
||||
{
|
||||
icon: Car,
|
||||
title: 'Driver',
|
||||
description: 'Race, track stats, join teams',
|
||||
color: 'primary-blue',
|
||||
intent: 'primary' as const,
|
||||
},
|
||||
{
|
||||
icon: Trophy,
|
||||
title: 'League Admin',
|
||||
description: 'Organize leagues and events',
|
||||
color: 'performance-green',
|
||||
intent: 'success' as const,
|
||||
},
|
||||
{
|
||||
icon: Users,
|
||||
title: 'Team Manager',
|
||||
description: 'Manage team and drivers',
|
||||
color: 'purple-400',
|
||||
intent: 'telemetry' as const,
|
||||
},
|
||||
] as const;
|
||||
|
||||
@@ -33,72 +34,46 @@ interface UserRolesPreviewProps {
|
||||
export function UserRolesPreview({ variant = 'full' }: UserRolesPreviewProps) {
|
||||
if (variant === 'compact') {
|
||||
return (
|
||||
<Stack mt={8} display={{ base: 'block', lg: 'none' }}>
|
||||
<Text align="center" size="xs" color="text-gray-500" mb={4} block>
|
||||
<div style={{ marginTop: '2rem' }}>
|
||||
<Text align="center" size="xs" variant="low" block marginBottom={4}>
|
||||
One account for all roles
|
||||
</Text>
|
||||
<Stack direction="row" justify="center" gap={6}>
|
||||
<div style={{ display: 'flex', justifyContent: 'center', gap: '1.5rem' }}>
|
||||
{USER_ROLES.map((role) => (
|
||||
<Stack key={role.title} direction="col" align="center">
|
||||
<Stack
|
||||
w="8"
|
||||
h="8"
|
||||
rounded="lg"
|
||||
bg={`bg-${role.color}/20`}
|
||||
align="center"
|
||||
justify="center"
|
||||
mb={1}
|
||||
>
|
||||
<Text color={`text-${role.color}`}>
|
||||
<Icon icon={role.icon} size={4} />
|
||||
</Text>
|
||||
</Stack>
|
||||
<Text size="xs" color="text-gray-500">{role.title}</Text>
|
||||
</Stack>
|
||||
<div key={role.title} style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', gap: '0.25rem' }}>
|
||||
<div style={{ width: '2rem', height: '2rem', borderRadius: '0.5rem', backgroundColor: 'var(--ui-color-bg-surface-muted)', display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
|
||||
<Icon icon={role.icon} size={4} intent={role.intent} />
|
||||
</div>
|
||||
<Text size="xs" variant="low">{role.title}</Text>
|
||||
</div>
|
||||
))}
|
||||
</Stack>
|
||||
</Stack>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Stack direction="col" gap={3} mb={8}>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '0.75rem', marginBottom: '2rem' }}>
|
||||
{USER_ROLES.map((role, index) => (
|
||||
<Stack
|
||||
as={motion.div}
|
||||
<motion.div
|
||||
key={role.title}
|
||||
{...({
|
||||
initial: { opacity: 0, x: -20 },
|
||||
animate: { opacity: 1, x: 0 },
|
||||
transition: { delay: index * 0.1 }
|
||||
} as any)}
|
||||
direction="row"
|
||||
align="center"
|
||||
gap={4}
|
||||
p={4}
|
||||
rounded="xl"
|
||||
bg="bg-iron-gray/50"
|
||||
border
|
||||
borderColor="border-charcoal-outline"
|
||||
initial={{ opacity: 0, x: -20 }}
|
||||
animate={{ opacity: 1, x: 0 }}
|
||||
transition={{ delay: index * 0.1 }}
|
||||
>
|
||||
<Stack
|
||||
w="10"
|
||||
h="10"
|
||||
rounded="lg"
|
||||
bg={`bg-${role.color}/20`}
|
||||
align="center"
|
||||
justify="center"
|
||||
>
|
||||
<Text color={`text-${role.color}`}>
|
||||
<Icon icon={role.icon} size={5} />
|
||||
</Text>
|
||||
</Stack>
|
||||
<Stack>
|
||||
<Heading level={4}>{role.title}</Heading>
|
||||
<Text size="sm" color="text-gray-500">{role.description}</Text>
|
||||
</Stack>
|
||||
</Stack>
|
||||
<ListItem>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '1rem' }}>
|
||||
<div style={{ width: '2.5rem', height: '2.5rem', borderRadius: '0.5rem', backgroundColor: 'var(--ui-color-bg-surface-muted)', display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
|
||||
<Icon icon={role.icon} size={5} intent={role.intent} />
|
||||
</div>
|
||||
<ListItemInfo
|
||||
title={role.title}
|
||||
description={role.description}
|
||||
/>
|
||||
</div>
|
||||
</ListItem>
|
||||
</motion.div>
|
||||
))}
|
||||
</Stack>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
'use client';
|
||||
|
||||
import { Panel } from '@/ui/Panel';
|
||||
import { Stack } from '@/ui/primitives/Stack';
|
||||
import { ActivityFeed } from '../feed/ActivityFeed';
|
||||
import React from 'react';
|
||||
|
||||
interface FeedItem {
|
||||
id: string;
|
||||
@@ -25,10 +27,8 @@ interface ActivityFeedPanelProps {
|
||||
*/
|
||||
export function ActivityFeedPanel({ items, hasItems }: ActivityFeedPanelProps) {
|
||||
return (
|
||||
<Panel title="Activity Feed" padding={0}>
|
||||
<Stack px={6} pb={6}>
|
||||
<ActivityFeed items={items} hasItems={hasItems} />
|
||||
</Stack>
|
||||
<Panel title="Activity Feed">
|
||||
<ActivityFeed items={items} hasItems={hasItems} />
|
||||
</Panel>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
'use client';
|
||||
|
||||
import { Heading } from '@/ui/Heading';
|
||||
import { Stack } from '@/ui/primitives/Stack';
|
||||
import { ControlBar } from '@/ui/ControlBar';
|
||||
import React from 'react';
|
||||
|
||||
interface DashboardControlBarProps {
|
||||
@@ -11,17 +13,17 @@ interface DashboardControlBarProps {
|
||||
* DashboardControlBar
|
||||
*
|
||||
* The top header bar for page-level controls and context.
|
||||
* Uses UI primitives to comply with architectural constraints.
|
||||
*/
|
||||
export function DashboardControlBar({ title, actions }: DashboardControlBarProps) {
|
||||
return (
|
||||
<Stack direction="row" h="full" align="center" justify="between" px={6}>
|
||||
<Heading level={6} weight="bold">
|
||||
{title}
|
||||
</Heading>
|
||||
<Stack direction="row" align="center" gap={4}>
|
||||
{actions}
|
||||
</Stack>
|
||||
</Stack>
|
||||
<ControlBar
|
||||
leftContent={
|
||||
<Heading level={6} weight="bold">
|
||||
{title}
|
||||
</Heading>
|
||||
}
|
||||
>
|
||||
{actions}
|
||||
</ControlBar>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -3,9 +3,10 @@ import { Avatar } from '../../ui/Avatar';
|
||||
import { Badge } from '../../ui/Badge';
|
||||
import { Heading } from '../../ui/Heading';
|
||||
import { Icon } from '../../ui/Icon';
|
||||
import { Box } from '../../ui/primitives/Box';
|
||||
import { Stack } from '../../ui/primitives/Stack';
|
||||
import { Text } from '../../ui/Text';
|
||||
import { ProfileHero, ProfileAvatar, ProfileStatsGroup, ProfileStat } from '../../ui/ProfileHero';
|
||||
import { BadgeGroup } from '../../ui/BadgeGroup';
|
||||
import { QuickStatCard, QuickStatItem } from '../../ui/QuickStatCard';
|
||||
import React from 'react';
|
||||
|
||||
interface DashboardHeroProps {
|
||||
driverName: string;
|
||||
@@ -14,7 +15,6 @@ interface DashboardHeroProps {
|
||||
rank: number;
|
||||
totalRaces: number;
|
||||
winRate: number;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function DashboardHero({
|
||||
@@ -24,123 +24,50 @@ export function DashboardHero({
|
||||
rank,
|
||||
totalRaces,
|
||||
winRate,
|
||||
className = '',
|
||||
}: DashboardHeroProps) {
|
||||
return (
|
||||
<Box
|
||||
position="relative"
|
||||
bg="bg-[#0C0D0F]"
|
||||
borderBottom
|
||||
borderColor="border-[#23272B]"
|
||||
overflow="hidden"
|
||||
className={className}
|
||||
>
|
||||
{/* Background Glow */}
|
||||
<Box
|
||||
position="absolute"
|
||||
top={-100}
|
||||
right={-100}
|
||||
w="500px"
|
||||
h="500px"
|
||||
bg="bg-primary-blue/10"
|
||||
rounded="full"
|
||||
blur="3xl"
|
||||
/>
|
||||
<ProfileHero glowColor="aqua">
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '2rem', flexWrap: 'wrap' }}>
|
||||
{/* Avatar Section */}
|
||||
<ProfileAvatar
|
||||
badge={<Icon icon={Star} size={5} intent="high" />}
|
||||
>
|
||||
<Avatar
|
||||
src={avatarUrl || undefined}
|
||||
alt={driverName}
|
||||
size="xl"
|
||||
/>
|
||||
</ProfileAvatar>
|
||||
|
||||
<Box p={{ base: 6, md: 10 }} position="relative" zIndex={10}>
|
||||
<Stack direction={{ base: 'col', md: 'row' }} align="center" gap={8}>
|
||||
{/* Avatar Section */}
|
||||
<Box position="relative">
|
||||
<Box
|
||||
p={1}
|
||||
rounded="2xl"
|
||||
bg="bg-[#141619]"
|
||||
border
|
||||
borderColor="border-[#23272B]"
|
||||
>
|
||||
<Avatar
|
||||
src={avatarUrl}
|
||||
alt={driverName}
|
||||
size={120}
|
||||
className="rounded-xl"
|
||||
/>
|
||||
</Box>
|
||||
<Box
|
||||
position="absolute"
|
||||
bottom={-2}
|
||||
right={-2}
|
||||
w="10"
|
||||
h="10"
|
||||
rounded="xl"
|
||||
bg="bg-[#4ED4E0]"
|
||||
borderWidth="2px"
|
||||
borderStyle="solid"
|
||||
borderColor="border-[#0C0D0F]"
|
||||
display="flex"
|
||||
center
|
||||
>
|
||||
<Icon icon={Star} size={5} color="#0C0D0F" />
|
||||
</Box>
|
||||
</Box>
|
||||
{/* Info Section */}
|
||||
<div style={{ flex: 1, minWidth: '200px' }}>
|
||||
<Heading level={1}>{driverName}</Heading>
|
||||
|
||||
<ProfileStatsGroup>
|
||||
<ProfileStat label="Rating" value={rating} intent="telemetry" />
|
||||
<ProfileStat label="Rank" value={`#${rank}`} intent="warning" />
|
||||
<ProfileStat label="Starts" value={totalRaces} intent="low" />
|
||||
</ProfileStatsGroup>
|
||||
|
||||
{/* Info Section */}
|
||||
<Stack flex={1} align={{ base: 'center', md: 'start' }} gap={4}>
|
||||
<Box>
|
||||
<Heading level={1} uppercase letterSpacing="tighter" mb={2}>
|
||||
{driverName}
|
||||
</Heading>
|
||||
<Stack direction="row" gap={4}>
|
||||
<Stack gap={0.5}>
|
||||
<Text size="xs" color="text-gray-500" uppercase>Rating</Text>
|
||||
<Text size="sm" weight="bold" color="text-[#4ED4E0]" font="mono">{rating}</Text>
|
||||
</Stack>
|
||||
<Stack gap={0.5}>
|
||||
<Text size="xs" color="text-gray-500" uppercase>Rank</Text>
|
||||
<Text size="sm" weight="bold" color="text-[#FFBE4D]" font="mono">#{rank}</Text>
|
||||
</Stack>
|
||||
<Stack gap={0.5}>
|
||||
<Text size="xs" color="text-gray-500" uppercase>Starts</Text>
|
||||
<Text size="sm" weight="bold" color="text-gray-300" font="mono">{totalRaces}</Text>
|
||||
</Stack>
|
||||
</Stack>
|
||||
</Box>
|
||||
<BadgeGroup>
|
||||
<Badge variant="primary" icon={Trophy}>
|
||||
{winRate}% Win Rate
|
||||
</Badge>
|
||||
<Badge variant="info" icon={Flag}>
|
||||
Pro License
|
||||
</Badge>
|
||||
<Badge variant="default" icon={Users}>
|
||||
Team Redline
|
||||
</Badge>
|
||||
</BadgeGroup>
|
||||
</div>
|
||||
|
||||
<Stack direction="row" gap={3} wrap>
|
||||
<Badge variant="primary" rounded="lg" icon={Trophy}>
|
||||
{winRate}% Win Rate
|
||||
</Badge>
|
||||
<Badge variant="info" rounded="lg" icon={Flag}>
|
||||
Pro License
|
||||
</Badge>
|
||||
<Badge variant="default" rounded="lg" icon={Users}>
|
||||
Team Redline
|
||||
</Badge>
|
||||
</Stack>
|
||||
</Stack>
|
||||
|
||||
{/* Quick Stats */}
|
||||
<Stack
|
||||
direction="row"
|
||||
gap={4}
|
||||
p={6}
|
||||
bg="bg-white/5"
|
||||
rounded="2xl"
|
||||
border
|
||||
borderColor="border-white/10"
|
||||
className="backdrop-blur-md"
|
||||
>
|
||||
<Stack align="center" px={4}>
|
||||
<Text size="2xl" weight="bold" color="text-white">12</Text>
|
||||
<Text size="xs" color="text-gray-500" uppercase>Podiums</Text>
|
||||
</Stack>
|
||||
<Box w="1px" h="10" bg="bg-white/10" />
|
||||
<Stack align="center" px={4}>
|
||||
<Text size="2xl" weight="bold" color="text-white">4</Text>
|
||||
<Text size="xs" color="text-gray-500" uppercase>Wins</Text>
|
||||
</Stack>
|
||||
</Stack>
|
||||
</Stack>
|
||||
</Box>
|
||||
</Box>
|
||||
{/* Quick Stats */}
|
||||
<QuickStatCard>
|
||||
<QuickStatItem label="Podiums" value="12" />
|
||||
<QuickStatItem label="Wins" value="4" />
|
||||
</QuickStatCard>
|
||||
</div>
|
||||
</ProfileHero>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,57 +1,64 @@
|
||||
'use client';
|
||||
|
||||
import { DashboardHero as UiDashboardHero } from '@/components/dashboard/DashboardHero';
|
||||
import { routes } from '@/lib/routing/RouteConfig';
|
||||
import { Button } from '@/ui/Button';
|
||||
import { Icon } from '@/ui/Icon';
|
||||
import { Link } from '@/ui/Link';
|
||||
import { StatBox } from '@/ui/StatBox';
|
||||
import { StatGrid } from '@/ui/StatGrid';
|
||||
import { Flag, Medal, Target, Trophy, User, Users } from 'lucide-react';
|
||||
import React from 'react';
|
||||
|
||||
interface DashboardHeroProps {
|
||||
currentDriver: {
|
||||
name: string;
|
||||
avatarUrl: string;
|
||||
country: string;
|
||||
rating: string | number;
|
||||
rank: string | number;
|
||||
totalRaces: string | number;
|
||||
wins: string | number;
|
||||
podiums: string | number;
|
||||
rating: number;
|
||||
rank: number;
|
||||
totalRaces: number;
|
||||
wins: number;
|
||||
podiums: number;
|
||||
consistency: string;
|
||||
};
|
||||
activeLeaguesCount: string | number;
|
||||
activeLeaguesCount: number;
|
||||
}
|
||||
|
||||
export function DashboardHero({ currentDriver, activeLeaguesCount }: DashboardHeroProps) {
|
||||
return (
|
||||
<UiDashboardHero
|
||||
driverName={currentDriver.name}
|
||||
avatarUrl={currentDriver.avatarUrl}
|
||||
country={currentDriver.country}
|
||||
rating={currentDriver.rating}
|
||||
rank={currentDriver.rank}
|
||||
totalRaces={currentDriver.totalRaces}
|
||||
actions={
|
||||
<>
|
||||
<Link href={routes.public.leagues} variant="ghost">
|
||||
<Button variant="secondary" icon={<Icon icon={Flag} size={4} />}>
|
||||
Browse Leagues
|
||||
</Button>
|
||||
</Link>
|
||||
<Link href={routes.protected.profile} variant="ghost">
|
||||
<Button variant="primary" icon={<Icon icon={User} size={4} />}>
|
||||
View Profile
|
||||
</Button>
|
||||
</Link>
|
||||
</>
|
||||
}
|
||||
stats={
|
||||
<>
|
||||
<StatBox icon={Trophy} label="Wins" value={currentDriver.wins} color="#10b981" />
|
||||
<StatBox icon={Medal} label="Podiums" value={currentDriver.podiums} color="#FFBE4D" />
|
||||
<StatBox icon={Target} label="Consistency" value={currentDriver.consistency} color="#198CFF" />
|
||||
<StatBox icon={Users} label="Active Leagues" value={activeLeaguesCount} color="#a855f7" />
|
||||
</>
|
||||
}
|
||||
/>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '2rem' }}>
|
||||
<UiDashboardHero
|
||||
driverName={currentDriver.name}
|
||||
avatarUrl={currentDriver.avatarUrl}
|
||||
rating={currentDriver.rating}
|
||||
rank={currentDriver.rank}
|
||||
totalRaces={currentDriver.totalRaces}
|
||||
winRate={Math.round((currentDriver.wins / currentDriver.totalRaces) * 100) || 0}
|
||||
/>
|
||||
|
||||
<div style={{ display: 'flex', gap: '1rem', flexWrap: 'wrap' }}>
|
||||
<Link href={routes.public.leagues}>
|
||||
<Button variant="secondary" icon={<Icon icon={Flag} size={4} />}>
|
||||
Browse Leagues
|
||||
</Button>
|
||||
</Link>
|
||||
<Link href={routes.protected.profile}>
|
||||
<Button variant="primary" icon={<Icon icon={User} size={4} />}>
|
||||
View Profile
|
||||
</Button>
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
<StatGrid
|
||||
variant="box"
|
||||
columns={{ base: 2, md: 4 }}
|
||||
stats={[
|
||||
{ icon: Trophy, label: 'Wins', value: currentDriver.wins, intent: 'success' },
|
||||
{ icon: Medal, label: 'Podiums', value: currentDriver.podiums, intent: 'warning' },
|
||||
{ icon: Target, label: 'Consistency', value: currentDriver.consistency, intent: 'primary' },
|
||||
{ icon: Users, label: 'Active Leagues', value: activeLeaguesCount, intent: 'telemetry' },
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,4 +1,7 @@
|
||||
import { Stack } from '@/ui/primitives/Stack';
|
||||
import { Sidebar } from '@/ui/Sidebar';
|
||||
import { Header } from '@/ui/Header';
|
||||
import { MainContent } from '@/ui/MainContent';
|
||||
import { Box } from '@/ui/primitives/Box';
|
||||
import React from 'react';
|
||||
|
||||
interface DashboardShellProps {
|
||||
@@ -12,28 +15,25 @@ interface DashboardShellProps {
|
||||
*
|
||||
* The primary layout container for the Telemetry Workspace.
|
||||
* Orchestrates the sidebar rail, top control bar, and main content area.
|
||||
* Uses UI primitives to comply with architectural constraints.
|
||||
*/
|
||||
export function DashboardShell({ children, rail, controlBar }: DashboardShellProps) {
|
||||
return (
|
||||
<Stack direction="row" h="screen" overflow="hidden" bg="base-black" color="white">
|
||||
<Box display="flex" height="100vh" style={{ overflow: 'hidden', backgroundColor: 'var(--ui-color-bg-base)' }}>
|
||||
{rail && (
|
||||
<Stack as="aside" w="16" flexShrink={0} borderRight bg="surface-charcoal" borderColor="var(--color-outline)">
|
||||
<Sidebar>
|
||||
{rail}
|
||||
</Stack>
|
||||
</Sidebar>
|
||||
)}
|
||||
<Stack flexGrow={1} overflow="hidden">
|
||||
<Box display="flex" flexDirection="col" flex={1} style={{ overflow: 'hidden' }}>
|
||||
{controlBar && (
|
||||
<Stack as="header" h="14" borderBottom bg="surface-charcoal" borderColor="var(--color-outline)">
|
||||
<Header>
|
||||
{controlBar}
|
||||
</Stack>
|
||||
</Header>
|
||||
)}
|
||||
<Stack as="main" flexGrow={1} overflow="auto" p={6}>
|
||||
<Stack maxWidth="7xl" mx="auto" gap={6} fullWidth>
|
||||
{children}
|
||||
</Stack>
|
||||
</Stack>
|
||||
</Stack>
|
||||
</Stack>
|
||||
<MainContent maxWidth="7xl">
|
||||
{children}
|
||||
</MainContent>
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
'use client';
|
||||
|
||||
import React from 'react';
|
||||
import { Text } from '@/ui/Text';
|
||||
import { StatusDot } from '@/ui/StatusDot';
|
||||
@@ -19,33 +21,23 @@ interface RecentActivityTableProps {
|
||||
* RecentActivityTable
|
||||
*
|
||||
* A high-density table for displaying recent events and telemetry logs.
|
||||
* Uses UI primitives to comply with architectural constraints.
|
||||
*/
|
||||
export function RecentActivityTable({ items }: RecentActivityTableProps) {
|
||||
const getStatusColor = (status?: string) => {
|
||||
switch (status) {
|
||||
case 'success': return 'var(--color-success)';
|
||||
case 'warning': return 'var(--color-warning)';
|
||||
case 'critical': return 'var(--color-critical)';
|
||||
default: return 'var(--color-primary)';
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Table>
|
||||
<TableHead>
|
||||
<TableRow>
|
||||
<TableHeader>
|
||||
<Text size="xs" weight="medium" uppercase letterSpacing="wider" color="var(--color-text-low)">Type</Text>
|
||||
<Text size="xs" weight="bold" uppercase variant="low">Type</Text>
|
||||
</TableHeader>
|
||||
<TableHeader>
|
||||
<Text size="xs" weight="medium" uppercase letterSpacing="wider" color="var(--color-text-low)">Description</Text>
|
||||
<Text size="xs" weight="bold" uppercase variant="low">Description</Text>
|
||||
</TableHeader>
|
||||
<TableHeader>
|
||||
<Text size="xs" weight="medium" uppercase letterSpacing="wider" color="var(--color-text-low)">Time</Text>
|
||||
<Text size="xs" weight="bold" uppercase variant="low">Time</Text>
|
||||
</TableHeader>
|
||||
<TableHeader>
|
||||
<Text size="xs" weight="medium" uppercase letterSpacing="wider" color="var(--color-text-low)">Status</Text>
|
||||
<Text size="xs" weight="bold" uppercase variant="low">Status</Text>
|
||||
</TableHeader>
|
||||
</TableRow>
|
||||
</TableHead>
|
||||
@@ -53,16 +45,16 @@ export function RecentActivityTable({ items }: RecentActivityTableProps) {
|
||||
{items.map((item) => (
|
||||
<TableRow key={item.id}>
|
||||
<TableCell>
|
||||
<Text font="mono" color="var(--color-telemetry)" size="xs">{item.type}</Text>
|
||||
<Text font="mono" variant="telemetry" size="xs">{item.type}</Text>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Text color="var(--color-text-med)" size="xs">{item.description}</Text>
|
||||
<Text variant="med" size="xs">{item.description}</Text>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Text color="var(--color-text-low)" size="xs">{item.timestamp}</Text>
|
||||
<Text variant="low" size="xs">{item.timestamp}</Text>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<StatusDot color={getStatusColor(item.status)} size={1.5} />
|
||||
<StatusDot intent={item.status === 'info' ? 'primary' : item.status} size="sm" />
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { Panel } from '@/ui/Panel';
|
||||
import { Stack } from '@/ui/primitives/Stack';
|
||||
import { Text } from '@/ui/Text';
|
||||
import React from 'react';
|
||||
|
||||
interface TelemetryPanelProps {
|
||||
@@ -11,14 +11,13 @@ interface TelemetryPanelProps {
|
||||
* TelemetryPanel
|
||||
*
|
||||
* A dense, instrument-grade panel for displaying data and controls.
|
||||
* Uses UI primitives to comply with architectural constraints.
|
||||
*/
|
||||
export function TelemetryPanel({ title, children }: TelemetryPanelProps) {
|
||||
return (
|
||||
<Panel title={title} variant="dark" padding={4}>
|
||||
<Stack fontSize="sm">
|
||||
<Text size="sm" variant="med">
|
||||
{children}
|
||||
</Stack>
|
||||
</Text>
|
||||
</Panel>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -4,11 +4,12 @@ import type { GlobalErrorHandler } from '@/lib/infrastructure/GlobalErrorHandler
|
||||
import { getGlobalErrorHandler } from '@/lib/infrastructure/GlobalErrorHandler';
|
||||
import { Button } from '@/ui/Button';
|
||||
import { Icon } from '@/ui/Icon';
|
||||
import { Grid } from '@/ui/primitives/Grid';
|
||||
import { Stack } from '@/ui/primitives/Stack';
|
||||
import { Text } from '@/ui/Text';
|
||||
import { Bug, Shield, X } from 'lucide-react';
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
import { FloatingAction } from '@/ui/FloatingAction';
|
||||
import { DebugPanel } from '@/ui/DebugPanel';
|
||||
import { StatGrid } from '@/ui/StatGrid';
|
||||
import { Bug, Shield } from 'lucide-react';
|
||||
import React, { useCallback, useEffect, useState } from 'react';
|
||||
|
||||
// Extend Window interface for debug globals
|
||||
declare global {
|
||||
@@ -187,141 +188,94 @@ export function DebugModeToggle({ show }: DebugModeToggleProps) {
|
||||
}
|
||||
|
||||
return (
|
||||
<Stack position="fixed" bottom="4" left="4" zIndex={50}>
|
||||
<React.Fragment>
|
||||
{/* Main Toggle Button */}
|
||||
{!isOpen && (
|
||||
<Button
|
||||
<FloatingAction
|
||||
onClick={() => setIsOpen(true)}
|
||||
p={3}
|
||||
rounded="full"
|
||||
variant={debugEnabled ? 'primary' : 'secondary'}
|
||||
className={`transition-all hover:scale-110 ${debugEnabled ? 'bg-green-600' : 'bg-iron-gray'}`}
|
||||
title={debugEnabled ? 'Debug Mode Active' : 'Enable Debug Mode'}
|
||||
>
|
||||
<Icon icon={Bug} size={5} />
|
||||
</Button>
|
||||
</FloatingAction>
|
||||
)}
|
||||
|
||||
{/* Debug Panel */}
|
||||
{isOpen && (
|
||||
<Stack w="80" bg="bg-deep-graphite" border={true} borderColor="border-charcoal-outline" rounded="xl" shadow="2xl" overflow="hidden">
|
||||
{/* Header */}
|
||||
<Stack direction="row" align="center" justify="between" px={3} py={2} bg="bg-iron-gray/50" borderBottom={true} borderColor="border-charcoal-outline">
|
||||
<Stack direction="row" align="center" gap={2}>
|
||||
<Icon icon={Bug} size={4} color="text-green-400" />
|
||||
<Text size="sm" weight="semibold" color="text-white">Debug Control</Text>
|
||||
</Stack>
|
||||
<Button
|
||||
onClick={() => setIsOpen(false)}
|
||||
p={1}
|
||||
variant="ghost"
|
||||
className="hover:bg-charcoal-outline rounded"
|
||||
>
|
||||
<Icon icon={X} size={4} color="text-gray-400" />
|
||||
</Button>
|
||||
</Stack>
|
||||
<DebugPanel
|
||||
title="Debug Control"
|
||||
onClose={() => setIsOpen(false)}
|
||||
icon={<Icon icon={Bug} size={4} intent="success" />}
|
||||
>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '1rem' }}>
|
||||
{/* Debug Toggle */}
|
||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', padding: '0.5rem', backgroundColor: 'var(--ui-color-bg-surface-muted)', borderRadius: '0.5rem' }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '0.5rem' }}>
|
||||
<Icon icon={Shield} size={4} intent={debugEnabled ? 'success' : 'low'} />
|
||||
<Text size="sm" weight="medium">Debug Mode</Text>
|
||||
</div>
|
||||
<Button
|
||||
onClick={toggleDebug}
|
||||
size="sm"
|
||||
variant={debugEnabled ? 'primary' : 'secondary'}
|
||||
>
|
||||
{debugEnabled ? 'ON' : 'OFF'}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<Stack p={3}>
|
||||
<Stack gap={3}>
|
||||
{/* Debug Toggle */}
|
||||
<Stack direction="row" align="center" justify="between" bg="bg-iron-gray/30" p={2} rounded="md" border={true} borderColor="border-charcoal-outline">
|
||||
<Stack direction="row" align="center" gap={2}>
|
||||
<Icon icon={Shield} size={4} color={debugEnabled ? 'text-green-400' : 'text-gray-500'} />
|
||||
<Text size="sm" weight="medium">Debug Mode</Text>
|
||||
</Stack>
|
||||
<Button
|
||||
onClick={toggleDebug}
|
||||
size="sm"
|
||||
variant={debugEnabled ? 'primary' : 'secondary'}
|
||||
className={debugEnabled ? 'bg-green-600 hover:bg-green-700' : ''}
|
||||
>
|
||||
{debugEnabled ? 'ON' : 'OFF'}
|
||||
</Button>
|
||||
</Stack>
|
||||
{/* Metrics */}
|
||||
{debugEnabled && (
|
||||
<StatGrid
|
||||
variant="box"
|
||||
columns={3}
|
||||
stats={[
|
||||
{ label: 'Errors', value: metrics.errors, intent: 'critical', icon: Bug },
|
||||
{ label: 'API', value: metrics.apiRequests, intent: 'primary', icon: Bug },
|
||||
{ label: 'Failures', value: metrics.apiFailures, intent: 'warning', icon: Bug },
|
||||
]}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Metrics */}
|
||||
{debugEnabled && (
|
||||
<Grid cols={3} gap={2}>
|
||||
<Stack bg="bg-iron-gray" border={true} borderColor="border-charcoal-outline" rounded="md" p={2} align="center">
|
||||
<Text size="xs" color="text-gray-500" block style={{ fontSize: '10px' }}>Errors</Text>
|
||||
<Text size="lg" weight="bold" color="text-red-400" block>{metrics.errors}</Text>
|
||||
</Stack>
|
||||
<Stack bg="bg-iron-gray" border={true} borderColor="border-charcoal-outline" rounded="md" p={2} align="center">
|
||||
<Text size="xs" color="text-gray-500" block style={{ fontSize: '10px' }}>API</Text>
|
||||
<Text size="lg" weight="bold" color="text-blue-400" block>{metrics.apiRequests}</Text>
|
||||
</Stack>
|
||||
<Stack bg="bg-iron-gray" border={true} borderColor="border-charcoal-outline" rounded="md" p={2} align="center">
|
||||
<Text size="xs" color="text-gray-500" block style={{ fontSize: '10px' }}>Failures</Text>
|
||||
<Text size="lg" weight="bold" color="text-yellow-400" block>{metrics.apiFailures}</Text>
|
||||
</Stack>
|
||||
</Grid>
|
||||
)}
|
||||
{/* Actions */}
|
||||
{debugEnabled && (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '0.5rem' }}>
|
||||
<Text size="xs" weight="semibold" variant="low" uppercase>Test Actions</Text>
|
||||
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '0.5rem' }}>
|
||||
<Button onClick={triggerTestError} variant="danger" size="sm">Test Error</Button>
|
||||
<Button onClick={triggerTestApiCall} size="sm">Test API</Button>
|
||||
</div>
|
||||
|
||||
{/* Actions */}
|
||||
{debugEnabled && (
|
||||
<Stack gap={2}>
|
||||
<Text size="xs" weight="semibold" color="text-gray-400">Test Actions</Text>
|
||||
<Grid cols={2} gap={2}>
|
||||
<Button
|
||||
onClick={triggerTestError}
|
||||
variant="danger"
|
||||
size="sm"
|
||||
>
|
||||
Test Error
|
||||
</Button>
|
||||
<Button
|
||||
onClick={triggerTestApiCall}
|
||||
size="sm"
|
||||
>
|
||||
Test API
|
||||
</Button>
|
||||
</Grid>
|
||||
<Text size="xs" weight="semibold" variant="low" uppercase style={{ marginTop: '0.5rem' }}>Utilities</Text>
|
||||
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '0.5rem' }}>
|
||||
<Button onClick={copyDebugInfo} variant="secondary" size="sm">Copy Info</Button>
|
||||
<Button onClick={clearAllLogs} variant="secondary" size="sm">Clear Logs</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Text size="xs" weight="semibold" color="text-gray-400" mt={2}>Utilities</Text>
|
||||
<Grid cols={2} gap={2}>
|
||||
<Button
|
||||
onClick={copyDebugInfo}
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
>
|
||||
Copy Info
|
||||
</Button>
|
||||
<Button
|
||||
onClick={clearAllLogs}
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
>
|
||||
Clear Logs
|
||||
</Button>
|
||||
</Grid>
|
||||
</Stack>
|
||||
)}
|
||||
{/* Quick Links */}
|
||||
{debugEnabled && (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '0.25rem' }}>
|
||||
<Text size="xs" weight="semibold" variant="low" uppercase>Quick Access</Text>
|
||||
<div style={{ fontSize: '10px', color: 'var(--ui-color-text-low)', fontFamily: 'var(--ui-font-mono)' }}>
|
||||
<div>• window.__GRIDPILOT_GLOBAL_HANDLER__</div>
|
||||
<div>• window.__GRIDPILOT_API_LOGGER__</div>
|
||||
<div>• window.__GRIDPILOT_REACT_ERRORS__</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Quick Links */}
|
||||
{debugEnabled && (
|
||||
<Stack gap={1}>
|
||||
<Text size="xs" weight="semibold" color="text-gray-400">Quick Access</Text>
|
||||
<Stack color="text-gray-500" className="font-mono" style={{ fontSize: '10px' }}>
|
||||
<Text block>• window.__GRIDPILOT_GLOBAL_HANDLER__</Text>
|
||||
<Text block>• window.__GRIDPILOT_API_LOGGER__</Text>
|
||||
<Text block>• window.__GRIDPILOT_REACT_ERRORS__</Text>
|
||||
</Stack>
|
||||
</Stack>
|
||||
)}
|
||||
|
||||
{/* Status */}
|
||||
<Stack pt={2} borderTop={true} borderColor="border-charcoal-outline" align="center">
|
||||
<Text size="xs" color="text-gray-500" style={{ fontSize: '10px' }} textAlign="center">
|
||||
{debugEnabled ? 'Debug features active' : 'Debug mode disabled'}
|
||||
{isDev && ' • Development Environment'}
|
||||
</Text>
|
||||
</Stack>
|
||||
</Stack>
|
||||
</Stack>
|
||||
</Stack>
|
||||
{/* Status */}
|
||||
<div style={{ paddingTop: '0.5rem', borderTop: '1px solid var(--ui-color-border-muted)', textAlign: 'center' }}>
|
||||
<Text size="xs" variant="low" style={{ fontSize: '10px' }}>
|
||||
{debugEnabled ? 'Debug features active' : 'Debug mode disabled'}
|
||||
{isDev && ' • Development Environment'}
|
||||
</Text>
|
||||
</div>
|
||||
</div>
|
||||
</DebugPanel>
|
||||
)}
|
||||
</Stack>
|
||||
</React.Fragment>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,52 +0,0 @@
|
||||
import { Image } from '@/ui/Image';
|
||||
import { Stack } from '@/ui/primitives/Stack';
|
||||
import { Text } from '@/ui/Text';
|
||||
|
||||
|
||||
export function ActiveDriverCard({
|
||||
name,
|
||||
avatarUrl,
|
||||
categoryLabel,
|
||||
categoryColor,
|
||||
skillLevelLabel,
|
||||
skillLevelColor,
|
||||
onClick,
|
||||
}: ActiveDriverCardProps) {
|
||||
return (
|
||||
<Stack
|
||||
as="button"
|
||||
{...({ type: 'button' } as any)}
|
||||
onClick={onClick}
|
||||
p={3}
|
||||
rounded="xl"
|
||||
bg="bg-iron-gray/40"
|
||||
border
|
||||
borderColor="border-charcoal-outline"
|
||||
className="transition-all hover:border-performance-green/40 group text-center"
|
||||
>
|
||||
<Stack position="relative" w="12" h="12" mx="auto" rounded="full" overflow="hidden" border borderColor="border-charcoal-outline" mb={2}>
|
||||
<Image src={avatarUrl || '/default-avatar.png'} alt={name} objectFit="cover" fill />
|
||||
<Stack position="absolute" bottom="0" right="0" w="3" h="3" rounded="full" bg="bg-performance-green" border borderColor="border-iron-gray" style={{ borderWidth: '2px' }}>{null}</Stack>
|
||||
</Stack>
|
||||
<Text
|
||||
size="sm"
|
||||
weight="medium"
|
||||
color="text-white"
|
||||
truncate
|
||||
block
|
||||
groupHoverTextColor="performance-green"
|
||||
transition
|
||||
>
|
||||
{name}
|
||||
</Text>
|
||||
<Stack direction="row" align="center" justify="center" gap={1}>
|
||||
{categoryLabel && (
|
||||
<Text size="xs" color={categoryColor}>{categoryLabel}</Text>
|
||||
)}
|
||||
{skillLevelLabel && (
|
||||
<Text size="xs" color={skillLevelColor}>{skillLevelLabel}</Text>
|
||||
)}
|
||||
</Stack>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
@@ -1,85 +0,0 @@
|
||||
import { Badge } from '@/ui/Badge';
|
||||
import { Image } from '@/ui/Image';
|
||||
import { Link } from '@/ui/Link';
|
||||
import { PlaceholderImage } from '@/ui/PlaceholderImage';
|
||||
import { Box } from '@/ui/primitives/Box';
|
||||
import { Text } from '@/ui/Text';
|
||||
|
||||
export interface DriverIdentityProps {
|
||||
driver: {
|
||||
id: string;
|
||||
name: string;
|
||||
avatarUrl: string | null;
|
||||
};
|
||||
href?: string;
|
||||
contextLabel?: React.ReactNode;
|
||||
meta?: React.ReactNode;
|
||||
size?: 'sm' | 'md';
|
||||
}
|
||||
|
||||
export function DriverIdentity(props: DriverIdentityProps) {
|
||||
const { driver, href, contextLabel, meta, size = 'md' } = props;
|
||||
|
||||
const avatarSize = size === 'sm' ? 40 : 48;
|
||||
const nameSize = size === 'sm' ? 'sm' : 'base';
|
||||
|
||||
const avatarUrl = driver.avatarUrl;
|
||||
|
||||
const content = (
|
||||
<Box display="flex" alignItems="center" gap={{ base: 3, md: 4 }} flexGrow={1} minWidth="0">
|
||||
<Box
|
||||
rounded="full"
|
||||
bg="bg-primary-blue/20"
|
||||
overflow="hidden"
|
||||
display="flex"
|
||||
alignItems="center"
|
||||
justifyContent="center"
|
||||
flexShrink={0}
|
||||
w={`${avatarSize}px`}
|
||||
h={`${avatarSize}px`}
|
||||
>
|
||||
{avatarUrl ? (
|
||||
<Image
|
||||
src={avatarUrl}
|
||||
alt={driver.name}
|
||||
width={avatarSize}
|
||||
height={avatarSize}
|
||||
fullWidth
|
||||
fullHeight
|
||||
objectFit="cover"
|
||||
/>
|
||||
) : (
|
||||
<PlaceholderImage size={avatarSize} />
|
||||
)}
|
||||
</Box>
|
||||
|
||||
<Box flexGrow={1} minWidth="0">
|
||||
<Box display="flex" alignItems="center" gap={2} minWidth="0">
|
||||
<Text size={nameSize} weight="medium" color="text-white" truncate>
|
||||
{driver.name}
|
||||
</Text>
|
||||
{contextLabel && (
|
||||
<Badge variant="default" bg="bg-charcoal-outline/60" size="xs">
|
||||
{contextLabel}
|
||||
</Badge>
|
||||
)}
|
||||
</Box>
|
||||
{meta && (
|
||||
<Text size="xs" color="text-gray-400" mt={0.5} truncate block>
|
||||
{meta}
|
||||
</Text>
|
||||
)}
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
|
||||
if (href) {
|
||||
return (
|
||||
<Link href={href} block variant="ghost">
|
||||
{content}
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
|
||||
return <Box display="flex" alignItems="center" gap={{ base: 3, md: 4 }} flexGrow={1} minWidth="0">{content}</Box>;
|
||||
}
|
||||
@@ -1,9 +1,10 @@
|
||||
'use client';
|
||||
|
||||
import React from 'react';
|
||||
import { Card } from '@/ui/Card';
|
||||
import { Heading } from '@/ui/Heading';
|
||||
import { Panel } from '@/ui/Panel';
|
||||
import { RankingListItem } from '@/components/leaderboards/RankingListItem';
|
||||
import { RankingList } from '@/components/leaderboards/RankingList';
|
||||
import { MinimalEmptyState } from '@/components/shared/state/EmptyState';
|
||||
import { EmptyState } from '@/ui/EmptyState';
|
||||
|
||||
export interface DriverRanking {
|
||||
type: 'overall' | 'league';
|
||||
@@ -21,19 +22,18 @@ interface DriverRankingsProps {
|
||||
export function DriverRankings({ rankings }: DriverRankingsProps) {
|
||||
if (!rankings || rankings.length === 0) {
|
||||
return (
|
||||
<Card bg="bg-iron-gray/60" borderColor="border-charcoal-outline/80" p={4}>
|
||||
<Heading level={3} mb={2}>Rankings</Heading>
|
||||
<MinimalEmptyState
|
||||
<Panel title="Rankings">
|
||||
<EmptyState
|
||||
title="No ranking data available yet"
|
||||
description="Compete in leagues to earn your first results."
|
||||
variant="minimal"
|
||||
/>
|
||||
</Card>
|
||||
</Panel>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Card bg="bg-iron-gray/60" borderColor="border-charcoal-outline/80" p={4}>
|
||||
<Heading level={3} mb={4}>Rankings</Heading>
|
||||
<Panel title="Rankings">
|
||||
<RankingList>
|
||||
{rankings.map((ranking, index) => (
|
||||
<RankingListItem
|
||||
@@ -47,6 +47,6 @@ export function DriverRankings({ rankings }: DriverRankingsProps) {
|
||||
/>
|
||||
))}
|
||||
</RankingList>
|
||||
</Card>
|
||||
</Panel>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,14 +1,15 @@
|
||||
'use client';
|
||||
|
||||
import { EmptyState } from '@/components/shared/state/EmptyState';
|
||||
import { LoadingWrapper } from '@/components/shared/state/LoadingWrapper';
|
||||
import { EmptyState } from '@/ui/EmptyState';
|
||||
import { LoadingWrapper } from '@/ui/LoadingWrapper';
|
||||
import { Button } from '@/ui/Button';
|
||||
import { Card } from '@/ui/Card';
|
||||
import { Pagination } from '@/ui/Pagination';
|
||||
import { Stack } from '@/ui/primitives/Stack';
|
||||
import { Text } from '@/ui/Text';
|
||||
import { ControlBar } from '@/ui/ControlBar';
|
||||
import { Trophy } from 'lucide-react';
|
||||
import { useEffect, useState } from 'react';
|
||||
import React from 'react';
|
||||
|
||||
interface RaceHistoryProps {
|
||||
driverId: string;
|
||||
@@ -24,7 +25,6 @@ export function ProfileRaceHistory({ driverId }: RaceHistoryProps) {
|
||||
async function loadRaceHistory() {
|
||||
try {
|
||||
// Driver race history is not exposed via API yet.
|
||||
// Keep as placeholder until an endpoint exists.
|
||||
} catch (err) {
|
||||
console.error('Failed to load race history:', err);
|
||||
} finally {
|
||||
@@ -40,18 +40,7 @@ export function ProfileRaceHistory({ driverId }: RaceHistoryProps) {
|
||||
const totalPages = Math.ceil(filteredResults.length / resultsPerPage);
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<Stack gap={4}>
|
||||
<Stack display="flex" alignItems="center" gap={2}>
|
||||
{[1, 2, 3].map(i => (
|
||||
<Stack key={i} h="9" w="24" bg="bg-iron-gray" rounded="md" animate="pulse" />
|
||||
))}
|
||||
</Stack>
|
||||
<Card>
|
||||
<LoadingWrapper variant="skeleton" skeletonCount={3} />
|
||||
</Card>
|
||||
</Stack>
|
||||
);
|
||||
return <LoadingWrapper variant="spinner" message="Loading race history..." />;
|
||||
}
|
||||
|
||||
if (filteredResults.length === 0) {
|
||||
@@ -60,50 +49,50 @@ export function ProfileRaceHistory({ driverId }: RaceHistoryProps) {
|
||||
icon={Trophy}
|
||||
title="No race history yet"
|
||||
description="Complete races to build your racing record"
|
||||
variant="minimal"
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Stack gap={4}>
|
||||
<Stack display="flex" alignItems="center" gap={2}>
|
||||
<Button
|
||||
variant={filter === 'all' ? 'primary' : 'secondary'}
|
||||
onClick={() => { setFilter('all'); setPage(1); }}
|
||||
size="sm"
|
||||
>
|
||||
All Races
|
||||
</Button>
|
||||
<Button
|
||||
variant={filter === 'wins' ? 'primary' : 'secondary'}
|
||||
onClick={() => { setFilter('wins'); setPage(1); }}
|
||||
size="sm"
|
||||
>
|
||||
Wins Only
|
||||
</Button>
|
||||
<Button
|
||||
variant={filter === 'podiums' ? 'primary' : 'secondary'}
|
||||
onClick={() => { setFilter('podiums'); setPage(1); }}
|
||||
size="sm"
|
||||
>
|
||||
Podiums
|
||||
</Button>
|
||||
</Stack>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '1rem' }}>
|
||||
<ControlBar>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '0.5rem' }}>
|
||||
<Button
|
||||
variant={filter === 'all' ? 'primary' : 'secondary'}
|
||||
onClick={() => { setFilter('all'); setPage(1); }}
|
||||
size="sm"
|
||||
>
|
||||
All Races
|
||||
</Button>
|
||||
<Button
|
||||
variant={filter === 'wins' ? 'primary' : 'secondary'}
|
||||
onClick={() => { setFilter('wins'); setPage(1); }}
|
||||
size="sm"
|
||||
>
|
||||
Wins Only
|
||||
</Button>
|
||||
<Button
|
||||
variant={filter === 'podiums' ? 'primary' : 'secondary'}
|
||||
onClick={() => { setFilter('podiums'); setPage(1); }}
|
||||
size="sm"
|
||||
>
|
||||
Podiums
|
||||
</Button>
|
||||
</div>
|
||||
</ControlBar>
|
||||
|
||||
<Card>
|
||||
{/* No results until API provides driver results */}
|
||||
<Stack minHeight="100px" display="flex" center>
|
||||
<Text color="text-gray-500">No results found for the selected filter.</Text>
|
||||
</Stack>
|
||||
<div style={{ minHeight: '10rem', display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
|
||||
<Text variant="low">No results found for the selected filter.</Text>
|
||||
</div>
|
||||
|
||||
<Pagination
|
||||
currentPage={page}
|
||||
totalPages={totalPages}
|
||||
totalItems={filteredResults.length}
|
||||
itemsPerPage={resultsPerPage}
|
||||
onPageChange={setPage}
|
||||
/>
|
||||
</Card>
|
||||
</Stack>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -3,8 +3,8 @@
|
||||
import React, { Component, ReactNode, useState } from 'react';
|
||||
import { ApiError } from '@/lib/api/base/ApiError';
|
||||
import { connectionMonitor } from '@/lib/api/base/ApiConnectionMonitor';
|
||||
import { ErrorDisplay } from '@/components/shared/state/ErrorDisplay';
|
||||
import { DevErrorPanel } from './DevErrorPanel';
|
||||
import { ErrorDisplay } from '@/ui/ErrorDisplay';
|
||||
import { DevErrorPanel } from '@/ui/DevErrorPanel';
|
||||
|
||||
interface Props {
|
||||
children: ReactNode;
|
||||
|
||||
@@ -2,7 +2,6 @@
|
||||
|
||||
import { Heading } from '@/ui/Heading';
|
||||
import { Icon } from '@/ui/Icon';
|
||||
import { Stack } from '@/ui/primitives/Stack';
|
||||
import { Text } from '@/ui/Text';
|
||||
import { AlertTriangle } from 'lucide-react';
|
||||
import React from 'react';
|
||||
@@ -21,32 +20,30 @@ interface AppErrorBoundaryViewProps {
|
||||
*/
|
||||
export function AppErrorBoundaryView({ title, description, children }: AppErrorBoundaryViewProps) {
|
||||
return (
|
||||
<Stack gap={6} align="center" fullWidth>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', gap: '1.5rem', width: '100%' }}>
|
||||
{/* Header Icon */}
|
||||
<Stack
|
||||
p={4}
|
||||
rounded="full"
|
||||
bg="bg-warning-amber"
|
||||
bgOpacity={0.1}
|
||||
border
|
||||
borderColor="border-warning-amber"
|
||||
<div
|
||||
style={{
|
||||
padding: '1rem',
|
||||
borderRadius: '9999px',
|
||||
backgroundColor: 'rgba(255, 190, 77, 0.1)',
|
||||
border: '1px solid rgba(255, 190, 77, 0.3)'
|
||||
}}
|
||||
>
|
||||
<Icon icon={AlertTriangle} size={8} color="var(--warning-amber)" />
|
||||
</Stack>
|
||||
<Icon icon={AlertTriangle} size={8} intent="warning" />
|
||||
</div>
|
||||
|
||||
{/* Typography */}
|
||||
<Stack gap={2} align="center">
|
||||
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', gap: '0.5rem' }}>
|
||||
<Heading level={1} weight="bold">
|
||||
<Text uppercase letterSpacing="tighter">
|
||||
{title}
|
||||
</Text>
|
||||
{title}
|
||||
</Heading>
|
||||
<Text color="text-gray-400" align="center" maxWidth="md" leading="relaxed">
|
||||
<Text variant="low" align="center" style={{ maxWidth: '32rem' }} leading="relaxed">
|
||||
{description}
|
||||
</Text>
|
||||
</Stack>
|
||||
</div>
|
||||
|
||||
{children}
|
||||
</Stack>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,382 +0,0 @@
|
||||
'use client';
|
||||
|
||||
import { connectionMonitor } from '@/lib/api/base/ApiConnectionMonitor';
|
||||
import { ApiError } from '@/lib/api/base/ApiError';
|
||||
import { CircuitBreakerRegistry } from '@/lib/api/base/RetryHandler';
|
||||
import { Badge } from '@/ui/Badge';
|
||||
import { Button } from '@/ui/Button';
|
||||
import { Card } from '@/ui/Card';
|
||||
import { Heading } from '@/ui/Heading';
|
||||
import { Icon } from '@/ui/Icon';
|
||||
import { Grid } from '@/ui/primitives/Grid';
|
||||
import { Stack } from '@/ui/primitives/Stack';
|
||||
import { Text } from '@/ui/Text';
|
||||
import { Activity, AlertTriangle, Copy, RefreshCw, Terminal, X } from 'lucide-react';
|
||||
import { useEffect, useState } from 'react';
|
||||
|
||||
interface DevErrorPanelProps {
|
||||
error: ApiError;
|
||||
onReset: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Developer-focused error panel with detailed debugging information
|
||||
*/
|
||||
export function DevErrorPanel({ error, onReset }: DevErrorPanelProps) {
|
||||
const [connectionStatus, setConnectionStatus] = useState(connectionMonitor.getHealth());
|
||||
const [circuitBreakers, setCircuitBreakers] = useState(CircuitBreakerRegistry.getInstance().getStatus());
|
||||
const [copied, setCopied] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
// Update status on mount
|
||||
const health = connectionMonitor.getHealth();
|
||||
setConnectionStatus(health);
|
||||
setCircuitBreakers(CircuitBreakerRegistry.getInstance().getStatus());
|
||||
|
||||
// Listen for status changes
|
||||
const handleStatusChange = () => {
|
||||
setConnectionStatus(connectionMonitor.getHealth());
|
||||
setCircuitBreakers(CircuitBreakerRegistry.getInstance().getStatus());
|
||||
};
|
||||
|
||||
connectionMonitor.on('success', handleStatusChange);
|
||||
connectionMonitor.on('failure', handleStatusChange);
|
||||
connectionMonitor.on('connected', handleStatusChange);
|
||||
connectionMonitor.on('disconnected', handleStatusChange);
|
||||
connectionMonitor.on('degraded', handleStatusChange);
|
||||
|
||||
return () => {
|
||||
connectionMonitor.off('success', handleStatusChange);
|
||||
connectionMonitor.off('failure', handleStatusChange);
|
||||
connectionMonitor.off('connected', handleStatusChange);
|
||||
connectionMonitor.off('disconnected', handleStatusChange);
|
||||
connectionMonitor.off('degraded', handleStatusChange);
|
||||
};
|
||||
}, []);
|
||||
|
||||
const copyToClipboard = async () => {
|
||||
const debugInfo = {
|
||||
error: {
|
||||
type: error.type,
|
||||
message: error.message,
|
||||
context: error.context,
|
||||
stack: error.stack,
|
||||
},
|
||||
connection: connectionStatus,
|
||||
circuitBreakers,
|
||||
timestamp: new Date().toISOString(),
|
||||
userAgent: navigator.userAgent,
|
||||
url: window.location.href,
|
||||
};
|
||||
|
||||
try {
|
||||
await navigator.clipboard.writeText(JSON.stringify(debugInfo, null, 2));
|
||||
setCopied(true);
|
||||
setTimeout(() => setCopied(false), 2000);
|
||||
} catch (err) {
|
||||
// Silent failure for clipboard operations
|
||||
}
|
||||
};
|
||||
|
||||
const triggerHealthCheck = async () => {
|
||||
await connectionMonitor.performHealthCheck();
|
||||
setConnectionStatus(connectionMonitor.getHealth());
|
||||
};
|
||||
|
||||
const resetCircuitBreakers = () => {
|
||||
CircuitBreakerRegistry.getInstance().resetAll();
|
||||
setCircuitBreakers(CircuitBreakerRegistry.getInstance().getStatus());
|
||||
};
|
||||
|
||||
const getSeverityVariant = (): 'danger' | 'warning' | 'info' | 'default' => {
|
||||
switch (error.getSeverity()) {
|
||||
case 'error': return 'danger';
|
||||
case 'warn': return 'warning';
|
||||
case 'info': return 'info';
|
||||
default: return 'default';
|
||||
}
|
||||
};
|
||||
|
||||
const reliability = connectionMonitor.getReliability();
|
||||
|
||||
return (
|
||||
<Stack
|
||||
position="fixed"
|
||||
inset="0"
|
||||
zIndex={50}
|
||||
overflow="auto"
|
||||
bg="bg-deep-graphite"
|
||||
p={4}
|
||||
>
|
||||
<Stack maxWidth="6xl" mx="auto" fullWidth>
|
||||
<Stack gap={4}>
|
||||
{/* Header */}
|
||||
<Stack bg="bg-iron-gray" border borderColor="border-charcoal-outline" rounded="lg" p={4} direction="row" align="center" justify="between">
|
||||
<Stack direction="row" align="center" gap={3}>
|
||||
<Icon icon={Terminal} size={5} color="rgb(59, 130, 246)" />
|
||||
<Heading level={2}>API Error Debug Panel</Heading>
|
||||
<Badge variant={getSeverityVariant()}>
|
||||
{error.type}
|
||||
</Badge>
|
||||
</Stack>
|
||||
<Stack direction="row" gap={2}>
|
||||
<Button
|
||||
variant="secondary"
|
||||
onClick={copyToClipboard}
|
||||
icon={<Icon icon={Copy} size={4} />}
|
||||
>
|
||||
{copied ? 'Copied!' : 'Copy'}
|
||||
</Button>
|
||||
<Button
|
||||
variant="primary"
|
||||
onClick={onReset}
|
||||
icon={<Icon icon={X} size={4} />}
|
||||
>
|
||||
Close
|
||||
</Button>
|
||||
</Stack>
|
||||
</Stack>
|
||||
|
||||
{/* Error Details */}
|
||||
<Grid cols={1} lgCols={2} gap={4}>
|
||||
<Stack gap={4}>
|
||||
<Card p={0} rounded="lg" overflow="hidden" variant="outline" borderColor="border-charcoal-outline" className="bg-panel-gray/40">
|
||||
<Stack bg="bg-charcoal-outline" px={4} py={2} direction="row" align="center" gap={2}>
|
||||
<Icon icon={AlertTriangle} size={4} color="text-white" />
|
||||
<Text weight="semibold" color="text-white">Error Details</Text>
|
||||
</Stack>
|
||||
<Stack p={4}>
|
||||
<Stack gap={2} style={{ fontSize: '0.75rem' }}>
|
||||
<Grid cols={3} gap={2}>
|
||||
<Text color="text-gray-500">Type:</Text>
|
||||
<Text className="col-span-2" color="text-red-400" weight="bold">{error.type}</Text>
|
||||
</Grid>
|
||||
<Grid cols={3} gap={2}>
|
||||
<Text color="text-gray-500">Message:</Text>
|
||||
<Text className="col-span-2" color="text-gray-300">{error.message}</Text>
|
||||
</Grid>
|
||||
<Grid cols={3} gap={2}>
|
||||
<Text color="text-gray-500">Endpoint:</Text>
|
||||
<Text className="col-span-2" color="text-primary-blue">{error.context.endpoint || 'N/A'}</Text>
|
||||
</Grid>
|
||||
<Grid cols={3} gap={2}>
|
||||
<Text color="text-gray-500">Method:</Text>
|
||||
<Text className="col-span-2" color="text-warning-amber">{error.context.method || 'N/A'}</Text>
|
||||
</Grid>
|
||||
<Grid cols={3} gap={2}>
|
||||
<Text color="text-gray-500">Status:</Text>
|
||||
<Text className="col-span-2">{error.context.statusCode || 'N/A'}</Text>
|
||||
</Grid>
|
||||
<Grid cols={3} gap={2}>
|
||||
<Text color="text-gray-500">Retry Count:</Text>
|
||||
<Text className="col-span-2">{error.context.retryCount || 0}</Text>
|
||||
</Grid>
|
||||
<Grid cols={3} gap={2}>
|
||||
<Text color="text-gray-500">Timestamp:</Text>
|
||||
<Text className="col-span-2" color="text-gray-500">{error.context.timestamp}</Text>
|
||||
</Grid>
|
||||
<Grid cols={3} gap={2}>
|
||||
<Text color="text-gray-500">Retryable:</Text>
|
||||
<Text className="col-span-2" color={error.isRetryable() ? 'text-performance-green' : 'text-red-400'}>
|
||||
{error.isRetryable() ? 'Yes' : 'No'}
|
||||
</Text>
|
||||
</Grid>
|
||||
<Grid cols={3} gap={2}>
|
||||
<Text color="text-gray-500">Connectivity:</Text>
|
||||
<Text className="col-span-2" color={error.isConnectivityIssue() ? 'text-red-400' : 'text-performance-green'}>
|
||||
{error.isConnectivityIssue() ? 'Yes' : 'No'}
|
||||
</Text>
|
||||
</Grid>
|
||||
{error.context.troubleshooting && (
|
||||
<Grid cols={3} gap={2}>
|
||||
<Text color="text-gray-500">Troubleshoot:</Text>
|
||||
<Text className="col-span-2" color="text-warning-amber">{error.context.troubleshooting}</Text>
|
||||
</Grid>
|
||||
)}
|
||||
</Stack>
|
||||
</Stack>
|
||||
</Card>
|
||||
|
||||
{/* Connection Status */}
|
||||
<Card p={0} rounded="lg" overflow="hidden" variant="outline" borderColor="border-charcoal-outline" className="bg-panel-gray/40">
|
||||
<Stack bg="bg-charcoal-outline" px={4} py={2} direction="row" align="center" gap={2}>
|
||||
<Icon icon={Activity} size={4} color="text-white" />
|
||||
<Text weight="semibold" color="text-white">Connection Health</Text>
|
||||
</Stack>
|
||||
<Stack p={4}>
|
||||
<Stack gap={2} style={{ fontSize: '0.75rem' }}>
|
||||
<Grid cols={3} gap={2}>
|
||||
<Text color="text-gray-500">Status:</Text>
|
||||
<Text className="col-span-2" weight="bold" color={
|
||||
connectionStatus.status === 'connected' ? 'text-performance-green' :
|
||||
connectionStatus.status === 'degraded' ? 'text-warning-amber' :
|
||||
'text-red-400'
|
||||
}>
|
||||
{connectionStatus.status.toUpperCase()}
|
||||
</Text>
|
||||
</Grid>
|
||||
<Grid cols={3} gap={2}>
|
||||
<Text color="text-gray-500">Reliability:</Text>
|
||||
<Text className="col-span-2">{reliability.toFixed(2)}%</Text>
|
||||
</Grid>
|
||||
<Grid cols={3} gap={2}>
|
||||
<Text color="text-gray-500">Total Requests:</Text>
|
||||
<Text className="col-span-2">{connectionStatus.totalRequests}</Text>
|
||||
</Grid>
|
||||
<Grid cols={3} gap={2}>
|
||||
<Text color="text-gray-500">Successful:</Text>
|
||||
<Text className="col-span-2" color="text-performance-green">{connectionStatus.successfulRequests}</Text>
|
||||
</Grid>
|
||||
<Grid cols={3} gap={2}>
|
||||
<Text color="text-gray-500">Failed:</Text>
|
||||
<Text className="col-span-2" color="text-red-400">{connectionStatus.failedRequests}</Text>
|
||||
</Grid>
|
||||
<Grid cols={3} gap={2}>
|
||||
<Text color="text-gray-500">Consecutive Failures:</Text>
|
||||
<Text className="col-span-2">{connectionStatus.consecutiveFailures}</Text>
|
||||
</Grid>
|
||||
<Grid cols={3} gap={2}>
|
||||
<Text color="text-gray-500">Avg Response:</Text>
|
||||
<Text className="col-span-2">{connectionStatus.averageResponseTime.toFixed(2)}ms</Text>
|
||||
</Grid>
|
||||
<Grid cols={3} gap={2}>
|
||||
<Text color="text-gray-500">Last Check:</Text>
|
||||
<Text className="col-span-2" color="text-gray-500">
|
||||
{connectionStatus.lastCheck?.toLocaleTimeString() || 'Never'}
|
||||
</Text>
|
||||
</Grid>
|
||||
</Stack>
|
||||
</Stack>
|
||||
</Card>
|
||||
</Stack>
|
||||
|
||||
{/* Right Column */}
|
||||
<Stack gap={4}>
|
||||
{/* Circuit Breakers */}
|
||||
<Card p={0} rounded="lg" overflow="hidden" variant="outline" borderColor="border-charcoal-outline" className="bg-panel-gray/40">
|
||||
<Stack bg="bg-charcoal-outline" px={4} py={2} direction="row" align="center" gap={2}>
|
||||
<Text size="lg">⚡</Text>
|
||||
<Text weight="semibold" color="text-white">Circuit Breakers</Text>
|
||||
</Stack>
|
||||
<Stack p={4}>
|
||||
{Object.keys(circuitBreakers).length === 0 ? (
|
||||
<Stack align="center" py={4}>
|
||||
<Text color="text-gray-500">No circuit breakers active</Text>
|
||||
</Stack>
|
||||
) : (
|
||||
<Stack gap={2} maxHeight="12rem" overflow="auto" style={{ fontSize: '0.75rem' }}>
|
||||
{Object.entries(circuitBreakers).map(([endpoint, status]) => (
|
||||
<Stack key={endpoint} direction="row" align="center" justify="between" p={2} bg="bg-deep-graphite" rounded="md" border borderColor="border-charcoal-outline">
|
||||
<Text color="text-primary-blue" truncate flexGrow={1}>{endpoint}</Text>
|
||||
<Stack px={2} py={1} rounded="sm" bg={
|
||||
status.state === 'CLOSED' ? 'bg-green-500/20' :
|
||||
status.state === 'OPEN' ? 'bg-red-500/20' :
|
||||
'bg-yellow-500/20'
|
||||
}>
|
||||
<Text color={
|
||||
status.state === 'CLOSED' ? 'text-performance-green' :
|
||||
status.state === 'OPEN' ? 'text-red-400' :
|
||||
'text-warning-amber'
|
||||
}>
|
||||
{status.state}
|
||||
</Text>
|
||||
</Stack>
|
||||
<Text color="text-gray-500" className="ml-2">{status.failures} failures</Text>
|
||||
</Stack>
|
||||
))}
|
||||
</Stack>
|
||||
)}
|
||||
</Stack>
|
||||
</Card>
|
||||
|
||||
{/* Actions */}
|
||||
<Card p={0} rounded="lg" overflow="hidden" variant="outline" borderColor="border-charcoal-outline" className="bg-panel-gray/40">
|
||||
<Stack bg="bg-charcoal-outline" px={4} py={2}>
|
||||
<Text weight="semibold" color="text-white">Actions</Text>
|
||||
</Stack>
|
||||
<Stack p={4}>
|
||||
<Stack gap={2}>
|
||||
<Button
|
||||
variant="primary"
|
||||
onClick={triggerHealthCheck}
|
||||
fullWidth
|
||||
icon={<Icon icon={RefreshCw} size={4} />}
|
||||
>
|
||||
Run Health Check
|
||||
</Button>
|
||||
<Button
|
||||
variant="secondary"
|
||||
onClick={resetCircuitBreakers}
|
||||
fullWidth
|
||||
icon={<Text size="lg">🔄</Text>}
|
||||
>
|
||||
Reset Circuit Breakers
|
||||
</Button>
|
||||
<Button
|
||||
variant="danger"
|
||||
onClick={() => {
|
||||
connectionMonitor.reset();
|
||||
setConnectionStatus(connectionMonitor.getHealth());
|
||||
}}
|
||||
fullWidth
|
||||
icon={<Text size="lg">🗑️</Text>}
|
||||
>
|
||||
Reset Connection Stats
|
||||
</Button>
|
||||
</Stack>
|
||||
</Stack>
|
||||
</Card>
|
||||
|
||||
{/* Quick Fixes */}
|
||||
<Card p={0} rounded="lg" overflow="hidden" variant="outline" borderColor="border-charcoal-outline" className="bg-panel-gray/40">
|
||||
<Stack bg="bg-charcoal-outline" px={4} py={2}>
|
||||
<Text weight="semibold" color="text-white">Quick Fixes</Text>
|
||||
</Stack>
|
||||
<Stack p={4}>
|
||||
<Stack gap={2} style={{ fontSize: '0.75rem' }}>
|
||||
<Text color="text-gray-400">Common solutions:</Text>
|
||||
<Stack as="ul" gap={1} pl={4}>
|
||||
<Stack as="li"><Text color="text-gray-300">Check API server is running</Text></Stack>
|
||||
<Stack as="li"><Text color="text-gray-300">Verify CORS configuration</Text></Stack>
|
||||
<Stack as="li"><Text color="text-gray-300">Check environment variables</Text></Stack>
|
||||
<Stack as="li"><Text color="text-gray-300">Review network connectivity</Text></Stack>
|
||||
<Stack as="li"><Text color="text-gray-300">Check API rate limits</Text></Stack>
|
||||
</Stack>
|
||||
</Stack>
|
||||
</Stack>
|
||||
</Card>
|
||||
|
||||
{/* Raw Error */}
|
||||
<Card p={0} rounded="lg" overflow="hidden" variant="outline" borderColor="border-charcoal-outline" className="bg-panel-gray/40">
|
||||
<Stack bg="bg-charcoal-outline" px={4} py={2}>
|
||||
<Text weight="semibold" color="text-white">Raw Error</Text>
|
||||
</Stack>
|
||||
<Stack p={4}>
|
||||
<Stack as="pre" p={2} bg="bg-deep-graphite" rounded="md" overflow="auto" maxHeight="8rem" style={{ fontSize: '0.75rem' }} color="text-gray-400">
|
||||
{JSON.stringify({
|
||||
type: error.type,
|
||||
message: error.message,
|
||||
context: error.context,
|
||||
}, null, 2)}
|
||||
</Stack>
|
||||
</Stack>
|
||||
</Card>
|
||||
</Stack>
|
||||
</Grid>
|
||||
|
||||
{/* Console Output */}
|
||||
<Card p={0} rounded="lg" overflow="hidden" variant="outline" borderColor="border-charcoal-outline" className="bg-panel-gray/40">
|
||||
<Stack bg="bg-charcoal-outline" px={4} py={2} direction="row" align="center" gap={2}>
|
||||
<Icon icon={Terminal} size={4} color="text-white" />
|
||||
<Text weight="semibold" color="text-white">Console Output</Text>
|
||||
</Stack>
|
||||
<Stack p={4} bg="bg-deep-graphite" style={{ fontSize: '0.75rem' }}>
|
||||
<Text color="text-gray-500" block mb={2}>{'>'} {error.getDeveloperMessage()}</Text>
|
||||
<Text color="text-gray-600" block>Check browser console for full stack trace and additional debug info.</Text>
|
||||
</Stack>
|
||||
</Card>
|
||||
</Stack>
|
||||
</Stack>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
@@ -3,10 +3,10 @@
|
||||
import { Button } from '@/ui/Button';
|
||||
import { Card } from '@/ui/Card';
|
||||
import { Icon } from '@/ui/Icon';
|
||||
import { Stack } from '@/ui/primitives/Stack';
|
||||
import { Text } from '@/ui/Text';
|
||||
import { ChevronDown, ChevronUp, Copy, Terminal } from 'lucide-react';
|
||||
import { useState } from 'react';
|
||||
import { Accordion } from '@/ui/Accordion';
|
||||
import { Copy } from 'lucide-react';
|
||||
import React, { useState } from 'react';
|
||||
|
||||
interface ErrorDetailsProps {
|
||||
error: Error & { digest?: string };
|
||||
@@ -19,7 +19,6 @@ interface ErrorDetailsProps {
|
||||
* Part of the 500 route redesign.
|
||||
*/
|
||||
export function ErrorDetails({ error }: ErrorDetailsProps) {
|
||||
const [showDetails, setShowDetails] = useState(false);
|
||||
const [copied, setCopied] = useState(false);
|
||||
|
||||
const copyError = async () => {
|
||||
@@ -41,62 +40,28 @@ export function ErrorDetails({ error }: ErrorDetailsProps) {
|
||||
};
|
||||
|
||||
return (
|
||||
<Stack gap={4} fullWidth pt={4} borderTop borderColor="border-white">
|
||||
<Stack
|
||||
as="button"
|
||||
onClick={() => setShowDetails(!showDetails)}
|
||||
direction="row"
|
||||
align="center"
|
||||
justify="center"
|
||||
gap={2}
|
||||
color="text-gray-500"
|
||||
className="transition-all hover:text-gray-300"
|
||||
>
|
||||
<Icon icon={Terminal} size={3} />
|
||||
<Text
|
||||
size="xs"
|
||||
weight="medium"
|
||||
uppercase
|
||||
letterSpacing="widest"
|
||||
color="inherit"
|
||||
>
|
||||
{showDetails ? 'Hide Technical Logs' : 'Show Technical Logs'}
|
||||
</Text>
|
||||
{showDetails ? <Icon icon={ChevronUp} size={3} /> : <Icon icon={ChevronDown} size={3} />}
|
||||
</Stack>
|
||||
|
||||
{showDetails && (
|
||||
<Stack gap={3}>
|
||||
<Card
|
||||
variant="outline"
|
||||
rounded="md"
|
||||
p={4}
|
||||
fullWidth
|
||||
maxHeight="48"
|
||||
overflow="auto"
|
||||
borderColor="border-white"
|
||||
className="bg-graphite-black/40"
|
||||
>
|
||||
<Text font="mono" size="xs" color="text-gray-500" block leading="relaxed">
|
||||
<div style={{ width: '100%', marginTop: '1.5rem', paddingTop: '1.5rem', borderTop: '1px solid var(--ui-color-border-muted)' }}>
|
||||
<Accordion title="Technical Logs">
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '1rem' }}>
|
||||
<Card variant="outline">
|
||||
<Text font="mono" size="xs" variant="low" block leading="relaxed" style={{ maxHeight: '12rem', overflow: 'auto' }}>
|
||||
{error.stack || 'No stack trace available'}
|
||||
{error.digest && `\n\nDigest: ${error.digest}`}
|
||||
</Text>
|
||||
</Card>
|
||||
|
||||
<Stack direction="row" justify="end">
|
||||
<div style={{ display: 'flex', justifyContent: 'flex-end' }}>
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
onClick={copyError}
|
||||
icon={<Icon icon={Copy} size={3} />}
|
||||
height="8"
|
||||
fontSize="10px"
|
||||
icon={<Icon icon={Copy} size={3} intent="low" />}
|
||||
>
|
||||
{copied ? 'Copied to Clipboard' : 'Copy Error Details'}
|
||||
{copied ? 'Copied!' : 'Copy Details'}
|
||||
</Button>
|
||||
</Stack>
|
||||
</Stack>
|
||||
)}
|
||||
</Stack>
|
||||
</div>
|
||||
</div>
|
||||
</Accordion>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -3,10 +3,10 @@
|
||||
import { Button } from '@/ui/Button';
|
||||
import { Card } from '@/ui/Card';
|
||||
import { Icon } from '@/ui/Icon';
|
||||
import { Stack } from '@/ui/primitives/Stack';
|
||||
import { Text } from '@/ui/Text';
|
||||
import { ChevronDown, ChevronUp, Copy } from 'lucide-react';
|
||||
import { useState } from 'react';
|
||||
import { Accordion } from '@/ui/Accordion';
|
||||
import { Copy } from 'lucide-react';
|
||||
import React, { useState } from 'react';
|
||||
|
||||
interface ErrorDetailsBlockProps {
|
||||
error: Error & { digest?: string };
|
||||
@@ -19,7 +19,6 @@ interface ErrorDetailsBlockProps {
|
||||
* Follows "Precision Racing Minimal" theme.
|
||||
*/
|
||||
export function ErrorDetailsBlock({ error }: ErrorDetailsBlockProps) {
|
||||
const [showDetails, setShowDetails] = useState(false);
|
||||
const [copied, setCopied] = useState(false);
|
||||
|
||||
const copyError = async () => {
|
||||
@@ -41,61 +40,28 @@ export function ErrorDetailsBlock({ error }: ErrorDetailsBlockProps) {
|
||||
};
|
||||
|
||||
return (
|
||||
<Stack gap={4} fullWidth pt={4} borderTop borderColor="border-white">
|
||||
<Stack
|
||||
as="button"
|
||||
onClick={() => setShowDetails(!showDetails)}
|
||||
direction="row"
|
||||
align="center"
|
||||
justify="center"
|
||||
gap={2}
|
||||
className="transition-all"
|
||||
>
|
||||
<Text
|
||||
size="xs"
|
||||
color="text-gray-500"
|
||||
className="hover:text-gray-300 flex items-center gap-2"
|
||||
uppercase
|
||||
letterSpacing="widest"
|
||||
weight="medium"
|
||||
>
|
||||
{showDetails ? <Icon icon={ChevronUp} size={3} /> : <Icon icon={ChevronDown} size={3} />}
|
||||
{showDetails ? 'Hide Technical Logs' : 'Show Technical Logs'}
|
||||
</Text>
|
||||
</Stack>
|
||||
|
||||
{showDetails && (
|
||||
<Stack gap={3}>
|
||||
<Card
|
||||
variant="outline"
|
||||
rounded="md"
|
||||
p={4}
|
||||
fullWidth
|
||||
maxHeight="48"
|
||||
overflow="auto"
|
||||
borderColor="border-white"
|
||||
className="bg-graphite-black/40"
|
||||
>
|
||||
<Text font="mono" size="xs" color="text-gray-500" block leading="relaxed">
|
||||
<div style={{ width: '100%', marginTop: '1.5rem', paddingTop: '1.5rem', borderTop: '1px solid var(--ui-color-border-muted)' }}>
|
||||
<Accordion title="Technical Logs">
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '1rem' }}>
|
||||
<Card variant="outline">
|
||||
<Text font="mono" size="xs" variant="low" block leading="relaxed" style={{ maxHeight: '12rem', overflow: 'auto' }}>
|
||||
{error.stack || 'No stack trace available'}
|
||||
{error.digest && `\n\nDigest: ${error.digest}`}
|
||||
</Text>
|
||||
</Card>
|
||||
|
||||
<Stack direction="row" justify="end">
|
||||
<div style={{ display: 'flex', justifyContent: 'flex-end' }}>
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
onClick={copyError}
|
||||
icon={<Icon icon={Copy} size={3} />}
|
||||
height="8"
|
||||
fontSize="10px"
|
||||
icon={<Icon icon={Copy} size={3} intent="low" />}
|
||||
>
|
||||
{copied ? 'Copied to Clipboard' : 'Copy Error Details'}
|
||||
{copied ? 'Copied!' : 'Copy Details'}
|
||||
</Button>
|
||||
</Stack>
|
||||
</Stack>
|
||||
)}
|
||||
</Stack>
|
||||
</div>
|
||||
</div>
|
||||
</Accordion>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,9 +1,7 @@
|
||||
'use client';
|
||||
|
||||
import { Button } from '@/ui/Button';
|
||||
import { Icon } from '@/ui/Icon';
|
||||
import { Stack } from '@/ui/primitives/Stack';
|
||||
import { Home, RefreshCw } from 'lucide-react';
|
||||
import { ErrorActionButtons } from '@/ui/ErrorActionButtons';
|
||||
import React from 'react';
|
||||
|
||||
interface ErrorRecoveryActionsProps {
|
||||
onRetry: () => void;
|
||||
@@ -18,30 +16,9 @@ interface ErrorRecoveryActionsProps {
|
||||
*/
|
||||
export function ErrorRecoveryActions({ onRetry, onHome }: ErrorRecoveryActionsProps) {
|
||||
return (
|
||||
<Stack
|
||||
direction="row"
|
||||
wrap
|
||||
align="center"
|
||||
justify="center"
|
||||
gap={3}
|
||||
fullWidth
|
||||
>
|
||||
<Button
|
||||
variant="primary"
|
||||
onClick={onRetry}
|
||||
icon={<Icon icon={RefreshCw} size={4} />}
|
||||
width="160px"
|
||||
>
|
||||
Retry Session
|
||||
</Button>
|
||||
<Button
|
||||
variant="secondary"
|
||||
onClick={onHome}
|
||||
icon={<Icon icon={Home} size={4} />}
|
||||
width="160px"
|
||||
>
|
||||
Return to Pits
|
||||
</Button>
|
||||
</Stack>
|
||||
<ErrorActionButtons
|
||||
onRetry={onRetry}
|
||||
onGoHome={onHome}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -2,11 +2,12 @@
|
||||
|
||||
import { Card } from '@/ui/Card';
|
||||
import { Glow } from '@/ui/Glow';
|
||||
import { Stack } from '@/ui/primitives/Stack';
|
||||
import { Text } from '@/ui/Text';
|
||||
import { ErrorPageContainer } from '@/ui/ErrorPageContainer';
|
||||
import { AppErrorBoundaryView } from './AppErrorBoundaryView';
|
||||
import { ErrorDetailsBlock } from './ErrorDetailsBlock';
|
||||
import { ErrorRecoveryActions } from './ErrorRecoveryActions';
|
||||
import React from 'react';
|
||||
|
||||
interface ErrorScreenProps {
|
||||
error: Error & { digest?: string };
|
||||
@@ -22,54 +23,27 @@ interface ErrorScreenProps {
|
||||
*/
|
||||
export function ErrorScreen({ error, reset, onHome }: ErrorScreenProps) {
|
||||
return (
|
||||
<Stack
|
||||
as="main"
|
||||
minHeight="screen"
|
||||
fullWidth
|
||||
align="center"
|
||||
justify="center"
|
||||
bg="bg-deep-graphite"
|
||||
position="relative"
|
||||
overflow="hidden"
|
||||
px={6}
|
||||
>
|
||||
<ErrorPageContainer size="lg" variant="glass">
|
||||
{/* Background Accents */}
|
||||
<Glow color="primary" size="xl" position="center" opacity={0.05} />
|
||||
|
||||
<Card
|
||||
variant="outline"
|
||||
rounded="lg"
|
||||
p={8}
|
||||
maxWidth="2xl"
|
||||
fullWidth
|
||||
position="relative"
|
||||
zIndex={10}
|
||||
borderColor="border-white"
|
||||
className="bg-white/5 backdrop-blur-md"
|
||||
<AppErrorBoundaryView
|
||||
title="System Malfunction"
|
||||
description="The application encountered an unexpected state. Our telemetry has logged the incident."
|
||||
>
|
||||
<AppErrorBoundaryView
|
||||
title="System Malfunction"
|
||||
description="The application encountered an unexpected state. Our telemetry has logged the incident."
|
||||
>
|
||||
{/* Error Message Summary */}
|
||||
<Card
|
||||
variant="outline"
|
||||
rounded="md"
|
||||
p={4}
|
||||
fullWidth
|
||||
borderColor="border-white"
|
||||
className="bg-graphite-black/20"
|
||||
>
|
||||
<Text font="mono" size="sm" color="text-warning-amber" block>
|
||||
{/* Error Message Summary */}
|
||||
<div style={{ width: '100%', marginBottom: '1.5rem' }}>
|
||||
<Card variant="outline">
|
||||
<Text font="mono" size="sm" variant="warning" block>
|
||||
{error.message || 'Unknown execution error'}
|
||||
</Text>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
<ErrorRecoveryActions onRetry={reset} onHome={onHome} />
|
||||
|
||||
<ErrorDetailsBlock error={error} />
|
||||
</AppErrorBoundaryView>
|
||||
</Card>
|
||||
</Stack>
|
||||
<ErrorRecoveryActions onRetry={reset} onHome={onHome} />
|
||||
|
||||
<ErrorDetailsBlock error={error} />
|
||||
</AppErrorBoundaryView>
|
||||
</ErrorPageContainer>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -5,9 +5,10 @@ import { Card } from '@/ui/Card';
|
||||
import { Glow } from '@/ui/Glow';
|
||||
import { Heading } from '@/ui/Heading';
|
||||
import { Icon } from '@/ui/Icon';
|
||||
import { Stack } from '@/ui/primitives/Stack';
|
||||
import { Text } from '@/ui/Text';
|
||||
import { ErrorPageContainer } from '@/ui/ErrorPageContainer';
|
||||
import { AlertTriangle, Home, RefreshCw, Terminal } from 'lucide-react';
|
||||
import React from 'react';
|
||||
|
||||
interface GlobalErrorScreenProps {
|
||||
error: Error & { digest?: string };
|
||||
@@ -23,88 +24,44 @@ interface GlobalErrorScreenProps {
|
||||
*/
|
||||
export function GlobalErrorScreen({ error, reset, onHome }: GlobalErrorScreenProps) {
|
||||
return (
|
||||
<Stack
|
||||
as="main"
|
||||
minHeight="screen"
|
||||
fullWidth
|
||||
align="center"
|
||||
justify="center"
|
||||
bg="bg-base-black"
|
||||
position="relative"
|
||||
overflow="hidden"
|
||||
px={6}
|
||||
>
|
||||
<ErrorPageContainer size="lg" variant="glass">
|
||||
{/* Background Accents - Subtle telemetry vibe */}
|
||||
<Glow color="primary" size="xl" position="center" opacity={0.03} />
|
||||
|
||||
<Card
|
||||
variant="outline"
|
||||
rounded="none"
|
||||
p={0}
|
||||
maxWidth="2xl"
|
||||
fullWidth
|
||||
position="relative"
|
||||
zIndex={10}
|
||||
borderColor="border-white"
|
||||
className="bg-graphite-black/10"
|
||||
>
|
||||
{/* System Status Header */}
|
||||
<Stack
|
||||
borderBottom
|
||||
borderColor="border-white"
|
||||
px={6}
|
||||
py={4}
|
||||
direction="row"
|
||||
align="center"
|
||||
justify="between"
|
||||
className="bg-white/5"
|
||||
>
|
||||
<Stack direction="row" gap={3} align="center">
|
||||
<Icon icon={AlertTriangle} size={5} color="var(--warning-amber)" />
|
||||
<Heading level={2} weight="bold">
|
||||
<Text uppercase letterSpacing="widest" size="sm">
|
||||
System Fault Detected
|
||||
</Text>
|
||||
</Heading>
|
||||
</Stack>
|
||||
<Text font="mono" size="xs" color="text-gray-500" uppercase>
|
||||
Status: Critical
|
||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: '1.5rem', paddingBottom: '1rem', borderBottom: '1px solid var(--ui-color-border-default)' }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '0.75rem' }}>
|
||||
<Icon icon={AlertTriangle} size={5} intent="warning" />
|
||||
<Heading level={2} weight="bold">
|
||||
System Fault Detected
|
||||
</Heading>
|
||||
</div>
|
||||
<Text font="mono" size="xs" variant="low" uppercase>
|
||||
Status: Critical
|
||||
</Text>
|
||||
</div>
|
||||
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '2rem' }}>
|
||||
{/* Fault Description */}
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '1rem' }}>
|
||||
<Text variant="med" size="base" leading="relaxed">
|
||||
The application kernel encountered an unrecoverable execution error.
|
||||
Telemetry has been captured for diagnostic review.
|
||||
</Text>
|
||||
</Stack>
|
||||
|
||||
<Stack p={8}>
|
||||
<Stack gap={8}>
|
||||
{/* Fault Description */}
|
||||
<Stack gap={4}>
|
||||
<Text color="text-gray-400" size="base" leading="relaxed">
|
||||
The application kernel encountered an unrecoverable execution error.
|
||||
Telemetry has been captured for diagnostic review.
|
||||
</Text>
|
||||
<SystemStatusPanel error={error} />
|
||||
</div>
|
||||
|
||||
<SystemStatusPanel error={error} />
|
||||
</Stack>
|
||||
{/* Recovery Actions */}
|
||||
<RecoveryActions onRetry={reset} onHome={onHome} />
|
||||
</div>
|
||||
|
||||
{/* Recovery Actions */}
|
||||
<RecoveryActions onRetry={reset} onHome={onHome} />
|
||||
</Stack>
|
||||
</Stack>
|
||||
|
||||
{/* Footer / Metadata */}
|
||||
<Stack
|
||||
borderTop
|
||||
borderColor="border-white"
|
||||
px={6}
|
||||
py={3}
|
||||
direction="row"
|
||||
justify="end"
|
||||
className="bg-white/5"
|
||||
>
|
||||
<Text font="mono" size="xs" color="text-gray-600">
|
||||
GP-CORE-ERR-{error.digest?.substring(0, 8).toUpperCase() || 'UNKNOWN'}
|
||||
</Text>
|
||||
</Stack>
|
||||
</Card>
|
||||
</Stack>
|
||||
{/* Footer / Metadata */}
|
||||
<div style={{ marginTop: '2rem', paddingTop: '1rem', borderTop: '1px solid var(--ui-color-border-default)', textAlign: 'right' }}>
|
||||
<Text font="mono" size="xs" variant="low">
|
||||
GP-CORE-ERR-{error.digest?.substring(0, 8).toUpperCase() || 'UNKNOWN'}
|
||||
</Text>
|
||||
</div>
|
||||
</ErrorPageContainer>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -115,30 +72,23 @@ export function GlobalErrorScreen({ error, reset, onHome }: GlobalErrorScreenPro
|
||||
*/
|
||||
function SystemStatusPanel({ error }: { error: Error & { digest?: string } }) {
|
||||
return (
|
||||
<Card
|
||||
variant="outline"
|
||||
rounded="none"
|
||||
p={4}
|
||||
fullWidth
|
||||
borderColor="border-white"
|
||||
className="bg-graphite-black/20"
|
||||
>
|
||||
<Stack gap={3}>
|
||||
<Stack direction="row" align="center" gap={2}>
|
||||
<Icon icon={Terminal} size={3} color="var(--gray-500)" />
|
||||
<Text font="mono" size="xs" color="text-gray-500" uppercase letterSpacing="wider">
|
||||
<Card variant="outline">
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '0.75rem' }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '0.5rem' }}>
|
||||
<Icon icon={Terminal} size={3} intent="low" />
|
||||
<Text font="mono" size="xs" variant="low" uppercase>
|
||||
Fault Log
|
||||
</Text>
|
||||
</Stack>
|
||||
<Text font="mono" size="sm" color="text-warning-amber" block>
|
||||
</div>
|
||||
<Text font="mono" size="sm" variant="warning" block>
|
||||
{error.message || 'Unknown execution fault'}
|
||||
</Text>
|
||||
{error.digest && (
|
||||
<Text font="mono" size="xs" color="text-gray-600" block>
|
||||
<Text font="mono" size="xs" variant="low" block>
|
||||
Digest: {error.digest}
|
||||
</Text>
|
||||
)}
|
||||
</Stack>
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -150,19 +100,11 @@ function SystemStatusPanel({ error }: { error: Error & { digest?: string } }) {
|
||||
*/
|
||||
function RecoveryActions({ onRetry, onHome }: { onRetry: () => void; onHome: () => void }) {
|
||||
return (
|
||||
<Stack
|
||||
direction="row"
|
||||
wrap
|
||||
align="center"
|
||||
gap={4}
|
||||
fullWidth
|
||||
>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '1rem', flexWrap: 'wrap' }}>
|
||||
<Button
|
||||
variant="primary"
|
||||
onClick={onRetry}
|
||||
icon={<Icon icon={RefreshCw} size={4} />}
|
||||
rounded="none"
|
||||
px={8}
|
||||
>
|
||||
Reboot Session
|
||||
</Button>
|
||||
@@ -170,11 +112,9 @@ function RecoveryActions({ onRetry, onHome }: { onRetry: () => void; onHome: ()
|
||||
variant="secondary"
|
||||
onClick={onHome}
|
||||
icon={<Icon icon={Home} size={4} />}
|
||||
rounded="none"
|
||||
px={8}
|
||||
>
|
||||
Return to Pits
|
||||
</Button>
|
||||
</Stack>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
'use client';
|
||||
|
||||
import { Button } from '@/ui/Button';
|
||||
import { Stack } from '@/ui/primitives/Stack';
|
||||
import { Group } from '@/ui/Group';
|
||||
import { Text } from '@/ui/Text';
|
||||
import { StatusDot } from '@/ui/StatusDot';
|
||||
|
||||
interface NotFoundActionsProps {
|
||||
primaryLabel: string;
|
||||
@@ -17,12 +18,11 @@ interface NotFoundActionsProps {
|
||||
*/
|
||||
export function NotFoundActions({ primaryLabel, onPrimaryClick }: NotFoundActionsProps) {
|
||||
return (
|
||||
<Stack direction="row" gap={4} align="center" justify="center">
|
||||
<Group direction="row" gap={4} align="center" justify="center">
|
||||
<Button
|
||||
variant="primary"
|
||||
size="lg"
|
||||
onClick={onPrimaryClick}
|
||||
minWidth="200px"
|
||||
>
|
||||
{primaryLabel}
|
||||
</Button>
|
||||
@@ -32,18 +32,13 @@ export function NotFoundActions({ primaryLabel, onPrimaryClick }: NotFoundAction
|
||||
size="lg"
|
||||
onClick={() => window.history.back()}
|
||||
>
|
||||
<Stack direction="row" gap={2} align="center">
|
||||
<Stack
|
||||
width={2}
|
||||
height={2}
|
||||
rounded="full"
|
||||
bg="soft-steel"
|
||||
/>
|
||||
<Text size="xs" weight="bold" uppercase letterSpacing="widest" color="text-gray-400">
|
||||
<Group direction="row" gap={2} align="center">
|
||||
<StatusDot intent="telemetry" size="sm" />
|
||||
<Text size="xs" weight="bold" uppercase variant="low">
|
||||
Previous Sector
|
||||
</Text>
|
||||
</Stack>
|
||||
</Group>
|
||||
</Button>
|
||||
</Stack>
|
||||
</Group>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
'use client';
|
||||
|
||||
import { Button } from '@/ui/Button';
|
||||
import { Stack } from '@/ui/primitives/Stack';
|
||||
import { Text } from '@/ui/Text';
|
||||
import React from 'react';
|
||||
|
||||
interface NotFoundCallToActionProps {
|
||||
label: string;
|
||||
@@ -17,7 +17,7 @@ interface NotFoundCallToActionProps {
|
||||
*/
|
||||
export function NotFoundCallToAction({ label, onClick }: NotFoundCallToActionProps) {
|
||||
return (
|
||||
<Stack gap={4} align="center">
|
||||
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', gap: '1rem' }}>
|
||||
<Button
|
||||
variant="primary"
|
||||
size="lg"
|
||||
@@ -25,9 +25,9 @@ export function NotFoundCallToAction({ label, onClick }: NotFoundCallToActionPro
|
||||
>
|
||||
{label}
|
||||
</Button>
|
||||
<Text size="xs" color="text-gray-500" uppercase letterSpacing="widest">
|
||||
<Text size="xs" variant="low" uppercase>
|
||||
Telemetry connection lost
|
||||
</Text>
|
||||
</Stack>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
'use client';
|
||||
|
||||
import { Stack } from '@/ui/primitives/Stack';
|
||||
import { Group } from '@/ui/Group';
|
||||
import { Text } from '@/ui/Text';
|
||||
import { Badge } from '@/ui/Badge';
|
||||
|
||||
interface NotFoundDiagnosticsProps {
|
||||
errorCode: string;
|
||||
@@ -15,35 +16,19 @@ interface NotFoundDiagnosticsProps {
|
||||
*/
|
||||
export function NotFoundDiagnostics({ errorCode }: NotFoundDiagnosticsProps) {
|
||||
return (
|
||||
<Stack gap={3} align="center">
|
||||
<Stack
|
||||
px={3}
|
||||
py={1}
|
||||
border
|
||||
borderColor="primary-accent"
|
||||
bg="primary-accent"
|
||||
bgOpacity={0.1}
|
||||
rounded="sm"
|
||||
>
|
||||
<Text
|
||||
size="xs"
|
||||
weight="bold"
|
||||
color="text-primary-accent"
|
||||
uppercase
|
||||
letterSpacing="widest"
|
||||
>
|
||||
{errorCode}
|
||||
</Text>
|
||||
</Stack>
|
||||
<Group direction="column" gap={3} align="center">
|
||||
<Badge variant="primary" size="md">
|
||||
{errorCode}
|
||||
</Badge>
|
||||
<Text
|
||||
size="xs"
|
||||
color="text-gray-500"
|
||||
variant="low"
|
||||
uppercase
|
||||
letterSpacing="widest"
|
||||
weight="medium"
|
||||
align="center"
|
||||
>
|
||||
Telemetry connection lost // Sector data unavailable
|
||||
</Text>
|
||||
</Stack>
|
||||
</Group>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
'use client';
|
||||
|
||||
import { Stack } from '@/ui/primitives/Stack';
|
||||
import { NavGroup } from '@/ui/NavGroup';
|
||||
import { Text } from '@/ui/Text';
|
||||
import { Link } from '@/ui/Link';
|
||||
import React from 'react';
|
||||
|
||||
interface NotFoundHelpLinksProps {
|
||||
@@ -16,31 +17,20 @@ interface NotFoundHelpLinksProps {
|
||||
*/
|
||||
export function NotFoundHelpLinks({ links }: NotFoundHelpLinksProps) {
|
||||
return (
|
||||
<Stack direction="row" gap={6} align="center" wrap center>
|
||||
{links.map((link, index) => (
|
||||
<React.Fragment key={link.href}>
|
||||
<Stack
|
||||
as="a"
|
||||
href={link.href}
|
||||
transition
|
||||
display="inline-block"
|
||||
<NavGroup direction="horizontal" gap={6} align="center">
|
||||
{links.map((link) => (
|
||||
<Link key={link.href} href={link.href} variant="ghost" underline="none">
|
||||
<Text
|
||||
variant="low"
|
||||
hoverVariant="primary"
|
||||
weight="medium"
|
||||
size="xs"
|
||||
uppercase
|
||||
>
|
||||
<Text
|
||||
color="text-gray-400"
|
||||
hoverTextColor="primary-accent"
|
||||
weight="medium"
|
||||
size="xs"
|
||||
letterSpacing="widest"
|
||||
uppercase
|
||||
>
|
||||
{link.label}
|
||||
</Text>
|
||||
</Stack>
|
||||
{index < links.length - 1 && (
|
||||
<Stack width="1px" height="12px" bg="border-gray" opacity={0.5} />
|
||||
)}
|
||||
</React.Fragment>
|
||||
{link.label}
|
||||
</Text>
|
||||
</Link>
|
||||
))}
|
||||
</Stack>
|
||||
</NavGroup>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,12 +1,13 @@
|
||||
'use client';
|
||||
|
||||
import { Card } from '@/ui/Card';
|
||||
import { ErrorPageContainer } from '@/ui/ErrorPageContainer';
|
||||
import { Glow } from '@/ui/Glow';
|
||||
import { Stack } from '@/ui/primitives/Stack';
|
||||
import { Text } from '@/ui/Text';
|
||||
import { FooterSection } from '@/ui/FooterSection';
|
||||
import { NotFoundActions } from './NotFoundActions';
|
||||
import { NotFoundDiagnostics } from './NotFoundDiagnostics';
|
||||
import { NotFoundHelpLinks } from './NotFoundHelpLinks';
|
||||
import React from 'react';
|
||||
|
||||
interface NotFoundScreenProps {
|
||||
errorCode: string;
|
||||
@@ -37,105 +38,44 @@ export function NotFoundScreen({
|
||||
];
|
||||
|
||||
return (
|
||||
<Stack
|
||||
as="main"
|
||||
minHeight="screen"
|
||||
align="center"
|
||||
justify="center"
|
||||
bg="graphite-black"
|
||||
position="relative"
|
||||
overflow="hidden"
|
||||
fullWidth
|
||||
>
|
||||
<ErrorPageContainer size="lg" variant="glass">
|
||||
{/* Background Glow Accent */}
|
||||
<Glow color="primary" size="xl" opacity={0.1} position="center" />
|
||||
|
||||
<Card
|
||||
variant="outline"
|
||||
p={12}
|
||||
rounded="none"
|
||||
maxWidth="2xl"
|
||||
fullWidth
|
||||
mx={6}
|
||||
position="relative"
|
||||
zIndex={10}
|
||||
className="bg-white/5 backdrop-blur-md"
|
||||
<NotFoundDiagnostics errorCode={errorCode} />
|
||||
|
||||
<Text
|
||||
as="h1"
|
||||
size="4xl"
|
||||
weight="bold"
|
||||
variant="high"
|
||||
uppercase
|
||||
block
|
||||
align="center"
|
||||
style={{ marginTop: '1rem', marginBottom: '2rem' }}
|
||||
>
|
||||
<Stack gap={12} align="center">
|
||||
{/* Header Section */}
|
||||
<Stack gap={4} align="center">
|
||||
<NotFoundDiagnostics errorCode={errorCode} />
|
||||
|
||||
<Text
|
||||
as="h1"
|
||||
size="4xl"
|
||||
weight="bold"
|
||||
color="text-white"
|
||||
letterSpacing="tighter"
|
||||
uppercase
|
||||
block
|
||||
leading="none"
|
||||
textAlign="center"
|
||||
>
|
||||
{title}
|
||||
</Text>
|
||||
</Stack>
|
||||
{title}
|
||||
</Text>
|
||||
|
||||
{/* Visual Separator */}
|
||||
<Stack width="full" height="1px" bg="primary-accent" opacity={0.3} position="relative" align="center" justify="center">
|
||||
<Stack
|
||||
w="3"
|
||||
h="3"
|
||||
bg="primary-accent"
|
||||
>{null}</Stack>
|
||||
</Stack>
|
||||
<Text
|
||||
size="xl"
|
||||
variant="med"
|
||||
block
|
||||
weight="medium"
|
||||
align="center"
|
||||
style={{ marginBottom: '3rem' }}
|
||||
>
|
||||
{message}
|
||||
</Text>
|
||||
|
||||
{/* Message Section */}
|
||||
<Text
|
||||
size="xl"
|
||||
color="text-gray-400"
|
||||
maxWidth="lg"
|
||||
leading="relaxed"
|
||||
block
|
||||
weight="medium"
|
||||
textAlign="center"
|
||||
>
|
||||
{message}
|
||||
</Text>
|
||||
<NotFoundActions
|
||||
primaryLabel={actionLabel}
|
||||
onPrimaryClick={onActionClick}
|
||||
/>
|
||||
|
||||
{/* Actions Section */}
|
||||
<NotFoundActions
|
||||
primaryLabel={actionLabel}
|
||||
onPrimaryClick={onActionClick}
|
||||
/>
|
||||
|
||||
{/* Footer Section */}
|
||||
<Stack pt={8} width="full">
|
||||
<Stack height="1px" width="full" bg="border-gray" opacity={0.1} mb={8}>{null}</Stack>
|
||||
<NotFoundHelpLinks links={helpLinks} />
|
||||
</Stack>
|
||||
</Stack>
|
||||
</Card>
|
||||
|
||||
{/* Subtle Edge Details */}
|
||||
<Stack
|
||||
position="absolute"
|
||||
top={0}
|
||||
left={0}
|
||||
right={0}
|
||||
h="2px"
|
||||
bg="primary-accent"
|
||||
opacity={0.1}
|
||||
>{null}</Stack>
|
||||
<Stack
|
||||
position="absolute"
|
||||
bottom={0}
|
||||
left={0}
|
||||
right={0}
|
||||
h="2px"
|
||||
bg="primary-accent"
|
||||
opacity={0.1}
|
||||
>{null}</Stack>
|
||||
</Stack>
|
||||
<FooterSection>
|
||||
<NotFoundHelpLinks links={helpLinks} />
|
||||
</FooterSection>
|
||||
</ErrorPageContainer>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -2,8 +2,8 @@
|
||||
|
||||
import { Button } from '@/ui/Button';
|
||||
import { Icon } from '@/ui/Icon';
|
||||
import { Stack } from '@/ui/primitives/Stack';
|
||||
import { Home, LifeBuoy, RefreshCw } from 'lucide-react';
|
||||
import React from 'react';
|
||||
|
||||
interface RecoveryActionsProps {
|
||||
onRetry: () => void;
|
||||
@@ -18,19 +18,11 @@ interface RecoveryActionsProps {
|
||||
*/
|
||||
export function RecoveryActions({ onRetry, onHome }: RecoveryActionsProps) {
|
||||
return (
|
||||
<Stack
|
||||
direction="row"
|
||||
wrap
|
||||
align="center"
|
||||
justify="center"
|
||||
gap={3}
|
||||
fullWidth
|
||||
>
|
||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'center', gap: '0.75rem', flexWrap: 'wrap', width: '100%' }}>
|
||||
<Button
|
||||
variant="primary"
|
||||
onClick={onRetry}
|
||||
icon={<Icon icon={RefreshCw} size={4} />}
|
||||
width="160px"
|
||||
>
|
||||
Retry Session
|
||||
</Button>
|
||||
@@ -38,7 +30,6 @@ export function RecoveryActions({ onRetry, onHome }: RecoveryActionsProps) {
|
||||
variant="secondary"
|
||||
onClick={onHome}
|
||||
icon={<Icon icon={Home} size={4} />}
|
||||
width="160px"
|
||||
>
|
||||
Return to Pits
|
||||
</Button>
|
||||
@@ -49,10 +40,9 @@ export function RecoveryActions({ onRetry, onHome }: RecoveryActionsProps) {
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
icon={<Icon icon={LifeBuoy} size={4} />}
|
||||
width="160px"
|
||||
>
|
||||
Contact Support
|
||||
</Button>
|
||||
</Stack>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -3,9 +3,9 @@
|
||||
import { Card } from '@/ui/Card';
|
||||
import { Heading } from '@/ui/Heading';
|
||||
import { Icon } from '@/ui/Icon';
|
||||
import { Stack } from '@/ui/primitives/Stack';
|
||||
import { Text } from '@/ui/Text';
|
||||
import { AlertTriangle } from 'lucide-react';
|
||||
import React from 'react';
|
||||
|
||||
interface ServerErrorPanelProps {
|
||||
message?: string;
|
||||
@@ -20,57 +20,53 @@ interface ServerErrorPanelProps {
|
||||
*/
|
||||
export function ServerErrorPanel({ message, incidentId }: ServerErrorPanelProps) {
|
||||
return (
|
||||
<Stack gap={6} align="center" fullWidth>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', gap: '1.5rem', width: '100%' }}>
|
||||
{/* Status Indicator */}
|
||||
<Stack
|
||||
p={4}
|
||||
rounded="full"
|
||||
bg="bg-warning-amber"
|
||||
{...({ bgOpacity: 0.1 } as any)}
|
||||
border
|
||||
borderColor="border-warning-amber"
|
||||
align="center"
|
||||
justify="center"
|
||||
<div
|
||||
style={{
|
||||
padding: '1rem',
|
||||
borderRadius: '9999px',
|
||||
backgroundColor: 'rgba(255, 190, 77, 0.1)',
|
||||
border: '1px solid rgba(255, 190, 77, 0.3)',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center'
|
||||
}}
|
||||
>
|
||||
<Icon icon={AlertTriangle} size={8} color="var(--warning-amber)" />
|
||||
</Stack>
|
||||
<Icon icon={AlertTriangle} size={8} intent="warning" />
|
||||
</div>
|
||||
|
||||
{/* Primary Message */}
|
||||
<Stack gap={2} align="center">
|
||||
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', gap: '0.5rem' }}>
|
||||
<Heading level={1} weight="bold">
|
||||
CRITICAL_SYSTEM_FAILURE
|
||||
</Heading>
|
||||
<Text color="text-gray-400" textAlign="center" maxWidth="md">
|
||||
<Text variant="low" align="center" style={{ maxWidth: '32rem' }}>
|
||||
The application engine encountered an unrecoverable state.
|
||||
Telemetry has been dispatched to engineering.
|
||||
</Text>
|
||||
</Stack>
|
||||
</div>
|
||||
|
||||
{/* Technical Summary */}
|
||||
<Card
|
||||
variant="outline"
|
||||
rounded="md"
|
||||
p={4}
|
||||
fullWidth
|
||||
borderColor="border-white"
|
||||
className="bg-graphite-black/20"
|
||||
>
|
||||
<Stack gap={2}>
|
||||
<Text font="mono" size="sm" color="text-warning-amber" block>
|
||||
STATUS: 500_INTERNAL_SERVER_ERROR
|
||||
</Text>
|
||||
{message && (
|
||||
<Text font="mono" size="xs" color="text-gray-400" block>
|
||||
EXCEPTION: {message}
|
||||
<div style={{ width: '100%' }}>
|
||||
<Card variant="outline">
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '0.5rem' }}>
|
||||
<Text font="mono" size="sm" variant="warning" block>
|
||||
STATUS: 500_INTERNAL_SERVER_ERROR
|
||||
</Text>
|
||||
)}
|
||||
{incidentId && (
|
||||
<Text font="mono" size="xs" color="text-gray-500" block>
|
||||
INCIDENT_ID: {incidentId}
|
||||
</Text>
|
||||
)}
|
||||
</Stack>
|
||||
</Card>
|
||||
</Stack>
|
||||
{message && (
|
||||
<Text font="mono" size="xs" variant="low" block>
|
||||
EXCEPTION: {message}
|
||||
</Text>
|
||||
)}
|
||||
{incidentId && (
|
||||
<Text font="mono" size="xs" variant="low" block>
|
||||
INCIDENT_ID: {incidentId}
|
||||
</Text>
|
||||
)}
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,11 +1,13 @@
|
||||
'use client';
|
||||
|
||||
import React from 'react';
|
||||
import { Activity } from 'lucide-react';
|
||||
import { Card } from '@/ui/Card';
|
||||
import { Heading } from '@/ui/Heading';
|
||||
import { SectionHeader } from '@/ui/SectionHeader';
|
||||
import { ActivityItem } from '@/ui/ActivityItem';
|
||||
import { Icon } from '@/ui/Icon';
|
||||
import { ActivityFeedList } from '@/components/feed/ActivityFeedList';
|
||||
import { MinimalEmptyState } from '@/components/shared/state/EmptyState';
|
||||
import { EmptyState } from '@/ui/EmptyState';
|
||||
import { Button } from '@/ui/Button';
|
||||
|
||||
interface FeedItem {
|
||||
id: string;
|
||||
@@ -24,27 +26,34 @@ interface ActivityFeedProps {
|
||||
export function ActivityFeed({ items, hasItems }: ActivityFeedProps) {
|
||||
return (
|
||||
<Card>
|
||||
<Heading level={2} icon={<Icon icon={Activity} size={5} color="var(--primary-blue)" />} mb={4}>
|
||||
Recent Activity
|
||||
</Heading>
|
||||
<SectionHeader
|
||||
title="Recent Activity"
|
||||
variant="minimal"
|
||||
actions={<Icon icon={Activity} size={5} intent="primary" />}
|
||||
/>
|
||||
{hasItems ? (
|
||||
<ActivityFeedList>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '1rem' }}>
|
||||
{items.slice(0, 5).map((item) => (
|
||||
<ActivityItem
|
||||
key={item.id}
|
||||
headline={item.headline}
|
||||
body={item.body}
|
||||
formattedTime={item.formattedTime}
|
||||
ctaHref={item.ctaHref}
|
||||
ctaLabel={item.ctaLabel}
|
||||
/>
|
||||
title={item.headline}
|
||||
description={item.body}
|
||||
timestamp={item.formattedTime}
|
||||
>
|
||||
{item.ctaHref && item.ctaLabel && (
|
||||
<Button variant="ghost" size="sm" as="a" href={item.ctaHref}>
|
||||
{item.ctaLabel}
|
||||
</Button>
|
||||
)}
|
||||
</ActivityItem>
|
||||
))}
|
||||
</ActivityFeedList>
|
||||
</div>
|
||||
) : (
|
||||
<MinimalEmptyState
|
||||
<EmptyState
|
||||
icon={Activity}
|
||||
title="No activity yet"
|
||||
description="Join leagues and add friends to see activity here"
|
||||
variant="minimal"
|
||||
/>
|
||||
)}
|
||||
</Card>
|
||||
|
||||
@@ -1,9 +1,7 @@
|
||||
'use client';
|
||||
|
||||
|
||||
import { Box } from '@/ui/primitives/Box';
|
||||
import { Surface } from '@/ui/primitives/Surface';
|
||||
import { Text } from '@/ui/Text';
|
||||
import { ReactNode } from 'react';
|
||||
import { ActivityItem } from '@/ui/ActivityItem';
|
||||
import React, { ReactNode } from 'react';
|
||||
|
||||
interface ActivityFeedItemProps {
|
||||
icon: ReactNode;
|
||||
@@ -17,34 +15,13 @@ export function ActivityFeedItem({
|
||||
timestamp,
|
||||
}: ActivityFeedItemProps) {
|
||||
return (
|
||||
<Box
|
||||
display="flex"
|
||||
alignItems="start"
|
||||
gap={3}
|
||||
py={3}
|
||||
borderBottom
|
||||
style={{ borderColor: 'rgba(38, 38, 38, 0.3)' }}
|
||||
className="last:border-0"
|
||||
<ActivityItem
|
||||
title=""
|
||||
description={typeof content === 'string' ? content : undefined}
|
||||
timestamp={timestamp}
|
||||
icon={icon}
|
||||
>
|
||||
<Surface
|
||||
variant="muted"
|
||||
w="8"
|
||||
h="8"
|
||||
rounded="full"
|
||||
display="flex"
|
||||
center
|
||||
flexShrink={0}
|
||||
>
|
||||
{icon}
|
||||
</Surface>
|
||||
<Box style={{ flex: 1, minWidth: 0 }}>
|
||||
<Text size="sm" leading="relaxed" block>
|
||||
{content}
|
||||
</Text>
|
||||
<Text size="xs" color="text-gray-500" mt={1} block>
|
||||
{timestamp}
|
||||
</Text>
|
||||
</Box>
|
||||
</Box>
|
||||
{typeof content !== 'string' && content}
|
||||
</ActivityItem>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { Stack } from '@/ui/primitives/Stack';
|
||||
import { ReactNode } from 'react';
|
||||
import React, { ReactNode } from 'react';
|
||||
|
||||
interface ActivityFeedListProps {
|
||||
children: ReactNode;
|
||||
@@ -7,8 +6,8 @@ interface ActivityFeedListProps {
|
||||
|
||||
export function ActivityFeedList({ children }: ActivityFeedListProps) {
|
||||
return (
|
||||
<Stack gap={4}>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '1rem' }}>
|
||||
{children}
|
||||
</Stack>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { Button } from '@/ui/Button';
|
||||
import { FeedItem } from '@/ui/FeedItem';
|
||||
import { TimeDisplay } from '@/lib/display-objects/TimeDisplay';
|
||||
|
||||
interface FeedItemData {
|
||||
id: string;
|
||||
@@ -15,18 +16,6 @@ interface FeedItemData {
|
||||
ctaLabel?: string;
|
||||
}
|
||||
|
||||
function timeAgo(timestamp: Date | string): string {
|
||||
const date = typeof timestamp === 'string' ? new Date(timestamp) : timestamp;
|
||||
const diffMs = Date.now() - date.getTime();
|
||||
const diffMinutes = Math.floor(diffMs / 60000);
|
||||
if (diffMinutes < 1) return 'Just now';
|
||||
if (diffMinutes < 60) return `${diffMinutes} min ago`;
|
||||
const diffHours = Math.floor(diffMinutes / 60);
|
||||
if (diffHours < 24) return `${diffHours} h ago`;
|
||||
const diffDays = Math.floor(diffHours / 24);
|
||||
return `${diffDays} d ago`;
|
||||
}
|
||||
|
||||
async function resolveActor() {
|
||||
return null;
|
||||
}
|
||||
@@ -55,20 +44,23 @@ export function FeedItemCard({ item }: FeedItemCardProps) {
|
||||
|
||||
return (
|
||||
<FeedItem
|
||||
actorName={actor?.name}
|
||||
actorAvatarUrl={actor?.avatarUrl}
|
||||
typeLabel={item.type.startsWith('friend') ? 'FR' : 'LG'}
|
||||
headline={item.headline}
|
||||
body={item.body}
|
||||
timeAgo={timeAgo(item.timestamp)}
|
||||
cta={item.ctaHref && item.ctaLabel ? (
|
||||
user={{
|
||||
name: actor?.name || 'Unknown',
|
||||
avatar: actor?.avatarUrl
|
||||
}}
|
||||
timestamp={TimeDisplay.timeAgo(item.timestamp)}
|
||||
content={
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '0.5rem' }}>
|
||||
<div style={{ fontWeight: 'bold' }}>{item.headline}</div>
|
||||
{item.body && <div>{item.body}</div>}
|
||||
</div>
|
||||
}
|
||||
actions={item.ctaHref && item.ctaLabel ? (
|
||||
<Button
|
||||
as="a"
|
||||
href={item.ctaHref}
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
px={4}
|
||||
py={2}
|
||||
>
|
||||
{item.ctaLabel}
|
||||
</Button>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { FeedItemCard } from '@/components/feed/FeedItemCard';
|
||||
import { FeedEmptyState } from '@/ui/FeedEmptyState';
|
||||
import { Stack } from '@/ui/primitives/Stack';
|
||||
import React from 'react';
|
||||
|
||||
interface FeedItemData {
|
||||
id: string;
|
||||
@@ -23,10 +23,10 @@ export function FeedList({ items }: FeedListProps) {
|
||||
}
|
||||
|
||||
return (
|
||||
<Stack gap={4}>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '1rem' }}>
|
||||
{items.map(item => (
|
||||
<FeedItemCard key={item.id} item={item} />
|
||||
))}
|
||||
</Stack>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,28 +1,13 @@
|
||||
|
||||
|
||||
import { ActiveDriverCard } from '@/components/drivers/ActiveDriverCard';
|
||||
'use client';
|
||||
|
||||
import { ActiveDriverCard } from '@/ui/ActiveDriverCard';
|
||||
import { mediaConfig } from '@/lib/config/mediaConfig';
|
||||
import { Heading } from '@/ui/Heading';
|
||||
import { SectionHeader } from '@/ui/SectionHeader';
|
||||
import { Icon } from '@/ui/Icon';
|
||||
import { Box } from '@/ui/primitives/Box';
|
||||
import { Text } from '@/ui/Text';
|
||||
import { Activity } from 'lucide-react';
|
||||
|
||||
const SKILL_LEVELS = [
|
||||
{ id: 'pro', label: 'Pro', color: 'text-yellow-400' },
|
||||
{ id: 'advanced', label: 'Advanced', color: 'text-purple-400' },
|
||||
{ id: 'intermediate', label: 'Intermediate', color: 'text-primary-blue' },
|
||||
{ id: 'beginner', label: 'Beginner', color: 'text-green-400' },
|
||||
];
|
||||
|
||||
const CATEGORIES = [
|
||||
{ id: 'beginner', label: 'Beginner', color: 'text-green-400' },
|
||||
{ id: 'intermediate', label: 'Intermediate', color: 'text-primary-blue' },
|
||||
{ id: 'advanced', label: 'Advanced', color: 'text-purple-400' },
|
||||
{ id: 'pro', label: 'Pro', color: 'text-yellow-400' },
|
||||
{ id: 'endurance', label: 'Endurance', color: 'text-orange-400' },
|
||||
{ id: 'sprint', label: 'Sprint', color: 'text-red-400' },
|
||||
];
|
||||
import React from 'react';
|
||||
|
||||
interface RecentActivityProps {
|
||||
drivers: {
|
||||
@@ -40,45 +25,28 @@ export function RecentActivity({ drivers, onDriverClick }: RecentActivityProps)
|
||||
const activeDrivers = drivers.filter((d) => d.isActive).slice(0, 6);
|
||||
|
||||
return (
|
||||
<Box mb={10}>
|
||||
<Box display="flex" alignItems="center" gap={3} mb={4}>
|
||||
<Box
|
||||
display="flex"
|
||||
h="10"
|
||||
w="10"
|
||||
alignItems="center"
|
||||
justifyContent="center"
|
||||
rounded="xl"
|
||||
bg="bg-performance-green/10"
|
||||
border
|
||||
borderColor="border-performance-green/20"
|
||||
>
|
||||
<Icon icon={Activity} size={5} color="rgb(16, 185, 129)" />
|
||||
</Box>
|
||||
<Box>
|
||||
<Heading level={2}>Active Drivers</Heading>
|
||||
<Text size="xs" color="text-gray-500">Currently competing in leagues</Text>
|
||||
</Box>
|
||||
</Box>
|
||||
<div style={{ marginBottom: '2.5rem' }}>
|
||||
<SectionHeader
|
||||
title="Active Drivers"
|
||||
description="Currently competing in leagues"
|
||||
variant="minimal"
|
||||
actions={<Icon icon={Activity} size={5} intent="success" />}
|
||||
/>
|
||||
|
||||
<Box display="grid" responsiveGridCols={{ base: 2, md: 3, lg: 6 }} gap={3}>
|
||||
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fill, minmax(10rem, 1fr))', gap: '0.75rem' }}>
|
||||
{activeDrivers.map((driver) => {
|
||||
const levelConfig = SKILL_LEVELS.find((l) => l.id === driver.skillLevel);
|
||||
const categoryConfig = CATEGORIES.find((c) => c.id === driver.category);
|
||||
return (
|
||||
<ActiveDriverCard
|
||||
key={driver.id}
|
||||
name={driver.name}
|
||||
avatarUrl={driver.avatarUrl || mediaConfig.avatars.defaultFallback}
|
||||
categoryLabel={categoryConfig?.label}
|
||||
categoryColor={categoryConfig?.color}
|
||||
skillLevelLabel={levelConfig?.label}
|
||||
skillLevelColor={levelConfig?.color}
|
||||
categoryLabel={driver.category}
|
||||
skillLevelLabel={driver.skillLevel}
|
||||
onClick={() => onDriverClick(driver.id)}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</Box>
|
||||
</Box>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,65 +0,0 @@
|
||||
import { Text } from '@/ui/Text';
|
||||
import { Box } from '@/ui/primitives/Box';
|
||||
import { LucideIcon } from 'lucide-react';
|
||||
import Link from 'next/link';
|
||||
|
||||
interface NavLinkProps {
|
||||
href: string;
|
||||
label: string;
|
||||
icon?: LucideIcon;
|
||||
isActive?: boolean;
|
||||
variant?: 'sidebar' | 'top';
|
||||
}
|
||||
|
||||
/**
|
||||
* NavLink provides a consistent link component for navigation.
|
||||
* Supports both sidebar and top navigation variants.
|
||||
*/
|
||||
export function NavLink({ href, label, icon: Icon, isActive, variant = 'sidebar' }: NavLinkProps) {
|
||||
if (variant === 'top') {
|
||||
return (
|
||||
<Box
|
||||
as={Link}
|
||||
href={href}
|
||||
display="flex"
|
||||
alignItems="center"
|
||||
gap={2}
|
||||
px={3}
|
||||
py={2}
|
||||
transition
|
||||
color={isActive ? 'primary-accent' : 'text-gray-400'}
|
||||
hoverTextColor="white"
|
||||
>
|
||||
{Icon && <Icon size={18} />}
|
||||
<Text size="sm" weight={isActive ? 'bold' : 'medium'}>
|
||||
{label}
|
||||
</Text>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Box
|
||||
as={Link}
|
||||
href={href}
|
||||
display="flex"
|
||||
alignItems="center"
|
||||
gap={3}
|
||||
px={3}
|
||||
py={2}
|
||||
rounded="md"
|
||||
transition
|
||||
bg={isActive ? 'primary-accent/10' : 'transparent'}
|
||||
color={isActive ? 'primary-accent' : 'text-gray-400'}
|
||||
hoverBg={isActive ? 'primary-accent/10' : 'white/5'}
|
||||
hoverTextColor={isActive ? 'primary-accent' : 'white'}
|
||||
group
|
||||
>
|
||||
{Icon && <Icon size={20} color={isActive ? '#198CFF' : '#6B7280'} />}
|
||||
<Text weight="medium">{label}</Text>
|
||||
{isActive && (
|
||||
<Box ml="auto" w="4px" h="16px" bg="primary-accent" rounded="full" shadow="[0_0_8px_rgba(25,140,255,0.5)]" />
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
@@ -1,7 +1,8 @@
|
||||
import { Icon } from '@/ui/Icon';
|
||||
import { Box } from '@/ui/primitives/Box';
|
||||
import { Text } from '@/ui/Text';
|
||||
import { Badge } from '@/ui/Badge';
|
||||
import { ChevronDown, ChevronUp, Minus } from 'lucide-react';
|
||||
import React from 'react';
|
||||
|
||||
interface DeltaChipProps {
|
||||
value: number;
|
||||
@@ -11,40 +12,26 @@ interface DeltaChipProps {
|
||||
export function DeltaChip({ value, type = 'rank' }: DeltaChipProps) {
|
||||
if (value === 0) {
|
||||
return (
|
||||
<Box display="flex" alignItems="center" gap={1} color="text-gray-600">
|
||||
<Icon icon={Minus} size={3} />
|
||||
<Text size="xs" font="mono">0</Text>
|
||||
</Box>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '0.25rem' }}>
|
||||
<Icon icon={Minus} size={3} intent="low" />
|
||||
<Text size="xs" font="mono" variant="low">0</Text>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const isPositive = value > 0;
|
||||
const color = isPositive
|
||||
? (type === 'rank' ? 'text-performance-green' : 'text-performance-green')
|
||||
: (type === 'rank' ? 'text-error-red' : 'text-error-red');
|
||||
|
||||
// For rank, positive delta usually means dropping positions (e.g. +1 rank means 1st -> 2nd)
|
||||
// But usually "Delta" in leaderboards means "positions gained/lost"
|
||||
// Let's assume value is "positions gained" (positive = up, negative = down)
|
||||
|
||||
const variant = isPositive ? 'success' : 'critical';
|
||||
const IconComponent = isPositive ? ChevronUp : ChevronDown;
|
||||
const absoluteValue = Math.abs(value);
|
||||
|
||||
return (
|
||||
<Box
|
||||
display="flex"
|
||||
alignItems="center"
|
||||
gap={0.5}
|
||||
color={color}
|
||||
bg={`${color.replace('text-', 'bg-')}/10`}
|
||||
px={1.5}
|
||||
py={0.5}
|
||||
rounded="full"
|
||||
>
|
||||
<Icon icon={IconComponent} size={3} />
|
||||
<Text size="xs" font="mono" weight="bold">
|
||||
{absoluteValue}
|
||||
</Text>
|
||||
</Box>
|
||||
<Badge variant={variant} size="sm">
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '0.125rem' }}>
|
||||
<Icon icon={IconComponent} size={3} />
|
||||
<Text size="xs" font="mono" weight="bold">
|
||||
{absoluteValue}
|
||||
</Text>
|
||||
</div>
|
||||
</Badge>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { Icon } from '@/ui/Icon';
|
||||
import { Stack } from '@/ui/primitives/Stack';
|
||||
import { Text } from '@/ui/Text';
|
||||
import { Input } from '@/ui/Input';
|
||||
import { ControlBar } from '@/ui/ControlBar';
|
||||
import { Button } from '@/ui/Button';
|
||||
import { Filter, Search } from 'lucide-react';
|
||||
import React from 'react';
|
||||
|
||||
@@ -18,70 +19,32 @@ export function LeaderboardFiltersBar({
|
||||
children,
|
||||
}: LeaderboardFiltersBarProps) {
|
||||
return (
|
||||
<Stack
|
||||
mb={6}
|
||||
p={3}
|
||||
bg="bg-deep-charcoal/40"
|
||||
border
|
||||
borderColor="border-charcoal-outline/50"
|
||||
rounded="lg"
|
||||
blur="sm"
|
||||
>
|
||||
<Stack direction="row" align="center" justify="between" gap={4}>
|
||||
<Stack position="relative" flexGrow={1} maxWidth="md">
|
||||
<Stack
|
||||
position="absolute"
|
||||
left="3"
|
||||
top="1/2"
|
||||
transform="translateY(-50%)"
|
||||
pointerEvents="none"
|
||||
zIndex={10}
|
||||
>
|
||||
<Icon icon={Search} size={4} color="text-gray-500" />
|
||||
</Stack>
|
||||
<Stack
|
||||
as="input"
|
||||
type="text"
|
||||
value={searchQuery}
|
||||
onChange={(e: React.ChangeEvent<HTMLInputElement>) => onSearchChange?.(e.target.value)}
|
||||
placeholder={placeholder}
|
||||
w="full"
|
||||
bg="bg-graphite-black/50"
|
||||
border
|
||||
borderColor="border-charcoal-outline"
|
||||
rounded="md"
|
||||
py={2}
|
||||
pl={10}
|
||||
pr={4}
|
||||
fontSize="0.875rem"
|
||||
color="text-white"
|
||||
transition
|
||||
hoverBorderColor="border-primary-blue/50"
|
||||
/>
|
||||
</Stack>
|
||||
|
||||
<Stack direction="row" align="center" gap={4}>
|
||||
<div style={{ marginBottom: '1.5rem' }}>
|
||||
<ControlBar
|
||||
leftContent={
|
||||
<div style={{ maxWidth: '32rem', width: '100%' }}>
|
||||
<Input
|
||||
type="text"
|
||||
value={searchQuery}
|
||||
onChange={(e) => onSearchChange?.(e.target.value)}
|
||||
placeholder={placeholder}
|
||||
icon={<Icon icon={Search} size={4} intent="low" />}
|
||||
fullWidth
|
||||
/>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '1rem' }}>
|
||||
{children}
|
||||
<Stack
|
||||
display="flex"
|
||||
alignItems="center"
|
||||
gap={2}
|
||||
px={3}
|
||||
py={2}
|
||||
bg="bg-graphite-black/30"
|
||||
border
|
||||
borderColor="border-charcoal-outline"
|
||||
rounded="md"
|
||||
cursor="pointer"
|
||||
transition
|
||||
hoverBg="bg-graphite-black/50"
|
||||
hoverBorderColor="border-gray-600"
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
icon={<Icon icon={Filter} size={3.5} intent="low" />}
|
||||
>
|
||||
<Icon icon={Filter} size={3.5} color="text-gray-400" />
|
||||
<Text size="xs" weight="bold" color="text-gray-400" uppercase letterSpacing="wider">Filters</Text>
|
||||
</Stack>
|
||||
</Stack>
|
||||
</Stack>
|
||||
</Stack>
|
||||
Filters
|
||||
</Button>
|
||||
</div>
|
||||
</ControlBar>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,15 +1,20 @@
|
||||
|
||||
|
||||
import { Box } from '@/ui/primitives/Box';
|
||||
import { Badge } from '@/ui/Badge';
|
||||
import { Text } from '@/ui/Text';
|
||||
import React from 'react';
|
||||
|
||||
interface RankBadgeProps {
|
||||
rank: number;
|
||||
size?: 'sm' | 'md' | 'lg';
|
||||
showLabel?: boolean;
|
||||
size?: 'sm' | 'md';
|
||||
}
|
||||
|
||||
export function RankBadge({ rank, size = 'md', showLabel = true }: RankBadgeProps) {
|
||||
export function RankBadge({ rank, size = 'md' }: RankBadgeProps) {
|
||||
const getVariant = (rank: number): 'warning' | 'primary' | 'info' | 'default' => {
|
||||
if (rank <= 3) return 'warning';
|
||||
if (rank <= 10) return 'primary';
|
||||
if (rank <= 50) return 'info';
|
||||
return 'default';
|
||||
};
|
||||
|
||||
const getMedalEmoji = (rank: number) => {
|
||||
switch (rank) {
|
||||
case 1: return '🥇';
|
||||
@@ -21,32 +26,12 @@ export function RankBadge({ rank, size = 'md', showLabel = true }: RankBadgeProp
|
||||
|
||||
const medal = getMedalEmoji(rank);
|
||||
|
||||
const sizeClasses = {
|
||||
sm: 'text-sm px-2 py-1',
|
||||
md: 'text-base px-3 py-1.5',
|
||||
lg: 'text-lg px-4 py-2'
|
||||
};
|
||||
|
||||
const getRankColor = (rank: number) => {
|
||||
if (rank <= 3) return 'bg-warning-amber/20 text-warning-amber border-warning-amber/30';
|
||||
if (rank <= 10) return 'bg-primary-blue/20 text-primary-blue border-primary-blue/30';
|
||||
if (rank <= 50) return 'bg-purple-500/20 text-purple-400 border-purple-500/30';
|
||||
return 'bg-charcoal-outline/20 text-gray-300 border-charcoal-outline';
|
||||
};
|
||||
|
||||
return (
|
||||
<Box
|
||||
as="span"
|
||||
display="inline-flex"
|
||||
alignItems="center"
|
||||
gap={1.5}
|
||||
rounded="md"
|
||||
border
|
||||
className={`font-medium ${getRankColor(rank)} ${sizeClasses[size]}`}
|
||||
>
|
||||
{medal && <Text>{medal}</Text>}
|
||||
{showLabel && <Text>#{rank}</Text>}
|
||||
{!showLabel && !medal && <Text>#{rank}</Text>}
|
||||
</Box>
|
||||
<Badge variant={getVariant(rank)} size={size}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '0.25rem' }}>
|
||||
{medal && <span>{medal}</span>}
|
||||
<Text size="xs" weight="bold">#{rank}</Text>
|
||||
</div>
|
||||
</Badge>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import { MedalDisplay } from '@/lib/display-objects/MedalDisplay';
|
||||
import { Icon } from '@/ui/Icon';
|
||||
import { Box } from '@/ui/primitives/Box';
|
||||
import { Text } from '@/ui/Text';
|
||||
import { Crown, Medal } from 'lucide-react';
|
||||
import React from 'react';
|
||||
|
||||
interface RankMedalProps {
|
||||
rank: number;
|
||||
@@ -12,11 +12,12 @@ interface RankMedalProps {
|
||||
|
||||
export function RankMedal({ rank, size = 'md', showIcon = true }: RankMedalProps) {
|
||||
const isTop3 = rank <= 3;
|
||||
const variant = MedalDisplay.getVariant(rank);
|
||||
|
||||
const sizeMap = {
|
||||
sm: '7',
|
||||
md: '8',
|
||||
lg: '10',
|
||||
const sizePx = {
|
||||
sm: '1.75rem',
|
||||
md: '2rem',
|
||||
lg: '2.5rem',
|
||||
};
|
||||
|
||||
const textSizeMap = {
|
||||
@@ -32,22 +33,23 @@ export function RankMedal({ rank, size = 'md', showIcon = true }: RankMedalProps
|
||||
};
|
||||
|
||||
return (
|
||||
<Box
|
||||
display="flex"
|
||||
alignItems="center"
|
||||
justifyContent="center"
|
||||
rounded="full"
|
||||
border
|
||||
h={sizeMap[size]}
|
||||
w={sizeMap[size]}
|
||||
bg={MedalDisplay.getBg(rank)}
|
||||
color={MedalDisplay.getColor(rank)}
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
borderRadius: '9999px',
|
||||
border: '1px solid var(--ui-color-border-default)',
|
||||
height: sizePx[size],
|
||||
width: sizePx[size],
|
||||
backgroundColor: 'var(--ui-color-bg-surface-muted)'
|
||||
}}
|
||||
>
|
||||
{isTop3 && showIcon ? (
|
||||
<Icon icon={rank === 1 ? Crown : Medal} size={iconSize[size]} />
|
||||
<Icon icon={rank === 1 ? Crown : Medal} size={iconSize[size]} intent={variant as any} />
|
||||
) : (
|
||||
<Text weight="bold" size={textSizeMap[size]}>{rank}</Text>
|
||||
<Text weight="bold" size={textSizeMap[size]} variant={variant as any}>{rank}</Text>
|
||||
)}
|
||||
</Box>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import { getMediaUrl } from '@/lib/utilities/media';
|
||||
import { Image } from '@/ui/Image';
|
||||
import { Box } from '@/ui/primitives/Box';
|
||||
import { TableCell, TableRow } from '@/ui/Table';
|
||||
import { Text } from '@/ui/Text';
|
||||
import { RankMedal } from './RankMedal';
|
||||
import React from 'react';
|
||||
|
||||
interface TeamRankingRowProps {
|
||||
id: string;
|
||||
@@ -32,70 +32,62 @@ export function TeamRankingRow({
|
||||
<TableRow
|
||||
clickable={!!onClick}
|
||||
onClick={onClick}
|
||||
group
|
||||
>
|
||||
<TableCell>
|
||||
<Box w="8" display="flex" justifyContent="center">
|
||||
<div style={{ width: '2rem', display: 'flex', justifyContent: 'center' }}>
|
||||
<RankMedal rank={rank} size="md" />
|
||||
</Box>
|
||||
</div>
|
||||
</TableCell>
|
||||
|
||||
<TableCell>
|
||||
<Box display="flex" alignItems="center" gap={3}>
|
||||
<Box
|
||||
position="relative"
|
||||
w="10"
|
||||
h="10"
|
||||
rounded="lg"
|
||||
overflow="hidden"
|
||||
border
|
||||
borderColor="border-charcoal-outline"
|
||||
bg="bg-graphite-black/50"
|
||||
groupHoverBorderColor="purple-400/50"
|
||||
transition
|
||||
>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '0.75rem' }}>
|
||||
<div style={{
|
||||
position: 'relative',
|
||||
width: '2.5rem',
|
||||
height: '2.5rem',
|
||||
borderRadius: '0.5rem',
|
||||
overflow: 'hidden',
|
||||
border: '1px solid var(--ui-color-border-default)',
|
||||
backgroundColor: 'var(--ui-color-bg-surface-muted)'
|
||||
}}>
|
||||
<Image
|
||||
src={logoUrl || getMediaUrl('team-logo', id)}
|
||||
alt={name}
|
||||
width={40}
|
||||
height={40}
|
||||
fullWidth
|
||||
fullHeight
|
||||
objectFit="cover"
|
||||
/>
|
||||
</Box>
|
||||
<Box minWidth="0">
|
||||
</div>
|
||||
<div style={{ minWidth: 0 }}>
|
||||
<Text
|
||||
weight="semibold"
|
||||
color="text-white"
|
||||
variant="high"
|
||||
block
|
||||
truncate
|
||||
groupHoverTextColor="text-purple-400"
|
||||
transition
|
||||
>
|
||||
{name}
|
||||
</Text>
|
||||
<Text size="xs" color="text-gray-500" block mt={0.5}>
|
||||
<Text size="xs" variant="low" block>
|
||||
{memberCount} Members
|
||||
</Text>
|
||||
</Box>
|
||||
</Box>
|
||||
</div>
|
||||
</div>
|
||||
</TableCell>
|
||||
|
||||
<TableCell textAlign="center">
|
||||
<Text font="mono" weight="bold" color="text-purple-400">
|
||||
<Text font="mono" weight="bold" variant="primary">
|
||||
{rating}
|
||||
</Text>
|
||||
</TableCell>
|
||||
|
||||
<TableCell textAlign="center">
|
||||
<Text font="mono" weight="bold" color="text-performance-green">
|
||||
<Text font="mono" weight="bold" variant="success">
|
||||
{wins}
|
||||
</Text>
|
||||
</TableCell>
|
||||
|
||||
<TableCell textAlign="center">
|
||||
<Text color="text-gray-400" font="mono">{races}</Text>
|
||||
<Text variant="low" font="mono">{races}</Text>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
);
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { Trophy, Sparkles, LucideIcon } from 'lucide-react';
|
||||
import { Card } from '@/ui/Card';
|
||||
import { EmptyState as UiEmptyState } from '@/components/shared/state/EmptyState';
|
||||
import { EmptyState as UiEmptyState } from '@/ui/EmptyState';
|
||||
import React from 'react';
|
||||
|
||||
interface EmptyStateProps {
|
||||
title: string;
|
||||
@@ -32,8 +33,10 @@ export function EmptyState({
|
||||
onClick: onAction,
|
||||
icon: actionIcon,
|
||||
} : undefined}
|
||||
/>
|
||||
{children}
|
||||
variant="minimal"
|
||||
>
|
||||
{children}
|
||||
</UiEmptyState>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,9 +1,13 @@
|
||||
import { Button } from '@/ui/Button';
|
||||
import { Heading } from '@/ui/Heading';
|
||||
'use client';
|
||||
|
||||
import { IconButton } from '@/ui/IconButton';
|
||||
import { Panel } from '@/ui/Panel';
|
||||
import { Icon } from '@/ui/Icon';
|
||||
import { Stack } from '@/ui/primitives/Stack';
|
||||
import { Text } from '@/ui/Text';
|
||||
import { Check, Clock, X } from 'lucide-react';
|
||||
import { ListItem, ListItemInfo, ListItemActions } from '@/ui/ListItem';
|
||||
import { EmptyState } from '@/ui/EmptyState';
|
||||
import { Check, Clock, X, UserPlus } from 'lucide-react';
|
||||
import React from 'react';
|
||||
|
||||
interface JoinRequestsPanelProps {
|
||||
requests: Array<{
|
||||
@@ -20,67 +24,49 @@ interface JoinRequestsPanelProps {
|
||||
export function JoinRequestsPanel({ requests, onAccept, onDecline }: JoinRequestsPanelProps) {
|
||||
if (requests.length === 0) {
|
||||
return (
|
||||
<Stack p={8} border borderDash borderColor="border-steel-grey" bg="surface-charcoal/20" textAlign="center">
|
||||
<Text color="text-gray-500" size="sm">No pending join requests</Text>
|
||||
</Stack>
|
||||
<EmptyState
|
||||
icon={UserPlus}
|
||||
title="No pending join requests"
|
||||
variant="minimal"
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Stack border borderColor="border-steel-grey" bg="surface-charcoal/50" overflow="hidden">
|
||||
<Stack p={4} borderBottom borderColor="border-steel-grey" bg="base-graphite/50">
|
||||
<Heading level={4} weight="bold" className="uppercase tracking-widest text-gray-400 text-[10px]">
|
||||
Pending Requests ({requests.length})
|
||||
</Heading>
|
||||
</Stack>
|
||||
<Stack gap={0} className="divide-y divide-border-steel-grey/30">
|
||||
<Panel title={`Pending Requests (${requests.length})`}>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '0.75rem' }}>
|
||||
{requests.map((request) => (
|
||||
<Stack key={request.id} p={4} className="hover:bg-white/[0.02] transition-colors">
|
||||
<Stack direction="row" align="start" justify="between" gap={4}>
|
||||
<Stack direction="row" align="center" gap={3}>
|
||||
<Stack w="10" h="10" bg="base-graphite" border borderColor="border-steel-grey" display="flex" center>
|
||||
<Text size="xs" weight="bold" color="text-primary-blue">
|
||||
{request.driverName.substring(0, 2).toUpperCase()}
|
||||
</Text>
|
||||
</Stack>
|
||||
<Stack>
|
||||
<Text weight="bold" size="sm" color="text-white" block>{request.driverName}</Text>
|
||||
<Stack direction="row" align="center" gap={1.5} mt={0.5}>
|
||||
<Icon icon={Clock} size={3} color="text-gray-500" />
|
||||
<Text size="xs" color="text-gray-500" font="mono">{request.requestedAt}</Text>
|
||||
</Stack>
|
||||
</Stack>
|
||||
</Stack>
|
||||
|
||||
<Stack direction="row" align="center" gap={2}>
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
onClick={() => onDecline(request.id)}
|
||||
className="h-8 w-8 p-0 flex items-center justify-center border-red-500/30 hover:bg-red-500/10"
|
||||
>
|
||||
<Icon icon={X} size={4} color="text-red-400" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="primary"
|
||||
size="sm"
|
||||
onClick={() => onAccept(request.id)}
|
||||
className="h-8 w-8 p-0 flex items-center justify-center"
|
||||
>
|
||||
<Icon icon={Check} size={4} />
|
||||
</Button>
|
||||
</Stack>
|
||||
</Stack>
|
||||
{request.message && (
|
||||
<Stack mt={3} p={3} bg="base-graphite/30" borderLeft borderPrimary borderColor="primary-blue/40">
|
||||
<Text size="xs" color="text-gray-400" italic leading="relaxed">
|
||||
“{request.message}”
|
||||
</Text>
|
||||
</Stack>
|
||||
)}
|
||||
</Stack>
|
||||
<ListItem key={request.id}>
|
||||
<ListItemInfo
|
||||
title={request.driverName}
|
||||
description={request.message}
|
||||
meta={
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '0.375rem' }}>
|
||||
<Icon icon={Clock} size={3} intent="low" />
|
||||
<Text size="xs" variant="low" font="mono">{request.requestedAt}</Text>
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
<ListItemActions>
|
||||
<IconButton
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
onClick={() => onDecline(request.id)}
|
||||
icon={X}
|
||||
intent="critical"
|
||||
title="Decline"
|
||||
/>
|
||||
<IconButton
|
||||
variant="primary"
|
||||
size="sm"
|
||||
onClick={() => onAccept(request.id)}
|
||||
icon={Check}
|
||||
title="Accept"
|
||||
/>
|
||||
</ListItemActions>
|
||||
</ListItem>
|
||||
))}
|
||||
</Stack>
|
||||
</Stack>
|
||||
</div>
|
||||
</Panel>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
|
||||
'use client';
|
||||
|
||||
import { Heading } from '@/ui/Heading';
|
||||
import { Icon } from '@/ui/Icon';
|
||||
import { Image } from '@/ui/Image';
|
||||
import { PlaceholderImage } from '@/ui/PlaceholderImage';
|
||||
import { Stack } from '@/ui/primitives/Stack';
|
||||
import { Text } from '@/ui/Text';
|
||||
import { Calendar as LucideCalendar, ChevronRight as LucideChevronRight } from 'lucide-react';
|
||||
import { ReactNode } from 'react';
|
||||
import { LeagueCard as UILeagueCard, LeagueCardStats, LeagueCardFooter } from '@/ui/LeagueCard';
|
||||
import { Calendar as LucideCalendar } from 'lucide-react';
|
||||
import React, { ReactNode } from 'react';
|
||||
|
||||
interface LeagueCardProps {
|
||||
name: string;
|
||||
@@ -42,151 +42,68 @@ export function LeagueCard({
|
||||
fillPercentage,
|
||||
hasOpenSlots,
|
||||
openSlotsCount,
|
||||
isTeamLeague: _isTeamLeague,
|
||||
usedDriverSlots: _usedDriverSlots,
|
||||
maxDrivers: _maxDrivers,
|
||||
timingSummary,
|
||||
onClick,
|
||||
}: LeagueCardProps) {
|
||||
return (
|
||||
<Stack
|
||||
position="relative"
|
||||
cursor={onClick ? 'pointer' : 'default'}
|
||||
h="full"
|
||||
<UILeagueCard
|
||||
onClick={onClick}
|
||||
className="group"
|
||||
coverUrl={coverUrl}
|
||||
logo={
|
||||
<div style={{ width: '3rem', height: '3rem', borderRadius: '0.5rem', overflow: 'hidden', border: '1px solid var(--ui-color-border-default)', backgroundColor: 'var(--ui-color-bg-base)' }}>
|
||||
{logoUrl ? (
|
||||
<Image
|
||||
src={logoUrl}
|
||||
alt={`${name} logo`}
|
||||
width={48}
|
||||
height={48}
|
||||
objectFit="cover"
|
||||
/>
|
||||
) : (
|
||||
<PlaceholderImage size={48} />
|
||||
)}
|
||||
</div>
|
||||
}
|
||||
badges={
|
||||
<React.Fragment>
|
||||
{badges}
|
||||
{championshipBadge}
|
||||
</React.Fragment>
|
||||
}
|
||||
>
|
||||
{/* Card Container */}
|
||||
<Stack
|
||||
position="relative"
|
||||
h="full"
|
||||
rounded="none"
|
||||
bg="panel-gray/40"
|
||||
border
|
||||
borderColor="border-gray/50"
|
||||
overflow="hidden"
|
||||
transition
|
||||
className="hover:border-primary-accent/30 hover:bg-panel-gray/60 transition-all duration-300"
|
||||
>
|
||||
{/* Cover Image */}
|
||||
<Stack position="relative" h="32" overflow="hidden">
|
||||
<Image
|
||||
src={coverUrl}
|
||||
alt={`${name} cover`}
|
||||
fullWidth
|
||||
fullHeight
|
||||
objectFit="cover"
|
||||
className="transition-transform duration-500 group-hover:scale-105 opacity-60"
|
||||
/>
|
||||
{/* Gradient Overlay */}
|
||||
<Stack position="absolute" inset="0" bg="linear-gradient(to top, #0C0D0F, transparent)" />
|
||||
|
||||
{/* Badges - Top Left */}
|
||||
<Stack position="absolute" top="3" left="3" display="flex" alignItems="center" gap={2}>
|
||||
{badges}
|
||||
</Stack>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '0.5rem', marginBottom: '0.25rem' }}>
|
||||
<div style={{ width: '0.25rem', height: '1rem', backgroundColor: 'var(--ui-color-intent-primary)' }} />
|
||||
<Heading level={3} weight="bold" truncate>{name}</Heading>
|
||||
</div>
|
||||
|
||||
<Text size="xs" variant="low" lineClamp={2} style={{ height: '2.5rem', marginBottom: '1rem' }} block leading="relaxed">
|
||||
{description || 'No description available'}
|
||||
</Text>
|
||||
|
||||
{/* Championship Type Badge - Top Right */}
|
||||
<Stack position="absolute" top="3" right="3">
|
||||
{championshipBadge}
|
||||
</Stack>
|
||||
<LeagueCardStats
|
||||
label={slotLabel}
|
||||
value={`${usedSlots}/${maxSlots || '∞'}`}
|
||||
percentage={fillPercentage}
|
||||
intent={fillPercentage >= 90 ? 'warning' : fillPercentage >= 70 ? 'primary' : 'success'}
|
||||
/>
|
||||
|
||||
{/* Logo */}
|
||||
<Stack position="absolute" left="4" bottom="-6" zIndex={10}>
|
||||
<Stack w="12" h="12" rounded="none" overflow="hidden" border borderColor="border-gray/50" bg="graphite-black" shadow="xl">
|
||||
{logoUrl ? (
|
||||
<Image
|
||||
src={logoUrl}
|
||||
alt={`${name} logo`}
|
||||
width={48}
|
||||
height={48}
|
||||
fullWidth
|
||||
fullHeight
|
||||
objectFit="cover"
|
||||
/>
|
||||
) : (
|
||||
<PlaceholderImage size={48} />
|
||||
)}
|
||||
</Stack>
|
||||
</Stack>
|
||||
</Stack>
|
||||
{hasOpenSlots && (
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '0.375rem', padding: '0.25rem 0.5rem', backgroundColor: 'rgba(25, 140, 255, 0.05)', border: '1px solid rgba(25, 140, 255, 0.2)', borderRadius: 'var(--ui-radius-sm)', width: 'fit-content', marginBottom: '1rem' }}>
|
||||
<div style={{ width: '0.375rem', height: '0.375rem', borderRadius: '9999px', backgroundColor: 'var(--ui-color-intent-primary)' }} />
|
||||
<Text size="xs" variant="primary" weight="bold" uppercase>{openSlotsCount} OPEN</Text>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Content */}
|
||||
<Stack pt={8} px={4} pb={4} display="flex" flexDirection="col" fullHeight>
|
||||
{/* Title & Description */}
|
||||
<Stack direction="row" align="center" gap={2} mb={1}>
|
||||
<Stack w="1" h="4" bg="primary-accent" />
|
||||
<Heading level={3} fontSize="lg" weight="bold" className="line-clamp-1 group-hover:text-primary-accent transition-colors tracking-tight">
|
||||
{name}
|
||||
</Heading>
|
||||
</Stack>
|
||||
<Text size="xs" color="text-gray-500" lineClamp={2} mb={4} style={{ height: '2.5rem' }} block leading="relaxed">
|
||||
{description || 'No description available'}
|
||||
</Text>
|
||||
|
||||
{/* Stats Row */}
|
||||
<Stack display="flex" alignItems="center" gap={3} mb={4}>
|
||||
{/* Primary Slots (Drivers/Teams/Nations) */}
|
||||
<Stack flexGrow={1}>
|
||||
<Stack display="flex" alignItems="center" justifyContent="between" mb={1.5}>
|
||||
<Text size="xs" color="text-gray-500" weight="bold" className="uppercase tracking-widest">{slotLabel}</Text>
|
||||
<Text size="xs" color="text-gray-400" font="mono">
|
||||
{usedSlots}/{maxSlots || '∞'}
|
||||
</Text>
|
||||
</Stack>
|
||||
<Stack h="1" rounded="none" bg="border-gray/30" overflow="hidden">
|
||||
<Stack
|
||||
h="full"
|
||||
rounded="none"
|
||||
transition
|
||||
bg={
|
||||
fillPercentage >= 90
|
||||
? 'warning-amber'
|
||||
: fillPercentage >= 70
|
||||
? 'primary-accent'
|
||||
: 'success-green'
|
||||
}
|
||||
style={{ width: `${Math.min(fillPercentage, 100)}%` }}
|
||||
/>
|
||||
</Stack>
|
||||
</Stack>
|
||||
|
||||
{/* Open Slots Badge */}
|
||||
{hasOpenSlots && (
|
||||
<Stack display="flex" alignItems="center" gap={1.5} px={2} py={1} rounded="none" bg="primary-accent/5" border borderColor="primary-accent/20">
|
||||
<Stack w="1.5" h="1.5" rounded="full" bg="primary-accent" className="animate-pulse" />
|
||||
<Text size="xs" color="text-primary-accent" weight="bold" className="uppercase tracking-tighter">
|
||||
{openSlotsCount} OPEN
|
||||
</Text>
|
||||
</Stack>
|
||||
)}
|
||||
</Stack>
|
||||
|
||||
{/* Spacer to push footer to bottom */}
|
||||
<Stack flexGrow={1} />
|
||||
|
||||
{/* Footer Info */}
|
||||
<Stack display="flex" alignItems="center" justifyContent="between" pt={3} borderTop borderColor="border-gray/30" mt="auto">
|
||||
<Stack display="flex" alignItems="center" gap={3}>
|
||||
{timingSummary && (
|
||||
<Stack display="flex" alignItems="center" gap={2}>
|
||||
<Icon icon={LucideCalendar} size={3} color="text-gray-500" />
|
||||
<Text size="xs" color="text-gray-500" font="mono">
|
||||
{timingSummary.split('•')[1]?.trim() || timingSummary}
|
||||
</Text>
|
||||
</Stack>
|
||||
)}
|
||||
</Stack>
|
||||
|
||||
{/* View Arrow */}
|
||||
<Stack display="flex" alignItems="center" gap={1} className="group-hover:text-primary-accent transition-colors">
|
||||
<Text size="xs" color="text-gray-500" weight="bold" className="uppercase tracking-widest">VIEW</Text>
|
||||
<Icon icon={LucideChevronRight} size={3} color="text-gray-500" className="transition-transform group-hover:translate-x-0.5" />
|
||||
</Stack>
|
||||
</Stack>
|
||||
</Stack>
|
||||
</Stack>
|
||||
</Stack>
|
||||
<LeagueCardFooter>
|
||||
{timingSummary && (
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '0.5rem' }}>
|
||||
<Icon icon={LucideCalendar} size={3} intent="low" />
|
||||
<Text size="xs" variant="low" font="mono">
|
||||
{timingSummary.split('•')[1]?.trim() || timingSummary}
|
||||
</Text>
|
||||
</div>
|
||||
)}
|
||||
</LeagueCardFooter>
|
||||
</UILeagueCard>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -3,237 +3,13 @@
|
||||
import type { LeagueConfigFormModel } from '@/lib/types/LeagueConfigFormModel';
|
||||
import { Heading } from '@/ui/Heading';
|
||||
import { Icon } from '@/ui/Icon';
|
||||
import { Stack } from '@/ui/primitives/Stack';
|
||||
import { Text } from '@/ui/Text';
|
||||
import { Check, HelpCircle, TrendingDown, X, Zap } from 'lucide-react';
|
||||
import React, { useEffect, useRef, useState } from 'react';
|
||||
import { createPortal } from 'react-dom';
|
||||
|
||||
// ============================================================================
|
||||
// INFO FLYOUT (duplicated for self-contained component)
|
||||
// ============================================================================
|
||||
|
||||
interface InfoFlyoutProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
title: string;
|
||||
children: React.ReactNode;
|
||||
anchorRef: React.RefObject<HTMLElement>;
|
||||
}
|
||||
|
||||
function InfoFlyout({ isOpen, onClose, title, children, anchorRef }: InfoFlyoutProps) {
|
||||
const [position, setPosition] = useState({ top: 0, left: 0 });
|
||||
const [mounted, setMounted] = useState(false);
|
||||
const flyoutRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
setMounted(true);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (isOpen && anchorRef.current && mounted) {
|
||||
const rect = anchorRef.current.getBoundingClientRect();
|
||||
const flyoutWidth = Math.min(380, window.innerWidth - 40);
|
||||
const flyoutHeight = 450;
|
||||
const padding = 16;
|
||||
|
||||
let left = rect.right + 12;
|
||||
let top = rect.top;
|
||||
|
||||
if (left + flyoutWidth > window.innerWidth - padding) {
|
||||
left = rect.left - flyoutWidth - 12;
|
||||
}
|
||||
if (left < padding) {
|
||||
left = Math.max(padding, (window.innerWidth - flyoutWidth) / 2);
|
||||
}
|
||||
|
||||
top = rect.top - flyoutHeight / 3;
|
||||
if (top + flyoutHeight > window.innerHeight - padding) {
|
||||
top = window.innerHeight - flyoutHeight - padding;
|
||||
}
|
||||
if (top < padding) top = padding;
|
||||
|
||||
left = Math.max(padding, Math.min(left, window.innerWidth - flyoutWidth - padding));
|
||||
|
||||
setPosition({ top, left });
|
||||
}
|
||||
}, [isOpen, anchorRef, mounted]);
|
||||
|
||||
useEffect(() => {
|
||||
const handleEscape = (e: KeyboardEvent) => {
|
||||
if (e.key === 'Escape') onClose();
|
||||
};
|
||||
const handleClickOutside = (e: MouseEvent) => {
|
||||
if (flyoutRef.current && !flyoutRef.current.contains(e.target as Node)) {
|
||||
onClose();
|
||||
}
|
||||
};
|
||||
|
||||
if (isOpen) {
|
||||
document.addEventListener('keydown', handleEscape);
|
||||
document.addEventListener('mousedown', handleClickOutside);
|
||||
}
|
||||
return () => {
|
||||
document.removeEventListener('keydown', handleEscape);
|
||||
document.removeEventListener('mousedown', handleClickOutside);
|
||||
};
|
||||
}, [isOpen, onClose]);
|
||||
|
||||
if (!isOpen) return null;
|
||||
|
||||
return createPortal(
|
||||
<Stack
|
||||
ref={flyoutRef}
|
||||
position="fixed"
|
||||
zIndex={50}
|
||||
w="380px"
|
||||
bg="bg-iron-gray"
|
||||
border
|
||||
borderColor="border-charcoal-outline"
|
||||
rounded="xl"
|
||||
shadow="2xl"
|
||||
// eslint-disable-next-line gridpilot-rules/component-classification
|
||||
style={{ top: position.top, left: position.left, maxHeight: '80vh', overflowY: 'auto' }}
|
||||
>
|
||||
<Stack
|
||||
display="flex"
|
||||
alignItems="center"
|
||||
justifyContent="between"
|
||||
p={4}
|
||||
borderBottom
|
||||
borderColor="border-charcoal-outline/50"
|
||||
position="sticky"
|
||||
top="0"
|
||||
bg="bg-iron-gray"
|
||||
zIndex={10}
|
||||
>
|
||||
<Stack direction="row" align="center" gap={2}>
|
||||
<Icon icon={HelpCircle} size={4} color="text-primary-blue" />
|
||||
<Text size="sm" weight="semibold" color="text-white">{title}</Text>
|
||||
</Stack>
|
||||
<Stack
|
||||
as="button"
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
display="flex"
|
||||
h="6"
|
||||
w="6"
|
||||
alignItems="center"
|
||||
justifyContent="center"
|
||||
rounded="md"
|
||||
transition
|
||||
hoverBg="bg-charcoal-outline"
|
||||
>
|
||||
<Icon icon={X} size={4} color="text-gray-400" />
|
||||
</Stack>
|
||||
</Stack>
|
||||
<Stack p={4}>
|
||||
{children}
|
||||
</Stack>
|
||||
</Stack>,
|
||||
document.body
|
||||
);
|
||||
}
|
||||
|
||||
function InfoButton({ onClick, buttonRef }: { onClick: () => void; buttonRef: React.Ref<HTMLButtonElement> }) {
|
||||
return (
|
||||
<Stack
|
||||
as="button"
|
||||
ref={buttonRef}
|
||||
type="button"
|
||||
onClick={onClick}
|
||||
display="flex"
|
||||
h="5"
|
||||
w="5"
|
||||
alignItems="center"
|
||||
justifyContent="center"
|
||||
rounded="full"
|
||||
transition
|
||||
color="text-gray-500"
|
||||
hoverTextColor="text-primary-blue"
|
||||
hoverBg="bg-primary-blue/10"
|
||||
>
|
||||
<Icon icon={HelpCircle} size={3.5} />
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
||||
// Drop Rules Mockup
|
||||
function DropRulesMockup() {
|
||||
const results = [
|
||||
{ round: 'R1', pts: 25, dropped: false },
|
||||
{ round: 'R2', pts: 18, dropped: false },
|
||||
{ round: 'R3', pts: 4, dropped: true },
|
||||
{ round: 'R4', pts: 15, dropped: false },
|
||||
{ round: 'R5', pts: 12, dropped: false },
|
||||
{ round: 'R6', pts: 0, dropped: true },
|
||||
];
|
||||
|
||||
const total = results.filter(r => !r.dropped).reduce((sum, r) => sum + r.pts, 0);
|
||||
const wouldBe = results.reduce((sum, r) => sum + r.pts, 0);
|
||||
|
||||
return (
|
||||
<Stack bg="bg-deep-graphite" rounded="lg" p={4}>
|
||||
<Stack display="flex" alignItems="center" gap={2} mb={3} pb={2} borderBottom borderColor="border-charcoal-outline/50" opacity={0.5}>
|
||||
<Text size="xs" weight="semibold" color="text-white">Best 4 of 6 Results</Text>
|
||||
</Stack>
|
||||
<Stack display="flex" gap={1} mb={3}>
|
||||
{results.map((r, i) => (
|
||||
<Stack
|
||||
key={i}
|
||||
flexGrow={1}
|
||||
p={2}
|
||||
rounded="lg"
|
||||
textAlign="center"
|
||||
border
|
||||
transition
|
||||
bg={r.dropped ? 'bg-charcoal-outline/20' : 'bg-performance-green/10'}
|
||||
borderColor={r.dropped ? 'border-charcoal-outline/50' : 'border-performance-green/30'}
|
||||
opacity={r.dropped ? 0.5 : 1}
|
||||
// eslint-disable-next-line gridpilot-rules/component-classification
|
||||
className={r.dropped ? 'border-dashed' : ''}
|
||||
>
|
||||
<Text
|
||||
// eslint-disable-next-line gridpilot-rules/component-classification
|
||||
style={{ fontSize: '9px' }}
|
||||
color="text-gray-500"
|
||||
block
|
||||
>
|
||||
{r.round}
|
||||
</Text>
|
||||
<Text font="mono" weight="semibold" size="xs" color={r.dropped ? 'text-gray-500' : 'text-white'}
|
||||
// eslint-disable-next-line gridpilot-rules/component-classification
|
||||
className={r.dropped ? 'line-through' : ''}
|
||||
block
|
||||
>
|
||||
{r.pts}
|
||||
</Text>
|
||||
</Stack>
|
||||
))}
|
||||
</Stack>
|
||||
<Stack display="flex" justifyContent="between" alignItems="center">
|
||||
<Text size="xs" color="text-gray-500">Total counted:</Text>
|
||||
<Text font="mono" weight="semibold" color="text-performance-green" size="xs">{total} pts</Text>
|
||||
</Stack>
|
||||
<Stack display="flex" justifyContent="between" alignItems="center" mt={1}>
|
||||
<Text
|
||||
// eslint-disable-next-line gridpilot-rules/component-classification
|
||||
style={{ fontSize: '10px' }}
|
||||
color="text-gray-500"
|
||||
>
|
||||
Without drops:
|
||||
</Text>
|
||||
<Text font="mono"
|
||||
// eslint-disable-next-line gridpilot-rules/component-classification
|
||||
style={{ fontSize: '10px' }}
|
||||
color="text-gray-500"
|
||||
>
|
||||
{wouldBe} pts
|
||||
</Text>
|
||||
</Stack>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
import { InfoFlyout } from '@/ui/InfoFlyout';
|
||||
import { Stepper } from '@/ui/Stepper';
|
||||
import { Button } from '@/ui/Button';
|
||||
import { IconButton } from '@/ui/IconButton';
|
||||
import { Check, HelpCircle, TrendingDown, Zap } from 'lucide-react';
|
||||
import React, { useRef, useState } from 'react';
|
||||
|
||||
interface LeagueDropSectionProps {
|
||||
form: LeagueConfigFormModel;
|
||||
@@ -243,43 +19,6 @@ interface LeagueDropSectionProps {
|
||||
|
||||
type DropStrategy = 'none' | 'bestNResults' | 'dropWorstN';
|
||||
|
||||
// Drop rule info content
|
||||
const DROP_RULE_INFO: Record<DropStrategy, { title: string; description: string; details: string[]; example: string }> = {
|
||||
none: {
|
||||
title: 'All Results Count',
|
||||
description: 'Every race result affects the championship standings with no exceptions.',
|
||||
details: [
|
||||
'All race results count toward final standings',
|
||||
'No safety net for bad races or DNFs',
|
||||
'Rewards consistency across entire season',
|
||||
'Best for shorter seasons (4-6 races)',
|
||||
],
|
||||
example: '8 races × your points = your total',
|
||||
},
|
||||
bestNResults: {
|
||||
title: 'Best N Results',
|
||||
description: 'Only your top N race results count toward the championship.',
|
||||
details: [
|
||||
'Choose how many of your best races count',
|
||||
'Extra races become "bonus" opportunities',
|
||||
'Protects against occasional bad days',
|
||||
'Encourages trying even when behind',
|
||||
],
|
||||
example: 'Best 6 of 8 races count',
|
||||
},
|
||||
dropWorstN: {
|
||||
title: 'Drop Worst N Results',
|
||||
description: 'Your N worst race results are excluded from championship calculations.',
|
||||
details: [
|
||||
'Automatically removes your worst performances',
|
||||
'Great for handling DNFs or incidents',
|
||||
'All other races count normally',
|
||||
'Common in real-world championships',
|
||||
],
|
||||
example: 'Drop 2 worst → 6 of 8 count',
|
||||
},
|
||||
};
|
||||
|
||||
const DROP_OPTIONS: Array<{
|
||||
value: DropStrategy;
|
||||
label: string;
|
||||
@@ -317,13 +56,7 @@ export function LeagueDropSection({
|
||||
const disabled = readOnly || !onChange;
|
||||
const dropPolicy = form.dropPolicy || { strategy: 'none' as const };
|
||||
const [showDropFlyout, setShowDropFlyout] = useState(false);
|
||||
const [activeDropRuleFlyout, setActiveDropRuleFlyout] = useState<DropStrategy | null>(null);
|
||||
const dropInfoRef = useRef<HTMLButtonElement>(null!);
|
||||
const dropRuleRefs = useRef<Record<DropStrategy, HTMLButtonElement | null>>({
|
||||
none: null,
|
||||
bestNResults: null,
|
||||
dropWorstN: null,
|
||||
});
|
||||
|
||||
const handleStrategyChange = (strategy: DropStrategy) => {
|
||||
if (disabled || !onChange) return;
|
||||
@@ -344,10 +77,8 @@ export function LeagueDropSection({
|
||||
onChange(next);
|
||||
};
|
||||
|
||||
const handleNChange = (delta: number) => {
|
||||
const handleNChange = (newValue: number) => {
|
||||
if (disabled || !onChange || dropPolicy.strategy === 'none') return;
|
||||
const current = dropPolicy.n ?? 1;
|
||||
const newValue = Math.max(1, current + delta);
|
||||
onChange({
|
||||
...form,
|
||||
dropPolicy: {
|
||||
@@ -360,328 +91,79 @@ export function LeagueDropSection({
|
||||
const needsN = dropPolicy.strategy !== 'none';
|
||||
|
||||
return (
|
||||
<Stack gap={4}>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '1rem' }}>
|
||||
{/* Section header */}
|
||||
<Stack display="flex" alignItems="center" gap={3}>
|
||||
<Stack display="flex" h="10" w="10" alignItems="center" justifyContent="center" rounded="xl" bg="bg-primary-blue/10">
|
||||
<Icon icon={TrendingDown} size={5} color="text-primary-blue" />
|
||||
</Stack>
|
||||
<Stack flexGrow={1}>
|
||||
<Stack display="flex" alignItems="center" gap={2}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '0.75rem' }}>
|
||||
<div style={{ display: 'flex', width: '2.5rem', height: '2.5rem', alignItems: 'center', justifyContent: 'center', borderRadius: '0.75rem', backgroundColor: 'rgba(25, 140, 255, 0.1)' }}>
|
||||
<Icon icon={TrendingDown} size={5} intent="primary" />
|
||||
</div>
|
||||
<div style={{ flex: 1 }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '0.5rem' }}>
|
||||
<Heading level={3}>Drop Rules</Heading>
|
||||
<InfoButton buttonRef={dropInfoRef} onClick={() => setShowDropFlyout(true)} />
|
||||
</Stack>
|
||||
<Text size="xs" color="text-gray-500">Protect from bad races</Text>
|
||||
</Stack>
|
||||
</Stack>
|
||||
<IconButton
|
||||
ref={dropInfoRef}
|
||||
icon={HelpCircle}
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
onClick={() => setShowDropFlyout(true)}
|
||||
title="Help"
|
||||
/>
|
||||
</div>
|
||||
<Text size="xs" variant="low">Protect from bad races</Text>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Drop Rules Flyout */}
|
||||
<InfoFlyout
|
||||
isOpen={showDropFlyout}
|
||||
onClose={() => setShowDropFlyout(false)}
|
||||
title="Drop Rules Explained"
|
||||
anchorRef={dropInfoRef}
|
||||
>
|
||||
<Stack gap={4}>
|
||||
<Text size="xs" color="text-gray-400" block>
|
||||
Drop rules allow drivers to exclude their worst results from championship calculations.
|
||||
This protects against mechanical failures, bad luck, or occasional poor performances.
|
||||
</Text>
|
||||
|
||||
<Stack>
|
||||
<Text size="xs" weight="bold" color="text-gray-500" transform="uppercase"
|
||||
// eslint-disable-next-line gridpilot-rules/component-classification
|
||||
className="tracking-wide"
|
||||
block
|
||||
mb={2}
|
||||
// eslint-disable-next-line gridpilot-rules/component-classification
|
||||
style={{ fontSize: '10px' }}
|
||||
>
|
||||
Visual Example
|
||||
</Text>
|
||||
<DropRulesMockup />
|
||||
</Stack>
|
||||
|
||||
<Stack gap={2}>
|
||||
<Text size="xs" weight="bold" color="text-gray-500" transform="uppercase"
|
||||
// eslint-disable-next-line gridpilot-rules/component-classification
|
||||
className="tracking-wide"
|
||||
block
|
||||
// eslint-disable-next-line gridpilot-rules/component-classification
|
||||
style={{ fontSize: '10px' }}
|
||||
>
|
||||
Drop Strategies
|
||||
</Text>
|
||||
<Stack gap={2}>
|
||||
<Stack display="flex" alignItems="start" gap={2} p={2} rounded="lg" bg="bg-deep-graphite" border borderColor="border-charcoal-outline/30">
|
||||
<Text size="base">✓</Text>
|
||||
<Stack>
|
||||
<Text size="xs" weight="medium" color="text-white" block
|
||||
// eslint-disable-next-line gridpilot-rules/component-classification
|
||||
style={{ fontSize: '10px' }}
|
||||
>
|
||||
All Count
|
||||
</Text>
|
||||
<Text size="xs" color="text-gray-500" block
|
||||
// eslint-disable-next-line gridpilot-rules/component-classification
|
||||
style={{ fontSize: '9px' }}
|
||||
>
|
||||
Every race affects standings. Best for short seasons.
|
||||
</Text>
|
||||
</Stack>
|
||||
</Stack>
|
||||
<Stack display="flex" alignItems="start" gap={2} p={2} rounded="lg" bg="bg-deep-graphite" border borderColor="border-charcoal-outline/30">
|
||||
<Text size="base">🏆</Text>
|
||||
<Stack>
|
||||
<Text size="xs" weight="medium" color="text-white" block
|
||||
// eslint-disable-next-line gridpilot-rules/component-classification
|
||||
style={{ fontSize: '10px' }}
|
||||
>
|
||||
Best N Results
|
||||
</Text>
|
||||
<Text size="xs" color="text-gray-500" block
|
||||
// eslint-disable-next-line gridpilot-rules/component-classification
|
||||
style={{ fontSize: '9px' }}
|
||||
>
|
||||
Only your top N races count. Extra races are optional.
|
||||
</Text>
|
||||
</Stack>
|
||||
</Stack>
|
||||
<Stack display="flex" alignItems="start" gap={2} p={2} rounded="lg" bg="bg-deep-graphite" border borderColor="border-charcoal-outline/30">
|
||||
<Text size="base">🗑️</Text>
|
||||
<Stack>
|
||||
<Text size="xs" weight="medium" color="text-white" block
|
||||
// eslint-disable-next-line gridpilot-rules/component-classification
|
||||
style={{ fontSize: '10px' }}
|
||||
>
|
||||
Drop Worst N
|
||||
</Text>
|
||||
<Text size="xs" color="text-gray-500" block
|
||||
// eslint-disable-next-line gridpilot-rules/component-classification
|
||||
style={{ fontSize: '9px' }}
|
||||
>
|
||||
Exclude your N worst results. Forgives bad days.
|
||||
</Text>
|
||||
</Stack>
|
||||
</Stack>
|
||||
</Stack>
|
||||
</Stack>
|
||||
|
||||
<Stack rounded="lg" bg="bg-primary-blue/5" border borderColor="border-primary-blue/20" p={3}>
|
||||
<Stack display="flex" alignItems="start" gap={2}>
|
||||
<Icon icon={Zap} size={3.5} color="text-primary-blue" flexShrink={0} mt={0.5} />
|
||||
<Stack>
|
||||
<Text size="xs" color="text-gray-400"
|
||||
// eslint-disable-next-line gridpilot-rules/component-classification
|
||||
style={{ fontSize: '11px' }}
|
||||
>
|
||||
<Text weight="medium" color="text-primary-blue">Pro tip:</Text> For an 8-round season,
|
||||
"Best 6" or "Drop 2" are popular choices.
|
||||
</Text>
|
||||
</Stack>
|
||||
</Stack>
|
||||
</Stack>
|
||||
</Stack>
|
||||
<Text size="xs" variant="low" block>
|
||||
Drop rules allow drivers to exclude their worst results from championship calculations.
|
||||
This protects against mechanical failures, bad luck, or occasional poor performances.
|
||||
</Text>
|
||||
</InfoFlyout>
|
||||
|
||||
{/* Strategy buttons + N stepper inline */}
|
||||
<Stack display="flex" flexWrap="wrap" alignItems="center" gap={2}>
|
||||
<div style={{ display: 'flex', flexWrap: 'wrap', alignItems: 'center', gap: '0.5rem' }}>
|
||||
{DROP_OPTIONS.map((option) => {
|
||||
const isSelected = dropPolicy.strategy === option.value;
|
||||
const ruleInfo = DROP_RULE_INFO[option.value];
|
||||
return (
|
||||
<Stack key={option.value} display="flex" alignItems="center" position="relative">
|
||||
<Stack
|
||||
as="button"
|
||||
type="button"
|
||||
disabled={disabled}
|
||||
onClick={() => handleStrategyChange(option.value)}
|
||||
display="flex"
|
||||
alignItems="center"
|
||||
gap={2}
|
||||
px={3}
|
||||
py={2}
|
||||
rounded="lg"
|
||||
border
|
||||
borderWidth="2px"
|
||||
transition
|
||||
borderColor={isSelected ? 'border-primary-blue' : 'border-charcoal-outline/40'}
|
||||
bg={isSelected ? 'bg-primary-blue/10' : 'bg-iron-gray/20'}
|
||||
hoverBorderColor={!isSelected && !disabled ? 'border-charcoal-outline' : undefined}
|
||||
hoverBg={!isSelected && !disabled ? 'bg-iron-gray/30' : undefined}
|
||||
cursor={disabled ? 'default' : 'pointer'}
|
||||
opacity={disabled ? 0.6 : 1}
|
||||
// eslint-disable-next-line gridpilot-rules/component-classification
|
||||
style={{ borderRightWidth: 0, borderTopRightRadius: 0, borderBottomRightRadius: 0 }}
|
||||
>
|
||||
{/* Radio indicator */}
|
||||
<Stack
|
||||
display="flex"
|
||||
h="4"
|
||||
w="4"
|
||||
alignItems="center"
|
||||
justifyContent="center"
|
||||
rounded="full"
|
||||
border
|
||||
borderColor={isSelected ? 'border-primary-blue' : 'border-gray-500'}
|
||||
bg={isSelected ? 'bg-primary-blue' : ''}
|
||||
flexShrink={0}
|
||||
transition
|
||||
>
|
||||
{isSelected && <Icon icon={Check} size={2.5} color="text-white" />}
|
||||
</Stack>
|
||||
|
||||
<Text size="sm">{option.emoji}</Text>
|
||||
<Text size="sm" weight="medium" color={isSelected ? 'text-white' : 'text-gray-400'}>
|
||||
{option.label}
|
||||
</Text>
|
||||
</Stack>
|
||||
|
||||
{/* Info button - separate from main button */}
|
||||
<Stack
|
||||
as="button"
|
||||
ref={(el: HTMLButtonElement | null) => { dropRuleRefs.current[option.value] = el; }}
|
||||
type="button"
|
||||
onClick={(e: React.MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
setActiveDropRuleFlyout(activeDropRuleFlyout === option.value ? null : option.value);
|
||||
}}
|
||||
display="flex"
|
||||
alignItems="center"
|
||||
justifyContent="center"
|
||||
px={2}
|
||||
py={2}
|
||||
rounded="lg"
|
||||
border
|
||||
borderWidth="2px"
|
||||
transition
|
||||
borderColor={isSelected ? 'border-primary-blue' : 'border-charcoal-outline/40'}
|
||||
bg={isSelected ? 'bg-primary-blue/10' : 'bg-iron-gray/20'}
|
||||
hoverBorderColor={!isSelected && !disabled ? 'border-charcoal-outline' : undefined}
|
||||
hoverBg={!isSelected && !disabled ? 'bg-iron-gray/30' : undefined}
|
||||
cursor={disabled ? 'default' : 'pointer'}
|
||||
opacity={disabled ? 0.6 : 1}
|
||||
// eslint-disable-next-line gridpilot-rules/component-classification
|
||||
style={{ borderLeftWidth: 0, borderTopLeftRadius: 0, borderBottomLeftRadius: 0, height: '100%' }}
|
||||
>
|
||||
<Icon icon={HelpCircle} size={3.5} color="text-gray-500"
|
||||
// eslint-disable-next-line gridpilot-rules/component-classification
|
||||
className="hover:text-primary-blue transition-colors"
|
||||
/>
|
||||
</Stack>
|
||||
|
||||
{/* Drop Rule Info Flyout */}
|
||||
<InfoFlyout
|
||||
isOpen={activeDropRuleFlyout === option.value}
|
||||
onClose={() => setActiveDropRuleFlyout(null)}
|
||||
title={ruleInfo.title}
|
||||
anchorRef={{ current: (dropRuleRefs.current[option.value] as HTMLElement | null) ?? dropInfoRef.current }}
|
||||
>
|
||||
<Stack gap={4}>
|
||||
<Text size="xs" color="text-gray-400" block>{ruleInfo.description}</Text>
|
||||
|
||||
<Stack gap={2}>
|
||||
<Text size="xs" weight="bold" color="text-gray-500" transform="uppercase"
|
||||
// eslint-disable-next-line gridpilot-rules/component-classification
|
||||
className="tracking-wide"
|
||||
block
|
||||
mb={2}
|
||||
// eslint-disable-next-line gridpilot-rules/component-classification
|
||||
style={{ fontSize: '10px' }}
|
||||
>
|
||||
How It Works
|
||||
</Text>
|
||||
<Stack gap={1.5}>
|
||||
{ruleInfo.details.map((detail, idx) => (
|
||||
<Stack key={idx} display="flex" alignItems="start" gap={2}>
|
||||
<Icon icon={Check} size={3} color="text-performance-green" flexShrink={0} mt={0.5} />
|
||||
<Text size="xs" color="text-gray-400">{detail}</Text>
|
||||
</Stack>
|
||||
))}
|
||||
</Stack>
|
||||
</Stack>
|
||||
|
||||
<Stack rounded="lg" bg="bg-deep-graphite" border borderColor="border-charcoal-outline/30" p={3}>
|
||||
<Stack display="flex" alignItems="center" gap={2}>
|
||||
<Text size="base">{option.emoji}</Text>
|
||||
<Stack>
|
||||
<Text size="xs" color="text-gray-400" block
|
||||
// eslint-disable-next-line gridpilot-rules/component-classification
|
||||
style={{ fontSize: '10px' }}
|
||||
>
|
||||
Example
|
||||
</Text>
|
||||
<Text size="xs" weight="medium" color="text-white" block>{ruleInfo.example}</Text>
|
||||
</Stack>
|
||||
</Stack>
|
||||
</Stack>
|
||||
</Stack>
|
||||
</InfoFlyout>
|
||||
</Stack>
|
||||
<Button
|
||||
key={option.value}
|
||||
variant={isSelected ? 'primary' : 'secondary'}
|
||||
size="sm"
|
||||
onClick={() => handleStrategyChange(option.value)}
|
||||
disabled={disabled}
|
||||
>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '0.5rem' }}>
|
||||
{isSelected && <Icon icon={Check} size={3} />}
|
||||
<span>{option.emoji}</span>
|
||||
<span>{option.label}</span>
|
||||
</div>
|
||||
</Button>
|
||||
);
|
||||
})}
|
||||
|
||||
{/* N Stepper - only show when needed */}
|
||||
{needsN && (
|
||||
<Stack display="flex" alignItems="center" gap={1} ml={2}>
|
||||
<Text size="xs" color="text-gray-500" mr={1}>N =</Text>
|
||||
<Stack
|
||||
as="button"
|
||||
type="button"
|
||||
disabled={disabled || (dropPolicy.n ?? 1) <= 1}
|
||||
onClick={() => handleNChange(-1)}
|
||||
display="flex"
|
||||
h="7"
|
||||
w="7"
|
||||
alignItems="center"
|
||||
justifyContent="center"
|
||||
rounded="md"
|
||||
bg="bg-iron-gray"
|
||||
border
|
||||
borderColor="border-charcoal-outline"
|
||||
color="text-gray-400"
|
||||
transition
|
||||
hoverTextColor={!disabled ? 'text-white' : undefined}
|
||||
hoverBorderColor={!disabled ? 'border-primary-blue' : undefined}
|
||||
opacity={disabled || (dropPolicy.n ?? 1) <= 1 ? 0.4 : 1}
|
||||
>
|
||||
−
|
||||
</Stack>
|
||||
<Stack display="flex" h="7" w="10" alignItems="center" justifyContent="center" rounded="md" bg="bg-iron-gray/50" border borderColor="border-charcoal-outline/50">
|
||||
<Text size="sm" weight="semibold" color="text-white">{dropPolicy.n ?? 1}</Text>
|
||||
</Stack>
|
||||
<Stack
|
||||
as="button"
|
||||
type="button"
|
||||
<div style={{ marginLeft: '0.5rem' }}>
|
||||
<Stepper
|
||||
value={dropPolicy.n ?? 1}
|
||||
onChange={handleNChange}
|
||||
label="N ="
|
||||
disabled={disabled}
|
||||
onClick={() => handleNChange(1)}
|
||||
display="flex"
|
||||
h="7"
|
||||
w="7"
|
||||
alignItems="center"
|
||||
justifyContent="center"
|
||||
rounded="md"
|
||||
bg="bg-iron-gray"
|
||||
border
|
||||
borderColor="border-charcoal-outline"
|
||||
color="text-gray-400"
|
||||
transition
|
||||
hoverTextColor={!disabled ? 'text-white' : undefined}
|
||||
hoverBorderColor={!disabled ? 'border-primary-blue' : undefined}
|
||||
opacity={disabled ? 0.4 : 1}
|
||||
>
|
||||
+
|
||||
</Stack>
|
||||
</Stack>
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</Stack>
|
||||
</div>
|
||||
|
||||
{/* Explanation text */}
|
||||
<Text size="xs" color="text-gray-500" block>
|
||||
<Text size="xs" variant="low" block>
|
||||
{dropPolicy.strategy === 'none' && 'Every race result affects the championship standings.'}
|
||||
{dropPolicy.strategy === 'bestNResults' && `Only your best ${dropPolicy.n ?? 1} results will count.`}
|
||||
{dropPolicy.strategy === 'dropWorstN' && `Your worst ${dropPolicy.n ?? 1} results will be excluded.`}
|
||||
</Text>
|
||||
</Stack>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
'use client';
|
||||
|
||||
import { Button } from '@/ui/Button';
|
||||
import { Card } from '@/ui/Card';
|
||||
import { Link } from '@/ui/Link';
|
||||
import { Text } from '@/ui/Text';
|
||||
import { Stack } from '@/ui/primitives/Stack';
|
||||
import { ListItem, ListItemInfo, ListItemActions } from '@/ui/ListItem';
|
||||
import React from 'react';
|
||||
|
||||
interface League {
|
||||
leagueId: string;
|
||||
@@ -18,40 +20,34 @@ interface LeagueListItemProps {
|
||||
|
||||
export function LeagueListItem({ league, isAdmin }: LeagueListItemProps) {
|
||||
return (
|
||||
<Card
|
||||
variant="outline"
|
||||
p={4}
|
||||
className="bg-graphite-black border-[#262626]"
|
||||
>
|
||||
<Stack direction="row" align="center" justify="between" fullWidth>
|
||||
<Stack style={{ flex: 1, minWidth: 0 }}>
|
||||
<Text weight="medium" color="text-white" block>{league.name}</Text>
|
||||
<Text size="xs" color="text-gray-400" block mt={1} style={{ display: '-webkit-box', WebkitLineClamp: 2, WebkitBoxOrient: 'vertical', overflow: 'hidden' }}>
|
||||
{league.description}
|
||||
</Text>
|
||||
{league.membershipRole && (
|
||||
<Text size="xs" color="text-gray-500" block mt={1}>
|
||||
<ListItem>
|
||||
<ListItemInfo
|
||||
title={league.name}
|
||||
description={league.description}
|
||||
meta={
|
||||
league.membershipRole && (
|
||||
<Text size="xs" variant="low">
|
||||
Your role:{' '}
|
||||
<Text color="text-gray-400" style={{ textTransform: 'capitalize' }}>{league.membershipRole}</Text>
|
||||
<Text as="span" variant="med" style={{ textTransform: 'capitalize' }}>{league.membershipRole}</Text>
|
||||
</Text>
|
||||
)}
|
||||
</Stack>
|
||||
<Stack direction="row" align="center" gap={2} style={{ marginLeft: '1rem' }}>
|
||||
<Link
|
||||
href={`/leagues/${league.leagueId}`}
|
||||
variant="ghost"
|
||||
>
|
||||
<Text size="sm" color="text-gray-300">View</Text>
|
||||
)
|
||||
}
|
||||
/>
|
||||
<ListItemActions>
|
||||
<Link
|
||||
href={`/leagues/${league.leagueId}`}
|
||||
variant="ghost"
|
||||
>
|
||||
<Text size="sm" variant="med">View</Text>
|
||||
</Link>
|
||||
{isAdmin && (
|
||||
<Link href={`/leagues/${league.leagueId}?tab=admin`} variant="ghost">
|
||||
<Button variant="primary" size="sm">
|
||||
Manage
|
||||
</Button>
|
||||
</Link>
|
||||
{isAdmin && (
|
||||
<Link href={`/leagues/${league.leagueId}?tab=admin`} variant="ghost">
|
||||
<Button variant="primary" size="sm">
|
||||
Manage
|
||||
</Button>
|
||||
</Link>
|
||||
)}
|
||||
</Stack>
|
||||
</Stack>
|
||||
</Card>
|
||||
)}
|
||||
</ListItemActions>
|
||||
</ListItem>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
import { DriverIdentity } from '@/components/drivers/DriverIdentity';
|
||||
import { DriverViewModel } from '@/lib/view-models/DriverViewModel';
|
||||
import { Badge } from '@/ui/Badge';
|
||||
import { Box } from '@/ui/primitives/Box';
|
||||
import { TableCell, TableRow } from '@/ui/Table';
|
||||
import { Text } from '@/ui/Text';
|
||||
import { ReactNode } from 'react';
|
||||
import { DateDisplay } from '@/lib/display-objects/DateDisplay';
|
||||
import React, { ReactNode } from 'react';
|
||||
|
||||
interface LeagueMemberRowProps {
|
||||
driver?: DriverViewModel;
|
||||
@@ -41,7 +41,7 @@ export function LeagueMemberRow({
|
||||
return (
|
||||
<TableRow variant={isTopPerformer ? 'highlight' : 'default'}>
|
||||
<TableCell>
|
||||
<Box display="flex" alignItems="center" gap={2}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '0.5rem' }}>
|
||||
{driver ? (
|
||||
<DriverIdentity
|
||||
driver={driver}
|
||||
@@ -51,28 +51,28 @@ export function LeagueMemberRow({
|
||||
size="md"
|
||||
/>
|
||||
) : (
|
||||
<Text color="text-white">Unknown Driver</Text>
|
||||
<Text variant="high">Unknown Driver</Text>
|
||||
)}
|
||||
{isCurrentUser && (
|
||||
<Text size="xs" color="text-gray-500">(You)</Text>
|
||||
<Text size="xs" variant="low">(You)</Text>
|
||||
)}
|
||||
{isTopPerformer && (
|
||||
<Text size="xs">⭐</Text>
|
||||
)}
|
||||
</Box>
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Text color="text-primary-blue" weight="medium">
|
||||
<Text variant="primary" weight="medium">
|
||||
{rating || '—'}
|
||||
</Text>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Text color="text-gray-300">
|
||||
<Text variant="med">
|
||||
#{rank || '—'}
|
||||
</Text>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Text color="text-green-400" weight="medium">
|
||||
<Text variant="success" weight="medium">
|
||||
{wins || 0}
|
||||
</Text>
|
||||
</TableCell>
|
||||
@@ -82,12 +82,8 @@ export function LeagueMemberRow({
|
||||
</Badge>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Text color="text-white" size="sm">
|
||||
{new Date(joinedAt).toLocaleDateString('en-US', {
|
||||
year: 'numeric',
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
})}
|
||||
<Text variant="high" size="sm">
|
||||
{DateDisplay.formatShort(joinedAt)}
|
||||
</Text>
|
||||
</TableCell>
|
||||
{actions && (
|
||||
|
||||
@@ -2,7 +2,8 @@
|
||||
|
||||
import { LeagueMemberRow } from '@/components/leagues/LeagueMemberRow';
|
||||
import { LeagueMemberTable } from '@/components/leagues/LeagueMemberTable';
|
||||
import { MinimalEmptyState } from '@/components/shared/state/EmptyState';
|
||||
import { EmptyState } from '@/ui/EmptyState';
|
||||
import { LoadingWrapper } from '@/ui/LoadingWrapper';
|
||||
import { useEffectiveDriverId } from '@/hooks/useEffectiveDriverId';
|
||||
import { useInject } from '@/lib/di/hooks/useInject';
|
||||
import { DRIVER_SERVICE_TOKEN, LEAGUE_MEMBERSHIP_SERVICE_TOKEN } from '@/lib/di/tokens';
|
||||
@@ -11,10 +12,11 @@ import type { LeagueMembership } from '@/lib/types/LeagueMembership';
|
||||
import type { MembershipRole } from '@/lib/types/MembershipRole';
|
||||
import { DriverViewModel } from '@/lib/view-models/DriverViewModel';
|
||||
import { Button } from '@/ui/Button';
|
||||
import { Box } from '@/ui/primitives/Box';
|
||||
import { Select } from '@/ui/Select';
|
||||
import { Text } from '@/ui/Text';
|
||||
import { ControlBar } from '@/ui/ControlBar';
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
import React from 'react';
|
||||
|
||||
interface LeagueMembersProps {
|
||||
leagueId: string;
|
||||
@@ -83,7 +85,6 @@ export function LeagueMembers({
|
||||
return null;
|
||||
};
|
||||
|
||||
// eslint-disable-next-line gridpilot-rules/component-no-data-manipulation
|
||||
const sortedMembers = [...members].sort((a, b) => {
|
||||
switch (sortBy) {
|
||||
case 'role':
|
||||
@@ -120,31 +121,30 @@ export function LeagueMembers({
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<Box textAlign="center" py={8}>
|
||||
<Text color="text-gray-400">Loading members...</Text>
|
||||
</Box>
|
||||
);
|
||||
return <LoadingWrapper variant="spinner" message="Loading members..." />;
|
||||
}
|
||||
|
||||
if (members.length === 0) {
|
||||
return (
|
||||
<MinimalEmptyState
|
||||
<EmptyState
|
||||
title="No members found"
|
||||
description="This league doesn't have any members yet."
|
||||
variant="minimal"
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Box>
|
||||
{/* Sort Controls */}
|
||||
<Box display="flex" alignItems="center" justifyContent="between" mb={4}>
|
||||
<Text size="sm" color="text-gray-400">
|
||||
{members.length} {members.length === 1 ? 'member' : 'members'}
|
||||
</Text>
|
||||
<Box display="flex" alignItems="center" gap={2}>
|
||||
<Text as="label" size="sm" color="text-gray-400">Sort by:</Text>
|
||||
<div>
|
||||
<ControlBar
|
||||
leftContent={
|
||||
<Text size="sm" variant="low">
|
||||
{members.length} {members.length === 1 ? 'member' : 'members'}
|
||||
</Text>
|
||||
}
|
||||
>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '0.5rem' }}>
|
||||
<Text size="sm" variant="low">Sort by:</Text>
|
||||
<Select
|
||||
value={sortBy}
|
||||
onChange={(e: React.ChangeEvent<HTMLSelectElement>) => setSortBy(e.target.value as typeof sortBy)}
|
||||
@@ -158,11 +158,10 @@ export function LeagueMembers({
|
||||
]}
|
||||
fullWidth={false}
|
||||
/>
|
||||
</Box>
|
||||
</Box>
|
||||
</div>
|
||||
</ControlBar>
|
||||
|
||||
{/* Members Table */}
|
||||
<Box overflow="auto">
|
||||
<div style={{ overflowX: 'auto', marginTop: '1rem' }}>
|
||||
<LeagueMemberTable showActions={showActions}>
|
||||
{sortedMembers.map((member, index) => {
|
||||
const isCurrentUser = member.driverId === currentDriverId;
|
||||
@@ -191,7 +190,7 @@ export function LeagueMembers({
|
||||
href={routes.driver.detail(member.driverId)}
|
||||
meta={ratingAndWinsMeta}
|
||||
actions={showActions && !cannotModify && !isCurrentUser ? (
|
||||
<Box display="flex" alignItems="center" justifyContent="end" gap={2}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'flex-end', gap: '0.5rem' }}>
|
||||
{onUpdateRole && (
|
||||
<Select
|
||||
value={member.role}
|
||||
@@ -202,8 +201,6 @@ export function LeagueMembers({
|
||||
{ value: 'admin', label: 'Admin' },
|
||||
]}
|
||||
fullWidth={false}
|
||||
// eslint-disable-next-line gridpilot-rules/component-classification
|
||||
className="text-xs py-1 px-2"
|
||||
/>
|
||||
)}
|
||||
{onRemoveMember && (
|
||||
@@ -211,18 +208,17 @@ export function LeagueMembers({
|
||||
variant="ghost"
|
||||
onClick={() => onRemoveMember(member.driverId)}
|
||||
size="sm"
|
||||
color="text-error-red"
|
||||
>
|
||||
Remove
|
||||
<Text variant="critical">Remove</Text>
|
||||
</Button>
|
||||
)}
|
||||
</Box>
|
||||
) : (showActions && cannotModify ? <Text size="xs" color="text-gray-500">—</Text> : undefined)}
|
||||
</div>
|
||||
) : (showActions && cannotModify ? <Text size="xs" variant="low">—</Text> : undefined)}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</LeagueMemberTable>
|
||||
</Box>
|
||||
</Box>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,12 +1,7 @@
|
||||
'use client';
|
||||
|
||||
import { Badge } from '@/ui/Badge';
|
||||
import { Heading } from '@/ui/Heading';
|
||||
import { Icon } from '@/ui/Icon';
|
||||
import { Stack } from '@/ui/primitives/Stack';
|
||||
import { Surface } from '@/ui/primitives/Surface';
|
||||
import { Text } from '@/ui/Text';
|
||||
import { DollarSign } from 'lucide-react';
|
||||
import { SponsorshipCard } from '@/ui/SponsorshipCard';
|
||||
import React from 'react';
|
||||
|
||||
interface SponsorshipSlot {
|
||||
id: string;
|
||||
@@ -26,42 +21,12 @@ interface SponsorshipSlotCardProps {
|
||||
|
||||
export function SponsorshipSlotCard({ slot }: SponsorshipSlotCardProps) {
|
||||
return (
|
||||
<Surface
|
||||
variant="muted"
|
||||
rounded="lg"
|
||||
border
|
||||
padding={4}
|
||||
// eslint-disable-next-line gridpilot-rules/component-classification
|
||||
style={{
|
||||
backgroundColor: slot.isAvailable ? 'rgba(16, 185, 129, 0.05)' : 'rgba(38, 38, 38, 0.3)',
|
||||
borderColor: slot.isAvailable ? '#10b981' : '#262626'
|
||||
}}
|
||||
>
|
||||
<Stack gap={3}>
|
||||
<Stack direction="row" align="start" justify="between">
|
||||
<Heading level={4}>{slot.name}</Heading>
|
||||
<Badge variant={slot.isAvailable ? 'success' : 'default'}>
|
||||
{slot.isAvailable ? 'Available' : 'Taken'}
|
||||
</Badge>
|
||||
</Stack>
|
||||
|
||||
<Text size="sm" color="text-gray-300" block>{slot.description}</Text>
|
||||
|
||||
<Stack direction="row" align="center" gap={2}>
|
||||
<Icon icon={DollarSign} size={4} color="#9ca3af" />
|
||||
<Text weight="semibold" color="text-white">
|
||||
{slot.price} {slot.currency}
|
||||
</Text>
|
||||
</Stack>
|
||||
|
||||
{!slot.isAvailable && slot.sponsoredBy && (
|
||||
// eslint-disable-next-line gridpilot-rules/component-classification
|
||||
<Stack pt={3} style={{ borderTop: '1px solid #262626' }}>
|
||||
<Text size="xs" color="text-gray-400" block mb={1}>Sponsored by</Text>
|
||||
<Text size="sm" weight="medium" color="text-white">{slot.sponsoredBy.name}</Text>
|
||||
</Stack>
|
||||
)}
|
||||
</Stack>
|
||||
</Surface>
|
||||
<SponsorshipCard
|
||||
name={slot.name}
|
||||
description={slot.description}
|
||||
price={`${slot.price} ${slot.currency}`}
|
||||
isAvailable={slot.isAvailable}
|
||||
sponsoredBy={slot.sponsoredBy?.name}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
'use client';
|
||||
|
||||
import { Icon } from '@/ui/Icon';
|
||||
import { Stack } from '@/ui/primitives/Stack';
|
||||
import { Surface } from '@/ui/primitives/Surface';
|
||||
import { Panel } from '@/ui/Panel';
|
||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/ui/Table';
|
||||
import { Text } from '@/ui/Text';
|
||||
import { PositionBadge } from '@/ui/ResultRow';
|
||||
import { TrendingUp, Trophy } from 'lucide-react';
|
||||
import React from 'react';
|
||||
|
||||
interface StandingsEntry {
|
||||
position: number;
|
||||
@@ -23,94 +24,63 @@ interface StandingsTableShellProps {
|
||||
|
||||
export function StandingsTableShell({ standings, title = 'Championship Standings' }: StandingsTableShellProps) {
|
||||
return (
|
||||
<Surface variant="dark" border rounded="lg" overflow="hidden">
|
||||
<Stack px={6} py={4} borderBottom borderColor="border-charcoal-outline" bg="bg-iron-gray/20">
|
||||
<Stack direction="row" align="center" justify="between">
|
||||
<Stack direction="row" align="center" gap={2}>
|
||||
<Icon icon={Trophy} size={4} color="text-warning-amber" />
|
||||
<Text weight="bold" letterSpacing="wider" size="sm" display="block">
|
||||
{title.toUpperCase()}
|
||||
</Text>
|
||||
</Stack>
|
||||
<Stack px={2} py={0.5} rounded="md" bg="bg-charcoal-outline/50">
|
||||
<Text size="xs" color="text-gray-400" weight="medium">{standings.length} Drivers</Text>
|
||||
</Stack>
|
||||
</Stack>
|
||||
</Stack>
|
||||
|
||||
<Table>
|
||||
<TableHead>
|
||||
<TableRow>
|
||||
<TableHeader w="4rem">Pos</TableHeader>
|
||||
<TableHeader>Driver</TableHeader>
|
||||
<TableHeader textAlign="center">Wins</TableHeader>
|
||||
<TableHeader textAlign="center">Podiums</TableHeader>
|
||||
<TableHeader textAlign="right">Points</TableHeader>
|
||||
</TableRow>
|
||||
</TableHead>
|
||||
<TableBody>
|
||||
{standings.map((entry) => (
|
||||
<TableRow key={entry.driverName}>
|
||||
<TableCell>
|
||||
<PositionBadge position={entry.position} />
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Stack direction="row" align="center" gap={3}>
|
||||
<Text weight="bold" color="text-white">{entry.driverName}</Text>
|
||||
{entry.change !== undefined && entry.change !== 0 && (
|
||||
<Stack direction="row" align="center" gap={0.5}>
|
||||
<Icon
|
||||
icon={TrendingUp}
|
||||
size={3}
|
||||
color={entry.change > 0 ? 'text-performance-green' : 'text-error-red'}
|
||||
transform={entry.change < 0 ? 'rotate(180deg)' : undefined}
|
||||
/>
|
||||
<Text size="xs" color={entry.change > 0 ? 'text-performance-green' : 'text-error-red'}>
|
||||
{Math.abs(entry.change)}
|
||||
</Text>
|
||||
</Stack>
|
||||
)}
|
||||
</Stack>
|
||||
</TableCell>
|
||||
<TableCell textAlign="center">
|
||||
<Text size="sm" color={entry.wins > 0 ? 'text-white' : 'text-gray-500'}>{entry.wins}</Text>
|
||||
</TableCell>
|
||||
<TableCell textAlign="center">
|
||||
<Text size="sm" color={entry.podiums > 0 ? 'text-white' : 'text-gray-500'}>{entry.podiums}</Text>
|
||||
</TableCell>
|
||||
<TableCell textAlign="right">
|
||||
<Text weight="bold" color="text-primary-blue">{entry.points}</Text>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</Surface>
|
||||
);
|
||||
}
|
||||
|
||||
function PositionBadge({ position }: { position: number }) {
|
||||
const isPodium = position <= 3;
|
||||
const colors = {
|
||||
1: 'text-warning-amber bg-warning-amber/10 border-warning-amber/20',
|
||||
2: 'text-gray-300 bg-gray-300/10 border-gray-300/20',
|
||||
3: 'text-orange-400 bg-orange-400/10 border-orange-400/20',
|
||||
};
|
||||
|
||||
return (
|
||||
<Stack
|
||||
center
|
||||
w={8}
|
||||
h={8}
|
||||
rounded="md"
|
||||
border={isPodium}
|
||||
bg={isPodium ? colors[position as keyof typeof colors].split(' ')[1] : undefined}
|
||||
color={isPodium ? colors[position as keyof typeof colors].split(' ')[0] : 'text-gray-500'}
|
||||
borderColor={isPodium ? colors[position as keyof typeof colors].split(' ')[2] : undefined}
|
||||
<Panel
|
||||
title={title}
|
||||
variant="dark"
|
||||
padding={0}
|
||||
footer={
|
||||
<Text size="xs" variant="low">{standings.length} Drivers</Text>
|
||||
}
|
||||
>
|
||||
<Text size="sm" weight="bold">
|
||||
{position}
|
||||
</Text>
|
||||
</Stack>
|
||||
<div style={{ overflowX: 'auto' }}>
|
||||
<Table>
|
||||
<TableHead>
|
||||
<TableRow>
|
||||
<TableHeader w="4rem">Pos</TableHeader>
|
||||
<TableHeader>Driver</TableHeader>
|
||||
<TableHeader textAlign="center">Wins</TableHeader>
|
||||
<TableHeader textAlign="center">Podiums</TableHeader>
|
||||
<TableHeader textAlign="right">Points</TableHeader>
|
||||
</TableRow>
|
||||
</TableHead>
|
||||
<TableBody>
|
||||
{standings.map((entry) => (
|
||||
<TableRow key={entry.driverName}>
|
||||
<TableCell>
|
||||
<PositionBadge position={entry.position} />
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '0.75rem' }}>
|
||||
<Text weight="bold" variant="high">{entry.driverName}</Text>
|
||||
{entry.change !== undefined && entry.change !== 0 && (
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '0.125rem' }}>
|
||||
<Icon
|
||||
icon={TrendingUp}
|
||||
size={3}
|
||||
intent={entry.change > 0 ? 'success' : 'critical'}
|
||||
style={{ transform: entry.change < 0 ? 'rotate(180deg)' : undefined }}
|
||||
/>
|
||||
<Text size="xs" variant={entry.change > 0 ? 'success' : 'critical'}>
|
||||
{Math.abs(entry.change)}
|
||||
</Text>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell textAlign="center">
|
||||
<Text size="sm" variant={entry.wins > 0 ? 'high' : 'low'}>{entry.wins}</Text>
|
||||
</TableCell>
|
||||
<TableCell textAlign="center">
|
||||
<Text size="sm" variant={entry.podiums > 0 ? 'high' : 'low'}>{entry.podiums}</Text>
|
||||
</TableCell>
|
||||
<TableCell textAlign="right">
|
||||
<Text weight="bold" variant="primary">{entry.points}</Text>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
</Panel>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,8 +1,13 @@
|
||||
'use client';
|
||||
|
||||
import { IconButton } from '@/ui/IconButton';
|
||||
import { Input } from '@/ui/Input';
|
||||
import { Stack } from '@/ui/primitives/Stack';
|
||||
import { Select } from '@/ui/Select';
|
||||
import { ControlBar } from '@/ui/ControlBar';
|
||||
import { SegmentedControl } from '@/ui/SegmentedControl';
|
||||
import { Icon } from '@/ui/Icon';
|
||||
import { Grid, List, Search } from 'lucide-react';
|
||||
import React from 'react';
|
||||
|
||||
export interface MediaFiltersBarProps {
|
||||
searchQuery: string;
|
||||
@@ -24,56 +29,41 @@ export function MediaFiltersBar({
|
||||
onViewModeChange,
|
||||
}: MediaFiltersBarProps) {
|
||||
return (
|
||||
<Stack
|
||||
direction={{ base: 'col', md: 'row' }}
|
||||
align={{ base: 'stretch', md: 'center' }}
|
||||
justify="between"
|
||||
gap={4}
|
||||
p={4}
|
||||
bg="bg-charcoal/20"
|
||||
border
|
||||
borderColor="border-charcoal-outline/20"
|
||||
rounded="xl"
|
||||
>
|
||||
<Stack flexGrow={1} maxWidth={{ md: 'md' }} gap={0}>
|
||||
<Input
|
||||
placeholder="Search media assets..."
|
||||
value={searchQuery}
|
||||
onChange={(e) => onSearchChange(e.target.value)}
|
||||
icon={<Search size={18} />}
|
||||
/>
|
||||
</Stack>
|
||||
|
||||
<Stack direction="row" align="center" gap={3}>
|
||||
<Stack w="40" gap={0}>
|
||||
<Select
|
||||
value={category}
|
||||
onChange={(e) => onCategoryChange(e.target.value)}
|
||||
options={categories}
|
||||
/>
|
||||
</Stack>
|
||||
|
||||
{onViewModeChange && (
|
||||
<Stack direction="row" bg="bg-charcoal/40" p={1} rounded="lg" border borderColor="border-charcoal-outline/20" gap={0}>
|
||||
<IconButton
|
||||
icon={Grid}
|
||||
variant={viewMode === 'grid' ? 'primary' : 'ghost'}
|
||||
size="sm"
|
||||
onClick={() => onViewModeChange('grid')}
|
||||
color={viewMode === 'grid' ? 'text-white' : 'text-gray-400'}
|
||||
backgroundColor={viewMode === 'grid' ? 'bg-blue-600' : undefined}
|
||||
<div style={{ marginBottom: '1.5rem' }}>
|
||||
<ControlBar
|
||||
leftContent={
|
||||
<div style={{ maxWidth: '32rem', width: '100%' }}>
|
||||
<Input
|
||||
placeholder="Search media assets..."
|
||||
value={searchQuery}
|
||||
onChange={(e) => onSearchChange(e.target.value)}
|
||||
icon={<Icon icon={Search} size={4} intent="low" />}
|
||||
fullWidth
|
||||
/>
|
||||
<IconButton
|
||||
icon={List}
|
||||
variant={viewMode === 'list' ? 'primary' : 'ghost'}
|
||||
size="sm"
|
||||
onClick={() => onViewModeChange('list')}
|
||||
color={viewMode === 'list' ? 'text-white' : 'text-gray-400'}
|
||||
backgroundColor={viewMode === 'list' ? 'bg-blue-600' : undefined}
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '1rem' }}>
|
||||
<div style={{ width: '10rem' }}>
|
||||
<Select
|
||||
value={category}
|
||||
onChange={(e) => onCategoryChange(e.target.value)}
|
||||
options={categories}
|
||||
/>
|
||||
</Stack>
|
||||
)}
|
||||
</Stack>
|
||||
</Stack>
|
||||
</div>
|
||||
|
||||
{onViewModeChange && (
|
||||
<SegmentedControl
|
||||
options={[
|
||||
{ id: 'grid', label: '', icon: <Icon icon={Grid} size={4} /> },
|
||||
{ id: 'list', label: '', icon: <Icon icon={List} size={4} /> },
|
||||
]}
|
||||
activeId={viewMode}
|
||||
onChange={(id) => onViewModeChange(id as 'grid' | 'list')}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</ControlBar>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,12 +1,15 @@
|
||||
'use client';
|
||||
|
||||
import { Box } from '@/ui/primitives/Box';
|
||||
import { Text } from '@/ui/Text';
|
||||
import { useState } from 'react';
|
||||
import { MediaCard } from './MediaCard';
|
||||
import { MediaCard } from '@/ui/MediaCard';
|
||||
import { MediaFiltersBar } from './MediaFiltersBar';
|
||||
import { MediaGrid } from './MediaGrid';
|
||||
import { Grid } from '@/ui/Grid';
|
||||
import { MediaViewerModal } from './MediaViewerModal';
|
||||
import { SectionHeader } from '@/ui/SectionHeader';
|
||||
import { EmptyState } from '@/ui/EmptyState';
|
||||
import { Search } from 'lucide-react';
|
||||
import React from 'react';
|
||||
|
||||
export interface MediaAsset {
|
||||
id: string;
|
||||
@@ -60,17 +63,11 @@ export function MediaGallery({
|
||||
};
|
||||
|
||||
return (
|
||||
<Box display="flex" flexDirection="col" gap={6}>
|
||||
<Box>
|
||||
<Text as="h1" size="3xl" weight="bold" color="text-white">
|
||||
{title}
|
||||
</Text>
|
||||
{description && (
|
||||
<Text block size="sm" color="text-gray-400" mt={1}>
|
||||
{description}
|
||||
</Text>
|
||||
)}
|
||||
</Box>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '1.5rem' }}>
|
||||
<SectionHeader
|
||||
title={title}
|
||||
description={description}
|
||||
/>
|
||||
|
||||
<MediaFiltersBar
|
||||
searchQuery={searchQuery}
|
||||
@@ -83,7 +80,7 @@ export function MediaGallery({
|
||||
/>
|
||||
|
||||
{filteredAssets.length > 0 ? (
|
||||
<MediaGrid columns={viewMode === 'grid' ? { base: 1, sm: 2, md: 3, lg: 4 } : { base: 1 }}>
|
||||
<Grid cols={viewMode === 'grid' ? { base: 1, sm: 2, md: 3, lg: 4 } : 1} gap={4}>
|
||||
{filteredAssets.map((asset) => (
|
||||
<MediaCard
|
||||
key={asset.id}
|
||||
@@ -93,11 +90,14 @@ export function MediaGallery({
|
||||
onClick={() => setViewerAsset(asset)}
|
||||
/>
|
||||
))}
|
||||
</MediaGrid>
|
||||
</Grid>
|
||||
) : (
|
||||
<Box py={20} center bg="bg-charcoal/10" rounded="xl" border borderStyle="dashed" borderColor="border-charcoal-outline/20">
|
||||
<Text color="text-gray-500">No media assets found matching your criteria.</Text>
|
||||
</Box>
|
||||
<EmptyState
|
||||
icon={Search}
|
||||
title="No media assets found"
|
||||
description="Try adjusting your search or filters"
|
||||
variant="minimal"
|
||||
/>
|
||||
)}
|
||||
|
||||
<MediaViewerModal
|
||||
@@ -109,6 +109,6 @@ export function MediaGallery({
|
||||
onNext={handleNext}
|
||||
onPrev={handlePrev}
|
||||
/>
|
||||
</Box>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,16 +1,16 @@
|
||||
import { Grid } from '@/ui/primitives/Grid';
|
||||
import { Grid } from '@/ui/Grid';
|
||||
import React from 'react';
|
||||
|
||||
export interface MediaGridProps {
|
||||
children: React.ReactNode;
|
||||
columns?: {
|
||||
columns?: number | {
|
||||
base?: number;
|
||||
sm?: number;
|
||||
md?: number;
|
||||
lg?: number;
|
||||
xl?: number;
|
||||
};
|
||||
gap?: 0 | 1 | 2 | 3 | 4 | 6 | 8 | 12 | 16;
|
||||
gap?: number;
|
||||
}
|
||||
|
||||
export function MediaGrid({
|
||||
@@ -20,10 +20,8 @@ export function MediaGrid({
|
||||
}: MediaGridProps) {
|
||||
return (
|
||||
<Grid
|
||||
cols={(columns.base ?? 1) as any}
|
||||
mdCols={columns.md as any}
|
||||
lgCols={columns.lg as any}
|
||||
gap={gap as any}
|
||||
cols={columns}
|
||||
gap={gap}
|
||||
>
|
||||
{children}
|
||||
</Grid>
|
||||
|
||||
@@ -2,10 +2,10 @@
|
||||
|
||||
import { IconButton } from '@/ui/IconButton';
|
||||
import { Image } from '@/ui/Image';
|
||||
import { Box } from '@/ui/primitives/Box';
|
||||
import { Modal } from '@/ui/Modal';
|
||||
import { Text } from '@/ui/Text';
|
||||
import { AnimatePresence, motion } from 'framer-motion';
|
||||
import { ChevronLeft, ChevronRight, Download, X } from 'lucide-react';
|
||||
import { ChevronLeft, ChevronRight, Download } from 'lucide-react';
|
||||
import React from 'react';
|
||||
|
||||
export interface MediaViewerModalProps {
|
||||
isOpen: boolean;
|
||||
@@ -27,143 +27,61 @@ export function MediaViewerModal({
|
||||
onPrev,
|
||||
}: MediaViewerModalProps) {
|
||||
return (
|
||||
<AnimatePresence>
|
||||
{isOpen && (
|
||||
<Box
|
||||
position="fixed"
|
||||
inset="0"
|
||||
zIndex={100}
|
||||
display="flex"
|
||||
alignItems="center"
|
||||
justifyContent="center"
|
||||
p={{ base: 4, md: 8 }}
|
||||
>
|
||||
{/* Backdrop with frosted blur */}
|
||||
<Box
|
||||
as={motion.div}
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
exit={{ opacity: 0 }}
|
||||
transition={{ duration: 0.2, ease: 'easeOut' }}
|
||||
position="absolute"
|
||||
inset="0"
|
||||
bg="bg-black/80"
|
||||
blur="md"
|
||||
onClick={onClose}
|
||||
<Modal
|
||||
isOpen={isOpen}
|
||||
onClose={onClose}
|
||||
title={title}
|
||||
size="xl"
|
||||
footer={
|
||||
<div style={{ width: '100%', textAlign: 'center' }}>
|
||||
<Text size="xs" variant="low" uppercase>
|
||||
Precision Racing Media Viewer
|
||||
</Text>
|
||||
</div>
|
||||
}
|
||||
actions={
|
||||
<IconButton
|
||||
icon={Download}
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
onClick={() => src && window.open(src, '_blank')}
|
||||
title="Download"
|
||||
/>
|
||||
}
|
||||
>
|
||||
<div style={{ position: 'relative', display: 'flex', alignItems: 'center', justifyContent: 'center', minHeight: '20rem' }}>
|
||||
{src ? (
|
||||
<Image
|
||||
src={src}
|
||||
alt={alt}
|
||||
objectFit="contain"
|
||||
/>
|
||||
) : (
|
||||
<Text variant="low">No image selected</Text>
|
||||
)}
|
||||
|
||||
{/* Content Container */}
|
||||
<Box
|
||||
as={motion.div}
|
||||
initial={{ opacity: 0, scale: 0.95 }}
|
||||
animate={{ opacity: 1, scale: 1 }}
|
||||
exit={{ opacity: 0, scale: 0.95 }}
|
||||
transition={{ duration: 0.2, ease: [0.25, 0.1, 0.25, 1] }}
|
||||
position="relative"
|
||||
zIndex={10}
|
||||
w="full"
|
||||
maxWidth="6xl"
|
||||
maxHeight="full"
|
||||
display="flex"
|
||||
flexDirection="col"
|
||||
>
|
||||
{/* Header */}
|
||||
<Box
|
||||
display="flex"
|
||||
alignItems="center"
|
||||
justifyContent="between"
|
||||
mb={4}
|
||||
color="text-white"
|
||||
>
|
||||
<Box>
|
||||
{title && (
|
||||
<Text size="lg" weight="semibold" color="text-white">
|
||||
{title}
|
||||
</Text>
|
||||
)}
|
||||
</Box>
|
||||
<Box display="flex" gap={2}>
|
||||
<IconButton
|
||||
icon={Download}
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
onClick={() => src && window.open(src, '_blank')}
|
||||
color="text-white"
|
||||
backgroundColor="bg-white/10"
|
||||
/>
|
||||
<IconButton
|
||||
icon={X}
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
onClick={onClose}
|
||||
color="text-white"
|
||||
backgroundColor="bg-white/10"
|
||||
/>
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
{/* Image Area */}
|
||||
<Box
|
||||
position="relative"
|
||||
display="flex"
|
||||
alignItems="center"
|
||||
justifyContent="center"
|
||||
bg="bg-black/40"
|
||||
rounded="xl"
|
||||
overflow="hidden"
|
||||
border
|
||||
borderColor="border-white/10"
|
||||
flexGrow={1}
|
||||
minHeight="0"
|
||||
>
|
||||
{src ? (
|
||||
<Image
|
||||
src={src}
|
||||
alt={alt}
|
||||
objectFit="contain"
|
||||
fullWidth
|
||||
fullHeight
|
||||
/>
|
||||
) : (
|
||||
<Box p={20}>
|
||||
<Text color="text-gray-500">No image selected</Text>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{/* Navigation Controls */}
|
||||
{onPrev && (
|
||||
<Box position="absolute" left="4" top="1/2" translateY="-1/2">
|
||||
<IconButton
|
||||
icon={ChevronLeft}
|
||||
variant="secondary"
|
||||
onClick={onPrev}
|
||||
color="text-white"
|
||||
backgroundColor="bg-black/50"
|
||||
/>
|
||||
</Box>
|
||||
)}
|
||||
{onNext && (
|
||||
<Box position="absolute" right="4" top="1/2" translateY="-1/2">
|
||||
<IconButton
|
||||
icon={ChevronRight}
|
||||
variant="secondary"
|
||||
onClick={onNext}
|
||||
color="text-white"
|
||||
backgroundColor="bg-black/50"
|
||||
/>
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
|
||||
{/* Footer / Info */}
|
||||
<Box mt={4} display="flex" justifyContent="center">
|
||||
<Text size="xs" color="text-gray-400" uppercase letterSpacing="widest">
|
||||
Precision Racing Media Viewer
|
||||
</Text>
|
||||
</Box>
|
||||
</Box>
|
||||
</Box>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
{/* Navigation Controls */}
|
||||
{onPrev && (
|
||||
<div style={{ position: 'absolute', left: '1rem', top: '50%', transform: 'translateY(-50%)' }}>
|
||||
<IconButton
|
||||
icon={ChevronLeft}
|
||||
variant="secondary"
|
||||
onClick={onPrev}
|
||||
title="Previous"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{onNext && (
|
||||
<div style={{ position: 'absolute', right: '1rem', top: '50%', transform: 'translateY(-50%)' }}>
|
||||
<IconButton
|
||||
icon={ChevronRight}
|
||||
variant="secondary"
|
||||
onClick={onNext}
|
||||
title="Next"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,11 +1,10 @@
|
||||
'use client';
|
||||
|
||||
import { Button } from '@/ui/Button';
|
||||
import { Heading } from '@/ui/Heading';
|
||||
import { Icon } from '@/ui/Icon';
|
||||
import { IconButton } from '@/ui/IconButton';
|
||||
import { Box } from '@/ui/primitives/Box';
|
||||
import { Modal } from '@/ui/Modal';
|
||||
import { Text } from '@/ui/Text';
|
||||
import { NotificationStat, NotificationDeadline } from '@/ui/NotificationContent';
|
||||
import {
|
||||
AlertCircle,
|
||||
AlertTriangle,
|
||||
@@ -18,9 +17,8 @@ import {
|
||||
Trophy,
|
||||
Users,
|
||||
Vote,
|
||||
X,
|
||||
} from 'lucide-react';
|
||||
import { useEffect, useState } from 'react';
|
||||
import React from 'react';
|
||||
import type { Notification, NotificationAction } from './notificationTypes';
|
||||
|
||||
interface ModalNotificationProps {
|
||||
@@ -42,71 +40,12 @@ const notificationIcons: Record<string, typeof Bell> = {
|
||||
race_reminder: Flag,
|
||||
};
|
||||
|
||||
const notificationColors: Record<string, { bg: string; border: string; text: string; glow: string }> = {
|
||||
protest_filed: {
|
||||
bg: 'bg-critical-red/10',
|
||||
border: 'border-critical-red/50',
|
||||
text: 'text-critical-red',
|
||||
glow: 'shadow-[0_0_60px_rgba(227,92,92,0.3)]',
|
||||
},
|
||||
protest_defense_requested: {
|
||||
bg: 'bg-warning-amber/10',
|
||||
border: 'border-warning-amber/50',
|
||||
text: 'text-warning-amber',
|
||||
glow: 'shadow-[0_0_60px_rgba(255,197,86,0.3)]',
|
||||
},
|
||||
protest_vote_required: {
|
||||
bg: 'bg-primary-accent/10',
|
||||
border: 'border-primary-accent/50',
|
||||
text: 'text-primary-accent',
|
||||
glow: 'shadow-[0_0_60px_rgba(25,140,255,0.3)]',
|
||||
},
|
||||
penalty_issued: {
|
||||
bg: 'bg-critical-red/10',
|
||||
border: 'border-critical-red/50',
|
||||
text: 'text-critical-red',
|
||||
glow: 'shadow-[0_0_60px_rgba(227,92,92,0.3)]',
|
||||
},
|
||||
race_performance_summary: {
|
||||
bg: 'bg-panel-gray',
|
||||
border: 'border-warning-amber/60',
|
||||
text: 'text-warning-amber',
|
||||
glow: 'shadow-[0_0_80px_rgba(255,197,86,0.2)]',
|
||||
},
|
||||
race_final_results: {
|
||||
bg: 'bg-panel-gray',
|
||||
border: 'border-primary-accent/60',
|
||||
text: 'text-primary-accent',
|
||||
glow: 'shadow-[0_0_80px_rgba(25,140,255,0.2)]',
|
||||
},
|
||||
};
|
||||
|
||||
export function ModalNotification({
|
||||
notification,
|
||||
onAction,
|
||||
onDismiss,
|
||||
onNavigate,
|
||||
}: ModalNotificationProps) {
|
||||
const [isVisible, setIsVisible] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
// Animate in
|
||||
const timeout = setTimeout(() => setIsVisible(true), 10);
|
||||
return () => clearTimeout(timeout);
|
||||
}, []);
|
||||
|
||||
// Handle ESC key to dismiss
|
||||
useEffect(() => {
|
||||
const handleKeyDown = (event: KeyboardEvent) => {
|
||||
if (event.key === 'Escape' && onDismiss && !notification.requiresResponse) {
|
||||
onDismiss(notification);
|
||||
}
|
||||
};
|
||||
|
||||
document.addEventListener('keydown', handleKeyDown);
|
||||
return () => document.removeEventListener('keydown', handleKeyDown);
|
||||
}, [notification, onDismiss]);
|
||||
|
||||
const handleAction = (action: NotificationAction) => {
|
||||
onAction(notification, action.id);
|
||||
if (action.href && onNavigate) {
|
||||
@@ -122,13 +61,6 @@ export function ModalNotification({
|
||||
};
|
||||
|
||||
const NotificationIcon = notificationIcons[notification.type] || AlertCircle;
|
||||
const colors = notificationColors[notification.type] || {
|
||||
bg: 'bg-panel-gray',
|
||||
border: 'border-border-gray',
|
||||
text: 'text-gray-400',
|
||||
glow: 'shadow-card',
|
||||
};
|
||||
|
||||
const data: Record<string, unknown> = notification.data ?? {};
|
||||
|
||||
const getNumber = (value: unknown): number | null => {
|
||||
@@ -148,7 +80,6 @@ export function ModalNotification({
|
||||
|
||||
const isValidDate = (value: unknown): value is Date => value instanceof Date && !Number.isNaN(value.getTime());
|
||||
|
||||
// Check if there's a deadline
|
||||
const deadlineValue = data.deadline;
|
||||
const deadline: Date | null =
|
||||
isValidDate(deadlineValue)
|
||||
@@ -158,200 +89,96 @@ export function ModalNotification({
|
||||
: null;
|
||||
const hasDeadline = !!deadline && !Number.isNaN(deadline.getTime());
|
||||
|
||||
// Special celebratory styling for race notifications
|
||||
const isRaceNotification = notification.type.startsWith('race_');
|
||||
|
||||
const provisionalRatingChange = getNumber(data.provisionalRatingChange) ?? 0;
|
||||
const finalRatingChange = getNumber(data.finalRatingChange) ?? 0;
|
||||
const ratingChange = provisionalRatingChange || finalRatingChange;
|
||||
const protestId = getString(data.protestId);
|
||||
|
||||
return (
|
||||
<Box
|
||||
position="fixed"
|
||||
inset="0"
|
||||
zIndex={100}
|
||||
display="flex"
|
||||
alignItems="center"
|
||||
justifyContent="center"
|
||||
p={4}
|
||||
transition
|
||||
bg={isVisible ? 'bg-black/80' : 'bg-transparent'}
|
||||
className={isVisible ? 'backdrop-blur-sm' : ''}
|
||||
>
|
||||
<Box
|
||||
w="full"
|
||||
maxWidth="lg"
|
||||
transform
|
||||
transition
|
||||
opacity={isVisible ? 1 : 0}
|
||||
className={isVisible ? 'scale-100' : 'scale-95'}
|
||||
>
|
||||
<Box
|
||||
rounded="sm"
|
||||
border
|
||||
borderColor={colors.border}
|
||||
bg="panel-gray"
|
||||
shadow={colors.glow}
|
||||
overflow="hidden"
|
||||
>
|
||||
{/* Header */}
|
||||
<Box
|
||||
px={6}
|
||||
py={4}
|
||||
bg="graphite-black"
|
||||
borderBottom
|
||||
borderColor={colors.border}
|
||||
>
|
||||
<Box display="flex" alignItems="center" justifyContent="between">
|
||||
<Box display="flex" alignItems="center" gap={4}>
|
||||
<Box
|
||||
p={2}
|
||||
rounded="sm"
|
||||
bg="panel-gray"
|
||||
border
|
||||
borderColor={colors.border}
|
||||
<Modal
|
||||
isOpen={true}
|
||||
onClose={onDismiss ? () => onDismiss(notification) : undefined}
|
||||
title={notification.title}
|
||||
description={isRaceNotification ? 'Race Update' : 'Action Required'}
|
||||
icon={<Icon icon={NotificationIcon} size={5} intent="primary" />}
|
||||
footer={
|
||||
<React.Fragment>
|
||||
{notification.actions && notification.actions.length > 0 ? (
|
||||
notification.actions.map((action, index) => (
|
||||
<Button
|
||||
key={index}
|
||||
variant={action.type === 'primary' ? 'primary' : 'secondary'}
|
||||
onClick={() => handleAction(action)}
|
||||
>
|
||||
{action.label}
|
||||
</Button>
|
||||
))
|
||||
) : (
|
||||
isRaceNotification ? (
|
||||
<>
|
||||
<Button
|
||||
variant="secondary"
|
||||
onClick={() => (onDismiss ? onDismiss(notification) : onAction(notification, 'dismiss'))}
|
||||
>
|
||||
<Icon icon={NotificationIcon} size={5} color={colors.text} />
|
||||
</Box>
|
||||
<Box>
|
||||
<Text
|
||||
size="xs"
|
||||
weight="bold"
|
||||
className="uppercase tracking-widest"
|
||||
color="text-gray-500"
|
||||
>
|
||||
{isRaceNotification ? 'Race Update' : 'Action Required'}
|
||||
</Text>
|
||||
<Heading level={3} weight="bold" color="text-white">
|
||||
{notification.title}
|
||||
</Heading>
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
{/* X button for dismissible notifications */}
|
||||
{onDismiss && !notification.requiresResponse && (
|
||||
<IconButton
|
||||
icon={X}
|
||||
onClick={() => onDismiss(notification)}
|
||||
variant="ghost"
|
||||
size="md"
|
||||
color="text-gray-500"
|
||||
title="Dismiss notification"
|
||||
/>
|
||||
)}
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
{/* Body */}
|
||||
<Box px={6} py={6}>
|
||||
<Text
|
||||
leading="relaxed"
|
||||
size="base"
|
||||
color="text-gray-300"
|
||||
block
|
||||
>
|
||||
{notification.message}
|
||||
</Text>
|
||||
|
||||
{/* Race performance stats */}
|
||||
{isRaceNotification && (
|
||||
<Box display="grid" gridCols={2} gap={4} mt={6}>
|
||||
<Box bg="graphite-black" rounded="sm" p={4} border borderColor="border-border-gray">
|
||||
<Text size="xs" color="text-gray-500" weight="bold" block mb={1} className="uppercase tracking-widest">POSITION</Text>
|
||||
<Text size="2xl" weight="bold" color="text-white" block>
|
||||
{notification.data?.position === 'DNF' ? 'DNF' : `P${notification.data?.position || '?'}`}
|
||||
</Text>
|
||||
</Box>
|
||||
<Box bg="graphite-black" rounded="sm" p={4} border borderColor="border-border-gray">
|
||||
<Text size="xs" color="text-gray-500" weight="bold" block mb={1} className="uppercase tracking-widest">RATING</Text>
|
||||
<Text size="2xl" weight="bold" color={ratingChange >= 0 ? 'text-success-green' : 'text-critical-red'} block>
|
||||
{ratingChange >= 0 ? '+' : ''}
|
||||
{ratingChange}
|
||||
</Text>
|
||||
</Box>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{/* Deadline warning */}
|
||||
{hasDeadline && !isRaceNotification && (
|
||||
<Box mt={6} display="flex" alignItems="center" gap={3} px={4} py={3} rounded="sm" bg="warning-amber/5" border borderColor="border-warning-amber/20">
|
||||
<Icon icon={Clock} size={5} color="text-warning-amber" />
|
||||
<Box>
|
||||
<Text size="sm" weight="bold" color="text-warning-amber" block className="uppercase tracking-wider">Response Required</Text>
|
||||
<Text size="xs" color="text-gray-500" block mt={0.5}>
|
||||
By {deadline ? deadline.toLocaleDateString() : ''} {deadline ? deadline.toLocaleTimeString() : ''}
|
||||
</Text>
|
||||
</Box>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{/* Additional context from data */}
|
||||
{protestId && (
|
||||
<Box mt={6} p={3} rounded="sm" bg="graphite-black" border borderColor="border-border-gray">
|
||||
<Text size="xs" color="text-gray-500" weight="bold" block mb={1} className="uppercase tracking-widest text-[10px]">PROTEST ID</Text>
|
||||
<Text size="xs" color="text-gray-400" font="mono" block>
|
||||
{protestId}
|
||||
</Text>
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
|
||||
{/* Actions */}
|
||||
<Box
|
||||
px={6}
|
||||
py={4}
|
||||
borderTop
|
||||
borderColor="border-border-gray"
|
||||
bg="graphite-black"
|
||||
>
|
||||
<Box display="flex" flexWrap="wrap" gap={3} justifyContent="end">
|
||||
{notification.actions && notification.actions.length > 0 ? (
|
||||
notification.actions.map((action, index) => (
|
||||
<Button
|
||||
key={index}
|
||||
variant={action.type === 'primary' ? 'primary' : 'secondary'}
|
||||
onClick={() => handleAction(action)}
|
||||
className={action.type === 'danger' ? 'bg-critical-red hover:bg-critical-red/90' : ''}
|
||||
>
|
||||
{action.label}
|
||||
</Button>
|
||||
))
|
||||
) : (
|
||||
isRaceNotification ? (
|
||||
<>
|
||||
<Button
|
||||
variant="secondary"
|
||||
onClick={() => (onDismiss ? onDismiss(notification) : onAction(notification, 'dismiss'))}
|
||||
>
|
||||
Dismiss
|
||||
</Button>
|
||||
<Button
|
||||
variant="primary"
|
||||
onClick={handlePrimaryAction}
|
||||
>
|
||||
{notification.type === 'race_performance_summary' ? 'View Results' : 'View Standings'}
|
||||
</Button>
|
||||
</>
|
||||
) : (
|
||||
<Button variant="primary" onClick={handlePrimaryAction}>
|
||||
{notification.actionUrl ? 'View Details' : 'Acknowledge'}
|
||||
</Button>
|
||||
)
|
||||
)}
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
{/* Cannot dismiss warning */}
|
||||
{notification.requiresResponse && !isRaceNotification && (
|
||||
<Box px={6} py={2} bg="critical-red/5" borderTop borderColor="border-critical-red/10">
|
||||
<Text size="xs" color="text-critical-red" textAlign="center" block weight="medium">
|
||||
This action is required to continue
|
||||
</Text>
|
||||
</Box>
|
||||
Dismiss
|
||||
</Button>
|
||||
<Button
|
||||
variant="primary"
|
||||
onClick={handlePrimaryAction}
|
||||
>
|
||||
{notification.type === 'race_performance_summary' ? 'View Results' : 'View Standings'}
|
||||
</Button>
|
||||
</>
|
||||
) : (
|
||||
<Button variant="primary" onClick={handlePrimaryAction}>
|
||||
{notification.actionUrl ? 'View Details' : 'Acknowledge'}
|
||||
</Button>
|
||||
)
|
||||
)}
|
||||
</Box>
|
||||
</Box>
|
||||
</Box>
|
||||
</React.Fragment>
|
||||
}
|
||||
>
|
||||
<Text leading="relaxed" size="base" variant="med" block>
|
||||
{notification.message}
|
||||
</Text>
|
||||
|
||||
{isRaceNotification && (
|
||||
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '1rem', marginTop: '1.5rem' }}>
|
||||
<NotificationStat
|
||||
label="POSITION"
|
||||
value={notification.data?.position === 'DNF' ? 'DNF' : `P${notification.data?.position || '?'}`}
|
||||
/>
|
||||
<NotificationStat
|
||||
label="RATING"
|
||||
value={`${ratingChange >= 0 ? '+' : ''}${ratingChange}`}
|
||||
intent={ratingChange >= 0 ? 'success' : 'critical'}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{hasDeadline && !isRaceNotification && (
|
||||
<NotificationDeadline
|
||||
label="Response Required"
|
||||
deadline={`By ${deadline ? deadline.toLocaleDateString() : ''} ${deadline ? deadline.toLocaleTimeString() : ''}`}
|
||||
icon={Clock}
|
||||
/>
|
||||
)}
|
||||
|
||||
{protestId && (
|
||||
<div style={{ marginTop: '1.5rem', padding: '0.75rem', backgroundColor: 'var(--ui-color-bg-base)', border: '1px solid var(--ui-color-border-default)', borderRadius: 'var(--ui-radius-sm)' }}>
|
||||
<Text size="xs" variant="low" weight="bold" uppercase block marginBottom={1}>PROTEST ID</Text>
|
||||
<Text size="xs" variant="med" font="mono" block>{protestId}</Text>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{notification.requiresResponse && !isRaceNotification && (
|
||||
<div style={{ marginTop: '1rem', textAlign: 'center' }}>
|
||||
<Text size="xs" variant="critical" weight="medium">
|
||||
This action is required to continue
|
||||
</Text>
|
||||
</div>
|
||||
)}
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,8 +1,7 @@
|
||||
'use client';
|
||||
|
||||
import { Icon } from '@/ui/Icon';
|
||||
import { IconButton } from '@/ui/IconButton';
|
||||
import { Box } from '@/ui/primitives/Box';
|
||||
import { Toast } from '@/ui/Toast';
|
||||
import { Text } from '@/ui/Text';
|
||||
import {
|
||||
AlertTriangle,
|
||||
@@ -13,9 +12,8 @@ import {
|
||||
Trophy,
|
||||
Users,
|
||||
Vote,
|
||||
X,
|
||||
} from 'lucide-react';
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
import React, { useCallback, useEffect, useState } from 'react';
|
||||
import type { Notification } from './notificationTypes';
|
||||
|
||||
interface ToastNotificationProps {
|
||||
@@ -36,16 +34,6 @@ const notificationIcons: Record<string, typeof Bell> = {
|
||||
race_reminder: Flag,
|
||||
};
|
||||
|
||||
const notificationColors: Record<string, { bg: string; border: string; text: string }> = {
|
||||
protest_filed: { bg: 'bg-red-500/10', border: 'border-red-500/30', text: 'text-red-400' },
|
||||
protest_defense_requested: { bg: 'bg-warning-amber/10', border: 'border-warning-amber/30', text: 'text-warning-amber' },
|
||||
protest_vote_required: { bg: 'bg-primary-blue/10', border: 'border-primary-blue/30', text: 'text-primary-blue' },
|
||||
penalty_issued: { bg: 'bg-red-500/10', border: 'border-red-500/30', text: 'text-red-400' },
|
||||
race_results_posted: { bg: 'bg-performance-green/10', border: 'border-performance-green/30', text: 'text-performance-green' },
|
||||
league_invite: { bg: 'bg-primary-blue/10', border: 'border-primary-blue/30', text: 'text-primary-blue' },
|
||||
race_reminder: { bg: 'bg-warning-amber/10', border: 'border-warning-amber/30', text: 'text-warning-amber' },
|
||||
};
|
||||
|
||||
export function ToastNotification({
|
||||
notification,
|
||||
onDismiss,
|
||||
@@ -55,6 +43,7 @@ export function ToastNotification({
|
||||
}: ToastNotificationProps) {
|
||||
const [isVisible, setIsVisible] = useState(false);
|
||||
const [isExiting, setIsExiting] = useState(false);
|
||||
const [progress, setProgress] = useState(100);
|
||||
|
||||
const handleDismiss = useCallback(() => {
|
||||
setIsExiting(true);
|
||||
@@ -64,17 +53,22 @@ export function ToastNotification({
|
||||
}, [notification, onDismiss]);
|
||||
|
||||
useEffect(() => {
|
||||
// Animate in
|
||||
const showTimeout = setTimeout(() => setIsVisible(true), 10);
|
||||
|
||||
// Auto-hide
|
||||
const hideTimeout = setTimeout(() => {
|
||||
handleDismiss();
|
||||
}, autoHideDuration);
|
||||
|
||||
const startTime = Date.now();
|
||||
const interval = setInterval(() => {
|
||||
const elapsed = Date.now() - startTime;
|
||||
const remaining = Math.max(0, 100 - (elapsed / autoHideDuration) * 100);
|
||||
setProgress(remaining);
|
||||
if (remaining === 0) {
|
||||
clearInterval(interval);
|
||||
handleDismiss();
|
||||
}
|
||||
}, 10);
|
||||
|
||||
return () => {
|
||||
clearTimeout(showTimeout);
|
||||
clearTimeout(hideTimeout);
|
||||
clearInterval(interval);
|
||||
};
|
||||
}, [autoHideDuration, handleDismiss]);
|
||||
|
||||
@@ -87,88 +81,30 @@ export function ToastNotification({
|
||||
};
|
||||
|
||||
const NotificationIcon = notificationIcons[notification.type] || Bell;
|
||||
const colors = notificationColors[notification.type] || {
|
||||
bg: 'bg-gray-500/10',
|
||||
border: 'border-gray-500/30',
|
||||
text: 'text-gray-400',
|
||||
};
|
||||
|
||||
return (
|
||||
<Box
|
||||
transform
|
||||
transition
|
||||
translateX={isVisible && !isExiting ? '0' : 'full'}
|
||||
opacity={isVisible && !isExiting ? 1 : 0}
|
||||
<Toast
|
||||
title={notification.title ?? 'Notification'}
|
||||
onClose={handleDismiss}
|
||||
icon={<Icon icon={NotificationIcon} size={5} intent="primary" />}
|
||||
isVisible={isVisible}
|
||||
isExiting={isExiting}
|
||||
progress={progress}
|
||||
>
|
||||
<Box
|
||||
w="96"
|
||||
rounded="xl"
|
||||
border
|
||||
borderColor={colors.border}
|
||||
bg={colors.bg}
|
||||
shadow="2xl"
|
||||
overflow="hidden"
|
||||
>
|
||||
{/* Progress bar */}
|
||||
<Box h="1" bg="bg-iron-gray/50" overflow="hidden">
|
||||
<Box
|
||||
h="full"
|
||||
bg={colors.text.replace('text-', 'bg-')}
|
||||
// eslint-disable-next-line gridpilot-rules/component-classification
|
||||
className="animate-toast-progress"
|
||||
// eslint-disable-next-line gridpilot-rules/component-classification
|
||||
style={{ animationDuration: `${autoHideDuration}ms` }}
|
||||
/>
|
||||
</Box>
|
||||
|
||||
<Box p={4}>
|
||||
<Box display="flex" gap={3}>
|
||||
{/* Icon */}
|
||||
<Box p={2} rounded="lg" bg={colors.bg} flexShrink={0}>
|
||||
<Icon icon={NotificationIcon} size={5} color={colors.text} />
|
||||
</Box>
|
||||
|
||||
{/* Content */}
|
||||
<Box flexGrow={1} minWidth="0">
|
||||
<Box display="flex" alignItems="start" justifyContent="between" gap={2}>
|
||||
<Text size="sm" weight="semibold" color="text-white" truncate>
|
||||
{notification.title ?? 'Notification'}
|
||||
</Text>
|
||||
<IconButton
|
||||
icon={X}
|
||||
onClick={(e: React.MouseEvent<HTMLButtonElement>) => {
|
||||
e.stopPropagation();
|
||||
handleDismiss();
|
||||
}}
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
color="text-gray-400"
|
||||
/>
|
||||
</Box>
|
||||
<Text size="xs" color="text-gray-400" lineClamp={2} mt={1}>
|
||||
{notification.message}
|
||||
</Text>
|
||||
{notification.actionUrl && (
|
||||
<Box
|
||||
as="button"
|
||||
onClick={handleClick}
|
||||
mt={2}
|
||||
display="flex"
|
||||
alignItems="center"
|
||||
gap={1}
|
||||
cursor="pointer"
|
||||
hoverScale
|
||||
>
|
||||
<Text size="xs" weight="medium" color={colors.text}>
|
||||
View details
|
||||
</Text>
|
||||
<Icon icon={ExternalLink} size={3} color={colors.text} />
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
</Box>
|
||||
</Box>
|
||||
</Box>
|
||||
</Box>
|
||||
<Text size="xs" variant="low" lineClamp={2}>
|
||||
{notification.message}
|
||||
</Text>
|
||||
{notification.actionUrl && (
|
||||
<div
|
||||
onClick={handleClick}
|
||||
style={{ marginTop: '0.5rem', display: 'flex', alignItems: 'center', gap: '0.25rem', cursor: 'pointer' }}
|
||||
>
|
||||
<Text size="xs" weight="medium" variant="primary">
|
||||
View details
|
||||
</Text>
|
||||
<Icon icon={ExternalLink} size={3} intent="primary" />
|
||||
</div>
|
||||
)}
|
||||
</Toast>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
'use client';
|
||||
|
||||
import { Button } from '@/ui/Button';
|
||||
import { Card } from '@/ui/Card';
|
||||
import { Heading } from '@/ui/Heading';
|
||||
import { Stack } from '@/ui/primitives/Stack';
|
||||
import { Text } from '@/ui/Text';
|
||||
import { Panel } from '@/ui/Panel';
|
||||
import { AccountItem } from '@/ui/AccountItem';
|
||||
import { Badge } from '@/ui/Badge';
|
||||
import { Globe, Link as LinkIcon } from 'lucide-react';
|
||||
import React from 'react';
|
||||
|
||||
interface ConnectedAccountsPanelProps {
|
||||
iracingId?: string | number;
|
||||
@@ -14,56 +14,35 @@ interface ConnectedAccountsPanelProps {
|
||||
|
||||
export function ConnectedAccountsPanel({ iracingId, onConnectIRacing }: ConnectedAccountsPanelProps) {
|
||||
return (
|
||||
<section aria-labelledby="connected-accounts-heading">
|
||||
<Card>
|
||||
<Stack gap={6}>
|
||||
<Heading level={3} id="connected-accounts-heading" fontSize="1.125rem">Connected Accounts</Heading>
|
||||
|
||||
<Stack gap={4} className="divide-y divide-border-gray/30">
|
||||
<Stack direction="row" justify="between" align="center" pt={0}>
|
||||
<Stack direction="row" align="center" gap={3}>
|
||||
<Stack backgroundColor="#1e293b" p={2} rounded="md">
|
||||
<Globe size={20} color="#4ED4E0" />
|
||||
</Stack>
|
||||
<Stack gap={0.5}>
|
||||
<Text weight="bold" size="sm">iRacing</Text>
|
||||
<Text size="xs" color="#9ca3af">
|
||||
{iracingId ? `Connected ID: ${iracingId}` : 'Not connected'}
|
||||
</Text>
|
||||
</Stack>
|
||||
</Stack>
|
||||
|
||||
{!iracingId && (
|
||||
<Button size="sm" variant="secondary" onClick={onConnectIRacing}>
|
||||
Connect
|
||||
</Button>
|
||||
)}
|
||||
{iracingId && (
|
||||
<Stack backgroundColor="rgba(16, 185, 129, 0.1)" px={2} py={1} rounded="full">
|
||||
<Text size="xs" color="#10b981" weight="bold">Verified</Text>
|
||||
</Stack>
|
||||
)}
|
||||
</Stack>
|
||||
<Panel title="Connected Accounts">
|
||||
<AccountItem
|
||||
icon={Globe}
|
||||
title="iRacing"
|
||||
description={iracingId ? `Connected ID: ${iracingId}` : 'Not connected'}
|
||||
intent="telemetry"
|
||||
action={
|
||||
iracingId ? (
|
||||
<Badge variant="success">Verified</Badge>
|
||||
) : (
|
||||
<Button size="sm" variant="secondary" onClick={onConnectIRacing}>
|
||||
Connect
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
/>
|
||||
|
||||
<Stack direction="row" justify="between" align="center" pt={4}>
|
||||
<Stack direction="row" align="center" gap={3}>
|
||||
<Stack backgroundColor="#1e293b" p={2} rounded="md">
|
||||
<LinkIcon size={20} color="#198CFF" />
|
||||
</Stack>
|
||||
<Stack gap={0.5}>
|
||||
<Text weight="bold" size="sm">Discord</Text>
|
||||
<Text size="xs" color="#9ca3af">Connect for notifications</Text>
|
||||
</Stack>
|
||||
</Stack>
|
||||
|
||||
<Button size="sm" variant="secondary">
|
||||
Connect
|
||||
</Button>
|
||||
</Stack>
|
||||
</Stack>
|
||||
</Stack>
|
||||
</Card>
|
||||
</section>
|
||||
<AccountItem
|
||||
icon={LinkIcon}
|
||||
title="Discord"
|
||||
description="Connect for notifications"
|
||||
intent="primary"
|
||||
action={
|
||||
<Button size="sm" variant="secondary">
|
||||
Connect
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
</Panel>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -2,9 +2,9 @@
|
||||
|
||||
import { LeagueListItem } from '@/components/leagues/LeagueListItem';
|
||||
import { Card } from '@/ui/Card';
|
||||
import { Stack } from '@/ui/primitives/Stack';
|
||||
import { Text } from '@/ui/Text';
|
||||
import { ProfileSection } from './ProfileSection';
|
||||
import React from 'react';
|
||||
|
||||
interface League {
|
||||
leagueId: string;
|
||||
@@ -22,23 +22,23 @@ interface MembershipPanelProps {
|
||||
|
||||
export function MembershipPanel({ ownedLeagues, memberLeagues }: MembershipPanelProps) {
|
||||
return (
|
||||
<Stack gap={8}>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '2rem' }}>
|
||||
<ProfileSection
|
||||
title="Leagues You Own"
|
||||
description="Manage the leagues you have created and lead."
|
||||
>
|
||||
{ownedLeagues.length === 0 ? (
|
||||
<Card>
|
||||
<Text size="sm" color="text-gray-400">
|
||||
<Text size="sm" variant="low">
|
||||
You don't own any leagues yet.
|
||||
</Text>
|
||||
</Card>
|
||||
) : (
|
||||
<Stack gap={3}>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '0.75rem' }}>
|
||||
{ownedLeagues.map((league) => (
|
||||
<LeagueListItem key={league.leagueId} league={league} isAdmin />
|
||||
))}
|
||||
</Stack>
|
||||
</div>
|
||||
)}
|
||||
</ProfileSection>
|
||||
|
||||
@@ -48,18 +48,18 @@ export function MembershipPanel({ ownedLeagues, memberLeagues }: MembershipPanel
|
||||
>
|
||||
{memberLeagues.length === 0 ? (
|
||||
<Card>
|
||||
<Text size="sm" color="text-gray-400">
|
||||
<Text size="sm" variant="low">
|
||||
You're not a member of any other leagues yet.
|
||||
</Text>
|
||||
</Card>
|
||||
) : (
|
||||
<Stack gap={3}>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '0.75rem' }}>
|
||||
{memberLeagues.map((league) => (
|
||||
<LeagueListItem key={league.leagueId} league={league} />
|
||||
))}
|
||||
</Stack>
|
||||
</div>
|
||||
)}
|
||||
</ProfileSection>
|
||||
</Stack>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,11 +1,10 @@
|
||||
'use client';
|
||||
|
||||
import { Card } from '@/ui/Card';
|
||||
import { Heading } from '@/ui/Heading';
|
||||
import { Stack } from '@/ui/primitives/Stack';
|
||||
import { Panel } from '@/ui/Panel';
|
||||
import { Select } from '@/ui/Select';
|
||||
import { Text } from '@/ui/Text';
|
||||
import { Toggle } from '@/ui/Toggle';
|
||||
import { ProfileStatsGroup, ProfileStat } from '@/ui/ProfileHero';
|
||||
import React from 'react';
|
||||
|
||||
interface PreferencesPanelProps {
|
||||
preferences: {
|
||||
@@ -22,75 +21,54 @@ interface PreferencesPanelProps {
|
||||
export function PreferencesPanel({ preferences, isEditing, onUpdate }: PreferencesPanelProps) {
|
||||
if (isEditing) {
|
||||
return (
|
||||
<section aria-labelledby="preferences-heading">
|
||||
<Card>
|
||||
<Stack gap={6}>
|
||||
<Heading level={3} id="preferences-heading" fontSize="1.125rem">Racing Preferences</Heading>
|
||||
|
||||
<Stack gap={4}>
|
||||
<Select
|
||||
label="Favorite Car Class"
|
||||
value={preferences.favoriteCarClass}
|
||||
onChange={(e) => onUpdate?.({ favoriteCarClass: e.target.value })}
|
||||
options={[
|
||||
{ value: 'GT3', label: 'GT3' },
|
||||
{ value: 'GT4', label: 'GT4' },
|
||||
{ value: 'Formula', label: 'Formula' },
|
||||
{ value: 'LMP2', label: 'LMP2' },
|
||||
]}
|
||||
/>
|
||||
<Select
|
||||
label="Competitive Level"
|
||||
value={preferences.competitiveLevel}
|
||||
onChange={(e) => onUpdate?.({ competitiveLevel: e.target.value })}
|
||||
options={[
|
||||
{ value: 'casual', label: 'Casual' },
|
||||
{ value: 'competitive', label: 'Competitive' },
|
||||
{ value: 'professional', label: 'Professional' },
|
||||
]}
|
||||
/>
|
||||
|
||||
<Stack gap={3} pt={2}>
|
||||
<Toggle
|
||||
label="Public Profile"
|
||||
checked={preferences.showProfile}
|
||||
onChange={(checked) => onUpdate?.({ showProfile: checked })}
|
||||
/>
|
||||
<Toggle
|
||||
label="Show Race History"
|
||||
checked={preferences.showHistory}
|
||||
onChange={(checked) => onUpdate?.({ showHistory: checked })}
|
||||
/>
|
||||
</Stack>
|
||||
</Stack>
|
||||
</Stack>
|
||||
</Card>
|
||||
</section>
|
||||
<Panel title="Racing Preferences">
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '1.5rem' }}>
|
||||
<Select
|
||||
label="Favorite Car Class"
|
||||
value={preferences.favoriteCarClass}
|
||||
onChange={(e) => onUpdate?.({ favoriteCarClass: e.target.value })}
|
||||
options={[
|
||||
{ value: 'GT3', label: 'GT3' },
|
||||
{ value: 'GT4', label: 'GT4' },
|
||||
{ value: 'Formula', label: 'Formula' },
|
||||
{ value: 'LMP2', label: 'LMP2' },
|
||||
]}
|
||||
/>
|
||||
<Select
|
||||
label="Competitive Level"
|
||||
value={preferences.competitiveLevel}
|
||||
onChange={(e) => onUpdate?.({ competitiveLevel: e.target.value })}
|
||||
options={[
|
||||
{ value: 'casual', label: 'Casual' },
|
||||
{ value: 'competitive', label: 'Competitive' },
|
||||
{ value: 'professional', label: 'Professional' },
|
||||
]}
|
||||
/>
|
||||
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '0.75rem', paddingTop: '0.5rem' }}>
|
||||
<Toggle
|
||||
label="Public Profile"
|
||||
checked={preferences.showProfile}
|
||||
onChange={(checked) => onUpdate?.({ showProfile: checked })}
|
||||
/>
|
||||
<Toggle
|
||||
label="Show Race History"
|
||||
checked={preferences.showHistory}
|
||||
onChange={(checked) => onUpdate?.({ showHistory: checked })}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</Panel>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<section aria-labelledby="preferences-heading">
|
||||
<Card>
|
||||
<Stack gap={6}>
|
||||
<Heading level={3} id="preferences-heading" fontSize="1.125rem">Racing Preferences</Heading>
|
||||
|
||||
<Stack direction="row" gap={8} wrap>
|
||||
<Stack gap={1}>
|
||||
<Text size="xs" color="#6b7280" weight="bold" letterSpacing="0.05em" uppercase>Car Class</Text>
|
||||
<Text color="#d1d5db">{preferences.favoriteCarClass}</Text>
|
||||
</Stack>
|
||||
<Stack gap={1}>
|
||||
<Text size="xs" color="#6b7280" weight="bold" letterSpacing="0.05em" uppercase>Level</Text>
|
||||
<Text color="#d1d5db" capitalize>{preferences.competitiveLevel}</Text>
|
||||
</Stack>
|
||||
<Stack gap={1}>
|
||||
<Text size="xs" color="#6b7280" weight="bold" letterSpacing="0.05em" uppercase>Visibility</Text>
|
||||
<Text color="#d1d5db">{preferences.showProfile ? 'Public' : 'Private'}</Text>
|
||||
</Stack>
|
||||
</Stack>
|
||||
</Stack>
|
||||
</Card>
|
||||
</section>
|
||||
<Panel title="Racing Preferences">
|
||||
<ProfileStatsGroup>
|
||||
<ProfileStat label="Car Class" value={preferences.favoriteCarClass} intent="primary" />
|
||||
<ProfileStat label="Level" value={preferences.competitiveLevel} intent="telemetry" />
|
||||
<ProfileStat label="Visibility" value={preferences.showProfile ? 'Public' : 'Private'} intent="low" />
|
||||
</ProfileStatsGroup>
|
||||
</Panel>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,11 +1,6 @@
|
||||
|
||||
|
||||
import { Card } from '@/ui/Card';
|
||||
import { Heading } from '@/ui/Heading';
|
||||
import { Icon } from '@/ui/Icon';
|
||||
import { Box } from '@/ui/primitives/Box';
|
||||
import { Panel } from '@/ui/Panel';
|
||||
import { Text } from '@/ui/Text';
|
||||
import { User } from 'lucide-react';
|
||||
import React from 'react';
|
||||
|
||||
interface ProfileBioProps {
|
||||
bio: string;
|
||||
@@ -13,13 +8,10 @@ interface ProfileBioProps {
|
||||
|
||||
export function ProfileBio({ bio }: ProfileBioProps) {
|
||||
return (
|
||||
<Card>
|
||||
<Box mb={3}>
|
||||
<Heading level={2} icon={<Icon icon={User} size={5} color="#3b82f6" />}>
|
||||
About
|
||||
</Heading>
|
||||
</Box>
|
||||
<Text color="text-gray-300">{bio}</Text>
|
||||
</Card>
|
||||
<Panel
|
||||
title="About"
|
||||
>
|
||||
<Text variant="med">{bio}</Text>
|
||||
</Panel>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
'use client';
|
||||
|
||||
import { CountryFlagDisplay } from '@/lib/display-objects/CountryFlagDisplay';
|
||||
import { Card } from '@/ui/Card';
|
||||
import { Heading } from '@/ui/Heading';
|
||||
import { Panel } from '@/ui/Panel';
|
||||
import { Input } from '@/ui/Input';
|
||||
import { Stack } from '@/ui/primitives/Stack';
|
||||
import { Text } from '@/ui/Text';
|
||||
import { TextArea } from '@/ui/TextArea';
|
||||
import { ProfileStat } from '@/ui/ProfileHero';
|
||||
import React from 'react';
|
||||
|
||||
interface ProfileDetailsPanelProps {
|
||||
driver: {
|
||||
@@ -21,60 +21,45 @@ interface ProfileDetailsPanelProps {
|
||||
export function ProfileDetailsPanel({ driver, isEditing, onUpdate }: ProfileDetailsPanelProps) {
|
||||
if (isEditing) {
|
||||
return (
|
||||
<section aria-labelledby="profile-details-heading">
|
||||
<Card>
|
||||
<Stack gap={6}>
|
||||
<Heading level={3} id="profile-details-heading" fontSize="1.125rem">Profile Details</Heading>
|
||||
<Stack gap={4}>
|
||||
<Input
|
||||
label="Nationality (ISO Code)"
|
||||
value={driver.country}
|
||||
onChange={(e) => onUpdate?.({ country: e.target.value })}
|
||||
placeholder="e.g. US, GB, DE"
|
||||
maxLength={2}
|
||||
/>
|
||||
<TextArea
|
||||
label="Bio"
|
||||
value={driver.bio || ''}
|
||||
onChange={(e) => onUpdate?.({ bio: e.target.value })}
|
||||
placeholder="Tell the community about your racing career..."
|
||||
rows={4}
|
||||
/>
|
||||
</Stack>
|
||||
</Stack>
|
||||
</Card>
|
||||
</section>
|
||||
<Panel title="Profile Details">
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '1.5rem' }}>
|
||||
<Input
|
||||
label="Nationality (ISO Code)"
|
||||
value={driver.country}
|
||||
onChange={(e) => onUpdate?.({ country: e.target.value })}
|
||||
placeholder="e.g. US, GB, DE"
|
||||
/>
|
||||
<TextArea
|
||||
label="Bio"
|
||||
value={driver.bio || ''}
|
||||
onChange={(e) => onUpdate?.({ bio: e.target.value })}
|
||||
placeholder="Tell the community about your racing career..."
|
||||
/>
|
||||
</div>
|
||||
</Panel>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<section aria-labelledby="profile-details-heading">
|
||||
<Card>
|
||||
<Stack gap={6}>
|
||||
<Stack direction="row" justify="between" align="center">
|
||||
<Heading level={3} id="profile-details-heading" fontSize="1.125rem">Profile Details</Heading>
|
||||
</Stack>
|
||||
|
||||
<Stack gap={4}>
|
||||
<Stack gap={1}>
|
||||
<Text size="xs" color="#6b7280" weight="bold" letterSpacing="0.05em" uppercase>Nationality</Text>
|
||||
<Stack direction="row" align="center" gap={2}>
|
||||
<Text size="xl">
|
||||
{CountryFlagDisplay.fromCountryCode(driver.country).toString()}
|
||||
</Text>
|
||||
<Text color="#d1d5db">{driver.country}</Text>
|
||||
</Stack>
|
||||
</Stack>
|
||||
<Panel title="Profile Details">
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '1.5rem' }}>
|
||||
<div>
|
||||
<Text size="xs" variant="low" weight="bold" uppercase block marginBottom={1}>Nationality</Text>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '0.5rem' }}>
|
||||
<Text size="xl">
|
||||
{CountryFlagDisplay.fromCountryCode(driver.country).toString()}
|
||||
</Text>
|
||||
<Text variant="med">{driver.country}</Text>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Stack gap={1}>
|
||||
<Text size="xs" color="#6b7280" weight="bold" letterSpacing="0.05em" uppercase>Bio</Text>
|
||||
<Text color="#d1d5db" lineHeight="relaxed">
|
||||
{driver.bio || 'No bio provided.'}
|
||||
</Text>
|
||||
</Stack>
|
||||
</Stack>
|
||||
</Stack>
|
||||
</Card>
|
||||
</section>
|
||||
<div>
|
||||
<Text size="xs" variant="low" weight="bold" uppercase block marginBottom={1}>Bio</Text>
|
||||
<Text variant="med" leading="relaxed">
|
||||
{driver.bio || 'No bio provided.'}
|
||||
</Text>
|
||||
</div>
|
||||
</div>
|
||||
</Panel>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -5,10 +5,10 @@ import { CountryFlagDisplay } from '@/lib/display-objects/CountryFlagDisplay';
|
||||
import { Button } from '@/ui/Button';
|
||||
import { Heading } from '@/ui/Heading';
|
||||
import { Image } from '@/ui/Image';
|
||||
import { Stack } from '@/ui/primitives/Stack';
|
||||
import { Surface } from '@/ui/primitives/Surface';
|
||||
import { ProfileHero, ProfileAvatar, ProfileStatsGroup, ProfileStat } from '@/ui/ProfileHero';
|
||||
import { Text } from '@/ui/Text';
|
||||
import { Calendar, Globe, Star, Trophy, UserPlus } from 'lucide-react';
|
||||
import React from 'react';
|
||||
|
||||
interface ProfileHeaderProps {
|
||||
driver: {
|
||||
@@ -36,103 +36,63 @@ export function ProfileHeader({
|
||||
isOwnProfile,
|
||||
}: ProfileHeaderProps) {
|
||||
return (
|
||||
<header>
|
||||
<Surface variant="muted" rounded="xl" border padding={6} backgroundColor="#141619" borderColor="#23272B">
|
||||
<Stack direction="row" align="center" gap={8} wrap>
|
||||
{/* Avatar with telemetry-style border */}
|
||||
<Stack position="relative">
|
||||
<Stack
|
||||
width="6rem"
|
||||
height="6rem"
|
||||
rounded="md"
|
||||
border
|
||||
borderColor="#23272B"
|
||||
p={0.5}
|
||||
backgroundColor="#0C0D0F"
|
||||
>
|
||||
<Stack width="100%" height="100%" rounded="sm" overflow="hidden">
|
||||
<Image
|
||||
src={driver.avatarUrl || mediaConfig.avatars.defaultFallback}
|
||||
alt={driver.name}
|
||||
width={96}
|
||||
height={96}
|
||||
objectFit="cover"
|
||||
fullWidth
|
||||
fullHeight
|
||||
/>
|
||||
</Stack>
|
||||
</Stack>
|
||||
</Stack>
|
||||
<ProfileHero variant="muted">
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '2rem', flexWrap: 'wrap' }}>
|
||||
<ProfileAvatar>
|
||||
<Image
|
||||
src={driver.avatarUrl || mediaConfig.avatars.defaultFallback}
|
||||
alt={driver.name}
|
||||
width={96}
|
||||
height={96}
|
||||
objectFit="cover"
|
||||
/>
|
||||
</ProfileAvatar>
|
||||
|
||||
{/* Driver Info */}
|
||||
<Stack flexGrow={1} minWidth="0">
|
||||
<Stack direction="row" align="center" gap={3} mb={1}>
|
||||
<Heading level={1} fontSize="1.5rem">
|
||||
{driver.name}
|
||||
</Heading>
|
||||
<Text size="2xl" aria-label={`Country: ${driver.country}`}>
|
||||
{CountryFlagDisplay.fromCountryCode(driver.country).toString()}
|
||||
<div style={{ flex: 1, minWidth: '200px' }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '0.75rem', marginBottom: '0.25rem' }}>
|
||||
<Heading level={1}>{driver.name}</Heading>
|
||||
<Text size="2xl" aria-label={`Country: ${driver.country}`}>
|
||||
{CountryFlagDisplay.fromCountryCode(driver.country).toString()}
|
||||
</Text>
|
||||
</div>
|
||||
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '1rem' }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '0.375rem' }}>
|
||||
<Globe size={14} color="var(--ui-color-text-low)" />
|
||||
<Text size="xs" font="mono" variant="low">ID: {driver.iracingId}</Text>
|
||||
</div>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '0.375rem' }}>
|
||||
<Calendar size={14} color="var(--ui-color-text-low)" />
|
||||
<Text size="xs" variant="low">
|
||||
Joined {new Date(driver.joinedAt).toLocaleDateString('en-US', { month: 'short', year: 'numeric' })}
|
||||
</Text>
|
||||
</Stack>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Stack direction="row" align="center" gap={4} color="#9ca3af">
|
||||
<Stack direction="row" align="center" gap={1.5}>
|
||||
<Globe size={14} />
|
||||
<Text size="xs" font="mono">ID: {driver.iracingId}</Text>
|
||||
</Stack>
|
||||
<Stack width="1px" height="12px" backgroundColor="#23272B" />
|
||||
<Stack direction="row" align="center" gap={1.5}>
|
||||
<Calendar size={14} />
|
||||
<Text size="xs">
|
||||
Joined {new Date(driver.joinedAt).toLocaleDateString('en-US', { month: 'short', year: 'numeric' })}
|
||||
</Text>
|
||||
</Stack>
|
||||
</Stack>
|
||||
</Stack>
|
||||
|
||||
{/* Stats Grid */}
|
||||
<Stack direction="row" align="center" gap={6}>
|
||||
{stats && (
|
||||
<>
|
||||
<Stack>
|
||||
<Stack gap={0.5}>
|
||||
<Text size="xs" color="#6b7280" weight="bold" letterSpacing="0.05em">RATING</Text>
|
||||
<Stack direction="row" align="center" gap={1.5}>
|
||||
<Star size={14} color="#198CFF" />
|
||||
<Text font="mono" size="lg" weight="bold" color="#198CFF">{stats.rating}</Text>
|
||||
</Stack>
|
||||
</Stack>
|
||||
</Stack>
|
||||
<Stack width="1px" height="32px" backgroundColor="#23272B" />
|
||||
<Stack>
|
||||
<Stack gap={0.5}>
|
||||
<Text size="xs" color="#6b7280" weight="bold" letterSpacing="0.05em">GLOBAL RANK</Text>
|
||||
<Stack direction="row" align="center" gap={1.5}>
|
||||
<Trophy size={14} color="#FFBE4D" />
|
||||
<Text font="mono" size="lg" weight="bold" color="#FFBE4D">#{globalRank}</Text>
|
||||
</Stack>
|
||||
</Stack>
|
||||
</Stack>
|
||||
</>
|
||||
)}
|
||||
</Stack>
|
||||
|
||||
{/* Actions */}
|
||||
{!isOwnProfile && onAddFriend && (
|
||||
<Stack ml="auto">
|
||||
<Button
|
||||
variant={friendRequestSent ? 'secondary' : 'primary'}
|
||||
onClick={onAddFriend}
|
||||
disabled={friendRequestSent}
|
||||
size="sm"
|
||||
icon={<UserPlus size={16} />}
|
||||
>
|
||||
{friendRequestSent ? 'Request Sent' : 'Add Friend'}
|
||||
</Button>
|
||||
</Stack>
|
||||
<ProfileStatsGroup>
|
||||
{stats && (
|
||||
<React.Fragment>
|
||||
<ProfileStat label="RATING" value={stats.rating} intent="primary" />
|
||||
<ProfileStat label="GLOBAL RANK" value={`#${globalRank}`} intent="warning" />
|
||||
</React.Fragment>
|
||||
)}
|
||||
</Stack>
|
||||
</Surface>
|
||||
</header>
|
||||
</ProfileStatsGroup>
|
||||
|
||||
{!isOwnProfile && onAddFriend && (
|
||||
<div style={{ marginLeft: 'auto' }}>
|
||||
<Button
|
||||
variant={friendRequestSent ? 'secondary' : 'primary'}
|
||||
onClick={onAddFriend}
|
||||
disabled={friendRequestSent}
|
||||
size="sm"
|
||||
icon={<UserPlus size={16} />}
|
||||
>
|
||||
{friendRequestSent ? 'Request Sent' : 'Add Friend'}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</ProfileHero>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,8 +1,6 @@
|
||||
'use client';
|
||||
|
||||
import { Heading } from '@/ui/Heading';
|
||||
import { Stack } from '@/ui/primitives/Stack';
|
||||
import { Text } from '@/ui/Text';
|
||||
import { SectionHeader } from '@/ui/SectionHeader';
|
||||
import React from 'react';
|
||||
|
||||
interface ProfileSectionProps {
|
||||
@@ -14,19 +12,14 @@ interface ProfileSectionProps {
|
||||
|
||||
export function ProfileSection({ title, description, action, children }: ProfileSectionProps) {
|
||||
return (
|
||||
<Stack mb={8}>
|
||||
<Stack direction="row" align="center" justify="between" mb={4}>
|
||||
<Stack>
|
||||
<Heading level={2}>{title}</Heading>
|
||||
{description && (
|
||||
<Text color="text-gray-400" size="sm" mt={1} block>
|
||||
{description}
|
||||
</Text>
|
||||
)}
|
||||
</Stack>
|
||||
{action && <Stack>{action}</Stack>}
|
||||
</Stack>
|
||||
<Stack>{children}</Stack>
|
||||
</Stack>
|
||||
<section style={{ marginBottom: '2rem' }}>
|
||||
<SectionHeader
|
||||
title={title}
|
||||
description={description}
|
||||
actions={action}
|
||||
variant="minimal"
|
||||
/>
|
||||
<div>{children}</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
import { Box } from '../../ui/primitives/Box';
|
||||
import { Grid } from '../../ui/primitives/Grid';
|
||||
import { Text } from '../../ui/Text';
|
||||
import { StatGrid } from '../../ui/StatGrid';
|
||||
import { Bug } from 'lucide-react';
|
||||
import React from 'react';
|
||||
|
||||
interface Stat {
|
||||
label: string;
|
||||
value: string | number;
|
||||
color?: string;
|
||||
intent?: 'primary' | 'telemetry' | 'success' | 'critical';
|
||||
}
|
||||
|
||||
interface ProfileStatGridProps {
|
||||
@@ -13,22 +13,18 @@ interface ProfileStatGridProps {
|
||||
}
|
||||
|
||||
export function ProfileStatGrid({ stats }: ProfileStatGridProps) {
|
||||
const mappedStats = stats.map(stat => ({
|
||||
label: stat.label,
|
||||
value: stat.value,
|
||||
intent: stat.intent || 'primary',
|
||||
icon: Bug // Default icon if none provided, but StatBox requires one
|
||||
}));
|
||||
|
||||
return (
|
||||
<Grid cols={2} mdCols={4} gap={4}>
|
||||
{stats.map((stat, idx) => (
|
||||
<Box
|
||||
key={idx}
|
||||
p={4}
|
||||
bg="bg-[#0f1115]"
|
||||
rounded="xl"
|
||||
border
|
||||
borderColor="border-[#262626]"
|
||||
textAlign="center"
|
||||
>
|
||||
<Text size="3xl" weight="bold" color={stat.color} block mb={1}>{stat.value}</Text>
|
||||
<Text size="xs" color="text-gray-500" uppercase letterSpacing="0.05em">{stat.label}</Text>
|
||||
</Box>
|
||||
))}
|
||||
</Grid>
|
||||
<StatGrid
|
||||
stats={mappedStats}
|
||||
columns={{ base: 2, md: 4 }}
|
||||
variant="box"
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,10 +1,9 @@
|
||||
'use client';
|
||||
|
||||
|
||||
import { Button } from '@/ui/Button';
|
||||
import { SegmentedControl } from '@/ui/SegmentedControl';
|
||||
import { Icon } from '@/ui/Icon';
|
||||
import { Box } from '@/ui/primitives/Box';
|
||||
import { Surface } from '@/ui/primitives/Surface';
|
||||
import { BarChart3, TrendingUp, User } from 'lucide-react';
|
||||
import React from 'react';
|
||||
|
||||
export type ProfileTab = 'overview' | 'stats' | 'ratings';
|
||||
|
||||
@@ -14,34 +13,17 @@ interface ProfileTabsProps {
|
||||
}
|
||||
|
||||
export function ProfileTabs({ activeTab, onTabChange }: ProfileTabsProps) {
|
||||
const options = [
|
||||
{ id: 'overview', label: 'Overview', icon: <Icon icon={User} size={4} /> },
|
||||
{ id: 'stats', label: 'Detailed Stats', icon: <Icon icon={BarChart3} size={4} /> },
|
||||
{ id: 'ratings', label: 'Ratings', icon: <Icon icon={TrendingUp} size={4} /> },
|
||||
];
|
||||
|
||||
return (
|
||||
<Surface variant="muted" rounded="xl" padding={1} style={{ backgroundColor: 'rgba(38, 38, 38, 0.5)', border: '1px solid #262626', width: 'fit-content' }}>
|
||||
<Box style={{ display: 'flex', alignItems: 'center', gap: '0.25rem' }}>
|
||||
<Button
|
||||
variant={activeTab === 'overview' ? 'primary' : 'ghost'}
|
||||
onClick={() => onTabChange('overview')}
|
||||
size="sm"
|
||||
icon={<Icon icon={User} size={4} />}
|
||||
>
|
||||
Overview
|
||||
</Button>
|
||||
<Button
|
||||
variant={activeTab === 'stats' ? 'primary' : 'ghost'}
|
||||
onClick={() => onTabChange('stats')}
|
||||
size="sm"
|
||||
icon={<Icon icon={BarChart3} size={4} />}
|
||||
>
|
||||
Detailed Stats
|
||||
</Button>
|
||||
<Button
|
||||
variant={activeTab === 'ratings' ? 'primary' : 'ghost'}
|
||||
onClick={() => onTabChange('ratings')}
|
||||
size="sm"
|
||||
icon={<Icon icon={TrendingUp} size={4} />}
|
||||
>
|
||||
Ratings
|
||||
</Button>
|
||||
</Box>
|
||||
</Surface>
|
||||
<SegmentedControl
|
||||
options={options}
|
||||
activeId={activeTab}
|
||||
onChange={(id) => onTabChange(id as ProfileTab)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -8,8 +8,8 @@ import { DriverViewModel as DriverViewModelClass } from '@/lib/view-models/Drive
|
||||
import { Icon } from '@/ui/Icon';
|
||||
import { Image } from '@/ui/Image';
|
||||
import { Link } from '@/ui/Link';
|
||||
import { Box } from '@/ui/primitives/Box';
|
||||
import { Text } from '@/ui/Text';
|
||||
import { UserDropdown, UserDropdownHeader, UserDropdownItem, UserDropdownFooter } from '@/ui/UserDropdown';
|
||||
import { AnimatePresence, motion } from 'framer-motion';
|
||||
import {
|
||||
BarChart3,
|
||||
@@ -22,7 +22,7 @@ import {
|
||||
Settings,
|
||||
Shield
|
||||
} from 'lucide-react';
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import React, { useEffect, useMemo, useState } from 'react';
|
||||
|
||||
// Hook to detect demo user mode based on session
|
||||
function useDemoUserMode(): { isDemo: boolean; demoRole: string | null } {
|
||||
@@ -143,32 +143,20 @@ export function UserPill() {
|
||||
// Handle unauthenticated users
|
||||
if (!session) {
|
||||
return (
|
||||
<Box display="flex" alignItems="center" gap={2}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '0.5rem' }}>
|
||||
<Link
|
||||
href={routes.auth.login}
|
||||
variant="secondary"
|
||||
rounded="full"
|
||||
px={4}
|
||||
py={1.5}
|
||||
size="xs"
|
||||
hoverTextColor="text-white"
|
||||
hoverBorderColor="border-gray-500"
|
||||
>
|
||||
Sign In
|
||||
</Link>
|
||||
<Link
|
||||
href={routes.auth.signup}
|
||||
variant="primary"
|
||||
rounded="full"
|
||||
px={4}
|
||||
py={1.5}
|
||||
size="xs"
|
||||
shadow="0 0 12px rgba(25,140,255,0.5)"
|
||||
hoverBg="rgba(25,140,255,0.9)"
|
||||
>
|
||||
Get Started
|
||||
</Link>
|
||||
</Box>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -186,292 +174,123 @@ export function UserPill() {
|
||||
'super-admin': 'Super Admin',
|
||||
} as Record<string, string>)[demoRole || 'driver'] : null;
|
||||
|
||||
const roleColor = isDemo ? ({
|
||||
'driver': 'text-primary-blue',
|
||||
'sponsor': 'text-performance-green',
|
||||
'league-owner': 'text-purple-400',
|
||||
'league-steward': 'text-warning-amber',
|
||||
'league-admin': 'text-red-400',
|
||||
'system-owner': 'text-indigo-400',
|
||||
'super-admin': 'text-pink-400',
|
||||
} as Record<string, string>)[demoRole || 'driver'] : null;
|
||||
const roleIntent = isDemo ? ({
|
||||
'driver': 'primary',
|
||||
'sponsor': 'success',
|
||||
'league-owner': 'primary',
|
||||
'league-steward': 'warning',
|
||||
'league-admin': 'critical',
|
||||
'system-owner': 'primary',
|
||||
'super-admin': 'primary',
|
||||
} as Record<string, 'primary' | 'success' | 'warning' | 'critical'>)[demoRole || 'driver'] : 'low';
|
||||
|
||||
return (
|
||||
<Box position="relative" display="inline-flex" alignItems="center" data-user-pill>
|
||||
<Box
|
||||
as="button"
|
||||
<div style={{ position: 'relative', display: 'inline-flex', alignItems: 'center' }} data-user-pill>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setIsMenuOpen((open) => !open)}
|
||||
display="flex"
|
||||
alignItems="center"
|
||||
gap={3}
|
||||
rounded="full"
|
||||
border
|
||||
px={3}
|
||||
py={1.5}
|
||||
transition
|
||||
cursor="pointer"
|
||||
bg="linear-gradient(to r, var(--iron-gray), var(--deep-graphite))"
|
||||
borderColor={isMenuOpen ? 'border-primary-blue/50' : 'border-charcoal-outline'}
|
||||
transform={isMenuOpen ? 'scale(1.02)' : 'scale(1)'}
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '0.75rem',
|
||||
borderRadius: '9999px',
|
||||
border: `1px solid ${isMenuOpen ? 'var(--ui-color-intent-primary)' : 'var(--ui-color-border-default)'}`,
|
||||
padding: '0.375rem 0.75rem',
|
||||
background: 'var(--ui-color-bg-surface-muted)',
|
||||
cursor: 'pointer'
|
||||
}}
|
||||
>
|
||||
{/* Avatar */}
|
||||
<Box position="relative">
|
||||
<div style={{ position: 'relative' }}>
|
||||
{avatarUrl ? (
|
||||
<Box w="8" h="8" rounded="full" overflow="hidden" bg="bg-charcoal-outline" display="flex" alignItems="center" justifyContent="center" border borderColor="border-charcoal-outline/80">
|
||||
<div style={{ width: '2rem', height: '2rem', borderRadius: '9999px', overflow: 'hidden', border: '1px solid var(--ui-color-border-default)' }}>
|
||||
<Image
|
||||
src={avatarUrl}
|
||||
alt={displayName}
|
||||
objectFit="cover"
|
||||
fill
|
||||
/>
|
||||
</Box>
|
||||
</div>
|
||||
) : (
|
||||
<Box w="8" h="8" rounded="full" bg="bg-primary-blue/20" border borderColor="border-primary-blue/30" display="flex" alignItems="center" justifyContent="center">
|
||||
<Text size="xs" weight="bold" color="text-primary-blue">
|
||||
<div style={{ width: '2rem', height: '2rem', borderRadius: '9999px', backgroundColor: 'var(--ui-color-intent-primary)', display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
|
||||
<Text size="xs" weight="bold" variant="high">
|
||||
{displayName[0]?.toUpperCase() || 'U'}
|
||||
</Text>
|
||||
</Box>
|
||||
</div>
|
||||
)}
|
||||
<Box position="absolute" bottom="-0.5" right="-0.5" w="3" h="3" rounded="full" bg="bg-primary-blue" border borderColor="border-deep-graphite" borderWidth="2px" />
|
||||
</Box>
|
||||
</div>
|
||||
|
||||
{/* Info */}
|
||||
<Box display={{ base: 'none', sm: 'flex' }} flexDirection="col" alignItems="start">
|
||||
<Text size="xs" weight="semibold" color="text-white" truncate maxWidth="100px" block>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'start' }}>
|
||||
<Text size="xs" weight="semibold" variant="high" truncate style={{ maxWidth: '100px' }}>
|
||||
{displayName}
|
||||
</Text>
|
||||
{roleLabel && (
|
||||
<Text size="xs" color={roleColor || 'text-gray-400'} weight="medium" fontSize="10px">
|
||||
<Text size="xs" variant={roleIntent as any} weight="medium" style={{ fontSize: '10px' }}>
|
||||
{roleLabel}
|
||||
</Text>
|
||||
)}
|
||||
</Box>
|
||||
</div>
|
||||
|
||||
{/* Chevron */}
|
||||
<Icon icon={ChevronDown} size={3.5} color="rgb(107, 114, 128)" groupHoverTextColor="text-gray-300" />
|
||||
</Box>
|
||||
<Icon icon={ChevronDown} size={3.5} intent="low" />
|
||||
</button>
|
||||
|
||||
<AnimatePresence>
|
||||
{isMenuOpen && (
|
||||
<Box
|
||||
as={motion.div}
|
||||
initial={{ opacity: 0, y: -10, scale: 0.95 }}
|
||||
animate={{ opacity: 1, y: 0, scale: 1 }}
|
||||
exit={{ opacity: 0, y: -10, scale: 0.95 }}
|
||||
transition={{ duration: 0.15 }}
|
||||
position="absolute"
|
||||
right={0}
|
||||
top="100%"
|
||||
mt={2}
|
||||
zIndex={50}
|
||||
>
|
||||
<Box w="56" rounded="xl" bg="bg-deep-graphite" border borderColor="border-charcoal-outline" shadow="xl" overflow="hidden">
|
||||
{/* Header */}
|
||||
<Box p={4} borderBottom borderColor="border-charcoal-outline" bg={`linear-gradient(to r, ${isDemo ? 'rgba(59, 130, 246, 0.1)' : 'rgba(38, 38, 38, 0.2)'}, transparent)`}>
|
||||
<Box display="flex" alignItems="center" gap={3}>
|
||||
{avatarUrl ? (
|
||||
<Box w="10" h="10" rounded="lg" overflow="hidden" bg="bg-charcoal-outline" display="flex" alignItems="center" justifyContent="center" border borderColor="border-charcoal-outline/80">
|
||||
<Image
|
||||
src={avatarUrl}
|
||||
alt={displayName}
|
||||
objectFit="cover"
|
||||
fill
|
||||
/>
|
||||
</Box>
|
||||
) : (
|
||||
<Box w="10" h="10" rounded="lg" bg="bg-primary-blue/20" border borderColor="border-primary-blue/30" display="flex" alignItems="center" justifyContent="center">
|
||||
<Text size="xs" weight="bold" color="text-primary-blue">
|
||||
{displayName[0]?.toUpperCase() || 'U'}
|
||||
</Text>
|
||||
</Box>
|
||||
)}
|
||||
<Box>
|
||||
<Text size="sm" weight="semibold" color="text-white" block>{displayName}</Text>
|
||||
{roleLabel && (
|
||||
<Text size="xs" color={roleColor || 'text-gray-400'} block>{roleLabel}</Text>
|
||||
)}
|
||||
{isDemo && (
|
||||
<Text size="xs" color="text-gray-500" block>Demo Account</Text>
|
||||
)}
|
||||
</Box>
|
||||
</Box>
|
||||
{isDemo && (
|
||||
<Text size="xs" color="text-gray-500" block mt={2}>
|
||||
Development account - not for production use
|
||||
</Text>
|
||||
)}
|
||||
</Box>
|
||||
<UserDropdown isOpen={isMenuOpen}>
|
||||
<UserDropdownHeader variant={isDemo ? 'demo' : 'default'}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '0.75rem' }}>
|
||||
{avatarUrl ? (
|
||||
<div style={{ width: '2.5rem', height: '2.5rem', borderRadius: '0.5rem', overflow: 'hidden', border: '1px solid var(--ui-color-border-default)' }}>
|
||||
<Image
|
||||
src={avatarUrl}
|
||||
alt={displayName}
|
||||
objectFit="cover"
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<div style={{ width: '2.5rem', height: '2.5rem', borderRadius: '0.5rem', backgroundColor: 'var(--ui-color-intent-primary)', display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
|
||||
<Text size="xs" weight="bold" variant="high">
|
||||
{displayName[0]?.toUpperCase() || 'U'}
|
||||
</Text>
|
||||
</div>
|
||||
)}
|
||||
<div>
|
||||
<Text size="sm" weight="semibold" variant="high" block>{displayName}</Text>
|
||||
{roleLabel && (
|
||||
<Text size="xs" variant="low" block>{roleLabel}</Text>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</UserDropdownHeader>
|
||||
|
||||
{/* Menu Items */}
|
||||
<Box py={1}>
|
||||
{/* Admin link for Owner/Super Admin users */}
|
||||
{hasAdminAccess && (
|
||||
<Link
|
||||
href="/admin"
|
||||
block
|
||||
px={4}
|
||||
py={2.5}
|
||||
onClick={() => setIsMenuOpen(false)}
|
||||
>
|
||||
<Icon icon={Shield} size={4} color="rgb(129, 140, 248)" mr={3} />
|
||||
<Text size="sm" color="text-gray-200">Admin Area</Text>
|
||||
</Link>
|
||||
)}
|
||||
|
||||
{/* Sponsor portal link for demo sponsor users */}
|
||||
{isDemo && demoRole === 'sponsor' && (
|
||||
<>
|
||||
<Link
|
||||
href="/sponsor"
|
||||
block
|
||||
px={4}
|
||||
py={2.5}
|
||||
onClick={() => setIsMenuOpen(false)}
|
||||
>
|
||||
<Icon icon={BarChart3} size={4} color="rgb(16, 185, 129)" mr={3} />
|
||||
<Text size="sm" color="text-gray-200">Dashboard</Text>
|
||||
</Link>
|
||||
<Link
|
||||
href={routes.sponsor.campaigns}
|
||||
block
|
||||
px={4}
|
||||
py={2.5}
|
||||
onClick={() => setIsMenuOpen(false)}
|
||||
>
|
||||
<Icon icon={Megaphone} size={4} color="rgb(59, 130, 246)" mr={3} />
|
||||
<Text size="sm" color="text-gray-200">My Sponsorships</Text>
|
||||
</Link>
|
||||
<Link
|
||||
href={routes.sponsor.billing}
|
||||
block
|
||||
px={4}
|
||||
py={2.5}
|
||||
onClick={() => setIsMenuOpen(false)}
|
||||
>
|
||||
<Icon icon={CreditCard} size={4} color="rgb(245, 158, 11)" mr={3} />
|
||||
<Text size="sm" color="text-gray-200">Billing</Text>
|
||||
</Link>
|
||||
<Link
|
||||
href={routes.sponsor.settings}
|
||||
block
|
||||
px={4}
|
||||
py={2.5}
|
||||
onClick={() => setIsMenuOpen(false)}
|
||||
>
|
||||
<Icon icon={Settings} size={4} color="rgb(156, 163, 175)" mr={3} />
|
||||
<Text size="sm" color="text-gray-200">Settings</Text>
|
||||
</Link>
|
||||
</>
|
||||
)}
|
||||
<div style={{ padding: '0.25rem 0' }}>
|
||||
{hasAdminAccess && (
|
||||
<UserDropdownItem href="/admin" icon={Shield} label="Admin Area" intent="primary" onClick={() => setIsMenuOpen(false)} />
|
||||
)}
|
||||
|
||||
{isDemo && demoRole === 'sponsor' && (
|
||||
<React.Fragment>
|
||||
<UserDropdownItem href="/sponsor" icon={BarChart3} label="Dashboard" onClick={() => setIsMenuOpen(false)} />
|
||||
<UserDropdownItem href={routes.sponsor.campaigns} icon={Megaphone} label="My Sponsorships" onClick={() => setIsMenuOpen(false)} />
|
||||
<UserDropdownItem href={routes.sponsor.billing} icon={CreditCard} label="Billing" onClick={() => setIsMenuOpen(false)} />
|
||||
</React.Fragment>
|
||||
)}
|
||||
|
||||
{/* Regular user profile links */}
|
||||
<Link
|
||||
href={routes.protected.profile}
|
||||
block
|
||||
px={4}
|
||||
py={2.5}
|
||||
onClick={() => setIsMenuOpen(false)}
|
||||
>
|
||||
<Text size="sm" color="text-gray-200">Profile</Text>
|
||||
</Link>
|
||||
<Link
|
||||
href={routes.protected.profileLeagues}
|
||||
block
|
||||
px={4}
|
||||
py={2.5}
|
||||
onClick={() => setIsMenuOpen(false)}
|
||||
>
|
||||
<Text size="sm" color="text-gray-200">Manage leagues</Text>
|
||||
</Link>
|
||||
<Link
|
||||
href={routes.protected.profileLiveries}
|
||||
block
|
||||
px={4}
|
||||
py={2.5}
|
||||
onClick={() => setIsMenuOpen(false)}
|
||||
>
|
||||
<Icon icon={Paintbrush} size={4} mr={2} />
|
||||
<Text size="sm" color="text-gray-200">Liveries</Text>
|
||||
</Link>
|
||||
<Link
|
||||
href={routes.protected.profileSponsorshipRequests}
|
||||
block
|
||||
px={4}
|
||||
py={2.5}
|
||||
onClick={() => setIsMenuOpen(false)}
|
||||
>
|
||||
<Icon icon={Handshake} size={4} color="rgb(16, 185, 129)" mr={2} />
|
||||
<Text size="sm" color="text-gray-200">Sponsorship Requests</Text>
|
||||
</Link>
|
||||
<Link
|
||||
href={routes.protected.profileSettings}
|
||||
block
|
||||
px={4}
|
||||
py={2.5}
|
||||
onClick={() => setIsMenuOpen(false)}
|
||||
>
|
||||
<Icon icon={Settings} size={4} mr={2} />
|
||||
<Text size="sm" color="text-gray-200">Settings</Text>
|
||||
</Link>
|
||||
<UserDropdownItem href={routes.protected.profile} label="Profile" onClick={() => setIsMenuOpen(false)} />
|
||||
<UserDropdownItem href={routes.protected.profileLeagues} label="Manage leagues" onClick={() => setIsMenuOpen(false)} />
|
||||
<UserDropdownItem href={routes.protected.profileLiveries} icon={Paintbrush} label="Liveries" onClick={() => setIsMenuOpen(false)} />
|
||||
<UserDropdownItem href={routes.protected.profileSponsorshipRequests} icon={Handshake} label="Sponsorship Requests" intent="success" onClick={() => setIsMenuOpen(false)} />
|
||||
<UserDropdownItem href={routes.protected.profileSettings} icon={Settings} label="Settings" onClick={() => setIsMenuOpen(false)} />
|
||||
</div>
|
||||
|
||||
{/* Demo-specific info */}
|
||||
{isDemo && (
|
||||
<Box px={4} py={2} borderTop borderColor="border-charcoal-outline/50" mt={1}>
|
||||
<Text size="xs" color="text-gray-500" italic>Demo users have limited profile access</Text>
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
|
||||
{/* Footer */}
|
||||
<Box borderTop borderColor="border-charcoal-outline">
|
||||
{isDemo ? (
|
||||
<Box
|
||||
as="button"
|
||||
type="button"
|
||||
onClick={handleLogout}
|
||||
display="flex"
|
||||
alignItems="center"
|
||||
justifyContent="between"
|
||||
fullWidth
|
||||
px={4}
|
||||
py={3}
|
||||
cursor="pointer"
|
||||
transition
|
||||
bg="transparent"
|
||||
hoverBg="rgba(239, 68, 68, 0.05)"
|
||||
hoverColor="text-racing-red"
|
||||
>
|
||||
<Text size="sm" color="text-gray-500">Logout</Text>
|
||||
<Icon icon={LogOut} size={4} color="rgb(107, 114, 128)" />
|
||||
</Box>
|
||||
) : (
|
||||
<Box as="form" action="/auth/logout" method="POST">
|
||||
<Box
|
||||
as="button"
|
||||
type="submit"
|
||||
display="flex"
|
||||
alignItems="center"
|
||||
justifyContent="between"
|
||||
fullWidth
|
||||
px={4}
|
||||
py={3}
|
||||
cursor="pointer"
|
||||
transition
|
||||
bg="transparent"
|
||||
hoverBg="rgba(239, 68, 68, 0.1)"
|
||||
hoverColor="text-red-400"
|
||||
>
|
||||
<Text size="sm" color="text-gray-500">Logout</Text>
|
||||
<Icon icon={LogOut} size={4} color="rgb(107, 114, 128)" />
|
||||
</Box>
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
</Box>
|
||||
</Box>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</Box>
|
||||
<UserDropdownFooter>
|
||||
<UserDropdownItem
|
||||
icon={LogOut}
|
||||
label="Logout"
|
||||
intent="critical"
|
||||
onClick={isDemo ? handleLogout : undefined}
|
||||
/>
|
||||
</UserDropdownFooter>
|
||||
</UserDropdown>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,12 +1,13 @@
|
||||
'use client';
|
||||
|
||||
import { Badge } from '@/ui/Badge';
|
||||
import { Heading } from '@/ui/Heading';
|
||||
import { Icon } from '@/ui/Icon';
|
||||
import { Link } from '@/ui/Link';
|
||||
import { Stack } from '@/ui/primitives/Stack';
|
||||
import { Text } from '@/ui/Text';
|
||||
import { ArrowRight, Car, ChevronRight, LucideIcon, Trophy, Zap } from 'lucide-react';
|
||||
import { RaceCard, RaceTimeColumn, RaceInfo } from '@/ui/RaceCard';
|
||||
import { Car, Trophy, Zap, ArrowRight } from 'lucide-react';
|
||||
import React from 'react';
|
||||
import { LucideIcon } from 'lucide-react';
|
||||
|
||||
interface RaceListItemProps {
|
||||
track: string;
|
||||
@@ -41,103 +42,52 @@ export function RaceListItem({
|
||||
onClick,
|
||||
statusConfig,
|
||||
}: RaceListItemProps) {
|
||||
const StatusIcon = statusConfig.icon;
|
||||
const isLive = status === 'running';
|
||||
|
||||
return (
|
||||
<Stack
|
||||
onClick={onClick}
|
||||
position="relative"
|
||||
overflow="hidden"
|
||||
rounded="xl"
|
||||
bg="bg-surface-charcoal"
|
||||
border
|
||||
borderColor="border-outline-steel"
|
||||
p={4}
|
||||
cursor="pointer"
|
||||
transition
|
||||
hoverScale
|
||||
group
|
||||
>
|
||||
{/* Live indicator */}
|
||||
{status === 'running' && (
|
||||
<Stack
|
||||
position="absolute"
|
||||
top="0"
|
||||
left="0"
|
||||
right="0"
|
||||
h="1"
|
||||
bg="bg-success-green"
|
||||
animate="pulse"
|
||||
/>
|
||||
)}
|
||||
<RaceCard onClick={onClick} isLive={isLive}>
|
||||
<RaceTimeColumn
|
||||
date={dateLabel}
|
||||
time={dayLabel || timeLabel || ''}
|
||||
relativeTime={relativeTimeLabel || timeLabel}
|
||||
isLive={isLive}
|
||||
/>
|
||||
|
||||
<Stack direction="row" align="center" gap={4}>
|
||||
{/* Time/Date Column */}
|
||||
<Stack flexShrink={0} textAlign="center" width="16">
|
||||
{dateLabel && (
|
||||
<Text size="xs" color="text-gray-500" block uppercase>
|
||||
{dateLabel}
|
||||
</Text>
|
||||
)}
|
||||
<Text size={dayLabel ? "2xl" : "lg"} weight="bold" color="text-white" block>
|
||||
{dayLabel || timeLabel}
|
||||
</Text>
|
||||
<Text size="xs" color={status === 'running' ? 'text-success-green' : 'text-gray-400'} block>
|
||||
{status === 'running' ? 'LIVE' : relativeTimeLabel || timeLabel}
|
||||
</Text>
|
||||
</Stack>
|
||||
<div style={{ width: '1px', height: '2.5rem', backgroundColor: 'var(--ui-color-border-muted)', opacity: 0.2 }} />
|
||||
|
||||
{/* Divider */}
|
||||
<Stack w="px" h="10" alignSelf="stretch" bg="border-outline-steel" />
|
||||
|
||||
{/* Main Content */}
|
||||
<Stack flexGrow={1} minWidth="0">
|
||||
<Stack direction="row" align="start" justify="between" gap={4}>
|
||||
<Stack minWidth="0">
|
||||
<Heading level={3} truncate groupHoverTextColor="text-primary-accent" transition>
|
||||
{track}
|
||||
</Heading>
|
||||
<Stack direction="row" align="center" gap={3} mt={1}>
|
||||
<Stack direction="row" align="center" gap={1}>
|
||||
<Icon icon={Car} size={3.5} color="var(--text-gray-400)" />
|
||||
<Text size="sm" color="text-gray-400">{car}</Text>
|
||||
</Stack>
|
||||
{strengthOfField && (
|
||||
<Stack direction="row" align="center" gap={1}>
|
||||
<Icon icon={Zap} size={3.5} color="var(--warning-amber)" />
|
||||
<Text size="sm" color="text-gray-400">SOF {strengthOfField}</Text>
|
||||
</Stack>
|
||||
)}
|
||||
</Stack>
|
||||
</Stack>
|
||||
|
||||
{/* Status Badge */}
|
||||
<Badge variant={statusConfig.variant}>
|
||||
<Icon icon={StatusIcon} size={3.5} />
|
||||
{statusConfig.label}
|
||||
</Badge>
|
||||
</Stack>
|
||||
|
||||
{/* League Link */}
|
||||
{leagueName && leagueHref && (
|
||||
<Stack mt={3} pt={3} borderTop borderColor="border-outline-steel" borderOpacity={0.3}>
|
||||
<RaceInfo
|
||||
title={track}
|
||||
subtitle={car}
|
||||
badge={
|
||||
<Badge variant={statusConfig.variant}>
|
||||
<Icon icon={statusConfig.icon} size={3.5} />
|
||||
{statusConfig.label}
|
||||
</Badge>
|
||||
}
|
||||
meta={
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '1rem' }}>
|
||||
{strengthOfField && (
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '0.25rem' }}>
|
||||
<Icon icon={Zap} size={3.5} intent="warning" />
|
||||
<Text size="sm" variant="low">SOF {strengthOfField}</Text>
|
||||
</div>
|
||||
)}
|
||||
{leagueName && leagueHref && (
|
||||
<Link
|
||||
href={leagueHref}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
variant="primary"
|
||||
size="sm"
|
||||
>
|
||||
<Icon icon={Trophy} size={3.5} mr={2} color="var(--primary-accent)" />
|
||||
<Text as="span" color="text-primary-accent">{leagueName}</Text>
|
||||
<Icon icon={ArrowRight} size={3} ml={2} color="var(--primary-accent)" />
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '0.25rem' }}>
|
||||
<Icon icon={Trophy} size={3.5} intent="primary" />
|
||||
<Text size="sm" variant="primary">{leagueName}</Text>
|
||||
<Icon icon={ArrowRight} size={3} intent="primary" />
|
||||
</div>
|
||||
</Link>
|
||||
</Stack>
|
||||
)}
|
||||
</Stack>
|
||||
|
||||
{/* Arrow */}
|
||||
<Icon icon={ChevronRight} size={5} color="var(--text-gray-500)" flexShrink={0} groupHoverTextColor="text-primary-accent" transition />
|
||||
</Stack>
|
||||
</Stack>
|
||||
)}
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
</RaceCard>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
|
||||
'use client';
|
||||
|
||||
import { CountryFlagDisplay } from '@/lib/display-objects/CountryFlagDisplay';
|
||||
import { Image } from '@/ui/Image';
|
||||
import { Stack } from '@/ui/primitives/Stack';
|
||||
import { Surface } from '@/ui/primitives/Surface';
|
||||
import { ResultRow, PositionBadge, ResultPoints } from '@/ui/ResultRow';
|
||||
import { Text } from '@/ui/Text';
|
||||
import { Badge } from '@/ui/Badge';
|
||||
import React from 'react';
|
||||
|
||||
interface ResultEntry {
|
||||
position: number;
|
||||
@@ -29,78 +30,45 @@ interface RaceResultRowProps {
|
||||
export function RaceResultRow({ result, points }: RaceResultRowProps) {
|
||||
const { isCurrentUser, position, driverAvatar, driverName, country, car, laps, incidents, time, fastestLap } = result;
|
||||
|
||||
const getPositionColor = (pos: number) => {
|
||||
if (pos === 1) return { bg: 'bg-yellow-500/20', color: 'text-yellow-400' };
|
||||
if (pos === 2) return { bg: 'bg-gray-400/20', color: 'text-gray-300' };
|
||||
if (pos === 3) return { bg: 'bg-amber-600/20', color: 'text-amber-600' };
|
||||
return { bg: 'bg-iron-gray/50', color: 'text-gray-500' };
|
||||
};
|
||||
|
||||
const posConfig = getPositionColor(position);
|
||||
|
||||
return (
|
||||
<Surface
|
||||
variant={isCurrentUser ? 'muted' : 'dark'}
|
||||
rounded="xl"
|
||||
border={isCurrentUser}
|
||||
padding={3}
|
||||
className={isCurrentUser ? 'border-primary-blue/40' : ''}
|
||||
style={isCurrentUser ? { background: 'linear-gradient(to right, rgba(59, 130, 246, 0.2), rgba(59, 130, 246, 0.1), transparent)' } : {}}
|
||||
>
|
||||
<Stack direction="row" align="center" gap={3}>
|
||||
{/* Position */}
|
||||
<Stack
|
||||
width="10"
|
||||
height="10"
|
||||
rounded="lg"
|
||||
display="flex"
|
||||
center
|
||||
className={`${posConfig.bg} ${posConfig.color}`}
|
||||
>
|
||||
<Text weight="bold">{position}</Text>
|
||||
</Stack>
|
||||
<ResultRow isHighlighted={isCurrentUser}>
|
||||
<PositionBadge position={position} />
|
||||
|
||||
{/* Avatar */}
|
||||
<Stack position="relative" flexShrink={0}>
|
||||
<Stack width="10" height="10" rounded="full" overflow="hidden" border={isCurrentUser} borderColor="border-primary-blue/50" className={isCurrentUser ? 'border-2' : ''}>
|
||||
<Image src={driverAvatar} alt={driverName} width={40} height={40} fullWidth fullHeight objectFit="cover" />
|
||||
</Stack>
|
||||
<Stack position="absolute" bottom="-0.5" right="-0.5" width="5" height="5" rounded="full" bg="bg-deep-graphite" display="flex" center style={{ fontSize: '0.625rem' }}>
|
||||
{CountryFlagDisplay.fromCountryCode(country).toString()}
|
||||
</Stack>
|
||||
</Stack>
|
||||
{/* Avatar */}
|
||||
<div style={{ position: 'relative', flexShrink: 0 }}>
|
||||
<div style={{ width: '2.5rem', height: '2.5rem', borderRadius: '9999px', overflow: 'hidden', border: isCurrentUser ? '2px solid var(--ui-color-intent-primary)' : '1px solid var(--ui-color-border-default)' }}>
|
||||
<Image src={driverAvatar} alt={driverName} width={40} height={40} objectFit="cover" />
|
||||
</div>
|
||||
<div style={{ position: 'absolute', bottom: '-0.125rem', right: '-0.125rem', width: '1.25rem', height: '1.25rem', borderRadius: '9999px', backgroundColor: 'var(--ui-color-bg-base)', display: 'flex', alignItems: 'center', justifyContent: 'center', fontSize: '0.625rem' }}>
|
||||
{CountryFlagDisplay.fromCountryCode(country).toString()}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Driver Info */}
|
||||
<Stack flexGrow={1} minWidth="0">
|
||||
<Stack direction="row" align="center" gap={2}>
|
||||
<Text weight="semibold" size="sm" color={isCurrentUser ? 'text-primary-blue' : 'text-white'} truncate>{driverName}</Text>
|
||||
{isCurrentUser && (
|
||||
<Stack px={2} py={0.5} rounded="full" bg="bg-primary-blue">
|
||||
<Text size="xs" weight="bold" color="text-white">YOU</Text>
|
||||
</Stack>
|
||||
)}
|
||||
</Stack>
|
||||
<Stack direction="row" align="center" gap={2} mt={1}>
|
||||
<Text size="xs" color="text-gray-500">{car}</Text>
|
||||
<Text size="xs" color="text-gray-500">•</Text>
|
||||
<Text size="xs" color="text-gray-500">Laps: {laps}</Text>
|
||||
<Text size="xs" color="text-gray-500">•</Text>
|
||||
<Text size="xs" color="text-gray-500">Incidents: {incidents}</Text>
|
||||
</Stack>
|
||||
</Stack>
|
||||
{/* Driver Info */}
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '0.5rem' }}>
|
||||
<Text weight="semibold" size="sm" variant={isCurrentUser ? 'primary' : 'high'} truncate>{driverName}</Text>
|
||||
{isCurrentUser && (
|
||||
<Badge variant="primary" size="sm">YOU</Badge>
|
||||
)}
|
||||
</div>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '0.5rem', marginTop: '0.25rem' }}>
|
||||
<Text size="xs" variant="low">{car}</Text>
|
||||
<Text size="xs" variant="low">•</Text>
|
||||
<Text size="xs" variant="low">Laps: {laps}</Text>
|
||||
<Text size="xs" variant="low">•</Text>
|
||||
<Text size="xs" variant="low">Incidents: {incidents}</Text>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Times */}
|
||||
<Stack textAlign="right" style={{ minWidth: '100px' }}>
|
||||
<Text size="sm" font="mono" color="text-white" block>{time}</Text>
|
||||
<Text size="xs" color="text-performance-green" block mt={1}>FL: {fastestLap}</Text>
|
||||
</Stack>
|
||||
{/* Times */}
|
||||
<div style={{ textAlign: 'right', minWidth: '100px' }}>
|
||||
<Text size="sm" font="mono" variant="high" block>{time}</Text>
|
||||
<Text size="xs" variant="success" block style={{ marginTop: '0.25rem' }}>FL: {fastestLap}</Text>
|
||||
</div>
|
||||
|
||||
{/* Points */}
|
||||
<Stack p={2} rounded="lg" border={true} borderColor="border-warning-amber/20" bg="bg-warning-amber/10" textAlign="center" style={{ minWidth: '3.5rem' }}>
|
||||
<Text size="xs" color="text-gray-500" block>PTS</Text>
|
||||
<Text size="sm" weight="bold" color="text-warning-amber">{points}</Text>
|
||||
</Stack>
|
||||
</Stack>
|
||||
</Surface>
|
||||
{/* Points */}
|
||||
<ResultPoints points={points} />
|
||||
</ResultRow>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
|
||||
'use client';
|
||||
|
||||
import { Badge } from '@/ui/Badge';
|
||||
import { Icon } from '@/ui/Icon';
|
||||
import { Link } from '@/ui/Link';
|
||||
import { Stack } from '@/ui/primitives/Stack';
|
||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/ui/Table';
|
||||
import { Text } from '@/ui/Text';
|
||||
import { PositionBadge } from '@/ui/ResultRow';
|
||||
import { AlertTriangle, ExternalLink } from 'lucide-react';
|
||||
import { ReactNode } from 'react';
|
||||
import React, { ReactNode } from 'react';
|
||||
|
||||
type PenaltyTypeDTO =
|
||||
| 'time_penalty'
|
||||
@@ -97,10 +97,10 @@ export function RaceResultsTable({
|
||||
return pointsSystem[position] || 0;
|
||||
};
|
||||
|
||||
const getPositionChangeColor = (change: number): string => {
|
||||
if (change > 0) return 'text-performance-green';
|
||||
if (change < 0) return 'text-warning-amber';
|
||||
return 'text-gray-500';
|
||||
const getPositionChangeVariant = (change: number): 'success' | 'warning' | 'low' => {
|
||||
if (change > 0) return 'success';
|
||||
if (change < 0) return 'warning';
|
||||
return 'low';
|
||||
};
|
||||
|
||||
const getPositionChangeText = (change: number): string => {
|
||||
@@ -111,14 +111,14 @@ export function RaceResultsTable({
|
||||
|
||||
if (results.length === 0) {
|
||||
return (
|
||||
<Stack textAlign="center" py={8} gap={0}>
|
||||
<Text color="text-gray-400">No results available</Text>
|
||||
</Stack>
|
||||
<div style={{ textAlign: 'center', padding: '2rem 0' }}>
|
||||
<Text variant="low">No results available</Text>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Stack overflow="auto" gap={0}>
|
||||
<div style={{ overflowX: 'auto' }}>
|
||||
<Table>
|
||||
<TableHead>
|
||||
<TableRow>
|
||||
@@ -129,7 +129,7 @@ export function RaceResultsTable({
|
||||
<TableHeader>Points</TableHeader>
|
||||
<TableHeader>+/-</TableHeader>
|
||||
<TableHeader>Penalties</TableHeader>
|
||||
{isAdmin && <TableHeader className="text-right">Actions</TableHeader>}
|
||||
{isAdmin && <TableHeader textAlign="right">Actions</TableHeader>}
|
||||
</TableRow>
|
||||
</TableHead>
|
||||
<TableBody>
|
||||
@@ -147,117 +147,85 @@ export function RaceResultsTable({
|
||||
variant={isCurrentUser ? 'highlight' : 'default'}
|
||||
>
|
||||
<TableCell>
|
||||
<Stack
|
||||
direction="row"
|
||||
align="center"
|
||||
justify="center"
|
||||
w="8"
|
||||
h="8"
|
||||
rounded="lg"
|
||||
bg={
|
||||
result.position === 1
|
||||
? 'bg-yellow-500/20'
|
||||
: result.position === 2
|
||||
? 'bg-gray-400/20'
|
||||
: result.position === 3
|
||||
? 'bg-amber-600/20'
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
<Text
|
||||
weight="bold"
|
||||
size="sm"
|
||||
color={
|
||||
result.position === 1
|
||||
? 'text-yellow-400'
|
||||
: result.position === 2
|
||||
? 'text-gray-300'
|
||||
: result.position === 3
|
||||
? 'text-amber-500'
|
||||
: 'text-white'
|
||||
}
|
||||
>
|
||||
{result.position}
|
||||
</Text>
|
||||
</Stack>
|
||||
<PositionBadge position={result.position} />
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Stack direction="row" align="center" gap={3}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '0.75rem' }}>
|
||||
{driver ? (
|
||||
<>
|
||||
<Stack
|
||||
w="8"
|
||||
h="8"
|
||||
rounded="full"
|
||||
align="center"
|
||||
justify="center"
|
||||
flexShrink={0}
|
||||
bg={isCurrentUser ? 'bg-primary-blue/30' : 'bg-iron-gray'}
|
||||
ring={isCurrentUser ? 'ring-2 ring-primary-blue/50' : undefined}
|
||||
>
|
||||
<React.Fragment>
|
||||
<div style={{
|
||||
width: '2rem',
|
||||
height: '2rem',
|
||||
borderRadius: '9999px',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
flexShrink: 0,
|
||||
backgroundColor: isCurrentUser ? 'rgba(25, 140, 255, 0.2)' : 'var(--ui-color-bg-surface-muted)',
|
||||
border: isCurrentUser ? '2px solid var(--ui-color-intent-primary)' : '1px solid var(--ui-color-border-default)'
|
||||
}}>
|
||||
<Text
|
||||
size="sm"
|
||||
weight="bold"
|
||||
color={isCurrentUser ? 'text-primary-blue' : 'text-gray-400'}
|
||||
variant={isCurrentUser ? 'primary' : 'low'}
|
||||
>
|
||||
{driver.name.charAt(0)}
|
||||
</Text>
|
||||
</Stack>
|
||||
</div>
|
||||
<Link
|
||||
href={`/drivers/${driver.id}`}
|
||||
variant="ghost"
|
||||
className={`group ${isCurrentUser ? 'text-primary-blue font-semibold' : 'text-white'}`}
|
||||
variant={isCurrentUser ? 'primary' : 'inherit'}
|
||||
>
|
||||
<Text className="group-hover:underline">{driver.name}</Text>
|
||||
{isCurrentUser && (
|
||||
<Badge size="xs" variant="primary" bg="bg-primary-blue" color="text-white" rounded="full" style={{ marginLeft: '6px' }}>
|
||||
You
|
||||
</Badge>
|
||||
)}
|
||||
<Icon icon={ExternalLink} size={3} className="ml-1.5 opacity-0 group-hover:opacity-100 transition-opacity" />
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '0.375rem' }}>
|
||||
<Text weight={isCurrentUser ? 'semibold' : 'normal'}>{driver.name}</Text>
|
||||
{isCurrentUser && (
|
||||
<Badge variant="primary" size="sm">You</Badge>
|
||||
)}
|
||||
<Icon icon={ExternalLink} size={3} intent="low" />
|
||||
</div>
|
||||
</Link>
|
||||
</>
|
||||
</React.Fragment>
|
||||
) : (
|
||||
<Text color="text-white">{getDriverName(result.driverId)}</Text>
|
||||
<Text variant="high">{getDriverName(result.driverId)}</Text>
|
||||
)}
|
||||
</Stack>
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Text color={isFastestLap ? 'text-performance-green' : 'text-white'} weight={isFastestLap ? 'medium' : 'normal'}>
|
||||
<Text variant={isFastestLap ? 'success' : 'high'} weight={isFastestLap ? 'medium' : 'normal'}>
|
||||
{formatLapTime(result.fastestLap)}
|
||||
</Text>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Text color={result.incidents > 0 ? 'text-warning-amber' : 'text-white'}>
|
||||
<Text variant={result.incidents > 0 ? 'warning' : 'high'}>
|
||||
{result.incidents}×
|
||||
</Text>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Text color="text-white" weight="medium">
|
||||
<Text variant="high" weight="medium">
|
||||
{getPoints(result.position)}
|
||||
</Text>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Text weight="medium" className={getPositionChangeColor(positionChange)}>
|
||||
<Text weight="medium" variant={getPositionChangeVariant(positionChange)}>
|
||||
{getPositionChangeText(positionChange)}
|
||||
</Text>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
{driverPenalties.length > 0 ? (
|
||||
<Stack gap={1}>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '0.25rem' }}>
|
||||
{driverPenalties.map((penalty, idx) => (
|
||||
<Stack key={idx} direction="row" align="center" gap={1.5}>
|
||||
<Icon icon={AlertTriangle} size={3} color="var(--critical-red)" />
|
||||
<Text size="xs" color="text-red-400">{getPenaltyDescription(penalty)}</Text>
|
||||
</Stack>
|
||||
<div key={idx} style={{ display: 'flex', alignItems: 'center', gap: '0.375rem' }}>
|
||||
<Icon icon={AlertTriangle} size={3} intent="critical" />
|
||||
<Text size="xs" variant="critical">{getPenaltyDescription(penalty)}</Text>
|
||||
</div>
|
||||
))}
|
||||
</Stack>
|
||||
</div>
|
||||
) : (
|
||||
<Text color="text-gray-500">—</Text>
|
||||
<Text variant="low">—</Text>
|
||||
)}
|
||||
</TableCell>
|
||||
{isAdmin && (
|
||||
<TableCell className="text-right">
|
||||
<TableCell style={{ textAlign: 'right' }}>
|
||||
{driver && penaltyButtonRenderer && penaltyButtonRenderer(driver)}
|
||||
</TableCell>
|
||||
)}
|
||||
@@ -266,6 +234,6 @@ export function RaceResultsTable({
|
||||
})}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</Stack>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -39,18 +39,18 @@ export function RaceScheduleTable({ races, onRaceClick }: RaceScheduleTableProps
|
||||
clickable
|
||||
>
|
||||
<TableCell>
|
||||
<Text size="xs" color="text-telemetry-aqua" weight="bold">{race.time}</Text>
|
||||
<Text size="xs" variant="telemetry" weight="bold">{race.time}</Text>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Text size="sm" weight="bold" groupHoverTextColor="text-primary-accent">
|
||||
<Text size="sm" weight="bold" variant="high">
|
||||
{race.track}
|
||||
</Text>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Text size="xs" color="text-gray-400">{race.car}</Text>
|
||||
<Text size="xs" variant="low">{race.car}</Text>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Text size="xs" color="text-gray-400">{race.leagueName || 'Official'}</Text>
|
||||
<Text size="xs" variant="low">{race.leagueName || 'Official'}</Text>
|
||||
</TableCell>
|
||||
<TableCell textAlign="right">
|
||||
<SessionStatusBadge status={race.status} />
|
||||
|
||||
@@ -1,13 +1,14 @@
|
||||
'use client';
|
||||
|
||||
import { SidebarRaceItem } from '@/components/races/SidebarRaceItem';
|
||||
import { routes } from '@/lib/routing/RouteConfig';
|
||||
import type { RaceViewData } from '@/lib/view-data/RacesViewData';
|
||||
import { Card } from '@/ui/Card';
|
||||
import { Heading } from '@/ui/Heading';
|
||||
import { Panel } from '@/ui/Panel';
|
||||
import { Icon } from '@/ui/Icon';
|
||||
import { Stack } from '@/ui/primitives/Stack';
|
||||
import { SidebarActionLink } from '@/ui/SidebarActionLink';
|
||||
import { Text } from '@/ui/Text';
|
||||
import { Clock, Trophy, Users } from 'lucide-react';
|
||||
import React from 'react';
|
||||
|
||||
interface RaceSidebarProps {
|
||||
upcomingRaces: RaceViewData[];
|
||||
@@ -17,88 +18,73 @@ interface RaceSidebarProps {
|
||||
|
||||
export function RaceSidebar({ upcomingRaces, recentResults, onRaceClick }: RaceSidebarProps) {
|
||||
return (
|
||||
<Stack gap={6}>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '1.5rem' }}>
|
||||
{/* Upcoming This Week */}
|
||||
<Card>
|
||||
<Stack gap={4}>
|
||||
<Stack direction="row" align="center" justify="between">
|
||||
<Heading level={3} icon={<Icon icon={Clock} size={4} color="var(--primary-accent)" />}>
|
||||
Next Up
|
||||
</Heading>
|
||||
<Text size="xs" color="text-gray-500">This week</Text>
|
||||
</Stack>
|
||||
|
||||
{upcomingRaces.length === 0 ? (
|
||||
<Stack py={4} textAlign="center">
|
||||
<Text size="sm" color="text-gray-400">No races scheduled this week</Text>
|
||||
</Stack>
|
||||
) : (
|
||||
<Stack gap={3}>
|
||||
{upcomingRaces.map((race) => (
|
||||
<SidebarRaceItem
|
||||
key={race.id}
|
||||
race={{
|
||||
id: race.id,
|
||||
track: race.track,
|
||||
scheduledAt: race.scheduledAt
|
||||
}}
|
||||
onClick={() => onRaceClick(race.id)}
|
||||
/>
|
||||
))}
|
||||
</Stack>
|
||||
)}
|
||||
</Stack>
|
||||
</Card>
|
||||
<Panel
|
||||
title="Next Up"
|
||||
description="This week"
|
||||
>
|
||||
{upcomingRaces.length === 0 ? (
|
||||
<div style={{ padding: '1rem 0', textAlign: 'center' }}>
|
||||
<Text size="sm" variant="low">No races scheduled this week</Text>
|
||||
</div>
|
||||
) : (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '0.75rem' }}>
|
||||
{upcomingRaces.map((race) => (
|
||||
<SidebarRaceItem
|
||||
key={race.id}
|
||||
race={{
|
||||
id: race.id,
|
||||
track: race.track,
|
||||
scheduledAt: race.scheduledAt
|
||||
}}
|
||||
onClick={() => onRaceClick(race.id)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</Panel>
|
||||
|
||||
{/* Recent Results */}
|
||||
<Card>
|
||||
<Stack gap={4}>
|
||||
<Heading level={3} icon={<Icon icon={Trophy} size={4} color="var(--warning-amber)" />}>
|
||||
Recent Results
|
||||
</Heading>
|
||||
|
||||
{recentResults.length === 0 ? (
|
||||
<Stack py={4} textAlign="center">
|
||||
<Text size="sm" color="text-gray-400">No completed races yet</Text>
|
||||
</Stack>
|
||||
) : (
|
||||
<Stack gap={3}>
|
||||
{recentResults.map((race) => (
|
||||
<SidebarRaceItem
|
||||
key={race.id}
|
||||
race={{
|
||||
id: race.id,
|
||||
track: race.track,
|
||||
scheduledAt: race.scheduledAt
|
||||
}}
|
||||
onClick={() => onRaceClick(race.id)}
|
||||
/>
|
||||
))}
|
||||
</Stack>
|
||||
)}
|
||||
</Stack>
|
||||
</Card>
|
||||
<Panel
|
||||
title="Recent Results"
|
||||
>
|
||||
{recentResults.length === 0 ? (
|
||||
<div style={{ padding: '1rem 0', textAlign: 'center' }}>
|
||||
<Text size="sm" variant="low">No completed races yet</Text>
|
||||
</div>
|
||||
) : (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '0.75rem' }}>
|
||||
{recentResults.map((race) => (
|
||||
<SidebarRaceItem
|
||||
key={race.id}
|
||||
race={{
|
||||
id: race.id,
|
||||
track: race.track,
|
||||
scheduledAt: race.scheduledAt
|
||||
}}
|
||||
onClick={() => onRaceClick(race.id)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</Panel>
|
||||
|
||||
{/* Quick Actions */}
|
||||
<Card>
|
||||
<Stack gap={4}>
|
||||
<Heading level={3}>Quick Actions</Heading>
|
||||
<Stack gap={2}>
|
||||
<SidebarActionLink
|
||||
href={routes.public.leagues}
|
||||
icon={Users}
|
||||
label="Browse Leagues"
|
||||
/>
|
||||
<SidebarActionLink
|
||||
href={routes.public.leaderboards}
|
||||
icon={Trophy}
|
||||
label="View Leaderboards"
|
||||
iconColor="text-warning-amber"
|
||||
iconBgColor="bg-warning-amber/10"
|
||||
/>
|
||||
</Stack>
|
||||
</Stack>
|
||||
</Card>
|
||||
</Stack>
|
||||
<Panel title="Quick Actions">
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '0.5rem' }}>
|
||||
<SidebarActionLink
|
||||
href={routes.public.leagues}
|
||||
icon={Users}
|
||||
label="Browse Leagues"
|
||||
/>
|
||||
<SidebarActionLink
|
||||
href={routes.public.leaderboards}
|
||||
icon={Trophy}
|
||||
label="View Leaderboards"
|
||||
/>
|
||||
</div>
|
||||
</Panel>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import { Panel } from '@/ui/Panel';
|
||||
import { Icon } from '@/ui/Icon';
|
||||
import { Stack } from '@/ui/primitives/Stack';
|
||||
import { Text } from '@/ui/Text';
|
||||
import { LucideIcon } from 'lucide-react';
|
||||
import React from 'react';
|
||||
|
||||
@@ -16,24 +15,12 @@ export function RaceSidebarPanel({
|
||||
children
|
||||
}: RaceSidebarPanelProps) {
|
||||
return (
|
||||
<Stack
|
||||
bg="bg-panel-gray"
|
||||
rounded="xl"
|
||||
border
|
||||
borderColor="border-charcoal-outline"
|
||||
overflow="hidden"
|
||||
<Panel
|
||||
title={title}
|
||||
variant="dark"
|
||||
padding={4}
|
||||
>
|
||||
<Stack p={4} borderBottom="1px solid" borderColor="border-charcoal-outline" bg="bg-graphite-black/30">
|
||||
<Stack direction="row" align="center" gap={2}>
|
||||
{icon && <Icon icon={icon} size={4} color="#198CFF" />}
|
||||
<Text weight="bold" size="sm" color="text-white" uppercase>
|
||||
{title}
|
||||
</Text>
|
||||
</Stack>
|
||||
</Stack>
|
||||
<Stack p={4}>
|
||||
{children}
|
||||
</Stack>
|
||||
</Stack>
|
||||
{children}
|
||||
</Panel>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { Box } from '@/ui/primitives/Box';
|
||||
import { StatGridItem } from '@/ui/StatGridItem';
|
||||
import { StatGrid } from '@/ui/StatGrid';
|
||||
import { CalendarDays, Clock, Trophy, Zap } from 'lucide-react';
|
||||
import React from 'react';
|
||||
|
||||
interface RaceStatsProps {
|
||||
stats: {
|
||||
@@ -12,32 +12,20 @@ interface RaceStatsProps {
|
||||
}
|
||||
|
||||
export function RaceStats({ stats }: RaceStatsProps) {
|
||||
const mappedStats = [
|
||||
{ label: 'Total', value: stats.total, icon: CalendarDays, intent: 'low' as const },
|
||||
{ label: 'Scheduled', value: stats.scheduled, icon: Clock, intent: 'primary' as const },
|
||||
{ label: 'Live Now', value: stats.running, icon: Zap, intent: 'success' as const },
|
||||
{ label: 'Completed', value: stats.completed, icon: Trophy, intent: 'low' as const },
|
||||
];
|
||||
|
||||
return (
|
||||
<Box display="grid" gridCols={{ base: 2, md: 4 }} gap={4} mt={6}>
|
||||
<StatGridItem
|
||||
label="Total"
|
||||
value={stats.total}
|
||||
icon={CalendarDays}
|
||||
color="text-gray-400"
|
||||
<div style={{ marginTop: '1.5rem' }}>
|
||||
<StatGrid
|
||||
stats={mappedStats}
|
||||
columns={{ base: 2, md: 4 }}
|
||||
variant="box"
|
||||
/>
|
||||
<StatGridItem
|
||||
label="Scheduled"
|
||||
value={stats.scheduled}
|
||||
icon={Clock}
|
||||
color="text-primary-blue"
|
||||
/>
|
||||
<StatGridItem
|
||||
label="Live Now"
|
||||
value={stats.running}
|
||||
icon={Zap}
|
||||
color="text-performance-green"
|
||||
/>
|
||||
<StatGridItem
|
||||
label="Completed"
|
||||
value={stats.completed}
|
||||
icon={Trophy}
|
||||
color="text-gray-400"
|
||||
/>
|
||||
</Box>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,59 +1,38 @@
|
||||
import { Box } from '@/ui/primitives/Box';
|
||||
import { StatusBadge } from '@/ui/StatusBadge';
|
||||
import React from 'react';
|
||||
|
||||
interface RaceStatusBadgeProps {
|
||||
status: 'scheduled' | 'running' | 'completed' | 'cancelled' | string;
|
||||
}
|
||||
|
||||
export function RaceStatusBadge({ status }: RaceStatusBadgeProps) {
|
||||
const config = {
|
||||
const config: Record<string, { variant: 'info' | 'success' | 'neutral' | 'warning'; label: string }> = {
|
||||
scheduled: {
|
||||
variant: 'info' as const,
|
||||
variant: 'info',
|
||||
label: 'SCHEDULED',
|
||||
color: 'text-primary-blue',
|
||||
bg: 'bg-primary-blue/10',
|
||||
border: 'border-primary-blue/30'
|
||||
},
|
||||
running: {
|
||||
variant: 'success' as const,
|
||||
variant: 'success',
|
||||
label: 'LIVE',
|
||||
color: 'text-performance-green',
|
||||
bg: 'bg-performance-green/10',
|
||||
border: 'border-performance-green/30'
|
||||
},
|
||||
completed: {
|
||||
variant: 'neutral' as const,
|
||||
variant: 'neutral',
|
||||
label: 'COMPLETED',
|
||||
color: 'text-gray-400',
|
||||
bg: 'bg-gray-400/10',
|
||||
border: 'border-gray-400/30'
|
||||
},
|
||||
cancelled: {
|
||||
variant: 'warning' as const,
|
||||
variant: 'warning',
|
||||
label: 'CANCELLED',
|
||||
color: 'text-warning-amber',
|
||||
bg: 'bg-warning-amber/10',
|
||||
border: 'border-warning-amber/30'
|
||||
},
|
||||
};
|
||||
|
||||
const badgeConfig = config[status as keyof typeof config] || {
|
||||
variant: 'neutral' as const,
|
||||
const badgeConfig = config[status] || {
|
||||
variant: 'neutral',
|
||||
label: status.toUpperCase(),
|
||||
color: 'text-gray-400',
|
||||
bg: 'bg-gray-400/10',
|
||||
border: 'border-gray-400/30'
|
||||
};
|
||||
|
||||
return (
|
||||
<Box
|
||||
px={2.5}
|
||||
py={0.5}
|
||||
rounded="none"
|
||||
border
|
||||
className={`${badgeConfig.bg} ${badgeConfig.color} ${badgeConfig.border}`}
|
||||
style={{ fontSize: '10px', fontWeight: '800', letterSpacing: '0.05em' }}
|
||||
>
|
||||
<StatusBadge variant={badgeConfig.variant}>
|
||||
{badgeConfig.label}
|
||||
</Box>
|
||||
</StatusBadge>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { Box } from '@/ui/primitives/Box';
|
||||
import { Text } from '@/ui/Text';
|
||||
import { RaceSummary } from '@/ui/RaceSummary';
|
||||
import { DateDisplay } from '@/lib/display-objects/DateDisplay';
|
||||
import React from 'react';
|
||||
|
||||
interface RaceSummaryItemProps {
|
||||
track: string;
|
||||
@@ -9,19 +10,10 @@ interface RaceSummaryItemProps {
|
||||
|
||||
export function RaceSummaryItem({ track, meta, date }: RaceSummaryItemProps) {
|
||||
return (
|
||||
<Box display="flex" justifyContent="between" gap={3}>
|
||||
<Box flexGrow={1} minWidth="0">
|
||||
<Text size="xs" color="text-white" block truncate>{track}</Text>
|
||||
<Text size="xs" color="text-gray-400" block truncate>{meta}</Text>
|
||||
</Box>
|
||||
<Box textAlign="right">
|
||||
<Text size="xs" color="text-gray-500" className="whitespace-nowrap">
|
||||
{date.toLocaleDateString(undefined, {
|
||||
month: 'short',
|
||||
day: 'numeric'
|
||||
})}
|
||||
</Text>
|
||||
</Box>
|
||||
</Box>
|
||||
<RaceSummary
|
||||
track={track}
|
||||
meta={meta}
|
||||
date={DateDisplay.formatShort(date)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import { Icon } from '@/ui/Icon';
|
||||
import { Box } from '@/ui/primitives/Box';
|
||||
import { SidebarItem } from '@/ui/SidebarItem';
|
||||
import { Text } from '@/ui/Text';
|
||||
import { ChevronRight } from 'lucide-react';
|
||||
import React from 'react';
|
||||
|
||||
interface SidebarRaceItemProps {
|
||||
race: {
|
||||
@@ -10,33 +9,24 @@ interface SidebarRaceItemProps {
|
||||
scheduledAt: string;
|
||||
};
|
||||
onClick?: () => void;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function SidebarRaceItem({ race, onClick, className }: SidebarRaceItemProps) {
|
||||
export function SidebarRaceItem({ race, onClick }: SidebarRaceItemProps) {
|
||||
const scheduledAtDate = new Date(race.scheduledAt);
|
||||
|
||||
return (
|
||||
<Box
|
||||
<SidebarItem
|
||||
onClick={onClick}
|
||||
display="flex"
|
||||
alignItems="center"
|
||||
gap={3}
|
||||
p={2}
|
||||
rounded="lg"
|
||||
cursor="pointer"
|
||||
className={`hover:bg-deep-graphite transition-colors ${className || ''}`}
|
||||
>
|
||||
<Box flexShrink={0} width="10" height="10" bg="bg-primary-blue/10" rounded="lg" display="flex" center>
|
||||
<Text size="sm" weight="bold" color="text-primary-blue">
|
||||
icon={
|
||||
<Text size="sm" weight="bold" variant="primary">
|
||||
{scheduledAtDate.getDate()}
|
||||
</Text>
|
||||
</Box>
|
||||
<Box flexGrow={1} minWidth="0">
|
||||
<Text size="sm" weight="medium" color="text-white" block truncate>{race.track}</Text>
|
||||
<Text size="xs" color="text-gray-500" block>{scheduledAtDate.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })}</Text>
|
||||
</Box>
|
||||
<Icon icon={ChevronRight} size={4} color="text-gray-500" />
|
||||
</Box>
|
||||
}
|
||||
>
|
||||
<Text size="sm" weight="medium" variant="high" block truncate>{race.track}</Text>
|
||||
<Text size="xs" variant="low" block>
|
||||
{scheduledAtDate.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })}
|
||||
</Text>
|
||||
</SidebarItem>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,343 +0,0 @@
|
||||
import { Button } from '@/ui/Button';
|
||||
import { Heading } from '@/ui/Heading';
|
||||
import { Link } from '@/ui/Link';
|
||||
import { Stack } from '@/ui/primitives/Stack';
|
||||
import { EmptyStateProps } from '@/ui/state-types';
|
||||
import { Text } from '@/ui/Text';
|
||||
import { Activity, Lock, Search } from 'lucide-react';
|
||||
|
||||
// Illustration components (simple SVG representations)
|
||||
const Illustrations = {
|
||||
racing: () => (
|
||||
<svg width="80" height="80" viewBox="0 0 100 100" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M20 70 L80 70 L85 50 L80 30 L20 30 L15 50 Z" fill="currentColor" opacity="0.2"/>
|
||||
<path d="M30 60 L70 60 L75 50 L70 40 L30 40 L25 50 Z" fill="currentColor" opacity="0.4"/>
|
||||
<circle cx="35" cy="65" r="3" fill="currentColor"/>
|
||||
<circle cx="65" cy="65" r="3" fill="currentColor"/>
|
||||
<path d="M50 30 L50 20 M45 25 L50 20 L55 25" stroke="currentColor" strokeWidth="2" strokeLinecap="round"/>
|
||||
</svg>
|
||||
),
|
||||
league: () => (
|
||||
<svg width="80" height="80" viewBox="0 0 100 100" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<circle cx="50" cy="35" r="15" fill="currentColor" opacity="0.3"/>
|
||||
<path d="M35 50 L50 45 L65 50 L65 70 L35 70 Z" fill="currentColor" opacity="0.2"/>
|
||||
<path d="M40 55 L50 52 L60 55" stroke="currentColor" strokeWidth="2" strokeLinecap="round"/>
|
||||
<path d="M40 62 L50 59 L60 62" stroke="currentColor" strokeWidth="2" strokeLinecap="round"/>
|
||||
</svg>
|
||||
),
|
||||
team: () => (
|
||||
<svg width="80" height="80" viewBox="0 0 100 100" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<circle cx="35" cy="35" r="8" fill="currentColor" opacity="0.3"/>
|
||||
<circle cx="65" cy="35" r="8" fill="currentColor" opacity="0.3"/>
|
||||
<circle cx="50" cy="55" r="10" fill="currentColor" opacity="0.2"/>
|
||||
<path d="M35 45 L35 60 M65 45 L65 60 M50 65 L50 80" stroke="currentColor" strokeWidth="3" strokeLinecap="round"/>
|
||||
</svg>
|
||||
),
|
||||
sponsor: () => (
|
||||
<svg width="80" height="80" viewBox="0 0 100 100" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<rect x="25" y="25" width="50" height="50" rx="8" fill="currentColor" opacity="0.2"/>
|
||||
<path d="M35 50 L45 60 L65 40" stroke="currentColor" strokeWidth="3" strokeLinecap="round" strokeLinejoin="round"/>
|
||||
<path d="M50 35 L50 65 M40 50 L60 50" stroke="currentColor" strokeWidth="2" opacity="0.5"/>
|
||||
</svg>
|
||||
),
|
||||
driver: () => (
|
||||
<svg width="80" height="80" viewBox="0 0 100 100" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<circle cx="50" cy="30" r="8" fill="currentColor" opacity="0.3"/>
|
||||
<path d="M42 38 L58 38 L55 55 L45 55 Z" fill="currentColor" opacity="0.2"/>
|
||||
<path d="M45 55 L40 70 M55 55 L60 70" stroke="currentColor" strokeWidth="3" strokeLinecap="round"/>
|
||||
<circle cx="40" cy="72" r="3" fill="currentColor"/>
|
||||
<circle cx="60" cy="72" r="3" fill="currentColor"/>
|
||||
</svg>
|
||||
),
|
||||
} as const;
|
||||
|
||||
/**
|
||||
* EmptyState Component
|
||||
*
|
||||
* Provides consistent empty/placeholder states with 3 variants:
|
||||
* - default: Standard empty state with icon, title, description, and action
|
||||
* - minimal: Simple version without extra styling
|
||||
* - full-page: Full page empty state with centered layout
|
||||
*
|
||||
* Supports both icons and illustrations for visual appeal.
|
||||
*/
|
||||
export function EmptyState({
|
||||
icon: Icon,
|
||||
title,
|
||||
description,
|
||||
action,
|
||||
variant = 'default',
|
||||
className = '',
|
||||
illustration,
|
||||
ariaLabel = 'Empty state',
|
||||
}: EmptyStateProps) {
|
||||
// Render illustration if provided
|
||||
const IllustrationComponent = illustration ? Illustrations[illustration] : null;
|
||||
|
||||
// Common content
|
||||
const Content = () => (
|
||||
<Stack align="center" gap={4} mb={4}>
|
||||
{/* Visual - Icon or Illustration */}
|
||||
<Stack align="center" justify="center">
|
||||
{IllustrationComponent ? (
|
||||
<Stack color="text-gray-500">
|
||||
<IllustrationComponent />
|
||||
</Stack>
|
||||
) : Icon ? (
|
||||
<Stack h="16" w="16" align="center" justify="center" rounded="2xl" bg="iron-gray/60" border borderColor="charcoal-outline/50">
|
||||
<Icon className="w-8 h-8 text-gray-500" />
|
||||
</Stack>
|
||||
) : null}
|
||||
</Stack>
|
||||
|
||||
{/* Title */}
|
||||
<Heading level={3} weight="semibold" color="text-white" textAlign="center">
|
||||
{title}
|
||||
</Heading>
|
||||
|
||||
{/* Description */}
|
||||
{description && (
|
||||
<Text color="text-gray-400" textAlign="center" leading="relaxed">
|
||||
{description}
|
||||
</Text>
|
||||
)}
|
||||
|
||||
{/* Action Button */}
|
||||
{action && (
|
||||
<Stack align="center" pt={2}>
|
||||
<Button
|
||||
variant={action.variant || 'primary'}
|
||||
onClick={action.onClick}
|
||||
className="min-w-[140px]"
|
||||
>
|
||||
{action.icon && (
|
||||
<action.icon className="w-4 h-4 mr-2" />
|
||||
)}
|
||||
{action.label}
|
||||
</Button>
|
||||
</Stack>
|
||||
)}
|
||||
</Stack>
|
||||
);
|
||||
|
||||
// Render different variants
|
||||
switch (variant) {
|
||||
case 'default':
|
||||
return (
|
||||
<Stack
|
||||
py={12}
|
||||
align="center"
|
||||
className={className}
|
||||
role="status"
|
||||
aria-label={ariaLabel}
|
||||
aria-live="polite"
|
||||
>
|
||||
<Stack maxWidth="md" fullWidth>
|
||||
<Content />
|
||||
</Stack>
|
||||
</Stack>
|
||||
);
|
||||
|
||||
case 'minimal':
|
||||
return (
|
||||
<Stack
|
||||
py={8}
|
||||
align="center"
|
||||
className={className}
|
||||
role="status"
|
||||
aria-label={ariaLabel}
|
||||
aria-live="polite"
|
||||
>
|
||||
<Stack maxWidth="sm" fullWidth gap={3}>
|
||||
{/* Minimal icon */}
|
||||
{Icon && (
|
||||
<Stack align="center">
|
||||
<Icon className="w-10 h-10 text-gray-600" />
|
||||
</Stack>
|
||||
)}
|
||||
<Heading level={3} weight="medium" color="text-gray-300">
|
||||
{title}
|
||||
</Heading>
|
||||
{description && (
|
||||
<Text size="sm" color="text-gray-500">
|
||||
{description}
|
||||
</Text>
|
||||
)}
|
||||
{action && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={action.onClick}
|
||||
className="text-primary-blue hover:text-blue-400 font-medium mt-2"
|
||||
icon={action.icon && <action.icon size={3} />}
|
||||
>
|
||||
{action.label}
|
||||
</Button>
|
||||
)}
|
||||
</Stack>
|
||||
</Stack>
|
||||
);
|
||||
|
||||
case 'full-page':
|
||||
return (
|
||||
<Stack
|
||||
position="fixed"
|
||||
inset="0"
|
||||
bg="bg-deep-graphite"
|
||||
align="center"
|
||||
justify="center"
|
||||
p={6}
|
||||
className={className}
|
||||
role="status"
|
||||
aria-label={ariaLabel}
|
||||
aria-live="polite"
|
||||
>
|
||||
<Stack maxWidth="lg" fullWidth align="center">
|
||||
<Stack mb={6} align="center">
|
||||
{IllustrationComponent ? (
|
||||
<Stack color="text-gray-500">
|
||||
<IllustrationComponent />
|
||||
</Stack>
|
||||
) : Icon ? (
|
||||
<Stack align="center">
|
||||
<Stack h="20" w="20" align="center" justify="center" rounded="2xl" bg="iron-gray/60" border borderColor="charcoal-outline/50">
|
||||
<Icon className="w-10 h-10 text-gray-500" />
|
||||
</Stack>
|
||||
</Stack>
|
||||
) : null}
|
||||
</Stack>
|
||||
|
||||
<Heading level={2} weight="bold" color="text-white" mb={4}>
|
||||
{title}
|
||||
</Heading>
|
||||
|
||||
{description && (
|
||||
<Text color="text-gray-400" size="lg" mb={8} leading="relaxed">
|
||||
{description}
|
||||
</Text>
|
||||
)}
|
||||
|
||||
{action && (
|
||||
<Stack direction={{ base: 'col', md: 'row' }} gap={3} justify="center">
|
||||
<Button
|
||||
variant={action.variant || 'primary'}
|
||||
onClick={action.onClick}
|
||||
className="min-w-[160px]"
|
||||
>
|
||||
{action.icon && (
|
||||
<action.icon className="w-4 h-4 mr-2" />
|
||||
)}
|
||||
{action.label}
|
||||
</Button>
|
||||
</Stack>
|
||||
)}
|
||||
|
||||
<Stack mt={8}>
|
||||
<Text size="sm" color="text-gray-500">
|
||||
Need help? Contact us at{' '}
|
||||
<Link
|
||||
href="mailto:support@gridpilot.com"
|
||||
className="text-primary-blue hover:underline"
|
||||
>
|
||||
support@gridpilot.com
|
||||
</Link>
|
||||
</Text>
|
||||
</Stack>
|
||||
</Stack>
|
||||
</Stack>
|
||||
);
|
||||
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Convenience component for default empty state
|
||||
*/
|
||||
export function DefaultEmptyState({ icon, title, description, action, className, illustration }: EmptyStateProps) {
|
||||
return (
|
||||
<EmptyState
|
||||
icon={icon}
|
||||
title={title}
|
||||
description={description}
|
||||
action={action}
|
||||
variant="default"
|
||||
className={className}
|
||||
illustration={illustration}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Convenience component for minimal empty state
|
||||
*/
|
||||
export function MinimalEmptyState({ icon, title, description, action, className }: Omit<EmptyStateProps, 'variant'>) {
|
||||
return (
|
||||
<EmptyState
|
||||
icon={icon}
|
||||
title={title}
|
||||
description={description}
|
||||
action={action}
|
||||
variant="minimal"
|
||||
className={className}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Convenience component for full-page empty state
|
||||
*/
|
||||
export function FullPageEmptyState({ icon, title, description, action, className, illustration }: EmptyStateProps) {
|
||||
return (
|
||||
<EmptyState
|
||||
icon={icon}
|
||||
title={title}
|
||||
description={description}
|
||||
action={action}
|
||||
variant="full-page"
|
||||
className={className}
|
||||
illustration={illustration}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Pre-configured empty states for common scenarios
|
||||
*/
|
||||
|
||||
export function NoDataEmptyState({ onRetry }: { onRetry?: () => void }) {
|
||||
return (
|
||||
<EmptyState
|
||||
icon={Activity}
|
||||
title="No data available"
|
||||
description="There is nothing to display here at the moment"
|
||||
action={onRetry ? { label: 'Refresh', onClick: onRetry } : undefined}
|
||||
variant="default"
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export function NoResultsEmptyState({ onRetry }: { onRetry?: () => void }) {
|
||||
return (
|
||||
<EmptyState
|
||||
icon={Search}
|
||||
title="No results found"
|
||||
description="Try adjusting your search or filters"
|
||||
action={onRetry ? { label: 'Clear Filters', onClick: onRetry } : undefined}
|
||||
variant="default"
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export function NoAccessEmptyState({ onBack }: { onBack?: () => void }) {
|
||||
return (
|
||||
<EmptyState
|
||||
icon={Lock}
|
||||
title="Access denied"
|
||||
description="You don't have permission to view this content"
|
||||
action={onBack ? { label: 'Go Back', onClick: onBack } : undefined}
|
||||
variant="full-page"
|
||||
/>
|
||||
);
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user