website refactor
This commit is contained in:
57
apps/website/components/actions/ActionFiltersBar.tsx
Normal file
57
apps/website/components/actions/ActionFiltersBar.tsx
Normal file
@@ -0,0 +1,57 @@
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import { Box } from '@/ui/Box';
|
||||
import { Text } from '@/ui/Text';
|
||||
import { Select } from '@/ui/Select';
|
||||
import { Input } from '@/ui/Input';
|
||||
|
||||
export function ActionFiltersBar() {
|
||||
const [filter, setFilter] = useState('all');
|
||||
|
||||
return (
|
||||
<Box
|
||||
h="12"
|
||||
borderBottom
|
||||
borderColor="border-[#23272B]"
|
||||
display="flex"
|
||||
alignItems="center"
|
||||
px={6}
|
||||
bg="bg-[#0C0D0F]"
|
||||
gap={6}
|
||||
>
|
||||
<Box display="flex" alignItems="center" gap={2}>
|
||||
<Text size="xs" color="text-gray-500" weight="bold" uppercase>Filter:</Text>
|
||||
<Select
|
||||
options={[
|
||||
{ label: 'All Types', value: 'all' },
|
||||
{ label: 'User Update', value: 'user' },
|
||||
{ label: 'Onboarding', value: 'onboarding' }
|
||||
]}
|
||||
value={filter}
|
||||
onChange={(e) => setFilter(e.target.value)}
|
||||
fullWidth={false}
|
||||
/>
|
||||
</Box>
|
||||
<Box display="flex" alignItems="center" gap={2}>
|
||||
<Text size="xs" color="text-gray-500" weight="bold" uppercase>Status:</Text>
|
||||
<Select
|
||||
options={[
|
||||
{ label: 'All Status', value: 'all' },
|
||||
{ label: 'Completed', value: 'completed' },
|
||||
{ label: 'Pending', value: 'pending' },
|
||||
{ label: 'Failed', value: 'failed' }
|
||||
]}
|
||||
value="all"
|
||||
onChange={() => {}}
|
||||
fullWidth={false}
|
||||
/>
|
||||
</Box>
|
||||
<Box ml="auto">
|
||||
<Input
|
||||
placeholder="SEARCH_ID..."
|
||||
/>
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
52
apps/website/components/actions/ActionList.tsx
Normal file
52
apps/website/components/actions/ActionList.tsx
Normal file
@@ -0,0 +1,52 @@
|
||||
'use client';
|
||||
|
||||
import { ActionItem } from '@/lib/queries/ActionsPageQuery';
|
||||
import { ActionStatusBadge } from './ActionStatusBadge';
|
||||
import { Table, TableHead, TableBody, TableRow, TableHeader, TableCell } from '@/ui/Table';
|
||||
import { Text } from '@/ui/Text';
|
||||
|
||||
interface ActionListProps {
|
||||
actions: ActionItem[];
|
||||
}
|
||||
|
||||
export function ActionList({ actions }: ActionListProps) {
|
||||
return (
|
||||
<Table>
|
||||
<TableHead>
|
||||
<TableRow>
|
||||
<TableHeader>Timestamp</TableHeader>
|
||||
<TableHeader>Type</TableHeader>
|
||||
<TableHeader>Initiator</TableHeader>
|
||||
<TableHeader>Status</TableHeader>
|
||||
<TableHeader>Details</TableHeader>
|
||||
</TableRow>
|
||||
</TableHead>
|
||||
<TableBody>
|
||||
{actions.map((action) => (
|
||||
<TableRow
|
||||
key={action.id}
|
||||
clickable
|
||||
>
|
||||
<TableCell>
|
||||
<Text font="mono" size="xs" color="text-gray-400">{action.timestamp}</Text>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Text size="xs" weight="medium" color="text-gray-200">{action.type}</Text>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Text size="xs" color="text-gray-400">{action.initiator}</Text>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<ActionStatusBadge status={action.status} />
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Text size="xs" color="text-gray-400">
|
||||
{action.details}
|
||||
</Text>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
);
|
||||
}
|
||||
43
apps/website/components/actions/ActionStatusBadge.tsx
Normal file
43
apps/website/components/actions/ActionStatusBadge.tsx
Normal file
@@ -0,0 +1,43 @@
|
||||
'use client';
|
||||
|
||||
import { Box } from '@/ui/Box';
|
||||
import { Text } from '@/ui/Text';
|
||||
|
||||
interface ActionStatusBadgeProps {
|
||||
status: 'PENDING' | 'COMPLETED' | 'FAILED' | 'IN_PROGRESS';
|
||||
}
|
||||
|
||||
export function ActionStatusBadge({ status }: ActionStatusBadgeProps) {
|
||||
const styles = {
|
||||
PENDING: { bg: 'bg-amber-500/10', text: 'text-[#FFBE4D]', border: 'border-amber-500/20' },
|
||||
COMPLETED: { bg: 'bg-emerald-500/10', text: 'text-emerald-400', border: 'border-emerald-500/20' },
|
||||
FAILED: { bg: 'bg-red-500/10', text: 'text-red-400', border: 'border-red-500/30' },
|
||||
IN_PROGRESS: { bg: 'bg-blue-500/10', text: 'text-[#198CFF]', border: 'border-blue-500/20' },
|
||||
};
|
||||
|
||||
const config = styles[status];
|
||||
|
||||
return (
|
||||
<Box
|
||||
as="span"
|
||||
px={2}
|
||||
py={0.5}
|
||||
rounded="sm"
|
||||
bg={config.bg}
|
||||
border
|
||||
borderColor={config.border}
|
||||
display="inline-block"
|
||||
>
|
||||
<Text
|
||||
size="xs"
|
||||
weight="bold"
|
||||
color={config.text}
|
||||
uppercase
|
||||
letterSpacing="tight"
|
||||
fontSize="10px"
|
||||
>
|
||||
{status.replace('_', ' ')}
|
||||
</Text>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
41
apps/website/components/actions/ActionsHeader.tsx
Normal file
41
apps/website/components/actions/ActionsHeader.tsx
Normal file
@@ -0,0 +1,41 @@
|
||||
'use client';
|
||||
|
||||
import { Box } from '@/ui/Box';
|
||||
import { Text } from '@/ui/Text';
|
||||
import { Activity } from 'lucide-react';
|
||||
import { StatusIndicator } from '@/ui/StatusIndicator';
|
||||
|
||||
interface ActionsHeaderProps {
|
||||
title: string;
|
||||
}
|
||||
|
||||
export function ActionsHeader({ title }: ActionsHeaderProps) {
|
||||
return (
|
||||
<Box
|
||||
as="header"
|
||||
h="16"
|
||||
borderBottom
|
||||
borderColor="border-[#23272B]"
|
||||
display="flex"
|
||||
alignItems="center"
|
||||
px={6}
|
||||
bg="bg-[#141619]"
|
||||
>
|
||||
<Box display="flex" alignItems="center" gap={4}>
|
||||
<Box
|
||||
w="2"
|
||||
h="6"
|
||||
bg="bg-[#198CFF]"
|
||||
rounded="sm"
|
||||
shadow="shadow-[0_0_8px_rgba(25,140,255,0.5)]"
|
||||
/>
|
||||
<Text as="h1" size="xl" weight="medium" letterSpacing="tight" uppercase>
|
||||
{title}
|
||||
</Text>
|
||||
</Box>
|
||||
<Box ml="auto" display="flex" alignItems="center" gap={4}>
|
||||
<StatusIndicator icon={Activity} variant="info" label="SYSTEM_READY" />
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
44
apps/website/components/admin/AdminDangerZonePanel.tsx
Normal file
44
apps/website/components/admin/AdminDangerZonePanel.tsx
Normal file
@@ -0,0 +1,44 @@
|
||||
'use client';
|
||||
|
||||
import React from 'react';
|
||||
import { Card } from '@/ui/Card';
|
||||
import { Stack } from '@/ui/Stack';
|
||||
import { Heading } from '@/ui/Heading';
|
||||
import { Text } from '@/ui/Text';
|
||||
import { Box } from '@/ui/Box';
|
||||
|
||||
interface AdminDangerZonePanelProps {
|
||||
title: string;
|
||||
description: string;
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
/**
|
||||
* AdminDangerZonePanel
|
||||
*
|
||||
* Semantic panel for destructive or dangerous admin actions.
|
||||
* Restrained but clear warning styling.
|
||||
*/
|
||||
export function AdminDangerZonePanel({
|
||||
title,
|
||||
description,
|
||||
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}>
|
||||
<Box>
|
||||
<Heading level={4} weight="bold" color="text-error-red">
|
||||
{title}
|
||||
</Heading>
|
||||
<Text size="sm" color="text-gray-400" block mt={1}>
|
||||
{description}
|
||||
</Text>
|
||||
</Box>
|
||||
<Box>
|
||||
{children}
|
||||
</Box>
|
||||
</Stack>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
32
apps/website/components/admin/AdminDataTable.tsx
Normal file
32
apps/website/components/admin/AdminDataTable.tsx
Normal file
@@ -0,0 +1,32 @@
|
||||
'use client';
|
||||
|
||||
import React from 'react';
|
||||
import { Card } from '@/ui/Card';
|
||||
import { Box } from '@/ui/Box';
|
||||
|
||||
interface AdminDataTableProps {
|
||||
children: React.ReactNode;
|
||||
maxHeight?: string | number;
|
||||
}
|
||||
|
||||
/**
|
||||
* AdminDataTable
|
||||
*
|
||||
* Semantic wrapper for high-density admin tables.
|
||||
* Provides a consistent container with "Precision Racing Minimal" styling.
|
||||
*/
|
||||
export function AdminDataTable({
|
||||
children,
|
||||
maxHeight
|
||||
}: AdminDataTableProps) {
|
||||
return (
|
||||
<Card p={0} overflow="hidden">
|
||||
<Box
|
||||
overflow="auto"
|
||||
maxHeight={maxHeight}
|
||||
>
|
||||
{children}
|
||||
</Box>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
49
apps/website/components/admin/AdminEmptyState.tsx
Normal file
49
apps/website/components/admin/AdminEmptyState.tsx
Normal file
@@ -0,0 +1,49 @@
|
||||
'use client';
|
||||
|
||||
import React from 'react';
|
||||
import { Stack } from '@/ui/Stack';
|
||||
import { Box } from '@/ui/Box';
|
||||
import { Text } from '@/ui/Text';
|
||||
import { Icon } from '@/ui/Icon';
|
||||
import { LucideIcon } from 'lucide-react';
|
||||
|
||||
interface AdminEmptyStateProps {
|
||||
icon: LucideIcon;
|
||||
title: string;
|
||||
description?: string;
|
||||
action?: React.ReactNode;
|
||||
}
|
||||
|
||||
/**
|
||||
* AdminEmptyState
|
||||
*
|
||||
* Semantic empty state for admin lists and tables.
|
||||
* Follows "Precision Racing Minimal" theme.
|
||||
*/
|
||||
export function AdminEmptyState({
|
||||
icon,
|
||||
title,
|
||||
description,
|
||||
action
|
||||
}: AdminEmptyStateProps) {
|
||||
return (
|
||||
<Stack center py={20} gap={4}>
|
||||
<Icon icon={icon} size={12} color="#23272B" />
|
||||
<Box textAlign="center">
|
||||
<Text size="lg" weight="bold" color="text-white" block>
|
||||
{title}
|
||||
</Text>
|
||||
{description && (
|
||||
<Text size="sm" color="text-gray-500" block mt={1}>
|
||||
{description}
|
||||
</Text>
|
||||
)}
|
||||
</Box>
|
||||
{action && (
|
||||
<Box mt={2}>
|
||||
{action}
|
||||
</Box>
|
||||
)}
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
53
apps/website/components/admin/AdminHeaderPanel.tsx
Normal file
53
apps/website/components/admin/AdminHeaderPanel.tsx
Normal file
@@ -0,0 +1,53 @@
|
||||
'use client';
|
||||
|
||||
import React from 'react';
|
||||
import { Stack } from '@/ui/Stack';
|
||||
import { Box } from '@/ui/Box';
|
||||
import { Heading } from '@/ui/Heading';
|
||||
import { Text } from '@/ui/Text';
|
||||
import { ProgressLine } from '@/components/shared/ux/ProgressLine';
|
||||
|
||||
interface AdminHeaderPanelProps {
|
||||
title: string;
|
||||
description?: string;
|
||||
actions?: React.ReactNode;
|
||||
isLoading?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* AdminHeaderPanel
|
||||
*
|
||||
* Semantic header for admin pages.
|
||||
* Includes title, description, actions, and a progress line for loading states.
|
||||
*/
|
||||
export function AdminHeaderPanel({
|
||||
title,
|
||||
description,
|
||||
actions,
|
||||
isLoading = false
|
||||
}: AdminHeaderPanelProps) {
|
||||
return (
|
||||
<Box position="relative" pb={4} borderBottom borderColor="border-charcoal-outline">
|
||||
<Stack direction="row" align="center" justify="between">
|
||||
<Box>
|
||||
<Heading level={1} weight="bold" color="text-white">
|
||||
{title}
|
||||
</Heading>
|
||||
{description && (
|
||||
<Text size="sm" color="text-gray-400" block mt={1}>
|
||||
{description}
|
||||
</Text>
|
||||
)}
|
||||
</Box>
|
||||
{actions && (
|
||||
<Stack direction="row" align="center" gap={3}>
|
||||
{actions}
|
||||
</Stack>
|
||||
)}
|
||||
</Stack>
|
||||
<Box position="absolute" bottom="0" left="0" w="full">
|
||||
<ProgressLine isLoading={isLoading} />
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
45
apps/website/components/admin/AdminSectionHeader.tsx
Normal file
45
apps/website/components/admin/AdminSectionHeader.tsx
Normal file
@@ -0,0 +1,45 @@
|
||||
'use client';
|
||||
|
||||
import React from 'react';
|
||||
import { Stack } from '@/ui/Stack';
|
||||
import { Heading } from '@/ui/Heading';
|
||||
import { Text } from '@/ui/Text';
|
||||
import { Box } from '@/ui/Box';
|
||||
|
||||
interface AdminSectionHeaderProps {
|
||||
title: string;
|
||||
description?: string;
|
||||
actions?: React.ReactNode;
|
||||
}
|
||||
|
||||
/**
|
||||
* AdminSectionHeader
|
||||
*
|
||||
* Semantic header for sections within admin pages.
|
||||
* Follows "Precision Racing Minimal" theme: dense, clear hierarchy.
|
||||
*/
|
||||
export function AdminSectionHeader({
|
||||
title,
|
||||
description,
|
||||
actions
|
||||
}: AdminSectionHeaderProps) {
|
||||
return (
|
||||
<Stack direction="row" align="center" justify="between" mb={4}>
|
||||
<Box>
|
||||
<Heading level={3} weight="bold" color="text-white">
|
||||
{title}
|
||||
</Heading>
|
||||
{description && (
|
||||
<Text size="xs" color="text-gray-500" block mt={0.5}>
|
||||
{description}
|
||||
</Text>
|
||||
)}
|
||||
</Box>
|
||||
{actions && (
|
||||
<Stack direction="row" align="center" gap={2}>
|
||||
{actions}
|
||||
</Stack>
|
||||
)}
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
45
apps/website/components/admin/AdminStatsPanel.tsx
Normal file
45
apps/website/components/admin/AdminStatsPanel.tsx
Normal file
@@ -0,0 +1,45 @@
|
||||
'use client';
|
||||
|
||||
import React from 'react';
|
||||
import { Grid } from '@/ui/Grid';
|
||||
import { StatCard } from '@/ui/StatCard';
|
||||
import { LucideIcon } from 'lucide-react';
|
||||
|
||||
interface AdminStat {
|
||||
label: string;
|
||||
value: string | number;
|
||||
icon: LucideIcon;
|
||||
variant?: 'blue' | 'purple' | 'green' | 'orange';
|
||||
trend?: {
|
||||
value: number;
|
||||
isPositive: boolean;
|
||||
};
|
||||
}
|
||||
|
||||
interface AdminStatsPanelProps {
|
||||
stats: AdminStat[];
|
||||
}
|
||||
|
||||
/**
|
||||
* AdminStatsPanel
|
||||
*
|
||||
* Semantic container for admin statistics.
|
||||
* Renders a grid of StatCards.
|
||||
*/
|
||||
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>
|
||||
);
|
||||
}
|
||||
37
apps/website/components/admin/AdminToolbar.tsx
Normal file
37
apps/website/components/admin/AdminToolbar.tsx
Normal file
@@ -0,0 +1,37 @@
|
||||
'use client';
|
||||
|
||||
import React from 'react';
|
||||
import { Card } from '@/ui/Card';
|
||||
import { Stack } from '@/ui/Stack';
|
||||
import { Box } from '@/ui/Box';
|
||||
|
||||
interface AdminToolbarProps {
|
||||
children: React.ReactNode;
|
||||
leftContent?: React.ReactNode;
|
||||
}
|
||||
|
||||
/**
|
||||
* AdminToolbar
|
||||
*
|
||||
* Semantic toolbar for admin pages.
|
||||
* Used for filters, search, and secondary actions.
|
||||
*/
|
||||
export function AdminToolbar({
|
||||
children,
|
||||
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 && (
|
||||
<Box flexGrow={1}>
|
||||
{leftContent}
|
||||
</Box>
|
||||
)}
|
||||
<Stack direction="row" align="center" gap={3} flexGrow={leftContent ? 0 : 1} wrap>
|
||||
{children}
|
||||
</Stack>
|
||||
</Stack>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
170
apps/website/components/admin/AdminUsersTable.tsx
Normal file
170
apps/website/components/admin/AdminUsersTable.tsx
Normal file
@@ -0,0 +1,170 @@
|
||||
'use client';
|
||||
|
||||
import React from 'react';
|
||||
import {
|
||||
Table,
|
||||
TableHead,
|
||||
TableBody,
|
||||
TableRow,
|
||||
TableHeader,
|
||||
TableCell
|
||||
} from '@/ui/Table';
|
||||
import { Stack } from '@/ui/Stack';
|
||||
import { Box } from '@/ui/Box';
|
||||
import { Text } from '@/ui/Text';
|
||||
import { Button } from '@/ui/Button';
|
||||
import { Icon } from '@/ui/Icon';
|
||||
import { SimpleCheckbox } from '@/ui/SimpleCheckbox';
|
||||
import { UserStatusTag } from './UserStatusTag';
|
||||
import { DateDisplay } from '@/lib/display-objects/DateDisplay';
|
||||
import { Shield, Trash2, MoreVertical } from 'lucide-react';
|
||||
import { AdminUsersViewData } from '@/lib/view-data/AdminUsersViewData';
|
||||
|
||||
interface AdminUsersTableProps {
|
||||
users: AdminUsersViewData['users'];
|
||||
selectedUserIds: string[];
|
||||
onSelectUser: (userId: string) => void;
|
||||
onSelectAll: () => void;
|
||||
onUpdateStatus: (userId: string, status: string) => void;
|
||||
onDeleteUser: (userId: string) => void;
|
||||
deletingUserId?: string | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* AdminUsersTable
|
||||
*
|
||||
* Semantic table for managing users.
|
||||
* High-density, instrument-grade UI.
|
||||
*/
|
||||
export function AdminUsersTable({
|
||||
users,
|
||||
selectedUserIds,
|
||||
onSelectUser,
|
||||
onSelectAll,
|
||||
onUpdateStatus,
|
||||
onDeleteUser,
|
||||
deletingUserId
|
||||
}: AdminUsersTableProps) {
|
||||
const allSelected = users.length > 0 && selectedUserIds.length === users.length;
|
||||
|
||||
return (
|
||||
<Table>
|
||||
<TableHead>
|
||||
<TableRow>
|
||||
<TableHeader width="10">
|
||||
<SimpleCheckbox
|
||||
checked={allSelected}
|
||||
onChange={onSelectAll}
|
||||
aria-label="Select all users"
|
||||
/>
|
||||
</TableHeader>
|
||||
<TableHeader>User</TableHeader>
|
||||
<TableHeader>Roles</TableHeader>
|
||||
<TableHeader>Status</TableHeader>
|
||||
<TableHeader>Last Login</TableHeader>
|
||||
<TableHeader textAlign="right">Actions</TableHeader>
|
||||
</TableRow>
|
||||
</TableHead>
|
||||
<TableBody>
|
||||
{users.map((user) => (
|
||||
<TableRow key={user.id} variant={selectedUserIds.includes(user.id) ? 'highlight' : 'default'}>
|
||||
<TableCell>
|
||||
<SimpleCheckbox
|
||||
checked={selectedUserIds.includes(user.id)}
|
||||
onChange={() => onSelectUser(user.id)}
|
||||
aria-label={`Select user ${user.displayName}`}
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Stack direction="row" align="center" gap={3}>
|
||||
<Box
|
||||
bg="bg-primary-blue/10"
|
||||
rounded="full"
|
||||
p={2}
|
||||
border
|
||||
borderColor="border-primary-blue/20"
|
||||
>
|
||||
<Icon icon={Shield} size={4} color="#198CFF" />
|
||||
</Box>
|
||||
<Box>
|
||||
<Text weight="semibold" color="text-white" block>
|
||||
{user.displayName}
|
||||
</Text>
|
||||
<Text size="xs" color="text-gray-500" block>
|
||||
{user.email}
|
||||
</Text>
|
||||
</Box>
|
||||
</Stack>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Stack direction="row" gap={1.5} wrap>
|
||||
{user.roles.map((role) => (
|
||||
<Box
|
||||
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>
|
||||
</Box>
|
||||
))}
|
||||
</Stack>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<UserStatusTag status={user.status} />
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Text size="sm" color="text-gray-400">
|
||||
{user.lastLoginAt ? DateDisplay.formatShort(user.lastLoginAt) : 'Never'}
|
||||
</Text>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Stack direction="row" align="center" justify="end" gap={2}>
|
||||
{user.status === 'active' ? (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="secondary"
|
||||
onClick={() => onUpdateStatus(user.id, 'suspended')}
|
||||
>
|
||||
Suspend
|
||||
</Button>
|
||||
) : user.status === 'suspended' ? (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="secondary"
|
||||
onClick={() => onUpdateStatus(user.id, 'active')}
|
||||
>
|
||||
Activate
|
||||
</Button>
|
||||
) : null}
|
||||
|
||||
<Button
|
||||
size="sm"
|
||||
variant="secondary"
|
||||
onClick={() => onDeleteUser(user.id)}
|
||||
disabled={deletingUserId === user.id}
|
||||
icon={<Icon icon={Trash2} size={3} />}
|
||||
>
|
||||
{deletingUserId === user.id ? '...' : ''}
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
icon={<Icon icon={MoreVertical} size={4} />}
|
||||
>
|
||||
{''}
|
||||
</Button>
|
||||
</Stack>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
);
|
||||
}
|
||||
93
apps/website/components/admin/BulkActionBar.tsx
Normal file
93
apps/website/components/admin/BulkActionBar.tsx
Normal file
@@ -0,0 +1,93 @@
|
||||
'use client';
|
||||
|
||||
import React from 'react';
|
||||
import { Stack } from '@/ui/Stack';
|
||||
import { Box } from '@/ui/Box';
|
||||
import { Text } from '@/ui/Text';
|
||||
import { Button } from '@/ui/Button';
|
||||
import { motion, AnimatePresence } from 'framer-motion';
|
||||
|
||||
interface BulkActionBarProps {
|
||||
selectedCount: number;
|
||||
actions: {
|
||||
label: string;
|
||||
onClick: () => void;
|
||||
variant?: 'primary' | 'secondary' | 'danger';
|
||||
icon?: React.ReactNode;
|
||||
}[];
|
||||
onClearSelection: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* BulkActionBar
|
||||
*
|
||||
* Floating action bar that appears when items are selected in a table.
|
||||
*/
|
||||
export function BulkActionBar({
|
||||
selectedCount,
|
||||
actions,
|
||||
onClearSelection
|
||||
}: BulkActionBarProps) {
|
||||
return (
|
||||
<AnimatePresence>
|
||||
{selectedCount > 0 && (
|
||||
<Box
|
||||
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"
|
||||
>
|
||||
<Stack direction="row" align="center" gap={8}>
|
||||
<Stack direction="row" align="center" gap={3}>
|
||||
<Box bg="bg-primary-blue" rounded="full" px={2} py={0.5}>
|
||||
<Text size="xs" weight="bold" color="text-white">
|
||||
{selectedCount}
|
||||
</Text>
|
||||
</Box>
|
||||
<Text size="sm" weight="medium" color="text-white">
|
||||
Items Selected
|
||||
</Text>
|
||||
</Stack>
|
||||
|
||||
<Box w="px" h="6" bg="bg-charcoal-outline" />
|
||||
|
||||
<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>
|
||||
</Box>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
);
|
||||
}
|
||||
@@ -2,14 +2,13 @@
|
||||
|
||||
import React from 'react';
|
||||
import { Filter, Search } from 'lucide-react';
|
||||
import { Card } from '@/ui/Card';
|
||||
import { Stack } from '@/ui/Stack';
|
||||
import { Text } from '@/ui/Text';
|
||||
import { Button } from '@/ui/Button';
|
||||
import { Grid } from '@/ui/Grid';
|
||||
import { Icon } from '@/ui/Icon';
|
||||
import { Input } from '@/ui/Input';
|
||||
import { Select } from '@/ui/Select';
|
||||
import { AdminToolbar } from './AdminToolbar';
|
||||
|
||||
interface UserFiltersProps {
|
||||
search: string;
|
||||
@@ -31,13 +30,11 @@ export function UserFilters({
|
||||
onClearFilters,
|
||||
}: UserFiltersProps) {
|
||||
return (
|
||||
<Card>
|
||||
<Stack gap={4}>
|
||||
<Stack direction="row" align="center" justify="between">
|
||||
<Stack direction="row" align="center" gap={2}>
|
||||
<Icon icon={Filter} size={4} color="#9ca3af" />
|
||||
<Text weight="medium" color="text-white">Filters</Text>
|
||||
</Stack>
|
||||
<AdminToolbar
|
||||
leftContent={
|
||||
<Stack direction="row" align="center" gap={2}>
|
||||
<Icon icon={Filter} size={4} color="#9ca3af" />
|
||||
<Text weight="medium" color="text-white">Filters</Text>
|
||||
{(search || roleFilter || statusFilter) && (
|
||||
<Button
|
||||
onClick={onClearFilters}
|
||||
@@ -48,39 +45,38 @@ export function UserFilters({
|
||||
</Button>
|
||||
)}
|
||||
</Stack>
|
||||
|
||||
<Grid cols={3} gap={4}>
|
||||
<Input
|
||||
type="text"
|
||||
placeholder="Search by email or name..."
|
||||
value={search}
|
||||
onChange={(e) => onSearch(e.target.value)}
|
||||
icon={<Icon icon={Search} size={4} color="#9ca3af" />}
|
||||
/>
|
||||
}
|
||||
>
|
||||
<Input
|
||||
type="text"
|
||||
placeholder="Search by email or name..."
|
||||
value={search}
|
||||
onChange={(e) => onSearch(e.target.value)}
|
||||
icon={<Icon icon={Search} size={4} color="#9ca3af" />}
|
||||
width="300px"
|
||||
/>
|
||||
|
||||
<Select
|
||||
value={roleFilter}
|
||||
onChange={(e) => onFilterRole(e.target.value)}
|
||||
options={[
|
||||
{ value: '', label: 'All Roles' },
|
||||
{ value: 'owner', label: 'Owner' },
|
||||
{ value: 'admin', label: 'Admin' },
|
||||
{ value: 'user', label: 'User' },
|
||||
]}
|
||||
/>
|
||||
<Select
|
||||
value={roleFilter}
|
||||
onChange={(e) => onFilterRole(e.target.value)}
|
||||
options={[
|
||||
{ value: '', label: 'All Roles' },
|
||||
{ value: 'owner', label: 'Owner' },
|
||||
{ value: 'admin', label: 'Admin' },
|
||||
{ value: 'user', label: 'User' },
|
||||
]}
|
||||
/>
|
||||
|
||||
<Select
|
||||
value={statusFilter}
|
||||
onChange={(e) => onFilterStatus(e.target.value)}
|
||||
options={[
|
||||
{ value: '', label: 'All Status' },
|
||||
{ value: 'active', label: 'Active' },
|
||||
{ value: 'suspended', label: 'Suspended' },
|
||||
{ value: 'deleted', label: 'Deleted' },
|
||||
]}
|
||||
/>
|
||||
</Grid>
|
||||
</Stack>
|
||||
</Card>
|
||||
<Select
|
||||
value={statusFilter}
|
||||
onChange={(e) => onFilterStatus(e.target.value)}
|
||||
options={[
|
||||
{ value: '', label: 'All Status' },
|
||||
{ value: 'active', label: 'Active' },
|
||||
{ value: 'suspended', label: 'Suspended' },
|
||||
{ value: 'deleted', label: 'Deleted' },
|
||||
]}
|
||||
/>
|
||||
</AdminToolbar>
|
||||
);
|
||||
}
|
||||
|
||||
68
apps/website/components/admin/UserStatusTag.tsx
Normal file
68
apps/website/components/admin/UserStatusTag.tsx
Normal file
@@ -0,0 +1,68 @@
|
||||
'use client';
|
||||
|
||||
import React from 'react';
|
||||
import { StatusBadge } from '@/ui/StatusBadge';
|
||||
import {
|
||||
CheckCircle2,
|
||||
AlertTriangle,
|
||||
XCircle,
|
||||
Clock,
|
||||
LucideIcon
|
||||
} from 'lucide-react';
|
||||
|
||||
export type UserStatus = 'active' | 'suspended' | 'deleted' | 'pending';
|
||||
|
||||
interface UserStatusTagProps {
|
||||
status: UserStatus | string;
|
||||
}
|
||||
|
||||
interface StatusConfig {
|
||||
variant: 'success' | 'warning' | 'error' | 'info' | 'neutral' | 'pending';
|
||||
icon: LucideIcon;
|
||||
label: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* UserStatusTag
|
||||
*
|
||||
* Semantic status indicator for users.
|
||||
* Maps status strings to appropriate visual variants and icons.
|
||||
*/
|
||||
export function UserStatusTag({ status }: UserStatusTagProps) {
|
||||
const normalizedStatus = status.toLowerCase() as UserStatus;
|
||||
|
||||
const config: Record<UserStatus, StatusConfig> = {
|
||||
active: {
|
||||
variant: 'success',
|
||||
icon: CheckCircle2,
|
||||
label: 'Active'
|
||||
},
|
||||
suspended: {
|
||||
variant: 'warning',
|
||||
icon: AlertTriangle,
|
||||
label: 'Suspended'
|
||||
},
|
||||
deleted: {
|
||||
variant: 'error',
|
||||
icon: XCircle,
|
||||
label: 'Deleted'
|
||||
},
|
||||
pending: {
|
||||
variant: 'pending',
|
||||
icon: Clock,
|
||||
label: 'Pending'
|
||||
}
|
||||
};
|
||||
|
||||
const { variant, icon, label } = config[normalizedStatus] || {
|
||||
variant: 'neutral',
|
||||
icon: Clock,
|
||||
label: status
|
||||
};
|
||||
|
||||
return (
|
||||
<StatusBadge variant={variant} icon={icon}>
|
||||
{label}
|
||||
</StatusBadge>
|
||||
);
|
||||
}
|
||||
47
apps/website/components/auth/AuthCard.tsx
Normal file
47
apps/website/components/auth/AuthCard.tsx
Normal file
@@ -0,0 +1,47 @@
|
||||
'use client';
|
||||
|
||||
import React from 'react';
|
||||
import { Box } from '@/ui/Box';
|
||||
import { Text } from '@/ui/Text';
|
||||
|
||||
interface AuthCardProps {
|
||||
children: React.ReactNode;
|
||||
title: string;
|
||||
description?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* AuthCard
|
||||
*
|
||||
* A matte surface container for auth forms with a subtle accent glow.
|
||||
*/
|
||||
export function AuthCard({ children, title, description }: AuthCardProps) {
|
||||
return (
|
||||
<Box bg="surface-charcoal" border borderColor="outline-steel" rounded="lg" shadow="card" position="relative" overflow="hidden">
|
||||
{/* Subtle top accent line */}
|
||||
<Box
|
||||
position="absolute"
|
||||
top="0"
|
||||
left="0"
|
||||
w="full"
|
||||
h="1px"
|
||||
bg="linear-gradient(to right, transparent, rgba(25, 140, 255, 0.3), transparent)"
|
||||
/>
|
||||
|
||||
<Box p={{ base: 6, md: 8 }}>
|
||||
<Box as="header" mb={8} textAlign="center">
|
||||
<Text as="h1" size="xl" weight="semibold" color="text-white" letterSpacing="tight" mb={2} block>
|
||||
{title}
|
||||
</Text>
|
||||
{description && (
|
||||
<Text size="sm" color="text-med" block>
|
||||
{description}
|
||||
</Text>
|
||||
)}
|
||||
</Box>
|
||||
|
||||
{children}
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
24
apps/website/components/auth/AuthFooterLinks.tsx
Normal file
24
apps/website/components/auth/AuthFooterLinks.tsx
Normal file
@@ -0,0 +1,24 @@
|
||||
'use client';
|
||||
|
||||
import React from 'react';
|
||||
import { Box } from '@/ui/Box';
|
||||
import { Stack } from '@/ui/Stack';
|
||||
|
||||
interface AuthFooterLinksProps {
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
/**
|
||||
* AuthFooterLinks
|
||||
*
|
||||
* Semantic container for links at the bottom of auth cards.
|
||||
*/
|
||||
export function AuthFooterLinks({ children }: AuthFooterLinksProps) {
|
||||
return (
|
||||
<Box as="footer" mt={8} pt={6} borderTop borderStyle="solid" borderColor="outline-steel">
|
||||
<Stack gap={3} align="center" textAlign="center">
|
||||
{children}
|
||||
</Stack>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
25
apps/website/components/auth/AuthForm.tsx
Normal file
25
apps/website/components/auth/AuthForm.tsx
Normal file
@@ -0,0 +1,25 @@
|
||||
'use client';
|
||||
|
||||
import React from 'react';
|
||||
import { Box } from '@/ui/Box';
|
||||
import { Stack } from '@/ui/Stack';
|
||||
|
||||
interface AuthFormProps {
|
||||
children: React.ReactNode;
|
||||
onSubmit: (e: React.FormEvent<HTMLFormElement>) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* AuthForm
|
||||
*
|
||||
* Semantic form wrapper for auth flows.
|
||||
*/
|
||||
export function AuthForm({ children, onSubmit }: AuthFormProps) {
|
||||
return (
|
||||
<Box as="form" onSubmit={onSubmit} noValidate>
|
||||
<Stack gap={6}>
|
||||
{children}
|
||||
</Stack>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
21
apps/website/components/auth/AuthProviderButtons.tsx
Normal file
21
apps/website/components/auth/AuthProviderButtons.tsx
Normal file
@@ -0,0 +1,21 @@
|
||||
'use client';
|
||||
|
||||
import React from 'react';
|
||||
import { Box } from '@/ui/Box';
|
||||
|
||||
interface AuthProviderButtonsProps {
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
/**
|
||||
* AuthProviderButtons
|
||||
*
|
||||
* Container for social login buttons (Google, Discord, etc.)
|
||||
*/
|
||||
export function AuthProviderButtons({ children }: AuthProviderButtonsProps) {
|
||||
return (
|
||||
<Box display="grid" gridCols={1} gap={3} mb={6}>
|
||||
{children}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
51
apps/website/components/auth/AuthShell.tsx
Normal file
51
apps/website/components/auth/AuthShell.tsx
Normal file
@@ -0,0 +1,51 @@
|
||||
'use client';
|
||||
|
||||
import React from 'react';
|
||||
import { Box } from '@/ui/Box';
|
||||
|
||||
interface AuthShellProps {
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
/**
|
||||
* AuthShell
|
||||
*
|
||||
* The outermost container for all authentication pages.
|
||||
* Provides the "calm intensity" background and centered layout.
|
||||
*/
|
||||
export function AuthShell({ children }: AuthShellProps) {
|
||||
return (
|
||||
<Box as="main" minHeight="100vh" display="flex" alignItems="center" justifyContent="center" p={4} bg="base-black" position="relative" overflow="hidden">
|
||||
{/* Subtle background glow - top right */}
|
||||
<Box
|
||||
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"
|
||||
/>
|
||||
{/* Subtle background glow - bottom left */}
|
||||
<Box
|
||||
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"
|
||||
/>
|
||||
|
||||
<Box w="full" maxWidth="400px" position="relative" zIndex={10} animate="fade-in">
|
||||
{children}
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
35
apps/website/components/dashboard/ActivityFeedPanel.tsx
Normal file
35
apps/website/components/dashboard/ActivityFeedPanel.tsx
Normal file
@@ -0,0 +1,35 @@
|
||||
import React from 'react';
|
||||
import { Panel } from '@/ui/Panel';
|
||||
import { Box } from '@/ui/Box';
|
||||
import { ActivityFeed } from '../feed/ActivityFeed';
|
||||
|
||||
interface FeedItem {
|
||||
id: string;
|
||||
type: string;
|
||||
headline: string;
|
||||
body?: string;
|
||||
timestamp: string;
|
||||
formattedTime: string;
|
||||
ctaHref?: string;
|
||||
ctaLabel?: string;
|
||||
}
|
||||
|
||||
interface ActivityFeedPanelProps {
|
||||
items: FeedItem[];
|
||||
hasItems: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* ActivityFeedPanel
|
||||
*
|
||||
* A semantic wrapper for the activity feed.
|
||||
*/
|
||||
export function ActivityFeedPanel({ items, hasItems }: ActivityFeedPanelProps) {
|
||||
return (
|
||||
<Panel title="Activity Feed" padding={0}>
|
||||
<Box px={6} pb={6}>
|
||||
<ActivityFeed items={items} hasItems={hasItems} />
|
||||
</Box>
|
||||
</Panel>
|
||||
);
|
||||
}
|
||||
28
apps/website/components/dashboard/DashboardControlBar.tsx
Normal file
28
apps/website/components/dashboard/DashboardControlBar.tsx
Normal file
@@ -0,0 +1,28 @@
|
||||
import React from 'react';
|
||||
import { Box } from '@/ui/Box';
|
||||
import { Heading } from '@/ui/Heading';
|
||||
import { Stack } from '@/ui/Stack';
|
||||
|
||||
interface DashboardControlBarProps {
|
||||
title: string;
|
||||
actions?: React.ReactNode;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 (
|
||||
<Box display="flex" h="full" alignItems="center" justifyContent="between" px={6}>
|
||||
<Heading level={6} weight="bold">
|
||||
{title}
|
||||
</Heading>
|
||||
<Stack direction="row" align="center" gap={4}>
|
||||
{actions}
|
||||
</Stack>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
@@ -1,5 +1,3 @@
|
||||
|
||||
|
||||
import { routes } from '@/lib/routing/RouteConfig';
|
||||
import { Button } from '@/ui/Button';
|
||||
import { DashboardHero as UiDashboardHero } from '@/ui/DashboardHero';
|
||||
@@ -48,10 +46,10 @@ export function DashboardHero({ currentDriver, activeLeaguesCount }: DashboardHe
|
||||
}
|
||||
stats={
|
||||
<>
|
||||
<StatBox icon={Trophy} label="Wins" value={currentDriver.wins} color="var(--performance-green)" />
|
||||
<StatBox icon={Medal} label="Podiums" value={currentDriver.podiums} color="var(--warning-amber)" />
|
||||
<StatBox icon={Target} label="Consistency" value={currentDriver.consistency} color="var(--primary-blue)" />
|
||||
<StatBox icon={Users} label="Active Leagues" value={activeLeaguesCount} color="var(--neon-purple)" />
|
||||
<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" />
|
||||
</>
|
||||
}
|
||||
/>
|
||||
|
||||
49
apps/website/components/dashboard/DashboardKpiRow.tsx
Normal file
49
apps/website/components/dashboard/DashboardKpiRow.tsx
Normal file
@@ -0,0 +1,49 @@
|
||||
import React from 'react';
|
||||
import { Box } from '@/ui/Box';
|
||||
import { Text } from '@/ui/Text';
|
||||
import { Grid } from '@/ui/Grid';
|
||||
|
||||
interface KpiItem {
|
||||
label: string;
|
||||
value: string | number;
|
||||
color?: string;
|
||||
}
|
||||
|
||||
interface DashboardKpiRowProps {
|
||||
items: KpiItem[];
|
||||
}
|
||||
|
||||
/**
|
||||
* DashboardKpiRow
|
||||
*
|
||||
* A horizontal row of key performance indicators with telemetry styling.
|
||||
* Uses UI primitives to comply with architectural constraints.
|
||||
*/
|
||||
export function DashboardKpiRow({ items }: DashboardKpiRowProps) {
|
||||
return (
|
||||
<Grid responsiveGridCols={{ base: 2, md: 3, lg: 6 }} gap={4}>
|
||||
{items.map((item, index) => (
|
||||
<Box key={index} borderLeft pl={4} borderColor="var(--color-outline)">
|
||||
<Text
|
||||
size="xs"
|
||||
weight="bold"
|
||||
uppercase
|
||||
letterSpacing="tighter"
|
||||
color="var(--color-text-low)"
|
||||
block
|
||||
>
|
||||
{item.label}
|
||||
</Text>
|
||||
<Text
|
||||
size="xl"
|
||||
font="mono"
|
||||
weight="bold"
|
||||
color={item.color || 'var(--color-text-high)'}
|
||||
>
|
||||
{item.value}
|
||||
</Text>
|
||||
</Box>
|
||||
))}
|
||||
</Grid>
|
||||
);
|
||||
}
|
||||
20
apps/website/components/dashboard/DashboardRail.tsx
Normal file
20
apps/website/components/dashboard/DashboardRail.tsx
Normal file
@@ -0,0 +1,20 @@
|
||||
import React from 'react';
|
||||
import { Box } from '@/ui/Box';
|
||||
|
||||
interface DashboardRailProps {
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
/**
|
||||
* DashboardRail
|
||||
*
|
||||
* A thin sidebar rail for high-level navigation and status indicators.
|
||||
* Uses UI primitives to comply with architectural constraints.
|
||||
*/
|
||||
export function DashboardRail({ children }: DashboardRailProps) {
|
||||
return (
|
||||
<Box as="nav" display="flex" h="full" flexDirection="col" alignItems="center" py={4} gap={4}>
|
||||
{children}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
39
apps/website/components/dashboard/DashboardShell.tsx
Normal file
39
apps/website/components/dashboard/DashboardShell.tsx
Normal file
@@ -0,0 +1,39 @@
|
||||
import React from 'react';
|
||||
import { Box } from '@/ui/Box';
|
||||
|
||||
interface DashboardShellProps {
|
||||
children: React.ReactNode;
|
||||
rail?: React.ReactNode;
|
||||
controlBar?: React.ReactNode;
|
||||
}
|
||||
|
||||
/**
|
||||
* DashboardShell
|
||||
*
|
||||
* 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 (
|
||||
<Box display="flex" h="screen" overflow="hidden" bg="base-black" color="white">
|
||||
{rail && (
|
||||
<Box as="aside" w="16" flexShrink={0} borderRight bg="surface-charcoal" borderColor="var(--color-outline)">
|
||||
{rail}
|
||||
</Box>
|
||||
)}
|
||||
<Box display="flex" flexGrow={1} flexDirection="col" overflow="hidden">
|
||||
{controlBar && (
|
||||
<Box as="header" h="14" borderBottom bg="surface-charcoal" borderColor="var(--color-outline)">
|
||||
{controlBar}
|
||||
</Box>
|
||||
)}
|
||||
<Box as="main" flexGrow={1} overflow="auto" p={6}>
|
||||
<Box maxWidth="7xl" mx="auto" display="flex" flexDirection="col" gap={6}>
|
||||
{children}
|
||||
</Box>
|
||||
</Box>
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
@@ -1,29 +0,0 @@
|
||||
|
||||
|
||||
import { routes } from '@/lib/routing/RouteConfig';
|
||||
import { Trophy, Users } from 'lucide-react';
|
||||
import { Box } from '@/ui/Box';
|
||||
import { Heading } from '@/ui/Heading';
|
||||
import { QuickActionItem } from '@/ui/QuickActionItem';
|
||||
|
||||
export function QuickActions() {
|
||||
return (
|
||||
<Box>
|
||||
<Heading level={3} mb={4}>Quick Actions</Heading>
|
||||
<Box display="flex" flexDirection="col" gap={2}>
|
||||
<QuickActionItem
|
||||
href={routes.public.leagues}
|
||||
label="Browse Leagues"
|
||||
icon={Users}
|
||||
iconVariant="blue"
|
||||
/>
|
||||
<QuickActionItem
|
||||
href={routes.public.leaderboards}
|
||||
label="View Leaderboards"
|
||||
icon={Trophy}
|
||||
iconVariant="amber"
|
||||
/>
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
74
apps/website/components/dashboard/RecentActivityTable.tsx
Normal file
74
apps/website/components/dashboard/RecentActivityTable.tsx
Normal file
@@ -0,0 +1,74 @@
|
||||
import React from 'react';
|
||||
import { Box } from '@/ui/Box';
|
||||
import { Text } from '@/ui/Text';
|
||||
import { StatusDot } from '@/ui/StatusDot';
|
||||
|
||||
export interface ActivityItem {
|
||||
id: string;
|
||||
type: string;
|
||||
description: string;
|
||||
timestamp: string;
|
||||
status?: 'success' | 'warning' | 'critical' | 'info';
|
||||
}
|
||||
|
||||
interface RecentActivityTableProps {
|
||||
items: ActivityItem[];
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 (
|
||||
<Box overflow="auto">
|
||||
<Box as="table" w="full" textAlign="left">
|
||||
<Box as="thead">
|
||||
<Box as="tr" borderBottom borderColor="var(--color-outline)">
|
||||
<Box as="th" pb={2}>
|
||||
<Text size="xs" weight="medium" uppercase letterSpacing="wider" color="var(--color-text-low)">Type</Text>
|
||||
</Box>
|
||||
<Box as="th" pb={2}>
|
||||
<Text size="xs" weight="medium" uppercase letterSpacing="wider" color="var(--color-text-low)">Description</Text>
|
||||
</Box>
|
||||
<Box as="th" pb={2}>
|
||||
<Text size="xs" weight="medium" uppercase letterSpacing="wider" color="var(--color-text-low)">Time</Text>
|
||||
</Box>
|
||||
<Box as="th" pb={2}>
|
||||
<Text size="xs" weight="medium" uppercase letterSpacing="wider" color="var(--color-text-low)">Status</Text>
|
||||
</Box>
|
||||
</Box>
|
||||
</Box>
|
||||
<Box as="tbody">
|
||||
{items.map((item) => (
|
||||
<Box key={item.id} as="tr" borderBottom borderColor="rgba(35, 39, 43, 0.5)" hoverBg="rgba(255, 255, 255, 0.05)" transition>
|
||||
<Box as="td" py={3}>
|
||||
<Text font="mono" color="var(--color-telemetry)" size="xs">{item.type}</Text>
|
||||
</Box>
|
||||
<Box as="td" py={3}>
|
||||
<Text color="var(--color-text-med)" size="xs">{item.description}</Text>
|
||||
</Box>
|
||||
<Box as="td" py={3}>
|
||||
<Text color="var(--color-text-low)" size="xs">{item.timestamp}</Text>
|
||||
</Box>
|
||||
<Box as="td" py={3}>
|
||||
<StatusDot color={getStatusColor(item.status)} size={1.5} />
|
||||
</Box>
|
||||
</Box>
|
||||
))}
|
||||
</Box>
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
28
apps/website/components/dashboard/TelemetryPanel.tsx
Normal file
28
apps/website/components/dashboard/TelemetryPanel.tsx
Normal file
@@ -0,0 +1,28 @@
|
||||
import React from 'react';
|
||||
import { Surface } from '@/ui/Surface';
|
||||
import { Heading } from '@/ui/Heading';
|
||||
import { Box } from '@/ui/Box';
|
||||
|
||||
interface TelemetryPanelProps {
|
||||
title: string;
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 (
|
||||
<Surface variant="dark" border rounded="sm" padding={4} shadow="sm">
|
||||
<Heading level={6} mb={4} color="var(--color-text-low)">
|
||||
{title}
|
||||
</Heading>
|
||||
<Box fontSize="sm">
|
||||
{children}
|
||||
</Box>
|
||||
</Surface>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
'use client';
|
||||
|
||||
import React from 'react';
|
||||
import { Heading } from '@/ui/Heading';
|
||||
import { Text } from '@/ui/Text';
|
||||
import { Box } from '@/ui/Box';
|
||||
|
||||
interface DriverPerformanceOverviewProps {
|
||||
stats: {
|
||||
wins: number;
|
||||
podiums: number;
|
||||
totalRaces: number;
|
||||
consistency: number;
|
||||
dnfs: number;
|
||||
bestFinish: number;
|
||||
avgFinish: number;
|
||||
};
|
||||
}
|
||||
|
||||
export function DriverPerformanceOverview({ stats }: DriverPerformanceOverviewProps) {
|
||||
const winRate = stats.totalRaces > 0 ? (stats.wins / stats.totalRaces) * 100 : 0;
|
||||
const podiumRate = stats.totalRaces > 0 ? (stats.podiums / stats.totalRaces) * 100 : 0;
|
||||
|
||||
const metrics = [
|
||||
{ label: 'Win Rate', value: `${winRate.toFixed(1)}%`, color: 'text-performance-green' },
|
||||
{ label: 'Podium Rate', value: `${podiumRate.toFixed(1)}%`, color: 'text-warning-amber' },
|
||||
{ label: 'Best Finish', value: `P${stats.bestFinish}`, color: 'text-white' },
|
||||
{ label: 'Avg Finish', value: `P${stats.avgFinish.toFixed(1)}`, color: 'text-gray-400' },
|
||||
{ label: 'Consistency', value: `${stats.consistency}%`, color: 'text-neon-aqua' },
|
||||
{ label: 'DNFs', value: stats.dnfs, color: 'text-red-500' },
|
||||
];
|
||||
|
||||
return (
|
||||
<Box display="flex" flexDirection="col" gap={6} rounded="2xl" border borderColor="border-charcoal-outline" bg="bg-deep-charcoal/50" p={6}>
|
||||
<Heading level={3}>Performance Overview</Heading>
|
||||
|
||||
<Box display="grid" gridCols={{ base: 2, md: 3, lg: 6 }} gap={6}>
|
||||
{metrics.map((metric, index) => (
|
||||
<Box key={index} display="flex" flexDirection="col" gap={1}>
|
||||
<Text size="xs" weight="bold" color="text-gray-500" uppercase letterSpacing="wider">
|
||||
{metric.label}
|
||||
</Text>
|
||||
<Text size="xl" weight="bold" font="mono" color={metric.color}>
|
||||
{metric.value}
|
||||
</Text>
|
||||
</Box>
|
||||
))}
|
||||
</Box>
|
||||
|
||||
{/* Visual Progress Bars */}
|
||||
<Box display="flex" flexDirection="col" gap={4} mt={2}>
|
||||
<Box display="flex" flexDirection="col" gap={2}>
|
||||
<Box display="flex" justifyContent="between" alignItems="center">
|
||||
<Text size="xs" weight="bold" color="text-gray-400">Win Rate</Text>
|
||||
<Text size="xs" weight="bold" font="mono" color="text-performance-green">{winRate.toFixed(1)}%</Text>
|
||||
</Box>
|
||||
<Box h="1.5" w="full" rounded="full" bg="bg-charcoal-outline" overflow="hidden">
|
||||
<Box
|
||||
h="full"
|
||||
bg="bg-performance-green"
|
||||
shadow="shadow-[0_0_8px_rgba(34,197,94,0.4)]"
|
||||
transition
|
||||
width={`${winRate}%`}
|
||||
/>
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
<Box display="flex" flexDirection="col" gap={2}>
|
||||
<Box display="flex" justifyContent="between" alignItems="center">
|
||||
<Text size="xs" weight="bold" color="text-gray-400">Podium Rate</Text>
|
||||
<Text size="xs" weight="bold" font="mono" color="text-warning-amber">{podiumRate.toFixed(1)}%</Text>
|
||||
</Box>
|
||||
<Box h="1.5" w="full" rounded="full" bg="bg-charcoal-outline" overflow="hidden">
|
||||
<Box
|
||||
h="full"
|
||||
bg="bg-warning-amber"
|
||||
shadow="shadow-[0_0_8px_rgba(255,190,77,0.4)]"
|
||||
transition
|
||||
width={`${podiumRate}%`}
|
||||
/>
|
||||
</Box>
|
||||
</Box>
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
106
apps/website/components/drivers/DriverProfileHeader.tsx
Normal file
106
apps/website/components/drivers/DriverProfileHeader.tsx
Normal file
@@ -0,0 +1,106 @@
|
||||
'use client';
|
||||
|
||||
import React from 'react';
|
||||
import { Globe, Trophy, UserPlus, Check } from 'lucide-react';
|
||||
import { Heading } from '@/ui/Heading';
|
||||
import { Text } from '@/ui/Text';
|
||||
import { Button } from '@/ui/Button';
|
||||
import { RatingBadge } from '@/ui/RatingBadge';
|
||||
import { Box } from '@/ui/Box';
|
||||
import { Stack } from '@/ui/Stack';
|
||||
import { Image } from '@/ui/Image';
|
||||
import { SafetyRatingBadge } from './SafetyRatingBadge';
|
||||
|
||||
interface DriverProfileHeaderProps {
|
||||
name: string;
|
||||
avatarUrl?: string | null;
|
||||
nationality: string;
|
||||
rating: number;
|
||||
safetyRating?: number;
|
||||
globalRank?: number;
|
||||
bio?: string | null;
|
||||
friendRequestSent: boolean;
|
||||
onAddFriend: () => void;
|
||||
}
|
||||
|
||||
export function DriverProfileHeader({
|
||||
name,
|
||||
avatarUrl,
|
||||
nationality,
|
||||
rating,
|
||||
safetyRating = 92,
|
||||
globalRank,
|
||||
bio,
|
||||
friendRequestSent,
|
||||
onAddFriend,
|
||||
}: DriverProfileHeaderProps) {
|
||||
const defaultAvatar = 'https://cdn.gridpilot.com/avatars/default.png';
|
||||
|
||||
return (
|
||||
<Box position="relative" overflow="hidden" rounded="2xl" border borderColor="border-charcoal-outline" bg="bg-deep-charcoal" p={{ base: 6, lg: 8 }}>
|
||||
{/* Background Accents */}
|
||||
<Box position="absolute" right="-24" top="-24" w="96" h="96" rounded="full" bg="bg-primary-blue/5" blur="3xl" />
|
||||
|
||||
<Box position="relative" display="flex" flexDirection={{ base: 'col', lg: 'row' }} gap={8}>
|
||||
{/* Avatar */}
|
||||
<Box position="relative" h={{ base: '32', lg: '40' }} w={{ base: '32', lg: '40' }} flexShrink={0} overflow="hidden" rounded="2xl" border={true} borderWidth="2px" borderColor="border-charcoal-outline" bg="bg-deep-graphite" shadow="2xl">
|
||||
<Image
|
||||
src={avatarUrl || defaultAvatar}
|
||||
alt={name}
|
||||
fill
|
||||
objectFit="cover"
|
||||
/>
|
||||
</Box>
|
||||
|
||||
{/* Info */}
|
||||
<Box display="flex" flexGrow={1} flexDirection="col" gap={4}>
|
||||
<Box display="flex" flexDirection={{ base: 'col', lg: 'row' }} alignItems={{ lg: 'center' }} justifyContent="between" gap={2}>
|
||||
<Box>
|
||||
<Stack direction="row" align="center" gap={3} mb={1}>
|
||||
<Heading level={1}>{name}</Heading>
|
||||
{globalRank && (
|
||||
<Box display="flex" alignItems="center" gap={1} rounded="md" bg="bg-warning-amber/10" px={2} py={0.5} border borderColor="border-warning-amber/20">
|
||||
<Trophy size={12} color="#FFBE4D" />
|
||||
<Text size="xs" weight="bold" font="mono" color="text-warning-amber">
|
||||
#{globalRank}
|
||||
</Text>
|
||||
</Box>
|
||||
)}
|
||||
</Stack>
|
||||
<Stack direction="row" align="center" gap={4}>
|
||||
<Stack direction="row" align="center" gap={1.5}>
|
||||
<Globe size={14} color="#6B7280" />
|
||||
<Text size="sm" color="text-gray-400">{nationality}</Text>
|
||||
</Stack>
|
||||
<Box w="1" h="1" rounded="full" bg="bg-gray-700" />
|
||||
<Stack direction="row" align="center" gap={2}>
|
||||
<RatingBadge rating={rating} size="sm" />
|
||||
<SafetyRatingBadge rating={safetyRating} size="sm" />
|
||||
</Stack>
|
||||
</Stack>
|
||||
</Box>
|
||||
|
||||
<Box mt={{ base: 4, lg: 0 }}>
|
||||
<Button
|
||||
variant={friendRequestSent ? 'secondary' : 'primary'}
|
||||
onClick={onAddFriend}
|
||||
disabled={friendRequestSent}
|
||||
icon={friendRequestSent ? <Check size={18} /> : <UserPlus size={18} />}
|
||||
>
|
||||
{friendRequestSent ? 'Request Sent' : 'Add Friend'}
|
||||
</Button>
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
{bio && (
|
||||
<Box maxWidth="3xl">
|
||||
<Text size="sm" color="text-gray-400" leading="relaxed">
|
||||
{bio}
|
||||
</Text>
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
57
apps/website/components/drivers/DriverProfileTabs.tsx
Normal file
57
apps/website/components/drivers/DriverProfileTabs.tsx
Normal file
@@ -0,0 +1,57 @@
|
||||
'use client';
|
||||
|
||||
import React from 'react';
|
||||
import { LayoutDashboard, BarChart3, ShieldCheck } from 'lucide-react';
|
||||
import { Text } from '@/ui/Text';
|
||||
import { Box } from '@/ui/Box';
|
||||
|
||||
export type ProfileTab = 'overview' | 'stats' | 'ratings';
|
||||
|
||||
interface DriverProfileTabsProps {
|
||||
activeTab: ProfileTab;
|
||||
onTabChange: (tab: ProfileTab) => void;
|
||||
}
|
||||
|
||||
export function DriverProfileTabs({ activeTab, onTabChange }: DriverProfileTabsProps) {
|
||||
const tabs = [
|
||||
{ id: 'overview', label: 'Overview', icon: LayoutDashboard },
|
||||
{ id: 'stats', label: 'Career Stats', icon: BarChart3 },
|
||||
{ id: 'ratings', label: 'Ratings', icon: ShieldCheck },
|
||||
] as const;
|
||||
|
||||
return (
|
||||
<Box display="flex" alignItems="center" gap={1} borderBottom borderColor="border-charcoal-outline">
|
||||
{tabs.map((tab) => {
|
||||
const isActive = activeTab === tab.id;
|
||||
const Icon = tab.icon;
|
||||
|
||||
return (
|
||||
<Box
|
||||
as="button"
|
||||
key={tab.id}
|
||||
onClick={() => onTabChange(tab.id)}
|
||||
position="relative"
|
||||
display="flex"
|
||||
alignItems="center"
|
||||
gap={2}
|
||||
px={6}
|
||||
py={4}
|
||||
transition
|
||||
hoverBg="bg-white/5"
|
||||
color={isActive ? 'text-primary-blue' : 'text-gray-500'}
|
||||
hoverTextColor={isActive ? 'text-primary-blue' : 'text-gray-300'}
|
||||
>
|
||||
<Icon size={18} />
|
||||
<Text size="sm" weight={isActive ? 'bold' : 'medium'} color="inherit">
|
||||
{tab.label}
|
||||
</Text>
|
||||
|
||||
{isActive && (
|
||||
<Box position="absolute" bottom="0" left="0" h="0.5" w="full" bg="bg-primary-blue" shadow="shadow-[0_0_8px_rgba(25,140,255,0.5)]" />
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
})}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
76
apps/website/components/drivers/DriverRacingProfile.tsx
Normal file
76
apps/website/components/drivers/DriverRacingProfile.tsx
Normal file
@@ -0,0 +1,76 @@
|
||||
'use client';
|
||||
|
||||
import React from 'react';
|
||||
import { Heading } from '@/ui/Heading';
|
||||
import { Text } from '@/ui/Text';
|
||||
import { Box } from '@/ui/Box';
|
||||
import { Stack } from '@/ui/Stack';
|
||||
import { MapPin, Car, Clock, Users2, MailCheck } from 'lucide-react';
|
||||
|
||||
interface DriverRacingProfileProps {
|
||||
racingStyle?: string | null;
|
||||
favoriteTrack?: string | null;
|
||||
favoriteCar?: string | null;
|
||||
availableHours?: string | null;
|
||||
lookingForTeam?: boolean;
|
||||
openToRequests?: boolean;
|
||||
}
|
||||
|
||||
export function DriverRacingProfile({
|
||||
racingStyle,
|
||||
favoriteTrack,
|
||||
favoriteCar,
|
||||
availableHours,
|
||||
lookingForTeam,
|
||||
openToRequests,
|
||||
}: DriverRacingProfileProps) {
|
||||
const details = [
|
||||
{ label: 'Racing Style', value: racingStyle || 'Not specified', icon: Users2 },
|
||||
{ label: 'Favorite Track', value: favoriteTrack || 'Not specified', icon: MapPin },
|
||||
{ label: 'Favorite Car', value: favoriteCar || 'Not specified', icon: Car },
|
||||
{ label: 'Availability', value: availableHours || 'Not specified', icon: Clock },
|
||||
];
|
||||
|
||||
return (
|
||||
<Box display="flex" flexDirection="col" gap={6} rounded="2xl" border borderColor="border-charcoal-outline" bg="bg-deep-charcoal/50" p={6}>
|
||||
<Box display="flex" alignItems="center" justifyContent="between">
|
||||
<Heading level={3}>Racing Profile</Heading>
|
||||
<Stack direction="row" gap={2}>
|
||||
{lookingForTeam && (
|
||||
<Box display="flex" alignItems="center" gap={1.5} rounded="full" bg="bg-primary-blue/10" border borderColor="border-primary-blue/20" px={3} py={1}>
|
||||
<Users2 size={12} color="#198CFF" />
|
||||
<Text size="xs" weight="bold" color="text-primary-blue" uppercase letterSpacing="tight">Looking for Team</Text>
|
||||
</Box>
|
||||
)}
|
||||
{openToRequests && (
|
||||
<Box display="flex" alignItems="center" gap={1.5} rounded="full" bg="bg-performance-green/10" border borderColor="border-performance-green/20" px={3} py={1}>
|
||||
<MailCheck size={12} color="#22C55E" />
|
||||
<Text size="xs" weight="bold" color="text-performance-green" uppercase letterSpacing="tight">Open to Requests</Text>
|
||||
</Box>
|
||||
)}
|
||||
</Stack>
|
||||
</Box>
|
||||
|
||||
<Box display="grid" gridCols={{ base: 1, sm: 2 }} gap={4}>
|
||||
{details.map((detail, index) => {
|
||||
const Icon = detail.icon;
|
||||
return (
|
||||
<Box key={index} display="flex" alignItems="center" gap={4} rounded="xl" border borderColor="border-charcoal-outline/50" bg="bg-deep-graphite/50" p={4}>
|
||||
<Box display="flex" h="10" w="10" alignItems="center" justifyContent="center" rounded="lg" bg="bg-charcoal-outline/50" color="text-gray-400">
|
||||
<Icon size={20} />
|
||||
</Box>
|
||||
<Box display="flex" flexDirection="col">
|
||||
<Text size="xs" weight="bold" color="text-gray-500" uppercase letterSpacing="wider">
|
||||
{detail.label}
|
||||
</Text>
|
||||
<Text size="sm" weight="semibold" color="text-white">
|
||||
{detail.value}
|
||||
</Text>
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
})}
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
24
apps/website/components/drivers/DriverSearchBar.tsx
Normal file
24
apps/website/components/drivers/DriverSearchBar.tsx
Normal file
@@ -0,0 +1,24 @@
|
||||
'use client';
|
||||
|
||||
import React from 'react';
|
||||
import { Search } from 'lucide-react';
|
||||
import { Box } from '@/ui/Box';
|
||||
import { Input } from '@/ui/Input';
|
||||
|
||||
interface DriverSearchBarProps {
|
||||
query: string;
|
||||
onChange: (query: string) => void;
|
||||
}
|
||||
|
||||
export function DriverSearchBar({ query, onChange }: DriverSearchBarProps) {
|
||||
return (
|
||||
<Box position="relative" group>
|
||||
<Input
|
||||
value={query}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
placeholder="Search drivers by name or nationality..."
|
||||
icon={<Search size={20} />}
|
||||
/>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
45
apps/website/components/drivers/DriverStatsPanel.tsx
Normal file
45
apps/website/components/drivers/DriverStatsPanel.tsx
Normal file
@@ -0,0 +1,45 @@
|
||||
'use client';
|
||||
|
||||
import React from 'react';
|
||||
import { Text } from '@/ui/Text';
|
||||
import { Box } from '@/ui/Box';
|
||||
|
||||
interface StatItem {
|
||||
label: string;
|
||||
value: string | number;
|
||||
subValue?: string;
|
||||
color?: string;
|
||||
}
|
||||
|
||||
interface DriverStatsPanelProps {
|
||||
stats: StatItem[];
|
||||
}
|
||||
|
||||
export function DriverStatsPanel({ stats }: DriverStatsPanelProps) {
|
||||
return (
|
||||
<Box display="grid" gridCols={{ base: 2, sm: 3, lg: 6 }} gap="px" overflow="hidden" rounded="xl" border borderColor="border-charcoal-outline" bg="bg-charcoal-outline">
|
||||
{stats.map((stat, index) => (
|
||||
<Box key={index} display="flex" flexDirection="col" gap={1} bg="bg-deep-charcoal" p={5} transition hoverBg="bg-deep-charcoal/80">
|
||||
<Text size="xs" weight="bold" color="text-gray-500" uppercase letterSpacing="wider">
|
||||
{stat.label}
|
||||
</Text>
|
||||
<Box display="flex" alignItems="baseline" gap={1.5}>
|
||||
<Text
|
||||
size="2xl"
|
||||
weight="bold"
|
||||
font="mono"
|
||||
color={stat.color || 'text-white'}
|
||||
>
|
||||
{stat.value}
|
||||
</Text>
|
||||
{stat.subValue && (
|
||||
<Text size="xs" weight="bold" color="text-gray-600" font="mono">
|
||||
{stat.subValue}
|
||||
</Text>
|
||||
)}
|
||||
</Box>
|
||||
</Box>
|
||||
))}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
45
apps/website/components/drivers/DriverTable.tsx
Normal file
45
apps/website/components/drivers/DriverTable.tsx
Normal file
@@ -0,0 +1,45 @@
|
||||
'use client';
|
||||
|
||||
import React from 'react';
|
||||
import { TrendingUp } from 'lucide-react';
|
||||
import { Heading } from '@/ui/Heading';
|
||||
import { Text } from '@/ui/Text';
|
||||
import { Box } from '@/ui/Box';
|
||||
import { Stack } from '@/ui/Stack';
|
||||
|
||||
interface DriverTableProps {
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
export function DriverTable({ children }: DriverTableProps) {
|
||||
return (
|
||||
<Stack gap={4}>
|
||||
<Stack direction="row" align="center" gap={3}>
|
||||
<Box display="flex" h="10" w="10" alignItems="center" justifyContent="center" rounded="xl" bg="bg-primary-blue/10" border borderColor="border-primary-blue/20">
|
||||
<TrendingUp size={20} color="#198CFF" />
|
||||
</Box>
|
||||
<Box>
|
||||
<Heading level={2}>Driver Rankings</Heading>
|
||||
<Text size="xs" color="text-gray-500">Top performers by skill rating</Text>
|
||||
</Box>
|
||||
</Stack>
|
||||
|
||||
<Box overflow="hidden" rounded="xl" border borderColor="border-charcoal-outline" bg="bg-deep-charcoal/50">
|
||||
<Box as="table" w="full" textAlign="left">
|
||||
<Box as="thead">
|
||||
<Box as="tr" borderBottom borderColor="border-charcoal-outline" bg="bg-deep-charcoal/80">
|
||||
<Box as="th" px={6} py={4} fontSize="xs" color="text-gray-500" textAlign="center" width="60px">#</Box>
|
||||
<Box as="th" px={6} py={4} fontSize="xs" color="text-gray-500">Driver</Box>
|
||||
<Box as="th" px={6} py={4} fontSize="xs" color="text-gray-500" width="150px">Nationality</Box>
|
||||
<Box as="th" px={6} py={4} fontSize="xs" color="text-gray-500" textAlign="right" width="100px">Rating</Box>
|
||||
<Box as="th" px={6} py={4} fontSize="xs" color="text-gray-500" textAlign="right" width="80px">Wins</Box>
|
||||
</Box>
|
||||
</Box>
|
||||
<Box as="tbody">
|
||||
{children}
|
||||
</Box>
|
||||
</Box>
|
||||
</Box>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
86
apps/website/components/drivers/DriverTableRow.tsx
Normal file
86
apps/website/components/drivers/DriverTableRow.tsx
Normal file
@@ -0,0 +1,86 @@
|
||||
'use client';
|
||||
|
||||
import React from 'react';
|
||||
import { RatingBadge } from '@/ui/RatingBadge';
|
||||
import { Text } from '@/ui/Text';
|
||||
import { Box } from '@/ui/Box';
|
||||
import { Stack } from '@/ui/Stack';
|
||||
import { Image } from '@/ui/Image';
|
||||
|
||||
interface DriverTableRowProps {
|
||||
rank: number;
|
||||
name: string;
|
||||
avatarUrl?: string | null;
|
||||
nationality: string;
|
||||
rating: number;
|
||||
wins: number;
|
||||
onClick: () => void;
|
||||
}
|
||||
|
||||
export function DriverTableRow({
|
||||
rank,
|
||||
name,
|
||||
avatarUrl,
|
||||
nationality,
|
||||
rating,
|
||||
wins,
|
||||
onClick,
|
||||
}: DriverTableRowProps) {
|
||||
const defaultAvatar = 'https://cdn.gridpilot.com/avatars/default.png';
|
||||
|
||||
return (
|
||||
<Box
|
||||
as="tr"
|
||||
onClick={onClick}
|
||||
cursor="pointer"
|
||||
transition
|
||||
hoverBg="bg-primary-blue/5"
|
||||
group
|
||||
borderBottom
|
||||
borderColor="border-charcoal-outline/50"
|
||||
>
|
||||
<Box as="td" px={6} py={4} textAlign="center">
|
||||
<Text
|
||||
size="sm"
|
||||
weight="bold"
|
||||
font="mono"
|
||||
color={rank <= 3 ? 'text-warning-amber' : 'text-gray-500'}
|
||||
>
|
||||
{rank}
|
||||
</Text>
|
||||
</Box>
|
||||
<Box as="td" px={6} py={4}>
|
||||
<Stack direction="row" align="center" gap={3}>
|
||||
<Box position="relative" h="8" w="8" overflow="hidden" rounded="full" border borderColor="border-charcoal-outline" bg="bg-deep-charcoal">
|
||||
<Image
|
||||
src={avatarUrl || defaultAvatar}
|
||||
alt={name}
|
||||
fill
|
||||
objectFit="cover"
|
||||
/>
|
||||
</Box>
|
||||
<Text
|
||||
size="sm"
|
||||
weight="semibold"
|
||||
color="text-white"
|
||||
groupHoverTextColor="text-primary-blue"
|
||||
transition
|
||||
>
|
||||
{name}
|
||||
</Text>
|
||||
</Stack>
|
||||
</Box>
|
||||
<Box as="td" px={6} py={4}>
|
||||
<Text size="xs" color="text-gray-400">{nationality}</Text>
|
||||
</Box>
|
||||
<Box as="td" px={6} py={4} textAlign="right">
|
||||
<RatingBadge rating={rating} size="sm" />
|
||||
</Box>
|
||||
<Box as="td" px={6} py={4} textAlign="right">
|
||||
<Text size="sm" weight="semibold" font="mono" color="text-performance-green">
|
||||
{wins}
|
||||
</Text>
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
101
apps/website/components/drivers/DriversDirectoryHeader.tsx
Normal file
101
apps/website/components/drivers/DriversDirectoryHeader.tsx
Normal file
@@ -0,0 +1,101 @@
|
||||
'use client';
|
||||
|
||||
import React from 'react';
|
||||
import { Users, Trophy } from 'lucide-react';
|
||||
import { Heading } from '@/ui/Heading';
|
||||
import { Text } from '@/ui/Text';
|
||||
import { Button } from '@/ui/Button';
|
||||
import { Box } from '@/ui/Box';
|
||||
import { Stack } from '@/ui/Stack';
|
||||
|
||||
interface DriverStat {
|
||||
label: string;
|
||||
value: string | number;
|
||||
color?: string;
|
||||
animate?: boolean;
|
||||
}
|
||||
|
||||
interface DriversDirectoryHeaderProps {
|
||||
totalDrivers: number;
|
||||
activeDrivers: number;
|
||||
totalWins: number;
|
||||
totalRaces: number;
|
||||
onViewLeaderboard: () => void;
|
||||
}
|
||||
|
||||
export function DriversDirectoryHeader({
|
||||
totalDrivers,
|
||||
activeDrivers,
|
||||
totalWins,
|
||||
totalRaces,
|
||||
onViewLeaderboard,
|
||||
}: DriversDirectoryHeaderProps) {
|
||||
const stats: DriverStat[] = [
|
||||
{ label: 'drivers', value: totalDrivers, color: 'text-primary-blue' },
|
||||
{ label: 'active', value: activeDrivers, color: 'text-performance-green', animate: true },
|
||||
{ label: 'total wins', value: totalWins.toLocaleString(), color: 'text-warning-amber' },
|
||||
{ label: 'races', value: totalRaces.toLocaleString(), color: 'text-neon-aqua' },
|
||||
];
|
||||
|
||||
return (
|
||||
<Box
|
||||
as="header"
|
||||
position="relative"
|
||||
overflow="hidden"
|
||||
rounded="2xl"
|
||||
border
|
||||
borderColor="border-charcoal-outline/50"
|
||||
bg="bg-gradient-to-br from-iron-gray/80 via-deep-graphite to-iron-gray/60"
|
||||
p={{ base: 8, lg: 10 }}
|
||||
>
|
||||
{/* Background Accents */}
|
||||
<Box position="absolute" right="-24" top="-24" w="96" h="96" rounded="full" bg="bg-primary-blue/5" blur="3xl" />
|
||||
<Box position="absolute" bottom="-16" left="-16" w="64" h="64" rounded="full" bg="bg-neon-aqua/5" blur="3xl" />
|
||||
|
||||
<Box position="relative" display="flex" flexDirection={{ base: 'col', lg: 'row' }} alignItems={{ lg: 'center' }} justifyContent="between" gap={8}>
|
||||
<Box maxWidth="2xl">
|
||||
<Stack direction="row" align="center" gap={3} mb={4}>
|
||||
<Box display="flex" h="12" w="12" alignItems="center" justifyContent="center" rounded="xl" border borderColor="border-charcoal-outline" bg="bg-deep-charcoal" shadow="lg">
|
||||
<Users size={24} color="#198CFF" />
|
||||
</Box>
|
||||
<Heading level={1}>Drivers</Heading>
|
||||
</Stack>
|
||||
|
||||
<Text size="lg" color="text-gray-400" block leading="relaxed">
|
||||
Meet the racers who make every lap count. From rookies to champions, track their journey and see who's dominating the grid.
|
||||
</Text>
|
||||
|
||||
<Box display="flex" flexWrap="wrap" gap={6} mt={6}>
|
||||
{stats.map((stat, index) => (
|
||||
<Stack key={index} direction="row" align="center" gap={2}>
|
||||
<Box
|
||||
w="2"
|
||||
h="2"
|
||||
rounded="full"
|
||||
bg={stat.color?.replace('text-', 'bg-') || 'bg-primary-blue'}
|
||||
animate={stat.animate ? 'pulse' : 'none'}
|
||||
/>
|
||||
<Text size="sm" color="text-gray-400">
|
||||
<Text as="span" weight="semibold" color="text-white">{stat.value}</Text> {stat.label}
|
||||
</Text>
|
||||
</Stack>
|
||||
))}
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
<Stack gap={2}>
|
||||
<Button
|
||||
variant="primary"
|
||||
onClick={onViewLeaderboard}
|
||||
icon={<Trophy size={20} />}
|
||||
>
|
||||
View Leaderboard
|
||||
</Button>
|
||||
<Text size="xs" color="text-gray-500" align="center" block>
|
||||
See full driver rankings
|
||||
</Text>
|
||||
</Stack>
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
73
apps/website/components/drivers/SafetyRatingBadge.tsx
Normal file
73
apps/website/components/drivers/SafetyRatingBadge.tsx
Normal file
@@ -0,0 +1,73 @@
|
||||
'use client';
|
||||
|
||||
import React from 'react';
|
||||
import { Shield } from 'lucide-react';
|
||||
import { Text } from '@/ui/Text';
|
||||
import { Box } from '@/ui/Box';
|
||||
|
||||
interface SafetyRatingBadgeProps {
|
||||
rating: number;
|
||||
size?: 'sm' | 'md' | 'lg';
|
||||
}
|
||||
|
||||
export function SafetyRatingBadge({ rating, size = 'md' }: SafetyRatingBadgeProps) {
|
||||
const getColor = (r: number) => {
|
||||
if (r >= 90) return 'text-performance-green';
|
||||
if (r >= 70) return 'text-warning-amber';
|
||||
return 'text-red-500';
|
||||
};
|
||||
|
||||
const getBgColor = (r: number) => {
|
||||
if (r >= 90) return 'bg-performance-green/10';
|
||||
if (r >= 70) return 'bg-warning-amber/10';
|
||||
return 'bg-red-500/10';
|
||||
};
|
||||
|
||||
const getBorderColor = (r: number) => {
|
||||
if (r >= 90) return 'border-performance-green/20';
|
||||
if (r >= 70) return 'border-warning-amber/20';
|
||||
return 'border-red-500/20';
|
||||
};
|
||||
|
||||
const sizeProps = {
|
||||
sm: { px: 2, py: 0.5, gap: 1 },
|
||||
md: { px: 3, py: 1, gap: 1.5 },
|
||||
lg: { px: 4, py: 2, gap: 2 },
|
||||
};
|
||||
|
||||
const iconSizes = {
|
||||
sm: 12,
|
||||
md: 14,
|
||||
lg: 16,
|
||||
};
|
||||
|
||||
const iconColors = {
|
||||
'text-performance-green': '#22C55E',
|
||||
'text-warning-amber': '#FFBE4D',
|
||||
'text-red-500': '#EF4444',
|
||||
};
|
||||
|
||||
const colorClass = getColor(rating);
|
||||
|
||||
return (
|
||||
<Box
|
||||
display="inline-flex"
|
||||
alignItems="center"
|
||||
rounded="full"
|
||||
border
|
||||
bg={getBgColor(rating)}
|
||||
borderColor={getBorderColor(rating)}
|
||||
{...sizeProps[size]}
|
||||
>
|
||||
<Shield size={iconSizes[size]} color={iconColors[colorClass as keyof typeof iconColors]} />
|
||||
<Text
|
||||
size={size === 'lg' ? 'sm' : 'xs'}
|
||||
weight="bold"
|
||||
font="mono"
|
||||
color={colorClass}
|
||||
>
|
||||
SR {rating.toFixed(0)}
|
||||
</Text>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
53
apps/website/components/errors/AppErrorBoundaryView.tsx
Normal file
53
apps/website/components/errors/AppErrorBoundaryView.tsx
Normal file
@@ -0,0 +1,53 @@
|
||||
'use client';
|
||||
|
||||
import React from 'react';
|
||||
import { AlertTriangle } from 'lucide-react';
|
||||
import { Box } from '@/ui/Box';
|
||||
import { Icon } from '@/ui/Icon';
|
||||
import { Heading } from '@/ui/Heading';
|
||||
import { Text } from '@/ui/Text';
|
||||
import { Stack } from '@/ui/Stack';
|
||||
|
||||
interface AppErrorBoundaryViewProps {
|
||||
title: string;
|
||||
description: string;
|
||||
children?: React.ReactNode;
|
||||
}
|
||||
|
||||
/**
|
||||
* AppErrorBoundaryView
|
||||
*
|
||||
* Semantic container for error boundary content.
|
||||
* Follows "Precision Racing Minimal" theme.
|
||||
*/
|
||||
export function AppErrorBoundaryView({ title, description, children }: AppErrorBoundaryViewProps) {
|
||||
return (
|
||||
<Stack gap={6} align="center" fullWidth>
|
||||
{/* Header Icon */}
|
||||
<Box
|
||||
p={4}
|
||||
rounded="full"
|
||||
bg="bg-warning-amber"
|
||||
bgOpacity={0.1}
|
||||
border
|
||||
borderColor="border-warning-amber"
|
||||
>
|
||||
<Icon icon={AlertTriangle} size={8} color="var(--warning-amber)" />
|
||||
</Box>
|
||||
|
||||
{/* Typography */}
|
||||
<Stack gap={2} align="center">
|
||||
<Heading level={1} weight="bold">
|
||||
<Text uppercase letterSpacing="tighter">
|
||||
{title}
|
||||
</Text>
|
||||
</Heading>
|
||||
<Text color="text-gray-400" align="center" maxWidth="md" leading="relaxed">
|
||||
{description}
|
||||
</Text>
|
||||
</Stack>
|
||||
|
||||
{children}
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
106
apps/website/components/errors/ErrorDetails.tsx
Normal file
106
apps/website/components/errors/ErrorDetails.tsx
Normal file
@@ -0,0 +1,106 @@
|
||||
'use client';
|
||||
|
||||
import React, { useState } from 'react';
|
||||
import { ChevronDown, ChevronUp, Copy, Terminal } from 'lucide-react';
|
||||
import { Box } from '@/ui/Box';
|
||||
import { Stack } from '@/ui/Stack';
|
||||
import { Text } from '@/ui/Text';
|
||||
import { Button } from '@/ui/Button';
|
||||
import { Icon } from '@/ui/Icon';
|
||||
import { Surface } from '@/ui/Surface';
|
||||
|
||||
interface ErrorDetailsProps {
|
||||
error: Error & { digest?: string };
|
||||
}
|
||||
|
||||
/**
|
||||
* ErrorDetails
|
||||
*
|
||||
* Handles the display of technical error information with a toggle.
|
||||
* Part of the 500 route redesign.
|
||||
*/
|
||||
export function ErrorDetails({ error }: ErrorDetailsProps) {
|
||||
const [showDetails, setShowDetails] = useState(false);
|
||||
const [copied, setCopied] = useState(false);
|
||||
|
||||
const copyError = async () => {
|
||||
const details = {
|
||||
message: error.message,
|
||||
digest: error.digest,
|
||||
stack: error.stack,
|
||||
url: typeof window !== 'undefined' ? window.location.href : 'unknown',
|
||||
timestamp: new Date().toISOString(),
|
||||
};
|
||||
|
||||
try {
|
||||
await navigator.clipboard.writeText(JSON.stringify(details, null, 2));
|
||||
setCopied(true);
|
||||
setTimeout(() => setCopied(false), 2000);
|
||||
} catch (err) {
|
||||
// Silent fail
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Stack gap={4} fullWidth pt={4} borderTop borderColor="border-white">
|
||||
<Box
|
||||
as="button"
|
||||
onClick={() => setShowDetails(!showDetails)}
|
||||
display="flex"
|
||||
alignItems="center"
|
||||
justifyContent="center"
|
||||
gap={2}
|
||||
color="text-gray-500"
|
||||
hoverTextColor="text-gray-300"
|
||||
transition
|
||||
>
|
||||
<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} />}
|
||||
</Box>
|
||||
|
||||
{showDetails && (
|
||||
<Stack gap={3}>
|
||||
<Surface
|
||||
variant="dark"
|
||||
rounded="md"
|
||||
padding={4}
|
||||
fullWidth
|
||||
maxHeight="48"
|
||||
overflow="auto"
|
||||
border
|
||||
borderColor="border-white"
|
||||
bgOpacity={0.4}
|
||||
hideScrollbar={false}
|
||||
>
|
||||
<Text font="mono" size="xs" color="text-gray-500" block leading="relaxed">
|
||||
{error.stack || 'No stack trace available'}
|
||||
{error.digest && `\n\nDigest: ${error.digest}`}
|
||||
</Text>
|
||||
</Surface>
|
||||
|
||||
<Box display="flex" justifyContent="end">
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
onClick={copyError}
|
||||
icon={<Icon icon={Copy} size={3} />}
|
||||
height="8"
|
||||
fontSize="10px"
|
||||
>
|
||||
{copied ? 'Copied to Clipboard' : 'Copy Error Details'}
|
||||
</Button>
|
||||
</Box>
|
||||
</Stack>
|
||||
)}
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
107
apps/website/components/errors/ErrorDetailsBlock.tsx
Normal file
107
apps/website/components/errors/ErrorDetailsBlock.tsx
Normal file
@@ -0,0 +1,107 @@
|
||||
'use client';
|
||||
|
||||
import React, { useState } from 'react';
|
||||
import { Copy, ChevronDown, ChevronUp } from 'lucide-react';
|
||||
import { Box } from '@/ui/Box';
|
||||
import { Surface } from '@/ui/Surface';
|
||||
import { Text } from '@/ui/Text';
|
||||
import { Button } from '@/ui/Button';
|
||||
import { Icon } from '@/ui/Icon';
|
||||
import { Stack } from '@/ui/Stack';
|
||||
|
||||
interface ErrorDetailsBlockProps {
|
||||
error: Error & { digest?: string };
|
||||
}
|
||||
|
||||
/**
|
||||
* ErrorDetailsBlock
|
||||
*
|
||||
* Semantic component for technical error details.
|
||||
* Follows "Precision Racing Minimal" theme.
|
||||
*/
|
||||
export function ErrorDetailsBlock({ error }: ErrorDetailsBlockProps) {
|
||||
const [showDetails, setShowDetails] = useState(false);
|
||||
const [copied, setCopied] = useState(false);
|
||||
|
||||
const copyError = async () => {
|
||||
const details = {
|
||||
message: error.message,
|
||||
digest: error.digest,
|
||||
stack: error.stack,
|
||||
url: typeof window !== 'undefined' ? window.location.href : 'unknown',
|
||||
timestamp: new Date().toISOString(),
|
||||
};
|
||||
|
||||
try {
|
||||
await navigator.clipboard.writeText(JSON.stringify(details, null, 2));
|
||||
setCopied(true);
|
||||
setTimeout(() => setCopied(false), 2000);
|
||||
} catch (err) {
|
||||
// Silent fail
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Stack gap={4} fullWidth pt={4} borderTop borderColor="border-white" bgOpacity={0.1}>
|
||||
<Box
|
||||
as="button"
|
||||
onClick={() => setShowDetails(!showDetails)}
|
||||
display="flex"
|
||||
alignItems="center"
|
||||
justifyContent="center"
|
||||
gap={2}
|
||||
transition
|
||||
>
|
||||
<Text
|
||||
size="xs"
|
||||
color="text-gray-500"
|
||||
hoverTextColor="text-gray-300"
|
||||
uppercase
|
||||
letterSpacing="widest"
|
||||
weight="medium"
|
||||
display="flex"
|
||||
alignItems="center"
|
||||
gap={2}
|
||||
>
|
||||
{showDetails ? <Icon icon={ChevronUp} size={3} /> : <Icon icon={ChevronDown} size={3} />}
|
||||
{showDetails ? 'Hide Technical Logs' : 'Show Technical Logs'}
|
||||
</Text>
|
||||
</Box>
|
||||
|
||||
{showDetails && (
|
||||
<Stack gap={3}>
|
||||
<Surface
|
||||
variant="dark"
|
||||
rounded="md"
|
||||
padding={4}
|
||||
fullWidth
|
||||
maxHeight="48"
|
||||
overflow="auto"
|
||||
border
|
||||
borderColor="border-white"
|
||||
bgOpacity={0.4}
|
||||
hideScrollbar={false}
|
||||
>
|
||||
<Text font="mono" size="xs" color="text-gray-500" block leading="relaxed">
|
||||
{error.stack || 'No stack trace available'}
|
||||
{error.digest && `\n\nDigest: ${error.digest}`}
|
||||
</Text>
|
||||
</Surface>
|
||||
|
||||
<Box display="flex" justifyContent="end">
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
onClick={copyError}
|
||||
icon={<Icon icon={Copy} size={3} />}
|
||||
height="8"
|
||||
fontSize="10px"
|
||||
>
|
||||
{copied ? 'Copied to Clipboard' : 'Copy Error Details'}
|
||||
</Button>
|
||||
</Box>
|
||||
</Stack>
|
||||
)}
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
48
apps/website/components/errors/ErrorRecoveryActions.tsx
Normal file
48
apps/website/components/errors/ErrorRecoveryActions.tsx
Normal file
@@ -0,0 +1,48 @@
|
||||
'use client';
|
||||
|
||||
import React from 'react';
|
||||
import { RefreshCw, Home } from 'lucide-react';
|
||||
import { Box } from '@/ui/Box';
|
||||
import { Button } from '@/ui/Button';
|
||||
import { Icon } from '@/ui/Icon';
|
||||
|
||||
interface ErrorRecoveryActionsProps {
|
||||
onRetry: () => void;
|
||||
onHome: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* ErrorRecoveryActions
|
||||
*
|
||||
* Semantic component for error recovery buttons.
|
||||
* Follows "Precision Racing Minimal" theme.
|
||||
*/
|
||||
export function ErrorRecoveryActions({ onRetry, onHome }: ErrorRecoveryActionsProps) {
|
||||
return (
|
||||
<Box
|
||||
display="flex"
|
||||
flexWrap="wrap"
|
||||
alignItems="center"
|
||||
justifyContent="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>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
52
apps/website/components/errors/ErrorScreen.test.tsx
Normal file
52
apps/website/components/errors/ErrorScreen.test.tsx
Normal file
@@ -0,0 +1,52 @@
|
||||
import { render, screen, fireEvent } from '@testing-library/react';
|
||||
import { describe, it, expect, vi } from 'vitest';
|
||||
import { ErrorScreen } from './ErrorScreen';
|
||||
|
||||
describe('ErrorScreen', () => {
|
||||
const mockError = new Error('Test error message');
|
||||
(mockError as any).digest = 'test-digest';
|
||||
(mockError as any).stack = 'test-stack-trace';
|
||||
|
||||
const mockReset = vi.fn();
|
||||
const mockOnHome = vi.fn();
|
||||
|
||||
it('renders error message and system malfunction title', () => {
|
||||
render(<ErrorScreen error={mockError} reset={mockReset} onHome={mockOnHome} />);
|
||||
|
||||
expect(screen.getByText('System Malfunction')).toBeDefined();
|
||||
expect(screen.getByText('Test error message')).toBeDefined();
|
||||
});
|
||||
|
||||
it('calls reset when Retry Session is clicked', () => {
|
||||
render(<ErrorScreen error={mockError} reset={mockReset} onHome={mockOnHome} />);
|
||||
|
||||
const button = screen.getByText('Retry Session');
|
||||
fireEvent.click(button);
|
||||
|
||||
expect(mockReset).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('calls onHome when Return to Pits is clicked', () => {
|
||||
render(<ErrorScreen error={mockError} reset={mockReset} onHome={mockOnHome} />);
|
||||
|
||||
const button = screen.getByText('Return to Pits');
|
||||
fireEvent.click(button);
|
||||
|
||||
expect(mockOnHome).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('toggles technical logs visibility', () => {
|
||||
render(<ErrorScreen error={mockError} reset={mockReset} onHome={mockOnHome} />);
|
||||
|
||||
expect(screen.queryByText('test-stack-trace')).toBeNull();
|
||||
|
||||
const toggle = screen.getByText('Show Technical Logs');
|
||||
fireEvent.click(toggle);
|
||||
|
||||
expect(screen.getByText(/test-stack-trace/)).toBeDefined();
|
||||
expect(screen.getByText(/Digest: test-digest/)).toBeDefined();
|
||||
|
||||
fireEvent.click(screen.getByText('Hide Technical Logs'));
|
||||
expect(screen.queryByText(/test-stack-trace/)).toBeNull();
|
||||
});
|
||||
});
|
||||
80
apps/website/components/errors/ErrorScreen.tsx
Normal file
80
apps/website/components/errors/ErrorScreen.tsx
Normal file
@@ -0,0 +1,80 @@
|
||||
'use client';
|
||||
|
||||
import React from 'react';
|
||||
import { Box } from '@/ui/Box';
|
||||
import { Surface } from '@/ui/Surface';
|
||||
import { Glow } from '@/ui/Glow';
|
||||
import { Text } from '@/ui/Text';
|
||||
import { AppErrorBoundaryView } from './AppErrorBoundaryView';
|
||||
import { ErrorRecoveryActions } from './ErrorRecoveryActions';
|
||||
import { ErrorDetailsBlock } from './ErrorDetailsBlock';
|
||||
|
||||
interface ErrorScreenProps {
|
||||
error: Error & { digest?: string };
|
||||
reset: () => void;
|
||||
onHome: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* ErrorScreen
|
||||
*
|
||||
* Semantic component for the root-level error boundary.
|
||||
* Follows "Precision Racing Minimal" theme.
|
||||
*/
|
||||
export function ErrorScreen({ error, reset, onHome }: ErrorScreenProps) {
|
||||
return (
|
||||
<Box
|
||||
as="main"
|
||||
minHeight="screen"
|
||||
fullWidth
|
||||
display="flex"
|
||||
alignItems="center"
|
||||
justifyContent="center"
|
||||
bg="bg-deep-graphite"
|
||||
position="relative"
|
||||
overflow="hidden"
|
||||
px={6}
|
||||
>
|
||||
{/* Background Accents */}
|
||||
<Glow color="primary" size="xl" position="center" opacity={0.05} />
|
||||
|
||||
<Surface
|
||||
variant="glass"
|
||||
border
|
||||
rounded="lg"
|
||||
padding={8}
|
||||
maxWidth="2xl"
|
||||
fullWidth
|
||||
position="relative"
|
||||
zIndex={10}
|
||||
shadow="xl"
|
||||
borderColor="border-white"
|
||||
bgOpacity={0.05}
|
||||
>
|
||||
<AppErrorBoundaryView
|
||||
title="System Malfunction"
|
||||
description="The application encountered an unexpected state. Our telemetry has logged the incident."
|
||||
>
|
||||
{/* Error Message Summary */}
|
||||
<Surface
|
||||
variant="dark"
|
||||
rounded="md"
|
||||
padding={4}
|
||||
fullWidth
|
||||
border
|
||||
borderColor="border-white"
|
||||
bgOpacity={0.2}
|
||||
>
|
||||
<Text font="mono" size="sm" color="text-warning-amber" block>
|
||||
{error.message || 'Unknown execution error'}
|
||||
</Text>
|
||||
</Surface>
|
||||
|
||||
<ErrorRecoveryActions onRetry={reset} onHome={onHome} />
|
||||
|
||||
<ErrorDetailsBlock error={error} />
|
||||
</AppErrorBoundaryView>
|
||||
</Surface>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
185
apps/website/components/errors/GlobalErrorScreen.tsx
Normal file
185
apps/website/components/errors/GlobalErrorScreen.tsx
Normal file
@@ -0,0 +1,185 @@
|
||||
'use client';
|
||||
|
||||
import React from 'react';
|
||||
import { Box } from '@/ui/Box';
|
||||
import { Surface } from '@/ui/Surface';
|
||||
import { Glow } from '@/ui/Glow';
|
||||
import { Text } from '@/ui/Text';
|
||||
import { Stack } from '@/ui/Stack';
|
||||
import { Heading } from '@/ui/Heading';
|
||||
import { Icon } from '@/ui/Icon';
|
||||
import { AlertTriangle, RefreshCw, Home, Terminal } from 'lucide-react';
|
||||
import { Button } from '@/ui/Button';
|
||||
|
||||
interface GlobalErrorScreenProps {
|
||||
error: Error & { digest?: string };
|
||||
reset: () => void;
|
||||
onHome: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* GlobalErrorScreen
|
||||
*
|
||||
* A strong, minimal "system fault" view for the root global error boundary.
|
||||
* Instrument-grade UI following the "Precision Racing Minimal" theme.
|
||||
*/
|
||||
export function GlobalErrorScreen({ error, reset, onHome }: GlobalErrorScreenProps) {
|
||||
return (
|
||||
<Box
|
||||
as="main"
|
||||
minHeight="screen"
|
||||
fullWidth
|
||||
display="flex"
|
||||
alignItems="center"
|
||||
justifyContent="center"
|
||||
bg="bg-base-black"
|
||||
position="relative"
|
||||
overflow="hidden"
|
||||
px={6}
|
||||
>
|
||||
{/* Background Accents - Subtle telemetry vibe */}
|
||||
<Glow color="primary" size="xl" position="center" opacity={0.03} />
|
||||
|
||||
<Surface
|
||||
variant="dark"
|
||||
border
|
||||
rounded="none"
|
||||
padding={0}
|
||||
maxWidth="2xl"
|
||||
fullWidth
|
||||
position="relative"
|
||||
zIndex={10}
|
||||
borderColor="border-white"
|
||||
bgOpacity={0.1}
|
||||
>
|
||||
{/* System Status Header */}
|
||||
<Box
|
||||
borderBottom
|
||||
borderColor="border-white"
|
||||
bgOpacity={0.05}
|
||||
px={6}
|
||||
py={4}
|
||||
display="flex"
|
||||
alignItems="center"
|
||||
justifyContent="space-between"
|
||||
>
|
||||
<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
|
||||
</Text>
|
||||
</Box>
|
||||
|
||||
<Box 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} />
|
||||
</Stack>
|
||||
|
||||
{/* Recovery Actions */}
|
||||
<RecoveryActions onRetry={reset} onHome={onHome} />
|
||||
</Stack>
|
||||
</Box>
|
||||
|
||||
{/* Footer / Metadata */}
|
||||
<Box
|
||||
borderTop
|
||||
borderColor="border-white"
|
||||
bgOpacity={0.05}
|
||||
px={6}
|
||||
py={3}
|
||||
display="flex"
|
||||
justifyContent="end"
|
||||
>
|
||||
<Text font="mono" size="xs" color="text-gray-600">
|
||||
GP-CORE-ERR-{error.digest?.substring(0, 8).toUpperCase() || 'UNKNOWN'}
|
||||
</Text>
|
||||
</Box>
|
||||
</Surface>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* SystemStatusPanel
|
||||
*
|
||||
* Displays technical fault details in an instrument-grade panel.
|
||||
*/
|
||||
function SystemStatusPanel({ error }: { error: Error & { digest?: string } }) {
|
||||
return (
|
||||
<Surface
|
||||
variant="dark"
|
||||
rounded="none"
|
||||
padding={4}
|
||||
fullWidth
|
||||
border
|
||||
borderColor="border-white"
|
||||
bgOpacity={0.2}
|
||||
>
|
||||
<Stack gap={3}>
|
||||
<Box display="flex" alignItems="center" gap={2}>
|
||||
<Icon icon={Terminal} size={3} color="var(--gray-500)" />
|
||||
<Text font="mono" size="xs" color="text-gray-500" uppercase letterSpacing="wider">
|
||||
Fault Log
|
||||
</Text>
|
||||
</Box>
|
||||
<Text font="mono" size="sm" color="text-warning-amber" block>
|
||||
{error.message || 'Unknown execution fault'}
|
||||
</Text>
|
||||
{error.digest && (
|
||||
<Text font="mono" size="xs" color="text-gray-600" block>
|
||||
Digest: {error.digest}
|
||||
</Text>
|
||||
)}
|
||||
</Stack>
|
||||
</Surface>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* RecoveryActions
|
||||
*
|
||||
* Clear, instrument-grade recovery options.
|
||||
*/
|
||||
function RecoveryActions({ onRetry, onHome }: { onRetry: () => void; onHome: () => void }) {
|
||||
return (
|
||||
<Box
|
||||
display="flex"
|
||||
flexWrap="wrap"
|
||||
alignItems="center"
|
||||
gap={4}
|
||||
fullWidth
|
||||
>
|
||||
<Button
|
||||
variant="primary"
|
||||
onClick={onRetry}
|
||||
icon={<Icon icon={RefreshCw} size={4} />}
|
||||
rounded="none"
|
||||
px={8}
|
||||
>
|
||||
Reboot Session
|
||||
</Button>
|
||||
<Button
|
||||
variant="secondary"
|
||||
onClick={onHome}
|
||||
icon={<Icon icon={Home} size={4} />}
|
||||
rounded="none"
|
||||
px={8}
|
||||
>
|
||||
Return to Pits
|
||||
</Button>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
51
apps/website/components/errors/NotFoundActions.tsx
Normal file
51
apps/website/components/errors/NotFoundActions.tsx
Normal file
@@ -0,0 +1,51 @@
|
||||
'use client';
|
||||
|
||||
import React from 'react';
|
||||
import { Stack } from '@/ui/Stack';
|
||||
import { Button } from '@/ui/Button';
|
||||
import { Box } from '@/ui/Box';
|
||||
import { Text } from '@/ui/Text';
|
||||
|
||||
interface NotFoundActionsProps {
|
||||
primaryLabel: string;
|
||||
onPrimaryClick: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* NotFoundActions
|
||||
*
|
||||
* Semantic component for the primary actions on the 404 page.
|
||||
* Follows "Precision Racing Minimal" theme with crisp styling.
|
||||
*/
|
||||
export function NotFoundActions({ primaryLabel, onPrimaryClick }: NotFoundActionsProps) {
|
||||
return (
|
||||
<Stack direction="row" gap={4} align="center" justify="center">
|
||||
<Button
|
||||
variant="primary"
|
||||
size="lg"
|
||||
onClick={onPrimaryClick}
|
||||
minWidth="200px"
|
||||
>
|
||||
{primaryLabel}
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="lg"
|
||||
onClick={() => window.history.back()}
|
||||
>
|
||||
<Stack direction="row" gap={2} align="center">
|
||||
<Box
|
||||
width={2}
|
||||
height={2}
|
||||
rounded="full"
|
||||
bg="soft-steel"
|
||||
/>
|
||||
<Text size="xs" weight="bold" uppercase letterSpacing="widest" color="text-gray-400">
|
||||
Previous Sector
|
||||
</Text>
|
||||
</Stack>
|
||||
</Button>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
34
apps/website/components/errors/NotFoundCallToAction.tsx
Normal file
34
apps/website/components/errors/NotFoundCallToAction.tsx
Normal file
@@ -0,0 +1,34 @@
|
||||
'use client';
|
||||
|
||||
import React from 'react';
|
||||
import { Stack } from '@/ui/Stack';
|
||||
import { Text } from '@/ui/Text';
|
||||
import { Button } from '@/ui/Button';
|
||||
|
||||
interface NotFoundCallToActionProps {
|
||||
label: string;
|
||||
onClick: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* NotFoundCallToAction
|
||||
*
|
||||
* Semantic component for the primary action on the 404 page.
|
||||
* Follows "Precision Racing Minimal" theme with crisp styling.
|
||||
*/
|
||||
export function NotFoundCallToAction({ label, onClick }: NotFoundCallToActionProps) {
|
||||
return (
|
||||
<Stack gap={4} align="center">
|
||||
<Button
|
||||
variant="primary"
|
||||
size="lg"
|
||||
onClick={onClick}
|
||||
>
|
||||
{label}
|
||||
</Button>
|
||||
<Text size="xs" color="text-gray-500" uppercase letterSpacing="widest">
|
||||
Telemetry connection lost
|
||||
</Text>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
51
apps/website/components/errors/NotFoundDiagnostics.tsx
Normal file
51
apps/website/components/errors/NotFoundDiagnostics.tsx
Normal file
@@ -0,0 +1,51 @@
|
||||
'use client';
|
||||
|
||||
import React from 'react';
|
||||
import { Stack } from '@/ui/Stack';
|
||||
import { Text } from '@/ui/Text';
|
||||
import { Box } from '@/ui/Box';
|
||||
|
||||
interface NotFoundDiagnosticsProps {
|
||||
errorCode: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* NotFoundDiagnostics
|
||||
*
|
||||
* Semantic component for displaying technical error details.
|
||||
* Styled as a telemetry status indicator.
|
||||
*/
|
||||
export function NotFoundDiagnostics({ errorCode }: NotFoundDiagnosticsProps) {
|
||||
return (
|
||||
<Stack gap={3} align="center">
|
||||
<Box
|
||||
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>
|
||||
</Box>
|
||||
<Text
|
||||
size="xs"
|
||||
color="text-gray-500"
|
||||
uppercase
|
||||
letterSpacing="widest"
|
||||
weight="medium"
|
||||
>
|
||||
Telemetry connection lost // Sector data unavailable
|
||||
</Text>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
47
apps/website/components/errors/NotFoundHelpLinks.tsx
Normal file
47
apps/website/components/errors/NotFoundHelpLinks.tsx
Normal file
@@ -0,0 +1,47 @@
|
||||
'use client';
|
||||
|
||||
import React from 'react';
|
||||
import { Stack } from '@/ui/Stack';
|
||||
import { Box } from '@/ui/Box';
|
||||
import { Text } from '@/ui/Text';
|
||||
|
||||
interface NotFoundHelpLinksProps {
|
||||
links: Array<{ label: string; href: string }>;
|
||||
}
|
||||
|
||||
/**
|
||||
* NotFoundHelpLinks
|
||||
*
|
||||
* Semantic component for secondary navigation on the 404 page.
|
||||
* Styled as technical metadata links.
|
||||
*/
|
||||
export function NotFoundHelpLinks({ links }: NotFoundHelpLinksProps) {
|
||||
return (
|
||||
<Stack direction="row" gap={6} align="center" wrap center>
|
||||
{links.map((link, index) => (
|
||||
<React.Fragment key={link.href}>
|
||||
<Box
|
||||
as="a"
|
||||
href={link.href}
|
||||
transition
|
||||
display="inline-block"
|
||||
>
|
||||
<Text
|
||||
color="text-gray-400"
|
||||
hoverTextColor="primary-accent"
|
||||
weight="medium"
|
||||
size="xs"
|
||||
letterSpacing="widest"
|
||||
uppercase
|
||||
>
|
||||
{link.label}
|
||||
</Text>
|
||||
</Box>
|
||||
{index < links.length - 1 && (
|
||||
<Box width="1px" height="12px" bg="border-gray" opacity={0.5} />
|
||||
)}
|
||||
</React.Fragment>
|
||||
))}
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
141
apps/website/components/errors/NotFoundScreen.tsx
Normal file
141
apps/website/components/errors/NotFoundScreen.tsx
Normal file
@@ -0,0 +1,141 @@
|
||||
'use client';
|
||||
|
||||
import React from 'react';
|
||||
import { Box } from '@/ui/Box';
|
||||
import { Text } from '@/ui/Text';
|
||||
import { Stack } from '@/ui/Stack';
|
||||
import { Surface } from '@/ui/Surface';
|
||||
import { Glow } from '@/ui/Glow';
|
||||
import { NotFoundActions } from './NotFoundActions';
|
||||
import { NotFoundHelpLinks } from './NotFoundHelpLinks';
|
||||
import { NotFoundDiagnostics } from './NotFoundDiagnostics';
|
||||
|
||||
interface NotFoundScreenProps {
|
||||
errorCode: string;
|
||||
title: string;
|
||||
message: string;
|
||||
actionLabel: string;
|
||||
onActionClick: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* NotFoundScreen
|
||||
*
|
||||
* App-specific semantic component for 404 states.
|
||||
* Encapsulates the visual representation of the "Off Track" state.
|
||||
* Redesigned for "Precision Racing Minimal" theme.
|
||||
*/
|
||||
export function NotFoundScreen({
|
||||
errorCode,
|
||||
title,
|
||||
message,
|
||||
actionLabel,
|
||||
onActionClick
|
||||
}: NotFoundScreenProps) {
|
||||
const helpLinks = [
|
||||
{ label: 'Support', href: '/support' },
|
||||
{ label: 'Status', href: '/status' },
|
||||
{ label: 'Documentation', href: '/docs' },
|
||||
];
|
||||
|
||||
return (
|
||||
<Box
|
||||
as="main"
|
||||
minHeight="100vh"
|
||||
display="flex"
|
||||
alignItems="center"
|
||||
justifyContent="center"
|
||||
bg="graphite-black"
|
||||
position="relative"
|
||||
overflow="hidden"
|
||||
>
|
||||
{/* Background Glow Accent */}
|
||||
<Glow color="primary" size="xl" opacity={0.1} position="center" />
|
||||
|
||||
<Surface
|
||||
variant="glass"
|
||||
border
|
||||
padding={12}
|
||||
rounded="none"
|
||||
maxWidth="2xl"
|
||||
fullWidth
|
||||
mx={6}
|
||||
position="relative"
|
||||
zIndex={10}
|
||||
>
|
||||
<Stack gap={12} align="center" textAlign="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"
|
||||
>
|
||||
{title}
|
||||
</Text>
|
||||
</Stack>
|
||||
|
||||
{/* Visual Separator */}
|
||||
<Box width="full" height="1px" bg="primary-accent" opacity={0.3} position="relative" display="flex" alignItems="center" justifyContent="center">
|
||||
<Box
|
||||
width={3}
|
||||
height={3}
|
||||
bg="primary-accent"
|
||||
/>
|
||||
</Box>
|
||||
|
||||
{/* Message Section */}
|
||||
<Text
|
||||
size="xl"
|
||||
color="text-gray-400"
|
||||
maxWidth="lg"
|
||||
leading="relaxed"
|
||||
block
|
||||
weight="medium"
|
||||
>
|
||||
{message}
|
||||
</Text>
|
||||
|
||||
{/* Actions Section */}
|
||||
<NotFoundActions
|
||||
primaryLabel={actionLabel}
|
||||
onPrimaryClick={onActionClick}
|
||||
/>
|
||||
|
||||
{/* Footer Section */}
|
||||
<Box pt={8} width="full">
|
||||
<Box height="1px" width="full" bg="border-gray" opacity={0.1} mb={8} />
|
||||
<NotFoundHelpLinks links={helpLinks} />
|
||||
</Box>
|
||||
</Stack>
|
||||
</Surface>
|
||||
|
||||
{/* Subtle Edge Details */}
|
||||
<Box
|
||||
position="absolute"
|
||||
top={0}
|
||||
left={0}
|
||||
right={0}
|
||||
height="2px"
|
||||
bg="primary-accent"
|
||||
opacity={0.1}
|
||||
/>
|
||||
<Box
|
||||
position="absolute"
|
||||
bottom={0}
|
||||
left={0}
|
||||
right={0}
|
||||
height="2px"
|
||||
bg="primary-accent"
|
||||
opacity={0.1}
|
||||
/>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
59
apps/website/components/errors/RecoveryActions.tsx
Normal file
59
apps/website/components/errors/RecoveryActions.tsx
Normal file
@@ -0,0 +1,59 @@
|
||||
'use client';
|
||||
|
||||
import React from 'react';
|
||||
import { RefreshCw, Home, LifeBuoy } from 'lucide-react';
|
||||
import { Box } from '@/ui/Box';
|
||||
import { Button } from '@/ui/Button';
|
||||
import { Icon } from '@/ui/Icon';
|
||||
|
||||
interface RecoveryActionsProps {
|
||||
onRetry: () => void;
|
||||
onHome: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* RecoveryActions
|
||||
*
|
||||
* Provides primary and secondary recovery paths for the user.
|
||||
* Part of the 500 route redesign.
|
||||
*/
|
||||
export function RecoveryActions({ onRetry, onHome }: RecoveryActionsProps) {
|
||||
return (
|
||||
<Box
|
||||
display="flex"
|
||||
flexWrap="wrap"
|
||||
alignItems="center"
|
||||
justifyContent="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>
|
||||
<Button
|
||||
variant="secondary"
|
||||
as="a"
|
||||
href="https://support.gridpilot.com"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
icon={<Icon icon={LifeBuoy} size={4} />}
|
||||
width="160px"
|
||||
>
|
||||
Contact Support
|
||||
</Button>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
77
apps/website/components/errors/ServerErrorPanel.tsx
Normal file
77
apps/website/components/errors/ServerErrorPanel.tsx
Normal file
@@ -0,0 +1,77 @@
|
||||
'use client';
|
||||
|
||||
import React from 'react';
|
||||
import { AlertTriangle } from 'lucide-react';
|
||||
import { Box } from '@/ui/Box';
|
||||
import { Stack } from '@/ui/Stack';
|
||||
import { Heading } from '@/ui/Heading';
|
||||
import { Text } from '@/ui/Text';
|
||||
import { Icon } from '@/ui/Icon';
|
||||
import { Surface } from '@/ui/Surface';
|
||||
|
||||
interface ServerErrorPanelProps {
|
||||
message?: string;
|
||||
incidentId?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* ServerErrorPanel
|
||||
*
|
||||
* Displays the primary error information in an "instrument-grade" style.
|
||||
* Part of the 500 route redesign.
|
||||
*/
|
||||
export function ServerErrorPanel({ message, incidentId }: ServerErrorPanelProps) {
|
||||
return (
|
||||
<Stack gap={6} align="center" fullWidth>
|
||||
{/* Status Indicator */}
|
||||
<Box
|
||||
p={4}
|
||||
rounded="full"
|
||||
bg="bg-warning-amber"
|
||||
bgOpacity={0.1}
|
||||
border
|
||||
borderColor="border-warning-amber"
|
||||
>
|
||||
<Icon icon={AlertTriangle} size={8} color="var(--warning-amber)" />
|
||||
</Box>
|
||||
|
||||
{/* Primary Message */}
|
||||
<Stack gap={2} align="center">
|
||||
<Heading level={1} weight="bold">
|
||||
CRITICAL_SYSTEM_FAILURE
|
||||
</Heading>
|
||||
<Text color="text-gray-400" align="center" maxWidth="md">
|
||||
The application engine encountered an unrecoverable state.
|
||||
Telemetry has been dispatched to engineering.
|
||||
</Text>
|
||||
</Stack>
|
||||
|
||||
{/* Technical Summary */}
|
||||
<Surface
|
||||
variant="dark"
|
||||
rounded="md"
|
||||
padding={4}
|
||||
fullWidth
|
||||
border
|
||||
borderColor="border-white"
|
||||
bgOpacity={0.2}
|
||||
>
|
||||
<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}
|
||||
</Text>
|
||||
)}
|
||||
{incidentId && (
|
||||
<Text font="mono" size="xs" color="text-gray-500" block>
|
||||
INCIDENT_ID: {incidentId}
|
||||
</Text>
|
||||
)}
|
||||
</Stack>
|
||||
</Surface>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
61
apps/website/components/home/HomeFeatureDescription.tsx
Normal file
61
apps/website/components/home/HomeFeatureDescription.tsx
Normal file
@@ -0,0 +1,61 @@
|
||||
'use client';
|
||||
|
||||
import React from 'react';
|
||||
import { Box } from '@/ui/Box';
|
||||
import { Text } from '@/ui/Text';
|
||||
import { Stack } from '@/ui/Stack';
|
||||
|
||||
interface HomeFeatureDescriptionProps {
|
||||
lead: string;
|
||||
items: string[];
|
||||
quote?: string;
|
||||
accentColor?: 'primary' | 'aqua' | 'amber' | 'gray';
|
||||
}
|
||||
|
||||
/**
|
||||
* HomeFeatureDescription - A semantic component for feature descriptions on the home page.
|
||||
* Refactored to use semantic HTML and Tailwind.
|
||||
*/
|
||||
export function HomeFeatureDescription({
|
||||
lead,
|
||||
items,
|
||||
quote,
|
||||
accentColor = 'primary',
|
||||
}: HomeFeatureDescriptionProps) {
|
||||
const borderColor = {
|
||||
primary: 'primary-accent',
|
||||
aqua: 'telemetry-aqua',
|
||||
amber: 'warning-amber',
|
||||
gray: 'border-gray',
|
||||
}[accentColor];
|
||||
|
||||
const bgColor = {
|
||||
primary: 'primary-accent/5',
|
||||
aqua: 'telemetry-aqua/5',
|
||||
amber: 'warning-amber/5',
|
||||
gray: 'white/5',
|
||||
}[accentColor];
|
||||
|
||||
return (
|
||||
<Stack gap={6}>
|
||||
<Text size="lg" color="text-gray-400" weight="medium" leading="relaxed">
|
||||
{lead}
|
||||
</Text>
|
||||
<Box as="ul" display="flex" flexDirection="col" gap={2}>
|
||||
{items.map((item, index) => (
|
||||
<Box as="li" key={index} display="flex" alignItems="start" gap={2}>
|
||||
<Text color="text-primary-accent">•</Text>
|
||||
<Text size="sm" color="text-gray-500">{item}</Text>
|
||||
</Box>
|
||||
))}
|
||||
</Box>
|
||||
{quote && (
|
||||
<Box borderLeft borderStyle="solid" borderWidth="2px" borderColor={borderColor} pl={4} py={1} bg={bgColor}>
|
||||
<Text color="text-gray-600" font="mono" size="xs" uppercase letterSpacing="widest" leading="relaxed">
|
||||
{quote}
|
||||
</Text>
|
||||
</Box>
|
||||
)}
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
87
apps/website/components/home/HomeFeatureSection.tsx
Normal file
87
apps/website/components/home/HomeFeatureSection.tsx
Normal file
@@ -0,0 +1,87 @@
|
||||
'use client';
|
||||
|
||||
import React from 'react';
|
||||
import { Panel } from '@/ui/Panel';
|
||||
import { Glow } from '@/ui/Glow';
|
||||
import { Box } from '@/ui/Box';
|
||||
import { Container } from '@/ui/Container';
|
||||
import { Heading } from '@/ui/Heading';
|
||||
import { Section } from '@/ui/Section';
|
||||
|
||||
interface HomeFeatureSectionProps {
|
||||
heading: string;
|
||||
description: React.ReactNode;
|
||||
mockup: React.ReactNode;
|
||||
layout: 'text-left' | 'text-right';
|
||||
accentColor?: 'primary' | 'aqua' | 'amber';
|
||||
}
|
||||
|
||||
/**
|
||||
* HomeFeatureSection - A semantic section highlighting a feature.
|
||||
* Refactored to use semantic HTML and Tailwind.
|
||||
*/
|
||||
export function HomeFeatureSection({
|
||||
heading,
|
||||
description,
|
||||
mockup,
|
||||
layout,
|
||||
accentColor = 'primary',
|
||||
}: HomeFeatureSectionProps) {
|
||||
const accentBorderColor = {
|
||||
primary: 'primary-accent/40',
|
||||
aqua: 'telemetry-aqua/40',
|
||||
amber: 'warning-amber/40',
|
||||
}[accentColor];
|
||||
|
||||
const accentBgColor = {
|
||||
primary: 'primary-accent',
|
||||
aqua: 'telemetry-aqua',
|
||||
amber: 'warning-amber',
|
||||
}[accentColor];
|
||||
|
||||
const glowColor = ({
|
||||
primary: 'primary',
|
||||
aqua: 'aqua',
|
||||
amber: 'amber',
|
||||
}[accentColor] || 'primary') as 'primary' | 'aqua' | 'amber' | 'purple';
|
||||
|
||||
return (
|
||||
<Section variant="dark" py={24} borderBottom borderColor="border-gray" overflow="hidden" position="relative">
|
||||
<Glow
|
||||
color={glowColor}
|
||||
size="lg"
|
||||
position={layout === 'text-left' ? 'bottom-left' : 'top-right'}
|
||||
opacity={0.02}
|
||||
/>
|
||||
|
||||
<Container>
|
||||
<Box display="grid" gridCols={{ base: 1, lg: 2 }} gap={{ base: 12, lg: 20 }} alignItems="center">
|
||||
{/* Text Content */}
|
||||
<Box display="flex" flexDirection="col" gap={8} order={{ lg: layout === 'text-right' ? 2 : 1 }}>
|
||||
<Box display="flex" flexDirection="col" gap={4}>
|
||||
<Box w="12" h="1" bg={accentBgColor} />
|
||||
<Heading level={2} fontSize={{ base: '3xl', md: '5xl' }} weight="bold" letterSpacing="tighter" lineHeight="none" color="text-white">
|
||||
{heading}
|
||||
</Heading>
|
||||
</Box>
|
||||
<Box color="text-gray-500" borderLeft borderStyle="solid" borderColor="border-gray/20" pl={6}>
|
||||
{description}
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
{/* Mockup Panel */}
|
||||
<Box order={{ lg: layout === 'text-right' ? 1 : 2 }}>
|
||||
<Panel padding={1} variant="dark" border={true} position="relative" group overflow="hidden">
|
||||
<Box bg="graphite-black" minHeight="300px" display="flex" alignItems="center" justifyContent="center">
|
||||
{mockup}
|
||||
</Box>
|
||||
{/* Decorative corner accents */}
|
||||
<Box position="absolute" top="0" left="0" w="4" h="4" borderTop borderLeft borderColor={accentBorderColor} opacity={0.4} />
|
||||
<Box position="absolute" bottom="0" right="0" w="4" h="4" borderBottom borderRight borderColor={accentBorderColor} opacity={0.4} />
|
||||
</Panel>
|
||||
</Box>
|
||||
</Box>
|
||||
</Container>
|
||||
</Section>
|
||||
);
|
||||
}
|
||||
126
apps/website/components/home/HomeFooterCTA.tsx
Normal file
126
apps/website/components/home/HomeFooterCTA.tsx
Normal file
@@ -0,0 +1,126 @@
|
||||
'use client';
|
||||
|
||||
import React from 'react';
|
||||
import { Button } from '@/ui/Button';
|
||||
import { Glow } from '@/ui/Glow';
|
||||
import { Icon } from '@/ui/Icon';
|
||||
import { DiscordIcon } from '@/ui/icons/DiscordIcon';
|
||||
import { Code, Lightbulb, LucideIcon, MessageSquare, Users } from 'lucide-react';
|
||||
import { Box } from '@/ui/Box';
|
||||
import { Container } from '@/ui/Container';
|
||||
import { Heading } from '@/ui/Heading';
|
||||
import { Text } from '@/ui/Text';
|
||||
import { Section } from '@/ui/Section';
|
||||
import { Stack } from '@/ui/Stack';
|
||||
import { Grid } from '@/ui/Grid';
|
||||
|
||||
export function HomeFooterCTA() {
|
||||
const discordUrl = process.env.NEXT_PUBLIC_DISCORD_URL || '#';
|
||||
|
||||
return (
|
||||
<Section variant="dark" py={32} borderBottom borderColor="border-gray/50" overflow="hidden" position="relative">
|
||||
<Glow color="primary" size="xl" position="center" opacity={0.05} />
|
||||
|
||||
<Container>
|
||||
<Box position="relative" overflow="hidden" bg="panel-gray/40" border borderColor="border-gray" p={{ base: 8, md: 12 }}>
|
||||
{/* Discord brand accent */}
|
||||
<Box position="absolute" top={0} left={0} right={0} h="1" bg="primary-accent" />
|
||||
|
||||
<Stack align="center" gap={12} center>
|
||||
{/* Header */}
|
||||
<Stack align="center" gap={6}>
|
||||
<Box position="relative" display="flex" alignItems="center" justifyContent="center" w={{ base: 16, md: 20 }} h={{ base: 16, md: 20 }} bg="primary-accent/10" border borderColor="primary-accent/30">
|
||||
<DiscordIcon color="text-primary-accent" size={40} />
|
||||
<Box position="absolute" top="-1px" left="-1px" w="2" h="2" borderTop borderLeft borderColor="primary-accent" />
|
||||
<Box position="absolute" bottom="-1px" right="-1px" w="2" h="2" borderBottom borderRight borderColor="primary-accent" />
|
||||
</Box>
|
||||
|
||||
<Stack gap={4} align="center">
|
||||
<Heading level={2} weight="bold" color="text-white" fontSize={{ base: '2xl', md: '4xl' }} letterSpacing="tight">
|
||||
Join the Grid on Discord
|
||||
</Heading>
|
||||
<Box w="16" h="1" bg="primary-accent" />
|
||||
</Stack>
|
||||
</Stack>
|
||||
|
||||
{/* Personal message */}
|
||||
<Box maxWidth="2xl" mx="auto" textAlign="center">
|
||||
<Stack gap={6}>
|
||||
<Text size="lg" color="text-gray-300" weight="medium" leading="relaxed">
|
||||
GridPilot is a <Text as="span" color="text-white" weight="bold">solo developer project</Text> built for the community.
|
||||
</Text>
|
||||
<Text size="base" color="text-gray-400" weight="normal" leading="relaxed">
|
||||
We are in early alpha. Join us to help shape the future of motorsport infrastructure. Your feedback directly influences the roadmap.
|
||||
</Text>
|
||||
</Stack>
|
||||
</Box>
|
||||
|
||||
{/* Benefits grid */}
|
||||
<Box maxWidth="4xl" mx="auto" fullWidth>
|
||||
<Grid cols={1} mdCols={2} gap={6}>
|
||||
<BenefitItem
|
||||
icon={MessageSquare}
|
||||
title="Share your pain points"
|
||||
description="Tell us what frustrates you about league racing today."
|
||||
/>
|
||||
<BenefitItem
|
||||
icon={Lightbulb}
|
||||
title="Shape the product"
|
||||
description="Your ideas directly influence our roadmap."
|
||||
/>
|
||||
<BenefitItem
|
||||
icon={Users}
|
||||
title="Connect with racers"
|
||||
description="Join a community of like-minded competitive drivers."
|
||||
/>
|
||||
<BenefitItem
|
||||
icon={Code}
|
||||
title="Early Access"
|
||||
description="Test new features before they go public."
|
||||
/>
|
||||
</Grid>
|
||||
</Box>
|
||||
|
||||
{/* CTA Button */}
|
||||
<Stack gap={6} pt={4} align="center">
|
||||
<Button
|
||||
as="a"
|
||||
href={discordUrl}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
variant="primary"
|
||||
size="lg"
|
||||
px={16}
|
||||
py={4}
|
||||
h="auto"
|
||||
icon={<DiscordIcon size={24} />}
|
||||
>
|
||||
Join Discord
|
||||
</Button>
|
||||
|
||||
<Box border borderStyle="dashed" borderColor="primary-accent/50" px={4} py={1}>
|
||||
<Text size="xs" color="text-primary-accent" weight="bold" font="mono" uppercase letterSpacing="widest">
|
||||
Early Alpha Access Available
|
||||
</Text>
|
||||
</Box>
|
||||
</Stack>
|
||||
</Stack>
|
||||
</Box>
|
||||
</Container>
|
||||
</Section>
|
||||
);
|
||||
}
|
||||
|
||||
function BenefitItem({ icon, title, description }: { icon: LucideIcon, title: string, description: string }) {
|
||||
return (
|
||||
<Box display="flex" alignItems="start" gap={5} p={6} bg="panel-gray/20" border borderColor="border-gray" hoverBorderColor="primary-accent/30" transition group>
|
||||
<Box display="flex" alignItems="center" justifyContent="center" flexShrink={0} w="10" h="10" bg="primary-accent/5" border borderColor="border-gray/50" groupHoverBorderColor="primary-accent/30" transition>
|
||||
<Icon icon={icon} size={5} color="text-primary-accent" />
|
||||
</Box>
|
||||
<Stack gap={2}>
|
||||
<Text size="base" weight="bold" color="text-white" letterSpacing="wide">{title}</Text>
|
||||
<Text size="sm" color="text-gray-400" leading="relaxed">{description}</Text>
|
||||
</Stack>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
95
apps/website/components/home/HomeHeader.tsx
Normal file
95
apps/website/components/home/HomeHeader.tsx
Normal file
@@ -0,0 +1,95 @@
|
||||
'use client';
|
||||
|
||||
import React from 'react';
|
||||
import { Button } from '@/ui/Button';
|
||||
import { Glow } from '@/ui/Glow';
|
||||
import { Box } from '@/ui/Box';
|
||||
import { Container } from '@/ui/Container';
|
||||
import { Heading } from '@/ui/Heading';
|
||||
import { Text } from '@/ui/Text';
|
||||
|
||||
interface HomeHeaderProps {
|
||||
title: string;
|
||||
subtitle: string;
|
||||
description: string;
|
||||
primaryAction: {
|
||||
label: string;
|
||||
href: string;
|
||||
};
|
||||
secondaryAction: {
|
||||
label: string;
|
||||
href: string;
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* HomeHeader - Semantic hero section for the landing page.
|
||||
* Follows "Precision Racing Minimal" theme.
|
||||
*/
|
||||
export function HomeHeader({
|
||||
title,
|
||||
subtitle,
|
||||
description,
|
||||
primaryAction,
|
||||
secondaryAction,
|
||||
}: HomeHeaderProps) {
|
||||
return (
|
||||
<Box as="header" position="relative" overflow="hidden" bg="graphite-black" py={{ base: 24, lg: 32 }} borderBottom borderColor="border-gray">
|
||||
<Glow color="primary" size="xl" position="top-right" opacity={0.1} />
|
||||
|
||||
<Container>
|
||||
<Box maxWidth="4xl">
|
||||
<Box display="flex" alignItems="center" gap={3} borderLeft borderStyle="solid" borderWidth="2px" borderColor="primary-accent" bg="primary-accent/5" px={4} py={1} mb={8}>
|
||||
<Text size="xs" weight="bold" uppercase letterSpacing="0.3em" color="text-primary-accent">
|
||||
{subtitle}
|
||||
</Text>
|
||||
</Box>
|
||||
|
||||
<Heading
|
||||
level={1}
|
||||
fontSize={{ base: '5xl', md: '7xl', lg: '8xl' }}
|
||||
weight="bold"
|
||||
color="text-white"
|
||||
letterSpacing="tighter"
|
||||
lineHeight="0.9"
|
||||
mb={8}
|
||||
>
|
||||
{title}
|
||||
</Heading>
|
||||
|
||||
<Box borderLeft borderStyle="solid" borderColor="border-gray" pl={8} mb={12} maxWidth="2xl">
|
||||
<Text size="lg" color="text-gray-400" leading="relaxed" opacity={0.8}>
|
||||
{description}
|
||||
</Text>
|
||||
</Box>
|
||||
|
||||
<Box display="flex" flexDirection={{ base: 'col', sm: 'row' }} gap={4}>
|
||||
<Button
|
||||
as="a"
|
||||
href={primaryAction.href}
|
||||
variant="primary"
|
||||
h="14"
|
||||
px={12}
|
||||
fontSize="xs"
|
||||
>
|
||||
{primaryAction.label}
|
||||
</Button>
|
||||
<Button
|
||||
as="a"
|
||||
href={secondaryAction.href}
|
||||
variant="secondary"
|
||||
h="14"
|
||||
px={12}
|
||||
fontSize="xs"
|
||||
bg="transparent"
|
||||
borderColor="border-gray"
|
||||
hoverBorderColor="primary-accent/50"
|
||||
>
|
||||
{secondaryAction.label}
|
||||
</Button>
|
||||
</Box>
|
||||
</Box>
|
||||
</Container>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
60
apps/website/components/home/HomeStatsStrip.tsx
Normal file
60
apps/website/components/home/HomeStatsStrip.tsx
Normal file
@@ -0,0 +1,60 @@
|
||||
'use client';
|
||||
|
||||
import React from 'react';
|
||||
import { MetricCard } from '@/ui/MetricCard';
|
||||
import { Activity, Users, Trophy, Calendar } from 'lucide-react';
|
||||
import { Box } from '@/ui/Box';
|
||||
import { Container } from '@/ui/Container';
|
||||
|
||||
/**
|
||||
* HomeStatsStrip - A thin strip showing some status or quick info.
|
||||
* Part of the "Telemetry-workspace" feel.
|
||||
* Refactored to use semantic HTML and Tailwind.
|
||||
*/
|
||||
export function HomeStatsStrip() {
|
||||
return (
|
||||
<Box bg="graphite-black" borderBottom borderTop borderColor="border-gray/30" py={0}>
|
||||
<Container>
|
||||
<Box display="grid" gridCols={{ base: 2, md: 4 }} gap={0} borderLeft borderRight borderColor="border-gray/30">
|
||||
<MetricCard
|
||||
label="Active Drivers"
|
||||
value="1,284"
|
||||
icon={Users}
|
||||
trend={{ value: 12, isPositive: true }}
|
||||
border={false}
|
||||
bg="transparent"
|
||||
/>
|
||||
<Box borderLeft borderColor="border-gray/30">
|
||||
<MetricCard
|
||||
label="Live Sessions"
|
||||
value="42"
|
||||
icon={Activity}
|
||||
color="text-telemetry-aqua"
|
||||
border={false}
|
||||
bg="transparent"
|
||||
/>
|
||||
</Box>
|
||||
<Box borderLeft borderColor="border-gray/30">
|
||||
<MetricCard
|
||||
label="Total Races"
|
||||
value="15,402"
|
||||
icon={Trophy}
|
||||
color="text-warning-amber"
|
||||
border={false}
|
||||
bg="transparent"
|
||||
/>
|
||||
</Box>
|
||||
<Box borderLeft borderColor="border-gray/30">
|
||||
<MetricCard
|
||||
label="Next Event"
|
||||
value="14:00"
|
||||
icon={Calendar}
|
||||
border={false}
|
||||
bg="transparent"
|
||||
/>
|
||||
</Box>
|
||||
</Box>
|
||||
</Container>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
61
apps/website/components/home/LeagueSummaryPanel.tsx
Normal file
61
apps/website/components/home/LeagueSummaryPanel.tsx
Normal file
@@ -0,0 +1,61 @@
|
||||
'use client';
|
||||
|
||||
import React from 'react';
|
||||
import { LeagueCard } from '@/ui/LeagueCard';
|
||||
import { routes } from '@/lib/routing/RouteConfig';
|
||||
import { Box } from '@/ui/Box';
|
||||
import { Heading } from '@/ui/Heading';
|
||||
import { Link } from '@/ui/Link';
|
||||
|
||||
interface League {
|
||||
id: string;
|
||||
name: string;
|
||||
description: string;
|
||||
}
|
||||
|
||||
interface LeagueSummaryPanelProps {
|
||||
leagues: League[];
|
||||
}
|
||||
|
||||
/**
|
||||
* LeagueSummaryPanel - Semantic section for featured leagues.
|
||||
*/
|
||||
export function LeagueSummaryPanel({ leagues }: LeagueSummaryPanelProps) {
|
||||
return (
|
||||
<Box as="section" bg="surface-charcoal" p={6} border borderColor="border-gray" rounded="none">
|
||||
<Box display="flex" alignItems="center" justifyContent="between" mb={6}>
|
||||
<Heading level={3} fontSize="xs" weight="bold" letterSpacing="widest" color="text-white">
|
||||
FEATURED LEAGUES
|
||||
</Heading>
|
||||
<Link
|
||||
href={routes.public.leagues}
|
||||
size="xs"
|
||||
weight="bold"
|
||||
letterSpacing="widest"
|
||||
variant="primary"
|
||||
hoverColor="text-white"
|
||||
transition
|
||||
>
|
||||
VIEW ALL →
|
||||
</Link>
|
||||
</Box>
|
||||
|
||||
<Box display="flex" flexDirection="col" gap={4}>
|
||||
{leagues.slice(0, 2).map((league) => (
|
||||
<LeagueCard
|
||||
key={league.id}
|
||||
name={league.name}
|
||||
description={league.description}
|
||||
coverUrl="/images/ff1600.jpeg"
|
||||
slotLabel="Drivers"
|
||||
usedSlots={18}
|
||||
maxSlots={24}
|
||||
fillPercentage={75}
|
||||
hasOpenSlots={true}
|
||||
openSlotsCount={6}
|
||||
/>
|
||||
))}
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
60
apps/website/components/home/QuickLinksPanel.tsx
Normal file
60
apps/website/components/home/QuickLinksPanel.tsx
Normal file
@@ -0,0 +1,60 @@
|
||||
'use client';
|
||||
|
||||
import React from 'react';
|
||||
import { Button } from '@/ui/Button';
|
||||
import { Icon } from '@/ui/Icon';
|
||||
import { routes } from '@/lib/routing/RouteConfig';
|
||||
import { Plus, Search, Shield, Users } from 'lucide-react';
|
||||
import { Box } from '@/ui/Box';
|
||||
import { Container } from '@/ui/Container';
|
||||
import { Text } from '@/ui/Text';
|
||||
|
||||
/**
|
||||
* QuickLinksPanel - Semantic quick actions bar.
|
||||
* Replaces HomeQuickActions with a more semantic implementation.
|
||||
*/
|
||||
export function QuickLinksPanel() {
|
||||
const links = [
|
||||
{ label: 'Find League', icon: Search, href: routes.public.leagues },
|
||||
{ label: 'Join Team', icon: Users, href: routes.public.teams },
|
||||
{ label: 'Create Race', icon: Plus, href: routes.protected.dashboard },
|
||||
{ label: 'Rulebooks', icon: Shield, href: '#' },
|
||||
];
|
||||
|
||||
return (
|
||||
<Box as="nav" bg="panel-gray/50" py={8} borderBottom borderColor="border-gray/30">
|
||||
<Container>
|
||||
<Box display="flex" flexWrap="wrap" justifyContent="center" gap={4}>
|
||||
{links.map((link) => (
|
||||
<Button
|
||||
key={link.label}
|
||||
as="a"
|
||||
href={link.href}
|
||||
variant="secondary"
|
||||
display="flex"
|
||||
alignItems="center"
|
||||
gap={3}
|
||||
px={6}
|
||||
bg="graphite-black"
|
||||
borderColor="border-gray/50"
|
||||
hoverBorderColor="primary-accent/50"
|
||||
transition
|
||||
group
|
||||
>
|
||||
<Icon
|
||||
icon={link.icon}
|
||||
size={4}
|
||||
color="text-gray-500"
|
||||
groupHoverTextColor="primary-accent"
|
||||
transition
|
||||
/>
|
||||
<Text size="xs" weight="bold" uppercase letterSpacing="widest">
|
||||
{link.label}
|
||||
</Text>
|
||||
</Button>
|
||||
))}
|
||||
</Box>
|
||||
</Container>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
67
apps/website/components/home/RecentRacesPanel.tsx
Normal file
67
apps/website/components/home/RecentRacesPanel.tsx
Normal file
@@ -0,0 +1,67 @@
|
||||
'use client';
|
||||
|
||||
import React from 'react';
|
||||
import { UpcomingRaceItem } from '@/ui/UpcomingRaceItem';
|
||||
import { routes } from '@/lib/routing/RouteConfig';
|
||||
import { Box } from '@/ui/Box';
|
||||
import { Heading } from '@/ui/Heading';
|
||||
import { Link } from '@/ui/Link';
|
||||
import { Text } from '@/ui/Text';
|
||||
|
||||
interface Race {
|
||||
id: string;
|
||||
track: string;
|
||||
car: string;
|
||||
formattedDate: string;
|
||||
}
|
||||
|
||||
interface RecentRacesPanelProps {
|
||||
races: Race[];
|
||||
}
|
||||
|
||||
/**
|
||||
* RecentRacesPanel - Semantic section for upcoming/recent races.
|
||||
*/
|
||||
export function RecentRacesPanel({ races }: RecentRacesPanelProps) {
|
||||
return (
|
||||
<Box as="section" bg="surface-charcoal" p={6} border borderColor="border-gray" rounded="none">
|
||||
<Box display="flex" alignItems="center" justifyContent="between" mb={6}>
|
||||
<Heading level={3} fontSize="xs" weight="bold" letterSpacing="widest" color="text-white">
|
||||
UPCOMING RACES
|
||||
</Heading>
|
||||
<Link
|
||||
href={routes.public.races}
|
||||
size="xs"
|
||||
weight="bold"
|
||||
letterSpacing="widest"
|
||||
variant="primary"
|
||||
hoverColor="text-white"
|
||||
transition
|
||||
>
|
||||
FULL SCHEDULE →
|
||||
</Link>
|
||||
</Box>
|
||||
|
||||
<Box display="flex" flexDirection="col" gap={3}>
|
||||
{races.length === 0 ? (
|
||||
<Box py={12} border borderStyle="dashed" borderColor="border-gray/30" bg="graphite-black/50" display="flex" alignItems="center" justifyContent="center">
|
||||
<Text size="xs" font="mono" uppercase letterSpacing="widest" color="text-gray-600">
|
||||
No races scheduled
|
||||
</Text>
|
||||
</Box>
|
||||
) : (
|
||||
races.slice(0, 3).map((race) => (
|
||||
<UpcomingRaceItem
|
||||
key={race.id}
|
||||
track={race.track}
|
||||
car={race.car}
|
||||
formattedDate={race.formattedDate}
|
||||
formattedTime="20:00 GMT"
|
||||
isMyLeague={false}
|
||||
/>
|
||||
))
|
||||
)}
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
58
apps/website/components/home/TeamSummaryPanel.tsx
Normal file
58
apps/website/components/home/TeamSummaryPanel.tsx
Normal file
@@ -0,0 +1,58 @@
|
||||
'use client';
|
||||
|
||||
import React from 'react';
|
||||
import { TeamCard } from '@/ui/TeamCard';
|
||||
import { routes } from '@/lib/routing/RouteConfig';
|
||||
import { Box } from '@/ui/Box';
|
||||
import { Heading } from '@/ui/Heading';
|
||||
import { Link } from '@/ui/Link';
|
||||
|
||||
interface Team {
|
||||
id: string;
|
||||
name: string;
|
||||
description: string;
|
||||
logoUrl?: string;
|
||||
}
|
||||
|
||||
interface TeamSummaryPanelProps {
|
||||
teams: Team[];
|
||||
}
|
||||
|
||||
/**
|
||||
* TeamSummaryPanel - Semantic section for teams.
|
||||
*/
|
||||
export function TeamSummaryPanel({ teams }: TeamSummaryPanelProps) {
|
||||
return (
|
||||
<Box as="section" bg="surface-charcoal" p={6} border borderColor="border-gray" rounded="none">
|
||||
<Box display="flex" alignItems="center" justifyContent="between" mb={6}>
|
||||
<Heading level={3} fontSize="xs" weight="bold" letterSpacing="widest" color="text-white">
|
||||
TEAMS ON THE GRID
|
||||
</Heading>
|
||||
<Link
|
||||
href={routes.public.teams}
|
||||
size="xs"
|
||||
weight="bold"
|
||||
letterSpacing="widest"
|
||||
variant="primary"
|
||||
hoverColor="text-white"
|
||||
transition
|
||||
>
|
||||
BROWSE TEAMS →
|
||||
</Link>
|
||||
</Box>
|
||||
|
||||
<Box display="flex" flexDirection="col" gap={4}>
|
||||
{teams.slice(0, 2).map((team) => (
|
||||
<TeamCard
|
||||
key={team.id}
|
||||
name={team.name}
|
||||
description={team.description}
|
||||
logo={team.logoUrl}
|
||||
memberCount={12}
|
||||
isRecruiting={true}
|
||||
/>
|
||||
))}
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
@@ -93,12 +93,12 @@ export function AlternatingSection({
|
||||
order={{ lg: layout === 'text-right' ? 2 : 1 }}
|
||||
>
|
||||
<Stack gap={4}>
|
||||
<Box w="12" h="1" bg="primary-accent" />
|
||||
<Heading level={2} fontSize={{ base: '3xl', md: '4xl' }} weight="bold" className="tracking-tight">
|
||||
<Box w="8" h="1" bg="primary-accent" />
|
||||
<Heading level={2} fontSize={{ base: '3xl', md: '5xl' }} weight="bold" className="tracking-tighter uppercase leading-none">
|
||||
{heading}
|
||||
</Heading>
|
||||
</Stack>
|
||||
<Box className="text-gray-400">
|
||||
<Box className="text-gray-500 border-l border-border-gray/20 pl-6">
|
||||
{typeof description === 'string' ? (
|
||||
<Text size="lg" leading="relaxed" weight="normal">{description}</Text>
|
||||
) : (
|
||||
@@ -111,18 +111,18 @@ export function AlternatingSection({
|
||||
<Box
|
||||
position="relative"
|
||||
order={{ lg: layout === 'text-right' ? 1 : 2 }}
|
||||
className="bg-panel-gray/30 border border-border-gray/50 rounded-none p-2 shadow-2xl group"
|
||||
className="bg-panel-gray/20 border border-border-gray/30 rounded-none p-1 shadow-2xl group"
|
||||
>
|
||||
<Box
|
||||
fullWidth
|
||||
minHeight={{ base: '240px', md: '380px' }}
|
||||
className="overflow-hidden rounded-none border border-border-gray/30 bg-graphite-black"
|
||||
className="overflow-hidden rounded-none border border-border-gray/20 bg-graphite-black"
|
||||
>
|
||||
{mockup}
|
||||
</Box>
|
||||
{/* Decorative corner accents */}
|
||||
<Box position="absolute" top="-1px" left="-1px" w="4" h="4" borderTop borderLeft borderColor="primary-accent/50" />
|
||||
<Box position="absolute" bottom="-1px" right="-1px" w="4" h="4" borderBottom borderRight borderColor="primary-accent/50" />
|
||||
<Box position="absolute" top="-1px" left="-1px" w="3" h="3" borderTop borderLeft borderColor="primary-accent/40" />
|
||||
<Box position="absolute" bottom="-1px" right="-1px" w="3" h="3" borderBottom borderRight borderColor="primary-accent/40" />
|
||||
</Box>
|
||||
</Box>
|
||||
</Container>
|
||||
|
||||
@@ -53,21 +53,21 @@ function FeatureCard({ feature, index }: { feature: typeof features[0], index: n
|
||||
display="flex"
|
||||
flexDirection="column"
|
||||
gap={6}
|
||||
className="p-8 bg-panel-gray/40 border border-border-gray/50 rounded-none hover:border-primary-accent/30 transition-all duration-300 ease-smooth group relative overflow-hidden"
|
||||
className="p-8 bg-panel-gray/20 border border-border-gray/20 rounded-none hover:border-primary-accent/20 transition-all duration-300 ease-smooth group relative overflow-hidden"
|
||||
>
|
||||
<Box aspectRatio="video" fullWidth position="relative" className="bg-graphite-black rounded-none overflow-hidden border border-border-gray/30">
|
||||
<Box aspectRatio="video" fullWidth position="relative" className="bg-graphite-black rounded-none overflow-hidden border border-border-gray/20">
|
||||
<MockupStack index={index}>
|
||||
<feature.MockupComponent />
|
||||
</MockupStack>
|
||||
</Box>
|
||||
<Stack gap={4}>
|
||||
<Box display="flex" alignItems="center" gap={3}>
|
||||
<Box w="1" h="4" bg="primary-accent" />
|
||||
<Heading level={3} weight="bold" fontSize="xl" className="tracking-tight">
|
||||
<Box w="1" h="3" bg="primary-accent" />
|
||||
<Heading level={3} weight="bold" fontSize="lg" className="tracking-tighter uppercase">
|
||||
{feature.title}
|
||||
</Heading>
|
||||
</Box>
|
||||
<Text size="sm" color="text-gray-400" leading="relaxed" weight="normal">
|
||||
<Text size="sm" color="text-gray-500" leading="relaxed" weight="normal" className="group-hover:text-gray-400 transition-colors">
|
||||
{feature.description}
|
||||
</Text>
|
||||
</Stack>
|
||||
@@ -77,7 +77,7 @@ function FeatureCard({ feature, index }: { feature: typeof features[0], index: n
|
||||
bottom="0"
|
||||
left="0"
|
||||
w="full"
|
||||
h="1"
|
||||
h="0.5"
|
||||
bg="primary-accent"
|
||||
className="scale-x-0 group-hover:scale-x-100 transition-transform duration-500 origin-left"
|
||||
/>
|
||||
@@ -87,24 +87,24 @@ function FeatureCard({ feature, index }: { feature: typeof features[0], index: n
|
||||
|
||||
export function FeatureGrid() {
|
||||
return (
|
||||
<Section className="bg-graphite-black border-b border-border-gray/50 py-24">
|
||||
<Section className="bg-graphite-black border-b border-border-gray/20 py-32">
|
||||
<Container position="relative" zIndex={10}>
|
||||
<Stack gap={16}>
|
||||
<Box maxWidth="2xl">
|
||||
<Box borderLeft borderStyle="solid" borderColor="primary-accent" pl={4} mb={4}>
|
||||
<Text size="xs" weight="bold" color="text-primary-accent" className="uppercase tracking-[0.2em]">
|
||||
<Box borderLeft borderStyle="solid" borderColor="primary-accent" pl={4} mb={4} bg="primary-accent/5" py={1}>
|
||||
<Text size="xs" weight="bold" color="text-primary-accent" className="uppercase tracking-[0.3em]">
|
||||
Engineered for Competition
|
||||
</Text>
|
||||
</Box>
|
||||
<Heading level={2} weight="bold" fontSize={{ base: '3xl', md: '4xl' }} className="tracking-tight">
|
||||
<Heading level={2} weight="bold" fontSize={{ base: '3xl', md: '5xl' }} className="tracking-tighter uppercase leading-none">
|
||||
Building for League Racing
|
||||
</Heading>
|
||||
<Text size="lg" color="text-gray-400" block mt={6} leading="relaxed">
|
||||
<Text size="lg" color="text-gray-500" block mt={6} leading="relaxed" className="border-l border-border-gray/20 pl-6">
|
||||
Every feature is designed to reduce friction and increase immersion. Join our Discord to help shape the future of the platform.
|
||||
</Text>
|
||||
</Box>
|
||||
|
||||
<Box display="grid" gridCols={{ base: 1, md: 2, lg: 3 }} gap={8}>
|
||||
<Box display="grid" gridCols={{ base: 1, md: 2, lg: 3 }} gap={6}>
|
||||
{features.map((feature, index) => (
|
||||
<FeatureCard key={feature.title} feature={feature} index={index} />
|
||||
))}
|
||||
|
||||
@@ -29,11 +29,11 @@ export function LandingHero() {
|
||||
<Box
|
||||
position="absolute"
|
||||
inset="0"
|
||||
bg="url(/images/header.jpeg)"
|
||||
backgroundImage="url(/images/header.jpeg)"
|
||||
backgroundSize="cover"
|
||||
backgroundPosition="center"
|
||||
opacity={0.2}
|
||||
style={{ transform: `translateY(${bgParallax * 0.5}px)` }}
|
||||
transform={`translateY(${bgParallax * 0.5}px)`}
|
||||
/>
|
||||
|
||||
{/* Robust gradient overlay */}
|
||||
@@ -47,29 +47,30 @@ export function LandingHero() {
|
||||
position="absolute"
|
||||
inset="0"
|
||||
bg="radial-gradient(circle at center, transparent 0%, #0C0D0F 100%)"
|
||||
opacity={0.6}
|
||||
opacity={0.8}
|
||||
/>
|
||||
|
||||
<Glow color="primary" size="xl" position="center" opacity={0.1} />
|
||||
<Glow color="primary" size="xl" position="center" opacity={0.15} />
|
||||
|
||||
<Container size="lg" position="relative" zIndex={10}>
|
||||
<Stack gap={{ base: 8, md: 12 }}>
|
||||
<Stack gap={6} maxWidth="3xl">
|
||||
<Box borderLeft borderStyle="solid" borderColor="primary-accent" pl={4} mb={2}>
|
||||
<Text size="xs" weight="bold" color="text-primary-accent" className="uppercase tracking-[0.2em]">
|
||||
<Box borderLeft borderStyle="solid" borderColor="primary-accent" pl={4} mb={2} bg="primary-accent/5" py={1}>
|
||||
<Text size="xs" weight="bold" color="text-primary-accent" uppercase letterSpacing="0.3em">
|
||||
Precision Racing Infrastructure
|
||||
</Text>
|
||||
</Box>
|
||||
<Heading
|
||||
level={1}
|
||||
fontSize={{ base: '3xl', sm: '4xl', md: '5xl', lg: '7xl' }}
|
||||
fontSize={{ base: '4xl', sm: '5xl', md: '6xl', lg: '8xl' }}
|
||||
weight="bold"
|
||||
className="text-white leading-[1.1] tracking-tight"
|
||||
color="text-white"
|
||||
lineHeight="0.95"
|
||||
letterSpacing="tighter"
|
||||
>
|
||||
Modern Motorsport <br />
|
||||
<span className="text-primary-accent">Infrastructure.</span>
|
||||
MODERN MOTORSPORT INFRASTRUCTURE.
|
||||
</Heading>
|
||||
<Text size={{ base: 'lg', md: 'xl' }} color="text-gray-400" weight="normal" leading="relaxed" className="max-w-2xl">
|
||||
<Text size={{ base: 'lg', md: 'xl' }} color="text-gray-400" weight="normal" leading="relaxed" maxWidth="2xl" borderLeft borderStyle="solid" borderColor="border-gray" pl={6} opacity={0.3}>
|
||||
GridPilot gives your league racing a real home. Results, standings, teams, and career progression — engineered for precision and control.
|
||||
</Text>
|
||||
</Stack>
|
||||
@@ -80,14 +81,23 @@ export function LandingHero() {
|
||||
href={discordUrl}
|
||||
variant="primary"
|
||||
size="lg"
|
||||
className="px-10 rounded-none uppercase tracking-widest text-xs font-bold"
|
||||
px={12}
|
||||
letterSpacing="0.2em"
|
||||
fontSize="xs"
|
||||
h="14"
|
||||
>
|
||||
Join the Grid
|
||||
</Button>
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="lg"
|
||||
className="px-10 rounded-none border-border-gray hover:border-primary-accent/50 uppercase tracking-widest text-xs font-bold"
|
||||
px={12}
|
||||
borderColor="border-gray"
|
||||
hoverBorderColor="primary-accent/50"
|
||||
letterSpacing="0.2em"
|
||||
fontSize="xs"
|
||||
h="14"
|
||||
bg="transparent"
|
||||
>
|
||||
Explore Leagues
|
||||
</Button>
|
||||
@@ -99,7 +109,11 @@ export function LandingHero() {
|
||||
gridCols={{ base: 1, sm: 2, lg: 4 }}
|
||||
gap={8}
|
||||
mt={12}
|
||||
className="border-t border-border-gray/30 pt-12"
|
||||
borderTop
|
||||
borderStyle="solid"
|
||||
borderColor="border-gray"
|
||||
opacity={0.2}
|
||||
pt={12}
|
||||
>
|
||||
{[
|
||||
{ label: 'IDENTITY', text: 'Your racing career in one place', color: 'primary' },
|
||||
@@ -107,14 +121,14 @@ export function LandingHero() {
|
||||
{ label: 'PRECISION', text: 'Real-time results and standings', color: 'amber' },
|
||||
{ label: 'COMMUNITY', text: 'Built for teams and leagues', color: 'green' }
|
||||
].map((item) => (
|
||||
<Stack key={item.label} gap={3} className="group">
|
||||
<Box display="flex" alignItems="center" gap={2}>
|
||||
<Box w="2" h="2" rounded="full" className={`bg-${item.color === 'primary' ? 'primary-accent' : item.color === 'aqua' ? 'telemetry-aqua' : item.color === 'amber' ? 'warning-amber' : 'success-green'}`} />
|
||||
<Text size="xs" weight="bold" color="text-gray-500" className="uppercase tracking-[0.2em] group-hover:text-white transition-colors">
|
||||
<Stack key={item.label} gap={3} group cursor="default">
|
||||
<Box display="flex" alignItems="center" gap={3}>
|
||||
<Box w="1" h="3" bg={item.color === 'primary' ? 'primary-accent' : item.color === 'aqua' ? 'telemetry-aqua' : item.color === 'amber' ? 'warning-amber' : 'success-green'} />
|
||||
<Text size="xs" weight="bold" color="text-gray-500" uppercase letterSpacing="0.2em" groupHoverTextColor="white" transition>
|
||||
{item.label}
|
||||
</Text>
|
||||
</Box>
|
||||
<Text size="sm" color="text-gray-400" className="group-hover:text-gray-200 transition-colors">
|
||||
<Text size="sm" color="text-gray-500" groupHoverTextColor="gray-300" transition leading="relaxed">
|
||||
{item.text}
|
||||
</Text>
|
||||
</Stack>
|
||||
|
||||
@@ -1,39 +0,0 @@
|
||||
import React from 'react';
|
||||
import Image from 'next/image';
|
||||
import Link from 'next/link';
|
||||
import { Text } from '@/ui/Text';
|
||||
|
||||
export function HeaderContent() {
|
||||
return (
|
||||
<div className="flex items-center justify-between h-16 md:h-20">
|
||||
<div className="flex items-center space-x-6">
|
||||
<Link href="/" className="inline-flex items-center group">
|
||||
<div className="relative">
|
||||
<Image
|
||||
src="/images/logos/wordmark-rectangle-dark.svg"
|
||||
alt="GridPilot"
|
||||
width={160}
|
||||
height={30}
|
||||
className="h-6 w-auto md:h-7 transition-opacity group-hover:opacity-80"
|
||||
priority
|
||||
/>
|
||||
<div className="absolute -bottom-1 left-0 w-0 h-0.5 bg-primary-accent transition-all group-hover:w-full" />
|
||||
</div>
|
||||
</Link>
|
||||
<div className="hidden sm:flex items-center space-x-2 border-l border-border-gray/50 pl-6">
|
||||
<div className="w-1.5 h-1.5 rounded-full bg-primary-accent animate-pulse" />
|
||||
<Text size="xs" color="text-gray-500" weight="bold" className="uppercase tracking-[0.2em] font-mono">
|
||||
Motorsport Infrastructure
|
||||
</Text>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center space-x-4">
|
||||
<div className="hidden md:flex items-center space-x-1 px-3 py-1 border border-border-gray/30 bg-panel-gray/20">
|
||||
<Text size="xs" color="text-gray-600" weight="bold" font="mono" className="uppercase">Status:</Text>
|
||||
<Text size="xs" color="text-success-green" weight="bold" font="mono" className="uppercase">Operational</Text>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
51
apps/website/components/leaderboards/DeltaChip.tsx
Normal file
51
apps/website/components/leaderboards/DeltaChip.tsx
Normal file
@@ -0,0 +1,51 @@
|
||||
import React from 'react';
|
||||
import { ChevronUp, ChevronDown, Minus } from 'lucide-react';
|
||||
import { Box } from '@/ui/Box';
|
||||
import { Text } from '@/ui/Text';
|
||||
import { Icon } from '@/ui/Icon';
|
||||
|
||||
interface DeltaChipProps {
|
||||
value: number;
|
||||
type?: 'rank' | 'rating';
|
||||
}
|
||||
|
||||
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>
|
||||
);
|
||||
}
|
||||
|
||||
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 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>
|
||||
);
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
import React from 'react';
|
||||
import { Trophy, Crown, Flag, ChevronRight } from 'lucide-react';
|
||||
import { Trophy, ChevronRight } from 'lucide-react';
|
||||
import { Button } from '@/ui/Button';
|
||||
import { Box } from '@/ui/Box';
|
||||
import { Text } from '@/ui/Text';
|
||||
@@ -8,8 +8,9 @@ import { Icon } from '@/ui/Icon';
|
||||
import { Stack } from '@/ui/Stack';
|
||||
import { Image } from '@/ui/Image';
|
||||
import { SkillLevelDisplay } from '@/lib/display-objects/SkillLevelDisplay';
|
||||
import { MedalDisplay } from '@/lib/display-objects/MedalDisplay';
|
||||
import { RatingDisplay } from '@/lib/display-objects/RatingDisplay';
|
||||
import { RankMedal } from './RankMedal';
|
||||
import { LeaderboardTableShell } from './LeaderboardTableShell';
|
||||
|
||||
interface DriverLeaderboardPreviewProps {
|
||||
drivers: {
|
||||
@@ -31,35 +32,54 @@ export function DriverLeaderboardPreview({ drivers, onDriverClick, onNavigateToD
|
||||
const top10 = drivers; // Already sliced in builder
|
||||
|
||||
return (
|
||||
<Box rounded="xl" bg="bg-iron-gray/30" border borderColor="border-charcoal-outline" overflow="hidden">
|
||||
<Box display="flex" alignItems="center" justifyContent="between" px={5} py={4} borderBottom borderColor="border-charcoal-outline" bg="bg-iron-gray/20">
|
||||
<LeaderboardTableShell>
|
||||
<Box
|
||||
display="flex"
|
||||
alignItems="center"
|
||||
justifyContent="between"
|
||||
px={5}
|
||||
py={4}
|
||||
borderBottom
|
||||
borderColor="border-charcoal-outline/50"
|
||||
bg="bg-deep-charcoal/40"
|
||||
>
|
||||
<Box display="flex" alignItems="center" gap={3}>
|
||||
<Box display="flex" h="10" w="10" alignItems="center" justifyContent="center" rounded="xl" bg="bg-gradient-to-br from-primary-blue/20 to-primary-blue/5" border borderColor="border-primary-blue/20">
|
||||
<Box
|
||||
display="flex"
|
||||
h="10"
|
||||
w="10"
|
||||
alignItems="center"
|
||||
justifyContent="center"
|
||||
rounded="lg"
|
||||
bg="bg-gradient-to-br from-primary-blue/15 to-primary-blue/5"
|
||||
border
|
||||
borderColor="border-primary-blue/20"
|
||||
>
|
||||
<Icon icon={Trophy} size={5} color="text-primary-blue" />
|
||||
</Box>
|
||||
<Box>
|
||||
<Heading level={3} fontSize="lg" weight="semibold" color="text-white">Driver Rankings</Heading>
|
||||
<Text size="xs" color="text-gray-500" block>Top performers across all leagues</Text>
|
||||
<Heading level={3} fontSize="lg" weight="bold" color="text-white" letterSpacing="tight">Driver Rankings</Heading>
|
||||
<Text size="xs" color="text-gray-500" block uppercase letterSpacing="wider" weight="bold">Top Performers</Text>
|
||||
</Box>
|
||||
</Box>
|
||||
<Button
|
||||
variant="secondary"
|
||||
onClick={onNavigateToDrivers}
|
||||
size="sm"
|
||||
hoverBg="bg-primary-blue/10"
|
||||
transition
|
||||
>
|
||||
<Stack direction="row" align="center" gap={2}>
|
||||
<Text size="sm">View All</Text>
|
||||
<Text size="sm" weight="medium">View All</Text>
|
||||
<Icon icon={ChevronRight} size={4} />
|
||||
</Stack>
|
||||
</Button>
|
||||
</Box>
|
||||
|
||||
<Stack gap={0}
|
||||
// eslint-disable-next-line gridpilot-rules/component-classification
|
||||
className="divide-y divide-charcoal-outline/50"
|
||||
>
|
||||
<Stack gap={0}>
|
||||
{top10.map((driver, index) => {
|
||||
const position = index + 1;
|
||||
const isLast = index === top10.length - 1;
|
||||
|
||||
return (
|
||||
<Box
|
||||
@@ -75,71 +95,64 @@ export function DriverLeaderboardPreview({ drivers, onDriverClick, onNavigateToD
|
||||
w="full"
|
||||
textAlign="left"
|
||||
transition
|
||||
hoverBg="bg-iron-gray/30"
|
||||
hoverBg="bg-white/[0.02]"
|
||||
group
|
||||
borderBottom={!isLast}
|
||||
borderColor="border-charcoal-outline/30"
|
||||
>
|
||||
<Box
|
||||
display="flex"
|
||||
h="8"
|
||||
w="8"
|
||||
alignItems="center"
|
||||
justifyContent="center"
|
||||
rounded="full"
|
||||
border
|
||||
// eslint-disable-next-line gridpilot-rules/component-classification
|
||||
className={`text-xs font-bold ${MedalDisplay.getBg(position)} ${MedalDisplay.getColor(position)}`}
|
||||
>
|
||||
{position <= 3 ? <Icon icon={Crown} size={3.5} /> : <Text weight="bold">{position}</Text>}
|
||||
<Box w="8" display="flex" justifyContent="center">
|
||||
<RankMedal rank={position} size="sm" />
|
||||
</Box>
|
||||
|
||||
<Box position="relative" w="9" h="9" rounded="full" overflow="hidden" border borderWidth="2px" borderColor="border-charcoal-outline">
|
||||
<Box
|
||||
position="relative"
|
||||
w="9"
|
||||
h="9"
|
||||
rounded="full"
|
||||
overflow="hidden"
|
||||
border
|
||||
borderWidth="1px"
|
||||
borderColor="border-charcoal-outline"
|
||||
groupHoverBorderColor="primary-blue/50"
|
||||
transition
|
||||
>
|
||||
<Image src={driver.avatarUrl} alt={driver.name} fullWidth fullHeight objectFit="cover" />
|
||||
</Box>
|
||||
|
||||
<Box flexGrow={1} minWidth="0">
|
||||
<Text weight="medium" color="text-white" truncate groupHoverTextColor="text-primary-blue" transition block>
|
||||
<Text
|
||||
weight="semibold"
|
||||
color="text-white"
|
||||
truncate
|
||||
groupHoverTextColor="text-primary-blue"
|
||||
transition
|
||||
block
|
||||
>
|
||||
{driver.name}
|
||||
</Text>
|
||||
<Box display="flex" alignItems="center" gap={2}>
|
||||
<Icon icon={Flag} size={3} color="text-gray-500" />
|
||||
<Text size="xs" color="text-gray-500">{driver.nationality}</Text>
|
||||
<Box as="span"
|
||||
// eslint-disable-next-line gridpilot-rules/component-classification
|
||||
className={SkillLevelDisplay.getColor(driver.skillLevel)}
|
||||
>
|
||||
<Text size="xs">{SkillLevelDisplay.getLabel(driver.skillLevel)}</Text>
|
||||
<Box w="1" h="1" rounded="full" bg="bg-gray-700" />
|
||||
<Box as="span" color={SkillLevelDisplay.getColor(driver.skillLevel)}>
|
||||
<Text size="xs" weight="medium">{SkillLevelDisplay.getLabel(driver.skillLevel)}</Text>
|
||||
</Box>
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
<Box display="flex" alignItems="center" gap={4}>
|
||||
<Box textAlign="center">
|
||||
<Text color="text-primary-blue" font="mono" weight="semibold" block>{RatingDisplay.format(driver.rating)}</Text>
|
||||
<Text
|
||||
// eslint-disable-next-line gridpilot-rules/component-classification
|
||||
style={{ fontSize: '10px' }}
|
||||
color="text-gray-500"
|
||||
block
|
||||
>
|
||||
Rating
|
||||
</Text>
|
||||
<Box display="flex" alignItems="center" gap={6}>
|
||||
<Box textAlign="right">
|
||||
<Text color="text-primary-blue" font="mono" weight="bold" block size="sm">{RatingDisplay.format(driver.rating)}</Text>
|
||||
<Text fontSize="10px" color="text-gray-500" block uppercase letterSpacing="wider" weight="bold">Rating</Text>
|
||||
</Box>
|
||||
<Box textAlign="center">
|
||||
<Text color="text-performance-green" font="mono" weight="semibold" block>{driver.wins}</Text>
|
||||
<Text
|
||||
// eslint-disable-next-line gridpilot-rules/component-classification
|
||||
style={{ fontSize: '10px' }}
|
||||
color="text-gray-500"
|
||||
block
|
||||
>
|
||||
Wins
|
||||
</Text>
|
||||
<Box textAlign="right" minWidth="12">
|
||||
<Text color="text-performance-green" font="mono" weight="bold" block size="sm">{driver.wins}</Text>
|
||||
<Text fontSize="10px" color="text-gray-500" block uppercase letterSpacing="wider" weight="bold">Wins</Text>
|
||||
</Box>
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
})}
|
||||
</Stack>
|
||||
</Box>
|
||||
</LeaderboardTableShell>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,88 @@
|
||||
import React from 'react';
|
||||
import { Search, Filter } from 'lucide-react';
|
||||
import { Box } from '@/ui/Box';
|
||||
import { Stack } from '@/ui/Stack';
|
||||
import { Icon } from '@/ui/Icon';
|
||||
import { Text } from '@/ui/Text';
|
||||
|
||||
interface LeaderboardFiltersBarProps {
|
||||
searchQuery?: string;
|
||||
onSearchChange?: (query: string) => void;
|
||||
placeholder?: string;
|
||||
children?: React.ReactNode;
|
||||
}
|
||||
|
||||
export function LeaderboardFiltersBar({
|
||||
searchQuery,
|
||||
onSearchChange,
|
||||
placeholder = 'Search drivers...',
|
||||
children,
|
||||
}: LeaderboardFiltersBarProps) {
|
||||
return (
|
||||
<Box
|
||||
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}>
|
||||
<Box position="relative" flexGrow={1} maxWidth="md">
|
||||
<Box
|
||||
position="absolute"
|
||||
left="3"
|
||||
top="1/2"
|
||||
transform="translateY(-50%)"
|
||||
pointerEvents="none"
|
||||
zIndex={10}
|
||||
>
|
||||
<Icon icon={Search} size={4} color="text-gray-500" />
|
||||
</Box>
|
||||
<Box
|
||||
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"
|
||||
/>
|
||||
</Box>
|
||||
|
||||
<Stack direction="row" align="center" gap={4}>
|
||||
{children}
|
||||
<Box
|
||||
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"
|
||||
>
|
||||
<Icon icon={Filter} size={3.5} color="text-gray-400" />
|
||||
<Text size="xs" weight="bold" color="text-gray-400" uppercase letterSpacing="wider">Filters</Text>
|
||||
</Box>
|
||||
</Stack>
|
||||
</Stack>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
72
apps/website/components/leaderboards/LeaderboardHeader.tsx
Normal file
72
apps/website/components/leaderboards/LeaderboardHeader.tsx
Normal file
@@ -0,0 +1,72 @@
|
||||
import React from 'react';
|
||||
import { ArrowLeft, LucideIcon } from 'lucide-react';
|
||||
import { Box } from '@/ui/Box';
|
||||
import { Heading } from '@/ui/Heading';
|
||||
import { Text } from '@/ui/Text';
|
||||
import { Stack } from '@/ui/Stack';
|
||||
import { Button } from '@/ui/Button';
|
||||
import { Icon } from '@/ui/Icon';
|
||||
|
||||
interface LeaderboardHeaderProps {
|
||||
title: string;
|
||||
description?: string;
|
||||
icon?: LucideIcon;
|
||||
onBack?: () => void;
|
||||
backLabel?: string;
|
||||
children?: React.ReactNode;
|
||||
}
|
||||
|
||||
export function LeaderboardHeader({
|
||||
title,
|
||||
description,
|
||||
icon,
|
||||
onBack,
|
||||
backLabel = 'Back',
|
||||
children,
|
||||
}: LeaderboardHeaderProps) {
|
||||
return (
|
||||
<Box mb={8}>
|
||||
{onBack && (
|
||||
<Box mb={6}>
|
||||
<Button
|
||||
variant="secondary"
|
||||
onClick={onBack}
|
||||
icon={<Icon icon={ArrowLeft} size={4} />}
|
||||
>
|
||||
{backLabel}
|
||||
</Button>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
<Stack direction="row" align="center" justify="between" gap={4}>
|
||||
<Stack direction="row" align="center" gap={4}>
|
||||
{icon && (
|
||||
<Box
|
||||
p={3}
|
||||
bg="linear-gradient(to bottom right, rgba(25, 140, 255, 0.15), rgba(25, 140, 255, 0.05))"
|
||||
border
|
||||
borderColor="border-primary-blue/20"
|
||||
rounded="xl"
|
||||
display="flex"
|
||||
alignItems="center"
|
||||
justifyContent="center"
|
||||
>
|
||||
<Icon icon={icon} size={6} color="text-primary-blue" />
|
||||
</Box>
|
||||
)}
|
||||
<Box>
|
||||
<Heading level={1} weight="bold" letterSpacing="tight">{title}</Heading>
|
||||
{description && (
|
||||
<Text color="text-gray-400" block mt={1} size="sm">
|
||||
{description}
|
||||
</Text>
|
||||
)}
|
||||
</Box>
|
||||
</Stack>
|
||||
<Box>
|
||||
{children}
|
||||
</Box>
|
||||
</Stack>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
import React from 'react';
|
||||
import { ArrowLeft, LucideIcon } from 'lucide-react';
|
||||
import { Box } from '@/ui/Box';
|
||||
import { Heading } from '@/ui/Heading';
|
||||
import { Text } from '@/ui/Text';
|
||||
import { Stack } from '@/ui/Stack';
|
||||
import { Button } from '@/ui/Button';
|
||||
import { Icon } from '@/ui/Icon';
|
||||
import { Surface } from '@/ui/Surface';
|
||||
|
||||
interface LeaderboardHeaderPanelProps {
|
||||
title: string;
|
||||
description?: string;
|
||||
icon?: LucideIcon;
|
||||
onBack?: () => void;
|
||||
backLabel?: string;
|
||||
children?: React.ReactNode;
|
||||
}
|
||||
|
||||
export function LeaderboardHeaderPanel({
|
||||
title,
|
||||
description,
|
||||
icon,
|
||||
onBack,
|
||||
backLabel = 'Back',
|
||||
children,
|
||||
}: LeaderboardHeaderPanelProps) {
|
||||
return (
|
||||
<Box mb={8}>
|
||||
{onBack && (
|
||||
<Box mb={6}>
|
||||
<Button
|
||||
variant="secondary"
|
||||
onClick={onBack}
|
||||
icon={<Icon icon={ArrowLeft} size={4} />}
|
||||
>
|
||||
{backLabel}
|
||||
</Button>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
<Stack direction="row" align="center" justify="between" gap={4}>
|
||||
<Stack direction="row" align="center" gap={4}>
|
||||
{icon && (
|
||||
<Surface
|
||||
variant="muted"
|
||||
rounded="xl"
|
||||
padding={3}
|
||||
bg="linear-gradient(to bottom right, rgba(25, 140, 255, 0.2), rgba(25, 140, 255, 0.05))"
|
||||
border
|
||||
borderColor="border-primary-blue/20"
|
||||
>
|
||||
<Icon icon={icon} size={7} color="text-primary-blue" />
|
||||
</Surface>
|
||||
)}
|
||||
<Box>
|
||||
<Heading level={1}>{title}</Heading>
|
||||
{description && (
|
||||
<Text color="text-gray-400" block mt={1}>
|
||||
{description}
|
||||
</Text>
|
||||
)}
|
||||
</Box>
|
||||
</Stack>
|
||||
{children}
|
||||
</Stack>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
@@ -1,116 +0,0 @@
|
||||
import React from 'react';
|
||||
import { Crown, Flag } from 'lucide-react';
|
||||
import { Box } from '@/ui/Box';
|
||||
import { Text } from '@/ui/Text';
|
||||
import { Stack } from '@/ui/Stack';
|
||||
import { Image } from '@/ui/Image';
|
||||
import { mediaConfig } from '@/lib/config/mediaConfig';
|
||||
|
||||
interface LeaderboardItemProps {
|
||||
position: number;
|
||||
name: string;
|
||||
avatarUrl?: string;
|
||||
nationality: string;
|
||||
rating: number;
|
||||
wins: number;
|
||||
skillLevelLabel?: string;
|
||||
skillLevelColor?: string;
|
||||
categoryLabel?: string;
|
||||
categoryColor?: string;
|
||||
onClick: () => void;
|
||||
}
|
||||
|
||||
export function LeaderboardItem({
|
||||
position,
|
||||
name,
|
||||
avatarUrl,
|
||||
nationality,
|
||||
rating,
|
||||
wins,
|
||||
skillLevelLabel,
|
||||
skillLevelColor,
|
||||
categoryLabel,
|
||||
categoryColor,
|
||||
onClick,
|
||||
}: LeaderboardItemProps) {
|
||||
const getMedalColor = (pos: number) => {
|
||||
switch (pos) {
|
||||
case 1: return 'text-yellow-400';
|
||||
case 2: return 'text-gray-300';
|
||||
case 3: return 'text-amber-600';
|
||||
default: return 'text-gray-500';
|
||||
}
|
||||
};
|
||||
|
||||
const getMedalBg = (pos: number) => {
|
||||
switch (pos) {
|
||||
case 1: return 'bg-yellow-400/10 border-yellow-400/30';
|
||||
case 2: return 'bg-gray-300/10 border-gray-300/30';
|
||||
case 3: return 'bg-amber-600/10 border-amber-600/30';
|
||||
default: return 'bg-iron-gray/50 border-charcoal-outline';
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Box
|
||||
as="button"
|
||||
type="button"
|
||||
onClick={onClick}
|
||||
display="flex"
|
||||
alignItems="center"
|
||||
gap={4}
|
||||
px={4}
|
||||
py={3}
|
||||
fullWidth
|
||||
textAlign="left"
|
||||
className="hover:bg-iron-gray/30 transition-colors group"
|
||||
>
|
||||
{/* Position */}
|
||||
<Box
|
||||
width="8"
|
||||
height="8"
|
||||
display="flex"
|
||||
center
|
||||
rounded="full"
|
||||
border
|
||||
className={`${getMedalBg(position)} ${getMedalColor(position)} text-xs font-bold`}
|
||||
>
|
||||
{position <= 3 ? <Crown className="w-3.5 h-3.5" /> : position}
|
||||
</Box>
|
||||
|
||||
{/* Avatar */}
|
||||
<Box position="relative" width="9" height="9" rounded="full" overflow="hidden" border={true} borderColor="border-charcoal-outline">
|
||||
<Image src={avatarUrl || mediaConfig.avatars.defaultFallback} alt={name} fill objectFit="cover" />
|
||||
</Box>
|
||||
|
||||
{/* Info */}
|
||||
<Box flexGrow={1} minWidth="0">
|
||||
<Text weight="medium" color="text-white" truncate block className="group-hover:text-primary-blue transition-colors">
|
||||
{name}
|
||||
</Text>
|
||||
<Stack direction="row" align="center" gap={2}>
|
||||
<Flag className="w-3 h-3 text-gray-500" />
|
||||
<Text size="xs" color="text-gray-500">{nationality}</Text>
|
||||
{categoryLabel && (
|
||||
<Text size="xs" className={categoryColor}>{categoryLabel}</Text>
|
||||
)}
|
||||
{skillLevelLabel && (
|
||||
<Text size="xs" className={skillLevelColor}>{skillLevelLabel}</Text>
|
||||
)}
|
||||
</Stack>
|
||||
</Box>
|
||||
|
||||
{/* Stats */}
|
||||
<Stack direction="row" align="center" gap={4}>
|
||||
<Box textAlign="center">
|
||||
<Text color="text-primary-blue" weight="semibold" font="mono" block>{rating.toLocaleString()}</Text>
|
||||
<Text size="xs" color="text-gray-500" block style={{ fontSize: '10px' }}>Rating</Text>
|
||||
</Box>
|
||||
<Box textAlign="center">
|
||||
<Text color="text-performance-green" weight="semibold" font="mono" block>{wins}</Text>
|
||||
<Text size="xs" color="text-gray-500" block style={{ fontSize: '10px' }}>Wins</Text>
|
||||
</Box>
|
||||
</Stack>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
173
apps/website/components/leaderboards/LeaderboardPodium.tsx
Normal file
173
apps/website/components/leaderboards/LeaderboardPodium.tsx
Normal file
@@ -0,0 +1,173 @@
|
||||
import React from 'react';
|
||||
import { Box } from '@/ui/Box';
|
||||
import { Image } from '@/ui/Image';
|
||||
import { Stack } from '@/ui/Stack';
|
||||
import { Text } from '@/ui/Text';
|
||||
import { RatingDisplay } from '@/lib/display-objects/RatingDisplay';
|
||||
import { MedalDisplay } from '@/lib/display-objects/MedalDisplay';
|
||||
|
||||
interface PodiumDriver {
|
||||
id: string;
|
||||
name: string;
|
||||
avatarUrl: string;
|
||||
rating: number;
|
||||
wins: number;
|
||||
podiums: number;
|
||||
}
|
||||
|
||||
interface LeaderboardPodiumProps {
|
||||
podium: PodiumDriver[];
|
||||
onDriverClick?: (id: string) => void;
|
||||
}
|
||||
|
||||
export function LeaderboardPodium({ podium, onDriverClick }: LeaderboardPodiumProps) {
|
||||
// Order: 2nd, 1st, 3rd
|
||||
const displayOrder = [1, 0, 2];
|
||||
|
||||
return (
|
||||
<Box mb={12}>
|
||||
<Box display="flex" alignItems="end" justifyContent="center" gap={4} maxWidth="4xl" mx="auto">
|
||||
{displayOrder.map((index) => {
|
||||
const driver = podium[index];
|
||||
if (!driver) return <Box key={index} flexGrow={1} />;
|
||||
|
||||
const position = index + 1;
|
||||
const isFirst = position === 1;
|
||||
|
||||
const config = {
|
||||
1: { height: '48', scale: '1.1', zIndex: 10, shadow: 'shadow-warning-amber/20' },
|
||||
2: { height: '36', scale: '1', zIndex: 0, shadow: 'shadow-white/5' },
|
||||
3: { height: '28', scale: '0.9', zIndex: 0, shadow: 'shadow-white/5' },
|
||||
}[position as 1 | 2 | 3];
|
||||
|
||||
return (
|
||||
<Box
|
||||
key={driver.id}
|
||||
as="button"
|
||||
type="button"
|
||||
onClick={() => onDriverClick?.(driver.id)}
|
||||
display="flex"
|
||||
flexDirection="col"
|
||||
alignItems="center"
|
||||
flexGrow={1}
|
||||
transition
|
||||
hoverScale
|
||||
group
|
||||
shadow={config.shadow}
|
||||
zIndex={config.zIndex}
|
||||
>
|
||||
<Box position="relative" mb={4} transform={`scale(${config.scale})`}>
|
||||
<Box
|
||||
position="relative"
|
||||
w={isFirst ? '32' : '24'}
|
||||
h={isFirst ? '32' : '24'}
|
||||
rounded="full"
|
||||
overflow="hidden"
|
||||
border
|
||||
borderColor={isFirst ? 'border-warning-amber' : 'border-charcoal-outline'}
|
||||
borderWidth="3px"
|
||||
transition
|
||||
groupHoverBorderColor="primary-blue"
|
||||
shadow="xl"
|
||||
>
|
||||
<Image
|
||||
src={driver.avatarUrl}
|
||||
alt={driver.name}
|
||||
width={128}
|
||||
height={128}
|
||||
fullWidth
|
||||
fullHeight
|
||||
objectFit="cover"
|
||||
/>
|
||||
</Box>
|
||||
<Box
|
||||
position="absolute"
|
||||
bottom="-2"
|
||||
left="50%"
|
||||
w="10"
|
||||
h="10"
|
||||
rounded="full"
|
||||
display="flex"
|
||||
alignItems="center"
|
||||
justifyContent="center"
|
||||
border
|
||||
transform="translateX(-50%)"
|
||||
borderWidth="2px"
|
||||
bg={MedalDisplay.getBg(position)}
|
||||
color={MedalDisplay.getColor(position)}
|
||||
shadow="lg"
|
||||
>
|
||||
<Text size="sm" weight="bold">{position}</Text>
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
<Text
|
||||
weight="bold"
|
||||
color="text-white"
|
||||
size={isFirst ? 'lg' : 'base'}
|
||||
mb={1}
|
||||
block
|
||||
truncate
|
||||
align="center"
|
||||
px={2}
|
||||
maxWidth="full"
|
||||
groupHoverTextColor="text-primary-blue"
|
||||
transition
|
||||
>
|
||||
{driver.name}
|
||||
</Text>
|
||||
|
||||
<Text
|
||||
font="mono"
|
||||
weight="bold"
|
||||
size={isFirst ? 'xl' : 'lg'}
|
||||
block
|
||||
color={isFirst ? 'text-warning-amber' : 'text-primary-blue'}
|
||||
>
|
||||
{RatingDisplay.format(driver.rating)}
|
||||
</Text>
|
||||
|
||||
<Stack direction="row" align="center" gap={3} mt={1}>
|
||||
<Stack direction="row" align="center" gap={1}>
|
||||
<Text size="xs" color="text-gray-500" weight="bold" uppercase letterSpacing="wider">Wins</Text>
|
||||
<Text size="xs" weight="bold" color="text-performance-green">{driver.wins}</Text>
|
||||
</Stack>
|
||||
<Box w="1" h="1" rounded="full" bg="bg-gray-700" />
|
||||
<Stack direction="row" align="center" gap={1}>
|
||||
<Text size="xs" color="text-gray-500" weight="bold" uppercase letterSpacing="wider">Podiums</Text>
|
||||
<Text size="xs" weight="bold" color="text-white">{driver.podiums}</Text>
|
||||
</Stack>
|
||||
</Stack>
|
||||
|
||||
<Box
|
||||
mt={6}
|
||||
w="full"
|
||||
h={config.height}
|
||||
rounded="lg"
|
||||
border
|
||||
borderColor="border-charcoal-outline/50"
|
||||
bg="bg-deep-charcoal/40"
|
||||
display="flex"
|
||||
alignItems="center"
|
||||
justifyContent="center"
|
||||
blur="sm"
|
||||
groupHoverBorderColor="primary-blue/30"
|
||||
transition
|
||||
>
|
||||
<Text
|
||||
weight="bold"
|
||||
size="4xl"
|
||||
color={MedalDisplay.getColor(position)}
|
||||
opacity={0.1}
|
||||
fontSize={isFirst ? '5rem' : '3.5rem'}
|
||||
>
|
||||
{position}
|
||||
</Text>
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
})}
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
@@ -1,105 +0,0 @@
|
||||
|
||||
|
||||
import { routes } from '@/lib/routing/RouteConfig';
|
||||
import { Box } from '@/ui/Box';
|
||||
import { Button } from '@/ui/Button';
|
||||
import { Heading } from '@/ui/Heading';
|
||||
import { Icon } from '@/ui/Icon';
|
||||
import { LeaderboardItem } from '@/components/leaderboards/LeaderboardItem';
|
||||
import { LeaderboardList } from '@/ui/LeaderboardList';
|
||||
import { Stack } from '@/ui/Stack';
|
||||
import { Text } from '@/ui/Text';
|
||||
import { Award, ChevronRight } 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' },
|
||||
];
|
||||
|
||||
interface LeaderboardPreviewProps {
|
||||
drivers: {
|
||||
id: string;
|
||||
name: string;
|
||||
avatarUrl?: string;
|
||||
nationality: string;
|
||||
rating: number;
|
||||
wins: number;
|
||||
skillLevel?: string;
|
||||
category?: string;
|
||||
}[];
|
||||
onDriverClick: (id: string) => void;
|
||||
onNavigate: (href: string) => void;
|
||||
}
|
||||
|
||||
export function LeaderboardPreview({ drivers, onDriverClick, onNavigate }: LeaderboardPreviewProps) {
|
||||
const top5 = drivers.slice(0, 5);
|
||||
|
||||
return (
|
||||
<Stack gap={4} mb={10}>
|
||||
<Stack direction="row" align="center" justify="between">
|
||||
<Stack direction="row" align="center" gap={3}>
|
||||
<Box
|
||||
display="flex"
|
||||
center
|
||||
w="10"
|
||||
h="10"
|
||||
rounded="xl"
|
||||
bg="bg-gradient-to-br from-yellow-400/20 to-amber-600/10"
|
||||
border
|
||||
borderColor="border-yellow-400/30"
|
||||
>
|
||||
<Icon icon={Award} size={5} color="rgb(250, 204, 21)" />
|
||||
</Box>
|
||||
<Box>
|
||||
<Heading level={2}>Top Drivers</Heading>
|
||||
<Text size="xs" color="text-gray-500">Highest rated competitors</Text>
|
||||
</Box>
|
||||
</Stack>
|
||||
|
||||
<Button
|
||||
variant="secondary"
|
||||
onClick={() => onNavigate(routes.leaderboards.drivers)}
|
||||
icon={<Icon icon={ChevronRight} size={4} />}
|
||||
>
|
||||
Full Rankings
|
||||
</Button>
|
||||
</Stack>
|
||||
|
||||
<LeaderboardList>
|
||||
{top5.map((driver, index) => {
|
||||
const levelConfig = SKILL_LEVELS.find((l) => l.id === driver.skillLevel);
|
||||
const categoryConfig = CATEGORIES.find((c) => c.id === driver.category);
|
||||
const position = index + 1;
|
||||
|
||||
return (
|
||||
<LeaderboardItem
|
||||
key={driver.id}
|
||||
position={position}
|
||||
name={driver.name}
|
||||
avatarUrl={driver.avatarUrl}
|
||||
nationality={driver.nationality}
|
||||
rating={driver.rating}
|
||||
wins={driver.wins}
|
||||
skillLevelLabel={levelConfig?.label}
|
||||
skillLevelColor={levelConfig?.color}
|
||||
categoryLabel={categoryConfig?.label}
|
||||
categoryColor={categoryConfig?.color}
|
||||
onClick={() => onDriverClick(driver.id)}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</LeaderboardList>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
49
apps/website/components/leaderboards/LeaderboardTable.tsx
Normal file
49
apps/website/components/leaderboards/LeaderboardTable.tsx
Normal file
@@ -0,0 +1,49 @@
|
||||
import React from 'react';
|
||||
import { Table, TableBody, TableHead, TableHeader, TableRow } from '@/ui/Table';
|
||||
import { RankingRow } from './RankingRow';
|
||||
import { LeaderboardTableShell } from './LeaderboardTableShell';
|
||||
|
||||
interface LeaderboardDriver {
|
||||
id: string;
|
||||
name: string;
|
||||
avatarUrl: string;
|
||||
rank: number;
|
||||
rankDelta?: number;
|
||||
nationality: string;
|
||||
skillLevel: string;
|
||||
racesCompleted: number;
|
||||
rating: number;
|
||||
wins: number;
|
||||
}
|
||||
|
||||
interface LeaderboardTableProps {
|
||||
drivers: LeaderboardDriver[];
|
||||
onDriverClick?: (id: string) => void;
|
||||
}
|
||||
|
||||
export function LeaderboardTable({ drivers, onDriverClick }: LeaderboardTableProps) {
|
||||
return (
|
||||
<LeaderboardTableShell isEmpty={drivers.length === 0} emptyMessage="No drivers found">
|
||||
<Table>
|
||||
<TableHead>
|
||||
<TableRow>
|
||||
<TableHeader w="32">Rank</TableHeader>
|
||||
<TableHeader>Driver</TableHeader>
|
||||
<TableHeader textAlign="center">Races</TableHeader>
|
||||
<TableHeader textAlign="center">Rating</TableHeader>
|
||||
<TableHeader textAlign="center">Wins</TableHeader>
|
||||
</TableRow>
|
||||
</TableHead>
|
||||
<TableBody>
|
||||
{drivers.map((driver) => (
|
||||
<RankingRow
|
||||
key={driver.id}
|
||||
{...driver}
|
||||
onClick={() => onDriverClick?.(driver.id)}
|
||||
/>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</LeaderboardTableShell>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
import React from 'react';
|
||||
import { Box } from '@/ui/Box';
|
||||
import { Text } from '@/ui/Text';
|
||||
|
||||
interface LeaderboardTableShellProps {
|
||||
children: React.ReactNode;
|
||||
isEmpty?: boolean;
|
||||
emptyMessage?: string;
|
||||
emptyDescription?: string;
|
||||
}
|
||||
|
||||
export function LeaderboardTableShell({
|
||||
children,
|
||||
isEmpty,
|
||||
emptyMessage = 'No data found',
|
||||
emptyDescription = 'Try adjusting your filters or search query',
|
||||
}: LeaderboardTableShellProps) {
|
||||
if (isEmpty) {
|
||||
return (
|
||||
<Box
|
||||
py={16}
|
||||
textAlign="center"
|
||||
bg="bg-iron-gray/20"
|
||||
border
|
||||
borderColor="border-charcoal-outline"
|
||||
rounded="xl"
|
||||
>
|
||||
<Text size="4xl" block mb={4}>🔍</Text>
|
||||
<Text color="text-gray-400" block mb={2} weight="semibold">{emptyMessage}</Text>
|
||||
<Text size="sm" color="text-gray-500">{emptyDescription}</Text>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Box
|
||||
rounded="xl"
|
||||
bg="bg-iron-gray/20"
|
||||
border
|
||||
borderColor="border-charcoal-outline"
|
||||
overflow="hidden"
|
||||
>
|
||||
{children}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
@@ -25,27 +25,29 @@ export function LeaderboardsHero({ onNavigateToDrivers, onNavigateToTeams }: Lea
|
||||
padding={8}
|
||||
position="relative"
|
||||
overflow="hidden"
|
||||
bg="bg-gradient-to-br from-yellow-600/20 via-iron-gray to-deep-graphite"
|
||||
borderColor="border-yellow-500/20"
|
||||
bg="bg-gradient-to-br from-primary-blue/10 via-deep-charcoal to-graphite-black"
|
||||
borderColor="border-primary-blue/20"
|
||||
>
|
||||
<DecorativeBlur color="yellow" size="lg" position="top-right" opacity={10} />
|
||||
<DecorativeBlur color="blue" size="md" position="bottom-left" opacity={5} />
|
||||
<DecorativeBlur color="blue" size="lg" position="top-right" opacity={10} />
|
||||
<DecorativeBlur color="purple" size="md" position="bottom-left" opacity={5} />
|
||||
|
||||
<Box position="relative" zIndex={10}>
|
||||
<Stack direction="row" align="center" gap={4} mb={4}>
|
||||
<Surface
|
||||
variant="muted"
|
||||
rounded="xl"
|
||||
padding={3}
|
||||
bg="bg-gradient-to-br from-yellow-400/20 to-yellow-600/10"
|
||||
<Box
|
||||
p={3}
|
||||
bg="linear-gradient(to bottom right, rgba(25, 140, 255, 0.2), rgba(25, 140, 255, 0.05))"
|
||||
border
|
||||
borderColor="border-yellow-400/30"
|
||||
borderColor="border-primary-blue/30"
|
||||
rounded="xl"
|
||||
display="flex"
|
||||
alignItems="center"
|
||||
justifyContent="center"
|
||||
>
|
||||
<Icon icon={Award} size={7} color="#facc15" />
|
||||
</Surface>
|
||||
<Icon icon={Award} size={7} color="text-primary-blue" />
|
||||
</Box>
|
||||
<Box>
|
||||
<Heading level={1}>Leaderboards</Heading>
|
||||
<Text color="text-gray-400" block mt={1}>Where champions rise and legends are made</Text>
|
||||
<Heading level={1} weight="bold" letterSpacing="tight">Leaderboards</Heading>
|
||||
<Text color="text-gray-400" block mt={1} size="sm" uppercase letterSpacing="widest" weight="bold">Precision Performance Tracking</Text>
|
||||
</Box>
|
||||
</Stack>
|
||||
|
||||
@@ -53,25 +55,27 @@ export function LeaderboardsHero({ onNavigateToDrivers, onNavigateToTeams }: Lea
|
||||
size="lg"
|
||||
color="text-gray-400"
|
||||
block
|
||||
mb={6}
|
||||
mb={8}
|
||||
leading="relaxed"
|
||||
maxWidth="42rem"
|
||||
>
|
||||
Track the best drivers and teams across all competitions. Every race counts. Every position matters. Who will claim the throne?
|
||||
Track the best drivers and teams across all competitions. Every race counts. Every position matters. Analyze telemetry-grade rankings and performance metrics.
|
||||
</Text>
|
||||
|
||||
<Stack direction="row" gap={3} wrap>
|
||||
<Stack direction="row" gap={4} wrap>
|
||||
<Button
|
||||
variant="secondary"
|
||||
variant="primary"
|
||||
onClick={onNavigateToDrivers}
|
||||
icon={<Icon icon={Trophy} size={4} color="#3b82f6" />}
|
||||
icon={<Icon icon={Trophy} size={4} />}
|
||||
shadow="shadow-lg shadow-primary-blue/20"
|
||||
>
|
||||
Driver Rankings
|
||||
</Button>
|
||||
<Button
|
||||
variant="secondary"
|
||||
onClick={onNavigateToTeams}
|
||||
icon={<Icon icon={Users} size={4} color="#a855f7" />}
|
||||
icon={<Icon icon={Users} size={4} />}
|
||||
hoverBg="bg-white/5"
|
||||
>
|
||||
Team Rankings
|
||||
</Button>
|
||||
|
||||
54
apps/website/components/leaderboards/RankMedal.tsx
Normal file
54
apps/website/components/leaderboards/RankMedal.tsx
Normal file
@@ -0,0 +1,54 @@
|
||||
import React from 'react';
|
||||
import { Crown, Medal } from 'lucide-react';
|
||||
import { Box } from '@/ui/Box';
|
||||
import { Text } from '@/ui/Text';
|
||||
import { Icon } from '@/ui/Icon';
|
||||
import { MedalDisplay } from '@/lib/display-objects/MedalDisplay';
|
||||
|
||||
interface RankMedalProps {
|
||||
rank: number;
|
||||
size?: 'sm' | 'md' | 'lg';
|
||||
showIcon?: boolean;
|
||||
}
|
||||
|
||||
export function RankMedal({ rank, size = 'md', showIcon = true }: RankMedalProps) {
|
||||
const isTop3 = rank <= 3;
|
||||
|
||||
const sizeMap = {
|
||||
sm: '7',
|
||||
md: '8',
|
||||
lg: '10',
|
||||
};
|
||||
|
||||
const textSizeMap = {
|
||||
sm: 'xs',
|
||||
md: 'xs',
|
||||
lg: 'sm',
|
||||
} as const;
|
||||
|
||||
const iconSize = {
|
||||
sm: 3,
|
||||
md: 3.5,
|
||||
lg: 4.5,
|
||||
};
|
||||
|
||||
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)}
|
||||
>
|
||||
{isTop3 && showIcon ? (
|
||||
<Icon icon={rank === 1 ? Crown : Medal} size={iconSize[size]} />
|
||||
) : (
|
||||
<Text weight="bold" size={textSizeMap[size]}>{rank}</Text>
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
116
apps/website/components/leaderboards/RankingRow.tsx
Normal file
116
apps/website/components/leaderboards/RankingRow.tsx
Normal file
@@ -0,0 +1,116 @@
|
||||
import React from 'react';
|
||||
import { Box } from '@/ui/Box';
|
||||
import { Image } from '@/ui/Image';
|
||||
import { Stack } from '@/ui/Stack';
|
||||
import { TableCell, TableRow } from '@/ui/Table';
|
||||
import { Text } from '@/ui/Text';
|
||||
import { RankMedal } from './RankMedal';
|
||||
import { DeltaChip } from './DeltaChip';
|
||||
import { RatingDisplay } from '@/lib/display-objects/RatingDisplay';
|
||||
|
||||
interface RankingRowProps {
|
||||
id: string;
|
||||
rank: number;
|
||||
rankDelta?: number;
|
||||
name: string;
|
||||
avatarUrl: string;
|
||||
nationality: string;
|
||||
skillLevel: string;
|
||||
racesCompleted: number;
|
||||
rating: number;
|
||||
wins: number;
|
||||
onClick?: () => void;
|
||||
}
|
||||
|
||||
export function RankingRow({
|
||||
rank,
|
||||
rankDelta,
|
||||
name,
|
||||
avatarUrl,
|
||||
nationality,
|
||||
skillLevel,
|
||||
racesCompleted,
|
||||
rating,
|
||||
wins,
|
||||
onClick,
|
||||
}: RankingRowProps) {
|
||||
return (
|
||||
<TableRow
|
||||
clickable={!!onClick}
|
||||
onClick={onClick}
|
||||
group
|
||||
>
|
||||
<TableCell>
|
||||
<Stack direction="row" align="center" gap={4}>
|
||||
<Box w="8" display="flex" justifyContent="center">
|
||||
<RankMedal rank={rank} size="md" />
|
||||
</Box>
|
||||
{rankDelta !== undefined && (
|
||||
<Box w="10">
|
||||
<DeltaChip value={rankDelta} type="rank" />
|
||||
</Box>
|
||||
)}
|
||||
</Stack>
|
||||
</TableCell>
|
||||
|
||||
<TableCell>
|
||||
<Box display="flex" alignItems="center" gap={3}>
|
||||
<Box
|
||||
position="relative"
|
||||
w="10"
|
||||
h="10"
|
||||
rounded="full"
|
||||
overflow="hidden"
|
||||
border
|
||||
borderColor="border-charcoal-outline"
|
||||
groupHoverBorderColor="primary-blue/50"
|
||||
transition
|
||||
>
|
||||
<Image
|
||||
src={avatarUrl}
|
||||
alt={name}
|
||||
width={40}
|
||||
height={40}
|
||||
fullWidth
|
||||
fullHeight
|
||||
objectFit="cover"
|
||||
/>
|
||||
</Box>
|
||||
<Box minWidth="0">
|
||||
<Text
|
||||
weight="semibold"
|
||||
color="text-white"
|
||||
block
|
||||
truncate
|
||||
groupHoverTextColor="text-primary-blue"
|
||||
transition
|
||||
>
|
||||
{name}
|
||||
</Text>
|
||||
<Stack direction="row" align="center" gap={2} mt={0.5}>
|
||||
<Text size="xs" color="text-gray-500">{nationality}</Text>
|
||||
<Box w="1" h="1" rounded="full" bg="bg-gray-700" />
|
||||
<Text size="xs" color="text-gray-500">{skillLevel}</Text>
|
||||
</Stack>
|
||||
</Box>
|
||||
</Box>
|
||||
</TableCell>
|
||||
|
||||
<TableCell textAlign="center">
|
||||
<Text color="text-gray-400" font="mono">{racesCompleted}</Text>
|
||||
</TableCell>
|
||||
|
||||
<TableCell textAlign="center">
|
||||
<Text font="mono" weight="bold" color="text-primary-blue">
|
||||
{RatingDisplay.format(rating)}
|
||||
</Text>
|
||||
</TableCell>
|
||||
|
||||
<TableCell textAlign="center">
|
||||
<Text font="mono" weight="bold" color="text-performance-green">
|
||||
{wins}
|
||||
</Text>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
);
|
||||
}
|
||||
42
apps/website/components/leaderboards/SeasonSelector.tsx
Normal file
42
apps/website/components/leaderboards/SeasonSelector.tsx
Normal file
@@ -0,0 +1,42 @@
|
||||
import React from 'react';
|
||||
import { Calendar } from 'lucide-react';
|
||||
import { Box } from '@/ui/Box';
|
||||
import { Text } from '@/ui/Text';
|
||||
import { Icon } from '@/ui/Icon';
|
||||
import { Select } from '@/ui/Select';
|
||||
|
||||
interface Season {
|
||||
id: string;
|
||||
name: string;
|
||||
isActive?: boolean;
|
||||
}
|
||||
|
||||
interface SeasonSelectorProps {
|
||||
seasons: Season[];
|
||||
selectedSeasonId: string;
|
||||
onSeasonChange: (id: string) => void;
|
||||
}
|
||||
|
||||
export function SeasonSelector({ seasons, selectedSeasonId, onSeasonChange }: SeasonSelectorProps) {
|
||||
const options = seasons.map(season => ({
|
||||
value: season.id,
|
||||
label: `${season.name}${season.isActive ? ' (Active)' : ''}`
|
||||
}));
|
||||
|
||||
return (
|
||||
<Box display="flex" alignItems="center" gap={3}>
|
||||
<Box display="flex" alignItems="center" gap={2} color="text-gray-500">
|
||||
<Icon icon={Calendar} size={4} />
|
||||
<Text size="xs" weight="bold" uppercase letterSpacing="wider">Season</Text>
|
||||
</Box>
|
||||
<Box width="48">
|
||||
<Select
|
||||
options={options}
|
||||
value={selectedSeasonId}
|
||||
onChange={(e) => onSeasonChange(e.target.value)}
|
||||
fullWidth={true}
|
||||
/>
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
import React from 'react';
|
||||
import { Users, Crown, ChevronRight } from 'lucide-react';
|
||||
import { Users, ChevronRight } from 'lucide-react';
|
||||
import { Button } from '@/ui/Button';
|
||||
import { Box } from '@/ui/Box';
|
||||
import { Text } from '@/ui/Text';
|
||||
@@ -8,8 +8,8 @@ import { Icon } from '@/ui/Icon';
|
||||
import { Stack } from '@/ui/Stack';
|
||||
import { Image } from '@/ui/Image';
|
||||
import { getMediaUrl } from '@/lib/utilities/media';
|
||||
import { SkillLevelDisplay } from '@/lib/display-objects/SkillLevelDisplay';
|
||||
import { MedalDisplay } from '@/lib/display-objects/MedalDisplay';
|
||||
import { RankMedal } from './RankMedal';
|
||||
import { LeaderboardTableShell } from './LeaderboardTableShell';
|
||||
|
||||
interface TeamLeaderboardPreviewProps {
|
||||
teams: {
|
||||
@@ -27,38 +27,57 @@ interface TeamLeaderboardPreviewProps {
|
||||
}
|
||||
|
||||
export function TeamLeaderboardPreview({ teams, onTeamClick, onNavigateToTeams }: TeamLeaderboardPreviewProps) {
|
||||
const top5 = teams; // Already sliced in builder when implemented
|
||||
const top5 = teams;
|
||||
|
||||
return (
|
||||
<Box rounded="xl" bg="bg-iron-gray/30" border borderColor="border-charcoal-outline" overflow="hidden">
|
||||
<Box display="flex" alignItems="center" justifyContent="between" px={5} py={4} borderBottom borderColor="border-charcoal-outline" bg="bg-iron-gray/20">
|
||||
<LeaderboardTableShell>
|
||||
<Box
|
||||
display="flex"
|
||||
alignItems="center"
|
||||
justifyContent="between"
|
||||
px={5}
|
||||
py={4}
|
||||
borderBottom
|
||||
borderColor="border-charcoal-outline/50"
|
||||
bg="bg-deep-charcoal/40"
|
||||
>
|
||||
<Box display="flex" alignItems="center" gap={3}>
|
||||
<Box display="flex" h="10" w="10" alignItems="center" justifyContent="center" rounded="xl" bg="bg-gradient-to-br from-purple-500/20 to-purple-500/5" border borderColor="border-purple-500/20">
|
||||
<Box
|
||||
display="flex"
|
||||
h="10"
|
||||
w="10"
|
||||
alignItems="center"
|
||||
justifyContent="center"
|
||||
rounded="lg"
|
||||
bg="bg-gradient-to-br from-purple-500/15 to-purple-500/5"
|
||||
border
|
||||
borderColor="border-purple-500/20"
|
||||
>
|
||||
<Icon icon={Users} size={5} color="text-purple-400" />
|
||||
</Box>
|
||||
<Box>
|
||||
<Heading level={3} fontSize="lg" weight="semibold" color="text-white">Team Rankings</Heading>
|
||||
<Text size="xs" color="text-gray-500" block>Top performing racing teams</Text>
|
||||
<Heading level={3} fontSize="lg" weight="bold" color="text-white" letterSpacing="tight">Team Rankings</Heading>
|
||||
<Text size="xs" color="text-gray-500" block uppercase letterSpacing="wider" weight="bold">Top Performing Teams</Text>
|
||||
</Box>
|
||||
</Box>
|
||||
<Button
|
||||
variant="secondary"
|
||||
onClick={onNavigateToTeams}
|
||||
size="sm"
|
||||
hoverBg="bg-purple-500/10"
|
||||
transition
|
||||
>
|
||||
<Stack direction="row" align="center" gap={2}>
|
||||
<Text size="sm">View All</Text>
|
||||
<Text size="sm" weight="medium">View All</Text>
|
||||
<Icon icon={ChevronRight} size={4} />
|
||||
</Stack>
|
||||
</Button>
|
||||
</Box>
|
||||
|
||||
<Stack gap={0}
|
||||
// eslint-disable-next-line gridpilot-rules/component-classification
|
||||
className="divide-y divide-charcoal-outline/50"
|
||||
>
|
||||
{top5.map((team) => {
|
||||
<Stack gap={0}>
|
||||
{top5.map((team, index) => {
|
||||
const position = team.position;
|
||||
const isLast = index === top5.length - 1;
|
||||
|
||||
return (
|
||||
<Box
|
||||
@@ -74,24 +93,29 @@ export function TeamLeaderboardPreview({ teams, onTeamClick, onNavigateToTeams }
|
||||
w="full"
|
||||
textAlign="left"
|
||||
transition
|
||||
hoverBg="bg-iron-gray/30"
|
||||
hoverBg="bg-white/[0.02]"
|
||||
group
|
||||
borderBottom={!isLast}
|
||||
borderColor="border-charcoal-outline/30"
|
||||
>
|
||||
<Box
|
||||
display="flex"
|
||||
h="8"
|
||||
w="8"
|
||||
alignItems="center"
|
||||
justifyContent="center"
|
||||
rounded="full"
|
||||
border
|
||||
// eslint-disable-next-line gridpilot-rules/component-classification
|
||||
className={`text-xs font-bold ${MedalDisplay.getBg(position)} ${MedalDisplay.getColor(position)}`}
|
||||
>
|
||||
{position <= 3 ? <Icon icon={Crown} size={3.5} /> : <Text weight="bold">{position}</Text>}
|
||||
<Box w="8" display="flex" justifyContent="center">
|
||||
<RankMedal rank={position} size="sm" />
|
||||
</Box>
|
||||
|
||||
<Box display="flex" h="9" w="9" alignItems="center" justifyContent="center" rounded="lg" bg="bg-charcoal-outline" border borderColor="border-charcoal-outline" overflow="hidden">
|
||||
<Box
|
||||
display="flex"
|
||||
h="9"
|
||||
w="9"
|
||||
alignItems="center"
|
||||
justifyContent="center"
|
||||
rounded="lg"
|
||||
bg="bg-graphite-black/50"
|
||||
border
|
||||
borderColor="border-charcoal-outline"
|
||||
overflow="hidden"
|
||||
groupHoverBorderColor="purple-400/50"
|
||||
transition
|
||||
>
|
||||
<Image
|
||||
src={team.logoUrl || getMediaUrl('team-logo', team.id)}
|
||||
alt={team.name}
|
||||
@@ -104,57 +128,45 @@ export function TeamLeaderboardPreview({ teams, onTeamClick, onNavigateToTeams }
|
||||
</Box>
|
||||
|
||||
<Box flexGrow={1} minWidth="0">
|
||||
<Text weight="medium" color="text-white" truncate groupHoverTextColor="text-purple-400" transition block>
|
||||
<Text
|
||||
weight="semibold"
|
||||
color="text-white"
|
||||
truncate
|
||||
groupHoverTextColor="text-purple-400"
|
||||
transition
|
||||
block
|
||||
>
|
||||
{team.name}
|
||||
</Text>
|
||||
<Box display="flex" alignItems="center" gap={2} flexWrap="wrap">
|
||||
{team.category && (
|
||||
<Box display="flex" alignItems="center" gap={1} color="text-purple-400">
|
||||
<Box w="1.5" h="1.5" rounded="full" bg="bg-purple-400" />
|
||||
<Text size="xs">{team.category}</Text>
|
||||
<Text size="xs" weight="medium">{team.category}</Text>
|
||||
</Box>
|
||||
)}
|
||||
<Box w="1" h="1" rounded="full" bg="bg-gray-700" />
|
||||
<Box display="flex" alignItems="center" gap={1}>
|
||||
<Icon icon={Users} size={3} color="text-gray-500" />
|
||||
<Text size="xs" color="text-gray-500">{team.memberCount} members</Text>
|
||||
</Box>
|
||||
<Box as="span"
|
||||
// eslint-disable-next-line gridpilot-rules/component-classification
|
||||
className={SkillLevelDisplay.getColor(team.category || '')}
|
||||
>
|
||||
<Text size="xs">{SkillLevelDisplay.getLabel(team.category || '')}</Text>
|
||||
</Box>
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
<Box display="flex" alignItems="center" gap={4}>
|
||||
<Box textAlign="center">
|
||||
<Text color="text-purple-400" font="mono" weight="semibold" block>{team.memberCount}</Text>
|
||||
<Text
|
||||
// eslint-disable-next-line gridpilot-rules/component-classification
|
||||
style={{ fontSize: '10px' }}
|
||||
color="text-gray-500"
|
||||
block
|
||||
>
|
||||
Members
|
||||
</Text>
|
||||
<Box display="flex" alignItems="center" gap={6}>
|
||||
<Box textAlign="right">
|
||||
<Text color="text-purple-400" font="mono" weight="bold" block size="sm">{team.memberCount}</Text>
|
||||
<Text fontSize="10px" color="text-gray-500" block uppercase letterSpacing="wider" weight="bold">Members</Text>
|
||||
</Box>
|
||||
<Box textAlign="center">
|
||||
<Text color="text-performance-green" font="mono" weight="semibold" block>{team.totalWins}</Text>
|
||||
<Text
|
||||
// eslint-disable-next-line gridpilot-rules/component-classification
|
||||
style={{ fontSize: '10px' }}
|
||||
color="text-gray-500"
|
||||
block
|
||||
>
|
||||
Wins
|
||||
</Text>
|
||||
<Box textAlign="right" minWidth="12">
|
||||
<Text color="text-performance-green" font="mono" weight="bold" block size="sm">{team.totalWins}</Text>
|
||||
<Text fontSize="10px" color="text-gray-500" block uppercase letterSpacing="wider" weight="bold">Wins</Text>
|
||||
</Box>
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
})}
|
||||
</Stack>
|
||||
</Box>
|
||||
</LeaderboardTableShell>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
103
apps/website/components/leaderboards/TeamRankingRow.tsx
Normal file
103
apps/website/components/leaderboards/TeamRankingRow.tsx
Normal file
@@ -0,0 +1,103 @@
|
||||
import React from 'react';
|
||||
import { Box } from '@/ui/Box';
|
||||
import { Image } from '@/ui/Image';
|
||||
import { TableCell, TableRow } from '@/ui/Table';
|
||||
import { Text } from '@/ui/Text';
|
||||
import { RankMedal } from './RankMedal';
|
||||
import { getMediaUrl } from '@/lib/utilities/media';
|
||||
|
||||
interface TeamRankingRowProps {
|
||||
id: string;
|
||||
rank: number;
|
||||
name: string;
|
||||
logoUrl?: string;
|
||||
rating: number;
|
||||
wins: number;
|
||||
races: number;
|
||||
memberCount: number;
|
||||
onClick?: () => void;
|
||||
}
|
||||
|
||||
export function TeamRankingRow({
|
||||
id,
|
||||
rank,
|
||||
name,
|
||||
logoUrl,
|
||||
rating,
|
||||
wins,
|
||||
races,
|
||||
memberCount,
|
||||
onClick,
|
||||
}: TeamRankingRowProps) {
|
||||
return (
|
||||
<TableRow
|
||||
clickable={!!onClick}
|
||||
onClick={onClick}
|
||||
group
|
||||
>
|
||||
<TableCell>
|
||||
<Box w="8" display="flex" justifyContent="center">
|
||||
<RankMedal rank={rank} size="md" />
|
||||
</Box>
|
||||
</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
|
||||
>
|
||||
<Image
|
||||
src={logoUrl || getMediaUrl('team-logo', id)}
|
||||
alt={name}
|
||||
width={40}
|
||||
height={40}
|
||||
fullWidth
|
||||
fullHeight
|
||||
objectFit="cover"
|
||||
/>
|
||||
</Box>
|
||||
<Box minWidth="0">
|
||||
<Text
|
||||
weight="semibold"
|
||||
color="text-white"
|
||||
block
|
||||
truncate
|
||||
groupHoverTextColor="text-purple-400"
|
||||
transition
|
||||
>
|
||||
{name}
|
||||
</Text>
|
||||
<Text size="xs" color="text-gray-500" block mt={0.5}>
|
||||
{memberCount} Members
|
||||
</Text>
|
||||
</Box>
|
||||
</Box>
|
||||
</TableCell>
|
||||
|
||||
<TableCell textAlign="center">
|
||||
<Text font="mono" weight="bold" color="text-purple-400">
|
||||
{rating}
|
||||
</Text>
|
||||
</TableCell>
|
||||
|
||||
<TableCell textAlign="center">
|
||||
<Text font="mono" weight="bold" color="text-performance-green">
|
||||
{wins}
|
||||
</Text>
|
||||
</TableCell>
|
||||
|
||||
<TableCell textAlign="center">
|
||||
<Text color="text-gray-400" font="mono">{races}</Text>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
);
|
||||
}
|
||||
183
apps/website/components/leagues/LeagueCard.tsx
Normal file
183
apps/website/components/leagues/LeagueCard.tsx
Normal file
@@ -0,0 +1,183 @@
|
||||
'use client';
|
||||
|
||||
import React from 'react';
|
||||
import { Box } from '@/ui/Box';
|
||||
import { Text } from '@/ui/Text';
|
||||
import { Heading } from '@/ui/Heading';
|
||||
import { Image } from '@/ui/Image';
|
||||
import { Trophy, Users, Calendar, ChevronRight } from 'lucide-react';
|
||||
|
||||
interface LeagueCardProps {
|
||||
id: string;
|
||||
name: string;
|
||||
description?: string;
|
||||
coverUrl: string;
|
||||
logoUrl?: string;
|
||||
gameName?: string;
|
||||
memberCount: number;
|
||||
maxMembers?: number;
|
||||
nextRaceDate?: string;
|
||||
championshipType: 'driver' | 'team' | 'nations' | 'trophy';
|
||||
onClick?: () => void;
|
||||
}
|
||||
|
||||
export function LeagueCard({
|
||||
name,
|
||||
description,
|
||||
coverUrl,
|
||||
logoUrl,
|
||||
gameName,
|
||||
memberCount,
|
||||
maxMembers,
|
||||
nextRaceDate,
|
||||
championshipType,
|
||||
onClick,
|
||||
}: LeagueCardProps) {
|
||||
const fillPercentage = maxMembers ? (memberCount / maxMembers) * 100 : 0;
|
||||
|
||||
return (
|
||||
<Box
|
||||
as="article"
|
||||
onClick={onClick}
|
||||
position="relative"
|
||||
display="flex"
|
||||
flexDirection="col"
|
||||
overflow="hidden"
|
||||
border
|
||||
borderColor="zinc-800"
|
||||
bg="zinc-900/50"
|
||||
hoverBorderColor="blue-500/30"
|
||||
hoverBg="zinc-900"
|
||||
transition
|
||||
cursor="pointer"
|
||||
group
|
||||
>
|
||||
{/* Cover Image */}
|
||||
<Box position="relative" h="32" overflow="hidden">
|
||||
<Box fullWidth fullHeight opacity={0.6}>
|
||||
<Image
|
||||
src={coverUrl}
|
||||
alt={`${name} cover`}
|
||||
fullWidth
|
||||
fullHeight
|
||||
objectFit="cover"
|
||||
// eslint-disable-next-line gridpilot-rules/component-classification
|
||||
className="transition-transform duration-500 group-hover:scale-105"
|
||||
/>
|
||||
</Box>
|
||||
<Box position="absolute" inset="0" bg="linear-gradient(to top, #09090b, transparent)" />
|
||||
|
||||
{/* Game Badge */}
|
||||
{gameName && (
|
||||
<Box
|
||||
position="absolute"
|
||||
top="3"
|
||||
left="3"
|
||||
px={2}
|
||||
py={1}
|
||||
bg="zinc-900/80"
|
||||
border
|
||||
borderColor="white/10"
|
||||
blur="sm"
|
||||
>
|
||||
<Text weight="bold" color="text-zinc-300" uppercase letterSpacing="0.05em" fontSize="10px">
|
||||
{gameName}
|
||||
</Text>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{/* Championship Icon */}
|
||||
<Box
|
||||
position="absolute"
|
||||
top="3"
|
||||
right="3"
|
||||
p={1.5}
|
||||
bg="zinc-900/80"
|
||||
color="text-zinc-400"
|
||||
border
|
||||
borderColor="white/10"
|
||||
blur="sm"
|
||||
>
|
||||
{championshipType === 'driver' && <Trophy size={14} />}
|
||||
{championshipType === 'team' && <Users size={14} />}
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
{/* Content */}
|
||||
<Box position="relative" display="flex" flexDirection="col" flexGrow={1} p={4} pt={6}>
|
||||
{/* Logo */}
|
||||
<Box
|
||||
position="absolute"
|
||||
top="-6"
|
||||
left="4"
|
||||
w="12"
|
||||
h="12"
|
||||
border
|
||||
borderColor="zinc-800"
|
||||
bg="zinc-950"
|
||||
shadow="xl"
|
||||
overflow="hidden"
|
||||
>
|
||||
{logoUrl ? (
|
||||
<Image src={logoUrl} alt={`${name} logo`} fullWidth fullHeight objectFit="cover" />
|
||||
) : (
|
||||
<Box fullWidth fullHeight display="flex" alignItems="center" justifyContent="center" bg="zinc-900" color="text-zinc-700">
|
||||
<Trophy size={20} />
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
|
||||
<Box display="flex" flexDirection="col" gap={1} mb={4}>
|
||||
<Heading level={3} fontSize="lg" weight="bold" color="text-white"
|
||||
// eslint-disable-next-line gridpilot-rules/component-classification
|
||||
className="group-hover:text-blue-400 transition-colors truncate"
|
||||
>
|
||||
{name}
|
||||
</Heading>
|
||||
<Text size="xs" color="text-zinc-500" lineClamp={2} leading="relaxed" h="8">
|
||||
{description || 'No description available'}
|
||||
</Text>
|
||||
</Box>
|
||||
|
||||
{/* Stats */}
|
||||
<Box display="flex" flexDirection="col" gap={3} mt="auto">
|
||||
<Box display="flex" flexDirection="col" gap={1.5}>
|
||||
<Box display="flex" justifyContent="between">
|
||||
<Text weight="bold" color="text-zinc-500" uppercase letterSpacing="widest" fontSize="10px">Drivers</Text>
|
||||
<Text color="text-zinc-400" font="mono" fontSize="10px">{memberCount}/{maxMembers || '∞'}</Text>
|
||||
</Box>
|
||||
<Box h="1" bg="zinc-800" overflow="hidden">
|
||||
<Box
|
||||
h="full"
|
||||
transition
|
||||
bg={fillPercentage > 90 ? 'bg-amber-500' : 'bg-blue-500'}
|
||||
w={`${Math.min(fillPercentage, 100)}%`}
|
||||
/>
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
<Box display="flex" alignItems="center" justifyContent="between" pt={3} borderTop borderColor="zinc-800/50">
|
||||
<Box display="flex" alignItems="center" gap={2} color="text-zinc-500">
|
||||
<Calendar size={12} />
|
||||
<Text weight="bold" uppercase font="mono" fontSize="10px">
|
||||
{nextRaceDate || 'TBD'}
|
||||
</Text>
|
||||
</Box>
|
||||
<Box display="flex" alignItems="center" gap={1} color="text-zinc-500"
|
||||
// eslint-disable-next-line gridpilot-rules/component-classification
|
||||
className="group-hover:text-blue-400 transition-colors"
|
||||
>
|
||||
<Text weight="bold" uppercase letterSpacing="widest" fontSize="10px">View</Text>
|
||||
<Box
|
||||
// eslint-disable-next-line gridpilot-rules/component-classification
|
||||
className="transition-transform group-hover:translate-x-0.5"
|
||||
>
|
||||
<ChevronRight size={12} />
|
||||
</Box>
|
||||
</Box>
|
||||
</Box>
|
||||
</Box>
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
@@ -1,11 +1,13 @@
|
||||
import React from 'react';
|
||||
import { MembershipStatus } from './MembershipStatus';
|
||||
import { getMediaUrl } from '@/lib/utilities/media';
|
||||
import { Text } from '@/ui/Text';
|
||||
import { Box } from '@/ui/Box';
|
||||
import { LeagueHeader as UiLeagueHeader } from '@/ui/LeagueHeader';
|
||||
'use client';
|
||||
|
||||
import React from 'react';
|
||||
import { Box } from '@/ui/Box';
|
||||
import { Stack } from '@/ui/Stack';
|
||||
import { Heading } from '@/ui/Heading';
|
||||
import { Text } from '@/ui/Text';
|
||||
import { Image } from '@/ui/Image';
|
||||
import { MembershipStatus } from './MembershipStatus';
|
||||
|
||||
// Main sponsor info for "by XYZ" display
|
||||
interface MainSponsorInfo {
|
||||
name: string;
|
||||
logoUrl?: string;
|
||||
@@ -27,33 +29,61 @@ export function LeagueHeader({
|
||||
description,
|
||||
mainSponsor,
|
||||
}: LeagueHeaderProps) {
|
||||
const logoUrl = getMediaUrl('league-logo', leagueId);
|
||||
|
||||
return (
|
||||
<UiLeagueHeader
|
||||
name={leagueName}
|
||||
description={description}
|
||||
logoUrl={logoUrl}
|
||||
statusContent={<MembershipStatus leagueId={leagueId} />}
|
||||
sponsorContent={
|
||||
mainSponsor ? (
|
||||
mainSponsor.websiteUrl ? (
|
||||
<Box
|
||||
as="a"
|
||||
href={mainSponsor.websiteUrl}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
color="text-primary-blue"
|
||||
hoverTextColor="text-primary-blue/80"
|
||||
transition
|
||||
>
|
||||
{mainSponsor.name}
|
||||
</Box>
|
||||
) : (
|
||||
<Text color="text-primary-blue">{mainSponsor.name}</Text>
|
||||
)
|
||||
) : undefined
|
||||
}
|
||||
/>
|
||||
<Box as="header" mb={8}>
|
||||
<Stack direction="row" align="center" gap={6}>
|
||||
<Box
|
||||
position="relative"
|
||||
w="20"
|
||||
h="20"
|
||||
overflow="hidden"
|
||||
border
|
||||
borderColor="white/10"
|
||||
bg="zinc-900"
|
||||
shadow="2xl"
|
||||
>
|
||||
<Image
|
||||
src={`/api/media/league-logo/${leagueId}`}
|
||||
alt={`${leagueName} logo`}
|
||||
fullWidth
|
||||
fullHeight
|
||||
objectFit="cover"
|
||||
/>
|
||||
</Box>
|
||||
<Stack gap={1}>
|
||||
<Stack direction="row" align="center" gap={4}>
|
||||
<Heading level={1} fontSize="3xl" weight="bold" color="text-white">
|
||||
{leagueName}
|
||||
{mainSponsor && (
|
||||
<Text ml={3} size="lg" weight="normal" color="text-zinc-500">
|
||||
by{' '}
|
||||
{mainSponsor.websiteUrl ? (
|
||||
<Box
|
||||
as="a"
|
||||
href={mainSponsor.websiteUrl}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
color="text-blue-500"
|
||||
hoverTextColor="text-blue-400"
|
||||
transition
|
||||
>
|
||||
{mainSponsor.name}
|
||||
</Box>
|
||||
) : (
|
||||
<Text color="text-blue-500">{mainSponsor.name}</Text>
|
||||
)}
|
||||
</Text>
|
||||
)}
|
||||
</Heading>
|
||||
<MembershipStatus leagueId={leagueId} />
|
||||
</Stack>
|
||||
{description && (
|
||||
<Text color="text-zinc-400" size="sm" maxWidth="2xl" block leading="relaxed">
|
||||
{description}
|
||||
</Text>
|
||||
)}
|
||||
</Stack>
|
||||
</Stack>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
85
apps/website/components/leagues/LeagueHeaderPanel.tsx
Normal file
85
apps/website/components/leagues/LeagueHeaderPanel.tsx
Normal file
@@ -0,0 +1,85 @@
|
||||
'use client';
|
||||
|
||||
import React from 'react';
|
||||
import { Box } from '@/ui/Box';
|
||||
import { Stack } from '@/ui/Stack';
|
||||
import { Text } from '@/ui/Text';
|
||||
import { Heading } from '@/ui/Heading';
|
||||
import { Surface } from '@/ui/Surface';
|
||||
import { Icon } from '@/ui/Icon';
|
||||
import { Trophy, Users, Timer, Activity, type LucideIcon } from 'lucide-react';
|
||||
import type { LeagueDetailViewData } from '@/lib/view-data/LeagueDetailViewData';
|
||||
|
||||
interface LeagueHeaderPanelProps {
|
||||
viewData: LeagueDetailViewData;
|
||||
}
|
||||
|
||||
export function LeagueHeaderPanel({ viewData }: LeagueHeaderPanelProps) {
|
||||
return (
|
||||
<Surface variant="dark" border rounded="lg" padding={6} position="relative" overflow="hidden">
|
||||
{/* Background Accent */}
|
||||
<Box
|
||||
position="absolute"
|
||||
top={0}
|
||||
right={0}
|
||||
w="300px"
|
||||
h="100%"
|
||||
bg="bg-gradient-to-l from-primary-blue/5 to-transparent"
|
||||
pointerEvents="none"
|
||||
/>
|
||||
|
||||
<Stack direction={{ base: 'col', md: 'row' }} justify="between" align="center" gap={6}>
|
||||
<Stack gap={2}>
|
||||
<Stack direction="row" align="center" gap={3}>
|
||||
<Box p={2} bg="bg-primary-blue/10" rounded="md" border borderColor="border-primary-blue/20">
|
||||
<Icon icon={Trophy} size={6} color="text-primary-blue" />
|
||||
</Box>
|
||||
<Heading level={1} letterSpacing="tight">
|
||||
{viewData.name}
|
||||
</Heading>
|
||||
</Stack>
|
||||
<Text color="text-gray-400" size="sm" maxWidth="42rem">
|
||||
{viewData.description}
|
||||
</Text>
|
||||
</Stack>
|
||||
|
||||
<Stack direction="row" gap={8} wrap>
|
||||
<StatItem
|
||||
icon={Users}
|
||||
label="Members"
|
||||
value={viewData.info.membersCount.toString()}
|
||||
color="text-primary-blue"
|
||||
/>
|
||||
<StatItem
|
||||
icon={Timer}
|
||||
label="Races"
|
||||
value={viewData.info.racesCount.toString()}
|
||||
color="text-neon-aqua"
|
||||
/>
|
||||
<StatItem
|
||||
icon={Activity}
|
||||
label="Avg SOF"
|
||||
value={(viewData.info.avgSOF ?? 0).toString()}
|
||||
color="text-performance-green"
|
||||
/>
|
||||
</Stack>
|
||||
</Stack>
|
||||
</Surface>
|
||||
);
|
||||
}
|
||||
|
||||
function StatItem({ icon, label, value, color }: { icon: LucideIcon, label: string, value: string, color: string }) {
|
||||
return (
|
||||
<Stack gap={1}>
|
||||
<Stack direction="row" align="center" gap={1.5}>
|
||||
<Icon icon={icon} size={3.5} color="text-gray-500" />
|
||||
<Text size="xs" color="text-gray-500" weight="medium" letterSpacing="wider" display="block">
|
||||
{label.toUpperCase()}
|
||||
</Text>
|
||||
</Stack>
|
||||
<Text size="xl" weight="bold" color={color} lineHeight="none">
|
||||
{value}
|
||||
</Text>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
59
apps/website/components/leagues/LeagueNavTabs.tsx
Normal file
59
apps/website/components/leagues/LeagueNavTabs.tsx
Normal file
@@ -0,0 +1,59 @@
|
||||
'use client';
|
||||
|
||||
import React from 'react';
|
||||
import { Box } from '@/ui/Box';
|
||||
import { Stack } from '@/ui/Stack';
|
||||
import { Link } from '@/ui/Link';
|
||||
|
||||
interface Tab {
|
||||
label: string;
|
||||
href: string;
|
||||
exact?: boolean;
|
||||
}
|
||||
|
||||
interface LeagueNavTabsProps {
|
||||
tabs: Tab[];
|
||||
currentPathname: string;
|
||||
}
|
||||
|
||||
export function LeagueNavTabs({ tabs, currentPathname }: LeagueNavTabsProps) {
|
||||
return (
|
||||
<Box as="nav" borderBottom borderColor="zinc-800" mb={6}>
|
||||
<Stack as="ul" direction="row" gap={8} overflow="auto" hideScrollbar>
|
||||
{tabs.map((tab) => {
|
||||
const isActive = tab.exact
|
||||
? currentPathname === tab.href
|
||||
: currentPathname.startsWith(tab.href);
|
||||
|
||||
return (
|
||||
<Box as="li" key={tab.href} position="relative">
|
||||
<Link
|
||||
href={tab.href}
|
||||
variant="ghost"
|
||||
pb={4}
|
||||
display="block"
|
||||
size="sm"
|
||||
weight="medium"
|
||||
color={isActive ? 'text-blue-500' : 'text-zinc-400'}
|
||||
hoverTextColor={isActive ? 'text-blue-500' : 'text-zinc-200'}
|
||||
transition
|
||||
>
|
||||
{tab.label}
|
||||
</Link>
|
||||
{isActive && (
|
||||
<Box
|
||||
position="absolute"
|
||||
bottom="0"
|
||||
left="0"
|
||||
right="0"
|
||||
h="0.5"
|
||||
bg="bg-blue-500"
|
||||
/>
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
})}
|
||||
</Stack>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
53
apps/website/components/leagues/LeagueRulesPanel.tsx
Normal file
53
apps/website/components/leagues/LeagueRulesPanel.tsx
Normal file
@@ -0,0 +1,53 @@
|
||||
'use client';
|
||||
|
||||
import React from 'react';
|
||||
import { Box } from '@/ui/Box';
|
||||
import { Stack } from '@/ui/Stack';
|
||||
import { Text } from '@/ui/Text';
|
||||
import { Heading } from '@/ui/Heading';
|
||||
import { Shield, Info } from 'lucide-react';
|
||||
|
||||
interface Rule {
|
||||
id: string;
|
||||
title: string;
|
||||
content: string;
|
||||
}
|
||||
|
||||
interface LeagueRulesPanelProps {
|
||||
rules: Rule[];
|
||||
}
|
||||
|
||||
export function LeagueRulesPanel({ rules }: LeagueRulesPanelProps) {
|
||||
return (
|
||||
<Box as="section">
|
||||
<Stack gap={8}>
|
||||
<Box display="flex" alignItems="start" gap={4} p={4} bg="blue-500/5" border borderColor="blue-500/20">
|
||||
<Box color="text-blue-500" mt={0.5}><Info size={20} /></Box>
|
||||
<Stack gap={1}>
|
||||
<Text size="sm" weight="bold" color="text-blue-500" uppercase letterSpacing="0.05em">Code of Conduct</Text>
|
||||
<Text size="sm" color="text-zinc-400" leading="relaxed">
|
||||
All drivers are expected to maintain a high standard of sportsmanship.
|
||||
Intentional wrecking or abusive behavior will result in immediate disqualification.
|
||||
</Text>
|
||||
</Stack>
|
||||
</Box>
|
||||
|
||||
<Box display="grid" responsiveGridCols={{ base: 1, md: 2 }} gap={6}>
|
||||
{rules.map((rule) => (
|
||||
<Box as="article" key={rule.id} display="flex" flexDirection="col" gap={3} p={6} border borderColor="zinc-800" bg="zinc-900/30">
|
||||
<Box display="flex" alignItems="center" gap={3}>
|
||||
<Box p={2} bg="zinc-800" color="text-zinc-400">
|
||||
<Shield size={18} />
|
||||
</Box>
|
||||
<Heading level={3} fontSize="md" weight="bold" color="text-white">{rule.title}</Heading>
|
||||
</Box>
|
||||
<Text size="sm" color="text-zinc-400" leading="relaxed">
|
||||
{rule.content}
|
||||
</Text>
|
||||
</Box>
|
||||
))}
|
||||
</Box>
|
||||
</Stack>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
93
apps/website/components/leagues/LeagueSchedulePanel.tsx
Normal file
93
apps/website/components/leagues/LeagueSchedulePanel.tsx
Normal file
@@ -0,0 +1,93 @@
|
||||
'use client';
|
||||
|
||||
import React from 'react';
|
||||
import { Box } from '@/ui/Box';
|
||||
import { Stack } from '@/ui/Stack';
|
||||
import { Text } from '@/ui/Text';
|
||||
import { Heading } from '@/ui/Heading';
|
||||
import { MapPin, Clock } from 'lucide-react';
|
||||
|
||||
interface RaceEvent {
|
||||
id: string;
|
||||
title: string;
|
||||
trackName: string;
|
||||
date: string;
|
||||
time: string;
|
||||
status: 'upcoming' | 'live' | 'completed';
|
||||
}
|
||||
|
||||
interface LeagueSchedulePanelProps {
|
||||
events: RaceEvent[];
|
||||
}
|
||||
|
||||
export function LeagueSchedulePanel({ events }: LeagueSchedulePanelProps) {
|
||||
return (
|
||||
<Box as="section">
|
||||
<Stack gap={4}>
|
||||
{events.map((event) => (
|
||||
<Box
|
||||
as="article"
|
||||
key={event.id}
|
||||
display="flex"
|
||||
alignItems="center"
|
||||
gap={6}
|
||||
p={4}
|
||||
border
|
||||
borderColor="zinc-800"
|
||||
bg="zinc-900/50"
|
||||
hoverBorderColor="zinc-700"
|
||||
transition
|
||||
>
|
||||
<Box display="flex" flexDirection="col" alignItems="center" justifyContent="center" w="16" h="16" borderRight borderColor="zinc-800" pr={6}>
|
||||
<Text size="xs" weight="bold" color="text-zinc-500" uppercase>
|
||||
{new Date(event.date).toLocaleDateString('en-US', { month: 'short' })}
|
||||
</Text>
|
||||
<Text size="2xl" weight="bold" color="text-white">
|
||||
{new Date(event.date).toLocaleDateString('en-US', { day: 'numeric' })}
|
||||
</Text>
|
||||
</Box>
|
||||
|
||||
<Box flexGrow={1}>
|
||||
<Heading level={3} fontSize="lg" weight="bold" color="text-white">{event.title}</Heading>
|
||||
<Stack direction="row" gap={4} mt={1}>
|
||||
<Box display="flex" alignItems="center" gap={1.5}>
|
||||
<Box color="text-zinc-600"><MapPin size={14} /></Box>
|
||||
<Text size="sm" color="text-zinc-400">{event.trackName}</Text>
|
||||
</Box>
|
||||
<Box display="flex" alignItems="center" gap={1.5}>
|
||||
<Box color="text-zinc-600"><Clock size={14} /></Box>
|
||||
<Text size="sm" color="text-zinc-400">{event.time}</Text>
|
||||
</Box>
|
||||
</Stack>
|
||||
</Box>
|
||||
|
||||
<Box display="flex" alignItems="center" gap={3}>
|
||||
{event.status === 'live' && (
|
||||
<Box display="flex" alignItems="center" gap={1.5} px={2} py={1} bg="red-500/10" border borderColor="red-500/20">
|
||||
<Box w="1.5" h="1.5" rounded="full" bg="bg-red-500" animate="pulse" />
|
||||
<Text size="xs" weight="bold" color="text-red-500" uppercase letterSpacing="0.05em">
|
||||
Live
|
||||
</Text>
|
||||
</Box>
|
||||
)}
|
||||
{event.status === 'upcoming' && (
|
||||
<Box px={2} py={1} bg="blue-500/10" border borderColor="blue-500/20">
|
||||
<Text size="xs" weight="bold" color="text-blue-500" uppercase letterSpacing="0.05em">
|
||||
Upcoming
|
||||
</Text>
|
||||
</Box>
|
||||
)}
|
||||
{event.status === 'completed' && (
|
||||
<Box px={2} py={1} bg="zinc-800" border borderColor="zinc-700">
|
||||
<Text size="xs" weight="bold" color="text-zinc-500" uppercase letterSpacing="0.05em">
|
||||
Results
|
||||
</Text>
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
</Box>
|
||||
))}
|
||||
</Stack>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
147
apps/website/components/leagues/LeagueSlider.tsx
Normal file
147
apps/website/components/leagues/LeagueSlider.tsx
Normal file
@@ -0,0 +1,147 @@
|
||||
'use client';
|
||||
|
||||
import React, { useCallback, useRef, useState } from 'react';
|
||||
import { Box } from '@/ui/Box';
|
||||
import { Stack } from '@/ui/Stack';
|
||||
import { Text } from '@/ui/Text';
|
||||
import { Heading } from '@/ui/Heading';
|
||||
import { Button } from '@/ui/Button';
|
||||
import { Icon } from '@/ui/Icon';
|
||||
import { Link } from '@/ui/Link';
|
||||
import { ChevronLeft, ChevronRight, type LucideIcon } from 'lucide-react';
|
||||
import { LeagueCard } from '@/components/leagues/LeagueCardWrapper';
|
||||
import { LeagueSummaryViewModelBuilder } from '@/lib/builders/view-models/LeagueSummaryViewModelBuilder';
|
||||
import type { LeaguesViewData } from '@/lib/view-data/LeaguesViewData';
|
||||
import { routes } from '@/lib/routing/RouteConfig';
|
||||
|
||||
interface LeagueSliderProps {
|
||||
title: string;
|
||||
icon: LucideIcon;
|
||||
description: string;
|
||||
leagues: LeaguesViewData['leagues'];
|
||||
autoScroll?: boolean;
|
||||
iconColor?: string;
|
||||
scrollSpeedMultiplier?: number;
|
||||
scrollDirection?: 'left' | 'right';
|
||||
}
|
||||
|
||||
export function LeagueSlider({
|
||||
title,
|
||||
icon: IconComp,
|
||||
description,
|
||||
leagues,
|
||||
iconColor = 'text-primary-blue',
|
||||
}: LeagueSliderProps) {
|
||||
const scrollRef = useRef<HTMLDivElement>(null);
|
||||
const [canScrollLeft, setCanScrollLeft] = useState(false);
|
||||
const [canScrollRight, setCanScrollRight] = useState(true);
|
||||
|
||||
const checkScrollButtons = useCallback(() => {
|
||||
if (scrollRef.current) {
|
||||
const { scrollLeft, scrollWidth, clientWidth } = scrollRef.current;
|
||||
setCanScrollLeft(scrollLeft > 0);
|
||||
setCanScrollRight(scrollLeft < scrollWidth - clientWidth - 10);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const scroll = useCallback((direction: 'left' | 'right') => {
|
||||
if (scrollRef.current) {
|
||||
const cardWidth = 340;
|
||||
const scrollAmount = direction === 'left' ? -cardWidth : cardWidth;
|
||||
scrollRef.current.scrollBy({ left: scrollAmount, behavior: 'smooth' });
|
||||
}
|
||||
}, []);
|
||||
|
||||
if (leagues.length === 0) return null;
|
||||
|
||||
return (
|
||||
<Box mb={10}>
|
||||
{/* Section header */}
|
||||
<Box display="flex" alignItems="center" justifyContent="between" mb={4}>
|
||||
<Stack direction="row" align="center" gap={3}>
|
||||
<Box display="flex" h={10} w={10} alignItems="center" justifyContent="center" rounded="xl" bg="bg-iron-gray" border borderColor="border-charcoal-outline">
|
||||
<Icon icon={IconComp} size={5} color={iconColor} />
|
||||
</Box>
|
||||
<Box>
|
||||
<Heading level={2}>{title}</Heading>
|
||||
<Text size="xs" color="text-gray-500">{description}</Text>
|
||||
</Box>
|
||||
<Box as="span" ml={2} px={2} py={0.5} rounded="full" fontSize="0.75rem" bg="bg-charcoal-outline/50" color="text-gray-400">
|
||||
{leagues.length}
|
||||
</Box>
|
||||
</Stack>
|
||||
|
||||
{/* Navigation arrows */}
|
||||
<Stack direction="row" align="center" gap={2}>
|
||||
<Button
|
||||
variant="secondary"
|
||||
onClick={() => scroll('left')}
|
||||
disabled={!canScrollLeft}
|
||||
size="sm"
|
||||
w="2rem"
|
||||
h="2rem"
|
||||
>
|
||||
<Icon icon={ChevronLeft} size={4} />
|
||||
</Button>
|
||||
<Button
|
||||
variant="secondary"
|
||||
onClick={() => scroll('right')}
|
||||
disabled={!canScrollRight}
|
||||
size="sm"
|
||||
w="2rem"
|
||||
h="2rem"
|
||||
>
|
||||
<Icon icon={ChevronRight} size={4} />
|
||||
</Button>
|
||||
</Stack>
|
||||
</Box>
|
||||
|
||||
{/* Scrollable container with fade edges */}
|
||||
<Box position="relative">
|
||||
<Box
|
||||
position="absolute"
|
||||
top={0}
|
||||
bottom={4}
|
||||
left={0}
|
||||
w="3rem"
|
||||
bg="bg-gradient-to-r from-deep-graphite to-transparent"
|
||||
zIndex={10}
|
||||
pointerEvents="none"
|
||||
/>
|
||||
<Box
|
||||
position="absolute"
|
||||
top={0}
|
||||
bottom={4}
|
||||
right={0}
|
||||
w="3rem"
|
||||
bg="bg-gradient-to-l from-deep-graphite to-transparent"
|
||||
zIndex={10}
|
||||
pointerEvents="none"
|
||||
/>
|
||||
|
||||
<Box
|
||||
ref={scrollRef}
|
||||
onScroll={checkScrollButtons}
|
||||
display="flex"
|
||||
gap={4}
|
||||
overflow="auto"
|
||||
pb={4}
|
||||
px={4}
|
||||
hideScrollbar
|
||||
>
|
||||
{leagues.map((league) => {
|
||||
const viewModel = LeagueSummaryViewModelBuilder.build(league);
|
||||
|
||||
return (
|
||||
<Box key={league.id} flexShrink={0} w="320px">
|
||||
<Link href={routes.league.detail(league.id)} block>
|
||||
<LeagueCard league={viewModel} />
|
||||
</Link>
|
||||
</Box>
|
||||
);
|
||||
})}
|
||||
</Box>
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
80
apps/website/components/leagues/LeagueStandingsTable.tsx
Normal file
80
apps/website/components/leagues/LeagueStandingsTable.tsx
Normal file
@@ -0,0 +1,80 @@
|
||||
'use client';
|
||||
|
||||
import React from 'react';
|
||||
import { Box } from '@/ui/Box';
|
||||
import { Text } from '@/ui/Text';
|
||||
|
||||
interface StandingEntry {
|
||||
position: number;
|
||||
driverName: string;
|
||||
teamName?: string;
|
||||
points: number;
|
||||
wins: number;
|
||||
podiums: number;
|
||||
gap: string;
|
||||
}
|
||||
|
||||
interface LeagueStandingsTableProps {
|
||||
standings: StandingEntry[];
|
||||
}
|
||||
|
||||
export function LeagueStandingsTable({ standings }: LeagueStandingsTableProps) {
|
||||
return (
|
||||
<Box as="section" overflow="hidden" border borderColor="zinc-800" bg="zinc-900/50">
|
||||
<Box as="table" w="full" textAlign="left">
|
||||
<Box as="thead">
|
||||
<Box as="tr" borderBottom borderColor="zinc-800" bg="zinc-900/80">
|
||||
<Box as="th" px={4} py={3} w="12">
|
||||
<Text size="xs" weight="bold" color="text-zinc-500" uppercase letterSpacing="widest">Pos</Text>
|
||||
</Box>
|
||||
<Box as="th" px={4} py={3}>
|
||||
<Text size="xs" weight="bold" color="text-zinc-500" uppercase letterSpacing="widest">Driver</Text>
|
||||
</Box>
|
||||
<Box as="th" px={4} py={3} display={{ base: 'none', md: 'table-cell' }}>
|
||||
<Text size="xs" weight="bold" color="text-zinc-500" uppercase letterSpacing="widest">Team</Text>
|
||||
</Box>
|
||||
<Box as="th" px={4} py={3} textAlign="center">
|
||||
<Text size="xs" weight="bold" color="text-zinc-500" uppercase letterSpacing="widest">Wins</Text>
|
||||
</Box>
|
||||
<Box as="th" px={4} py={3} textAlign="center">
|
||||
<Text size="xs" weight="bold" color="text-zinc-500" uppercase letterSpacing="widest">Podiums</Text>
|
||||
</Box>
|
||||
<Box as="th" px={4} py={3} textAlign="right">
|
||||
<Text size="xs" weight="bold" color="text-zinc-500" uppercase letterSpacing="widest">Points</Text>
|
||||
</Box>
|
||||
<Box as="th" px={4} py={3} textAlign="right">
|
||||
<Text size="xs" weight="bold" color="text-zinc-500" uppercase letterSpacing="widest">Gap</Text>
|
||||
</Box>
|
||||
</Box>
|
||||
</Box>
|
||||
<Box as="tbody">
|
||||
{standings.map((entry) => (
|
||||
<Box as="tr" key={entry.driverName} borderBottom borderColor="zinc-800" hoverBg="zinc-800/50" transition>
|
||||
<Box as="td" px={4} py={3}>
|
||||
<Text size="sm" color="text-zinc-400" font="mono">{entry.position}</Text>
|
||||
</Box>
|
||||
<Box as="td" px={4} py={3}>
|
||||
<Text size="sm" weight="medium" color="text-zinc-200">{entry.driverName}</Text>
|
||||
</Box>
|
||||
<Box as="td" px={4} py={3} display={{ base: 'none', md: 'table-cell' }}>
|
||||
<Text size="sm" color="text-zinc-500">{entry.teamName || '—'}</Text>
|
||||
</Box>
|
||||
<Box as="td" px={4} py={3} textAlign="center">
|
||||
<Text size="sm" color="text-zinc-400">{entry.wins}</Text>
|
||||
</Box>
|
||||
<Box as="td" px={4} py={3} textAlign="center">
|
||||
<Text size="sm" color="text-zinc-400">{entry.podiums}</Text>
|
||||
</Box>
|
||||
<Box as="td" px={4} py={3} textAlign="right">
|
||||
<Text size="sm" weight="bold" color="text-white">{entry.points}</Text>
|
||||
</Box>
|
||||
<Box as="td" px={4} py={3} textAlign="right">
|
||||
<Text size="sm" color="text-zinc-500" font="mono">{entry.gap}</Text>
|
||||
</Box>
|
||||
</Box>
|
||||
))}
|
||||
</Box>
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
118
apps/website/components/leagues/ScheduleTable.tsx
Normal file
118
apps/website/components/leagues/ScheduleTable.tsx
Normal file
@@ -0,0 +1,118 @@
|
||||
'use client';
|
||||
|
||||
import React from 'react';
|
||||
import { Box } from '@/ui/Box';
|
||||
import { Stack } from '@/ui/Stack';
|
||||
import { Text } from '@/ui/Text';
|
||||
import { Icon } from '@/ui/Icon';
|
||||
import { Calendar, MapPin, ChevronRight } from 'lucide-react';
|
||||
import { Surface } from '@/ui/Surface';
|
||||
import { Table, TableHead, TableBody, TableRow, TableHeader, TableCell } from '@/ui/Table';
|
||||
import { Link } from '@/ui/Link';
|
||||
import { routes } from '@/lib/routing/RouteConfig';
|
||||
|
||||
interface RaceEntry {
|
||||
id: string;
|
||||
name: string;
|
||||
track: string;
|
||||
scheduledAt: string;
|
||||
status: 'upcoming' | 'live' | 'completed';
|
||||
}
|
||||
|
||||
interface ScheduleTableProps {
|
||||
races: RaceEntry[];
|
||||
}
|
||||
|
||||
export function ScheduleTable({ races }: ScheduleTableProps) {
|
||||
return (
|
||||
<Surface variant="dark" border rounded="lg" overflow="hidden">
|
||||
<Table>
|
||||
<TableHead>
|
||||
<TableRow>
|
||||
<TableHeader>Race</TableHeader>
|
||||
<TableHeader>Track</TableHeader>
|
||||
<TableHeader>Date</TableHeader>
|
||||
<TableHeader>Status</TableHeader>
|
||||
<TableHeader textAlign="right">Actions</TableHeader>
|
||||
</TableRow>
|
||||
</TableHead>
|
||||
<TableBody>
|
||||
{races.length === 0 ? (
|
||||
<TableRow>
|
||||
<TableCell colSpan={5} textAlign="center" py={12}>
|
||||
<Text color="text-gray-500">No races scheduled yet.</Text>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
) : (
|
||||
races.map((race) => (
|
||||
<TableRow key={race.id}>
|
||||
<TableCell>
|
||||
<Text weight="medium" color="text-white">{race.name}</Text>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Stack direction="row" align="center" gap={2}>
|
||||
<Icon icon={MapPin} size={3.5} color="text-gray-500" />
|
||||
<Text size="sm" color="text-gray-400">{race.track}</Text>
|
||||
</Stack>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Stack direction="row" align="center" gap={2}>
|
||||
<Icon icon={Calendar} size={3.5} color="text-gray-500" />
|
||||
<Text size="sm" color="text-gray-400">
|
||||
{new Date(race.scheduledAt).toLocaleDateString()}
|
||||
</Text>
|
||||
</Stack>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<StatusBadge status={race.status} />
|
||||
</TableCell>
|
||||
<TableCell textAlign="right">
|
||||
<Link
|
||||
href={routes.race.detail(race.id)}
|
||||
variant="primary"
|
||||
size="xs"
|
||||
weight="bold"
|
||||
letterSpacing="wider"
|
||||
>
|
||||
<Stack direction="row" align="center" gap={1}>
|
||||
<Text>DETAILS</Text>
|
||||
<Icon icon={ChevronRight} size={3} />
|
||||
</Stack>
|
||||
</Link>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</Surface>
|
||||
);
|
||||
}
|
||||
|
||||
function StatusBadge({ status }: { status: RaceEntry['status'] }) {
|
||||
const styles = {
|
||||
upcoming: 'bg-gray-500/10 text-gray-500 border-gray-500/20',
|
||||
live: 'bg-performance-green/10 text-performance-green border-performance-green/20 animate-pulse',
|
||||
completed: 'bg-primary-blue/10 text-primary-blue border-primary-blue/20',
|
||||
};
|
||||
|
||||
return (
|
||||
<Box
|
||||
as="span"
|
||||
px={2}
|
||||
py={0.5}
|
||||
rounded="full"
|
||||
fontSize="10px"
|
||||
border
|
||||
bg={styles[status].split(' ')[0]}
|
||||
color={styles[status].split(' ')[1]}
|
||||
borderColor={styles[status].split(' ')[2]}
|
||||
animate={status === 'live' ? 'pulse' : 'none'}
|
||||
letterSpacing="widest"
|
||||
weight="bold"
|
||||
display="inline-block"
|
||||
>
|
||||
{status.toUpperCase()}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
118
apps/website/components/leagues/StandingsTableShell.tsx
Normal file
118
apps/website/components/leagues/StandingsTableShell.tsx
Normal file
@@ -0,0 +1,118 @@
|
||||
'use client';
|
||||
|
||||
import React from 'react';
|
||||
import { Box } from '@/ui/Box';
|
||||
import { Stack } from '@/ui/Stack';
|
||||
import { Text } from '@/ui/Text';
|
||||
import { Surface } from '@/ui/Surface';
|
||||
import { Icon } from '@/ui/Icon';
|
||||
import { Table, TableHead, TableBody, TableRow, TableHeader, TableCell } from '@/ui/Table';
|
||||
import { Trophy, TrendingUp } from 'lucide-react';
|
||||
|
||||
interface StandingsEntry {
|
||||
position: number;
|
||||
driverName: string;
|
||||
points: number;
|
||||
wins: number;
|
||||
podiums: number;
|
||||
change?: number;
|
||||
}
|
||||
|
||||
interface StandingsTableShellProps {
|
||||
standings: StandingsEntry[];
|
||||
title?: string;
|
||||
}
|
||||
|
||||
export function StandingsTableShell({ standings, title = 'Championship Standings' }: StandingsTableShellProps) {
|
||||
return (
|
||||
<Surface variant="dark" border rounded="lg" overflow="hidden">
|
||||
<Box 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>
|
||||
<Box 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>
|
||||
</Box>
|
||||
</Stack>
|
||||
</Box>
|
||||
|
||||
<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 (
|
||||
<Box
|
||||
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}
|
||||
>
|
||||
<Text size="sm" weight="bold">
|
||||
{position}
|
||||
</Text>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
117
apps/website/components/leagues/StewardingQueuePanel.tsx
Normal file
117
apps/website/components/leagues/StewardingQueuePanel.tsx
Normal file
@@ -0,0 +1,117 @@
|
||||
'use client';
|
||||
|
||||
import React from 'react';
|
||||
import { Box } from '@/ui/Box';
|
||||
import { Stack } from '@/ui/Stack';
|
||||
import { Text } from '@/ui/Text';
|
||||
import { Surface } from '@/ui/Surface';
|
||||
import { Icon } from '@/ui/Icon';
|
||||
import { Clock, ShieldAlert, MessageSquare } from 'lucide-react';
|
||||
import { Button } from '@/ui/Button';
|
||||
|
||||
interface Protest {
|
||||
id: string;
|
||||
raceName: string;
|
||||
protestingDriver: string;
|
||||
accusedDriver: string;
|
||||
description: string;
|
||||
submittedAt: string;
|
||||
status: 'pending' | 'under_review' | 'resolved' | 'rejected';
|
||||
}
|
||||
|
||||
interface StewardingQueuePanelProps {
|
||||
protests: Protest[];
|
||||
onReview: (id: string) => void;
|
||||
}
|
||||
|
||||
export function StewardingQueuePanel({ protests, onReview }: StewardingQueuePanelProps) {
|
||||
return (
|
||||
<Surface variant="dark" border rounded="lg" overflow="hidden">
|
||||
<Box 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={ShieldAlert} size={4} color="text-error-red" />
|
||||
<Text weight="bold" letterSpacing="wider" size="sm" display="block">
|
||||
STEWARDING QUEUE
|
||||
</Text>
|
||||
</Stack>
|
||||
<Box px={2} py={0.5} rounded="md" bg="bg-error-red/10" border borderColor="border-error-red/20">
|
||||
<Text size="xs" color="text-error-red" weight="bold">
|
||||
{protests.filter(p => p.status === 'pending').length} Pending
|
||||
</Text>
|
||||
</Box>
|
||||
</Stack>
|
||||
</Box>
|
||||
|
||||
<Stack gap={0}>
|
||||
{protests.length === 0 ? (
|
||||
<Box py={12} center>
|
||||
<Stack align="center" gap={3}>
|
||||
<Icon icon={ShieldAlert} size={8} color="text-gray-700" />
|
||||
<Text color="text-gray-500">No active protests in the queue.</Text>
|
||||
</Stack>
|
||||
</Box>
|
||||
) : (
|
||||
protests.map((protest) => (
|
||||
<Box key={protest.id} p={6} borderBottom borderColor="border-charcoal-outline" hoverBg="bg-white/5" transition>
|
||||
<Stack direction={{ base: 'col', md: 'row' }} justify="between" align="start" gap={4}>
|
||||
<Stack gap={3} flexGrow={1}>
|
||||
<Stack direction="row" align="center" gap={2}>
|
||||
<StatusIndicator status={protest.status} />
|
||||
<Text size="xs" color="text-gray-500" weight="bold" letterSpacing="widest">
|
||||
{protest.raceName.toUpperCase()}
|
||||
</Text>
|
||||
<Box w={1} h={1} rounded="full" bg="bg-gray-700" />
|
||||
<Stack direction="row" align="center" gap={1.5}>
|
||||
<Icon icon={Clock} size={3} color="text-gray-600" />
|
||||
<Text size="xs" color="text-gray-500">
|
||||
{new Date(protest.submittedAt).toLocaleString()}
|
||||
</Text>
|
||||
</Stack>
|
||||
</Stack>
|
||||
|
||||
<Box>
|
||||
<Stack direction="row" align="center" gap={2} wrap>
|
||||
<Text weight="bold" color="text-white">{protest.protestingDriver}</Text>
|
||||
<Text size="xs" color="text-gray-600" weight="bold">VS</Text>
|
||||
<Text weight="bold" color="text-white">{protest.accusedDriver}</Text>
|
||||
</Stack>
|
||||
<Text size="sm" color="text-gray-400" mt={2} lineClamp={2}>
|
||||
“{protest.description}”
|
||||
</Text>
|
||||
</Box>
|
||||
</Stack>
|
||||
|
||||
<Stack direction="row" align="center" gap={3} w={{ base: 'full', md: 'auto' }}>
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
onClick={() => onReview(protest.id)}
|
||||
>
|
||||
<Stack direction="row" align="center" gap={2}>
|
||||
<Icon icon={MessageSquare} size={3.5} />
|
||||
<Text>Review</Text>
|
||||
</Stack>
|
||||
</Button>
|
||||
</Stack>
|
||||
</Stack>
|
||||
</Box>
|
||||
))
|
||||
)}
|
||||
</Stack>
|
||||
</Surface>
|
||||
);
|
||||
}
|
||||
|
||||
function StatusIndicator({ status }: { status: Protest['status'] }) {
|
||||
const colors = {
|
||||
pending: 'bg-warning-amber',
|
||||
under_review: 'bg-primary-blue',
|
||||
resolved: 'bg-performance-green',
|
||||
rejected: 'bg-gray-500',
|
||||
};
|
||||
|
||||
return (
|
||||
<Box w={2} h={2} rounded="full" bg={colors[status]} animate={status === 'under_review' ? 'pulse' : 'none'} />
|
||||
);
|
||||
}
|
||||
128
apps/website/components/leagues/WalletSummaryPanel.tsx
Normal file
128
apps/website/components/leagues/WalletSummaryPanel.tsx
Normal file
@@ -0,0 +1,128 @@
|
||||
'use client';
|
||||
|
||||
import React from 'react';
|
||||
import { Box } from '@/ui/Box';
|
||||
import { Stack } from '@/ui/Stack';
|
||||
import { Text } from '@/ui/Text';
|
||||
import { Surface } from '@/ui/Surface';
|
||||
import { Icon } from '@/ui/Icon';
|
||||
import { Button } from '@/ui/Button';
|
||||
import { Wallet, ArrowUpRight, ArrowDownLeft, History } from 'lucide-react';
|
||||
|
||||
interface Transaction {
|
||||
id: string;
|
||||
type: 'credit' | 'debit';
|
||||
amount: number;
|
||||
description: string;
|
||||
date: string;
|
||||
}
|
||||
|
||||
interface WalletSummaryPanelProps {
|
||||
balance: number;
|
||||
currency: string;
|
||||
transactions: Transaction[];
|
||||
onDeposit: () => void;
|
||||
onWithdraw: () => void;
|
||||
}
|
||||
|
||||
export function WalletSummaryPanel({ balance, currency, transactions, onDeposit, onWithdraw }: WalletSummaryPanelProps) {
|
||||
return (
|
||||
<Stack gap={6}>
|
||||
<Surface variant="dark" border rounded="lg" padding={8} position="relative" overflow="hidden">
|
||||
{/* Background Pattern */}
|
||||
<Box
|
||||
position="absolute"
|
||||
top="-5rem"
|
||||
right="-5rem"
|
||||
w="12rem"
|
||||
h="12rem"
|
||||
bg="bg-primary-blue/5"
|
||||
rounded="full"
|
||||
blur="xl"
|
||||
pointerEvents="none"
|
||||
/>
|
||||
|
||||
<Stack direction={{ base: 'col', md: 'row' }} justify="between" align="center" gap={8}>
|
||||
<Stack gap={2}>
|
||||
<Stack direction="row" align="center" gap={2}>
|
||||
<Icon icon={Wallet} size={4} color="text-gray-500" />
|
||||
<Text size="xs" color="text-gray-500" weight="bold" letterSpacing="widest">AVAILABLE BALANCE</Text>
|
||||
</Stack>
|
||||
<Stack direction="row" align="baseline" gap={2}>
|
||||
<Text size="4xl" weight="bold" color="text-white">
|
||||
{balance.toLocaleString(undefined, { minimumFractionDigits: 2, maximumFractionDigits: 2 })}
|
||||
</Text>
|
||||
<Text size="xl" weight="medium" color="text-gray-500">{currency}</Text>
|
||||
</Stack>
|
||||
</Stack>
|
||||
|
||||
<Stack direction="row" gap={3}>
|
||||
<Button variant="primary" onClick={onDeposit}>
|
||||
<Stack direction="row" align="center" gap={2}>
|
||||
<Icon icon={ArrowDownLeft} size={4} />
|
||||
<Text>Deposit</Text>
|
||||
</Stack>
|
||||
</Button>
|
||||
<Button variant="secondary" onClick={onWithdraw}>
|
||||
<Stack direction="row" align="center" gap={2}>
|
||||
<Icon icon={ArrowUpRight} size={4} />
|
||||
<Text>Withdraw</Text>
|
||||
</Stack>
|
||||
</Button>
|
||||
</Stack>
|
||||
</Stack>
|
||||
</Surface>
|
||||
|
||||
<Surface variant="dark" border rounded="lg" overflow="hidden">
|
||||
<Box px={6} py={4} borderBottom borderColor="border-charcoal-outline" bg="bg-iron-gray/20">
|
||||
<Stack direction="row" align="center" gap={2}>
|
||||
<Icon icon={History} size={4} color="text-primary-blue" />
|
||||
<Text weight="bold" letterSpacing="wider" size="sm" display="block">
|
||||
RECENT TRANSACTIONS
|
||||
</Text>
|
||||
</Stack>
|
||||
</Box>
|
||||
|
||||
<Stack gap={0}>
|
||||
{transactions.length === 0 ? (
|
||||
<Box py={12} center>
|
||||
<Text color="text-gray-500">No recent transactions.</Text>
|
||||
</Box>
|
||||
) : (
|
||||
transactions.map((tx) => (
|
||||
<Box key={tx.id} p={4} borderBottom borderColor="border-charcoal-outline" hoverBg="bg-white/5" transition>
|
||||
<Stack direction="row" justify="between" align="center">
|
||||
<Stack direction="row" align="center" gap={4}>
|
||||
<Box
|
||||
center
|
||||
w={10}
|
||||
h={10}
|
||||
rounded="full"
|
||||
bg={tx.type === 'credit' ? 'bg-performance-green/10' : 'bg-error-red/10'}
|
||||
>
|
||||
<Icon
|
||||
icon={tx.type === 'credit' ? ArrowDownLeft : ArrowUpRight}
|
||||
size={4}
|
||||
color={tx.type === 'credit' ? 'text-performance-green' : 'text-error-red'}
|
||||
/>
|
||||
</Box>
|
||||
<Stack gap={0.5}>
|
||||
<Text weight="medium" color="text-white">{tx.description}</Text>
|
||||
<Text size="xs" color="text-gray-500">{new Date(tx.date).toLocaleDateString()}</Text>
|
||||
</Stack>
|
||||
</Stack>
|
||||
<Text
|
||||
weight="bold"
|
||||
color={tx.type === 'credit' ? 'text-performance-green' : 'text-white'}
|
||||
>
|
||||
{tx.type === 'credit' ? '+' : '-'}{tx.amount.toFixed(2)}
|
||||
</Text>
|
||||
</Stack>
|
||||
</Box>
|
||||
))
|
||||
)}
|
||||
</Stack>
|
||||
</Surface>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
105
apps/website/components/media/MediaCard.tsx
Normal file
105
apps/website/components/media/MediaCard.tsx
Normal file
@@ -0,0 +1,105 @@
|
||||
'use client';
|
||||
|
||||
import React from 'react';
|
||||
import { motion } from 'framer-motion';
|
||||
import { Box } from '@/ui/Box';
|
||||
import { Text } from '@/ui/Text';
|
||||
import { Image } from '@/ui/Image';
|
||||
import { ImagePlaceholder } from '@/ui/ImagePlaceholder';
|
||||
|
||||
export interface MediaCardProps {
|
||||
src?: string;
|
||||
alt?: string;
|
||||
title?: string;
|
||||
subtitle?: string;
|
||||
aspectRatio?: string;
|
||||
isLoading?: boolean;
|
||||
error?: string;
|
||||
onClick?: () => void;
|
||||
actions?: React.ReactNode;
|
||||
}
|
||||
|
||||
export function MediaCard({
|
||||
src,
|
||||
alt = 'Media asset',
|
||||
title,
|
||||
subtitle,
|
||||
aspectRatio = '16/9',
|
||||
isLoading,
|
||||
error,
|
||||
onClick,
|
||||
actions,
|
||||
}: MediaCardProps) {
|
||||
return (
|
||||
<Box
|
||||
as={motion.div}
|
||||
whileHover={{ y: -4 }}
|
||||
transition={{ duration: 0.2, ease: [0.25, 0.1, 0.25, 1] }} // Fast ease-out
|
||||
h="full"
|
||||
>
|
||||
<Box
|
||||
display="flex"
|
||||
flexDirection="col"
|
||||
bg="bg-charcoal/40"
|
||||
border
|
||||
borderColor="border-charcoal-outline/30"
|
||||
rounded="lg"
|
||||
overflow="hidden"
|
||||
cursor={onClick ? 'pointer' : 'default'}
|
||||
onClick={onClick}
|
||||
group
|
||||
h="full"
|
||||
>
|
||||
<Box position="relative" width="full" aspectRatio={aspectRatio}>
|
||||
{isLoading ? (
|
||||
<ImagePlaceholder variant="loading" aspectRatio={aspectRatio} rounded="none" />
|
||||
) : error ? (
|
||||
<ImagePlaceholder variant="error" message={error} aspectRatio={aspectRatio} rounded="none" />
|
||||
) : src ? (
|
||||
<Box w="full" h="full" overflow="hidden">
|
||||
<Image
|
||||
src={src}
|
||||
alt={alt}
|
||||
objectFit="cover"
|
||||
fullWidth
|
||||
fullHeight
|
||||
/>
|
||||
</Box>
|
||||
) : (
|
||||
<ImagePlaceholder aspectRatio={aspectRatio} rounded="none" />
|
||||
)}
|
||||
|
||||
{actions && (
|
||||
<Box
|
||||
position="absolute"
|
||||
top="2"
|
||||
right="2"
|
||||
display="flex"
|
||||
gap={2}
|
||||
opacity={0}
|
||||
groupHoverOpacity={1}
|
||||
transition
|
||||
>
|
||||
{actions}
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
|
||||
{(title || subtitle) && (
|
||||
<Box p={3} borderTop borderColor="border-charcoal-outline/20">
|
||||
{title && (
|
||||
<Text block size="sm" weight="semibold" truncate color="text-white">
|
||||
{title}
|
||||
</Text>
|
||||
)}
|
||||
{subtitle && (
|
||||
<Text block size="xs" color="text-gray-400" truncate mt={0.5}>
|
||||
{subtitle}
|
||||
</Text>
|
||||
)}
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
81
apps/website/components/media/MediaFiltersBar.tsx
Normal file
81
apps/website/components/media/MediaFiltersBar.tsx
Normal file
@@ -0,0 +1,81 @@
|
||||
import React from 'react';
|
||||
import { Search, Grid, List } from 'lucide-react';
|
||||
import { Box } from '@/ui/Box';
|
||||
import { Input } from '@/ui/Input';
|
||||
import { IconButton } from '@/ui/IconButton';
|
||||
import { Select } from '@/ui/Select';
|
||||
|
||||
export interface MediaFiltersBarProps {
|
||||
searchQuery: string;
|
||||
onSearchChange: (query: string) => void;
|
||||
category: string;
|
||||
onCategoryChange: (category: string) => void;
|
||||
categories: { label: string; value: string }[];
|
||||
viewMode?: 'grid' | 'list';
|
||||
onViewModeChange?: (mode: 'grid' | 'list') => void;
|
||||
}
|
||||
|
||||
export function MediaFiltersBar({
|
||||
searchQuery,
|
||||
onSearchChange,
|
||||
category,
|
||||
onCategoryChange,
|
||||
categories,
|
||||
viewMode = 'grid',
|
||||
onViewModeChange,
|
||||
}: MediaFiltersBarProps) {
|
||||
return (
|
||||
<Box
|
||||
display="flex"
|
||||
flexDirection={{ base: 'col', md: 'row' }}
|
||||
alignItems={{ base: 'stretch', md: 'center' }}
|
||||
justifyContent="between"
|
||||
gap={4}
|
||||
p={4}
|
||||
bg="bg-charcoal/20"
|
||||
border
|
||||
borderColor="border-charcoal-outline/20"
|
||||
rounded="xl"
|
||||
>
|
||||
<Box display="flex" flexGrow={1} maxWidth={{ md: 'md' }}>
|
||||
<Input
|
||||
placeholder="Search media assets..."
|
||||
value={searchQuery}
|
||||
onChange={(e) => onSearchChange(e.target.value)}
|
||||
icon={<Search size={18} />}
|
||||
/>
|
||||
</Box>
|
||||
|
||||
<Box display="flex" alignItems="center" gap={3}>
|
||||
<Box w="40">
|
||||
<Select
|
||||
value={category}
|
||||
onChange={(e) => onCategoryChange(e.target.value)}
|
||||
options={categories}
|
||||
/>
|
||||
</Box>
|
||||
|
||||
{onViewModeChange && (
|
||||
<Box display="flex" bg="bg-charcoal/40" p={1} rounded="lg" border borderColor="border-charcoal-outline/20">
|
||||
<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}
|
||||
/>
|
||||
<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}
|
||||
/>
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
114
apps/website/components/media/MediaGallery.tsx
Normal file
114
apps/website/components/media/MediaGallery.tsx
Normal file
@@ -0,0 +1,114 @@
|
||||
'use client';
|
||||
|
||||
import React, { useState } from 'react';
|
||||
import { Box } from '@/ui/Box';
|
||||
import { MediaGrid } from './MediaGrid';
|
||||
import { MediaCard } from './MediaCard';
|
||||
import { MediaFiltersBar } from './MediaFiltersBar';
|
||||
import { MediaViewerModal } from './MediaViewerModal';
|
||||
import { Text } from '@/ui/Text';
|
||||
|
||||
export interface MediaAsset {
|
||||
id: string;
|
||||
src: string;
|
||||
title: string;
|
||||
category: string;
|
||||
date?: string;
|
||||
dimensions?: string;
|
||||
}
|
||||
|
||||
export interface MediaGalleryProps {
|
||||
assets: MediaAsset[];
|
||||
categories: { label: string; value: string }[];
|
||||
title?: string;
|
||||
description?: string;
|
||||
}
|
||||
|
||||
export function MediaGallery({
|
||||
assets,
|
||||
categories,
|
||||
title = 'Media Gallery',
|
||||
description,
|
||||
}: MediaGalleryProps) {
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
const [selectedCategory, setSelectedCategory] = useState('all');
|
||||
const [viewMode, setViewMode] = useState<'grid' | 'list'>('grid');
|
||||
const [viewerAsset, setViewerAsset] = useState<MediaAsset | null>(null);
|
||||
|
||||
const filteredAssets = assets.filter((asset) => {
|
||||
const matchesSearch = asset.title.toLowerCase().includes(searchQuery.toLowerCase());
|
||||
const matchesCategory = selectedCategory === 'all' || asset.category === selectedCategory;
|
||||
return matchesSearch && matchesCategory;
|
||||
});
|
||||
|
||||
const handleNext = () => {
|
||||
if (!viewerAsset) return;
|
||||
const currentIndex = filteredAssets.findIndex((a) => a.id === viewerAsset.id);
|
||||
if (currentIndex < filteredAssets.length - 1) {
|
||||
const nextAsset = filteredAssets[currentIndex + 1];
|
||||
if (nextAsset) setViewerAsset(nextAsset);
|
||||
}
|
||||
};
|
||||
|
||||
const handlePrev = () => {
|
||||
if (!viewerAsset) return;
|
||||
const currentIndex = filteredAssets.findIndex((a) => a.id === viewerAsset.id);
|
||||
if (currentIndex > 0) {
|
||||
const prevAsset = filteredAssets[currentIndex - 1];
|
||||
if (prevAsset) setViewerAsset(prevAsset);
|
||||
}
|
||||
};
|
||||
|
||||
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>
|
||||
|
||||
<MediaFiltersBar
|
||||
searchQuery={searchQuery}
|
||||
onSearchChange={setSearchQuery}
|
||||
category={selectedCategory}
|
||||
onCategoryChange={setSelectedCategory}
|
||||
categories={categories}
|
||||
viewMode={viewMode}
|
||||
onViewModeChange={setViewMode}
|
||||
/>
|
||||
|
||||
{filteredAssets.length > 0 ? (
|
||||
<MediaGrid columns={viewMode === 'grid' ? { base: 1, sm: 2, md: 3, lg: 4 } : { base: 1 }}>
|
||||
{filteredAssets.map((asset) => (
|
||||
<MediaCard
|
||||
key={asset.id}
|
||||
src={asset.src}
|
||||
title={asset.title}
|
||||
subtitle={`${asset.category}${asset.dimensions ? ` • ${asset.dimensions}` : ''}`}
|
||||
onClick={() => setViewerAsset(asset)}
|
||||
/>
|
||||
))}
|
||||
</MediaGrid>
|
||||
) : (
|
||||
<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>
|
||||
)}
|
||||
|
||||
<MediaViewerModal
|
||||
isOpen={!!viewerAsset}
|
||||
onClose={() => setViewerAsset(null)}
|
||||
src={viewerAsset?.src}
|
||||
alt={viewerAsset?.title}
|
||||
title={viewerAsset?.title}
|
||||
onNext={handleNext}
|
||||
onPrev={handlePrev}
|
||||
/>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
34
apps/website/components/media/MediaGrid.tsx
Normal file
34
apps/website/components/media/MediaGrid.tsx
Normal file
@@ -0,0 +1,34 @@
|
||||
import React from 'react';
|
||||
import { Box } from '@/ui/Box';
|
||||
|
||||
export interface MediaGridProps {
|
||||
children: React.ReactNode;
|
||||
columns?: {
|
||||
base?: number;
|
||||
sm?: number;
|
||||
md?: number;
|
||||
lg?: number;
|
||||
xl?: number;
|
||||
};
|
||||
gap?: 2 | 3 | 4 | 6 | 8;
|
||||
}
|
||||
|
||||
export function MediaGrid({
|
||||
children,
|
||||
columns = { base: 1, sm: 2, md: 3, lg: 4 },
|
||||
gap = 4,
|
||||
}: MediaGridProps) {
|
||||
return (
|
||||
<Box
|
||||
display="grid"
|
||||
responsiveGridCols={{
|
||||
base: columns.base,
|
||||
md: columns.md,
|
||||
lg: columns.lg,
|
||||
}}
|
||||
gap={gap}
|
||||
>
|
||||
{children}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
170
apps/website/components/media/MediaViewerModal.tsx
Normal file
170
apps/website/components/media/MediaViewerModal.tsx
Normal file
@@ -0,0 +1,170 @@
|
||||
'use client';
|
||||
|
||||
import React from 'react';
|
||||
import { motion, AnimatePresence } from 'framer-motion';
|
||||
import { X, ChevronLeft, ChevronRight, Download } from 'lucide-react';
|
||||
import { Box } from '@/ui/Box';
|
||||
import { IconButton } from '@/ui/IconButton';
|
||||
import { Image } from '@/ui/Image';
|
||||
import { Text } from '@/ui/Text';
|
||||
|
||||
export interface MediaViewerModalProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
src?: string;
|
||||
alt?: string;
|
||||
title?: string;
|
||||
onNext?: () => void;
|
||||
onPrev?: () => void;
|
||||
}
|
||||
|
||||
export function MediaViewerModal({
|
||||
isOpen,
|
||||
onClose,
|
||||
src,
|
||||
alt = 'Media viewer',
|
||||
title,
|
||||
onNext,
|
||||
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}
|
||||
/>
|
||||
|
||||
{/* 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>
|
||||
);
|
||||
}
|
||||
187
apps/website/components/media/UploadDropzone.tsx
Normal file
187
apps/website/components/media/UploadDropzone.tsx
Normal file
@@ -0,0 +1,187 @@
|
||||
import React, { useState, useRef } from 'react';
|
||||
import { Upload, File, X, CheckCircle2, AlertCircle } from 'lucide-react';
|
||||
import { Box } from '@/ui/Box';
|
||||
import { Text } from '@/ui/Text';
|
||||
import { Icon } from '@/ui/Icon';
|
||||
import { Button } from '@/ui/Button';
|
||||
|
||||
export interface UploadDropzoneProps {
|
||||
onFilesSelected: (files: File[]) => void;
|
||||
accept?: string;
|
||||
multiple?: boolean;
|
||||
maxSize?: number; // in bytes
|
||||
isLoading?: boolean;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
export function UploadDropzone({
|
||||
onFilesSelected,
|
||||
accept,
|
||||
multiple = false,
|
||||
maxSize,
|
||||
isLoading,
|
||||
error,
|
||||
}: UploadDropzoneProps) {
|
||||
const [isDragging, setIsDragging] = useState(false);
|
||||
const [selectedFiles, setSelectedFiles] = useState<File[]>([]);
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
const handleDragOver = (e: React.DragEvent) => {
|
||||
e.preventDefault();
|
||||
setIsDragging(true);
|
||||
};
|
||||
|
||||
const handleDragLeave = () => {
|
||||
setIsDragging(false);
|
||||
};
|
||||
|
||||
const handleDrop = (e: React.DragEvent) => {
|
||||
e.preventDefault();
|
||||
setIsDragging(false);
|
||||
const files = Array.from(e.dataTransfer.files);
|
||||
validateAndSelectFiles(files);
|
||||
};
|
||||
|
||||
const handleFileSelect = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
if (e.target.files) {
|
||||
const files = Array.from(e.target.files);
|
||||
validateAndSelectFiles(files);
|
||||
}
|
||||
};
|
||||
|
||||
const validateAndSelectFiles = (files: File[]) => {
|
||||
let filteredFiles = files;
|
||||
|
||||
if (accept) {
|
||||
const acceptedTypes = accept.split(',').map(t => t.trim());
|
||||
filteredFiles = filteredFiles.filter(file => {
|
||||
return acceptedTypes.some(type => {
|
||||
if (type.startsWith('.')) {
|
||||
return file.name.endsWith(type);
|
||||
}
|
||||
if (type.endsWith('/*')) {
|
||||
return file.type.startsWith(type.replace('/*', ''));
|
||||
}
|
||||
return file.type === type;
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
if (maxSize) {
|
||||
filteredFiles = filteredFiles.filter(file => file.size <= maxSize);
|
||||
}
|
||||
|
||||
if (!multiple) {
|
||||
filteredFiles = filteredFiles.slice(0, 1);
|
||||
}
|
||||
|
||||
setSelectedFiles(filteredFiles);
|
||||
onFilesSelected(filteredFiles);
|
||||
};
|
||||
|
||||
const removeFile = (index: number) => {
|
||||
const newFiles = [...selectedFiles];
|
||||
newFiles.splice(index, 1);
|
||||
setSelectedFiles(newFiles);
|
||||
onFilesSelected(newFiles);
|
||||
};
|
||||
|
||||
return (
|
||||
<Box w="full">
|
||||
<Box
|
||||
onDragOver={handleDragOver}
|
||||
onDragLeave={handleDragLeave}
|
||||
onDrop={handleDrop}
|
||||
onClick={() => fileInputRef.current?.click()}
|
||||
display="flex"
|
||||
flexDirection="col"
|
||||
alignItems="center"
|
||||
justifyContent="center"
|
||||
p={8}
|
||||
border
|
||||
borderStyle="dashed"
|
||||
borderColor={isDragging ? 'border-blue-500' : error ? 'border-amber-500' : 'border-charcoal-outline'}
|
||||
bg={isDragging ? 'bg-blue-500/5' : 'bg-charcoal-outline/10'}
|
||||
rounded="xl"
|
||||
cursor="pointer"
|
||||
transition
|
||||
hoverBg="bg-charcoal-outline/20"
|
||||
>
|
||||
<Box as="input"
|
||||
type="file"
|
||||
ref={fileInputRef}
|
||||
onChange={handleFileSelect}
|
||||
accept={accept}
|
||||
multiple={multiple}
|
||||
display="none"
|
||||
/>
|
||||
|
||||
<Icon
|
||||
icon={isLoading ? Upload : (selectedFiles.length > 0 ? CheckCircle2 : Upload)}
|
||||
size={10}
|
||||
color={isDragging ? 'text-blue-500' : error ? 'text-amber-500' : 'text-gray-500'}
|
||||
animate={isLoading ? 'pulse' : 'none'}
|
||||
mb={4}
|
||||
/>
|
||||
|
||||
<Text weight="semibold" size="lg" mb={1}>
|
||||
{isDragging ? 'Drop files here' : 'Click or drag to upload'}
|
||||
</Text>
|
||||
|
||||
<Text size="sm" color="text-gray-500" align="center">
|
||||
{accept ? `Accepted formats: ${accept}` : 'All file types accepted'}
|
||||
{maxSize && ` (Max ${Math.round(maxSize / 1024 / 1024)}MB)`}
|
||||
</Text>
|
||||
|
||||
{error && (
|
||||
<Box display="flex" alignItems="center" gap={2} mt={4} color="text-amber-500">
|
||||
<Icon icon={AlertCircle} size={4} />
|
||||
<Text size="sm" weight="medium">{error}</Text>
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
|
||||
{selectedFiles.length > 0 && (
|
||||
<Box mt={4} display="flex" flexDirection="col" gap={2}>
|
||||
{selectedFiles.map((file, index) => (
|
||||
<Box
|
||||
key={`${file.name}-${index}`}
|
||||
display="flex"
|
||||
alignItems="center"
|
||||
justifyContent="between"
|
||||
p={3}
|
||||
bg="bg-charcoal-outline/20"
|
||||
rounded="lg"
|
||||
border
|
||||
borderColor="border-charcoal-outline/30"
|
||||
>
|
||||
<Box display="flex" alignItems="center" gap={3}>
|
||||
<Icon icon={File} size={5} color="text-gray-400" />
|
||||
<Box>
|
||||
<Text block size="sm" weight="medium" truncate maxWidth="200px">
|
||||
{file.name}
|
||||
</Text>
|
||||
<Text block size="xs" color="text-gray-500">
|
||||
{Math.round(file.size / 1024)} KB
|
||||
</Text>
|
||||
</Box>
|
||||
</Box>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
removeFile(index);
|
||||
}}
|
||||
p={1}
|
||||
h="auto"
|
||||
>
|
||||
<Icon icon={X} size={4} />
|
||||
</Button>
|
||||
</Box>
|
||||
))}
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user