website refactor

This commit is contained in:
2026-01-18 22:55:55 +01:00
parent b43a23a48c
commit aeaa43f4d3
179 changed files with 4736 additions and 6832 deletions

View File

@@ -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>
);
}

View File

@@ -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>
);
})}

View File

@@ -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>
);
}

View File

@@ -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>
);
}

View File

@@ -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>

View File

@@ -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>
);
}

View File

@@ -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>
);
}

View File

@@ -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>
);
}

View File

@@ -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>
);
}

View File

@@ -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} />}
/>
);
}

View File

@@ -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"
/>
);
}

View File

@@ -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"
/>
);
}

View File

@@ -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>
);
}

View File

@@ -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>
))}

View File

@@ -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>
);
}

View File

@@ -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

View File

@@ -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}
/>
);
}

View File

@@ -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 />
);
}

View File

@@ -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>
);
}

View File

@@ -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>
);
}

View File

@@ -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>
);
}

View File

@@ -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>
);
}

View File

@@ -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>
);
}

View File

@@ -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',
},
];

View File

@@ -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>
);
}

View File

@@ -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>
);
}

View File

@@ -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>
);
}

View File

@@ -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>
);
}

View File

@@ -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>
);
}

View File

@@ -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>
);
}

View File

@@ -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>
))}

View File

@@ -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>
);
}

View File

@@ -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>
);
}

View File

@@ -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>
);
}

View File

@@ -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>;
}

View File

@@ -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>
);
}

View File

@@ -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>
);
}

View File

@@ -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;

View File

@@ -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>
);
}

View File

@@ -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>
);
}

View File

@@ -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>
);
}

View File

@@ -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>
);
}

View File

@@ -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}
/>
);
}

View File

@@ -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>
);
}

View File

@@ -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>
);
}

View File

@@ -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>
);
}

View File

@@ -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>
);
}

View File

@@ -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>
);
}

View File

@@ -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>
);
}

View File

@@ -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>
);
}

View File

@@ -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>
);
}

View File

@@ -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>
);
}

View File

@@ -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>

View File

@@ -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>
);
}

View File

@@ -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>
);
}

View File

@@ -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>

View File

@@ -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>
);
}

View File

@@ -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>
);
}

View File

@@ -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>
);
}

View File

@@ -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>
);
}

View File

@@ -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>
);
}

View File

@@ -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>
);
}

View File

@@ -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>
);
}

View File

@@ -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>
);

View File

@@ -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>
);
}

View File

@@ -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">
&ldquo;{request.message}&rdquo;
</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>
);
}

View File

@@ -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>
);
}

View File

@@ -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,
&quot;Best 6&quot; or &quot;Drop 2&quot; 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>
);
}

View File

@@ -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>
);
}

View File

@@ -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 && (

View File

@@ -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>
);
}

View File

@@ -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}
/>
);
}

View File

@@ -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>
);
}

View File

@@ -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>
);
}

View File

@@ -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>
);
}

View File

@@ -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>

View File

@@ -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>
);
}

View File

@@ -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>
);
}

View File

@@ -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>
);
}

View File

@@ -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>
);
}

View File

@@ -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&apos;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&apos;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>
);
}

View File

@@ -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>
);
}

View File

@@ -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>
);
}

View File

@@ -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>
);
}

View File

@@ -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>
);
}

View File

@@ -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>
);
}

View File

@@ -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"
/>
);
}

View File

@@ -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)}
/>
);
}

View File

@@ -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>
);
}

View File

@@ -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>
);
}

View File

@@ -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>
);
}

View File

@@ -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>
);
}

View File

@@ -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} />

View File

@@ -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>
);
}

View File

@@ -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>
);
}

View File

@@ -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>
);
}

View File

@@ -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>
);
}

View File

@@ -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)}
/>
);
}

View File

@@ -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>
);
}

View File

@@ -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