website refactor
This commit is contained in:
17
apps/website/ui/AppFooter.tsx
Normal file
17
apps/website/ui/AppFooter.tsx
Normal file
@@ -0,0 +1,17 @@
|
||||
import React from 'react';
|
||||
|
||||
interface AppFooterProps {
|
||||
children?: React.ReactNode;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* AppFooter is the bottom section of the application.
|
||||
*/
|
||||
export function AppFooter({ children, className = '' }: AppFooterProps) {
|
||||
return (
|
||||
<footer className={`bg-[#141619] border-t border-[#23272B] py-8 px-4 md:px-6 ${className}`}>
|
||||
{children}
|
||||
</footer>
|
||||
);
|
||||
}
|
||||
20
apps/website/ui/AppHeader.tsx
Normal file
20
apps/website/ui/AppHeader.tsx
Normal file
@@ -0,0 +1,20 @@
|
||||
import React from 'react';
|
||||
|
||||
interface AppHeaderProps {
|
||||
children: React.ReactNode;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* AppHeader is the top control bar of the application.
|
||||
* It follows the "Telemetry Workspace" structure.
|
||||
*/
|
||||
export function AppHeader({ children, className = '' }: AppHeaderProps) {
|
||||
return (
|
||||
<header
|
||||
className={`sticky top-0 z-50 h-16 md:h-20 bg-[#0C0D0F]/80 backdrop-blur-md border-b border-[#23272B] flex items-center px-4 md:px-6 ${className}`}
|
||||
>
|
||||
{children}
|
||||
</header>
|
||||
);
|
||||
}
|
||||
18
apps/website/ui/AppShell.tsx
Normal file
18
apps/website/ui/AppShell.tsx
Normal file
@@ -0,0 +1,18 @@
|
||||
import React from 'react';
|
||||
|
||||
interface AppShellProps {
|
||||
children: React.ReactNode;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* AppShell is the root container for the entire application layout.
|
||||
* It provides the base background and layout structure.
|
||||
*/
|
||||
export function AppShell({ children, className = '' }: AppShellProps) {
|
||||
return (
|
||||
<div className={`min-h-screen bg-[#0C0D0F] text-gray-100 flex flex-col ${className}`}>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
20
apps/website/ui/AppSidebar.tsx
Normal file
20
apps/website/ui/AppSidebar.tsx
Normal file
@@ -0,0 +1,20 @@
|
||||
import React from 'react';
|
||||
|
||||
interface AppSidebarProps {
|
||||
children?: React.ReactNode;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* AppSidebar is the "dashboard rail" of the application.
|
||||
* It provides global navigation and context.
|
||||
*/
|
||||
export function AppSidebar({ children, className = '' }: AppSidebarProps) {
|
||||
return (
|
||||
<aside
|
||||
className={`hidden lg:flex flex-col w-64 bg-[#141619] border-r border-[#23272B] overflow-y-auto ${className}`}
|
||||
>
|
||||
{children}
|
||||
</aside>
|
||||
);
|
||||
}
|
||||
@@ -1,16 +0,0 @@
|
||||
|
||||
|
||||
import React from 'react';
|
||||
import { Box } from './Box';
|
||||
|
||||
interface AuthContainerProps {
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
export function AuthContainer({ children }: AuthContainerProps) {
|
||||
return (
|
||||
<Box style={{ minHeight: '100vh', display: 'flex', alignItems: 'center', justifyContent: 'center', padding: '1rem', backgroundColor: '#0f1115' }}>
|
||||
{children}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
@@ -1,27 +1,51 @@
|
||||
/**
|
||||
* Avatar
|
||||
*
|
||||
* Pure UI component for displaying driver avatars.
|
||||
* Renders an image with fallback on error.
|
||||
*/
|
||||
import React from 'react';
|
||||
import { Box } from './Box';
|
||||
import { Image } from './Image';
|
||||
import { User } from 'lucide-react';
|
||||
import { Icon } from './Icon';
|
||||
|
||||
export interface AvatarProps {
|
||||
driverId: string;
|
||||
driverId?: string;
|
||||
src?: string;
|
||||
alt: string;
|
||||
size?: number;
|
||||
className?: string;
|
||||
border?: boolean;
|
||||
}
|
||||
|
||||
export function Avatar({ driverId, alt, className = '' }: AvatarProps) {
|
||||
export function Avatar({
|
||||
driverId,
|
||||
src,
|
||||
alt,
|
||||
size = 40,
|
||||
className = '',
|
||||
border = true,
|
||||
}: AvatarProps) {
|
||||
const avatarSrc = src || (driverId ? `/media/avatar/${driverId}` : undefined);
|
||||
|
||||
return (
|
||||
// eslint-disable-next-line @next/next/no-img-element
|
||||
<img
|
||||
src={`/media/avatar/${driverId}`}
|
||||
alt={alt}
|
||||
className={`w-10 h-10 rounded-full object-cover ${className}`}
|
||||
onError={(e) => {
|
||||
// Fallback to default avatar
|
||||
(e.target as HTMLImageElement).src = '/default-avatar.png';
|
||||
}}
|
||||
/>
|
||||
<Box
|
||||
display="flex"
|
||||
alignItems="center"
|
||||
justifyContent="center"
|
||||
rounded="full"
|
||||
overflow="hidden"
|
||||
bg="bg-charcoal-outline/20"
|
||||
border={border}
|
||||
borderColor="border-charcoal-outline/50"
|
||||
className={className}
|
||||
style={{ width: size, height: size, flexShrink: 0 }}
|
||||
>
|
||||
{avatarSrc ? (
|
||||
<Image
|
||||
src={avatarSrc}
|
||||
alt={alt}
|
||||
className="w-full h-full object-cover"
|
||||
fallbackSrc="/default-avatar.png"
|
||||
/>
|
||||
) : (
|
||||
<Icon icon={User} size={size > 32 ? 5 : 4} color="text-gray-500" />
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -91,6 +91,7 @@ export interface BoxProps<T extends ElementType> {
|
||||
groupHoverTextColor?: string;
|
||||
groupHoverScale?: boolean;
|
||||
groupHoverOpacity?: number;
|
||||
groupHoverWidth?: 'full' | '0' | 'auto';
|
||||
fontSize?: string;
|
||||
transform?: string;
|
||||
borderWidth?: string;
|
||||
@@ -115,6 +116,9 @@ export interface BoxProps<T extends ElementType> {
|
||||
backgroundPosition?: string;
|
||||
backgroundColor?: string;
|
||||
insetY?: Spacing | string;
|
||||
letterSpacing?: string;
|
||||
lineHeight?: string;
|
||||
backgroundImage?: string;
|
||||
}
|
||||
|
||||
type ResponsiveValue<T> = {
|
||||
@@ -190,6 +194,7 @@ export const Box = forwardRef(<T extends ElementType = 'div'>(
|
||||
groupHoverTextColor,
|
||||
groupHoverScale,
|
||||
groupHoverOpacity,
|
||||
groupHoverWidth,
|
||||
fontSize,
|
||||
transform,
|
||||
borderWidth,
|
||||
@@ -213,6 +218,9 @@ export const Box = forwardRef(<T extends ElementType = 'div'>(
|
||||
backgroundPosition,
|
||||
backgroundColor,
|
||||
insetY,
|
||||
letterSpacing,
|
||||
lineHeight,
|
||||
backgroundImage,
|
||||
...props
|
||||
}: BoxProps<T> & ComponentPropsWithoutRef<T>,
|
||||
ref: ForwardedRef<HTMLElement>
|
||||
@@ -356,6 +364,7 @@ export const Box = forwardRef(<T extends ElementType = 'div'>(
|
||||
groupHoverTextColor ? `group-hover:text-${groupHoverTextColor}` : '',
|
||||
groupHoverScale ? 'group-hover:scale-[1.02]' : '',
|
||||
groupHoverOpacity !== undefined ? `group-hover:opacity-${groupHoverOpacity * 100}` : '',
|
||||
groupHoverWidth ? `group-hover:w-${groupHoverWidth}` : '',
|
||||
getResponsiveClasses('', display),
|
||||
getFlexDirectionClass(flexDirection),
|
||||
getAlignItemsClass(alignItems),
|
||||
@@ -399,6 +408,9 @@ export const Box = forwardRef(<T extends ElementType = 'div'>(
|
||||
...(webkitMaskImage ? { WebkitMaskImage: webkitMaskImage } : {}),
|
||||
...(backgroundSize ? { backgroundSize } : {}),
|
||||
...(backgroundPosition ? { backgroundPosition } : {}),
|
||||
...(backgroundImage ? { backgroundImage } : {}),
|
||||
...(letterSpacing ? { letterSpacing } : {}),
|
||||
...(lineHeight ? { lineHeight } : {}),
|
||||
...(top !== undefined && !spacingMap[top as string | number] ? { top } : {}),
|
||||
...(bottom !== undefined && !spacingMap[bottom as string | number] ? { bottom } : {}),
|
||||
...(left !== undefined && !spacingMap[left as string | number] ? { left } : {}),
|
||||
|
||||
17
apps/website/ui/BreadcrumbBar.tsx
Normal file
17
apps/website/ui/BreadcrumbBar.tsx
Normal file
@@ -0,0 +1,17 @@
|
||||
import React from 'react';
|
||||
|
||||
interface BreadcrumbBarProps {
|
||||
children: React.ReactNode;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* BreadcrumbBar is a container for breadcrumbs, typically placed at the top of the ContentShell.
|
||||
*/
|
||||
export function BreadcrumbBar({ children, className = '' }: BreadcrumbBarProps) {
|
||||
return (
|
||||
<div className={`mb-6 flex items-center space-x-2 text-sm ${className}`}>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,6 +1,8 @@
|
||||
import React, { ReactNode, MouseEventHandler, ButtonHTMLAttributes, forwardRef } from 'react';
|
||||
import { Stack } from './Stack';
|
||||
import { Box, BoxProps } from './Box';
|
||||
import { Loader2 } from 'lucide-react';
|
||||
import { Icon } from './Icon';
|
||||
|
||||
interface ButtonProps extends Omit<ButtonHTMLAttributes<HTMLButtonElement>, 'as' | 'onMouseEnter' | 'onMouseLeave' | 'onSubmit'>, Omit<BoxProps<'button'>, 'as' | 'onClick' | 'onSubmit'> {
|
||||
children: ReactNode;
|
||||
@@ -9,6 +11,7 @@ interface ButtonProps extends Omit<ButtonHTMLAttributes<HTMLButtonElement>, 'as'
|
||||
variant?: 'primary' | 'secondary' | 'danger' | 'ghost' | 'race-final' | 'discord';
|
||||
size?: 'sm' | 'md' | 'lg';
|
||||
disabled?: boolean;
|
||||
isLoading?: boolean;
|
||||
type?: 'button' | 'submit' | 'reset';
|
||||
icon?: ReactNode;
|
||||
fullWidth?: boolean;
|
||||
@@ -25,6 +28,7 @@ export const Button = forwardRef<HTMLButtonElement, ButtonProps>(({
|
||||
variant = 'primary',
|
||||
size = 'md',
|
||||
disabled = false,
|
||||
isLoading = false,
|
||||
type = 'button',
|
||||
icon,
|
||||
fullWidth = false,
|
||||
@@ -51,7 +55,7 @@ export const Button = forwardRef<HTMLButtonElement, ButtonProps>(({
|
||||
lg: 'min-h-[48px] px-6 py-3 text-base font-medium'
|
||||
};
|
||||
|
||||
const disabledClasses = disabled ? 'opacity-40 cursor-not-allowed' : 'cursor-pointer';
|
||||
const disabledClasses = (disabled || isLoading) ? 'opacity-40 cursor-not-allowed' : 'cursor-pointer';
|
||||
const widthClasses = fullWidth ? 'w-full' : '';
|
||||
|
||||
const classes = [
|
||||
@@ -63,12 +67,13 @@ export const Button = forwardRef<HTMLButtonElement, ButtonProps>(({
|
||||
className
|
||||
].filter(Boolean).join(' ');
|
||||
|
||||
const content = icon ? (
|
||||
const content = (
|
||||
<Stack direction="row" align="center" gap={2} center={fullWidth}>
|
||||
{icon}
|
||||
{isLoading && <Icon icon={Loader2} size={size === 'sm' ? 3 : 4} animate="spin" />}
|
||||
{!isLoading && icon}
|
||||
{children}
|
||||
</Stack>
|
||||
) : children;
|
||||
);
|
||||
|
||||
if (as === 'a') {
|
||||
return (
|
||||
@@ -92,7 +97,7 @@ export const Button = forwardRef<HTMLButtonElement, ButtonProps>(({
|
||||
type={type}
|
||||
className={classes}
|
||||
onClick={onClick}
|
||||
disabled={disabled}
|
||||
disabled={disabled || isLoading}
|
||||
{...props}
|
||||
>
|
||||
{content}
|
||||
|
||||
@@ -1,27 +1,44 @@
|
||||
/**
|
||||
* CategoryIcon
|
||||
*
|
||||
* Pure UI component for displaying category icons.
|
||||
* Renders an image with fallback on error.
|
||||
*/
|
||||
import React from 'react';
|
||||
import { Box } from './Box';
|
||||
import { Image } from './Image';
|
||||
import { Tag } from 'lucide-react';
|
||||
import { Icon } from './Icon';
|
||||
|
||||
export interface CategoryIconProps {
|
||||
categoryId: string;
|
||||
categoryId?: string;
|
||||
src?: string;
|
||||
alt: string;
|
||||
size?: number;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function CategoryIcon({ categoryId, alt, className = '' }: CategoryIconProps) {
|
||||
export function CategoryIcon({
|
||||
categoryId,
|
||||
src,
|
||||
alt,
|
||||
size = 24,
|
||||
className = '',
|
||||
}: CategoryIconProps) {
|
||||
const iconSrc = src || (categoryId ? `/media/categories/${categoryId}/icon` : undefined);
|
||||
|
||||
return (
|
||||
// eslint-disable-next-line @next/next/no-img-element
|
||||
<img
|
||||
src={`/media/categories/${categoryId}/icon`}
|
||||
alt={alt}
|
||||
className={`w-6 h-6 object-contain ${className}`}
|
||||
onError={(e) => {
|
||||
// Fallback to default icon
|
||||
(e.target as HTMLImageElement).src = '/default-category-icon.png';
|
||||
}}
|
||||
/>
|
||||
<Box
|
||||
display="flex"
|
||||
alignItems="center"
|
||||
justifyContent="center"
|
||||
className={className}
|
||||
style={{ width: size, height: size, flexShrink: 0 }}
|
||||
>
|
||||
{iconSrc ? (
|
||||
<Image
|
||||
src={iconSrc}
|
||||
alt={alt}
|
||||
className="w-full h-full object-contain"
|
||||
fallbackSrc="/default-category-icon.png"
|
||||
/>
|
||||
) : (
|
||||
<Icon icon={Tag} size={size > 20 ? 4 : 3} color="text-gray-500" />
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
20
apps/website/ui/ContentShell.tsx
Normal file
20
apps/website/ui/ContentShell.tsx
Normal file
@@ -0,0 +1,20 @@
|
||||
import React from 'react';
|
||||
|
||||
interface ContentShellProps {
|
||||
children: React.ReactNode;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* ContentShell is the main data zone of the application.
|
||||
* It houses the primary content and track maps/data tables.
|
||||
*/
|
||||
export function ContentShell({ children, className = '' }: ContentShellProps) {
|
||||
return (
|
||||
<main className={`flex-1 overflow-y-auto bg-[#0C0D0F] ${className}`}>
|
||||
<div className="max-w-7xl mx-auto px-4 md:px-6 py-6">
|
||||
{children}
|
||||
</div>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
21
apps/website/ui/ContentViewport.tsx
Normal file
21
apps/website/ui/ContentViewport.tsx
Normal file
@@ -0,0 +1,21 @@
|
||||
import React from 'react';
|
||||
|
||||
interface ContentViewportProps {
|
||||
children: React.ReactNode;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* ContentViewport is the main data zone of the "Telemetry Workspace".
|
||||
* It houses the primary content, track maps, and data tables.
|
||||
* Aligned with "Precision Racing Minimal" theme.
|
||||
*/
|
||||
export function ContentViewport({ children, className = '' }: ContentViewportProps) {
|
||||
return (
|
||||
<main className={`flex-1 overflow-y-auto bg-[#0C0D0F] ${className}`}>
|
||||
<div className="max-w-7xl mx-auto px-4 md:px-6 py-6">
|
||||
{children}
|
||||
</div>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
21
apps/website/ui/ControlBar.tsx
Normal file
21
apps/website/ui/ControlBar.tsx
Normal file
@@ -0,0 +1,21 @@
|
||||
import React from 'react';
|
||||
|
||||
interface ControlBarProps {
|
||||
children: React.ReactNode;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* ControlBar is the top-level header of the "Telemetry Workspace".
|
||||
* It provides global controls, navigation, and status information.
|
||||
* Aligned with "Precision Racing Minimal" theme.
|
||||
*/
|
||||
export function ControlBar({ children, className = '' }: ControlBarProps) {
|
||||
return (
|
||||
<header
|
||||
className={`sticky top-0 z-50 h-16 md:h-20 bg-[#0C0D0F]/80 backdrop-blur-md border-b border-[#23272B] flex items-center px-4 md:px-6 ${className}`}
|
||||
>
|
||||
{children}
|
||||
</header>
|
||||
);
|
||||
}
|
||||
@@ -1,12 +1,9 @@
|
||||
|
||||
|
||||
import { ReactNode } from 'react';
|
||||
import { Badge } from './Badge';
|
||||
import React, { ReactNode } from 'react';
|
||||
import { Box } from './Box';
|
||||
import { Heading } from './Heading';
|
||||
import { Image } from './Image';
|
||||
import { Stack } from './Stack';
|
||||
import { Text } from './Text';
|
||||
import { Glow } from './Glow';
|
||||
|
||||
interface DashboardHeroProps {
|
||||
driverName: string;
|
||||
@@ -17,8 +14,15 @@ interface DashboardHeroProps {
|
||||
totalRaces: string | number;
|
||||
actions?: ReactNode;
|
||||
stats?: ReactNode;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* DashboardHero
|
||||
*
|
||||
* Redesigned for "Precision Racing Minimal" theme.
|
||||
* Uses subtle accent glows and crisp separators.
|
||||
*/
|
||||
export function DashboardHero({
|
||||
driverName,
|
||||
avatarUrl,
|
||||
@@ -28,112 +32,111 @@ export function DashboardHero({
|
||||
totalRaces,
|
||||
actions,
|
||||
stats,
|
||||
className = '',
|
||||
}: DashboardHeroProps) {
|
||||
return (
|
||||
<Box as="section" position="relative" overflow="hidden">
|
||||
{/* Background Pattern */}
|
||||
<Box
|
||||
position="absolute"
|
||||
inset="0"
|
||||
style={{
|
||||
background: 'linear-gradient(to bottom right, rgba(59, 130, 246, 0.1), #0f1115, rgba(147, 51, 234, 0.05))',
|
||||
}}
|
||||
<Box
|
||||
as="section"
|
||||
position="relative"
|
||||
className={`bg-[#0C0D0F] border-b border-[#23272B] overflow-hidden ${className}`}
|
||||
>
|
||||
{/* Subtle Accent Glow */}
|
||||
<Glow
|
||||
position="top-right"
|
||||
color="primary"
|
||||
opacity={0.1}
|
||||
size="xl"
|
||||
/>
|
||||
|
||||
<Box
|
||||
position="relative"
|
||||
maxWidth="80rem"
|
||||
mx="auto"
|
||||
px={6}
|
||||
py={10}
|
||||
py={8}
|
||||
position="relative"
|
||||
zIndex={1}
|
||||
>
|
||||
<Stack gap={8}>
|
||||
<Stack direction="row" align="center" justify="between" wrap gap={8}>
|
||||
{/* Welcome Message */}
|
||||
<Stack direction="row" align="start" gap={5}>
|
||||
<Box display="flex" flexDirection="col" gap={8}>
|
||||
<Box display="flex" align="center" justify="between" wrap gap={6}>
|
||||
{/* Driver Identity */}
|
||||
<Box display="flex" align="center" gap={6}>
|
||||
<Box position="relative">
|
||||
<Box
|
||||
w="20"
|
||||
h="20"
|
||||
rounded="xl"
|
||||
p={0.5}
|
||||
style={{
|
||||
background: 'linear-gradient(to bottom right, #3b82f6, #9333ea)',
|
||||
boxShadow: '0 20px 25px -5px rgba(59, 130, 246, 0.2)',
|
||||
}}
|
||||
w="24"
|
||||
h="24"
|
||||
className="border border-[#23272B] p-1 bg-[#141619]"
|
||||
>
|
||||
<Box
|
||||
w="full"
|
||||
h="full"
|
||||
rounded="lg"
|
||||
overflow="hidden"
|
||||
bg="bg-deep-graphite"
|
||||
>
|
||||
<Image
|
||||
src={avatarUrl}
|
||||
alt={driverName}
|
||||
width={80}
|
||||
height={80}
|
||||
style={{ width: '100%', height: '100%', objectFit: 'cover' }}
|
||||
/>
|
||||
</Box>
|
||||
<Image
|
||||
src={avatarUrl}
|
||||
alt={driverName}
|
||||
width={96}
|
||||
height={96}
|
||||
className="w-full h-full object-cover grayscale hover:grayscale-0 transition-all duration-300"
|
||||
/>
|
||||
</Box>
|
||||
<Box
|
||||
position="absolute"
|
||||
bottom="-1"
|
||||
right="-1"
|
||||
w="5"
|
||||
h="5"
|
||||
rounded="full"
|
||||
bg="bg-performance-green"
|
||||
border
|
||||
style={{ borderColor: '#0f1115', borderWidth: '3px' }}
|
||||
w="4"
|
||||
h="4"
|
||||
className="bg-[#4ED4E0] border-2 border-[#0C0D0F]"
|
||||
/>
|
||||
</Box>
|
||||
|
||||
<Box>
|
||||
<Text size="sm" color="text-gray-400" block mb={1}>
|
||||
Good morning,
|
||||
</Text>
|
||||
<Heading level={1} mb={2}>
|
||||
{driverName}
|
||||
<Text size="2xl" ml={3}>
|
||||
<Box display="flex" align="center" gap={3} mb={1}>
|
||||
<Text size="xs" color="text-gray-500" uppercase weight="bold" letterSpacing="widest">
|
||||
Driver Profile
|
||||
</Text>
|
||||
<Text size="xs" color="text-gray-600">
|
||||
/
|
||||
</Text>
|
||||
<Text size="xs" color="text-gray-400">
|
||||
{country}
|
||||
</Text>
|
||||
</Box>
|
||||
<Heading level={1} className="text-3xl md:text-4xl font-black uppercase tracking-tighter mb-2">
|
||||
{driverName}
|
||||
</Heading>
|
||||
<Stack direction="row" align="center" gap={3} wrap>
|
||||
<Badge variant="primary">
|
||||
{rating}
|
||||
</Badge>
|
||||
<Badge variant="warning">
|
||||
#{rank}
|
||||
</Badge>
|
||||
<Text size="xs" color="text-gray-500">
|
||||
{totalRaces} races completed
|
||||
</Text>
|
||||
</Stack>
|
||||
<Box display="flex" align="center" gap={4}>
|
||||
<Box display="flex" align="center" gap={2}>
|
||||
<Text size="xs" color="text-gray-500" uppercase>Rating</Text>
|
||||
<Text size="sm" weight="bold" className="text-[#4ED4E0] font-mono">{rating}</Text>
|
||||
</Box>
|
||||
<Box display="flex" align="center" gap={2}>
|
||||
<Text size="xs" color="text-gray-500" uppercase>Rank</Text>
|
||||
<Text size="sm" weight="bold" className="text-[#FFBE4D] font-mono">#{rank}</Text>
|
||||
</Box>
|
||||
<Box display="flex" align="center" gap={2}>
|
||||
<Text size="xs" color="text-gray-500" uppercase>Starts</Text>
|
||||
<Text size="sm" weight="bold" className="text-gray-300 font-mono">{totalRaces}</Text>
|
||||
</Box>
|
||||
</Box>
|
||||
</Box>
|
||||
</Stack>
|
||||
</Box>
|
||||
|
||||
{/* Quick Actions */}
|
||||
{/* Actions */}
|
||||
{actions && (
|
||||
<Stack direction="row" gap={3} wrap>
|
||||
<Box display="flex" gap={3}>
|
||||
{actions}
|
||||
</Stack>
|
||||
</Box>
|
||||
)}
|
||||
</Stack>
|
||||
</Box>
|
||||
|
||||
{/* Quick Stats Row */}
|
||||
{/* Stats Grid */}
|
||||
{stats && (
|
||||
<Box
|
||||
display="grid"
|
||||
gridCols={2}
|
||||
responsiveGridCols={{ md: 4 }}
|
||||
<Box
|
||||
display="grid"
|
||||
gridCols={2}
|
||||
responsiveGridCols={{ md: 4 }}
|
||||
gap={4}
|
||||
className="border-t border-[#23272B]/50 pt-6"
|
||||
>
|
||||
{stats}
|
||||
</Box>
|
||||
)}
|
||||
</Stack>
|
||||
</Box>
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
|
||||
@@ -1,21 +0,0 @@
|
||||
import { ReactNode } from 'react';
|
||||
|
||||
// TODO very useless component
|
||||
|
||||
interface DashboardLayoutWrapperProps {
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
/**
|
||||
* DashboardLayoutWrapper
|
||||
*
|
||||
* Full-screen layout wrapper for dashboard pages.
|
||||
* Provides the base container with background styling.
|
||||
*/
|
||||
export function DashboardLayoutWrapper({ children }: DashboardLayoutWrapperProps) {
|
||||
return (
|
||||
<div className="min-h-screen bg-deep-graphite">
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
21
apps/website/ui/DashboardRail.tsx
Normal file
21
apps/website/ui/DashboardRail.tsx
Normal file
@@ -0,0 +1,21 @@
|
||||
import React from 'react';
|
||||
|
||||
interface DashboardRailProps {
|
||||
children?: React.ReactNode;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* DashboardRail is the primary sidebar navigation for the "Telemetry Workspace".
|
||||
* It provides a high-density, instrument-grade navigation experience.
|
||||
* Aligned with "Precision Racing Minimal" theme.
|
||||
*/
|
||||
export function DashboardRail({ children, className = '' }: DashboardRailProps) {
|
||||
return (
|
||||
<aside
|
||||
className={`hidden lg:flex flex-col w-64 bg-[#141619] border-r border-[#23272B] overflow-y-auto ${className}`}
|
||||
>
|
||||
{children}
|
||||
</aside>
|
||||
);
|
||||
}
|
||||
@@ -1,182 +0,0 @@
|
||||
|
||||
|
||||
import { Box } from '@/ui/Box';
|
||||
import { Button } from '@/ui/Button';
|
||||
import { Container } from '@/ui/Container';
|
||||
import { Heading } from '@/ui/Heading';
|
||||
import { Glow } from '@/ui/Glow';
|
||||
import { Icon } from '@/ui/Icon';
|
||||
import { DiscordIcon } from '@/ui/icons/DiscordIcon';
|
||||
import { Stack } from '@/ui/Stack';
|
||||
import { Surface } from '@/ui/Surface';
|
||||
import { Text } from '@/ui/Text';
|
||||
import { Grid } from '@/ui/Grid';
|
||||
import { Code, Lightbulb, LucideIcon, MessageSquare, Users } from 'lucide-react';
|
||||
|
||||
export function DiscordCTA() {
|
||||
const discordUrl = process.env.NEXT_PUBLIC_DISCORD_URL || '#';
|
||||
|
||||
return (
|
||||
<Box
|
||||
as="section"
|
||||
bg="graphite-black"
|
||||
position="relative"
|
||||
py={{ base: 20, md: 32 }}
|
||||
borderBottom
|
||||
borderColor="border-gray/50"
|
||||
overflow="hidden"
|
||||
>
|
||||
<Glow color="primary" size="xl" position="center" opacity={0.05} />
|
||||
|
||||
<Container size="lg" position="relative" zIndex={10}>
|
||||
<Surface
|
||||
variant="default"
|
||||
padding={12}
|
||||
border
|
||||
rounded="none"
|
||||
position="relative"
|
||||
className="overflow-hidden bg-panel-gray/40"
|
||||
>
|
||||
{/* Discord brand accent */}
|
||||
<Box
|
||||
position="absolute"
|
||||
top={0}
|
||||
left={0}
|
||||
right={0}
|
||||
height="1"
|
||||
bg="primary-accent"
|
||||
/>
|
||||
|
||||
<Stack align="center" gap={12} center>
|
||||
{/* Header */}
|
||||
<Stack align="center" gap={6}>
|
||||
<Box
|
||||
display="flex"
|
||||
center
|
||||
rounded="none"
|
||||
w={{ base: "16", md: "20" }}
|
||||
h={{ base: "16", md: "20" }}
|
||||
bg="primary-accent/10"
|
||||
border
|
||||
borderColor="primary-accent/30"
|
||||
className="relative"
|
||||
>
|
||||
<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' }} className="tracking-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 <span className="text-white font-bold">solo developer project</span> 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"
|
||||
className="px-16 py-4"
|
||||
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>
|
||||
</Surface>
|
||||
</Container>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
function BenefitItem({ icon, title, description }: { icon: LucideIcon, title: string, description: string }) {
|
||||
return (
|
||||
<Surface
|
||||
variant="muted"
|
||||
border
|
||||
padding={6}
|
||||
rounded="none"
|
||||
display="flex"
|
||||
gap={5}
|
||||
className="items-start hover:border-primary-accent/30 transition-all bg-panel-gray/20 group"
|
||||
>
|
||||
<Box
|
||||
display="flex"
|
||||
center
|
||||
rounded="none"
|
||||
flexShrink={0}
|
||||
w="10"
|
||||
h="10"
|
||||
bg="primary-accent/5"
|
||||
border
|
||||
borderColor="border-gray/50"
|
||||
className="group-hover:border-primary-accent/30 transition-colors"
|
||||
>
|
||||
<Icon icon={icon} size={5} color="text-primary-accent" />
|
||||
</Box>
|
||||
<Stack gap={2}>
|
||||
<Text size="base" weight="bold" color="text-white" className="tracking-wide">{title}</Text>
|
||||
<Text size="sm" color="text-gray-400" leading="relaxed">{description}</Text>
|
||||
</Stack>
|
||||
</Surface>
|
||||
);
|
||||
}
|
||||
109
apps/website/ui/DriverHeaderPanel.tsx
Normal file
109
apps/website/ui/DriverHeaderPanel.tsx
Normal file
@@ -0,0 +1,109 @@
|
||||
import React from 'react';
|
||||
import { Box } from './Box';
|
||||
import { Text } from './Text';
|
||||
import { Stack } from './Stack';
|
||||
import { Image } from './Image';
|
||||
import { RatingBadge } from './RatingBadge';
|
||||
|
||||
interface DriverHeaderPanelProps {
|
||||
name: string;
|
||||
avatarUrl?: string;
|
||||
nationality: string;
|
||||
rating: number;
|
||||
globalRank?: number | null;
|
||||
bio?: string | null;
|
||||
actions?: React.ReactNode;
|
||||
}
|
||||
|
||||
export function DriverHeaderPanel({
|
||||
name,
|
||||
avatarUrl,
|
||||
nationality,
|
||||
rating,
|
||||
globalRank,
|
||||
bio,
|
||||
actions
|
||||
}: DriverHeaderPanelProps) {
|
||||
const defaultAvatar = 'https://cdn.gridpilot.com/avatars/default.png';
|
||||
|
||||
return (
|
||||
<Box
|
||||
bg="bg-panel-gray"
|
||||
rounded="xl"
|
||||
border
|
||||
borderColor="border-charcoal-outline"
|
||||
overflow="hidden"
|
||||
position="relative"
|
||||
>
|
||||
{/* Background Accent */}
|
||||
<Box
|
||||
position="absolute"
|
||||
top={0}
|
||||
left={0}
|
||||
right={0}
|
||||
height="24"
|
||||
bg="bg-gradient-to-r from-primary-blue/20 to-transparent"
|
||||
opacity={0.5}
|
||||
/>
|
||||
|
||||
<Box p={6} position="relative">
|
||||
<Stack direction={{ base: 'col', md: 'row' }} gap={6} align="start" className="md:items-center">
|
||||
{/* Avatar */}
|
||||
<Box
|
||||
width="32"
|
||||
height="32"
|
||||
rounded="2xl"
|
||||
overflow="hidden"
|
||||
border
|
||||
borderColor="border-charcoal-outline"
|
||||
bg="bg-graphite-black"
|
||||
flexShrink={0}
|
||||
>
|
||||
<Image
|
||||
src={avatarUrl || defaultAvatar}
|
||||
alt={name}
|
||||
fill
|
||||
objectFit="cover"
|
||||
/>
|
||||
</Box>
|
||||
|
||||
{/* Info */}
|
||||
<Box flexGrow={1}>
|
||||
<Stack gap={2}>
|
||||
<Stack direction="row" align="center" gap={3} wrap>
|
||||
<Text as="h1" size="3xl" weight="bold" color="text-white">
|
||||
{name}
|
||||
</Text>
|
||||
<RatingBadge rating={rating} size="lg" />
|
||||
</Stack>
|
||||
|
||||
<Stack direction="row" align="center" gap={4} wrap>
|
||||
<Text size="sm" color="text-gray-400">
|
||||
{nationality}
|
||||
</Text>
|
||||
{globalRank !== undefined && globalRank !== null && (
|
||||
<Text size="sm" color="text-gray-400">
|
||||
Global Rank: <Text color="text-warning-amber" weight="semibold">#{globalRank}</Text>
|
||||
</Text>
|
||||
)}
|
||||
</Stack>
|
||||
|
||||
{bio && (
|
||||
<Text size="sm" color="text-gray-400" className="max-w-2xl mt-2" lineClamp={2}>
|
||||
{bio}
|
||||
</Text>
|
||||
)}
|
||||
</Stack>
|
||||
</Box>
|
||||
|
||||
{/* Actions */}
|
||||
{actions && (
|
||||
<Box flexShrink={0}>
|
||||
{actions}
|
||||
</Box>
|
||||
)}
|
||||
</Stack>
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
@@ -55,7 +55,7 @@ export function DurationField({
|
||||
disabled={disabled}
|
||||
min={1}
|
||||
className="pr-16"
|
||||
error={!!error}
|
||||
variant={error ? 'error' : 'default'}
|
||||
/>
|
||||
</div>
|
||||
<span className="text-xs text-gray-400 -ml-14">{unitLabel}</span>
|
||||
|
||||
33
apps/website/ui/FormSection.tsx
Normal file
33
apps/website/ui/FormSection.tsx
Normal file
@@ -0,0 +1,33 @@
|
||||
import React, { ReactNode } from 'react';
|
||||
import { Stack } from './Stack';
|
||||
import { Text } from './Text';
|
||||
|
||||
interface FormSectionProps {
|
||||
children: ReactNode;
|
||||
title?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* FormSection
|
||||
*
|
||||
* Groups related form fields with an optional title.
|
||||
*/
|
||||
export function FormSection({ children, title }: FormSectionProps) {
|
||||
return (
|
||||
<Stack gap={4} fullWidth>
|
||||
{title && (
|
||||
<Text
|
||||
size="xs"
|
||||
weight="bold"
|
||||
color="text-gray-500"
|
||||
className="uppercase tracking-widest border-b border-border-gray pb-1"
|
||||
>
|
||||
{title}
|
||||
</Text>
|
||||
)}
|
||||
<Stack gap={4} fullWidth>
|
||||
{children}
|
||||
</Stack>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
@@ -13,6 +13,8 @@ interface IconButtonProps {
|
||||
title?: string;
|
||||
disabled?: boolean;
|
||||
color?: string;
|
||||
className?: string;
|
||||
backgroundColor?: string;
|
||||
}
|
||||
|
||||
export function IconButton({
|
||||
@@ -23,6 +25,8 @@ export function IconButton({
|
||||
title,
|
||||
disabled,
|
||||
color,
|
||||
className = '',
|
||||
backgroundColor,
|
||||
}: IconButtonProps) {
|
||||
const sizeMap = {
|
||||
sm: { btn: 'w-8 h-8 p-0', icon: 4 },
|
||||
@@ -36,7 +40,8 @@ export function IconButton({
|
||||
onClick={onClick}
|
||||
title={title}
|
||||
disabled={disabled}
|
||||
className={`${sizeMap[size].btn} rounded-full flex items-center justify-center min-h-0`}
|
||||
className={`${sizeMap[size].btn} rounded-full flex items-center justify-center min-h-0 ${className}`}
|
||||
backgroundColor={backgroundColor}
|
||||
>
|
||||
<Icon icon={icon} size={sizeMap[size].icon} color={color} />
|
||||
</Button>
|
||||
|
||||
86
apps/website/ui/ImagePlaceholder.tsx
Normal file
86
apps/website/ui/ImagePlaceholder.tsx
Normal file
@@ -0,0 +1,86 @@
|
||||
import React from 'react';
|
||||
import { Image as ImageIcon, AlertCircle, Loader2 } from 'lucide-react';
|
||||
import { Box } from './Box';
|
||||
import { Icon } from './Icon';
|
||||
import { Text } from './Text';
|
||||
|
||||
export interface ImagePlaceholderProps {
|
||||
size?: number | string;
|
||||
aspectRatio?: string;
|
||||
variant?: 'default' | 'error' | 'loading';
|
||||
message?: string;
|
||||
className?: string;
|
||||
rounded?: 'none' | 'sm' | 'md' | 'lg' | 'xl' | '2xl' | 'full';
|
||||
}
|
||||
|
||||
export function ImagePlaceholder({
|
||||
size = 'full',
|
||||
aspectRatio = '1/1',
|
||||
variant = 'default',
|
||||
message,
|
||||
className = '',
|
||||
rounded = 'md',
|
||||
}: ImagePlaceholderProps) {
|
||||
const config = {
|
||||
default: {
|
||||
icon: ImageIcon,
|
||||
color: 'text-gray-500',
|
||||
bg: 'bg-charcoal-outline/20',
|
||||
borderColor: 'border-charcoal-outline/50',
|
||||
animate: undefined as 'spin' | 'pulse' | 'bounce' | 'fade-in' | 'none' | undefined,
|
||||
},
|
||||
error: {
|
||||
icon: AlertCircle,
|
||||
color: 'text-amber-500',
|
||||
bg: 'bg-amber-500/5',
|
||||
borderColor: 'border-amber-500/20',
|
||||
animate: undefined as 'spin' | 'pulse' | 'bounce' | 'fade-in' | 'none' | undefined,
|
||||
},
|
||||
loading: {
|
||||
icon: Loader2,
|
||||
color: 'text-blue-500',
|
||||
bg: 'bg-blue-500/5',
|
||||
borderColor: 'border-blue-500/20',
|
||||
animate: 'spin' as const,
|
||||
},
|
||||
};
|
||||
|
||||
const { icon, color, bg, borderColor, animate } = config[variant];
|
||||
|
||||
return (
|
||||
<Box
|
||||
display="flex"
|
||||
flexDirection="col"
|
||||
alignItems="center"
|
||||
justifyContent="center"
|
||||
w={typeof size === 'string' ? size : undefined}
|
||||
h={typeof size === 'string' ? size : undefined}
|
||||
style={typeof size === 'number' ? { width: size, height: size } : { aspectRatio }}
|
||||
bg={bg}
|
||||
border
|
||||
borderColor={borderColor}
|
||||
rounded={rounded}
|
||||
className={`overflow-hidden ${className}`}
|
||||
gap={2}
|
||||
p={4}
|
||||
>
|
||||
<Icon
|
||||
icon={icon}
|
||||
size={6}
|
||||
color={color}
|
||||
animate={animate}
|
||||
/>
|
||||
{message && (
|
||||
<Text
|
||||
size="xs"
|
||||
color={color}
|
||||
weight="medium"
|
||||
align="center"
|
||||
className="max-w-[80%]"
|
||||
>
|
||||
{message}
|
||||
</Text>
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
88
apps/website/ui/JoinRequestsPanel.tsx
Normal file
88
apps/website/ui/JoinRequestsPanel.tsx
Normal file
@@ -0,0 +1,88 @@
|
||||
import React from 'react';
|
||||
import { Box } from './Box';
|
||||
import { Stack } from './Stack';
|
||||
import { Text } from './Text';
|
||||
import { Heading } from './Heading';
|
||||
import { Button } from './Button';
|
||||
import { Check, X, Clock } from 'lucide-react';
|
||||
import { Icon } from './Icon';
|
||||
|
||||
interface JoinRequestsPanelProps {
|
||||
requests: Array<{
|
||||
id: string;
|
||||
driverName: string;
|
||||
driverAvatar?: string;
|
||||
message?: string;
|
||||
requestedAt: string;
|
||||
}>;
|
||||
onAccept: (id: string) => void;
|
||||
onDecline: (id: string) => void;
|
||||
}
|
||||
|
||||
export function JoinRequestsPanel({ requests, onAccept, onDecline }: JoinRequestsPanelProps) {
|
||||
if (requests.length === 0) {
|
||||
return (
|
||||
<Box p={8} border borderDash borderColor="border-steel-grey" bg="surface-charcoal/20" textAlign="center">
|
||||
<Text color="text-gray-500" size="sm">No pending join requests</Text>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Box border borderColor="border-steel-grey" bg="surface-charcoal/50" overflow="hidden">
|
||||
<Box p={4} borderBottom borderColor="border-steel-grey" bg="base-graphite/50">
|
||||
<Heading level={4} weight="bold" className="uppercase tracking-widest text-gray-400 text-[10px]">
|
||||
Pending Requests ({requests.length})
|
||||
</Heading>
|
||||
</Box>
|
||||
<Stack gap={0} className="divide-y divide-border-steel-grey/30">
|
||||
{requests.map((request) => (
|
||||
<Box key={request.id} p={4} className="hover:bg-white/[0.02] transition-colors">
|
||||
<Stack direction="row" align="start" justify="between" gap={4}>
|
||||
<Stack direction="row" align="center" gap={3}>
|
||||
<Box w="10" h="10" bg="base-graphite" border borderColor="border-steel-grey" display="flex" center>
|
||||
<Text size="xs" weight="bold" color="text-primary-blue">
|
||||
{request.driverName.substring(0, 2).toUpperCase()}
|
||||
</Text>
|
||||
</Box>
|
||||
<Box>
|
||||
<Text weight="bold" size="sm" color="text-white" block>{request.driverName}</Text>
|
||||
<Stack direction="row" align="center" gap={1.5} mt={0.5}>
|
||||
<Icon icon={Clock} size={3} color="text-gray-500" />
|
||||
<Text size="xs" color="text-gray-500" font="mono">{request.requestedAt}</Text>
|
||||
</Stack>
|
||||
</Box>
|
||||
</Stack>
|
||||
|
||||
<Stack direction="row" align="center" gap={2}>
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
onClick={() => onDecline(request.id)}
|
||||
className="h-8 w-8 p-0 flex items-center justify-center border-red-500/30 hover:bg-red-500/10"
|
||||
>
|
||||
<Icon icon={X} size={4} color="text-red-400" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="primary"
|
||||
size="sm"
|
||||
onClick={() => onAccept(request.id)}
|
||||
className="h-8 w-8 p-0 flex items-center justify-center"
|
||||
>
|
||||
<Icon icon={Check} size={4} />
|
||||
</Button>
|
||||
</Stack>
|
||||
</Stack>
|
||||
{request.message && (
|
||||
<Box mt={3} p={3} bg="base-graphite/30" borderLeft borderPrimary borderColor="primary-blue/40">
|
||||
<Text size="xs" color="text-gray-400" italic leading="relaxed">
|
||||
“{request.message}”
|
||||
</Text>
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
))}
|
||||
</Stack>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
@@ -6,10 +6,10 @@ import { Box } from '@/ui/Box';
|
||||
|
||||
export function FeatureItem({ text }: { text: string }) {
|
||||
return (
|
||||
<Surface variant="muted" rounded="none" border padding={4} bg="panel-gray/40" borderColor="border-gray/50" className="group hover:border-primary-accent/30 transition-colors">
|
||||
<Surface variant="muted" rounded="none" border padding={4} bg="panel-gray/10" borderColor="border-gray/10" className="group hover:border-primary-accent/20 transition-colors">
|
||||
<Stack direction="row" align="center" gap={4}>
|
||||
<Box w="1" h="4" bg="primary-accent" className="group-hover:h-6 transition-all" />
|
||||
<Text color="text-gray-300" leading="relaxed" weight="normal" size="sm" className="tracking-wide">
|
||||
<Box w="0.5" h="3" bg="primary-accent" className="group-hover:h-5 transition-all" />
|
||||
<Text color="text-gray-500" leading="relaxed" weight="normal" size="sm" className="tracking-wide group-hover:text-gray-300 transition-colors">
|
||||
{text}
|
||||
</Text>
|
||||
</Stack>
|
||||
@@ -19,10 +19,10 @@ export function FeatureItem({ text }: { text: string }) {
|
||||
|
||||
export function ResultItem({ text, color }: { text: string, color: string }) {
|
||||
return (
|
||||
<Surface variant="muted" rounded="none" border padding={4} bg="panel-gray/40" borderColor="border-gray/50" className="group hover:border-primary-accent/30 transition-colors">
|
||||
<Surface variant="muted" rounded="none" border padding={4} bg="panel-gray/10" borderColor="border-gray/10" className="group hover:border-primary-accent/20 transition-colors">
|
||||
<Stack direction="row" align="center" gap={4}>
|
||||
<Box w="1" h="4" style={{ backgroundColor: color }} className="group-hover:h-6 transition-all" />
|
||||
<Text color="text-gray-300" leading="relaxed" weight="normal" size="sm" className="tracking-wide">
|
||||
<Box w="0.5" h="3" style={{ backgroundColor: color }} className="group-hover:h-5 transition-all" />
|
||||
<Text color="text-gray-500" leading="relaxed" weight="normal" size="sm" className="tracking-wide group-hover:text-gray-300 transition-colors">
|
||||
{text}
|
||||
</Text>
|
||||
</Stack>
|
||||
@@ -32,12 +32,12 @@ export function ResultItem({ text, color }: { text: string, color: string }) {
|
||||
|
||||
export function StepItem({ step, text }: { step: number, text: string }) {
|
||||
return (
|
||||
<Surface variant="muted" rounded="none" border padding={4} bg="panel-gray/40" borderColor="border-gray/50" className="group hover:border-primary-accent/30 transition-colors">
|
||||
<Surface variant="muted" rounded="none" border padding={4} bg="panel-gray/10" borderColor="border-gray/10" className="group hover:border-primary-accent/20 transition-colors">
|
||||
<Stack direction="row" align="center" gap={4}>
|
||||
<Box w="8" h="8" display="flex" center border borderColor="border-gray" className="group-hover:border-primary-accent/50 transition-colors">
|
||||
<Text weight="bold" size="xs" color="text-primary-accent" font="mono">{step.toString().padStart(2, '0')}</Text>
|
||||
<Box w="6" h="6" display="flex" center border borderColor="border-gray/20" className="group-hover:border-primary-accent/30 transition-colors">
|
||||
<Text weight="bold" size="xs" color="text-primary-accent" font="mono" opacity={0.7}>{step.toString().padStart(2, '0')}</Text>
|
||||
</Box>
|
||||
<Text color="text-gray-300" leading="relaxed" weight="normal" size="sm" className="tracking-wide">
|
||||
<Text color="text-gray-500" leading="relaxed" weight="normal" size="sm" className="tracking-wide group-hover:text-gray-300 transition-colors">
|
||||
{text}
|
||||
</Text>
|
||||
</Stack>
|
||||
|
||||
49
apps/website/ui/LeaderboardTableShell.tsx
Normal file
49
apps/website/ui/LeaderboardTableShell.tsx
Normal file
@@ -0,0 +1,49 @@
|
||||
import React from 'react';
|
||||
import { Box } from './Box';
|
||||
|
||||
interface LeaderboardTableShellProps {
|
||||
columns: {
|
||||
key: string;
|
||||
label: string;
|
||||
align?: 'left' | 'center' | 'right';
|
||||
width?: string;
|
||||
}[];
|
||||
children: React.ReactNode;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function LeaderboardTableShell({ columns, children, className = '' }: LeaderboardTableShellProps) {
|
||||
return (
|
||||
<Box
|
||||
rounded="xl"
|
||||
bg="bg-iron-gray/30"
|
||||
border
|
||||
borderColor="border-charcoal-outline"
|
||||
overflow="hidden"
|
||||
className={className}
|
||||
>
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-left border-collapse">
|
||||
<thead>
|
||||
<tr className="border-b border-charcoal-outline/50 bg-graphite-black/50">
|
||||
{columns.map((col) => (
|
||||
<th
|
||||
key={col.key}
|
||||
className={`px-4 py-3 text-[10px] uppercase tracking-widest font-bold text-gray-500 ${
|
||||
col.align === 'center' ? 'text-center' : col.align === 'right' ? 'text-right' : 'text-left'
|
||||
}`}
|
||||
style={col.width ? { width: col.width } : {}}
|
||||
>
|
||||
{col.label}
|
||||
</th>
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-charcoal-outline/30">
|
||||
{children}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
@@ -1,20 +1,45 @@
|
||||
|
||||
|
||||
import React from 'react';
|
||||
import { Box } from './Box';
|
||||
import { Image } from './Image';
|
||||
import { ImagePlaceholder } from './ImagePlaceholder';
|
||||
|
||||
export interface LeagueCoverProps {
|
||||
leagueId: string;
|
||||
leagueId?: string;
|
||||
src?: string;
|
||||
alt: string;
|
||||
height?: string;
|
||||
aspectRatio?: string;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function LeagueCover({ leagueId, alt, height = '12rem' }: LeagueCoverProps) {
|
||||
export function LeagueCover({
|
||||
leagueId,
|
||||
src,
|
||||
alt,
|
||||
height,
|
||||
aspectRatio = '21/9',
|
||||
className = '',
|
||||
}: LeagueCoverProps) {
|
||||
const coverSrc = src || (leagueId ? `/media/leagues/${leagueId}/cover` : undefined);
|
||||
|
||||
return (
|
||||
<Image
|
||||
src={`/media/leagues/${leagueId}/cover`}
|
||||
alt={alt}
|
||||
style={{ width: '100%', height, objectFit: 'cover' }}
|
||||
fallbackSrc="/default-league-cover.png"
|
||||
/>
|
||||
<Box
|
||||
width="full"
|
||||
overflow="hidden"
|
||||
bg="bg-charcoal-outline/10"
|
||||
className={className}
|
||||
style={{ height, aspectRatio: height ? undefined : aspectRatio }}
|
||||
>
|
||||
{coverSrc ? (
|
||||
<Image
|
||||
src={coverSrc}
|
||||
alt={alt}
|
||||
className="w-full h-full object-cover"
|
||||
fallbackSrc="/default-league-cover.png"
|
||||
/>
|
||||
) : (
|
||||
<ImagePlaceholder aspectRatio={aspectRatio} rounded="none" />
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,22 +1,53 @@
|
||||
|
||||
|
||||
import React from 'react';
|
||||
import { Box } from './Box';
|
||||
import { Image } from './Image';
|
||||
import { Trophy } from 'lucide-react';
|
||||
import { Icon } from './Icon';
|
||||
|
||||
export interface LeagueLogoProps {
|
||||
leagueId: string;
|
||||
leagueId?: string;
|
||||
src?: string;
|
||||
alt: string;
|
||||
size?: number;
|
||||
className?: string;
|
||||
border?: boolean;
|
||||
rounded?: 'none' | 'sm' | 'md' | 'lg' | 'xl' | 'full';
|
||||
}
|
||||
|
||||
export function LeagueLogo({ leagueId, alt, size = 100 }: LeagueLogoProps) {
|
||||
export function LeagueLogo({
|
||||
leagueId,
|
||||
src,
|
||||
alt,
|
||||
size = 64,
|
||||
className = '',
|
||||
border = true,
|
||||
rounded = 'md',
|
||||
}: LeagueLogoProps) {
|
||||
const logoSrc = src || (leagueId ? `/media/leagues/${leagueId}/logo` : undefined);
|
||||
|
||||
return (
|
||||
<Image
|
||||
src={`/media/leagues/${leagueId}/logo`}
|
||||
alt={alt}
|
||||
width={size}
|
||||
height={size}
|
||||
style={{ objectFit: 'contain' }}
|
||||
fallbackSrc="/default-league-logo.png"
|
||||
/>
|
||||
<Box
|
||||
display="flex"
|
||||
alignItems="center"
|
||||
justifyContent="center"
|
||||
rounded={rounded}
|
||||
overflow="hidden"
|
||||
bg="bg-charcoal-outline/10"
|
||||
border={border}
|
||||
borderColor="border-charcoal-outline/50"
|
||||
className={className}
|
||||
style={{ width: size, height: size, flexShrink: 0 }}
|
||||
>
|
||||
{logoSrc ? (
|
||||
<Image
|
||||
src={logoSrc}
|
||||
alt={alt}
|
||||
className="w-full h-full object-contain p-1"
|
||||
fallbackSrc="/default-league-logo.png"
|
||||
/>
|
||||
) : (
|
||||
<Icon icon={Trophy} size={size > 32 ? 5 : 4} color="text-gray-500" />
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -7,7 +7,7 @@ import { Card } from './Card';
|
||||
import { Grid } from './Grid';
|
||||
import { Heading } from './Heading';
|
||||
import { Icon } from './Icon';
|
||||
import { Image } from './Image';
|
||||
import { LeagueLogo } from './LeagueLogo';
|
||||
import { Link } from './Link';
|
||||
import { Stack } from './Stack';
|
||||
import { Surface } from './Surface';
|
||||
@@ -34,22 +34,7 @@ export function LeagueSummaryCard({
|
||||
<Card p={0} style={{ overflow: 'hidden' }}>
|
||||
<Box p={4}>
|
||||
<Stack direction="row" align="center" gap={4} mb={4}>
|
||||
<Box
|
||||
w="14"
|
||||
h="14"
|
||||
rounded="lg"
|
||||
overflow="hidden"
|
||||
bg="bg-deep-graphite"
|
||||
flexShrink={0}
|
||||
>
|
||||
<Image
|
||||
src={`/media/league-logo/${id}`}
|
||||
alt={name}
|
||||
width={56}
|
||||
height={56}
|
||||
style={{ width: '100%', height: '100%', objectFit: 'cover' }}
|
||||
/>
|
||||
</Box>
|
||||
<LeagueLogo leagueId={id} alt={name} size={56} />
|
||||
<Box style={{ flex: 1, minWidth: 0 }}>
|
||||
<Text
|
||||
size="xs"
|
||||
|
||||
95
apps/website/ui/MediaMetaPanel.tsx
Normal file
95
apps/website/ui/MediaMetaPanel.tsx
Normal file
@@ -0,0 +1,95 @@
|
||||
import React from 'react';
|
||||
import { Info, FileText, Maximize2, Type, Calendar, LucideIcon } from 'lucide-react';
|
||||
import { Box } from './Box';
|
||||
import { Text } from './Text';
|
||||
import { Icon } from './Icon';
|
||||
|
||||
export interface MediaMetaItem {
|
||||
label: string;
|
||||
value: string | number;
|
||||
icon?: LucideIcon;
|
||||
}
|
||||
|
||||
export interface MediaMetaPanelProps {
|
||||
title?: string;
|
||||
items: MediaMetaItem[];
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function MediaMetaPanel({
|
||||
title = 'Asset Details',
|
||||
items,
|
||||
className = '',
|
||||
}: MediaMetaPanelProps) {
|
||||
return (
|
||||
<Box
|
||||
bg="bg-charcoal-outline/10"
|
||||
border
|
||||
borderColor="border-charcoal-outline/50"
|
||||
rounded="lg"
|
||||
p={4}
|
||||
className={className}
|
||||
>
|
||||
<Box display="flex" alignItems="center" gap={2} mb={4}>
|
||||
<Icon icon={Info} size={4} color="text-blue-500" />
|
||||
<Text weight="semibold" size="sm" uppercase letterSpacing="wider">
|
||||
{title}
|
||||
</Text>
|
||||
</Box>
|
||||
|
||||
<Box display="flex" flexDirection="col" gap={3}>
|
||||
{items.map((item, index) => (
|
||||
<Box
|
||||
key={`${item.label}-${index}`}
|
||||
display="flex"
|
||||
alignItems="center"
|
||||
justifyContent="between"
|
||||
pb={index === items.length - 1 ? 0 : 3}
|
||||
borderBottom={index === items.length - 1 ? false : true}
|
||||
borderColor="border-charcoal-outline/20"
|
||||
>
|
||||
<Box display="flex" alignItems="center" gap={2}>
|
||||
{item.icon && <Icon icon={item.icon} size={3.5} color="text-gray-500" />}
|
||||
<Text size="xs" color="text-gray-500" weight="medium">
|
||||
{item.label}
|
||||
</Text>
|
||||
</Box>
|
||||
<Text size="xs" weight="semibold" font="mono">
|
||||
{item.value}
|
||||
</Text>
|
||||
</Box>
|
||||
))}
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
// Helper to map common media metadata
|
||||
export const mapMediaMetadata = (metadata: {
|
||||
filename?: string;
|
||||
size?: number;
|
||||
dimensions?: string;
|
||||
contentType?: string;
|
||||
createdAt?: string | Date;
|
||||
}): MediaMetaItem[] => {
|
||||
const items: MediaMetaItem[] = [];
|
||||
|
||||
if (metadata.filename) {
|
||||
items.push({ label: 'Filename', value: metadata.filename, icon: FileText });
|
||||
}
|
||||
if (metadata.size) {
|
||||
items.push({ label: 'Size', value: `${Math.round(metadata.size / 1024)} KB`, icon: Maximize2 });
|
||||
}
|
||||
if (metadata.dimensions) {
|
||||
items.push({ label: 'Dimensions', value: metadata.dimensions, icon: Maximize2 });
|
||||
}
|
||||
if (metadata.contentType) {
|
||||
items.push({ label: 'Type', value: metadata.contentType, icon: Type });
|
||||
}
|
||||
if (metadata.createdAt) {
|
||||
const date = typeof metadata.createdAt === 'string' ? new Date(metadata.createdAt) : metadata.createdAt;
|
||||
items.push({ label: 'Created', value: date.toLocaleDateString(), icon: Calendar });
|
||||
}
|
||||
|
||||
return items;
|
||||
};
|
||||
93
apps/website/ui/MediaPreviewCard.tsx
Normal file
93
apps/website/ui/MediaPreviewCard.tsx
Normal file
@@ -0,0 +1,93 @@
|
||||
import React from 'react';
|
||||
import { Box } from './Box';
|
||||
import { Text } from './Text';
|
||||
import { ImagePlaceholder } from './ImagePlaceholder';
|
||||
import { Image } from './Image';
|
||||
|
||||
export interface MediaPreviewCardProps {
|
||||
src?: string;
|
||||
alt?: string;
|
||||
title?: string;
|
||||
subtitle?: string;
|
||||
aspectRatio?: string;
|
||||
isLoading?: boolean;
|
||||
error?: string;
|
||||
onClick?: () => void;
|
||||
className?: string;
|
||||
actions?: React.ReactNode;
|
||||
}
|
||||
|
||||
export function MediaPreviewCard({
|
||||
src,
|
||||
alt = 'Media preview',
|
||||
title,
|
||||
subtitle,
|
||||
aspectRatio = '16/9',
|
||||
isLoading,
|
||||
error,
|
||||
onClick,
|
||||
className = '',
|
||||
actions,
|
||||
}: MediaPreviewCardProps) {
|
||||
return (
|
||||
<Box
|
||||
display="flex"
|
||||
flexDirection="col"
|
||||
bg="bg-charcoal-outline/10"
|
||||
border
|
||||
borderColor="border-charcoal-outline/50"
|
||||
rounded="lg"
|
||||
overflow="hidden"
|
||||
transition
|
||||
hoverScale={!!onClick}
|
||||
cursor={onClick ? 'pointer' : 'default'}
|
||||
onClick={onClick}
|
||||
className={`group ${className}`}
|
||||
>
|
||||
<Box position="relative" width="full" style={{ aspectRatio }}>
|
||||
{isLoading ? (
|
||||
<ImagePlaceholder variant="loading" aspectRatio={aspectRatio} rounded="none" />
|
||||
) : error ? (
|
||||
<ImagePlaceholder variant="error" message={error} aspectRatio={aspectRatio} rounded="none" />
|
||||
) : src ? (
|
||||
<Image
|
||||
src={src}
|
||||
alt={alt}
|
||||
className="w-full h-full object-cover"
|
||||
/>
|
||||
) : (
|
||||
<ImagePlaceholder aspectRatio={aspectRatio} rounded="none" />
|
||||
)}
|
||||
|
||||
{actions && (
|
||||
<Box
|
||||
position="absolute"
|
||||
top={2}
|
||||
right={2}
|
||||
display="flex"
|
||||
gap={2}
|
||||
opacity={0}
|
||||
className="group-hover:opacity-100 transition-opacity"
|
||||
>
|
||||
{actions}
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
|
||||
{(title || subtitle) && (
|
||||
<Box p={3} borderTop borderColor="border-charcoal-outline/30">
|
||||
{title && (
|
||||
<Text block size="sm" weight="semibold" truncate>
|
||||
{title}
|
||||
</Text>
|
||||
)}
|
||||
{subtitle && (
|
||||
<Text block size="xs" color="text-gray-500" truncate mt={0.5}>
|
||||
{subtitle}
|
||||
</Text>
|
||||
)}
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
64
apps/website/ui/MetricCard.tsx
Normal file
64
apps/website/ui/MetricCard.tsx
Normal file
@@ -0,0 +1,64 @@
|
||||
import React from 'react';
|
||||
import { LucideIcon } from 'lucide-react';
|
||||
import { Box } from './Box';
|
||||
import { Text } from './Text';
|
||||
import { Icon } from './Icon';
|
||||
|
||||
interface MetricCardProps {
|
||||
label: string;
|
||||
value: string | number;
|
||||
icon?: LucideIcon;
|
||||
color?: string;
|
||||
trend?: {
|
||||
value: number;
|
||||
isPositive: boolean;
|
||||
};
|
||||
border?: boolean;
|
||||
bg?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* A semantic component for displaying metrics.
|
||||
* Instrument-grade typography and dense-but-readable hierarchy.
|
||||
*/
|
||||
export function MetricCard({
|
||||
label,
|
||||
value,
|
||||
icon,
|
||||
color = 'text-primary-accent',
|
||||
trend,
|
||||
border = true,
|
||||
bg = 'panel-gray/40',
|
||||
}: MetricCardProps) {
|
||||
return (
|
||||
<Box
|
||||
bg={bg}
|
||||
rounded="none"
|
||||
p={4}
|
||||
border={border}
|
||||
borderColor="border-gray/30"
|
||||
display="flex"
|
||||
flexDirection="col"
|
||||
gap={2}
|
||||
hoverBg="panel-gray/60"
|
||||
transition
|
||||
>
|
||||
<Box display="flex" alignItems="center" justifyContent="between">
|
||||
<Box display="flex" alignItems="center" gap={2}>
|
||||
{icon && <Icon icon={icon} size={4} className={color} />}
|
||||
<Text size="xs" weight="bold" color="text-gray-500" uppercase letterSpacing="widest">
|
||||
{label}
|
||||
</Text>
|
||||
</Box>
|
||||
{trend && (
|
||||
<Text size="xs" color={trend.isPositive ? 'text-success-green' : 'text-red-400'} font="mono">
|
||||
{trend.isPositive ? '▲' : '▼'} {trend.value}%
|
||||
</Text>
|
||||
)}
|
||||
</Box>
|
||||
<Text size="2xl" weight="bold" color="text-white" font="mono">
|
||||
{typeof value === 'number' ? value.toLocaleString() : value}
|
||||
</Text>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
59
apps/website/ui/OnboardingCTA.tsx
Normal file
59
apps/website/ui/OnboardingCTA.tsx
Normal file
@@ -0,0 +1,59 @@
|
||||
import { Button } from '@/ui/Button';
|
||||
import { Stack } from '@/ui/Stack';
|
||||
|
||||
interface OnboardingCTAProps {
|
||||
onBack?: () => void;
|
||||
onNext?: () => void;
|
||||
nextLabel?: string;
|
||||
backLabel?: string;
|
||||
isLastStep?: boolean;
|
||||
canNext?: boolean;
|
||||
isLoading?: boolean;
|
||||
type?: 'button' | 'submit';
|
||||
}
|
||||
|
||||
export function OnboardingCTA({
|
||||
onBack,
|
||||
onNext,
|
||||
nextLabel = 'Continue',
|
||||
backLabel = 'Back',
|
||||
isLastStep = false,
|
||||
canNext = true,
|
||||
isLoading = false,
|
||||
type = 'button',
|
||||
}: OnboardingCTAProps) {
|
||||
return (
|
||||
<Stack direction="row" justify="between" mt={8} gap={4}>
|
||||
{onBack ? (
|
||||
<Button
|
||||
type="button"
|
||||
variant="secondary"
|
||||
onClick={onBack}
|
||||
disabled={isLoading}
|
||||
className="px-8"
|
||||
>
|
||||
{backLabel}
|
||||
</Button>
|
||||
) : (
|
||||
<div />
|
||||
)}
|
||||
|
||||
<Button
|
||||
type={type}
|
||||
variant="primary"
|
||||
onClick={onNext}
|
||||
disabled={isLoading || !canNext}
|
||||
className="px-8 min-w-[140px]"
|
||||
>
|
||||
{isLoading ? (
|
||||
<Stack direction="row" gap={2} align="center">
|
||||
<span className="animate-spin">⟳</span>
|
||||
<span>Processing...</span>
|
||||
</Stack>
|
||||
) : (
|
||||
isLastStep ? 'Complete Setup' : nextLabel
|
||||
)}
|
||||
</Button>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
22
apps/website/ui/OnboardingStepHeader.tsx
Normal file
22
apps/website/ui/OnboardingStepHeader.tsx
Normal file
@@ -0,0 +1,22 @@
|
||||
import { Stack } from '@/ui/Stack';
|
||||
import { Text } from '@/ui/Text';
|
||||
|
||||
interface OnboardingStepHeaderProps {
|
||||
title: string;
|
||||
description?: string;
|
||||
}
|
||||
|
||||
export function OnboardingStepHeader({ title, description }: OnboardingStepHeaderProps) {
|
||||
return (
|
||||
<Stack gap={1} mb={6}>
|
||||
<Text size="2xl" color="text-white" weight="bold" className="tracking-tight" block>
|
||||
{title}
|
||||
</Text>
|
||||
{description && (
|
||||
<Text size="sm" color="text-gray-400" block>
|
||||
{description}
|
||||
</Text>
|
||||
)}
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
20
apps/website/ui/OnboardingStepPanel.tsx
Normal file
20
apps/website/ui/OnboardingStepPanel.tsx
Normal file
@@ -0,0 +1,20 @@
|
||||
import { Surface } from '@/ui/Surface';
|
||||
|
||||
interface OnboardingStepPanelProps {
|
||||
children: React.ReactNode;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function OnboardingStepPanel({ children, className = '' }: OnboardingStepPanelProps) {
|
||||
return (
|
||||
<Surface
|
||||
variant="dark"
|
||||
rounded="xl"
|
||||
border
|
||||
padding={6}
|
||||
className={`border-charcoal-outline ${className}`}
|
||||
>
|
||||
{children}
|
||||
</Surface>
|
||||
);
|
||||
}
|
||||
57
apps/website/ui/Panel.tsx
Normal file
57
apps/website/ui/Panel.tsx
Normal file
@@ -0,0 +1,57 @@
|
||||
import React, { ReactNode } from 'react';
|
||||
import { Surface } from './Surface';
|
||||
import { Box, BoxProps } from './Box';
|
||||
import { Text } from './Text';
|
||||
|
||||
interface PanelProps extends Omit<BoxProps<'div'>, 'variant' | 'padding'> {
|
||||
children: ReactNode;
|
||||
title?: string;
|
||||
description?: string;
|
||||
variant?: 'default' | 'muted' | 'dark' | 'glass';
|
||||
padding?: 0 | 1 | 2 | 3 | 4 | 6 | 8 | 10 | 12;
|
||||
border?: boolean;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* A semantic wrapper for content panels.
|
||||
* Follows the "Precision Racing Minimal" theme.
|
||||
*/
|
||||
export function Panel({
|
||||
children,
|
||||
title,
|
||||
description,
|
||||
variant = 'default',
|
||||
padding = 6,
|
||||
border = true,
|
||||
...props
|
||||
}: PanelProps) {
|
||||
return (
|
||||
<Surface
|
||||
variant={variant}
|
||||
padding={padding}
|
||||
border={border}
|
||||
display="flex"
|
||||
flexDirection="col"
|
||||
gap={4}
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
{...(props as any)}
|
||||
>
|
||||
{(title || description) && (
|
||||
<Box display="flex" flexDirection="col" gap={1} borderBottom borderStyle="solid" borderColor="border-gray/30" pb={4} mb={2}>
|
||||
{title && (
|
||||
<Text as="h3" size="xs" weight="bold" color="text-gray-500" uppercase letterSpacing="widest">
|
||||
{title}
|
||||
</Text>
|
||||
)}
|
||||
{description && (
|
||||
<Text size="sm" color="text-gray-400">
|
||||
{description}
|
||||
</Text>
|
||||
)}
|
||||
</Box>
|
||||
)}
|
||||
{children}
|
||||
</Surface>
|
||||
);
|
||||
}
|
||||
42
apps/website/ui/PasswordField.tsx
Normal file
42
apps/website/ui/PasswordField.tsx
Normal file
@@ -0,0 +1,42 @@
|
||||
import React, { ComponentProps } from 'react';
|
||||
import { Eye, EyeOff, Lock } from 'lucide-react';
|
||||
import { Input } from './Input';
|
||||
import { Box } from './Box';
|
||||
|
||||
interface PasswordFieldProps extends ComponentProps<typeof Input> {
|
||||
showPassword?: boolean;
|
||||
onTogglePassword?: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* PasswordField
|
||||
*
|
||||
* A specialized input for passwords with visibility toggling.
|
||||
* Stateless UI component.
|
||||
*/
|
||||
export function PasswordField({ showPassword, onTogglePassword, ...props }: PasswordFieldProps) {
|
||||
return (
|
||||
<Box position="relative" fullWidth>
|
||||
<Input
|
||||
{...props}
|
||||
type={showPassword ? 'text' : 'password'}
|
||||
icon={<Lock size={16} />}
|
||||
/>
|
||||
{onTogglePassword && (
|
||||
<Box
|
||||
as="button"
|
||||
type="button"
|
||||
onClick={onTogglePassword}
|
||||
position="absolute"
|
||||
right="3"
|
||||
top={props.label ? "34px" : "50%"}
|
||||
style={props.label ? {} : { transform: 'translateY(-50%)' }}
|
||||
zIndex={10}
|
||||
className="text-gray-500 hover:text-gray-300 transition-colors"
|
||||
>
|
||||
{showPassword ? <EyeOff size={16} /> : <Eye size={16} />}
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
@@ -1,9 +0,0 @@
|
||||
import type { ReactNode } from 'react';
|
||||
|
||||
interface ProfileLayoutShellProps {
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
export function ProfileLayoutShell({ children }: ProfileLayoutShellProps) {
|
||||
return <div className="min-h-screen bg-deep-graphite">{children}</div>;
|
||||
}
|
||||
46
apps/website/ui/QuickActionsPanel.tsx
Normal file
46
apps/website/ui/QuickActionsPanel.tsx
Normal file
@@ -0,0 +1,46 @@
|
||||
import React from 'react';
|
||||
import { Panel } from './Panel';
|
||||
import { Box } from './Box';
|
||||
import { Button } from './Button';
|
||||
import { Icon } from './Icon';
|
||||
import { LucideIcon } from 'lucide-react';
|
||||
|
||||
interface QuickAction {
|
||||
label: string;
|
||||
icon: LucideIcon;
|
||||
onClick: () => void;
|
||||
variant?: 'primary' | 'secondary' | 'ghost';
|
||||
}
|
||||
|
||||
interface QuickActionsPanelProps {
|
||||
actions: QuickAction[];
|
||||
className?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* QuickActionsPanel
|
||||
*
|
||||
* Provides fast access to common dashboard tasks.
|
||||
*/
|
||||
export function QuickActionsPanel({ actions, className = '' }: QuickActionsPanelProps) {
|
||||
return (
|
||||
<Panel title="Quick Actions" className={className}>
|
||||
<Box display="grid" gridCols={1} gap={2}>
|
||||
{actions.map((action, index) => (
|
||||
<Button
|
||||
key={index}
|
||||
variant={action.variant || 'secondary'}
|
||||
onClick={action.onClick}
|
||||
fullWidth
|
||||
style={{ justifyContent: 'flex-start', height: 'auto', padding: '12px' }}
|
||||
>
|
||||
<Box display="flex" align="center" gap={3}>
|
||||
<Icon icon={action.icon} size={5} />
|
||||
<span>{action.label}</span>
|
||||
</Box>
|
||||
</Button>
|
||||
))}
|
||||
</Box>
|
||||
</Panel>
|
||||
);
|
||||
}
|
||||
146
apps/website/ui/RaceActionBar.tsx
Normal file
146
apps/website/ui/RaceActionBar.tsx
Normal file
@@ -0,0 +1,146 @@
|
||||
import React from 'react';
|
||||
import { Stack } from './Stack';
|
||||
import { Button } from './Button';
|
||||
import { Icon } from './Icon';
|
||||
import { Trophy, Scale, LogOut, CheckCircle, XCircle, PlayCircle } from 'lucide-react';
|
||||
|
||||
interface RaceActionBarProps {
|
||||
status: 'scheduled' | 'running' | 'completed' | 'cancelled' | string;
|
||||
isUserRegistered: boolean;
|
||||
canRegister: boolean;
|
||||
onRegister?: () => void;
|
||||
onWithdraw?: () => void;
|
||||
onResultsClick?: () => void;
|
||||
onStewardingClick?: () => void;
|
||||
onFileProtest?: () => void;
|
||||
isAdmin?: boolean;
|
||||
onCancel?: () => void;
|
||||
onReopen?: () => void;
|
||||
onEndRace?: () => void;
|
||||
isLoading?: {
|
||||
register?: boolean;
|
||||
withdraw?: boolean;
|
||||
cancel?: boolean;
|
||||
reopen?: boolean;
|
||||
complete?: boolean;
|
||||
};
|
||||
}
|
||||
|
||||
export function RaceActionBar({
|
||||
status,
|
||||
isUserRegistered,
|
||||
canRegister,
|
||||
onRegister,
|
||||
onWithdraw,
|
||||
onResultsClick,
|
||||
onStewardingClick,
|
||||
onFileProtest,
|
||||
isAdmin,
|
||||
onCancel,
|
||||
onReopen,
|
||||
onEndRace,
|
||||
isLoading = {}
|
||||
}: RaceActionBarProps) {
|
||||
return (
|
||||
<Stack direction="row" gap={3} wrap>
|
||||
{status === 'scheduled' && (
|
||||
<>
|
||||
{!isUserRegistered && canRegister && (
|
||||
<Button
|
||||
variant="primary"
|
||||
onClick={onRegister}
|
||||
disabled={isLoading.register}
|
||||
icon={<Icon icon={CheckCircle} size={4} />}
|
||||
>
|
||||
Register
|
||||
</Button>
|
||||
)}
|
||||
{isUserRegistered && (
|
||||
<Button
|
||||
variant="secondary"
|
||||
onClick={onWithdraw}
|
||||
disabled={isLoading.withdraw}
|
||||
icon={<Icon icon={LogOut} size={4} />}
|
||||
>
|
||||
Withdraw
|
||||
</Button>
|
||||
)}
|
||||
{isAdmin && (
|
||||
<Button
|
||||
variant="danger"
|
||||
onClick={onCancel}
|
||||
disabled={isLoading.cancel}
|
||||
icon={<Icon icon={XCircle} size={4} />}
|
||||
>
|
||||
Cancel Race
|
||||
</Button>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
{status === 'running' && (
|
||||
<>
|
||||
<Button variant="race-final" disabled icon={<Icon icon={PlayCircle} size={4} />}>
|
||||
Live Now
|
||||
</Button>
|
||||
{isAdmin && (
|
||||
<Button
|
||||
variant="primary"
|
||||
onClick={onEndRace}
|
||||
disabled={isLoading.complete}
|
||||
>
|
||||
End Race
|
||||
</Button>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
{status === 'completed' && (
|
||||
<>
|
||||
<Button
|
||||
variant="primary"
|
||||
onClick={onResultsClick}
|
||||
icon={<Icon icon={Trophy} size={4} />}
|
||||
>
|
||||
View Results
|
||||
</Button>
|
||||
{isUserRegistered && onFileProtest && (
|
||||
<Button
|
||||
variant="secondary"
|
||||
onClick={onFileProtest}
|
||||
icon={<Icon icon={Scale} size={4} />}
|
||||
>
|
||||
File Protest
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
variant="secondary"
|
||||
onClick={onStewardingClick}
|
||||
icon={<Icon icon={Scale} size={4} />}
|
||||
>
|
||||
Stewarding
|
||||
</Button>
|
||||
{isAdmin && onReopen && (
|
||||
<Button
|
||||
variant="secondary"
|
||||
onClick={onReopen}
|
||||
disabled={isLoading.reopen}
|
||||
>
|
||||
Reopen Race
|
||||
</Button>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
{status === 'cancelled' && isAdmin && onReopen && (
|
||||
<Button
|
||||
variant="primary"
|
||||
onClick={onReopen}
|
||||
disabled={isLoading.reopen}
|
||||
>
|
||||
Reopen Race
|
||||
</Button>
|
||||
)}
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
95
apps/website/ui/RaceHeaderPanel.tsx
Normal file
95
apps/website/ui/RaceHeaderPanel.tsx
Normal file
@@ -0,0 +1,95 @@
|
||||
import React from 'react';
|
||||
import { Box } from './Box';
|
||||
import { Text } from './Text';
|
||||
import { Stack } from './Stack';
|
||||
import { RaceStatusBadge } from './RaceStatusBadge';
|
||||
import { Icon } from './Icon';
|
||||
import { Calendar, MapPin, Car } from 'lucide-react';
|
||||
|
||||
interface RaceHeaderPanelProps {
|
||||
track: string;
|
||||
car: string;
|
||||
scheduledAt: string;
|
||||
status: string;
|
||||
leagueName?: string;
|
||||
actions?: React.ReactNode;
|
||||
}
|
||||
|
||||
export function RaceHeaderPanel({
|
||||
track,
|
||||
car,
|
||||
scheduledAt,
|
||||
status,
|
||||
leagueName,
|
||||
actions
|
||||
}: RaceHeaderPanelProps) {
|
||||
return (
|
||||
<Box
|
||||
bg="bg-panel-gray"
|
||||
rounded="xl"
|
||||
border
|
||||
borderColor="border-charcoal-outline"
|
||||
overflow="hidden"
|
||||
position="relative"
|
||||
>
|
||||
{/* Background Accent */}
|
||||
<Box
|
||||
position="absolute"
|
||||
top={0}
|
||||
left={0}
|
||||
right={0}
|
||||
height="24"
|
||||
bg="bg-gradient-to-r from-primary-blue/20 to-transparent"
|
||||
opacity={0.5}
|
||||
/>
|
||||
|
||||
<Box p={6} position="relative">
|
||||
<Stack direction={{ base: 'col', md: 'row' }} gap={6} align="start" className="md:items-center">
|
||||
{/* Info */}
|
||||
<Box flexGrow={1}>
|
||||
<Stack gap={3}>
|
||||
<Stack direction="row" align="center" gap={3} wrap>
|
||||
<Text as="h1" size="3xl" weight="bold" color="text-white">
|
||||
{track}
|
||||
</Text>
|
||||
<RaceStatusBadge status={status} />
|
||||
</Stack>
|
||||
|
||||
<Stack direction="row" align="center" gap={6} wrap>
|
||||
<Stack direction="row" align="center" gap={2}>
|
||||
<Icon icon={Car} size={4} color="#9ca3af" />
|
||||
<Text size="sm" color="text-gray-400">
|
||||
{car}
|
||||
</Text>
|
||||
</Stack>
|
||||
|
||||
<Stack direction="row" align="center" gap={2}>
|
||||
<Icon icon={Calendar} size={4} color="#9ca3af" />
|
||||
<Text size="sm" color="text-gray-400">
|
||||
{scheduledAt}
|
||||
</Text>
|
||||
</Stack>
|
||||
|
||||
{leagueName && (
|
||||
<Stack direction="row" align="center" gap={2}>
|
||||
<Icon icon={MapPin} size={4} color="#9ca3af" />
|
||||
<Text size="sm" color="text-gray-400">
|
||||
{leagueName}
|
||||
</Text>
|
||||
</Stack>
|
||||
)}
|
||||
</Stack>
|
||||
</Stack>
|
||||
</Box>
|
||||
|
||||
{/* Actions */}
|
||||
{actions && (
|
||||
<Box flexShrink={0} width={{ base: 'full', md: 'auto' }}>
|
||||
{actions}
|
||||
</Box>
|
||||
)}
|
||||
</Stack>
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
@@ -1,13 +1,12 @@
|
||||
import React from 'react';
|
||||
import { Flag, CalendarDays, Clock, Zap, Trophy } from 'lucide-react';
|
||||
import { Flag, CalendarDays, Clock, Zap, Trophy, LucideIcon } from 'lucide-react';
|
||||
import { Heading } from '@/ui/Heading';
|
||||
import { Text } from '@/ui/Text';
|
||||
import { Hero } from '@/ui/Hero';
|
||||
import { Stack } from '@/ui/Stack';
|
||||
import { Box } from '@/ui/Box';
|
||||
import { Icon } from '@/ui/Icon';
|
||||
import { Grid } from '@/ui/Grid';
|
||||
import { StatBox } from '@/ui/StatBox';
|
||||
import { Surface } from '@/ui/Surface';
|
||||
|
||||
interface RacePageHeaderProps {
|
||||
totalCount: number;
|
||||
@@ -23,26 +22,57 @@ export function RacePageHeader({
|
||||
completedCount,
|
||||
}: RacePageHeaderProps) {
|
||||
return (
|
||||
<Hero variant="primary">
|
||||
<Stack gap={2}>
|
||||
<Stack direction="row" align="center" gap={3}>
|
||||
<Box p={2} bg="bg-primary-blue/10" rounded="lg">
|
||||
<Icon icon={Flag} size={6} color="rgb(59, 130, 246)" />
|
||||
</Box>
|
||||
<Heading level={1}>Race Calendar</Heading>
|
||||
</Stack>
|
||||
<Text color="text-gray-400" maxWidth="42rem">
|
||||
Track upcoming races, view live events, and explore results across all your leagues.
|
||||
</Text>
|
||||
</Stack>
|
||||
<Surface
|
||||
bg="bg-panel-gray"
|
||||
rounded="xl"
|
||||
border
|
||||
borderColor="border-charcoal-outline"
|
||||
padding={6}
|
||||
position="relative"
|
||||
overflow="hidden"
|
||||
>
|
||||
{/* Background Accent */}
|
||||
<Box
|
||||
position="absolute"
|
||||
top={0}
|
||||
left={0}
|
||||
right={0}
|
||||
height="1"
|
||||
bg="bg-primary-blue"
|
||||
/>
|
||||
|
||||
{/* Quick Stats */}
|
||||
<Grid cols={2} mdCols={4} gap={4} mt={6}>
|
||||
<StatBox icon={CalendarDays} label="Total" value={totalCount} />
|
||||
<StatBox icon={Clock} label="Scheduled" value={scheduledCount} color="rgb(59, 130, 246)" />
|
||||
<StatBox icon={Zap} label="Live Now" value={runningCount} color="rgb(16, 185, 129)" />
|
||||
<StatBox icon={Trophy} label="Completed" value={completedCount} />
|
||||
</Grid>
|
||||
</Hero>
|
||||
<Stack gap={6}>
|
||||
<Stack gap={2}>
|
||||
<Stack direction="row" align="center" gap={3}>
|
||||
<Icon icon={Flag} size={6} color="#198CFF" />
|
||||
<Heading level={1}>RACE DASHBOARD</Heading>
|
||||
</Stack>
|
||||
<Text color="text-gray-400" size="sm">
|
||||
Precision tracking for upcoming sessions and live events.
|
||||
</Text>
|
||||
</Stack>
|
||||
|
||||
<Grid cols={2} mdCols={4} gap={4}>
|
||||
<StatItem icon={CalendarDays} label="TOTAL SESSIONS" value={totalCount} />
|
||||
<StatItem icon={Clock} label="SCHEDULED" value={scheduledCount} color="text-primary-blue" />
|
||||
<StatItem icon={Zap} label="LIVE NOW" value={runningCount} color="text-performance-green" />
|
||||
<StatItem icon={Trophy} label="COMPLETED" value={completedCount} color="text-gray-400" />
|
||||
</Grid>
|
||||
</Stack>
|
||||
</Surface>
|
||||
);
|
||||
}
|
||||
|
||||
function StatItem({ icon, label, value, color = 'text-white' }: { icon: LucideIcon, label: string, value: number, color?: string }) {
|
||||
return (
|
||||
<Box p={4} bg="bg-graphite-black/50" border borderColor="border-charcoal-outline">
|
||||
<Stack gap={1}>
|
||||
<Stack direction="row" align="center" gap={2}>
|
||||
<Icon icon={icon} size={3} color={color === 'text-white' ? '#9ca3af' : undefined} className={color !== 'text-white' ? color : ''} />
|
||||
<Text size="xs" color="text-gray-500" weight="bold" uppercase>{label}</Text>
|
||||
</Stack>
|
||||
<Text size="2xl" weight="bold" className={color}>{value}</Text>
|
||||
</Stack>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -129,7 +129,7 @@ export function RaceResultsTable({
|
||||
<TableHeader>Points</TableHeader>
|
||||
<TableHeader>+/-</TableHeader>
|
||||
<TableHeader>Penalties</TableHeader>
|
||||
{isAdmin && <TableHeader align="right">Actions</TableHeader>}
|
||||
{isAdmin && <TableHeader className="text-right">Actions</TableHeader>}
|
||||
</TableRow>
|
||||
</TableHead>
|
||||
<TableBody>
|
||||
@@ -250,7 +250,7 @@ export function RaceResultsTable({
|
||||
)}
|
||||
</TableCell>
|
||||
{isAdmin && (
|
||||
<TableCell align="right">
|
||||
<TableCell className="text-right">
|
||||
{driver && penaltyButtonRenderer && penaltyButtonRenderer(driver)}
|
||||
</TableCell>
|
||||
)}
|
||||
|
||||
40
apps/website/ui/RaceSidebarPanel.tsx
Normal file
40
apps/website/ui/RaceSidebarPanel.tsx
Normal file
@@ -0,0 +1,40 @@
|
||||
import React from 'react';
|
||||
import { Box } from './Box';
|
||||
import { Text } from './Text';
|
||||
import { Stack } from './Stack';
|
||||
import { Icon } from './Icon';
|
||||
import { LucideIcon } from 'lucide-react';
|
||||
|
||||
interface RaceSidebarPanelProps {
|
||||
title: string;
|
||||
icon?: LucideIcon;
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
export function RaceSidebarPanel({
|
||||
title,
|
||||
icon,
|
||||
children
|
||||
}: RaceSidebarPanelProps) {
|
||||
return (
|
||||
<Box
|
||||
bg="bg-panel-gray"
|
||||
rounded="xl"
|
||||
border
|
||||
borderColor="border-charcoal-outline"
|
||||
overflow="hidden"
|
||||
>
|
||||
<Box p={4} borderBottom="1px solid" borderColor="border-charcoal-outline" bg="bg-graphite-black/30">
|
||||
<Stack direction="row" align="center" gap={2}>
|
||||
{icon && <Icon icon={icon} size={4} color="#198CFF" />}
|
||||
<Text weight="bold" size="sm" color="text-white" uppercase>
|
||||
{title}
|
||||
</Text>
|
||||
</Stack>
|
||||
</Box>
|
||||
<Box p={4}>
|
||||
{children}
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
import React from 'react';
|
||||
import { StatusBadge } from './StatusBadge';
|
||||
import { Box } from './Box';
|
||||
|
||||
interface RaceStatusBadgeProps {
|
||||
status: 'scheduled' | 'running' | 'completed' | 'cancelled' | string;
|
||||
@@ -9,30 +9,52 @@ export function RaceStatusBadge({ status }: RaceStatusBadgeProps) {
|
||||
const config = {
|
||||
scheduled: {
|
||||
variant: 'info' as const,
|
||||
label: 'Scheduled',
|
||||
label: 'SCHEDULED',
|
||||
color: 'text-primary-blue',
|
||||
bg: 'bg-primary-blue/10',
|
||||
border: 'border-primary-blue/30'
|
||||
},
|
||||
running: {
|
||||
variant: 'success' as const,
|
||||
label: 'LIVE',
|
||||
color: 'text-performance-green',
|
||||
bg: 'bg-performance-green/10',
|
||||
border: 'border-performance-green/30'
|
||||
},
|
||||
completed: {
|
||||
variant: 'neutral' as const,
|
||||
label: 'Completed',
|
||||
label: 'COMPLETED',
|
||||
color: 'text-gray-400',
|
||||
bg: 'bg-gray-400/10',
|
||||
border: 'border-gray-400/30'
|
||||
},
|
||||
cancelled: {
|
||||
variant: 'warning' as const,
|
||||
label: 'Cancelled',
|
||||
label: 'CANCELLED',
|
||||
color: 'text-warning-amber',
|
||||
bg: 'bg-warning-amber/10',
|
||||
border: 'border-warning-amber/30'
|
||||
},
|
||||
};
|
||||
|
||||
const badgeConfig = config[status as keyof typeof config] || {
|
||||
variant: 'neutral' as const,
|
||||
label: status,
|
||||
label: status.toUpperCase(),
|
||||
color: 'text-gray-400',
|
||||
bg: 'bg-gray-400/10',
|
||||
border: 'border-gray-400/30'
|
||||
};
|
||||
|
||||
return (
|
||||
<StatusBadge variant={badgeConfig.variant}>
|
||||
<Box
|
||||
px={2.5}
|
||||
py={0.5}
|
||||
rounded="none"
|
||||
border
|
||||
className={`${badgeConfig.bg} ${badgeConfig.color} ${badgeConfig.border}`}
|
||||
style={{ fontSize: '10px', fontWeight: '800', letterSpacing: '0.05em' }}
|
||||
>
|
||||
{badgeConfig.label}
|
||||
</StatusBadge>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
29
apps/website/ui/RatingBadge.tsx
Normal file
29
apps/website/ui/RatingBadge.tsx
Normal file
@@ -0,0 +1,29 @@
|
||||
import React from 'react';
|
||||
|
||||
interface RatingBadgeProps {
|
||||
rating: number;
|
||||
size?: 'sm' | 'md' | 'lg';
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function RatingBadge({ rating, size = 'md', className = '' }: RatingBadgeProps) {
|
||||
const getColor = (val: number) => {
|
||||
if (val >= 2500) return 'text-yellow-400 bg-yellow-400/10 border-yellow-400/20';
|
||||
if (val >= 2000) return 'text-purple-400 bg-purple-400/10 border-purple-400/20';
|
||||
if (val >= 1500) return 'text-primary-blue bg-primary-blue/10 border-primary-blue/20';
|
||||
if (val >= 1000) return 'text-performance-green bg-performance-green/10 border-performance-green/20';
|
||||
return 'text-gray-400 bg-gray-400/10 border-gray-400/20';
|
||||
};
|
||||
|
||||
const sizeMap = {
|
||||
sm: 'px-1.5 py-0.5 text-[10px]',
|
||||
md: 'px-2 py-1 text-xs',
|
||||
lg: 'px-3 py-1.5 text-sm',
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={`inline-flex items-center justify-center font-mono font-bold rounded border ${sizeMap[size]} ${getColor(rating)} ${className}`}>
|
||||
{rating.toLocaleString()}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
85
apps/website/ui/RosterTable.tsx
Normal file
85
apps/website/ui/RosterTable.tsx
Normal file
@@ -0,0 +1,85 @@
|
||||
import React, { ReactNode } from 'react';
|
||||
import { Box } from './Box';
|
||||
import { Stack } from './Stack';
|
||||
import { Text } from './Text';
|
||||
import { Table, TableHead, TableBody, TableRow, TableHeader, TableCell } from './Table';
|
||||
|
||||
interface RosterTableProps {
|
||||
children: ReactNode;
|
||||
columns?: string[];
|
||||
}
|
||||
|
||||
export function RosterTable({ children, columns = ['Driver', 'Role', 'Joined', 'Rating', 'Rank'] }: RosterTableProps) {
|
||||
return (
|
||||
<Box border borderColor="border-steel-grey" bg="surface-charcoal/50" overflow="hidden">
|
||||
<Table>
|
||||
<TableHead className="bg-base-graphite/50">
|
||||
<TableRow>
|
||||
{columns.map((col) => (
|
||||
<TableHeader key={col} className="py-3 border-b border-border-steel-grey">
|
||||
<Text size="xs" weight="bold" color="text-gray-500" className="uppercase tracking-widest" block>
|
||||
{col}
|
||||
</Text>
|
||||
</TableHeader>
|
||||
))}
|
||||
<TableHeader className="py-3 border-b border-border-steel-grey">
|
||||
{null}
|
||||
</TableHeader>
|
||||
</TableRow>
|
||||
</TableHead>
|
||||
<TableBody>
|
||||
{children}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
interface RosterTableRowProps {
|
||||
driver: ReactNode;
|
||||
role: ReactNode;
|
||||
joined: string;
|
||||
rating: ReactNode;
|
||||
rank: ReactNode;
|
||||
actions?: ReactNode;
|
||||
onClick?: () => void;
|
||||
}
|
||||
|
||||
export function RosterTableRow({
|
||||
driver,
|
||||
role,
|
||||
joined,
|
||||
rating,
|
||||
rank,
|
||||
actions,
|
||||
onClick,
|
||||
}: RosterTableRowProps) {
|
||||
return (
|
||||
<TableRow
|
||||
onClick={onClick}
|
||||
clickable={!!onClick}
|
||||
className="group hover:bg-primary-blue/5 transition-colors border-b border-border-steel-grey/30 last:border-0"
|
||||
>
|
||||
<TableCell className="py-4">
|
||||
{driver}
|
||||
</TableCell>
|
||||
<TableCell className="py-4">
|
||||
{role}
|
||||
</TableCell>
|
||||
<TableCell className="py-4">
|
||||
<Text size="sm" color="text-gray-400" font="mono">{joined}</Text>
|
||||
</TableCell>
|
||||
<TableCell className="py-4">
|
||||
{rating}
|
||||
</TableCell>
|
||||
<TableCell className="py-4">
|
||||
{rank}
|
||||
</TableCell>
|
||||
<TableCell className="py-4 text-right">
|
||||
<Stack direction="row" align="center" justify="end" gap={2}>
|
||||
{actions}
|
||||
</Stack>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
);
|
||||
}
|
||||
@@ -14,6 +14,10 @@ interface SectionProps {
|
||||
id?: string;
|
||||
py?: number;
|
||||
minHeight?: string;
|
||||
borderBottom?: boolean;
|
||||
borderColor?: string;
|
||||
overflow?: 'hidden' | 'visible' | 'auto' | 'scroll';
|
||||
position?: 'relative' | 'absolute' | 'fixed' | 'sticky';
|
||||
}
|
||||
|
||||
export function Section({
|
||||
@@ -24,7 +28,11 @@ export function Section({
|
||||
variant = 'default',
|
||||
id,
|
||||
py = 16,
|
||||
minHeight
|
||||
minHeight,
|
||||
borderBottom,
|
||||
borderColor,
|
||||
overflow,
|
||||
position
|
||||
}: SectionProps) {
|
||||
const variantClasses = {
|
||||
default: '',
|
||||
@@ -40,7 +48,18 @@ export function Section({
|
||||
].filter(Boolean).join(' ');
|
||||
|
||||
return (
|
||||
<Box as="section" id={id} className={classes} py={py as 0} px={4} minHeight={minHeight}>
|
||||
<Box
|
||||
as="section"
|
||||
id={id}
|
||||
className={classes}
|
||||
py={py as 0}
|
||||
px={4}
|
||||
minHeight={minHeight}
|
||||
borderBottom={borderBottom}
|
||||
borderColor={borderColor}
|
||||
overflow={overflow}
|
||||
position={position}
|
||||
>
|
||||
<Box className="mx-auto max-w-7xl">
|
||||
{(title || description) && (
|
||||
<Box mb={8}>
|
||||
|
||||
68
apps/website/ui/SessionSummaryPanel.tsx
Normal file
68
apps/website/ui/SessionSummaryPanel.tsx
Normal file
@@ -0,0 +1,68 @@
|
||||
import React from 'react';
|
||||
import { Panel } from './Panel';
|
||||
import { Text } from './Text';
|
||||
import { Box } from './Box';
|
||||
import { StatusDot } from './StatusDot';
|
||||
|
||||
interface SessionSummaryPanelProps {
|
||||
title: string;
|
||||
status: 'live' | 'upcoming' | 'completed';
|
||||
startTime?: string;
|
||||
trackName?: string;
|
||||
carName?: string;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* SessionSummaryPanel
|
||||
*
|
||||
* Displays a dense summary of a racing session.
|
||||
* Part of the "Telemetry Workspace" layout.
|
||||
*/
|
||||
export function SessionSummaryPanel({
|
||||
title,
|
||||
status,
|
||||
startTime,
|
||||
trackName,
|
||||
carName,
|
||||
className = '',
|
||||
}: SessionSummaryPanelProps) {
|
||||
const statusColor = status === 'live' ? '#4ED4E0' : status === 'upcoming' ? '#FFBE4D' : '#94a3b8';
|
||||
|
||||
return (
|
||||
<Panel title="Session Summary" className={className}>
|
||||
<Box display="flex" flexDirection="col" gap={3}>
|
||||
<Box display="flex" align="center" justify="between">
|
||||
<Text weight="bold" size="lg">{title}</Text>
|
||||
<Box display="flex" align="center" gap={2}>
|
||||
<StatusDot color={statusColor} pulse={status === 'live'} size={2} />
|
||||
<Text size="xs" uppercase weight="bold" style={{ color: statusColor }}>
|
||||
{status}
|
||||
</Text>
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
<Box display="grid" gridCols={2} gap={4} borderTop borderStyle="solid" borderColor="border-gray/10" pt={3}>
|
||||
{startTime && (
|
||||
<Box>
|
||||
<Text size="xs" color="text-gray-500" uppercase block mb={1}>Start Time</Text>
|
||||
<Text size="sm">{startTime}</Text>
|
||||
</Box>
|
||||
)}
|
||||
{trackName && (
|
||||
<Box>
|
||||
<Text size="xs" color="text-gray-500" uppercase block mb={1}>Track</Text>
|
||||
<Text size="sm">{trackName}</Text>
|
||||
</Box>
|
||||
)}
|
||||
{carName && (
|
||||
<Box colSpan={2}>
|
||||
<Text size="xs" color="text-gray-500" uppercase block mb={1}>Vehicle</Text>
|
||||
<Text size="sm">{carName}</Text>
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
</Box>
|
||||
</Panel>
|
||||
);
|
||||
}
|
||||
36
apps/website/ui/SimpleCheckbox.tsx
Normal file
36
apps/website/ui/SimpleCheckbox.tsx
Normal file
@@ -0,0 +1,36 @@
|
||||
'use client';
|
||||
|
||||
import React from 'react';
|
||||
import { Box } from './Box';
|
||||
|
||||
interface CheckboxProps {
|
||||
checked: boolean;
|
||||
onChange: (checked: boolean) => void;
|
||||
disabled?: boolean;
|
||||
'aria-label'?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* SimpleCheckbox
|
||||
*
|
||||
* A checkbox without a label for use in tables.
|
||||
*/
|
||||
export function SimpleCheckbox({ checked, onChange, disabled, 'aria-label': ariaLabel }: CheckboxProps) {
|
||||
return (
|
||||
<Box
|
||||
as="input"
|
||||
type="checkbox"
|
||||
checked={checked}
|
||||
onChange={(e: React.ChangeEvent<HTMLInputElement>) => onChange(e.target.checked)}
|
||||
disabled={disabled}
|
||||
w="4"
|
||||
h="4"
|
||||
bg="bg-deep-graphite"
|
||||
border
|
||||
borderColor="border-charcoal-outline"
|
||||
rounded="sm"
|
||||
aria-label={ariaLabel}
|
||||
className="text-primary-blue focus:ring-primary-blue"
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -1,31 +1,53 @@
|
||||
/**
|
||||
* SponsorLogo
|
||||
*
|
||||
* Pure UI component for displaying sponsor logos.
|
||||
* Renders an optimized image with fallback on error.
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import { Box } from './Box';
|
||||
import { Image } from './Image';
|
||||
import { Building2 } from 'lucide-react';
|
||||
import { Icon } from './Icon';
|
||||
|
||||
export interface SponsorLogoProps {
|
||||
sponsorId: string;
|
||||
sponsorId?: string;
|
||||
src?: string;
|
||||
alt: string;
|
||||
size?: number;
|
||||
className?: string;
|
||||
border?: boolean;
|
||||
rounded?: 'none' | 'sm' | 'md' | 'lg' | 'xl' | 'full';
|
||||
}
|
||||
|
||||
export function SponsorLogo({ sponsorId, alt, className = '' }: SponsorLogoProps) {
|
||||
export function SponsorLogo({
|
||||
sponsorId,
|
||||
src,
|
||||
alt,
|
||||
size = 48,
|
||||
className = '',
|
||||
border = true,
|
||||
rounded = 'md',
|
||||
}: SponsorLogoProps) {
|
||||
const logoSrc = src || (sponsorId ? `/media/sponsors/${sponsorId}/logo` : undefined);
|
||||
|
||||
return (
|
||||
<Image
|
||||
src={`/media/sponsors/${sponsorId}/logo`}
|
||||
alt={alt}
|
||||
width={100}
|
||||
height={100}
|
||||
className={`object-contain ${className}`}
|
||||
onError={(e) => {
|
||||
// Fallback to default logo
|
||||
(e.target as HTMLImageElement).src = '/default-sponsor-logo.png';
|
||||
}}
|
||||
/>
|
||||
<Box
|
||||
display="flex"
|
||||
alignItems="center"
|
||||
justifyContent="center"
|
||||
rounded={rounded}
|
||||
overflow="hidden"
|
||||
bg="bg-charcoal-outline/10"
|
||||
border={border}
|
||||
borderColor="border-charcoal-outline/50"
|
||||
className={className}
|
||||
style={{ width: size, height: size, flexShrink: 0 }}
|
||||
>
|
||||
{logoSrc ? (
|
||||
<Image
|
||||
src={logoSrc}
|
||||
alt={alt}
|
||||
className="w-full h-full object-contain p-1"
|
||||
fallbackSrc="/default-sponsor-logo.png"
|
||||
/>
|
||||
) : (
|
||||
<Icon icon={Building2} size={size > 32 ? 5 : 4} color="text-gray-500" />
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
58
apps/website/ui/StatGrid.tsx
Normal file
58
apps/website/ui/StatGrid.tsx
Normal file
@@ -0,0 +1,58 @@
|
||||
import React from 'react';
|
||||
import { Grid } from './Grid';
|
||||
import { GridItem } from './GridItem';
|
||||
import { Surface } from './Surface';
|
||||
import { Text } from './Text';
|
||||
import { Stack } from './Stack';
|
||||
|
||||
type GridCols = 1 | 2 | 3 | 4 | 5 | 6 | 12;
|
||||
|
||||
interface StatItem {
|
||||
label: string;
|
||||
value: string | number;
|
||||
subValue?: string;
|
||||
color?: string;
|
||||
icon?: React.ElementType;
|
||||
}
|
||||
|
||||
interface StatGridProps {
|
||||
stats: StatItem[];
|
||||
cols?: GridCols;
|
||||
mdCols?: GridCols;
|
||||
lgCols?: GridCols;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function StatGrid({ stats, cols = 2, mdCols = 3, lgCols = 4, className = '' }: StatGridProps) {
|
||||
return (
|
||||
<Grid
|
||||
cols={cols}
|
||||
mdCols={mdCols}
|
||||
lgCols={lgCols}
|
||||
gap={4}
|
||||
className={className}
|
||||
>
|
||||
{stats.map((stat, index) => (
|
||||
<GridItem key={index}>
|
||||
<Surface variant="muted" padding={4} rounded="lg" border className="h-full">
|
||||
<Stack gap={1}>
|
||||
<Text size="xs" color="text-gray-500" uppercase weight="semibold" letterSpacing="wider">
|
||||
{stat.label}
|
||||
</Text>
|
||||
<Stack direction="row" align="baseline" gap={2}>
|
||||
<Text size="2xl" weight="bold" font="mono" color={stat.color || 'text-white'}>
|
||||
{stat.value}
|
||||
</Text>
|
||||
{stat.subValue && (
|
||||
<Text size="xs" color="text-gray-500" font="mono">
|
||||
{stat.subValue}
|
||||
</Text>
|
||||
)}
|
||||
</Stack>
|
||||
</Stack>
|
||||
</Surface>
|
||||
</GridItem>
|
||||
))}
|
||||
</Grid>
|
||||
);
|
||||
}
|
||||
43
apps/website/ui/StatusDot.tsx
Normal file
43
apps/website/ui/StatusDot.tsx
Normal file
@@ -0,0 +1,43 @@
|
||||
import React from 'react';
|
||||
import { Box } from './Box';
|
||||
|
||||
interface StatusDotProps {
|
||||
color?: string;
|
||||
pulse?: boolean;
|
||||
size?: number;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* StatusDot
|
||||
*
|
||||
* A simple status indicator dot with optional pulse effect.
|
||||
*/
|
||||
export function StatusDot({
|
||||
color = '#4ED4E0',
|
||||
pulse = false,
|
||||
size = 2,
|
||||
className = ''
|
||||
}: StatusDotProps) {
|
||||
const sizeClass = `w-${size} h-${size}`;
|
||||
|
||||
return (
|
||||
<Box position="relative" className={`${sizeClass} ${className}`}>
|
||||
<Box
|
||||
w="full"
|
||||
h="full"
|
||||
rounded="full"
|
||||
style={{ backgroundColor: color }}
|
||||
/>
|
||||
{pulse && (
|
||||
<Box
|
||||
position="absolute"
|
||||
inset={0}
|
||||
rounded="full"
|
||||
className="animate-ping"
|
||||
style={{ backgroundColor: color, opacity: 0.75 }}
|
||||
/>
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
22
apps/website/ui/StepProgressRail.tsx
Normal file
22
apps/website/ui/StepProgressRail.tsx
Normal file
@@ -0,0 +1,22 @@
|
||||
import { Box } from '@/ui/Box';
|
||||
import { motion } from 'framer-motion';
|
||||
|
||||
interface StepProgressRailProps {
|
||||
currentStep: number;
|
||||
totalSteps: number;
|
||||
}
|
||||
|
||||
export function StepProgressRail({ currentStep, totalSteps }: StepProgressRailProps) {
|
||||
const progress = (currentStep / totalSteps) * 100;
|
||||
|
||||
return (
|
||||
<Box w="full" h="1" bg="bg-iron-gray" rounded="full" overflow="hidden" mb={8}>
|
||||
<motion.div
|
||||
className="h-full bg-primary-blue"
|
||||
initial={{ width: 0 }}
|
||||
animate={{ width: `${progress}%` }}
|
||||
transition={{ duration: 0.5, ease: 'easeOut' }}
|
||||
/>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
117
apps/website/ui/TeamHeaderPanel.tsx
Normal file
117
apps/website/ui/TeamHeaderPanel.tsx
Normal file
@@ -0,0 +1,117 @@
|
||||
import React, { ReactNode } from 'react';
|
||||
import { Box } from './Box';
|
||||
import { Stack } from './Stack';
|
||||
import { Heading } from './Heading';
|
||||
import { Text } from './Text';
|
||||
import { TeamLogo } from './TeamLogo';
|
||||
import { TeamTag } from './TeamTag';
|
||||
|
||||
interface TeamHeaderPanelProps {
|
||||
teamId: string;
|
||||
name: string;
|
||||
tag?: string | null;
|
||||
description?: string;
|
||||
memberCount: number;
|
||||
activeLeaguesCount?: number;
|
||||
foundedDate?: string;
|
||||
category?: string | null;
|
||||
actions?: ReactNode;
|
||||
}
|
||||
|
||||
export function TeamHeaderPanel({
|
||||
teamId,
|
||||
name,
|
||||
tag,
|
||||
description,
|
||||
memberCount,
|
||||
activeLeaguesCount,
|
||||
foundedDate,
|
||||
category,
|
||||
actions,
|
||||
}: TeamHeaderPanelProps) {
|
||||
return (
|
||||
<Box
|
||||
bg="surface-charcoal"
|
||||
border
|
||||
borderColor="border-steel-grey"
|
||||
p={6}
|
||||
className="relative overflow-hidden"
|
||||
>
|
||||
{/* Instrument-grade accent corner */}
|
||||
<Box position="absolute" top="-1px" left="-1px" w="4" h="4" borderTop borderLeft borderColor="primary-blue/40" />
|
||||
|
||||
<Stack direction="row" align="start" justify="between" wrap gap={6}>
|
||||
<Stack direction="row" align="start" gap={6} wrap flexGrow={1}>
|
||||
{/* Logo Container */}
|
||||
<Box
|
||||
w="24"
|
||||
h="24"
|
||||
bg="base-graphite"
|
||||
border
|
||||
borderColor="border-steel-grey"
|
||||
display="flex"
|
||||
center
|
||||
overflow="hidden"
|
||||
className="relative"
|
||||
>
|
||||
<TeamLogo teamId={teamId} alt={name} />
|
||||
{/* Corner detail */}
|
||||
<Box position="absolute" bottom="0" right="0" w="2" h="2" bg="primary-blue/20" />
|
||||
</Box>
|
||||
|
||||
<Box flexGrow={1} minWidth="0">
|
||||
<Stack direction="row" align="center" gap={3} mb={2}>
|
||||
<Heading level={1} weight="bold" className="tracking-tight">{name}</Heading>
|
||||
{tag && <TeamTag tag={tag} />}
|
||||
</Stack>
|
||||
|
||||
{description && (
|
||||
<Text color="text-gray-400" block mb={4} maxWidth="42rem" size="sm" leading="relaxed">
|
||||
{description}
|
||||
</Text>
|
||||
)}
|
||||
|
||||
<Stack direction="row" align="center" gap={4} wrap>
|
||||
<Box display="flex" alignItems="center" gap={1.5}>
|
||||
<Box w="1.5" h="1.5" bg="primary-blue" />
|
||||
<Text size="xs" color="text-gray-300" font="mono" className="uppercase tracking-wider">
|
||||
{memberCount} {memberCount === 1 ? 'Member' : 'Members'}
|
||||
</Text>
|
||||
</Box>
|
||||
|
||||
{category && (
|
||||
<Box display="flex" alignItems="center" gap={1.5}>
|
||||
<Box w="1.5" h="1.5" bg="telemetry-aqua" />
|
||||
<Text size="xs" color="text-gray-300" font="mono" className="uppercase tracking-wider">
|
||||
{category}
|
||||
</Text>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{activeLeaguesCount !== undefined && (
|
||||
<Box display="flex" alignItems="center" gap={1.5}>
|
||||
<Box w="1.5" h="1.5" bg="warning-amber" />
|
||||
<Text size="xs" color="text-gray-300" font="mono" className="uppercase tracking-wider">
|
||||
{activeLeaguesCount} {activeLeaguesCount === 1 ? 'League' : 'Leagues'}
|
||||
</Text>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{foundedDate && (
|
||||
<Text size="xs" color="text-gray-500" font="mono" className="uppercase tracking-wider">
|
||||
EST. {foundedDate}
|
||||
</Text>
|
||||
)}
|
||||
</Stack>
|
||||
</Box>
|
||||
</Stack>
|
||||
|
||||
{actions && (
|
||||
<Box>
|
||||
{actions}
|
||||
</Box>
|
||||
)}
|
||||
</Stack>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
75
apps/website/ui/TeamLeaderboardPanel.tsx
Normal file
75
apps/website/ui/TeamLeaderboardPanel.tsx
Normal file
@@ -0,0 +1,75 @@
|
||||
import React from 'react';
|
||||
import { Box } from './Box';
|
||||
import { Stack } from './Stack';
|
||||
import { Text } from './Text';
|
||||
import { Table, TableHead, TableBody, TableRow, TableHeader, TableCell } from './Table';
|
||||
import { TeamLogo } from './TeamLogo';
|
||||
import { RankBadge } from './RankBadge';
|
||||
|
||||
interface TeamLeaderboardPanelProps {
|
||||
teams: Array<{
|
||||
id: string;
|
||||
name: string;
|
||||
logoUrl?: string;
|
||||
rating: number;
|
||||
wins: number;
|
||||
races: number;
|
||||
memberCount: number;
|
||||
}>;
|
||||
onTeamClick: (id: string) => void;
|
||||
}
|
||||
|
||||
export function TeamLeaderboardPanel({ teams, onTeamClick }: TeamLeaderboardPanelProps) {
|
||||
return (
|
||||
<Box border borderColor="border-steel-grey" bg="surface-charcoal/50" overflow="hidden">
|
||||
<Table>
|
||||
<TableHead className="bg-base-graphite/50">
|
||||
<TableRow>
|
||||
<TableHeader className="w-16 text-center">Rank</TableHeader>
|
||||
<TableHeader>Team</TableHeader>
|
||||
<TableHeader className="text-center">Rating</TableHeader>
|
||||
<TableHeader className="text-center">Wins</TableHeader>
|
||||
<TableHeader className="text-center">Races</TableHeader>
|
||||
<TableHeader className="text-center">Members</TableHeader>
|
||||
</TableRow>
|
||||
</TableHead>
|
||||
<TableBody>
|
||||
{teams.map((team, index) => (
|
||||
<TableRow
|
||||
key={team.id}
|
||||
onClick={() => onTeamClick(team.id)}
|
||||
clickable
|
||||
className="group hover:bg-primary-blue/5 transition-colors border-b border-border-steel-grey/30 last:border-0"
|
||||
>
|
||||
<TableCell className="text-center">
|
||||
<RankBadge rank={index + 1} />
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Stack direction="row" align="center" gap={3}>
|
||||
<Box w="8" h="8" bg="base-graphite" border borderColor="border-steel-grey" display="flex" center overflow="hidden">
|
||||
<TeamLogo teamId={team.id} alt={team.name} />
|
||||
</Box>
|
||||
<Text weight="bold" size="sm" color="text-white" className="group-hover:text-primary-blue transition-colors">
|
||||
{team.name}
|
||||
</Text>
|
||||
</Stack>
|
||||
</TableCell>
|
||||
<TableCell className="text-center">
|
||||
<Text font="mono" weight="bold" color="text-primary-blue">{team.rating}</Text>
|
||||
</TableCell>
|
||||
<TableCell className="text-center">
|
||||
<Text font="mono" color="text-gray-300">{team.wins}</Text>
|
||||
</TableCell>
|
||||
<TableCell className="text-center">
|
||||
<Text font="mono" color="text-gray-300">{team.races}</Text>
|
||||
</TableCell>
|
||||
<TableCell className="text-center">
|
||||
<Text font="mono" color="text-gray-400" size="xs">{team.memberCount}</Text>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
@@ -1,31 +1,53 @@
|
||||
/**
|
||||
* TeamLogo
|
||||
*
|
||||
* Pure UI component for displaying team logos.
|
||||
* Renders an optimized image with fallback on error.
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import { Box } from './Box';
|
||||
import { Image } from './Image';
|
||||
import { Users } from 'lucide-react';
|
||||
import { Icon } from './Icon';
|
||||
|
||||
export interface TeamLogoProps {
|
||||
teamId: string;
|
||||
teamId?: string;
|
||||
src?: string;
|
||||
alt: string;
|
||||
size?: number;
|
||||
className?: string;
|
||||
border?: boolean;
|
||||
rounded?: 'none' | 'sm' | 'md' | 'lg' | 'xl' | 'full';
|
||||
}
|
||||
|
||||
export function TeamLogo({ teamId, alt, className = '' }: TeamLogoProps) {
|
||||
export function TeamLogo({
|
||||
teamId,
|
||||
src,
|
||||
alt,
|
||||
size = 48,
|
||||
className = '',
|
||||
border = true,
|
||||
rounded = 'md',
|
||||
}: TeamLogoProps) {
|
||||
const logoSrc = src || (teamId ? `/media/teams/${teamId}/logo` : undefined);
|
||||
|
||||
return (
|
||||
<Image
|
||||
src={`/media/teams/${teamId}/logo`}
|
||||
alt={alt}
|
||||
width={100}
|
||||
height={100}
|
||||
className={`object-contain ${className}`}
|
||||
onError={(e) => {
|
||||
// Fallback to default logo
|
||||
(e.target as HTMLImageElement).src = '/default-team-logo.png';
|
||||
}}
|
||||
/>
|
||||
<Box
|
||||
display="flex"
|
||||
alignItems="center"
|
||||
justifyContent="center"
|
||||
rounded={rounded}
|
||||
overflow="hidden"
|
||||
bg="bg-charcoal-outline/10"
|
||||
border={border}
|
||||
borderColor="border-charcoal-outline/50"
|
||||
className={className}
|
||||
style={{ width: size, height: size, flexShrink: 0 }}
|
||||
>
|
||||
{logoSrc ? (
|
||||
<Image
|
||||
src={logoSrc}
|
||||
alt={alt}
|
||||
className="w-full h-full object-contain p-1"
|
||||
fallbackSrc="/default-team-logo.png"
|
||||
/>
|
||||
) : (
|
||||
<Icon icon={Users} size={size > 32 ? 5 : 4} color="text-gray-500" />
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
50
apps/website/ui/TelemetryStrip.tsx
Normal file
50
apps/website/ui/TelemetryStrip.tsx
Normal file
@@ -0,0 +1,50 @@
|
||||
import React from 'react';
|
||||
import { Box } from './Box';
|
||||
import { Text } from './Text';
|
||||
|
||||
interface TelemetryItem {
|
||||
label: string;
|
||||
value: string | number;
|
||||
trend?: 'up' | 'down' | 'neutral';
|
||||
color?: string;
|
||||
}
|
||||
|
||||
interface TelemetryStripProps {
|
||||
items: TelemetryItem[];
|
||||
className?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* TelemetryStrip
|
||||
*
|
||||
* A thin, dense strip showing key telemetry or performance metrics.
|
||||
* Follows the "Precision Racing Minimal" theme.
|
||||
*/
|
||||
export function TelemetryStrip({ items, className = '' }: TelemetryStripProps) {
|
||||
return (
|
||||
<Box
|
||||
className={`bg-graphite-black/80 border-y border-border-gray/30 py-2 px-4 flex items-center gap-8 overflow-x-auto no-scrollbar ${className}`}
|
||||
>
|
||||
{items.map((item, index) => (
|
||||
<Box key={index} display="flex" align="center" gap={2} className="whitespace-nowrap">
|
||||
<Text size="xs" color="text-gray-500" uppercase weight="bold" letterSpacing="widest">
|
||||
{item.label}
|
||||
</Text>
|
||||
<Text
|
||||
size="sm"
|
||||
weight="bold"
|
||||
style={{ color: item.color || '#4ED4E0' }}
|
||||
className="font-mono"
|
||||
>
|
||||
{item.value}
|
||||
</Text>
|
||||
{item.trend && (
|
||||
<Text size="xs" style={{ color: item.trend === 'up' ? '#10b981' : item.trend === 'down' ? '#ef4444' : '#94a3b8' }}>
|
||||
{item.trend === 'up' ? '↑' : item.trend === 'down' ? '↓' : '•'}
|
||||
</Text>
|
||||
)}
|
||||
</Box>
|
||||
))}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
@@ -36,7 +36,8 @@ interface TextProps<T extends ElementType = 'span'> extends Omit<BoxProps<T>, 'c
|
||||
align?: TextAlign | ResponsiveTextAlign;
|
||||
truncate?: boolean;
|
||||
uppercase?: boolean;
|
||||
letterSpacing?: 'tighter' | 'tight' | 'normal' | 'wide' | 'wider' | 'widest' | '0.05em';
|
||||
capitalize?: boolean;
|
||||
letterSpacing?: 'tighter' | 'tight' | 'normal' | 'wide' | 'wider' | 'widest' | '0.05em' | string;
|
||||
leading?: 'none' | 'tight' | 'snug' | 'normal' | 'relaxed' | 'loose';
|
||||
fontSize?: string;
|
||||
style?: React.CSSProperties;
|
||||
@@ -68,6 +69,7 @@ export function Text<T extends ElementType = 'span'>({
|
||||
align = 'left',
|
||||
truncate = false,
|
||||
uppercase = false,
|
||||
capitalize = false,
|
||||
letterSpacing,
|
||||
leading,
|
||||
fontSize,
|
||||
@@ -180,6 +182,7 @@ export function Text<T extends ElementType = 'span'>({
|
||||
color,
|
||||
truncate ? 'truncate' : '',
|
||||
uppercase ? 'uppercase' : '',
|
||||
capitalize ? 'capitalize' : '',
|
||||
italic ? 'italic' : '',
|
||||
letterSpacing === '0.05em' ? 'tracking-wider' : letterSpacing ? `tracking-${letterSpacing}` : '',
|
||||
getSpacingClass('ml', ml),
|
||||
|
||||
17
apps/website/ui/TopNav.tsx
Normal file
17
apps/website/ui/TopNav.tsx
Normal file
@@ -0,0 +1,17 @@
|
||||
import React from 'react';
|
||||
|
||||
interface TopNavProps {
|
||||
children: React.ReactNode;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* TopNav is a horizontal navigation container used within the AppHeader.
|
||||
*/
|
||||
export function TopNav({ children, className = '' }: TopNavProps) {
|
||||
return (
|
||||
<nav className={`flex items-center justify-between w-full ${className}`}>
|
||||
{children}
|
||||
</nav>
|
||||
);
|
||||
}
|
||||
@@ -1,31 +1,46 @@
|
||||
/**
|
||||
* TrackImage
|
||||
*
|
||||
* Pure UI component for displaying track images.
|
||||
* Renders an optimized image with fallback on error.
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import { Box } from './Box';
|
||||
import { Image } from './Image';
|
||||
import { ImagePlaceholder } from './ImagePlaceholder';
|
||||
|
||||
export interface TrackImageProps {
|
||||
trackId: string;
|
||||
trackId?: string;
|
||||
src?: string;
|
||||
alt: string;
|
||||
aspectRatio?: string;
|
||||
className?: string;
|
||||
rounded?: 'none' | 'sm' | 'md' | 'lg' | 'xl' | '2xl';
|
||||
}
|
||||
|
||||
export function TrackImage({ trackId, alt, className = '' }: TrackImageProps) {
|
||||
export function TrackImage({
|
||||
trackId,
|
||||
src,
|
||||
alt,
|
||||
aspectRatio = '16/9',
|
||||
className = '',
|
||||
rounded = 'lg',
|
||||
}: TrackImageProps) {
|
||||
const imageSrc = src || (trackId ? `/media/tracks/${trackId}/image` : undefined);
|
||||
|
||||
return (
|
||||
<Image
|
||||
src={`/media/tracks/${trackId}/image`}
|
||||
alt={alt}
|
||||
width={800}
|
||||
height={256}
|
||||
className={`w-full h-64 object-cover ${className}`}
|
||||
onError={(e) => {
|
||||
// Fallback to default track image
|
||||
(e.target as HTMLImageElement).src = '/default-track-image.png';
|
||||
}}
|
||||
/>
|
||||
<Box
|
||||
width="full"
|
||||
overflow="hidden"
|
||||
bg="bg-charcoal-outline/10"
|
||||
rounded={rounded}
|
||||
className={className}
|
||||
style={{ aspectRatio }}
|
||||
>
|
||||
{imageSrc ? (
|
||||
<Image
|
||||
src={imageSrc}
|
||||
alt={alt}
|
||||
className="w-full h-full object-cover"
|
||||
fallbackSrc="/default-track-image.png"
|
||||
/>
|
||||
) : (
|
||||
<ImagePlaceholder aspectRatio={aspectRatio} rounded="none" />
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -6,7 +6,7 @@ import { LucideIcon } from 'lucide-react';
|
||||
export interface EmptyStateAction {
|
||||
label: string;
|
||||
onClick: () => void;
|
||||
variant?: 'primary' | 'secondary' | 'danger' | 'race-performance' | 'race-final';
|
||||
variant?: 'primary' | 'secondary' | 'danger' | 'ghost' | 'race-final' | 'discord';
|
||||
icon?: LucideIcon;
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user