website refactor
This commit is contained in:
@@ -53,9 +53,9 @@
|
||||
"lib/builders/view-data/*.tsx"
|
||||
],
|
||||
"rules": {
|
||||
"gridpilot-rules/view-data-builder-contract": "off",
|
||||
"gridpilot-rules/filename-matches-export": "off",
|
||||
"gridpilot-rules/single-export-per-file": "off",
|
||||
"gridpilot-rules/filename-matches-export": "off"
|
||||
"gridpilot-rules/view-data-builder-contract": "off"
|
||||
}
|
||||
},
|
||||
{
|
||||
@@ -72,11 +72,11 @@
|
||||
"lib/mutations/**/*.ts"
|
||||
],
|
||||
"rules": {
|
||||
"gridpilot-rules/mutation-contract": "off",
|
||||
"gridpilot-rules/mutation-must-use-builders": "off",
|
||||
"gridpilot-rules/mutation-must-map-errors": "off",
|
||||
"gridpilot-rules/clean-error-handling": "off",
|
||||
"gridpilot-rules/filename-service-match": "off",
|
||||
"gridpilot-rules/clean-error-handling": "off"
|
||||
"gridpilot-rules/mutation-contract": "off",
|
||||
"gridpilot-rules/mutation-must-map-errors": "off",
|
||||
"gridpilot-rules/mutation-must-use-builders": "off"
|
||||
}
|
||||
},
|
||||
{
|
||||
@@ -84,16 +84,16 @@
|
||||
"templates/**/*.tsx"
|
||||
],
|
||||
"rules": {
|
||||
"gridpilot-rules/template-no-direct-mutations": "off",
|
||||
"gridpilot-rules/template-no-side-effects": "off",
|
||||
"gridpilot-rules/component-no-data-manipulation": "off",
|
||||
"gridpilot-rules/no-hardcoded-routes": "off",
|
||||
"gridpilot-rules/no-raw-html-in-app": "off",
|
||||
"gridpilot-rules/template-no-async-render": "off",
|
||||
"gridpilot-rules/template-no-direct-mutations": "off",
|
||||
"gridpilot-rules/template-no-external-state": "off",
|
||||
"gridpilot-rules/template-no-global-objects": "off",
|
||||
"gridpilot-rules/template-no-mutation-props": "off",
|
||||
"gridpilot-rules/template-no-unsafe-html": "off",
|
||||
"gridpilot-rules/component-no-data-manipulation": "off",
|
||||
"gridpilot-rules/no-hardcoded-routes": "off",
|
||||
"gridpilot-rules/no-raw-html-in-app": "off"
|
||||
"gridpilot-rules/template-no-side-effects": "off",
|
||||
"gridpilot-rules/template-no-unsafe-html": "off"
|
||||
}
|
||||
},
|
||||
{
|
||||
@@ -111,33 +111,33 @@
|
||||
"app/**/layout.tsx"
|
||||
],
|
||||
"rules": {
|
||||
"@typescript-eslint/no-explicit-any": "off",
|
||||
"@typescript-eslint/no-unused-vars": "off",
|
||||
"gridpilot-rules/component-classification": "off",
|
||||
"gridpilot-rules/no-console": "off",
|
||||
"gridpilot-rules/no-direct-process-env": "off",
|
||||
"gridpilot-rules/no-hardcoded-routes": "off",
|
||||
"gridpilot-rules/no-hardcoded-search-params": "off",
|
||||
"gridpilot-rules/no-index-files": "off",
|
||||
"gridpilot-rules/no-next-cookies-in-pages": "off",
|
||||
"gridpilot-rules/no-raw-html-in-app": "off",
|
||||
"gridpilot-rules/rsc-no-container-manager": "off",
|
||||
"gridpilot-rules/rsc-no-page-data-fetcher": "off",
|
||||
"gridpilot-rules/rsc-no-view-models": "off",
|
||||
"gridpilot-rules/rsc-no-presenters": "off",
|
||||
"gridpilot-rules/rsc-no-intl": "off",
|
||||
"gridpilot-rules/rsc-no-sorting-filtering": "off",
|
||||
"gridpilot-rules/rsc-no-display-objects": "off",
|
||||
"gridpilot-rules/rsc-no-unsafe-services": "off",
|
||||
"gridpilot-rules/rsc-no-container-manager-calls": "off",
|
||||
"gridpilot-rules/rsc-no-di": "off",
|
||||
"gridpilot-rules/rsc-no-display-objects": "off",
|
||||
"gridpilot-rules/rsc-no-intl": "off",
|
||||
"gridpilot-rules/rsc-no-local-helpers": "off",
|
||||
"gridpilot-rules/rsc-no-object-construction": "off",
|
||||
"gridpilot-rules/rsc-no-container-manager-calls": "off",
|
||||
"gridpilot-rules/no-hardcoded-search-params": "off",
|
||||
"gridpilot-rules/no-next-cookies-in-pages": "off",
|
||||
"gridpilot-rules/no-hardcoded-routes": "off",
|
||||
"gridpilot-rules/component-classification": "off",
|
||||
"gridpilot-rules/no-raw-html-in-app": "off",
|
||||
"gridpilot-rules/no-console": "off",
|
||||
"gridpilot-rules/rsc-no-page-data-fetcher": "off",
|
||||
"gridpilot-rules/rsc-no-presenters": "off",
|
||||
"gridpilot-rules/rsc-no-sorting-filtering": "off",
|
||||
"gridpilot-rules/rsc-no-unsafe-services": "off",
|
||||
"gridpilot-rules/rsc-no-view-models": "off",
|
||||
"import/no-default-export": "off",
|
||||
"no-restricted-syntax": "off",
|
||||
"react-hooks/exhaustive-deps": "off",
|
||||
"react-hooks/rules-of-hooks": "off",
|
||||
"react/no-unescaped-entities": "off",
|
||||
"gridpilot-rules/no-index-files": "off",
|
||||
"gridpilot-rules/no-direct-process-env": "off",
|
||||
"@typescript-eslint/no-explicit-any": "off",
|
||||
"@typescript-eslint/no-unused-vars": "off"
|
||||
"react/no-unescaped-entities": "off"
|
||||
}
|
||||
},
|
||||
{
|
||||
@@ -149,8 +149,8 @@
|
||||
"lib/mutations/auth/types/*.ts"
|
||||
],
|
||||
"rules": {
|
||||
"gridpilot-rules/no-direct-process-env": "off",
|
||||
"gridpilot-rules/clean-error-handling": "off"
|
||||
"gridpilot-rules/clean-error-handling": "off",
|
||||
"gridpilot-rules/no-direct-process-env": "off"
|
||||
}
|
||||
},
|
||||
{
|
||||
@@ -159,10 +159,10 @@
|
||||
"lib/display-objects/**/*.tsx"
|
||||
],
|
||||
"rules": {
|
||||
"gridpilot-rules/display-no-domain-models": "off",
|
||||
"gridpilot-rules/display-no-business-logic": "off",
|
||||
"gridpilot-rules/model-no-domain-in-display": "off",
|
||||
"gridpilot-rules/filename-display-match": "off"
|
||||
"gridpilot-rules/display-no-domain-models": "off",
|
||||
"gridpilot-rules/filename-display-match": "off",
|
||||
"gridpilot-rules/model-no-domain-in-display": "off"
|
||||
}
|
||||
},
|
||||
{
|
||||
@@ -170,17 +170,17 @@
|
||||
"lib/page-queries/**/*.ts"
|
||||
],
|
||||
"rules": {
|
||||
"gridpilot-rules/page-query-no-null-returns": "off",
|
||||
"gridpilot-rules/page-query-filename": "off",
|
||||
"gridpilot-rules/clean-error-handling": "off",
|
||||
"gridpilot-rules/filename-matches-export": "off",
|
||||
"gridpilot-rules/no-hardcoded-routes": "off",
|
||||
"gridpilot-rules/no-hardcoded-search-params": "off",
|
||||
"gridpilot-rules/page-query-contract": "off",
|
||||
"gridpilot-rules/page-query-execute": "off",
|
||||
"gridpilot-rules/page-query-return-type": "off",
|
||||
"gridpilot-rules/page-query-filename": "off",
|
||||
"gridpilot-rules/page-query-must-use-builders": "off",
|
||||
"gridpilot-rules/single-export-per-file": "off",
|
||||
"gridpilot-rules/filename-matches-export": "off",
|
||||
"gridpilot-rules/clean-error-handling": "off",
|
||||
"gridpilot-rules/no-hardcoded-routes": "off",
|
||||
"gridpilot-rules/no-hardcoded-search-params": "off"
|
||||
"gridpilot-rules/page-query-no-null-returns": "off",
|
||||
"gridpilot-rules/page-query-return-type": "off",
|
||||
"gridpilot-rules/single-export-per-file": "off"
|
||||
}
|
||||
},
|
||||
{
|
||||
@@ -197,11 +197,11 @@
|
||||
"lib/services/**/*.ts"
|
||||
],
|
||||
"rules": {
|
||||
"gridpilot-rules/services-no-external-api": "off",
|
||||
"gridpilot-rules/services-must-be-pure": "off",
|
||||
"gridpilot-rules/filename-service-match": "off",
|
||||
"gridpilot-rules/services-implement-contract": "off",
|
||||
"gridpilot-rules/services-must-be-pure": "off",
|
||||
"gridpilot-rules/services-must-return-result": "off",
|
||||
"gridpilot-rules/services-implement-contract": "off"
|
||||
"gridpilot-rules/services-no-external-api": "off"
|
||||
}
|
||||
},
|
||||
{
|
||||
@@ -210,12 +210,12 @@
|
||||
"app/**/*.ts"
|
||||
],
|
||||
"rules": {
|
||||
"gridpilot-rules/client-only-no-server-code": "off",
|
||||
"gridpilot-rules/client-only-must-have-directive": "off",
|
||||
"gridpilot-rules/server-actions-must-use-mutations": "off",
|
||||
"gridpilot-rules/server-actions-return-result": "off",
|
||||
"gridpilot-rules/client-only-no-server-code": "off",
|
||||
"gridpilot-rules/no-use-mutation-in-client": "off",
|
||||
"gridpilot-rules/server-actions-interface": "off",
|
||||
"gridpilot-rules/no-use-mutation-in-client": "off"
|
||||
"gridpilot-rules/server-actions-must-use-mutations": "off",
|
||||
"gridpilot-rules/server-actions-return-result": "off"
|
||||
}
|
||||
},
|
||||
{
|
||||
@@ -223,8 +223,8 @@
|
||||
"lib/write-boundaries/**/*.ts"
|
||||
],
|
||||
"rules": {
|
||||
"gridpilot-rules/write-boundary-no-direct-mutations": "error",
|
||||
"gridpilot-rules/write-boundary-must-use-repository": "error"
|
||||
"gridpilot-rules/write-boundary-must-use-repository": "error",
|
||||
"gridpilot-rules/write-boundary-no-direct-mutations": "error"
|
||||
}
|
||||
},
|
||||
{
|
||||
@@ -263,10 +263,10 @@
|
||||
"app/**/*.ts"
|
||||
],
|
||||
"rules": {
|
||||
"gridpilot-rules/no-raw-html-in-app": "off",
|
||||
"gridpilot-rules/no-nextjs-imports-in-ui": "off",
|
||||
"gridpilot-rules/component-classification": "off",
|
||||
"gridpilot-rules/no-hardcoded-routes": "off",
|
||||
"gridpilot-rules/component-classification": "off"
|
||||
"gridpilot-rules/no-nextjs-imports-in-ui": "off",
|
||||
"gridpilot-rules/no-raw-html-in-app": "off"
|
||||
}
|
||||
},
|
||||
{
|
||||
@@ -276,8 +276,8 @@
|
||||
],
|
||||
"rules": {
|
||||
"gridpilot-rules/no-hardcoded-routes": "error",
|
||||
"gridpilot-rules/server-actions-return-result": "error",
|
||||
"gridpilot-rules/server-actions-interface": "error"
|
||||
"gridpilot-rules/server-actions-interface": "error",
|
||||
"gridpilot-rules/server-actions-return-result": "error"
|
||||
}
|
||||
},
|
||||
{
|
||||
@@ -286,9 +286,9 @@
|
||||
"ui/**/*.ts"
|
||||
],
|
||||
"rules": {
|
||||
"gridpilot-rules/ui-element-purity": "error",
|
||||
"gridpilot-rules/component-classification": "error",
|
||||
"gridpilot-rules/no-nextjs-imports-in-ui": "error",
|
||||
"gridpilot-rules/component-classification": "error"
|
||||
"gridpilot-rules/ui-element-purity": "error"
|
||||
}
|
||||
},
|
||||
{
|
||||
@@ -297,11 +297,10 @@
|
||||
"components/**/*.ts"
|
||||
],
|
||||
"rules": {
|
||||
"gridpilot-rules/no-nextjs-imports-in-ui": "off",
|
||||
"gridpilot-rules/component-classification": "off",
|
||||
"gridpilot-rules/no-hardcoded-routes": "off",
|
||||
"gridpilot-rules/no-nextjs-imports-in-ui": "off",
|
||||
"gridpilot-rules/no-raw-html-in-app": "error",
|
||||
"gridpilot-rules/no-generic-ui-primitives-in-components": "error",
|
||||
"no-restricted-imports": "off"
|
||||
}
|
||||
},
|
||||
@@ -310,8 +309,7 @@
|
||||
"components/mockups/**/*.tsx"
|
||||
],
|
||||
"rules": {
|
||||
"gridpilot-rules/no-raw-html-in-app": "off",
|
||||
"gridpilot-rules/no-generic-ui-primitives-in-components": "off"
|
||||
"gridpilot-rules/no-raw-html-in-app": "off"
|
||||
}
|
||||
},
|
||||
{
|
||||
@@ -319,11 +317,11 @@
|
||||
"lib/services/**/*.ts"
|
||||
],
|
||||
"rules": {
|
||||
"gridpilot-rules/no-hardcoded-routes": "off",
|
||||
"gridpilot-rules/service-function-format": "off",
|
||||
"gridpilot-rules/services-must-be-pure": "off",
|
||||
"gridpilot-rules/services-no-external-api": "off",
|
||||
"gridpilot-rules/services-implement-contract": "off",
|
||||
"gridpilot-rules/no-hardcoded-routes": "off"
|
||||
"gridpilot-rules/services-must-be-pure": "off",
|
||||
"gridpilot-rules/services-no-external-api": "off"
|
||||
}
|
||||
},
|
||||
{
|
||||
@@ -350,8 +348,8 @@
|
||||
"off",
|
||||
{
|
||||
"argsIgnorePattern": "^_",
|
||||
"varsIgnorePattern": "^_",
|
||||
"caughtErrorsIgnorePattern": "^_"
|
||||
"caughtErrorsIgnorePattern": "^_",
|
||||
"varsIgnorePattern": "^_"
|
||||
}
|
||||
],
|
||||
"boundaries/element-types": [
|
||||
@@ -370,6 +368,7 @@
|
||||
]
|
||||
}
|
||||
],
|
||||
"gridpilot-rules/no-index-files": "off",
|
||||
"import/no-default-export": "off",
|
||||
"import/no-named-as-default-member": "off",
|
||||
"no-restricted-syntax": "off",
|
||||
@@ -377,8 +376,7 @@
|
||||
"react-hooks/rules-of-hooks": "off",
|
||||
"react/no-unescaped-entities": "off",
|
||||
"unused-imports/no-unused-imports": "off",
|
||||
"unused-imports/no-unused-vars": "off",
|
||||
"gridpilot-rules/no-index-files": "off"
|
||||
"unused-imports/no-unused-vars": "off"
|
||||
},
|
||||
"settings": {
|
||||
"boundaries/elements": [
|
||||
@@ -393,4 +391,4 @@
|
||||
"typescript": {}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
import { Card } from '@/ui/Card';
|
||||
import { Text } from '@/ui/Text';
|
||||
import { Group } from '@/ui/Group';
|
||||
import { Stack } from '@/ui/Stack';
|
||||
import { DateDisplay } from '@/lib/display-objects/DateDisplay';
|
||||
|
||||
interface AchievementCardProps {
|
||||
@@ -31,13 +32,13 @@ export function AchievementCard({
|
||||
>
|
||||
<Group direction="row" align="start" gap={3}>
|
||||
<Text size="3xl">{icon}</Text>
|
||||
<Group direction="column" gap={1}>
|
||||
<Stack gap={1}>
|
||||
<Text weight="medium" variant="high">{title}</Text>
|
||||
<Text size="xs" variant="med">{description}</Text>
|
||||
<Text size="xs" variant="low">
|
||||
{DateDisplay.formatShort(unlockedAt)}
|
||||
</Text>
|
||||
</Group>
|
||||
</Stack>
|
||||
</Group>
|
||||
</Card>
|
||||
);
|
||||
|
||||
@@ -4,6 +4,9 @@ import { Grid } from '@/ui/Grid';
|
||||
import { Heading } from '@/ui/Heading';
|
||||
import { Icon } from '@/ui/Icon';
|
||||
import { Group } from '@/ui/Group';
|
||||
import { Stack } from '@/ui/Stack';
|
||||
import { Box } from '@/ui/Box';
|
||||
import { Surface } from '@/ui/Surface';
|
||||
import { Text } from '@/ui/Text';
|
||||
import { Award, Crown, Medal, Star, Target, Trophy, Zap } from 'lucide-react';
|
||||
import React from 'react';
|
||||
@@ -36,9 +39,9 @@ function getAchievementIcon(icon: string) {
|
||||
export function AchievementGrid({ achievements }: AchievementGridProps) {
|
||||
return (
|
||||
<Card>
|
||||
<Group direction="column" gap={4} fullWidth>
|
||||
<Group direction="row" align="center" justify="between" fullWidth>
|
||||
<Group direction="row" align="center" gap={2}>
|
||||
<Stack gap={4} fullWidth>
|
||||
<Group align="center" justify="between" fullWidth>
|
||||
<Group align="center" gap={2}>
|
||||
<Icon icon={Award} size={5} intent="warning" />
|
||||
<Heading level={2}>
|
||||
Achievements
|
||||
@@ -46,38 +49,39 @@ export function AchievementGrid({ achievements }: AchievementGridProps) {
|
||||
</Group>
|
||||
<Text size="sm" variant="low" weight="normal">{achievements.length} earned</Text>
|
||||
</Group>
|
||||
</Group>
|
||||
<Grid cols={1} gap={4}>
|
||||
{achievements.map((achievement) => {
|
||||
const AchievementIcon = getAchievementIcon(achievement.icon);
|
||||
const rarity = AchievementDisplay.getRarityVariant(achievement.rarity);
|
||||
return (
|
||||
<Card
|
||||
key={achievement.id}
|
||||
variant={rarity.surface}
|
||||
>
|
||||
<Group direction="row" align="start" gap={3}>
|
||||
<Card variant="default" style={{ padding: '0.75rem' }}>
|
||||
<Icon icon={AchievementIcon} size={5} intent={rarity.iconIntent} />
|
||||
</Card>
|
||||
<Group direction="column" fullWidth>
|
||||
<Text weight="semibold" size="sm" variant="high" block>{achievement.title}</Text>
|
||||
<Text size="xs" variant="med" block>{achievement.description}</Text>
|
||||
<Group direction="row" align="center" gap={2}>
|
||||
<Text size="xs" variant={rarity.text} weight="medium" uppercase>
|
||||
{achievement.rarity}
|
||||
</Text>
|
||||
<Text size="xs" variant="low">•</Text>
|
||||
<Text size="xs" variant="low">
|
||||
{AchievementDisplay.formatDate(achievement.earnedAt)}
|
||||
</Text>
|
||||
</Group>
|
||||
|
||||
<Grid cols={1} gap={4}>
|
||||
{achievements.map((achievement) => {
|
||||
const AchievementIcon = getAchievementIcon(achievement.icon);
|
||||
const rarity = AchievementDisplay.getRarityVariant(achievement.rarity);
|
||||
return (
|
||||
<Card
|
||||
key={achievement.id}
|
||||
variant={rarity.surface}
|
||||
>
|
||||
<Group align="start" gap={3}>
|
||||
<Surface variant="default" padding={3} rounded="md">
|
||||
<Icon icon={AchievementIcon} size={5} intent={rarity.iconIntent} />
|
||||
</Surface>
|
||||
<Stack flex={1} gap={1}>
|
||||
<Text weight="semibold" size="sm" variant="high" block>{achievement.title}</Text>
|
||||
<Text size="xs" variant="med" block>{achievement.description}</Text>
|
||||
<Group align="center" gap={2}>
|
||||
<Text size="xs" variant={rarity.text} weight="medium" uppercase>
|
||||
{achievement.rarity}
|
||||
</Text>
|
||||
<Text size="xs" variant="low">•</Text>
|
||||
<Text size="xs" variant="low">
|
||||
{AchievementDisplay.formatDate(achievement.earnedAt)}
|
||||
</Text>
|
||||
</Group>
|
||||
</Stack>
|
||||
</Group>
|
||||
</Group>
|
||||
</Card>
|
||||
);
|
||||
})}
|
||||
</Grid>
|
||||
</Card>
|
||||
);
|
||||
})}
|
||||
</Grid>
|
||||
</Stack>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
'use client';
|
||||
|
||||
import { Card } from '@/ui/Card';
|
||||
import { Box } from '@/ui/Box';
|
||||
import React from 'react';
|
||||
|
||||
interface AdminDataTableProps {
|
||||
@@ -20,14 +21,12 @@ export function AdminDataTable({
|
||||
}: AdminDataTableProps) {
|
||||
return (
|
||||
<Card padding={0}>
|
||||
<div
|
||||
style={{
|
||||
overflow: 'auto',
|
||||
maxHeight: typeof maxHeight === 'number' ? `${maxHeight}px` : maxHeight
|
||||
}}
|
||||
<Box
|
||||
overflow="auto"
|
||||
maxHeight={typeof maxHeight === 'number' ? `${maxHeight}px` : maxHeight}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
</Box>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -6,6 +6,8 @@ import { Button } from '@/ui/Button';
|
||||
import { IconButton } from '@/ui/IconButton';
|
||||
import { SimpleCheckbox } from '@/ui/SimpleCheckbox';
|
||||
import { Badge } from '@/ui/Badge';
|
||||
import { Box } from '@/ui/Box';
|
||||
import { Group } from '@/ui/Group';
|
||||
import { DriverIdentity } from '@/ui/DriverIdentity';
|
||||
import {
|
||||
Table,
|
||||
@@ -87,13 +89,13 @@ export function AdminUsersTable({
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<div style={{ display: 'flex', gap: '0.375rem', flexWrap: 'wrap' }}>
|
||||
<Group gap={1.5} wrap>
|
||||
{user.roles.map((role) => (
|
||||
<Badge key={role} variant="default" size="sm">
|
||||
{role}
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
</Group>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<UserStatusTag status={user.status} />
|
||||
@@ -104,7 +106,7 @@ export function AdminUsersTable({
|
||||
</Text>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'flex-end', gap: '0.5rem' }}>
|
||||
<Group gap={2} justify="end">
|
||||
{user.status === 'active' ? (
|
||||
<Button
|
||||
size="sm"
|
||||
@@ -138,7 +140,7 @@ export function AdminUsersTable({
|
||||
icon={MoreVertical}
|
||||
title="More"
|
||||
/>
|
||||
</div>
|
||||
</Group>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
|
||||
@@ -5,6 +5,8 @@ import { Icon } from '@/ui/Icon';
|
||||
import { Input } from '@/ui/Input';
|
||||
import { Select } from '@/ui/Select';
|
||||
import { Text } from '@/ui/Text';
|
||||
import { Box } from '@/ui/Box';
|
||||
import { Group } from '@/ui/Group';
|
||||
import { Filter, Search } from 'lucide-react';
|
||||
import { AdminToolbar } from './AdminToolbar';
|
||||
import React from 'react';
|
||||
@@ -31,7 +33,7 @@ export function UserFilters({
|
||||
return (
|
||||
<AdminToolbar
|
||||
leftContent={
|
||||
<React.Fragment>
|
||||
<Group gap={3}>
|
||||
<Icon icon={Filter} size={4} intent="low" />
|
||||
<Text weight="medium" variant="high">Filters</Text>
|
||||
{(search || roleFilter || statusFilter) && (
|
||||
@@ -43,16 +45,18 @@ export function UserFilters({
|
||||
Clear all
|
||||
</Button>
|
||||
)}
|
||||
</React.Fragment>
|
||||
</Group>
|
||||
}
|
||||
>
|
||||
<Input
|
||||
type="text"
|
||||
placeholder="Search by email or name..."
|
||||
value={search}
|
||||
onChange={(e) => onSearch(e.target.value)}
|
||||
style={{ width: '300px' }}
|
||||
/>
|
||||
<Box width="18.75rem">
|
||||
<Input
|
||||
type="text"
|
||||
placeholder="Search by email or name..."
|
||||
value={search}
|
||||
onChange={(e) => onSearch(e.target.value)}
|
||||
fullWidth
|
||||
/>
|
||||
</Box>
|
||||
|
||||
<Select
|
||||
value={roleFilter}
|
||||
|
||||
@@ -1,18 +1,24 @@
|
||||
import React from 'react';
|
||||
import { Box } from '@/ui/Box';
|
||||
|
||||
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) {
|
||||
export function AppShell({ children }: AppShellProps) {
|
||||
return (
|
||||
<div className={`min-h-screen bg-[#0C0D0F] text-gray-100 flex flex-col ${className}`}>
|
||||
<Box
|
||||
minHeight="100vh"
|
||||
bg="#0C0D0F"
|
||||
color="var(--ui-color-text-high)"
|
||||
display="flex"
|
||||
flexDirection="col"
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
'use client';
|
||||
|
||||
import { Card } from '@/ui/Card';
|
||||
import { Text } from '@/ui/Text';
|
||||
import { Box } from '@/ui/Box';
|
||||
import { SectionHeader } from '@/ui/SectionHeader';
|
||||
import React from 'react';
|
||||
|
||||
@@ -24,9 +24,9 @@ export function AuthCard({ children, title, description }: AuthCardProps) {
|
||||
description={description}
|
||||
variant="minimal"
|
||||
/>
|
||||
<div>
|
||||
<Box>
|
||||
{children}
|
||||
</div>
|
||||
</Box>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
'use client';
|
||||
|
||||
import { Grid } from '@/ui/Grid';
|
||||
import { Box } from '@/ui/Box';
|
||||
import React from 'react';
|
||||
|
||||
interface AuthProviderButtonsProps {
|
||||
@@ -14,10 +15,10 @@ interface AuthProviderButtonsProps {
|
||||
*/
|
||||
export function AuthProviderButtons({ children }: AuthProviderButtonsProps) {
|
||||
return (
|
||||
<div style={{ marginBottom: '1.5rem' }}>
|
||||
<Box marginBottom={6}>
|
||||
<Grid cols={1} gap={3}>
|
||||
{children}
|
||||
</Grid>
|
||||
</div>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,6 +1,10 @@
|
||||
import { Heading } from '@/ui/Heading';
|
||||
import { Icon } from '@/ui/Icon';
|
||||
import { Text } from '@/ui/Text';
|
||||
import { Box } from '@/ui/Box';
|
||||
import { Group } from '@/ui/Group';
|
||||
import { Stack } from '@/ui/Stack';
|
||||
import { Surface } from '@/ui/Surface';
|
||||
import { ListItem, ListItemInfo } from '@/ui/ListItem';
|
||||
import { motion } from 'framer-motion';
|
||||
import { Car, Trophy, Users } from 'lucide-react';
|
||||
@@ -34,26 +38,34 @@ interface UserRolesPreviewProps {
|
||||
export function UserRolesPreview({ variant = 'full' }: UserRolesPreviewProps) {
|
||||
if (variant === 'compact') {
|
||||
return (
|
||||
<div style={{ marginTop: '2rem' }}>
|
||||
<Box marginTop={8}>
|
||||
<Text align="center" size="xs" variant="low" block marginBottom={4}>
|
||||
One account for all roles
|
||||
</Text>
|
||||
<div style={{ display: 'flex', justifyContent: 'center', gap: '1.5rem' }}>
|
||||
<Group justify="center" gap={6}>
|
||||
{USER_ROLES.map((role) => (
|
||||
<div key={role.title} style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', gap: '0.25rem' }}>
|
||||
<div style={{ width: '2rem', height: '2rem', borderRadius: '0.5rem', backgroundColor: 'var(--ui-color-bg-surface-muted)', display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
|
||||
<Stack key={role.title} align="center" gap={1}>
|
||||
<Surface
|
||||
width="2rem"
|
||||
height="2rem"
|
||||
rounded="md"
|
||||
variant="muted"
|
||||
display="flex"
|
||||
alignItems="center"
|
||||
justifyContent="center"
|
||||
>
|
||||
<Icon icon={role.icon} size={4} intent={role.intent} />
|
||||
</div>
|
||||
</Surface>
|
||||
<Text size="xs" variant="low">{role.title}</Text>
|
||||
</div>
|
||||
</Stack>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</Group>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '0.75rem', marginBottom: '2rem' }}>
|
||||
<Stack gap={3} marginBottom={8}>
|
||||
{USER_ROLES.map((role, index) => (
|
||||
<motion.div
|
||||
key={role.title}
|
||||
@@ -62,18 +74,26 @@ export function UserRolesPreview({ variant = 'full' }: UserRolesPreviewProps) {
|
||||
transition={{ delay: index * 0.1 }}
|
||||
>
|
||||
<ListItem>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '1rem' }}>
|
||||
<div style={{ width: '2.5rem', height: '2.5rem', borderRadius: '0.5rem', backgroundColor: 'var(--ui-color-bg-surface-muted)', display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
|
||||
<Group gap={4}>
|
||||
<Surface
|
||||
width="2.5rem"
|
||||
height="2.5rem"
|
||||
rounded="md"
|
||||
variant="muted"
|
||||
display="flex"
|
||||
alignItems="center"
|
||||
justifyContent="center"
|
||||
>
|
||||
<Icon icon={role.icon} size={5} intent={role.intent} />
|
||||
</div>
|
||||
</Surface>
|
||||
<ListItemInfo
|
||||
title={role.title}
|
||||
description={role.description}
|
||||
/>
|
||||
</div>
|
||||
</Group>
|
||||
</ListItem>
|
||||
</motion.div>
|
||||
))}
|
||||
</div>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -6,6 +6,9 @@ import { Button } from '@/ui/Button';
|
||||
import { Card } from '@/ui/Card';
|
||||
import { Pagination } from '@/ui/Pagination';
|
||||
import { Text } from '@/ui/Text';
|
||||
import { Box } from '@/ui/Box';
|
||||
import { Group } from '@/ui/Group';
|
||||
import { Stack } from '@/ui/Stack';
|
||||
import { ControlBar } from '@/ui/ControlBar';
|
||||
import { Trophy } from 'lucide-react';
|
||||
import { useEffect, useState } from 'react';
|
||||
@@ -55,9 +58,9 @@ export function ProfileRaceHistory({ driverId }: RaceHistoryProps) {
|
||||
}
|
||||
|
||||
return (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '1rem' }}>
|
||||
<Stack gap={4}>
|
||||
<ControlBar>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '0.5rem' }}>
|
||||
<Group gap={2}>
|
||||
<Button
|
||||
variant={filter === 'all' ? 'primary' : 'secondary'}
|
||||
onClick={() => { setFilter('all'); setPage(1); }}
|
||||
@@ -79,13 +82,13 @@ export function ProfileRaceHistory({ driverId }: RaceHistoryProps) {
|
||||
>
|
||||
Podiums
|
||||
</Button>
|
||||
</div>
|
||||
</Group>
|
||||
</ControlBar>
|
||||
|
||||
<Card>
|
||||
<div style={{ minHeight: '10rem', display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
|
||||
<Box minHeight="10rem" display="flex" alignItems="center" justifyContent="center">
|
||||
<Text variant="low">No results found for the selected filter.</Text>
|
||||
</div>
|
||||
</Box>
|
||||
|
||||
<Pagination
|
||||
currentPage={page}
|
||||
@@ -93,6 +96,6 @@ export function ProfileRaceHistory({ driverId }: RaceHistoryProps) {
|
||||
onPageChange={setPage}
|
||||
/>
|
||||
</Card>
|
||||
</div>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ import { Button } from '@/ui/Button';
|
||||
import { Icon } from '@/ui/Icon';
|
||||
import { Stack } from '@/ui/Stack';
|
||||
import { Text } from '@/ui/Text';
|
||||
import { Group } from '@/ui/Group';
|
||||
import { LucideIcon } from 'lucide-react';
|
||||
|
||||
interface SkillLevelButtonProps {
|
||||
@@ -28,13 +29,20 @@ export function SkillLevelButton({
|
||||
variant="ghost"
|
||||
onClick={onClick}
|
||||
fullWidth
|
||||
className={`${bgColor} border ${borderColor} flex items-center justify-between p-3 rounded-lg h-auto`}
|
||||
style={{
|
||||
padding: '0.75rem',
|
||||
height: 'auto',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
borderRadius: 'var(--ui-radius-lg)'
|
||||
}}
|
||||
>
|
||||
<Stack direction="row" align="center" gap={2}>
|
||||
<Group gap={2}>
|
||||
<Icon icon={icon} size={4} className={color} />
|
||||
<Text weight="medium" color="text-white">{label}</Text>
|
||||
</Stack>
|
||||
<Text size="sm" color="text-gray-400">{count} teams</Text>
|
||||
<Text weight="medium" variant="high">{label}</Text>
|
||||
</Group>
|
||||
<Text size="sm" variant="low">{count} teams</Text>
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -3,6 +3,8 @@
|
||||
import { Heading } from '@/ui/Heading';
|
||||
import { Icon } from '@/ui/Icon';
|
||||
import { Text } from '@/ui/Text';
|
||||
import { Stack } from '@/ui/Stack';
|
||||
import { Surface } from '@/ui/Surface';
|
||||
import { AlertTriangle } from 'lucide-react';
|
||||
import React from 'react';
|
||||
|
||||
@@ -20,30 +22,31 @@ interface AppErrorBoundaryViewProps {
|
||||
*/
|
||||
export function AppErrorBoundaryView({ title, description, children }: AppErrorBoundaryViewProps) {
|
||||
return (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', gap: '1.5rem', width: '100%' }}>
|
||||
<Stack alignItems="center" gap={6} fullWidth>
|
||||
{/* Header Icon */}
|
||||
<div
|
||||
style={{
|
||||
padding: '1rem',
|
||||
borderRadius: '9999px',
|
||||
backgroundColor: 'rgba(255, 190, 77, 0.1)',
|
||||
border: '1px solid rgba(255, 190, 77, 0.3)'
|
||||
}}
|
||||
<Surface
|
||||
padding={4}
|
||||
rounded="full"
|
||||
bg="rgba(255, 190, 77, 0.1)"
|
||||
border="1px solid rgba(255, 190, 77, 0.3)"
|
||||
display="flex"
|
||||
alignItems="center"
|
||||
justifyContent="center"
|
||||
>
|
||||
<Icon icon={AlertTriangle} size={8} intent="warning" />
|
||||
</div>
|
||||
</Surface>
|
||||
|
||||
{/* Typography */}
|
||||
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', gap: '0.5rem' }}>
|
||||
<Stack alignItems="center" gap={2}>
|
||||
<Heading level={1} weight="bold">
|
||||
{title}
|
||||
</Heading>
|
||||
<Text variant="low" align="center" style={{ maxWidth: '32rem' }} leading="relaxed">
|
||||
{description}
|
||||
</Text>
|
||||
</div>
|
||||
</Stack>
|
||||
|
||||
{children}
|
||||
</div>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -194,10 +194,10 @@ export function ErrorAnalyticsDashboard({
|
||||
{/* Header */}
|
||||
<Stack display="flex" alignItems="center" justifyContent="between" px={4} py={3} bg="bg-iron-gray/50" borderBottom borderColor="border-charcoal-outline">
|
||||
<Stack display="flex" alignItems="center" gap={2}>
|
||||
<Icon icon={Activity} size={4} color="rgb(239, 68, 68)" />
|
||||
<Text size="sm" weight="semibold" color="text-white">Error Analytics</Text>
|
||||
<Icon icon={Activity} size={4} intent="critical" />
|
||||
<Text size="sm" weight="semibold" variant="high">Error Analytics</Text>
|
||||
{isDev && (
|
||||
<Badge variant="danger" size="xs">
|
||||
<Badge variant="critical" size="sm">
|
||||
DEV
|
||||
</Badge>
|
||||
)}
|
||||
|
||||
@@ -4,6 +4,8 @@ import { Button } from '@/ui/Button';
|
||||
import { Card } from '@/ui/Card';
|
||||
import { Icon } from '@/ui/Icon';
|
||||
import { Text } from '@/ui/Text';
|
||||
import { Box } from '@/ui/Box';
|
||||
import { Stack } from '@/ui/Stack';
|
||||
import { Accordion } from '@/ui/Accordion';
|
||||
import { Copy } from 'lucide-react';
|
||||
import React, { useState } from 'react';
|
||||
@@ -40,9 +42,9 @@ export function ErrorDetails({ error }: ErrorDetailsProps) {
|
||||
};
|
||||
|
||||
return (
|
||||
<div style={{ width: '100%', marginTop: '1.5rem', paddingTop: '1.5rem', borderTop: '1px solid var(--ui-color-border-muted)' }}>
|
||||
<Box width="100%" marginTop={6} paddingTop={6} borderTop="1px solid var(--ui-color-border-muted)">
|
||||
<Accordion title="Technical Logs">
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '1rem' }}>
|
||||
<Stack gap={4}>
|
||||
<Card variant="outline">
|
||||
<Text font="mono" size="xs" variant="low" block leading="relaxed" style={{ maxHeight: '12rem', overflow: 'auto' }}>
|
||||
{error.stack || 'No stack trace available'}
|
||||
@@ -50,7 +52,7 @@ export function ErrorDetails({ error }: ErrorDetailsProps) {
|
||||
</Text>
|
||||
</Card>
|
||||
|
||||
<div style={{ display: 'flex', justifyContent: 'flex-end' }}>
|
||||
<Box display="flex" justifyContent="end">
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
@@ -59,9 +61,9 @@ export function ErrorDetails({ error }: ErrorDetailsProps) {
|
||||
>
|
||||
{copied ? 'Copied!' : 'Copy Details'}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Box>
|
||||
</Stack>
|
||||
</Accordion>
|
||||
</div>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -4,6 +4,8 @@ import { Button } from '@/ui/Button';
|
||||
import { Card } from '@/ui/Card';
|
||||
import { Icon } from '@/ui/Icon';
|
||||
import { Text } from '@/ui/Text';
|
||||
import { Box } from '@/ui/Box';
|
||||
import { Stack } from '@/ui/Stack';
|
||||
import { Accordion } from '@/ui/Accordion';
|
||||
import { Copy } from 'lucide-react';
|
||||
import React, { useState } from 'react';
|
||||
@@ -40,9 +42,9 @@ export function ErrorDetailsBlock({ error }: ErrorDetailsBlockProps) {
|
||||
};
|
||||
|
||||
return (
|
||||
<div style={{ width: '100%', marginTop: '1.5rem', paddingTop: '1.5rem', borderTop: '1px solid var(--ui-color-border-muted)' }}>
|
||||
<Box width="100%" marginTop={6} paddingTop={6} borderTop="1px solid var(--ui-color-border-muted)">
|
||||
<Accordion title="Technical Logs">
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '1rem' }}>
|
||||
<Stack gap={4}>
|
||||
<Card variant="outline">
|
||||
<Text font="mono" size="xs" variant="low" block leading="relaxed" style={{ maxHeight: '12rem', overflow: 'auto' }}>
|
||||
{error.stack || 'No stack trace available'}
|
||||
@@ -50,7 +52,7 @@ export function ErrorDetailsBlock({ error }: ErrorDetailsBlockProps) {
|
||||
</Text>
|
||||
</Card>
|
||||
|
||||
<div style={{ display: 'flex', justifyContent: 'flex-end' }}>
|
||||
<Box display="flex" justifyContent="end">
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
@@ -59,9 +61,9 @@ export function ErrorDetailsBlock({ error }: ErrorDetailsBlockProps) {
|
||||
>
|
||||
{copied ? 'Copied!' : 'Copy Details'}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Box>
|
||||
</Stack>
|
||||
</Accordion>
|
||||
</div>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -6,6 +6,9 @@ import { Glow } from '@/ui/Glow';
|
||||
import { Heading } from '@/ui/Heading';
|
||||
import { Icon } from '@/ui/Icon';
|
||||
import { Text } from '@/ui/Text';
|
||||
import { Box } from '@/ui/Box';
|
||||
import { Group } from '@/ui/Group';
|
||||
import { Stack } from '@/ui/Stack';
|
||||
import { ErrorPageContainer } from '@/ui/ErrorPageContainer';
|
||||
import { AlertTriangle, Home, RefreshCw, Terminal } from 'lucide-react';
|
||||
import React from 'react';
|
||||
@@ -28,39 +31,39 @@ export function GlobalErrorScreen({ error, reset, onHome }: GlobalErrorScreenPro
|
||||
{/* Background Accents - Subtle telemetry vibe */}
|
||||
<Glow color="primary" size="xl" position="center" opacity={0.03} />
|
||||
|
||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: '1.5rem', paddingBottom: '1rem', borderBottom: '1px solid var(--ui-color-border-default)' }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '0.75rem' }}>
|
||||
<Box display="flex" alignItems="center" justifyContent="between" marginBottom={6} paddingBottom={4} borderBottom="1px solid var(--ui-color-border-default)">
|
||||
<Group gap={3}>
|
||||
<Icon icon={AlertTriangle} size={5} intent="warning" />
|
||||
<Heading level={2} weight="bold">
|
||||
System Fault Detected
|
||||
</Heading>
|
||||
</div>
|
||||
</Group>
|
||||
<Text font="mono" size="xs" variant="low" uppercase>
|
||||
Status: Critical
|
||||
</Text>
|
||||
</div>
|
||||
</Box>
|
||||
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '2rem' }}>
|
||||
<Stack gap={8}>
|
||||
{/* Fault Description */}
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '1rem' }}>
|
||||
<Stack gap={4}>
|
||||
<Text variant="med" size="base" leading="relaxed">
|
||||
The application kernel encountered an unrecoverable execution error.
|
||||
Telemetry has been captured for diagnostic review.
|
||||
</Text>
|
||||
|
||||
<SystemStatusPanel error={error} />
|
||||
</div>
|
||||
</Stack>
|
||||
|
||||
{/* Recovery Actions */}
|
||||
<RecoveryActions onRetry={reset} onHome={onHome} />
|
||||
</div>
|
||||
</Stack>
|
||||
|
||||
{/* Footer / Metadata */}
|
||||
<div style={{ marginTop: '2rem', paddingTop: '1rem', borderTop: '1px solid var(--ui-color-border-default)', textAlign: 'right' }}>
|
||||
<Box marginTop={8} paddingTop={4} borderTop="1px solid var(--ui-color-border-default)" textAlign="right">
|
||||
<Text font="mono" size="xs" variant="low">
|
||||
GP-CORE-ERR-{error.digest?.substring(0, 8).toUpperCase() || 'UNKNOWN'}
|
||||
</Text>
|
||||
</div>
|
||||
</Box>
|
||||
</ErrorPageContainer>
|
||||
);
|
||||
}
|
||||
@@ -73,13 +76,13 @@ export function GlobalErrorScreen({ error, reset, onHome }: GlobalErrorScreenPro
|
||||
function SystemStatusPanel({ error }: { error: Error & { digest?: string } }) {
|
||||
return (
|
||||
<Card variant="outline">
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '0.75rem' }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '0.5rem' }}>
|
||||
<Stack gap={3}>
|
||||
<Group gap={2}>
|
||||
<Icon icon={Terminal} size={3} intent="low" />
|
||||
<Text font="mono" size="xs" variant="low" uppercase>
|
||||
Fault Log
|
||||
</Text>
|
||||
</div>
|
||||
</Group>
|
||||
<Text font="mono" size="sm" variant="warning" block>
|
||||
{error.message || 'Unknown execution fault'}
|
||||
</Text>
|
||||
@@ -88,7 +91,7 @@ function SystemStatusPanel({ error }: { error: Error & { digest?: string } }) {
|
||||
Digest: {error.digest}
|
||||
</Text>
|
||||
)}
|
||||
</div>
|
||||
</Stack>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -100,7 +103,7 @@ function SystemStatusPanel({ error }: { error: Error & { digest?: string } }) {
|
||||
*/
|
||||
function RecoveryActions({ onRetry, onHome }: { onRetry: () => void; onHome: () => void }) {
|
||||
return (
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '1rem', flexWrap: 'wrap' }}>
|
||||
<Group gap={4} wrap>
|
||||
<Button
|
||||
variant="primary"
|
||||
onClick={onRetry}
|
||||
@@ -115,6 +118,6 @@ function RecoveryActions({ onRetry, onHome }: { onRetry: () => void; onHome: ()
|
||||
>
|
||||
Return to Pits
|
||||
</Button>
|
||||
</div>
|
||||
</Group>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
import { Button } from '@/ui/Button';
|
||||
import { Text } from '@/ui/Text';
|
||||
import { Stack } from '@/ui/Stack';
|
||||
import React from 'react';
|
||||
|
||||
interface NotFoundCallToActionProps {
|
||||
@@ -17,7 +18,7 @@ interface NotFoundCallToActionProps {
|
||||
*/
|
||||
export function NotFoundCallToAction({ label, onClick }: NotFoundCallToActionProps) {
|
||||
return (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', gap: '1rem' }}>
|
||||
<Stack alignItems="center" gap={4}>
|
||||
<Button
|
||||
variant="primary"
|
||||
size="lg"
|
||||
@@ -28,6 +29,6 @@ export function NotFoundCallToAction({ label, onClick }: NotFoundCallToActionPro
|
||||
<Text size="xs" variant="low" uppercase>
|
||||
Telemetry connection lost
|
||||
</Text>
|
||||
</div>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
import { Button } from '@/ui/Button';
|
||||
import { Icon } from '@/ui/Icon';
|
||||
import { Group } from '@/ui/Group';
|
||||
import { Home, LifeBuoy, RefreshCw } from 'lucide-react';
|
||||
import React from 'react';
|
||||
|
||||
@@ -18,7 +19,7 @@ interface RecoveryActionsProps {
|
||||
*/
|
||||
export function RecoveryActions({ onRetry, onHome }: RecoveryActionsProps) {
|
||||
return (
|
||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'center', gap: '0.75rem', flexWrap: 'wrap', width: '100%' }}>
|
||||
<Group gap={3} justify="center" wrap fullWidth>
|
||||
<Button
|
||||
variant="primary"
|
||||
onClick={onRetry}
|
||||
@@ -43,6 +44,6 @@ export function RecoveryActions({ onRetry, onHome }: RecoveryActionsProps) {
|
||||
>
|
||||
Contact Support
|
||||
</Button>
|
||||
</div>
|
||||
</Group>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -4,6 +4,9 @@ import { Card } from '@/ui/Card';
|
||||
import { Heading } from '@/ui/Heading';
|
||||
import { Icon } from '@/ui/Icon';
|
||||
import { Text } from '@/ui/Text';
|
||||
import { Box } from '@/ui/Box';
|
||||
import { Stack } from '@/ui/Stack';
|
||||
import { Surface } from '@/ui/Surface';
|
||||
import { AlertTriangle } from 'lucide-react';
|
||||
import React from 'react';
|
||||
|
||||
@@ -20,24 +23,22 @@ interface ServerErrorPanelProps {
|
||||
*/
|
||||
export function ServerErrorPanel({ message, incidentId }: ServerErrorPanelProps) {
|
||||
return (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', gap: '1.5rem', width: '100%' }}>
|
||||
<Stack alignItems="center" gap={6} fullWidth>
|
||||
{/* Status Indicator */}
|
||||
<div
|
||||
style={{
|
||||
padding: '1rem',
|
||||
borderRadius: '9999px',
|
||||
backgroundColor: 'rgba(255, 190, 77, 0.1)',
|
||||
border: '1px solid rgba(255, 190, 77, 0.3)',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center'
|
||||
}}
|
||||
<Surface
|
||||
padding={4}
|
||||
rounded="full"
|
||||
bg="rgba(255, 190, 77, 0.1)"
|
||||
border="1px solid rgba(255, 190, 77, 0.3)"
|
||||
display="flex"
|
||||
alignItems="center"
|
||||
justifyContent="center"
|
||||
>
|
||||
<Icon icon={AlertTriangle} size={8} intent="warning" />
|
||||
</div>
|
||||
</Surface>
|
||||
|
||||
{/* Primary Message */}
|
||||
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', gap: '0.5rem' }}>
|
||||
<Stack alignItems="center" gap={2}>
|
||||
<Heading level={1} weight="bold">
|
||||
CRITICAL_SYSTEM_FAILURE
|
||||
</Heading>
|
||||
@@ -45,12 +46,12 @@ export function ServerErrorPanel({ message, incidentId }: ServerErrorPanelProps)
|
||||
The application engine encountered an unrecoverable state.
|
||||
Telemetry has been dispatched to engineering.
|
||||
</Text>
|
||||
</div>
|
||||
</Stack>
|
||||
|
||||
{/* Technical Summary */}
|
||||
<div style={{ width: '100%' }}>
|
||||
<Box fullWidth>
|
||||
<Card variant="outline">
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '0.5rem' }}>
|
||||
<Stack gap={2}>
|
||||
<Text font="mono" size="sm" variant="warning" block>
|
||||
STATUS: 500_INTERNAL_SERVER_ERROR
|
||||
</Text>
|
||||
@@ -64,9 +65,9 @@ export function ServerErrorPanel({ message, incidentId }: ServerErrorPanelProps)
|
||||
INCIDENT_ID: {incidentId}
|
||||
</Text>
|
||||
)}
|
||||
</div>
|
||||
</Stack>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
</Box>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -3,6 +3,8 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { Button } from '@/ui/Button';
|
||||
import { FeedItem } from '@/ui/FeedItem';
|
||||
import { Text } from '@/ui/Text';
|
||||
import { Stack } from '@/ui/Stack';
|
||||
import { TimeDisplay } from '@/lib/display-objects/TimeDisplay';
|
||||
|
||||
interface FeedItemData {
|
||||
@@ -50,10 +52,10 @@ export function FeedItemCard({ item }: FeedItemCardProps) {
|
||||
}}
|
||||
timestamp={TimeDisplay.timeAgo(item.timestamp)}
|
||||
content={
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '0.5rem' }}>
|
||||
<div style={{ fontWeight: 'bold' }}>{item.headline}</div>
|
||||
{item.body && <div>{item.body}</div>}
|
||||
</div>
|
||||
<Stack gap={2}>
|
||||
<Text weight="bold" variant="high">{item.headline}</Text>
|
||||
{item.body && <Text variant="med">{item.body}</Text>}
|
||||
</Stack>
|
||||
}
|
||||
actions={item.ctaHref && item.ctaLabel ? (
|
||||
<Button
|
||||
|
||||
@@ -6,6 +6,8 @@ import { ActiveDriverCard } from '@/ui/ActiveDriverCard';
|
||||
import { mediaConfig } from '@/lib/config/mediaConfig';
|
||||
import { SectionHeader } from '@/ui/SectionHeader';
|
||||
import { Icon } from '@/ui/Icon';
|
||||
import { Box } from '@/ui/Box';
|
||||
import { Grid } from '@/ui/Grid';
|
||||
import { Activity } from 'lucide-react';
|
||||
import React from 'react';
|
||||
|
||||
@@ -25,7 +27,7 @@ export function RecentActivity({ drivers, onDriverClick }: RecentActivityProps)
|
||||
const activeDrivers = drivers.filter((d) => d.isActive).slice(0, 6);
|
||||
|
||||
return (
|
||||
<div style={{ marginBottom: '2.5rem' }}>
|
||||
<Box marginBottom={10}>
|
||||
<SectionHeader
|
||||
title="Active Drivers"
|
||||
description="Currently competing in leagues"
|
||||
@@ -33,7 +35,7 @@ export function RecentActivity({ drivers, onDriverClick }: RecentActivityProps)
|
||||
actions={<Icon icon={Activity} size={5} intent="success" />}
|
||||
/>
|
||||
|
||||
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fill, minmax(10rem, 1fr))', gap: '0.75rem' }}>
|
||||
<Grid cols={{ base: 2, sm: 3, md: 4, lg: 6 }} gap={3}>
|
||||
{activeDrivers.map((driver) => {
|
||||
return (
|
||||
<ActiveDriverCard
|
||||
@@ -46,7 +48,7 @@ export function RecentActivity({ drivers, onDriverClick }: RecentActivityProps)
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</Grid>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { Icon } from '@/ui/Icon';
|
||||
import { Text } from '@/ui/Text';
|
||||
import { Badge } from '@/ui/Badge';
|
||||
import { Group } from '@/ui/Group';
|
||||
import { ChevronDown, ChevronUp, Minus } from 'lucide-react';
|
||||
import React from 'react';
|
||||
|
||||
@@ -12,10 +13,10 @@ interface DeltaChipProps {
|
||||
export function DeltaChip({ value, type = 'rank' }: DeltaChipProps) {
|
||||
if (value === 0) {
|
||||
return (
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '0.25rem' }}>
|
||||
<Group gap={1}>
|
||||
<Icon icon={Minus} size={3} intent="low" />
|
||||
<Text size="xs" font="mono" variant="low">0</Text>
|
||||
</div>
|
||||
</Group>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -26,12 +27,12 @@ export function DeltaChip({ value, type = 'rank' }: DeltaChipProps) {
|
||||
|
||||
return (
|
||||
<Badge variant={variant} size="sm">
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '0.125rem' }}>
|
||||
<Group gap={0.5}>
|
||||
<Icon icon={IconComponent} size={3} />
|
||||
<Text size="xs" font="mono" weight="bold">
|
||||
{absoluteValue}
|
||||
</Text>
|
||||
</div>
|
||||
</Group>
|
||||
</Badge>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -2,6 +2,8 @@ import { Icon } from '@/ui/Icon';
|
||||
import { Input } from '@/ui/Input';
|
||||
import { ControlBar } from '@/ui/ControlBar';
|
||||
import { Button } from '@/ui/Button';
|
||||
import { Box } from '@/ui/Box';
|
||||
import { Group } from '@/ui/Group';
|
||||
import { Filter, Search } from 'lucide-react';
|
||||
import React from 'react';
|
||||
|
||||
@@ -19,10 +21,10 @@ export function LeaderboardFiltersBar({
|
||||
children,
|
||||
}: LeaderboardFiltersBarProps) {
|
||||
return (
|
||||
<div style={{ marginBottom: '1.5rem' }}>
|
||||
<Box marginBottom={6}>
|
||||
<ControlBar
|
||||
leftContent={
|
||||
<div style={{ maxWidth: '32rem', width: '100%' }}>
|
||||
<Box maxWidth="32rem" fullWidth>
|
||||
<Input
|
||||
type="text"
|
||||
value={searchQuery}
|
||||
@@ -31,10 +33,10 @@ export function LeaderboardFiltersBar({
|
||||
icon={<Icon icon={Search} size={4} intent="low" />}
|
||||
fullWidth
|
||||
/>
|
||||
</div>
|
||||
</Box>
|
||||
}
|
||||
>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '1rem' }}>
|
||||
<Group gap={4}>
|
||||
{children}
|
||||
<Button
|
||||
variant="secondary"
|
||||
@@ -43,8 +45,8 @@ export function LeaderboardFiltersBar({
|
||||
>
|
||||
Filters
|
||||
</Button>
|
||||
</div>
|
||||
</Group>
|
||||
</ControlBar>
|
||||
</div>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -18,42 +18,42 @@ export function LeaderboardsHero({ onNavigateToDrivers, onNavigateToTeams }: Lea
|
||||
return (
|
||||
<Surface
|
||||
variant="muted"
|
||||
rounded="2xl"
|
||||
rounded="xl"
|
||||
border
|
||||
padding={8}
|
||||
position="relative"
|
||||
overflow="hidden"
|
||||
bg="bg-gradient-to-br from-primary-blue/10 via-deep-charcoal to-graphite-black"
|
||||
borderColor="border-primary-blue/20"
|
||||
bg="linear-gradient(to bottom right, rgba(25, 140, 255, 0.1), var(--ui-color-bg-surface), var(--ui-color-bg-base))"
|
||||
borderColor="var(--ui-color-border-default)"
|
||||
>
|
||||
<DecorativeBlur color="blue" size="lg" position="top-right" opacity={10} />
|
||||
<DecorativeBlur color="purple" size="md" position="bottom-left" opacity={5} />
|
||||
|
||||
<Stack position="relative" zIndex={10}>
|
||||
<Stack direction="row" align="center" gap={4} mb={4}>
|
||||
<Stack
|
||||
p={3}
|
||||
<Stack direction="row" align="center" gap={4} marginBottom={4}>
|
||||
<Surface
|
||||
padding={3}
|
||||
bg="linear-gradient(to bottom right, rgba(25, 140, 255, 0.2), rgba(25, 140, 255, 0.05))"
|
||||
border
|
||||
borderColor="border-primary-blue/30"
|
||||
borderColor="rgba(25, 140, 255, 0.3)"
|
||||
rounded="xl"
|
||||
display="flex"
|
||||
alignItems="center"
|
||||
justifyContent="center"
|
||||
>
|
||||
<Icon icon={Award} size={7} color="text-primary-blue" />
|
||||
</Stack>
|
||||
<Icon icon={Award} size={7} intent="primary" />
|
||||
</Surface>
|
||||
<Stack>
|
||||
<Heading level={1} weight="bold" letterSpacing="tight">Leaderboards</Heading>
|
||||
<Text color="text-gray-400" block mt={1} size="sm" uppercase letterSpacing="widest" weight="bold">Precision Performance Tracking</Text>
|
||||
<Heading level={1} weight="bold">Leaderboards</Heading>
|
||||
<Text variant="low" block marginTop={1} size="sm" uppercase weight="bold">Precision Performance Tracking</Text>
|
||||
</Stack>
|
||||
</Stack>
|
||||
|
||||
<Text
|
||||
size="lg"
|
||||
color="text-gray-400"
|
||||
variant="low"
|
||||
block
|
||||
mb={8}
|
||||
marginBottom={8}
|
||||
leading="relaxed"
|
||||
maxWidth="42rem"
|
||||
>
|
||||
@@ -65,7 +65,6 @@ export function LeaderboardsHero({ onNavigateToDrivers, onNavigateToTeams }: Lea
|
||||
variant="primary"
|
||||
onClick={onNavigateToDrivers}
|
||||
icon={<Icon icon={Trophy} size={4} />}
|
||||
shadow="shadow-lg shadow-primary-blue/20"
|
||||
>
|
||||
Driver Rankings
|
||||
</Button>
|
||||
@@ -73,7 +72,6 @@ export function LeaderboardsHero({ onNavigateToDrivers, onNavigateToTeams }: Lea
|
||||
variant="secondary"
|
||||
onClick={onNavigateToTeams}
|
||||
icon={<Icon icon={Users} size={4} />}
|
||||
hoverBg="bg-white/5"
|
||||
>
|
||||
Team Rankings
|
||||
</Button>
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { Badge } from '@/ui/Badge';
|
||||
import { Text } from '@/ui/Text';
|
||||
import { Group } from '@/ui/Group';
|
||||
import React from 'react';
|
||||
|
||||
interface RankBadgeProps {
|
||||
@@ -28,10 +29,10 @@ export function RankBadge({ rank, size = 'md' }: RankBadgeProps) {
|
||||
|
||||
return (
|
||||
<Badge variant={getVariant(rank)} size={size}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '0.25rem' }}>
|
||||
{medal && <span>{medal}</span>}
|
||||
<Group gap={1}>
|
||||
{medal && <Text size="xs">{medal}</Text>}
|
||||
<Text size="xs" weight="bold">#{rank}</Text>
|
||||
</div>
|
||||
</Group>
|
||||
</Badge>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { MedalDisplay } from '@/lib/display-objects/MedalDisplay';
|
||||
import { Icon } from '@/ui/Icon';
|
||||
import { Text } from '@/ui/Text';
|
||||
import { Surface } from '@/ui/Surface';
|
||||
import { Crown, Medal } from 'lucide-react';
|
||||
import React from 'react';
|
||||
|
||||
@@ -33,23 +34,21 @@ export function RankMedal({ rank, size = 'md', showIcon = true }: RankMedalProps
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
borderRadius: '9999px',
|
||||
border: '1px solid var(--ui-color-border-default)',
|
||||
height: sizePx[size],
|
||||
width: sizePx[size],
|
||||
backgroundColor: 'var(--ui-color-bg-surface-muted)'
|
||||
}}
|
||||
<Surface
|
||||
variant="muted"
|
||||
rounded="full"
|
||||
border
|
||||
height={sizePx[size]}
|
||||
width={sizePx[size]}
|
||||
display="flex"
|
||||
alignItems="center"
|
||||
justifyContent="center"
|
||||
>
|
||||
{isTop3 && showIcon ? (
|
||||
<Icon icon={rank === 1 ? Crown : Medal} size={iconSize[size]} intent={variant as any} />
|
||||
) : (
|
||||
<Text weight="bold" size={textSizeMap[size]} variant={variant as any}>{rank}</Text>
|
||||
)}
|
||||
</div>
|
||||
</Surface>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -2,6 +2,10 @@ import { getMediaUrl } from '@/lib/utilities/media';
|
||||
import { Image } from '@/ui/Image';
|
||||
import { TableCell, TableRow } from '@/ui/Table';
|
||||
import { Text } from '@/ui/Text';
|
||||
import { Box } from '@/ui/Box';
|
||||
import { Group } from '@/ui/Group';
|
||||
import { Stack } from '@/ui/Stack';
|
||||
import { Surface } from '@/ui/Surface';
|
||||
import { RankMedal } from './RankMedal';
|
||||
import React from 'react';
|
||||
|
||||
@@ -34,22 +38,22 @@ export function TeamRankingRow({
|
||||
onClick={onClick}
|
||||
>
|
||||
<TableCell>
|
||||
<div style={{ width: '2rem', display: 'flex', justifyContent: 'center' }}>
|
||||
<Box width="2rem" display="flex" justifyContent="center">
|
||||
<RankMedal rank={rank} size="md" />
|
||||
</div>
|
||||
</Box>
|
||||
</TableCell>
|
||||
|
||||
<TableCell>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '0.75rem' }}>
|
||||
<div style={{
|
||||
position: 'relative',
|
||||
width: '2.5rem',
|
||||
height: '2.5rem',
|
||||
borderRadius: '0.5rem',
|
||||
overflow: 'hidden',
|
||||
border: '1px solid var(--ui-color-border-default)',
|
||||
backgroundColor: 'var(--ui-color-bg-surface-muted)'
|
||||
}}>
|
||||
<Group gap={3}>
|
||||
<Surface
|
||||
position="relative"
|
||||
width="2.5rem"
|
||||
height="2.5rem"
|
||||
rounded="md"
|
||||
overflow="hidden"
|
||||
border
|
||||
variant="muted"
|
||||
>
|
||||
<Image
|
||||
src={logoUrl || getMediaUrl('team-logo', id)}
|
||||
alt={name}
|
||||
@@ -57,8 +61,8 @@ export function TeamRankingRow({
|
||||
height={40}
|
||||
objectFit="cover"
|
||||
/>
|
||||
</div>
|
||||
<div style={{ minWidth: 0 }}>
|
||||
</Surface>
|
||||
<Stack gap={0} flex={1} minWidth="0">
|
||||
<Text
|
||||
weight="semibold"
|
||||
variant="high"
|
||||
@@ -70,8 +74,8 @@ export function TeamRankingRow({
|
||||
<Text size="xs" variant="low" block>
|
||||
{memberCount} Members
|
||||
</Text>
|
||||
</div>
|
||||
</div>
|
||||
</Stack>
|
||||
</Group>
|
||||
</TableCell>
|
||||
|
||||
<TableCell textAlign="center">
|
||||
|
||||
@@ -4,6 +4,9 @@ import { IconButton } from '@/ui/IconButton';
|
||||
import { Panel } from '@/ui/Panel';
|
||||
import { Icon } from '@/ui/Icon';
|
||||
import { Text } from '@/ui/Text';
|
||||
import { Box } from '@/ui/Box';
|
||||
import { Group } from '@/ui/Group';
|
||||
import { Stack } from '@/ui/Stack';
|
||||
import { ListItem, ListItemInfo, ListItemActions } from '@/ui/ListItem';
|
||||
import { EmptyState } from '@/ui/EmptyState';
|
||||
import { Check, Clock, X, UserPlus } from 'lucide-react';
|
||||
@@ -34,17 +37,17 @@ export function JoinRequestsPanel({ requests, onAccept, onDecline }: JoinRequest
|
||||
|
||||
return (
|
||||
<Panel title={`Pending Requests (${requests.length})`}>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '0.75rem' }}>
|
||||
<Stack gap={3}>
|
||||
{requests.map((request) => (
|
||||
<ListItem key={request.id}>
|
||||
<ListItemInfo
|
||||
title={request.driverName}
|
||||
description={request.message}
|
||||
meta={
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '0.375rem' }}>
|
||||
<Group gap={1.5}>
|
||||
<Icon icon={Clock} size={3} intent="low" />
|
||||
<Text size="xs" variant="low" font="mono">{request.requestedAt}</Text>
|
||||
</div>
|
||||
</Group>
|
||||
}
|
||||
/>
|
||||
<ListItemActions>
|
||||
@@ -66,7 +69,7 @@ export function JoinRequestsPanel({ requests, onAccept, onDecline }: JoinRequest
|
||||
</ListItemActions>
|
||||
</ListItem>
|
||||
))}
|
||||
</div>
|
||||
</Stack>
|
||||
</Panel>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -7,6 +7,8 @@ import { Icon } from '@/ui/Icon';
|
||||
import { Input } from '@/ui/Input';
|
||||
import { Grid } from '@/ui/Grid';
|
||||
import { Stack } from '@/ui/Stack';
|
||||
import { Box } from '@/ui/Box';
|
||||
import { Group } from '@/ui/Group';
|
||||
import { Surface } from '@/ui/Surface';
|
||||
import { Text } from '@/ui/Text';
|
||||
import { TextArea } from '@/ui/TextArea';
|
||||
@@ -46,42 +48,38 @@ export function LeagueBasicsSection({
|
||||
return (
|
||||
<Stack gap={8}>
|
||||
{/* Emotional header for the step */}
|
||||
<Stack textAlign="center" pb={2}>
|
||||
<Stack mb={2}>
|
||||
<Stack textAlign="center" paddingBottom={2}>
|
||||
<Box marginBottom={2}>
|
||||
<Heading level={3}>
|
||||
Every great championship starts with a name
|
||||
</Heading>
|
||||
</Stack>
|
||||
<Stack maxWidth="lg" mx="auto">
|
||||
<Text size="sm" color="text-gray-400">
|
||||
</Box>
|
||||
<Box maxWidth="lg" marginX="auto">
|
||||
<Text size="sm" variant="low">
|
||||
This is where legends begin. Give your league an identity that drivers will remember.
|
||||
</Text>
|
||||
</Stack>
|
||||
</Box>
|
||||
</Stack>
|
||||
|
||||
{/* League name */}
|
||||
<Stack gap={3}>
|
||||
<Input
|
||||
label={
|
||||
<Stack direction="row" align="center" gap={2}>
|
||||
<Icon icon={FileText} size={4} color="var(--primary-blue)" />
|
||||
<Text size="sm" weight="medium" color="text-gray-300">League name *</Text>
|
||||
</Stack>
|
||||
}
|
||||
label="League name *"
|
||||
icon={<Icon icon={FileText} size={4} intent="primary" />}
|
||||
value={basics.name}
|
||||
onChange={(e: React.ChangeEvent<HTMLInputElement>) => updateBasics({ name: e.target.value })}
|
||||
placeholder="e.g., GridPilot Sprint Series"
|
||||
variant={errors?.name ? 'error' : 'default'}
|
||||
errorMessage={errors?.name}
|
||||
error={errors?.name}
|
||||
disabled={disabled}
|
||||
autoFocus
|
||||
fullWidth
|
||||
/>
|
||||
<Stack gap={2}>
|
||||
<Text size="xs" color="text-gray-500">
|
||||
<Text size="xs" variant="low">
|
||||
Make it memorable — this is what drivers will see first
|
||||
</Text>
|
||||
<Stack direction="row" wrap gap={2}>
|
||||
<Text size="xs" color="text-gray-500">Try:</Text>
|
||||
<Group wrap gap={2}>
|
||||
<Text size="xs" variant="low">Try:</Text>
|
||||
{[
|
||||
'Sunday Showdown Series',
|
||||
'Midnight Endurance League',
|
||||
@@ -94,27 +92,19 @@ export function LeagueBasicsSection({
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
disabled={disabled}
|
||||
rounded="full"
|
||||
fontSize="0.75rem"
|
||||
px={2}
|
||||
py={0.5}
|
||||
h="auto"
|
||||
rounded
|
||||
style={{ fontSize: '0.75rem', height: 'auto', padding: '0.125rem 0.5rem' }}
|
||||
>
|
||||
{name}
|
||||
</Button>
|
||||
))}
|
||||
</Stack>
|
||||
</Group>
|
||||
</Stack>
|
||||
</Stack>
|
||||
|
||||
{/* Description - Now Required */}
|
||||
<TextArea
|
||||
label={
|
||||
<Stack direction="row" align="center" gap={2}>
|
||||
<Icon icon={FileText} size={4} color="var(--primary-blue)" />
|
||||
<Text size="sm" weight="medium" color="text-gray-300">Tell your story *</Text>
|
||||
</Stack>
|
||||
}
|
||||
label="Tell your story *"
|
||||
value={basics.description ?? ''}
|
||||
onChange={(e: React.ChangeEvent<HTMLTextAreaElement>) =>
|
||||
updateBasics({
|
||||
@@ -123,27 +113,27 @@ export function LeagueBasicsSection({
|
||||
}
|
||||
rows={4}
|
||||
disabled={disabled}
|
||||
variant={errors?.description ? 'error' : 'default'}
|
||||
errorMessage={errors?.description}
|
||||
error={errors?.description}
|
||||
placeholder="What makes your league special? Tell drivers what to expect..."
|
||||
fullWidth
|
||||
/>
|
||||
|
||||
<Surface variant="muted" rounded="lg" border padding={4}>
|
||||
<Stack mb={3}>
|
||||
<Text size="xs" color="text-gray-400">
|
||||
<Text weight="medium" color="text-gray-300">Great descriptions include:</Text>
|
||||
<Box marginBottom={3}>
|
||||
<Text size="xs" variant="low">
|
||||
<Text weight="medium" variant="med">Great descriptions include:</Text>
|
||||
</Text>
|
||||
</Stack>
|
||||
</Box>
|
||||
<Grid cols={3} gap={3}>
|
||||
{[
|
||||
'Racing style & pace',
|
||||
'Schedule & timezone',
|
||||
'Community vibe'
|
||||
].map(item => (
|
||||
<Stack key={item} direction="row" align="start" gap={2}>
|
||||
<Icon icon={Check} size={3.5} color="var(--performance-green)" mt={0.5} />
|
||||
<Text size="xs" color="text-gray-400">{item}</Text>
|
||||
</Stack>
|
||||
<Group key={item} align="start" gap={2}>
|
||||
<Icon icon={Check} size={3.5} intent="success" style={{ marginTop: '0.125rem' }} />
|
||||
<Text size="xs" variant="low">{item}</Text>
|
||||
</Group>
|
||||
))}
|
||||
</Grid>
|
||||
</Surface>
|
||||
@@ -151,16 +141,13 @@ export function LeagueBasicsSection({
|
||||
{/* Game Platform */}
|
||||
<Stack gap={2}>
|
||||
<Input
|
||||
label={
|
||||
<Stack direction="row" align="center" gap={2}>
|
||||
<Icon icon={Gamepad2} size={4} color="var(--text-gray-400)" />
|
||||
<Text size="sm" weight="medium" color="text-gray-300">Game platform</Text>
|
||||
</Stack>
|
||||
}
|
||||
label="Game platform"
|
||||
icon={<Icon icon={Gamepad2} size={4} intent="low" />}
|
||||
value="iRacing"
|
||||
disabled
|
||||
fullWidth
|
||||
/>
|
||||
<Text size="xs" color="text-gray-500">
|
||||
<Text size="xs" variant="low">
|
||||
More platforms soon
|
||||
</Text>
|
||||
</Stack>
|
||||
|
||||
@@ -5,6 +5,9 @@ import { Icon } from '@/ui/Icon';
|
||||
import { Image } from '@/ui/Image';
|
||||
import { PlaceholderImage } from '@/ui/PlaceholderImage';
|
||||
import { Text } from '@/ui/Text';
|
||||
import { Box } from '@/ui/Box';
|
||||
import { Group } from '@/ui/Group';
|
||||
import { Surface } from '@/ui/Surface';
|
||||
import { LeagueCard as UILeagueCard, LeagueCardStats, LeagueCardFooter } from '@/ui/LeagueCard';
|
||||
import { Calendar as LucideCalendar } from 'lucide-react';
|
||||
import React, { ReactNode } from 'react';
|
||||
@@ -50,7 +53,14 @@ export function LeagueCard({
|
||||
onClick={onClick}
|
||||
coverUrl={coverUrl}
|
||||
logo={
|
||||
<div style={{ width: '3rem', height: '3rem', borderRadius: '0.5rem', overflow: 'hidden', border: '1px solid var(--ui-color-border-default)', backgroundColor: 'var(--ui-color-bg-base)' }}>
|
||||
<Surface
|
||||
width="3rem"
|
||||
height="3rem"
|
||||
rounded="md"
|
||||
overflow="hidden"
|
||||
border
|
||||
variant="dark"
|
||||
>
|
||||
{logoUrl ? (
|
||||
<Image
|
||||
src={logoUrl}
|
||||
@@ -62,7 +72,7 @@ export function LeagueCard({
|
||||
) : (
|
||||
<PlaceholderImage size={48} />
|
||||
)}
|
||||
</div>
|
||||
</Surface>
|
||||
}
|
||||
badges={
|
||||
<React.Fragment>
|
||||
@@ -71,12 +81,14 @@ export function LeagueCard({
|
||||
</React.Fragment>
|
||||
}
|
||||
>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '0.5rem', marginBottom: '0.25rem' }}>
|
||||
<div style={{ width: '0.25rem', height: '1rem', backgroundColor: 'var(--ui-color-intent-primary)' }} />
|
||||
<Heading level={3} weight="bold" truncate>{name}</Heading>
|
||||
</div>
|
||||
<Box marginBottom={1}>
|
||||
<Group gap={2}>
|
||||
<Box width="0.25rem" height="1rem" bg="var(--ui-color-intent-primary)" />
|
||||
<Heading level={3} weight="bold" truncate>{name}</Heading>
|
||||
</Group>
|
||||
</Box>
|
||||
|
||||
<Text size="xs" variant="low" lineClamp={2} style={{ height: '2.5rem', marginBottom: '1rem' }} block leading="relaxed">
|
||||
<Text size="xs" variant="low" lineClamp={2} style={{ height: '2.5rem' }} block leading="relaxed" marginBottom={4}>
|
||||
{description || 'No description available'}
|
||||
</Text>
|
||||
|
||||
@@ -88,20 +100,32 @@ export function LeagueCard({
|
||||
/>
|
||||
|
||||
{hasOpenSlots && (
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '0.375rem', padding: '0.25rem 0.5rem', backgroundColor: 'rgba(25, 140, 255, 0.05)', border: '1px solid rgba(25, 140, 255, 0.2)', borderRadius: 'var(--ui-radius-sm)', width: 'fit-content', marginBottom: '1rem' }}>
|
||||
<div style={{ width: '0.375rem', height: '0.375rem', borderRadius: '9999px', backgroundColor: 'var(--ui-color-intent-primary)' }} />
|
||||
<Text size="xs" variant="primary" weight="bold" uppercase>{openSlotsCount} OPEN</Text>
|
||||
</div>
|
||||
<Box marginBottom={4}>
|
||||
<Surface
|
||||
display="flex"
|
||||
alignItems="center"
|
||||
gap={1.5}
|
||||
paddingX={2}
|
||||
paddingY={1}
|
||||
bg="rgba(25, 140, 255, 0.05)"
|
||||
border="1px solid rgba(25, 140, 255, 0.2)"
|
||||
rounded="sm"
|
||||
width="fit-content"
|
||||
>
|
||||
<Box width="0.375rem" height="0.375rem" rounded="full" bg="var(--ui-color-intent-primary)" />
|
||||
<Text size="xs" variant="primary" weight="bold" uppercase>{openSlotsCount} OPEN</Text>
|
||||
</Surface>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
<LeagueCardFooter>
|
||||
{timingSummary && (
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '0.5rem' }}>
|
||||
<Group gap={2}>
|
||||
<Icon icon={LucideCalendar} size={3} intent="low" />
|
||||
<Text size="xs" variant="low" font="mono">
|
||||
{timingSummary.split('•')[1]?.trim() || timingSummary}
|
||||
</Text>
|
||||
</div>
|
||||
</Group>
|
||||
)}
|
||||
</LeagueCardFooter>
|
||||
</UILeagueCard>
|
||||
|
||||
@@ -4,6 +4,10 @@ import type { LeagueConfigFormModel } from '@/lib/types/LeagueConfigFormModel';
|
||||
import { Heading } from '@/ui/Heading';
|
||||
import { Icon } from '@/ui/Icon';
|
||||
import { Text } from '@/ui/Text';
|
||||
import { Box } from '@/ui/Box';
|
||||
import { Group } from '@/ui/Group';
|
||||
import { Stack } from '@/ui/Stack';
|
||||
import { Surface } from '@/ui/Surface';
|
||||
import { InfoFlyout } from '@/ui/InfoFlyout';
|
||||
import { Stepper } from '@/ui/Stepper';
|
||||
import { Button } from '@/ui/Button';
|
||||
@@ -91,14 +95,22 @@ export function LeagueDropSection({
|
||||
const needsN = dropPolicy.strategy !== 'none';
|
||||
|
||||
return (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '1rem' }}>
|
||||
<Stack gap={4}>
|
||||
{/* Section header */}
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '0.75rem' }}>
|
||||
<div style={{ display: 'flex', width: '2.5rem', height: '2.5rem', alignItems: 'center', justifyContent: 'center', borderRadius: '0.75rem', backgroundColor: 'rgba(25, 140, 255, 0.1)' }}>
|
||||
<Group gap={3}>
|
||||
<Surface
|
||||
display="flex"
|
||||
width="2.5rem"
|
||||
height="2.5rem"
|
||||
alignItems="center"
|
||||
justifyContent="center"
|
||||
rounded="md"
|
||||
bg="rgba(25, 140, 255, 0.1)"
|
||||
>
|
||||
<Icon icon={TrendingDown} size={5} intent="primary" />
|
||||
</div>
|
||||
<div style={{ flex: 1 }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '0.5rem' }}>
|
||||
</Surface>
|
||||
<Stack flex={1} gap={0}>
|
||||
<Group gap={2}>
|
||||
<Heading level={3}>Drop Rules</Heading>
|
||||
<IconButton
|
||||
ref={dropInfoRef}
|
||||
@@ -108,10 +120,10 @@ export function LeagueDropSection({
|
||||
onClick={() => setShowDropFlyout(true)}
|
||||
title="Help"
|
||||
/>
|
||||
</div>
|
||||
</Group>
|
||||
<Text size="xs" variant="low">Protect from bad races</Text>
|
||||
</div>
|
||||
</div>
|
||||
</Stack>
|
||||
</Group>
|
||||
|
||||
<InfoFlyout
|
||||
isOpen={showDropFlyout}
|
||||
@@ -126,7 +138,7 @@ export function LeagueDropSection({
|
||||
</InfoFlyout>
|
||||
|
||||
{/* Strategy buttons + N stepper inline */}
|
||||
<div style={{ display: 'flex', flexWrap: 'wrap', alignItems: 'center', gap: '0.5rem' }}>
|
||||
<Group gap={2} wrap>
|
||||
{DROP_OPTIONS.map((option) => {
|
||||
const isSelected = dropPolicy.strategy === option.value;
|
||||
return (
|
||||
@@ -137,26 +149,26 @@ export function LeagueDropSection({
|
||||
onClick={() => handleStrategyChange(option.value)}
|
||||
disabled={disabled}
|
||||
>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '0.5rem' }}>
|
||||
<Group gap={2}>
|
||||
{isSelected && <Icon icon={Check} size={3} />}
|
||||
<span>{option.emoji}</span>
|
||||
<span>{option.label}</span>
|
||||
</div>
|
||||
<Text as="span">{option.emoji}</Text>
|
||||
<Text as="span">{option.label}</Text>
|
||||
</Group>
|
||||
</Button>
|
||||
);
|
||||
})}
|
||||
|
||||
{needsN && (
|
||||
<div style={{ marginLeft: '0.5rem' }}>
|
||||
<Box marginLeft={2}>
|
||||
<Stepper
|
||||
value={dropPolicy.n ?? 1}
|
||||
onChange={handleNChange}
|
||||
label="N ="
|
||||
disabled={disabled}
|
||||
/>
|
||||
</div>
|
||||
</Box>
|
||||
)}
|
||||
</div>
|
||||
</Group>
|
||||
|
||||
{/* Explanation text */}
|
||||
<Text size="xs" variant="low" block>
|
||||
@@ -164,6 +176,6 @@ export function LeagueDropSection({
|
||||
{dropPolicy.strategy === 'bestNResults' && `Only your best ${dropPolicy.n ?? 1} results will count.`}
|
||||
{dropPolicy.strategy === 'dropWorstN' && `Your worst ${dropPolicy.n ?? 1} results will be excluded.`}
|
||||
</Text>
|
||||
</div>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -14,6 +14,8 @@ import { DriverViewModel } from '@/lib/view-models/DriverViewModel';
|
||||
import { Button } from '@/ui/Button';
|
||||
import { Select } from '@/ui/Select';
|
||||
import { Text } from '@/ui/Text';
|
||||
import { Box } from '@/ui/Box';
|
||||
import { Group } from '@/ui/Group';
|
||||
import { ControlBar } from '@/ui/ControlBar';
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
import React from 'react';
|
||||
@@ -135,7 +137,7 @@ export function LeagueMembers({
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<Box>
|
||||
<ControlBar
|
||||
leftContent={
|
||||
<Text size="sm" variant="low">
|
||||
@@ -143,7 +145,7 @@ export function LeagueMembers({
|
||||
</Text>
|
||||
}
|
||||
>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '0.5rem' }}>
|
||||
<Group gap={2}>
|
||||
<Text size="sm" variant="low">Sort by:</Text>
|
||||
<Select
|
||||
value={sortBy}
|
||||
@@ -158,10 +160,10 @@ export function LeagueMembers({
|
||||
]}
|
||||
fullWidth={false}
|
||||
/>
|
||||
</div>
|
||||
</Group>
|
||||
</ControlBar>
|
||||
|
||||
<div style={{ overflowX: 'auto', marginTop: '1rem' }}>
|
||||
<Box overflowX="auto" marginTop={4}>
|
||||
<LeagueMemberTable showActions={showActions}>
|
||||
{sortedMembers.map((member, index) => {
|
||||
const isCurrentUser = member.driverId === currentDriverId;
|
||||
@@ -190,7 +192,7 @@ export function LeagueMembers({
|
||||
href={routes.driver.detail(member.driverId)}
|
||||
meta={ratingAndWinsMeta}
|
||||
actions={showActions && !cannotModify && !isCurrentUser ? (
|
||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'flex-end', gap: '0.5rem' }}>
|
||||
<Group gap={2} justify="end">
|
||||
{onUpdateRole && (
|
||||
<Select
|
||||
value={member.role}
|
||||
@@ -212,13 +214,13 @@ export function LeagueMembers({
|
||||
<Text variant="critical">Remove</Text>
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</Group>
|
||||
) : (showActions && cannotModify ? <Text size="xs" variant="low">—</Text> : undefined)}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</LeagueMemberTable>
|
||||
</div>
|
||||
</div>
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -4,6 +4,8 @@ import { Icon } from '@/ui/Icon';
|
||||
import { Panel } from '@/ui/Panel';
|
||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/ui/Table';
|
||||
import { Text } from '@/ui/Text';
|
||||
import { Box } from '@/ui/Box';
|
||||
import { Group } from '@/ui/Group';
|
||||
import { PositionBadge } from '@/ui/ResultRow';
|
||||
import { TrendingUp, Trophy } from 'lucide-react';
|
||||
import React from 'react';
|
||||
@@ -32,7 +34,7 @@ export function StandingsTableShell({ standings, title = 'Championship Standings
|
||||
<Text size="xs" variant="low">{standings.length} Drivers</Text>
|
||||
}
|
||||
>
|
||||
<div style={{ overflowX: 'auto' }}>
|
||||
<Box overflowX="auto">
|
||||
<Table>
|
||||
<TableHead>
|
||||
<TableRow>
|
||||
@@ -50,10 +52,10 @@ export function StandingsTableShell({ standings, title = 'Championship Standings
|
||||
<PositionBadge position={entry.position} />
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '0.75rem' }}>
|
||||
<Group gap={3}>
|
||||
<Text weight="bold" variant="high">{entry.driverName}</Text>
|
||||
{entry.change !== undefined && entry.change !== 0 && (
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '0.125rem' }}>
|
||||
<Group gap={0.5}>
|
||||
<Icon
|
||||
icon={TrendingUp}
|
||||
size={3}
|
||||
@@ -63,9 +65,9 @@ export function StandingsTableShell({ standings, title = 'Championship Standings
|
||||
<Text size="xs" variant={entry.change > 0 ? 'success' : 'critical'}>
|
||||
{Math.abs(entry.change)}
|
||||
</Text>
|
||||
</div>
|
||||
</Group>
|
||||
)}
|
||||
</div>
|
||||
</Group>
|
||||
</TableCell>
|
||||
<TableCell textAlign="center">
|
||||
<Text size="sm" variant={entry.wins > 0 ? 'high' : 'low'}>{entry.wins}</Text>
|
||||
@@ -80,7 +82,7 @@ export function StandingsTableShell({ standings, title = 'Championship Standings
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
</Box>
|
||||
</Panel>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -6,6 +6,8 @@ import { Select } from '@/ui/Select';
|
||||
import { ControlBar } from '@/ui/ControlBar';
|
||||
import { SegmentedControl } from '@/ui/SegmentedControl';
|
||||
import { Icon } from '@/ui/Icon';
|
||||
import { Box } from '@/ui/Box';
|
||||
import { Group } from '@/ui/Group';
|
||||
import { Grid, List, Search } from 'lucide-react';
|
||||
import React from 'react';
|
||||
|
||||
@@ -29,10 +31,10 @@ export function MediaFiltersBar({
|
||||
onViewModeChange,
|
||||
}: MediaFiltersBarProps) {
|
||||
return (
|
||||
<div style={{ marginBottom: '1.5rem' }}>
|
||||
<Box marginBottom={6}>
|
||||
<ControlBar
|
||||
leftContent={
|
||||
<div style={{ maxWidth: '32rem', width: '100%' }}>
|
||||
<Box maxWidth="32rem" fullWidth>
|
||||
<Input
|
||||
placeholder="Search media assets..."
|
||||
value={searchQuery}
|
||||
@@ -40,17 +42,17 @@ export function MediaFiltersBar({
|
||||
icon={<Icon icon={Search} size={4} intent="low" />}
|
||||
fullWidth
|
||||
/>
|
||||
</div>
|
||||
</Box>
|
||||
}
|
||||
>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '1rem' }}>
|
||||
<div style={{ width: '10rem' }}>
|
||||
<Group gap={4}>
|
||||
<Box width="10rem">
|
||||
<Select
|
||||
value={category}
|
||||
onChange={(e) => onCategoryChange(e.target.value)}
|
||||
options={categories}
|
||||
/>
|
||||
</div>
|
||||
</Box>
|
||||
|
||||
{onViewModeChange && (
|
||||
<SegmentedControl
|
||||
@@ -62,8 +64,8 @@ export function MediaFiltersBar({
|
||||
onChange={(id) => onViewModeChange(id as 'grid' | 'list')}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</Group>
|
||||
</ControlBar>
|
||||
</div>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ import { useState } from 'react';
|
||||
import { MediaCard } from '@/ui/MediaCard';
|
||||
import { MediaFiltersBar } from './MediaFiltersBar';
|
||||
import { Grid } from '@/ui/Grid';
|
||||
import { Stack } from '@/ui/Stack';
|
||||
import { MediaViewerModal } from './MediaViewerModal';
|
||||
import { SectionHeader } from '@/ui/SectionHeader';
|
||||
import { EmptyState } from '@/ui/EmptyState';
|
||||
@@ -63,7 +64,7 @@ export function MediaGallery({
|
||||
};
|
||||
|
||||
return (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '1.5rem' }}>
|
||||
<Stack gap={6}>
|
||||
<SectionHeader
|
||||
title={title}
|
||||
description={description}
|
||||
@@ -109,6 +110,6 @@ export function MediaGallery({
|
||||
onNext={handleNext}
|
||||
onPrev={handlePrev}
|
||||
/>
|
||||
</div>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ import { IconButton } from '@/ui/IconButton';
|
||||
import { Image } from '@/ui/Image';
|
||||
import { Modal } from '@/ui/Modal';
|
||||
import { Text } from '@/ui/Text';
|
||||
import { Box } from '@/ui/Box';
|
||||
import { ChevronLeft, ChevronRight, Download } from 'lucide-react';
|
||||
import React from 'react';
|
||||
|
||||
@@ -33,11 +34,11 @@ export function MediaViewerModal({
|
||||
title={title}
|
||||
size="xl"
|
||||
footer={
|
||||
<div style={{ width: '100%', textAlign: 'center' }}>
|
||||
<Box width="100%" textAlign="center">
|
||||
<Text size="xs" variant="low" uppercase>
|
||||
Precision Racing Media Viewer
|
||||
</Text>
|
||||
</div>
|
||||
</Box>
|
||||
}
|
||||
actions={
|
||||
<IconButton
|
||||
@@ -49,7 +50,7 @@ export function MediaViewerModal({
|
||||
/>
|
||||
}
|
||||
>
|
||||
<div style={{ position: 'relative', display: 'flex', alignItems: 'center', justifyContent: 'center', minHeight: '20rem' }}>
|
||||
<Box position="relative" display="flex" alignItems="center" justifyContent="center" minHeight="20rem">
|
||||
{src ? (
|
||||
<Image
|
||||
src={src}
|
||||
@@ -62,26 +63,26 @@ export function MediaViewerModal({
|
||||
|
||||
{/* Navigation Controls */}
|
||||
{onPrev && (
|
||||
<div style={{ position: 'absolute', left: '1rem', top: '50%', transform: 'translateY(-50%)' }}>
|
||||
<Box position="absolute" left={4} top="50%" style={{ transform: 'translateY(-50%)' }}>
|
||||
<IconButton
|
||||
icon={ChevronLeft}
|
||||
variant="secondary"
|
||||
onClick={onPrev}
|
||||
title="Previous"
|
||||
/>
|
||||
</div>
|
||||
</Box>
|
||||
)}
|
||||
{onNext && (
|
||||
<div style={{ position: 'absolute', right: '1rem', top: '50%', transform: 'translateY(-50%)' }}>
|
||||
<Box position="absolute" right={4} top="50%" style={{ transform: 'translateY(-50%)' }}>
|
||||
<IconButton
|
||||
icon={ChevronRight}
|
||||
variant="secondary"
|
||||
onClick={onNext}
|
||||
title="Next"
|
||||
/>
|
||||
</div>
|
||||
</Box>
|
||||
)}
|
||||
</div>
|
||||
</Box>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -4,6 +4,9 @@ import { Button } from '@/ui/Button';
|
||||
import { Icon } from '@/ui/Icon';
|
||||
import { Modal } from '@/ui/Modal';
|
||||
import { Text } from '@/ui/Text';
|
||||
import { Box } from '@/ui/Box';
|
||||
import { Grid } from '@/ui/Grid';
|
||||
import { Surface } from '@/ui/Surface';
|
||||
import { NotificationStat, NotificationDeadline } from '@/ui/NotificationContent';
|
||||
import {
|
||||
AlertCircle,
|
||||
@@ -144,17 +147,19 @@ export function ModalNotification({
|
||||
</Text>
|
||||
|
||||
{isRaceNotification && (
|
||||
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '1rem', marginTop: '1.5rem' }}>
|
||||
<NotificationStat
|
||||
label="POSITION"
|
||||
value={notification.data?.position === 'DNF' ? 'DNF' : `P${notification.data?.position || '?'}`}
|
||||
/>
|
||||
<NotificationStat
|
||||
label="RATING"
|
||||
value={`${ratingChange >= 0 ? '+' : ''}${ratingChange}`}
|
||||
intent={ratingChange >= 0 ? 'success' : 'critical'}
|
||||
/>
|
||||
</div>
|
||||
<Box marginTop={6}>
|
||||
<Grid cols={2} gap={4}>
|
||||
<NotificationStat
|
||||
label="POSITION"
|
||||
value={notification.data?.position === 'DNF' ? 'DNF' : `P${notification.data?.position || '?'}`}
|
||||
/>
|
||||
<NotificationStat
|
||||
label="RATING"
|
||||
value={`${ratingChange >= 0 ? '+' : ''}${ratingChange}`}
|
||||
intent={ratingChange >= 0 ? 'success' : 'critical'}
|
||||
/>
|
||||
</Grid>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{hasDeadline && !isRaceNotification && (
|
||||
@@ -166,18 +171,24 @@ export function ModalNotification({
|
||||
)}
|
||||
|
||||
{protestId && (
|
||||
<div style={{ marginTop: '1.5rem', padding: '0.75rem', backgroundColor: 'var(--ui-color-bg-base)', border: '1px solid var(--ui-color-border-default)', borderRadius: 'var(--ui-radius-sm)' }}>
|
||||
<Surface
|
||||
marginTop={6}
|
||||
padding={3}
|
||||
variant="dark"
|
||||
border
|
||||
rounded="sm"
|
||||
>
|
||||
<Text size="xs" variant="low" weight="bold" uppercase block marginBottom={1}>PROTEST ID</Text>
|
||||
<Text size="xs" variant="med" font="mono" block>{protestId}</Text>
|
||||
</div>
|
||||
</Surface>
|
||||
)}
|
||||
|
||||
{notification.requiresResponse && !isRaceNotification && (
|
||||
<div style={{ marginTop: '1rem', textAlign: 'center' }}>
|
||||
<Box marginTop={4} textAlign="center">
|
||||
<Text size="xs" variant="critical" weight="medium">
|
||||
This action is required to continue
|
||||
</Text>
|
||||
</div>
|
||||
</Box>
|
||||
)}
|
||||
</Modal>
|
||||
);
|
||||
|
||||
@@ -3,6 +3,8 @@
|
||||
import { Icon } from '@/ui/Icon';
|
||||
import { Toast } from '@/ui/Toast';
|
||||
import { Text } from '@/ui/Text';
|
||||
import { Box } from '@/ui/Box';
|
||||
import { Group } from '@/ui/Group';
|
||||
import {
|
||||
AlertTriangle,
|
||||
Bell,
|
||||
@@ -95,15 +97,16 @@ export function ToastNotification({
|
||||
{notification.message}
|
||||
</Text>
|
||||
{notification.actionUrl && (
|
||||
<div
|
||||
onClick={handleClick}
|
||||
style={{ marginTop: '0.5rem', display: 'flex', alignItems: 'center', gap: '0.25rem', cursor: 'pointer' }}
|
||||
>
|
||||
<Text size="xs" weight="medium" variant="primary">
|
||||
View details
|
||||
</Text>
|
||||
<Icon icon={ExternalLink} size={3} intent="primary" />
|
||||
</div>
|
||||
<Box marginTop={2}>
|
||||
<Box onClick={handleClick} style={{ cursor: 'pointer' }}>
|
||||
<Group gap={1}>
|
||||
<Text size="xs" weight="medium" variant="primary">
|
||||
View details
|
||||
</Text>
|
||||
<Icon icon={ExternalLink} size={3} intent="primary" />
|
||||
</Group>
|
||||
</Box>
|
||||
</Box>
|
||||
)}
|
||||
</Toast>
|
||||
);
|
||||
|
||||
@@ -1,5 +1,17 @@
|
||||
import { Box } from '@/ui/Box';
|
||||
|
||||
export function OnboardingCardAccent() {
|
||||
return (
|
||||
<div className="absolute top-0 right-0 w-40 h-40 bg-gradient-to-bl from-primary-blue/10 to-transparent rounded-bl-full" />
|
||||
<Box
|
||||
position="absolute"
|
||||
top={0}
|
||||
right={0}
|
||||
width="10rem"
|
||||
height="10rem"
|
||||
style={{
|
||||
background: 'linear-gradient(to bottom left, rgba(25, 140, 255, 0.1), transparent)',
|
||||
borderBottomLeftRadius: '9999px'
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -1,11 +1,16 @@
|
||||
import { Container } from '@/ui/Container';
|
||||
import { Box } from '@/ui/Box';
|
||||
|
||||
interface OnboardingContainerProps {
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
export function OnboardingContainer({ children }: OnboardingContainerProps) {
|
||||
return (
|
||||
<div className="max-w-3xl mx-auto px-4 py-10">
|
||||
{children}
|
||||
</div>
|
||||
<Container size="md">
|
||||
<Box paddingY={10}>
|
||||
{children}
|
||||
</Box>
|
||||
</Container>
|
||||
);
|
||||
}
|
||||
@@ -1,3 +1,8 @@
|
||||
import { Box } from '@/ui/Box';
|
||||
import { Heading } from '@/ui/Heading';
|
||||
import { Text } from '@/ui/Text';
|
||||
import { Surface } from '@/ui/Surface';
|
||||
|
||||
interface OnboardingHeaderProps {
|
||||
title: string;
|
||||
subtitle: string;
|
||||
@@ -6,12 +11,23 @@ interface OnboardingHeaderProps {
|
||||
|
||||
export function OnboardingHeader({ title, subtitle, emoji }: OnboardingHeaderProps) {
|
||||
return (
|
||||
<div className="text-center mb-8">
|
||||
<div className="flex h-16 w-16 items-center justify-center rounded-2xl bg-gradient-to-br from-primary-blue/20 to-purple-600/10 border border-primary-blue/30 mx-auto mb-4">
|
||||
<span className="text-2xl">{emoji}</span>
|
||||
</div>
|
||||
<h1 className="text-4xl font-bold mb-2">{title}</h1>
|
||||
<p className="text-gray-400">{subtitle}</p>
|
||||
</div>
|
||||
<Box textAlign="center" marginBottom={8}>
|
||||
<Surface
|
||||
variant="gradient-blue"
|
||||
rounded="xl"
|
||||
width="4rem"
|
||||
height="4rem"
|
||||
display="flex"
|
||||
alignItems="center"
|
||||
justifyContent="center"
|
||||
marginX="auto"
|
||||
marginBottom={4}
|
||||
border="1px solid var(--ui-color-intent-primary)"
|
||||
>
|
||||
<Text size="2xl">{emoji}</Text>
|
||||
</Surface>
|
||||
<Heading level={1} size="4xl" weight="bold" marginBottom={2}>{title}</Heading>
|
||||
<Text variant="low">{subtitle}</Text>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
@@ -1,7 +1,12 @@
|
||||
import { Text } from '@/ui/Text';
|
||||
import { Box } from '@/ui/Box';
|
||||
|
||||
export function OnboardingHelpText() {
|
||||
return (
|
||||
<p className="text-center text-xs text-gray-500 mt-6">
|
||||
Your avatar will be AI-generated based on your photo and chosen suit color
|
||||
</p>
|
||||
<Box marginTop={6} textAlign="center">
|
||||
<Text size="xs" variant="low">
|
||||
Your avatar will be AI-generated based on your photo and chosen suit color
|
||||
</Text>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
@@ -3,6 +3,7 @@
|
||||
import { LeagueListItem } from '@/components/leagues/LeagueListItem';
|
||||
import { Card } from '@/ui/Card';
|
||||
import { Text } from '@/ui/Text';
|
||||
import { Stack } from '@/ui/Stack';
|
||||
import { ProfileSection } from './ProfileSection';
|
||||
import React from 'react';
|
||||
|
||||
@@ -22,7 +23,7 @@ interface MembershipPanelProps {
|
||||
|
||||
export function MembershipPanel({ ownedLeagues, memberLeagues }: MembershipPanelProps) {
|
||||
return (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '2rem' }}>
|
||||
<Stack gap={8}>
|
||||
<ProfileSection
|
||||
title="Leagues You Own"
|
||||
description="Manage the leagues you have created and lead."
|
||||
@@ -34,11 +35,11 @@ export function MembershipPanel({ ownedLeagues, memberLeagues }: MembershipPanel
|
||||
</Text>
|
||||
</Card>
|
||||
) : (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '0.75rem' }}>
|
||||
<Stack gap={3}>
|
||||
{ownedLeagues.map((league) => (
|
||||
<LeagueListItem key={league.leagueId} league={league} isAdmin />
|
||||
))}
|
||||
</div>
|
||||
</Stack>
|
||||
)}
|
||||
</ProfileSection>
|
||||
|
||||
@@ -53,13 +54,13 @@ export function MembershipPanel({ ownedLeagues, memberLeagues }: MembershipPanel
|
||||
</Text>
|
||||
</Card>
|
||||
) : (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '0.75rem' }}>
|
||||
<Stack gap={3}>
|
||||
{memberLeagues.map((league) => (
|
||||
<LeagueListItem key={league.leagueId} league={league} />
|
||||
))}
|
||||
</div>
|
||||
</Stack>
|
||||
)}
|
||||
</ProfileSection>
|
||||
</div>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -3,6 +3,8 @@
|
||||
import { Panel } from '@/ui/Panel';
|
||||
import { Select } from '@/ui/Select';
|
||||
import { Toggle } from '@/ui/Toggle';
|
||||
import { Box } from '@/ui/Box';
|
||||
import { Stack } from '@/ui/Stack';
|
||||
import { ProfileStatsGroup, ProfileStat } from '@/ui/ProfileHero';
|
||||
import React from 'react';
|
||||
|
||||
@@ -22,7 +24,7 @@ export function PreferencesPanel({ preferences, isEditing, onUpdate }: Preferenc
|
||||
if (isEditing) {
|
||||
return (
|
||||
<Panel title="Racing Preferences">
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '1.5rem' }}>
|
||||
<Stack gap={6}>
|
||||
<Select
|
||||
label="Favorite Car Class"
|
||||
value={preferences.favoriteCarClass}
|
||||
@@ -45,19 +47,21 @@ export function PreferencesPanel({ preferences, isEditing, onUpdate }: Preferenc
|
||||
]}
|
||||
/>
|
||||
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '0.75rem', paddingTop: '0.5rem' }}>
|
||||
<Toggle
|
||||
label="Public Profile"
|
||||
checked={preferences.showProfile}
|
||||
onChange={(checked) => onUpdate?.({ showProfile: checked })}
|
||||
/>
|
||||
<Toggle
|
||||
label="Show Race History"
|
||||
checked={preferences.showHistory}
|
||||
onChange={(checked) => onUpdate?.({ showHistory: checked })}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<Box paddingTop={2}>
|
||||
<Stack gap={3}>
|
||||
<Toggle
|
||||
label="Public Profile"
|
||||
checked={preferences.showProfile}
|
||||
onChange={(checked) => onUpdate?.({ showProfile: checked })}
|
||||
/>
|
||||
<Toggle
|
||||
label="Show Race History"
|
||||
checked={preferences.showHistory}
|
||||
onChange={(checked) => onUpdate?.({ showHistory: checked })}
|
||||
/>
|
||||
</Stack>
|
||||
</Box>
|
||||
</Stack>
|
||||
</Panel>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -5,6 +5,9 @@ import { Panel } from '@/ui/Panel';
|
||||
import { Input } from '@/ui/Input';
|
||||
import { Text } from '@/ui/Text';
|
||||
import { TextArea } from '@/ui/TextArea';
|
||||
import { Box } from '@/ui/Box';
|
||||
import { Group } from '@/ui/Group';
|
||||
import { Stack } from '@/ui/Stack';
|
||||
import { ProfileStat } from '@/ui/ProfileHero';
|
||||
import React from 'react';
|
||||
|
||||
@@ -22,7 +25,7 @@ export function ProfileDetailsPanel({ driver, isEditing, onUpdate }: ProfileDeta
|
||||
if (isEditing) {
|
||||
return (
|
||||
<Panel title="Profile Details">
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '1.5rem' }}>
|
||||
<Stack gap={6}>
|
||||
<Input
|
||||
label="Nationality (ISO Code)"
|
||||
value={driver.country}
|
||||
@@ -35,31 +38,31 @@ export function ProfileDetailsPanel({ driver, isEditing, onUpdate }: ProfileDeta
|
||||
onChange={(e) => onUpdate?.({ bio: e.target.value })}
|
||||
placeholder="Tell the community about your racing career..."
|
||||
/>
|
||||
</div>
|
||||
</Stack>
|
||||
</Panel>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Panel title="Profile Details">
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '1.5rem' }}>
|
||||
<div>
|
||||
<Text size="xs" variant="low" weight="bold" uppercase block marginBottom={1}>Nationality</Text>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '0.5rem' }}>
|
||||
<Stack gap={6}>
|
||||
<Stack gap={1}>
|
||||
<Text size="xs" variant="low" weight="bold" uppercase block>Nationality</Text>
|
||||
<Group gap={2}>
|
||||
<Text size="xl">
|
||||
{CountryFlagDisplay.fromCountryCode(driver.country).toString()}
|
||||
</Text>
|
||||
<Text variant="med">{driver.country}</Text>
|
||||
</div>
|
||||
</div>
|
||||
</Group>
|
||||
</Stack>
|
||||
|
||||
<div>
|
||||
<Text size="xs" variant="low" weight="bold" uppercase block marginBottom={1}>Bio</Text>
|
||||
<Stack gap={1}>
|
||||
<Text size="xs" variant="low" weight="bold" uppercase block>Bio</Text>
|
||||
<Text variant="med" leading="relaxed">
|
||||
{driver.bio || 'No bio provided.'}
|
||||
</Text>
|
||||
</div>
|
||||
</div>
|
||||
</Stack>
|
||||
</Stack>
|
||||
</Panel>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -7,6 +7,9 @@ import { Heading } from '@/ui/Heading';
|
||||
import { Image } from '@/ui/Image';
|
||||
import { ProfileHero, ProfileAvatar, ProfileStatsGroup, ProfileStat } from '@/ui/ProfileHero';
|
||||
import { Text } from '@/ui/Text';
|
||||
import { Box } from '@/ui/Box';
|
||||
import { Group } from '@/ui/Group';
|
||||
import { Stack } from '@/ui/Stack';
|
||||
import { Calendar, Globe, Star, Trophy, UserPlus } from 'lucide-react';
|
||||
import React from 'react';
|
||||
|
||||
@@ -37,7 +40,7 @@ export function ProfileHeader({
|
||||
}: ProfileHeaderProps) {
|
||||
return (
|
||||
<ProfileHero variant="muted">
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '2rem', flexWrap: 'wrap' }}>
|
||||
<Box display="flex" alignItems="center" gap={8} flexWrap="wrap">
|
||||
<ProfileAvatar>
|
||||
<Image
|
||||
src={driver.avatarUrl || mediaConfig.avatars.defaultFallback}
|
||||
@@ -48,27 +51,29 @@ export function ProfileHeader({
|
||||
/>
|
||||
</ProfileAvatar>
|
||||
|
||||
<div style={{ flex: 1, minWidth: '200px' }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '0.75rem', marginBottom: '0.25rem' }}>
|
||||
<Heading level={1}>{driver.name}</Heading>
|
||||
<Text size="2xl" aria-label={`Country: ${driver.country}`}>
|
||||
{CountryFlagDisplay.fromCountryCode(driver.country).toString()}
|
||||
</Text>
|
||||
</div>
|
||||
<Stack flex={1} minWidth="200px" gap={1}>
|
||||
<Box marginBottom={1}>
|
||||
<Group gap={3}>
|
||||
<Heading level={1}>{driver.name}</Heading>
|
||||
<Text size="2xl" aria-label={`Country: ${driver.country}`}>
|
||||
{CountryFlagDisplay.fromCountryCode(driver.country).toString()}
|
||||
</Text>
|
||||
</Group>
|
||||
</Box>
|
||||
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '1rem' }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '0.375rem' }}>
|
||||
<Group gap={4}>
|
||||
<Group gap={1.5}>
|
||||
<Globe size={14} color="var(--ui-color-text-low)" />
|
||||
<Text size="xs" font="mono" variant="low">ID: {driver.iracingId}</Text>
|
||||
</div>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '0.375rem' }}>
|
||||
</Group>
|
||||
<Group gap={1.5}>
|
||||
<Calendar size={14} color="var(--ui-color-text-low)" />
|
||||
<Text size="xs" variant="low">
|
||||
Joined {new Date(driver.joinedAt).toLocaleDateString('en-US', { month: 'short', year: 'numeric' })}
|
||||
</Text>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Group>
|
||||
</Group>
|
||||
</Stack>
|
||||
|
||||
<ProfileStatsGroup>
|
||||
{stats && (
|
||||
@@ -80,7 +85,7 @@ export function ProfileHeader({
|
||||
</ProfileStatsGroup>
|
||||
|
||||
{!isOwnProfile && onAddFriend && (
|
||||
<div style={{ marginLeft: 'auto' }}>
|
||||
<Box marginLeft="auto">
|
||||
<Button
|
||||
variant={friendRequestSent ? 'secondary' : 'primary'}
|
||||
onClick={onAddFriend}
|
||||
@@ -90,9 +95,9 @@ export function ProfileHeader({
|
||||
>
|
||||
{friendRequestSent ? 'Request Sent' : 'Add Friend'}
|
||||
</Button>
|
||||
</div>
|
||||
</Box>
|
||||
)}
|
||||
</div>
|
||||
</Box>
|
||||
</ProfileHero>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
'use client';
|
||||
|
||||
import { SectionHeader } from '@/ui/SectionHeader';
|
||||
import { Box } from '@/ui/Box';
|
||||
import React from 'react';
|
||||
|
||||
interface ProfileSectionProps {
|
||||
@@ -12,14 +13,14 @@ interface ProfileSectionProps {
|
||||
|
||||
export function ProfileSection({ title, description, action, children }: ProfileSectionProps) {
|
||||
return (
|
||||
<section style={{ marginBottom: '2rem' }}>
|
||||
<Box as="section" marginBottom={8}>
|
||||
<SectionHeader
|
||||
title={title}
|
||||
description={description}
|
||||
actions={action}
|
||||
variant="minimal"
|
||||
/>
|
||||
<div>{children}</div>
|
||||
</section>
|
||||
<Box>{children}</Box>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -9,6 +9,10 @@ import { Icon } from '@/ui/Icon';
|
||||
import { Image } from '@/ui/Image';
|
||||
import { Link } from '@/ui/Link';
|
||||
import { Text } from '@/ui/Text';
|
||||
import { Box } from '@/ui/Box';
|
||||
import { Group } from '@/ui/Group';
|
||||
import { Stack } from '@/ui/Stack';
|
||||
import { Surface } from '@/ui/Surface';
|
||||
import { UserDropdown, UserDropdownHeader, UserDropdownItem, UserDropdownFooter } from '@/ui/UserDropdown';
|
||||
import { AnimatePresence, motion } from 'framer-motion';
|
||||
import {
|
||||
@@ -143,7 +147,7 @@ export function UserPill() {
|
||||
// Handle unauthenticated users
|
||||
if (!session) {
|
||||
return (
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '0.5rem' }}>
|
||||
<Group gap={2}>
|
||||
<Link
|
||||
href={routes.auth.login}
|
||||
variant="secondary"
|
||||
@@ -156,7 +160,7 @@ export function UserPill() {
|
||||
>
|
||||
Get Started
|
||||
</Link>
|
||||
</div>
|
||||
</Group>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -185,7 +189,7 @@ export function UserPill() {
|
||||
} as Record<string, 'primary' | 'success' | 'warning' | 'critical'>)[demoRole || 'driver'] : 'low';
|
||||
|
||||
return (
|
||||
<div style={{ position: 'relative', display: 'inline-flex', alignItems: 'center' }} data-user-pill>
|
||||
<Box position="relative" display="inline-flex" alignItems="center" data-user-pill>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setIsMenuOpen((open) => !open)}
|
||||
@@ -201,26 +205,26 @@ export function UserPill() {
|
||||
}}
|
||||
>
|
||||
{/* Avatar */}
|
||||
<div style={{ position: 'relative' }}>
|
||||
<Box position="relative">
|
||||
{avatarUrl ? (
|
||||
<div style={{ width: '2rem', height: '2rem', borderRadius: '9999px', overflow: 'hidden', border: '1px solid var(--ui-color-border-default)' }}>
|
||||
<Surface width="2rem" height="2rem" rounded="full" overflow="hidden" border>
|
||||
<Image
|
||||
src={avatarUrl}
|
||||
alt={displayName}
|
||||
objectFit="cover"
|
||||
/>
|
||||
</div>
|
||||
</Surface>
|
||||
) : (
|
||||
<div style={{ width: '2rem', height: '2rem', borderRadius: '9999px', backgroundColor: 'var(--ui-color-intent-primary)', display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
|
||||
<Surface width="2rem" height="2rem" rounded="full" variant="gradient-blue" display="flex" alignItems="center" justifyContent="center">
|
||||
<Text size="xs" weight="bold" variant="high">
|
||||
{displayName[0]?.toUpperCase() || 'U'}
|
||||
</Text>
|
||||
</div>
|
||||
</Surface>
|
||||
)}
|
||||
</div>
|
||||
</Box>
|
||||
|
||||
{/* Info */}
|
||||
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'start' }}>
|
||||
<Stack align="start" gap={0}>
|
||||
<Text size="xs" weight="semibold" variant="high" truncate style={{ maxWidth: '100px' }}>
|
||||
{displayName}
|
||||
</Text>
|
||||
@@ -229,7 +233,7 @@ export function UserPill() {
|
||||
{roleLabel}
|
||||
</Text>
|
||||
)}
|
||||
</div>
|
||||
</Stack>
|
||||
|
||||
{/* Chevron */}
|
||||
<Icon icon={ChevronDown} size={3.5} intent="low" />
|
||||
@@ -237,32 +241,32 @@ export function UserPill() {
|
||||
|
||||
<UserDropdown isOpen={isMenuOpen}>
|
||||
<UserDropdownHeader variant={isDemo ? 'demo' : 'default'}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '0.75rem' }}>
|
||||
<Group gap={3}>
|
||||
{avatarUrl ? (
|
||||
<div style={{ width: '2.5rem', height: '2.5rem', borderRadius: '0.5rem', overflow: 'hidden', border: '1px solid var(--ui-color-border-default)' }}>
|
||||
<Surface width="2.5rem" height="2.5rem" rounded="md" overflow="hidden" border>
|
||||
<Image
|
||||
src={avatarUrl}
|
||||
alt={displayName}
|
||||
objectFit="cover"
|
||||
/>
|
||||
</div>
|
||||
</Surface>
|
||||
) : (
|
||||
<div style={{ width: '2.5rem', height: '2.5rem', borderRadius: '0.5rem', backgroundColor: 'var(--ui-color-intent-primary)', display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
|
||||
<Surface width="2.5rem" height="2.5rem" rounded="md" variant="gradient-blue" display="flex" alignItems="center" justifyContent="center">
|
||||
<Text size="xs" weight="bold" variant="high">
|
||||
{displayName[0]?.toUpperCase() || 'U'}
|
||||
</Text>
|
||||
</div>
|
||||
</Surface>
|
||||
)}
|
||||
<div>
|
||||
<Stack gap={0}>
|
||||
<Text size="sm" weight="semibold" variant="high" block>{displayName}</Text>
|
||||
{roleLabel && (
|
||||
<Text size="xs" variant="low" block>{roleLabel}</Text>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</Stack>
|
||||
</Group>
|
||||
</UserDropdownHeader>
|
||||
|
||||
<div style={{ padding: '0.25rem 0' }}>
|
||||
<Box paddingY={1}>
|
||||
{hasAdminAccess && (
|
||||
<UserDropdownItem href="/admin" icon={Shield} label="Admin Area" intent="primary" onClick={() => setIsMenuOpen(false)} />
|
||||
)}
|
||||
@@ -280,7 +284,7 @@ export function UserPill() {
|
||||
<UserDropdownItem href={routes.protected.profileLiveries} icon={Paintbrush} label="Liveries" onClick={() => setIsMenuOpen(false)} />
|
||||
<UserDropdownItem href={routes.protected.profileSponsorshipRequests} icon={Handshake} label="Sponsorship Requests" intent="success" onClick={() => setIsMenuOpen(false)} />
|
||||
<UserDropdownItem href={routes.protected.profileSettings} icon={Settings} label="Settings" onClick={() => setIsMenuOpen(false)} />
|
||||
</div>
|
||||
</Box>
|
||||
|
||||
<UserDropdownFooter>
|
||||
<UserDropdownItem
|
||||
@@ -291,6 +295,6 @@ export function UserPill() {
|
||||
/>
|
||||
</UserDropdownFooter>
|
||||
</UserDropdown>
|
||||
</div>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -4,6 +4,8 @@ import { Badge } from '@/ui/Badge';
|
||||
import { Icon } from '@/ui/Icon';
|
||||
import { Link } from '@/ui/Link';
|
||||
import { Text } from '@/ui/Text';
|
||||
import { Box } from '@/ui/Box';
|
||||
import { Group } from '@/ui/Group';
|
||||
import { RaceCard, RaceTimeColumn, RaceInfo } from '@/ui/RaceCard';
|
||||
import { Car, Trophy, Zap, ArrowRight, Clock, PlayCircle, CheckCircle2, XCircle, HelpCircle } from 'lucide-react';
|
||||
import React from 'react';
|
||||
@@ -61,7 +63,7 @@ export function RaceListItem({
|
||||
isLive={isLive}
|
||||
/>
|
||||
|
||||
<div style={{ width: '1px', height: '2.5rem', backgroundColor: 'var(--ui-color-border-muted)', opacity: 0.2 }} />
|
||||
<Box width="1px" height="2.5rem" bg="var(--ui-color-border-muted)" opacity={0.2} />
|
||||
|
||||
<RaceInfo
|
||||
title={track}
|
||||
@@ -73,12 +75,12 @@ export function RaceListItem({
|
||||
</Badge>
|
||||
}
|
||||
meta={
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '1rem' }}>
|
||||
<Group gap={4}>
|
||||
{strengthOfField && (
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '0.25rem' }}>
|
||||
<Group gap={1}>
|
||||
<Icon icon={Zap} size={3.5} intent="warning" />
|
||||
<Text size="sm" variant="low">SOF {strengthOfField}</Text>
|
||||
</div>
|
||||
</Group>
|
||||
)}
|
||||
{leagueName && leagueHref && (
|
||||
<Link
|
||||
@@ -86,14 +88,14 @@ export function RaceListItem({
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
variant="primary"
|
||||
>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '0.25rem' }}>
|
||||
<Group gap={1}>
|
||||
<Icon icon={Trophy} size={3.5} intent="primary" />
|
||||
<Text size="sm" variant="primary">{leagueName}</Text>
|
||||
<Icon icon={ArrowRight} size={3} intent="primary" />
|
||||
</div>
|
||||
</Group>
|
||||
</Link>
|
||||
)}
|
||||
</div>
|
||||
</Group>
|
||||
}
|
||||
/>
|
||||
</RaceCard>
|
||||
|
||||
@@ -5,6 +5,10 @@ import { Image } from '@/ui/Image';
|
||||
import { ResultRow, PositionBadge, ResultPoints } from '@/ui/ResultRow';
|
||||
import { Text } from '@/ui/Text';
|
||||
import { Badge } from '@/ui/Badge';
|
||||
import { Box } from '@/ui/Box';
|
||||
import { Group } from '@/ui/Group';
|
||||
import { Stack } from '@/ui/Stack';
|
||||
import { Surface } from '@/ui/Surface';
|
||||
import React from 'react';
|
||||
|
||||
interface ResultEntry {
|
||||
@@ -35,37 +39,56 @@ export function RaceResultRow({ result, points }: RaceResultRowProps) {
|
||||
<PositionBadge position={position} />
|
||||
|
||||
{/* Avatar */}
|
||||
<div style={{ position: 'relative', flexShrink: 0 }}>
|
||||
<div style={{ width: '2.5rem', height: '2.5rem', borderRadius: '9999px', overflow: 'hidden', border: isCurrentUser ? '2px solid var(--ui-color-intent-primary)' : '1px solid var(--ui-color-border-default)' }}>
|
||||
<Box position="relative" flexShrink={0}>
|
||||
<Surface
|
||||
width="2.5rem"
|
||||
height="2.5rem"
|
||||
rounded="full"
|
||||
overflow="hidden"
|
||||
border={isCurrentUser ? '2px solid var(--ui-color-intent-primary)' : true}
|
||||
>
|
||||
<Image src={driverAvatar} alt={driverName} width={40} height={40} objectFit="cover" />
|
||||
</div>
|
||||
<div style={{ position: 'absolute', bottom: '-0.125rem', right: '-0.125rem', width: '1.25rem', height: '1.25rem', borderRadius: '9999px', backgroundColor: 'var(--ui-color-bg-base)', display: 'flex', alignItems: 'center', justifyContent: 'center', fontSize: '0.625rem' }}>
|
||||
{CountryFlagDisplay.fromCountryCode(country).toString()}
|
||||
</div>
|
||||
</div>
|
||||
</Surface>
|
||||
<Surface
|
||||
position="absolute"
|
||||
bottom="-0.125rem"
|
||||
right="-0.125rem"
|
||||
width="1.25rem"
|
||||
height="1.25rem"
|
||||
rounded="full"
|
||||
variant="dark"
|
||||
display="flex"
|
||||
alignItems="center"
|
||||
justifyContent="center"
|
||||
>
|
||||
<Text size="xs" style={{ fontSize: '0.625rem' }}>
|
||||
{CountryFlagDisplay.fromCountryCode(country).toString()}
|
||||
</Text>
|
||||
</Surface>
|
||||
</Box>
|
||||
|
||||
{/* Driver Info */}
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '0.5rem' }}>
|
||||
<Stack flex={1} minWidth="0" gap={1}>
|
||||
<Group gap={2}>
|
||||
<Text weight="semibold" size="sm" variant={isCurrentUser ? 'primary' : 'high'} truncate>{driverName}</Text>
|
||||
{isCurrentUser && (
|
||||
<Badge variant="primary" size="sm">YOU</Badge>
|
||||
)}
|
||||
</div>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '0.5rem', marginTop: '0.25rem' }}>
|
||||
</Group>
|
||||
<Group gap={2}>
|
||||
<Text size="xs" variant="low">{car}</Text>
|
||||
<Text size="xs" variant="low">•</Text>
|
||||
<Text size="xs" variant="low">Laps: {laps}</Text>
|
||||
<Text size="xs" variant="low">•</Text>
|
||||
<Text size="xs" variant="low">Incidents: {incidents}</Text>
|
||||
</div>
|
||||
</div>
|
||||
</Group>
|
||||
</Stack>
|
||||
|
||||
{/* Times */}
|
||||
<div style={{ textAlign: 'right', minWidth: '100px' }}>
|
||||
<Stack textAlign="right" minWidth="100px" gap={1}>
|
||||
<Text size="sm" font="mono" variant="high" block>{time}</Text>
|
||||
<Text size="xs" variant="success" block style={{ marginTop: '0.25rem' }}>FL: {fastestLap}</Text>
|
||||
</div>
|
||||
<Text size="xs" variant="success" block>FL: {fastestLap}</Text>
|
||||
</Stack>
|
||||
|
||||
{/* Points */}
|
||||
<ResultPoints points={points} />
|
||||
|
||||
@@ -5,6 +5,10 @@ import { Icon } from '@/ui/Icon';
|
||||
import { Link } from '@/ui/Link';
|
||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/ui/Table';
|
||||
import { Text } from '@/ui/Text';
|
||||
import { Box } from '@/ui/Box';
|
||||
import { Group } from '@/ui/Group';
|
||||
import { Stack } from '@/ui/Stack';
|
||||
import { Surface } from '@/ui/Surface';
|
||||
import { PositionBadge } from '@/ui/ResultRow';
|
||||
import { AlertTriangle, ExternalLink } from 'lucide-react';
|
||||
import React, { ReactNode } from 'react';
|
||||
@@ -111,14 +115,14 @@ export function RaceResultsTable({
|
||||
|
||||
if (results.length === 0) {
|
||||
return (
|
||||
<div style={{ textAlign: 'center', padding: '2rem 0' }}>
|
||||
<Box textAlign="center" paddingY={8}>
|
||||
<Text variant="low">No results available</Text>
|
||||
</div>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div style={{ overflowX: 'auto' }}>
|
||||
<Box overflowX="auto">
|
||||
<Table>
|
||||
<TableHead>
|
||||
<TableRow>
|
||||
@@ -150,20 +154,20 @@ export function RaceResultsTable({
|
||||
<PositionBadge position={result.position} />
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '0.75rem' }}>
|
||||
<Group gap={3}>
|
||||
{driver ? (
|
||||
<React.Fragment>
|
||||
<div style={{
|
||||
width: '2rem',
|
||||
height: '2rem',
|
||||
borderRadius: '9999px',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
flexShrink: 0,
|
||||
backgroundColor: isCurrentUser ? 'rgba(25, 140, 255, 0.2)' : 'var(--ui-color-bg-surface-muted)',
|
||||
border: isCurrentUser ? '2px solid var(--ui-color-intent-primary)' : '1px solid var(--ui-color-border-default)'
|
||||
}}>
|
||||
<Surface
|
||||
variant={isCurrentUser ? 'gradient-blue' : 'muted'}
|
||||
rounded="full"
|
||||
width="2rem"
|
||||
height="2rem"
|
||||
display="flex"
|
||||
alignItems="center"
|
||||
justifyContent="center"
|
||||
flexShrink={0}
|
||||
border={isCurrentUser ? '2px solid var(--ui-color-intent-primary)' : true}
|
||||
>
|
||||
<Text
|
||||
size="sm"
|
||||
weight="bold"
|
||||
@@ -171,24 +175,24 @@ export function RaceResultsTable({
|
||||
>
|
||||
{driver.name.charAt(0)}
|
||||
</Text>
|
||||
</div>
|
||||
</Surface>
|
||||
<Link
|
||||
href={`/drivers/${driver.id}`}
|
||||
variant={isCurrentUser ? 'primary' : 'inherit'}
|
||||
>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '0.375rem' }}>
|
||||
<Group gap={1.5}>
|
||||
<Text weight={isCurrentUser ? 'semibold' : 'normal'}>{driver.name}</Text>
|
||||
{isCurrentUser && (
|
||||
<Badge variant="primary" size="sm">You</Badge>
|
||||
)}
|
||||
<Icon icon={ExternalLink} size={3} intent="low" />
|
||||
</div>
|
||||
</Group>
|
||||
</Link>
|
||||
</React.Fragment>
|
||||
) : (
|
||||
<Text variant="high">{getDriverName(result.driverId)}</Text>
|
||||
)}
|
||||
</div>
|
||||
</Group>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Text variant={isFastestLap ? 'success' : 'high'} weight={isFastestLap ? 'medium' : 'normal'}>
|
||||
@@ -212,20 +216,20 @@ export function RaceResultsTable({
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
{driverPenalties.length > 0 ? (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '0.25rem' }}>
|
||||
<Stack gap={1}>
|
||||
{driverPenalties.map((penalty, idx) => (
|
||||
<div key={idx} style={{ display: 'flex', alignItems: 'center', gap: '0.375rem' }}>
|
||||
<Group key={idx} gap={1.5}>
|
||||
<Icon icon={AlertTriangle} size={3} intent="critical" />
|
||||
<Text size="xs" variant="critical">{getPenaltyDescription(penalty)}</Text>
|
||||
</div>
|
||||
</Group>
|
||||
))}
|
||||
</div>
|
||||
</Stack>
|
||||
) : (
|
||||
<Text variant="low">—</Text>
|
||||
)}
|
||||
</TableCell>
|
||||
{isAdmin && (
|
||||
<TableCell style={{ textAlign: 'right' }}>
|
||||
<TableCell textAlign="right">
|
||||
{driver && penaltyButtonRenderer && penaltyButtonRenderer(driver)}
|
||||
</TableCell>
|
||||
)}
|
||||
@@ -234,6 +238,6 @@ export function RaceResultsTable({
|
||||
})}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -7,6 +7,8 @@ import { Panel } from '@/ui/Panel';
|
||||
import { Icon } from '@/ui/Icon';
|
||||
import { SidebarActionLink } from '@/ui/SidebarActionLink';
|
||||
import { Text } from '@/ui/Text';
|
||||
import { Stack } from '@/ui/Stack';
|
||||
import { Box } from '@/ui/Box';
|
||||
import { Clock, Trophy, Users } from 'lucide-react';
|
||||
import React from 'react';
|
||||
|
||||
@@ -18,18 +20,18 @@ interface RaceSidebarProps {
|
||||
|
||||
export function RaceSidebar({ upcomingRaces, recentResults, onRaceClick }: RaceSidebarProps) {
|
||||
return (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '1.5rem' }}>
|
||||
<Stack gap={6}>
|
||||
{/* Upcoming This Week */}
|
||||
<Panel
|
||||
title="Next Up"
|
||||
description="This week"
|
||||
>
|
||||
{upcomingRaces.length === 0 ? (
|
||||
<div style={{ padding: '1rem 0', textAlign: 'center' }}>
|
||||
<Box paddingY={4} textAlign="center">
|
||||
<Text size="sm" variant="low">No races scheduled this week</Text>
|
||||
</div>
|
||||
</Box>
|
||||
) : (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '0.75rem' }}>
|
||||
<Stack gap={3}>
|
||||
{upcomingRaces.map((race) => (
|
||||
<SidebarRaceItem
|
||||
key={race.id}
|
||||
@@ -41,7 +43,7 @@ export function RaceSidebar({ upcomingRaces, recentResults, onRaceClick }: RaceS
|
||||
onClick={() => onRaceClick(race.id)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</Stack>
|
||||
)}
|
||||
</Panel>
|
||||
|
||||
@@ -50,11 +52,11 @@ export function RaceSidebar({ upcomingRaces, recentResults, onRaceClick }: RaceS
|
||||
title="Recent Results"
|
||||
>
|
||||
{recentResults.length === 0 ? (
|
||||
<div style={{ padding: '1rem 0', textAlign: 'center' }}>
|
||||
<Box paddingY={4} textAlign="center">
|
||||
<Text size="sm" variant="low">No completed races yet</Text>
|
||||
</div>
|
||||
</Box>
|
||||
) : (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '0.75rem' }}>
|
||||
<Stack gap={3}>
|
||||
{recentResults.map((race) => (
|
||||
<SidebarRaceItem
|
||||
key={race.id}
|
||||
@@ -66,13 +68,13 @@ export function RaceSidebar({ upcomingRaces, recentResults, onRaceClick }: RaceS
|
||||
onClick={() => onRaceClick(race.id)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</Stack>
|
||||
)}
|
||||
</Panel>
|
||||
|
||||
{/* Quick Actions */}
|
||||
<Panel title="Quick Actions">
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '0.5rem' }}>
|
||||
<Stack gap={2}>
|
||||
<SidebarActionLink
|
||||
href={routes.public.leagues}
|
||||
icon={Users}
|
||||
@@ -83,8 +85,8 @@ export function RaceSidebar({ upcomingRaces, recentResults, onRaceClick }: RaceS
|
||||
icon={Trophy}
|
||||
label="View Leaderboards"
|
||||
/>
|
||||
</div>
|
||||
</Stack>
|
||||
</Panel>
|
||||
</div>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { StatGrid } from '@/ui/StatGrid';
|
||||
import { Box } from '@/ui/Box';
|
||||
import { CalendarDays, Clock, Trophy, Zap } from 'lucide-react';
|
||||
import React from 'react';
|
||||
|
||||
@@ -20,12 +21,12 @@ export function RaceStats({ stats }: RaceStatsProps) {
|
||||
];
|
||||
|
||||
return (
|
||||
<div style={{ marginTop: '1.5rem' }}>
|
||||
<Box marginTop={6}>
|
||||
<StatGrid
|
||||
stats={mappedStats}
|
||||
columns={{ base: 2, md: 4 }}
|
||||
variant="box"
|
||||
/>
|
||||
</div>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -4,6 +4,12 @@
|
||||
import { Check, ChevronDown, Globe, Search } from 'lucide-react';
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { CountryFlag } from '@/ui/CountryFlag';
|
||||
import { Box } from '@/ui/Box';
|
||||
import { Group } from '@/ui/Group';
|
||||
import { Stack } from '@/ui/Stack';
|
||||
import { Text } from '@/ui/Text';
|
||||
import { Surface } from '@/ui/Surface';
|
||||
import { Icon } from '@/ui/Icon';
|
||||
|
||||
export interface Country {
|
||||
code: string;
|
||||
@@ -107,38 +113,62 @@ export function CountrySelect({
|
||||
};
|
||||
|
||||
return (
|
||||
<div ref={containerRef} className="relative">
|
||||
<Box ref={containerRef} position="relative">
|
||||
{/* Trigger Button */}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => !disabled && setIsOpen(!isOpen)}
|
||||
disabled={disabled}
|
||||
className={`flex items-center justify-between w-full rounded-md border-0 px-4 py-3 bg-iron-gray text-white shadow-sm ring-1 ring-inset transition-all duration-150 sm:text-sm ${
|
||||
error
|
||||
? 'ring-warning-amber focus:ring-warning-amber'
|
||||
: 'ring-charcoal-outline focus:ring-primary-blue'
|
||||
} ${disabled ? 'opacity-50 cursor-not-allowed' : 'cursor-pointer hover:ring-gray-500'}`}
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
width: '100%',
|
||||
borderRadius: 'var(--ui-radius-md)',
|
||||
border: 'none',
|
||||
padding: '0.75rem 1rem',
|
||||
backgroundColor: 'var(--ui-color-bg-surface-muted)',
|
||||
color: 'white',
|
||||
boxShadow: 'var(--ui-shadow-sm)',
|
||||
transition: 'all 150ms',
|
||||
cursor: disabled ? 'not-allowed' : 'pointer',
|
||||
opacity: disabled ? 0.5 : 1,
|
||||
outline: 'none',
|
||||
ring: '1px inset',
|
||||
borderColor: error ? 'var(--ui-color-intent-critical)' : 'var(--ui-color-border-default)'
|
||||
} as any}
|
||||
className={error ? 'ring-warning-amber' : 'ring-charcoal-outline focus:ring-primary-blue'}
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
<Group gap={3}>
|
||||
<Globe className="w-4 h-4 text-gray-500" />
|
||||
{selectedCountry ? (
|
||||
<span className="flex items-center gap-2">
|
||||
<CountryFlag countryCode={selectedCountry.code} size="md" showTooltip={false} />
|
||||
<span>{selectedCountry.name}</span>
|
||||
</span>
|
||||
<Group gap={2}>
|
||||
<CountryFlag countryCode={selectedCountry.code} size="md" />
|
||||
<Text as="span">{selectedCountry.name}</Text>
|
||||
</Group>
|
||||
) : (
|
||||
<span className="text-gray-500">{placeholder}</span>
|
||||
<Text variant="low">{placeholder}</Text>
|
||||
)}
|
||||
</div>
|
||||
</Group>
|
||||
<ChevronDown className={`w-4 h-4 text-gray-500 transition-transform ${isOpen ? 'rotate-180' : ''}`} />
|
||||
</button>
|
||||
|
||||
{/* Dropdown */}
|
||||
{isOpen && (
|
||||
<div className="absolute z-50 mt-2 w-full rounded-lg bg-iron-gray border border-charcoal-outline shadow-xl max-h-80 overflow-hidden">
|
||||
<Surface
|
||||
position="absolute"
|
||||
zIndex={50}
|
||||
marginTop={2}
|
||||
width="100%"
|
||||
rounded="lg"
|
||||
variant="dark"
|
||||
border
|
||||
shadow="xl"
|
||||
overflow="hidden"
|
||||
>
|
||||
{/* Search Input */}
|
||||
<div className="p-2 border-b border-charcoal-outline">
|
||||
<div className="relative">
|
||||
<Box padding={2} borderBottom="1px solid var(--ui-color-border-muted)">
|
||||
<Box position="relative">
|
||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-gray-500" />
|
||||
<input
|
||||
ref={inputRef}
|
||||
@@ -146,47 +176,67 @@ export function CountrySelect({
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
placeholder="Search countries..."
|
||||
className="w-full rounded-md border-0 px-4 py-2 pl-9 bg-deep-graphite text-white text-sm placeholder:text-gray-500 focus:outline-none focus:ring-1 focus:ring-primary-blue"
|
||||
style={{
|
||||
width: '100%',
|
||||
borderRadius: 'var(--ui-radius-md)',
|
||||
border: 'none',
|
||||
padding: '0.5rem 1rem 0.5rem 2.25rem',
|
||||
backgroundColor: 'var(--ui-color-bg-base)',
|
||||
color: 'white',
|
||||
fontSize: '0.875rem',
|
||||
outline: 'none'
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
{/* Country List */}
|
||||
<div className="overflow-y-auto max-h-60">
|
||||
<Box overflowY="auto" maxHeight="15rem">
|
||||
{filteredCountries.length > 0 ? (
|
||||
filteredCountries.map((country) => (
|
||||
<button
|
||||
key={country.code}
|
||||
type="button"
|
||||
onClick={() => handleSelect(country.code)}
|
||||
className={`flex items-center justify-between w-full px-4 py-2.5 text-left text-sm transition-colors ${
|
||||
value === country.code
|
||||
? 'bg-primary-blue/20 text-white'
|
||||
: 'text-gray-300 hover:bg-deep-graphite'
|
||||
}`}
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
width: '100%',
|
||||
padding: '0.625rem 1rem',
|
||||
textAlign: 'left',
|
||||
fontSize: '0.875rem',
|
||||
transition: 'colors 150ms',
|
||||
border: 'none',
|
||||
backgroundColor: value === country.code ? 'rgba(25, 140, 255, 0.2)' : 'transparent',
|
||||
color: value === country.code ? 'white' : 'var(--ui-color-text-med)',
|
||||
cursor: 'pointer'
|
||||
}}
|
||||
>
|
||||
<span className="flex items-center gap-3">
|
||||
<CountryFlag countryCode={country.code} size="md" showTooltip={false} />
|
||||
<span>{country.name}</span>
|
||||
</span>
|
||||
<Group gap={3}>
|
||||
<CountryFlag countryCode={country.code} size="md" />
|
||||
<Text as="span">{country.name}</Text>
|
||||
</Group>
|
||||
{value === country.code && (
|
||||
<Check className="w-4 h-4 text-primary-blue" />
|
||||
)}
|
||||
</button>
|
||||
))
|
||||
) : (
|
||||
<div className="px-4 py-6 text-center text-gray-500 text-sm">
|
||||
No countries found
|
||||
</div>
|
||||
<Box paddingX={4} paddingY={6} textAlign="center">
|
||||
<Text size="sm" variant="low">No countries found</Text>
|
||||
</Box>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</Box>
|
||||
</Surface>
|
||||
)}
|
||||
|
||||
{/* Error Message */}
|
||||
{error && errorMessage && (
|
||||
<p className="mt-2 text-sm text-warning-amber">{errorMessage}</p>
|
||||
<Box marginTop={2}>
|
||||
<Text size="sm" variant="critical">{errorMessage}</Text>
|
||||
</Box>
|
||||
)}
|
||||
</div>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
/* eslint-disable gridpilot-rules/no-raw-html-in-app */
|
||||
|
||||
|
||||
import React, { useCallback, useEffect, useRef, useState } from 'react';
|
||||
|
||||
import { Box } from '@/ui/Box';
|
||||
import { Group } from '@/ui/Group';
|
||||
import { Stack } from '@/ui/Stack';
|
||||
import { Text } from '@/ui/Text';
|
||||
import { Surface } from '@/ui/Surface';
|
||||
|
||||
interface RangeFieldProps {
|
||||
label: string;
|
||||
value: number;
|
||||
@@ -20,7 +22,7 @@ interface RangeFieldProps {
|
||||
/** Compact mode - single line */
|
||||
compact?: boolean;
|
||||
}
|
||||
|
||||
|
||||
export function RangeField({
|
||||
label,
|
||||
value,
|
||||
@@ -40,23 +42,23 @@ export function RangeField({
|
||||
const [isDragging, setIsDragging] = useState(false);
|
||||
const sliderRef = useRef<HTMLDivElement>(null);
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
|
||||
// Sync local value with prop when not dragging
|
||||
useEffect(() => {
|
||||
if (!isDragging) {
|
||||
setLocalValue(value);
|
||||
}
|
||||
}, [value, isDragging]);
|
||||
|
||||
|
||||
const clampedValue = Number.isFinite(localValue)
|
||||
? Math.min(Math.max(localValue, min), max)
|
||||
: min;
|
||||
|
||||
|
||||
const rangePercent = ((clampedValue - min) / Math.max(max - min, 1)) * 100;
|
||||
|
||||
|
||||
const effectiveRangeHint =
|
||||
rangeHint ?? (min === 0 ? `Up to ${max} ${unitLabel}` : `${min}–${max} ${unitLabel}`);
|
||||
|
||||
|
||||
const calculateValueFromPosition = useCallback(
|
||||
(clientX: number) => {
|
||||
if (!sliderRef.current) return clampedValue;
|
||||
@@ -68,7 +70,7 @@ export function RangeField({
|
||||
},
|
||||
[min, max, step, clampedValue]
|
||||
);
|
||||
|
||||
|
||||
const handlePointerDown = useCallback(
|
||||
(e: React.PointerEvent) => {
|
||||
if (disabled) return;
|
||||
@@ -81,7 +83,7 @@ export function RangeField({
|
||||
},
|
||||
[disabled, calculateValueFromPosition, onChange]
|
||||
);
|
||||
|
||||
|
||||
const handlePointerMove = useCallback(
|
||||
(e: React.PointerEvent) => {
|
||||
if (!isDragging || disabled) return;
|
||||
@@ -91,11 +93,11 @@ export function RangeField({
|
||||
},
|
||||
[isDragging, disabled, calculateValueFromPosition, onChange]
|
||||
);
|
||||
|
||||
|
||||
const handlePointerUp = useCallback(() => {
|
||||
setIsDragging(false);
|
||||
}, []);
|
||||
|
||||
|
||||
const handleInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const raw = e.target.value;
|
||||
if (raw === '') {
|
||||
@@ -109,120 +111,165 @@ export function RangeField({
|
||||
onChange(clamped);
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
const handleInputBlur = () => {
|
||||
// Ensure value is synced on blur
|
||||
onChange(clampedValue);
|
||||
};
|
||||
|
||||
|
||||
// Quick preset buttons for common values
|
||||
const quickPresets = [
|
||||
Math.round(min + (max - min) * 0.25),
|
||||
Math.round(min + (max - min) * 0.5),
|
||||
Math.round(min + (max - min) * 0.75),
|
||||
].filter((v, i, arr) => arr.indexOf(v) === i && v !== clampedValue);
|
||||
|
||||
|
||||
if (compact) {
|
||||
return (
|
||||
<div className="space-y-1.5">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<label className="text-xs font-medium text-gray-400 shrink-0">{label}</label>
|
||||
<div className="flex items-center gap-2 flex-1 max-w-[200px]">
|
||||
<Stack gap={1.5}>
|
||||
<Group justify="between" gap={3}>
|
||||
<Text as="label" size="xs" weight="medium" variant="low">{label}</Text>
|
||||
<Box display="flex" alignItems="center" gap={2} flex={1} maxWidth="200px">
|
||||
<div
|
||||
ref={sliderRef}
|
||||
className={`relative flex-1 h-6 cursor-pointer touch-none ${disabled ? 'opacity-50 cursor-not-allowed' : ''}`}
|
||||
style={{
|
||||
position: 'relative',
|
||||
flex: 1,
|
||||
height: '1.5rem',
|
||||
cursor: disabled ? 'not-allowed' : 'pointer',
|
||||
touchAction: 'none',
|
||||
opacity: disabled ? 0.5 : 1
|
||||
}}
|
||||
onPointerDown={handlePointerDown}
|
||||
onPointerMove={handlePointerMove}
|
||||
onPointerUp={handlePointerUp}
|
||||
>
|
||||
{/* Track background */}
|
||||
<div className="absolute top-1/2 -translate-y-1/2 left-0 right-0 h-1.5 rounded-full bg-charcoal-outline" />
|
||||
<Box position="absolute" top="50%" left={0} right={0} height="0.375rem" rounded="full" bg="var(--ui-color-border-muted)" style={{ transform: 'translateY(-50%)' }} />
|
||||
{/* Track fill */}
|
||||
<div
|
||||
className="absolute top-1/2 -translate-y-1/2 left-0 h-1.5 rounded-full bg-primary-blue transition-all duration-75"
|
||||
style={{ width: `${rangePercent}%` }}
|
||||
<Box
|
||||
position="absolute"
|
||||
top="50%"
|
||||
left={0}
|
||||
height="0.375rem"
|
||||
rounded="full"
|
||||
bg="var(--ui-color-intent-primary)"
|
||||
style={{ width: `${rangePercent}%`, transform: 'translateY(-50%)', transition: 'width 75ms' }}
|
||||
/>
|
||||
{/* Thumb */}
|
||||
<div
|
||||
className={`
|
||||
absolute top-1/2 -translate-y-1/2 -translate-x-1/2 w-4 h-4 rounded-full
|
||||
bg-white border-2 border-primary-blue shadow-md
|
||||
transition-transform duration-75
|
||||
${isDragging ? 'scale-125 shadow-[0_0_12px_rgba(25,140,255,0.5)]' : ''}
|
||||
`}
|
||||
style={{ left: `${rangePercent}%` }}
|
||||
<Box
|
||||
position="absolute"
|
||||
top="50%"
|
||||
width="1rem"
|
||||
height="1rem"
|
||||
rounded="full"
|
||||
bg="white"
|
||||
border="2px solid var(--ui-color-intent-primary)"
|
||||
shadow="md"
|
||||
style={{
|
||||
left: `${rangePercent}%`,
|
||||
transform: `translate(-50%, -50%) ${isDragging ? 'scale(1.25)' : ''}`,
|
||||
transition: 'transform 75ms, left 75ms',
|
||||
boxShadow: isDragging ? '0 0 12px rgba(25, 140, 255, 0.5)' : undefined
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center gap-1 shrink-0">
|
||||
<span className="text-sm font-semibold text-white w-8 text-right">{clampedValue}</span>
|
||||
<span className="text-[10px] text-gray-500">{unitLabel}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{error && <p className="text-[10px] text-warning-amber">{error}</p>}
|
||||
</div>
|
||||
<Box flexShrink={0}>
|
||||
<Group gap={1}>
|
||||
<Text size="sm" weight="semibold" variant="high" textAlign="right" width="2rem">{clampedValue}</Text>
|
||||
<Text size="xs" variant="low" style={{ fontSize: '10px' }}>{unitLabel}</Text>
|
||||
</Group>
|
||||
</Box>
|
||||
</Box>
|
||||
</Group>
|
||||
{error && <Text size="xs" variant="critical" style={{ fontSize: '10px' }}>{error}</Text>}
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-baseline justify-between gap-2">
|
||||
<label className="block text-sm font-medium text-gray-300">{label}</label>
|
||||
<span className="text-[10px] text-gray-500">{effectiveRangeHint}</span>
|
||||
</div>
|
||||
|
||||
<Stack gap={3}>
|
||||
<Group justify="between" align="baseline" gap={2}>
|
||||
<Text as="label" size="sm" weight="medium" variant="med">{label}</Text>
|
||||
<Text size="xs" variant="low" style={{ fontSize: '10px' }}>{effectiveRangeHint}</Text>
|
||||
</Group>
|
||||
|
||||
{showLargeValue && (
|
||||
<div className="flex items-baseline gap-1">
|
||||
<span className="text-3xl font-bold text-white tabular-nums">{clampedValue}</span>
|
||||
<span className="text-sm text-gray-400">{unitLabel}</span>
|
||||
</div>
|
||||
<Group align="baseline" gap={1}>
|
||||
<Text size="3xl" weight="bold" variant="high" font="mono">{clampedValue}</Text>
|
||||
<Text size="sm" variant="low">{unitLabel}</Text>
|
||||
</Group>
|
||||
)}
|
||||
|
||||
|
||||
{/* Custom slider */}
|
||||
<div
|
||||
ref={sliderRef}
|
||||
className={`relative h-8 cursor-pointer touch-none select-none ${disabled ? 'opacity-50 cursor-not-allowed' : ''}`}
|
||||
style={{
|
||||
position: 'relative',
|
||||
height: '2rem',
|
||||
cursor: disabled ? 'not-allowed' : 'pointer',
|
||||
touchAction: 'none',
|
||||
userSelect: 'none',
|
||||
opacity: disabled ? 0.5 : 1
|
||||
}}
|
||||
onPointerDown={handlePointerDown}
|
||||
onPointerMove={handlePointerMove}
|
||||
onPointerUp={handlePointerUp}
|
||||
>
|
||||
{/* Track background */}
|
||||
<div className="absolute top-1/2 -translate-y-1/2 left-0 right-0 h-2 rounded-full bg-charcoal-outline/80" />
|
||||
<Box position="absolute" top="50%" left={0} right={0} height="0.5rem" rounded="full" bg="var(--ui-color-border-muted)" style={{ transform: 'translateY(-50%)', opacity: 0.8 }} />
|
||||
|
||||
{/* Track fill with gradient */}
|
||||
<div
|
||||
className="absolute top-1/2 -translate-y-1/2 left-0 h-2 rounded-full bg-gradient-to-r from-primary-blue to-neon-aqua transition-all duration-75"
|
||||
style={{ width: `${rangePercent}%` }}
|
||||
<Box
|
||||
position="absolute"
|
||||
top="50%"
|
||||
left={0}
|
||||
height="0.5rem"
|
||||
rounded="full"
|
||||
style={{
|
||||
width: `${rangePercent}%`,
|
||||
transform: 'translateY(-50%)',
|
||||
transition: 'width 75ms',
|
||||
background: 'linear-gradient(to right, var(--ui-color-intent-primary), #00f2ff)'
|
||||
}}
|
||||
/>
|
||||
|
||||
|
||||
{/* Tick marks */}
|
||||
<div className="absolute top-1/2 -translate-y-1/2 left-0 right-0 flex justify-between px-1">
|
||||
<Box position="absolute" top="50%" left={0} right={0} display="flex" justifyContent="between" paddingX={1} style={{ transform: 'translateY(-50%)' }}>
|
||||
{[0, 25, 50, 75, 100].map((tick) => (
|
||||
<div
|
||||
<Box
|
||||
key={tick}
|
||||
className={`w-0.5 h-1 rounded-full transition-colors ${
|
||||
rangePercent >= tick ? 'bg-white/40' : 'bg-charcoal-outline'
|
||||
}`}
|
||||
width="0.125rem"
|
||||
height="0.25rem"
|
||||
rounded="full"
|
||||
bg={rangePercent >= tick ? 'rgba(255, 255, 255, 0.4)' : 'var(--ui-color-border-muted)'}
|
||||
style={{ transition: 'background-color 75ms' }}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
</Box>
|
||||
|
||||
{/* Thumb */}
|
||||
<div
|
||||
className={`
|
||||
absolute top-1/2 -translate-y-1/2 -translate-x-1/2
|
||||
w-5 h-5 rounded-full bg-white border-2 border-primary-blue
|
||||
shadow-[0_2px_8px_rgba(0,0,0,0.3)]
|
||||
transition-all duration-75
|
||||
${isDragging ? 'scale-125 shadow-[0_0_16px_rgba(25,140,255,0.6)]' : 'hover:scale-110'}
|
||||
`}
|
||||
style={{ left: `${rangePercent}%` }}
|
||||
<Box
|
||||
position="absolute"
|
||||
top="50%"
|
||||
width="1.25rem"
|
||||
height="1.25rem"
|
||||
rounded="full"
|
||||
bg="white"
|
||||
border="2px solid var(--ui-color-intent-primary)"
|
||||
shadow="lg"
|
||||
style={{
|
||||
left: `${rangePercent}%`,
|
||||
transform: `translate(-50%, -50%) ${isDragging ? 'scale(1.25)' : ''}`,
|
||||
transition: 'transform 75ms, left 75ms',
|
||||
boxShadow: isDragging ? '0 0 16px rgba(25, 140, 255, 0.6)' : '0 2px 8px rgba(0,0,0,0.3)'
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
|
||||
{/* Value input and quick presets */}
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<Group justify="between" align="center" gap={3}>
|
||||
<Group gap={2}>
|
||||
<input
|
||||
ref={inputRef}
|
||||
type="number"
|
||||
@@ -233,20 +280,26 @@ export function RangeField({
|
||||
onChange={handleInputChange}
|
||||
onBlur={handleInputBlur}
|
||||
disabled={disabled}
|
||||
className={`
|
||||
w-16 px-2 py-1.5 text-sm font-medium text-center rounded-lg
|
||||
bg-iron-gray border border-charcoal-outline text-white
|
||||
focus:border-primary-blue focus:ring-1 focus:ring-primary-blue focus:outline-none
|
||||
transition-colors
|
||||
${error ? 'border-warning-amber' : ''}
|
||||
${disabled ? 'opacity-50 cursor-not-allowed' : ''}
|
||||
`}
|
||||
style={{
|
||||
width: '4rem',
|
||||
padding: '0.375rem 0.5rem',
|
||||
fontSize: '0.875rem',
|
||||
fontWeight: 500,
|
||||
textAlign: 'center',
|
||||
borderRadius: 'var(--ui-radius-lg)',
|
||||
backgroundColor: 'var(--ui-color-bg-surface-muted)',
|
||||
border: `1px solid ${error ? 'var(--ui-color-intent-critical)' : 'var(--ui-color-border-default)'}`,
|
||||
color: 'white',
|
||||
outline: 'none',
|
||||
opacity: disabled ? 0.5 : 1,
|
||||
cursor: disabled ? 'not-allowed' : 'text'
|
||||
}}
|
||||
/>
|
||||
<span className="text-xs text-gray-400">{unitLabel}</span>
|
||||
</div>
|
||||
|
||||
<Text size="xs" variant="low">{unitLabel}</Text>
|
||||
</Group>
|
||||
|
||||
{quickPresets.length > 0 && (
|
||||
<div className="flex gap-1">
|
||||
<Group gap={1}>
|
||||
{quickPresets.slice(0, 3).map((preset) => (
|
||||
<button
|
||||
key={preset}
|
||||
@@ -256,17 +309,26 @@ export function RangeField({
|
||||
onChange(preset);
|
||||
}}
|
||||
disabled={disabled}
|
||||
className="px-2 py-1 text-[10px] rounded bg-charcoal-outline/50 text-gray-400 hover:bg-charcoal-outline hover:text-white transition-colors"
|
||||
style={{
|
||||
padding: '0.25rem 0.5rem',
|
||||
fontSize: '10px',
|
||||
borderRadius: 'var(--ui-radius-sm)',
|
||||
backgroundColor: 'rgba(255, 255, 255, 0.05)',
|
||||
color: 'var(--ui-color-text-low)',
|
||||
border: 'none',
|
||||
cursor: disabled ? 'not-allowed' : 'pointer',
|
||||
transition: 'all 150ms'
|
||||
}}
|
||||
>
|
||||
{preset}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</Group>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{helperText && <p className="text-xs text-gray-500">{helperText}</p>}
|
||||
{error && <p className="text-xs text-warning-amber">{error}</p>}
|
||||
</div>
|
||||
</Group>
|
||||
|
||||
{helperText && <Text size="xs" variant="low">{helperText}</Text>}
|
||||
{error && <Text size="xs" variant="critical">{error}</Text>}
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
@@ -3,6 +3,8 @@
|
||||
import { Toast as UIToast } from '@/ui/Toast';
|
||||
import { Text } from '@/ui/Text';
|
||||
import { Icon } from '@/ui/Icon';
|
||||
import { Box } from '@/ui/Box';
|
||||
import { Stack } from '@/ui/Stack';
|
||||
import { AlertCircle, CheckCircle, Info } from 'lucide-react';
|
||||
import React, { createContext, useContext, useState } from 'react';
|
||||
|
||||
@@ -32,16 +34,25 @@ export function ToastProvider({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<ToastContext.Provider value={{ showToast }}>
|
||||
{children}
|
||||
<div style={{ position: 'fixed', bottom: '1.5rem', right: '1.5rem', zIndex: 100, display: 'flex', flexDirection: 'column', gap: '0.75rem', pointerEvents: 'none' }}>
|
||||
<Box
|
||||
position="fixed"
|
||||
bottom={6}
|
||||
right={6}
|
||||
zIndex={100}
|
||||
display="flex"
|
||||
flexDirection="col"
|
||||
gap={3}
|
||||
style={{ pointerEvents: 'none' }}
|
||||
>
|
||||
{toasts.map((toast) => (
|
||||
<div key={toast.id} style={{ pointerEvents: 'auto' }}>
|
||||
<Box key={toast.id} style={{ pointerEvents: 'auto' }}>
|
||||
<ToastItem
|
||||
toast={toast}
|
||||
onClose={() => setToasts((prev) => prev.filter((t) => t.id !== toast.id))}
|
||||
/>
|
||||
</div>
|
||||
</Box>
|
||||
))}
|
||||
</div>
|
||||
</Box>
|
||||
</ToastContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
import { Image } from '@/ui/Image';
|
||||
import { SidebarItem } from '@/ui/SidebarItem';
|
||||
import { Text } from '@/ui/Text';
|
||||
import { Surface } from '@/ui/Surface';
|
||||
import React from 'react';
|
||||
|
||||
interface FriendItemProps {
|
||||
@@ -15,7 +16,7 @@ export function FriendItem({ name, avatarUrl, country }: FriendItemProps) {
|
||||
return (
|
||||
<SidebarItem
|
||||
icon={
|
||||
<div style={{ width: '100%', height: '100%', borderRadius: '9999px', overflow: 'hidden' }}>
|
||||
<Surface width="100%" height="100%" rounded="full" overflow="hidden">
|
||||
<Image
|
||||
src={avatarUrl}
|
||||
alt={name}
|
||||
@@ -23,7 +24,7 @@ export function FriendItem({ name, avatarUrl, country }: FriendItemProps) {
|
||||
height={36}
|
||||
objectFit="cover"
|
||||
/>
|
||||
</div>
|
||||
</Surface>
|
||||
}
|
||||
>
|
||||
<Text size="sm" variant="high" weight="medium" truncate block>
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import React, { ReactNode } from 'react';
|
||||
import { Stack } from '@/ui/Stack';
|
||||
|
||||
interface FriendsListProps {
|
||||
children: ReactNode;
|
||||
@@ -6,8 +7,8 @@ interface FriendsListProps {
|
||||
|
||||
export function FriendsList({ children }: FriendsListProps) {
|
||||
return (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '0.5rem' }}>
|
||||
<Stack gap={2}>
|
||||
{children}
|
||||
</div>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -9,6 +9,7 @@ import { SectionHeader } from '@/ui/SectionHeader';
|
||||
import { Icon } from '@/ui/Icon';
|
||||
import { Link } from '@/ui/Link';
|
||||
import { Text } from '@/ui/Text';
|
||||
import { Box } from '@/ui/Box';
|
||||
import { UserPlus, Users } from 'lucide-react';
|
||||
import React from 'react';
|
||||
|
||||
@@ -44,14 +45,14 @@ export function FriendsSidebar({ friends, hasFriends }: FriendsSidebarProps) {
|
||||
/>
|
||||
))}
|
||||
{friends.length > 6 && (
|
||||
<div style={{ padding: '0.5rem 0' }}>
|
||||
<Box paddingY={2}>
|
||||
<Link
|
||||
href={routes.protected.profile}
|
||||
variant="primary"
|
||||
>
|
||||
<Text size="sm" block align="center">+{friends.length - 6} more</Text>
|
||||
</Link>
|
||||
</div>
|
||||
</Box>
|
||||
)}
|
||||
</FriendsList>
|
||||
) : (
|
||||
|
||||
@@ -16,6 +16,9 @@ import { Input } from '@/ui/Input';
|
||||
import { Text } from '@/ui/Text';
|
||||
import { TextArea } from '@/ui/TextArea';
|
||||
import { SectionHeader } from '@/ui/SectionHeader';
|
||||
import { Box } from '@/ui/Box';
|
||||
import { Group } from '@/ui/Group';
|
||||
import { Stack } from '@/ui/Stack';
|
||||
import { useState } from 'react';
|
||||
import React from 'react';
|
||||
|
||||
@@ -94,7 +97,7 @@ export function TeamAdmin({ team, onUpdate }: TeamAdminProps) {
|
||||
};
|
||||
|
||||
return (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '1.5rem' }}>
|
||||
<Stack gap={6}>
|
||||
<Panel
|
||||
title="Team Settings"
|
||||
actions={!editMode && (
|
||||
@@ -104,7 +107,7 @@ export function TeamAdmin({ team, onUpdate }: TeamAdminProps) {
|
||||
)}
|
||||
>
|
||||
{editMode ? (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '1rem' }}>
|
||||
<Stack gap={4}>
|
||||
<Input
|
||||
label="Team Name"
|
||||
value={editedTeam.name}
|
||||
@@ -126,7 +129,7 @@ export function TeamAdmin({ team, onUpdate }: TeamAdminProps) {
|
||||
onChange={(e) => setEditedTeam({ ...editedTeam, description: e.target.value })}
|
||||
/>
|
||||
|
||||
<div style={{ display: 'flex', gap: '0.5rem' }}>
|
||||
<Group gap={2}>
|
||||
<Button variant="primary" onClick={handleSaveChanges} disabled={updateTeamMutation.isPending}>
|
||||
{updateTeamMutation.isPending ? 'Saving...' : 'Save Changes'}
|
||||
</Button>
|
||||
@@ -143,23 +146,23 @@ export function TeamAdmin({ team, onUpdate }: TeamAdminProps) {
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Group>
|
||||
</Stack>
|
||||
) : (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '1rem' }}>
|
||||
<div>
|
||||
<Text size="sm" variant="low" block marginBottom={1}>Team Name</Text>
|
||||
<Stack gap={4}>
|
||||
<Stack gap={1}>
|
||||
<Text size="sm" variant="low" block>Team Name</Text>
|
||||
<Text variant="high" weight="medium" block>{team.name}</Text>
|
||||
</div>
|
||||
<div>
|
||||
<Text size="sm" variant="low" block marginBottom={1}>Team Tag</Text>
|
||||
</Stack>
|
||||
<Stack gap={1}>
|
||||
<Text size="sm" variant="low" block>Team Tag</Text>
|
||||
<Text variant="high" weight="medium" block>{team.tag}</Text>
|
||||
</div>
|
||||
<div>
|
||||
<Text size="sm" variant="low" block marginBottom={1}>Description</Text>
|
||||
</Stack>
|
||||
<Stack gap={1}>
|
||||
<Text size="sm" variant="low" block>Description</Text>
|
||||
<Text variant="high" block>{team.description}</Text>
|
||||
</div>
|
||||
</div>
|
||||
</Stack>
|
||||
</Stack>
|
||||
)}
|
||||
</Panel>
|
||||
|
||||
@@ -197,6 +200,6 @@ export function TeamAdmin({ team, onUpdate }: TeamAdminProps) {
|
||||
Disband Team (Coming Soon)
|
||||
</Button>
|
||||
</DangerZone>
|
||||
</div>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -6,6 +6,9 @@ import { TeamHero } from '@/ui/TeamHero';
|
||||
import { Text } from '@/ui/Text';
|
||||
import { Badge } from '@/ui/Badge';
|
||||
import { StatGrid } from '@/ui/StatGrid';
|
||||
import { Group } from '@/ui/Group';
|
||||
import { Surface } from '@/ui/Surface';
|
||||
import { Box } from '@/ui/Box';
|
||||
|
||||
interface TeamDetailsHeaderProps {
|
||||
teamId: string;
|
||||
@@ -36,23 +39,30 @@ export function TeamDetailsHeader({
|
||||
return (
|
||||
<TeamHero
|
||||
title={
|
||||
<div className="flex items-center gap-3">
|
||||
<Group gap={3}>
|
||||
{name}
|
||||
{tag && <Badge variant="outline">[{tag}]</Badge>}
|
||||
</div>
|
||||
</Group>
|
||||
}
|
||||
description={description || 'No mission statement provided.'}
|
||||
memberCount={memberCount}
|
||||
memberCountLabel={memberCountLabel}
|
||||
foundedDateLabel={foundedDateLabel}
|
||||
sideContent={
|
||||
<div className="w-32 h-32 bg-[var(--ui-color-bg-surface-muted)] border border-[var(--ui-color-border-default)] flex items-center justify-center overflow-hidden rounded-lg">
|
||||
<Surface
|
||||
variant="muted"
|
||||
rounded="lg"
|
||||
width="8rem"
|
||||
height="8rem"
|
||||
display="flex"
|
||||
alignItems="center"
|
||||
justifyContent="center"
|
||||
overflow="hidden"
|
||||
border
|
||||
>
|
||||
{logoUrl ? (
|
||||
<Image src={logoUrl} alt={name} width={128} height={128} />
|
||||
) : (
|
||||
<Text size="2xl" weight="bold" variant="low">{name.substring(0, 2).toUpperCase()}</Text>
|
||||
)}
|
||||
</div>
|
||||
</Surface>
|
||||
}
|
||||
stats={
|
||||
<StatGrid
|
||||
@@ -71,7 +81,7 @@ export function TeamDetailsHeader({
|
||||
/>
|
||||
}
|
||||
actions={
|
||||
<>
|
||||
<Group gap={3}>
|
||||
{isAdmin && (
|
||||
<Button variant="secondary" onClick={onAdminClick}>
|
||||
Configure
|
||||
@@ -80,7 +90,7 @@ export function TeamDetailsHeader({
|
||||
<Button variant="primary">
|
||||
Join Request
|
||||
</Button>
|
||||
</>
|
||||
</Group>
|
||||
}
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -9,6 +9,7 @@ import { Button } from '@/ui/Button';
|
||||
import { Icon } from '@/ui/Icon';
|
||||
import { TeamHero } from '@/ui/TeamHero';
|
||||
import { Text } from '@/ui/Text';
|
||||
import { Stack } from '@/ui/Stack';
|
||||
import {
|
||||
Crown,
|
||||
Plus,
|
||||
@@ -27,24 +28,36 @@ const SKILL_LEVELS = [
|
||||
label: 'Pro',
|
||||
icon: Crown,
|
||||
intent: 'warning' as const,
|
||||
color: 'text-warning-amber',
|
||||
bgColor: 'bg-warning-amber/10',
|
||||
borderColor: 'border-warning-amber/20',
|
||||
},
|
||||
{
|
||||
id: 'advanced',
|
||||
label: 'Advanced',
|
||||
icon: Star,
|
||||
intent: 'primary' as const,
|
||||
color: 'text-primary-blue',
|
||||
bgColor: 'bg-primary-blue/10',
|
||||
borderColor: 'border-primary-blue/20',
|
||||
},
|
||||
{
|
||||
id: 'intermediate',
|
||||
label: 'Intermediate',
|
||||
icon: TrendingUp,
|
||||
intent: 'telemetry' as const,
|
||||
color: 'text-telemetry-aqua',
|
||||
bgColor: 'bg-telemetry-aqua/10',
|
||||
borderColor: 'border-telemetry-aqua/20',
|
||||
},
|
||||
{
|
||||
id: 'beginner',
|
||||
label: 'Beginner',
|
||||
icon: Shield,
|
||||
intent: 'success' as const,
|
||||
color: 'text-performance-green',
|
||||
bgColor: 'bg-performance-green/10',
|
||||
borderColor: 'border-performance-green/20',
|
||||
},
|
||||
] as const;
|
||||
|
||||
@@ -96,11 +109,11 @@ export function TeamHeroSection({
|
||||
</React.Fragment>
|
||||
}
|
||||
sideContent={
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '1rem' }}>
|
||||
<Stack gap={4}>
|
||||
<Text size="xs" variant="low" weight="bold" uppercase>
|
||||
Find Your Level
|
||||
</Text>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '0.5rem' }}>
|
||||
<Stack gap={2}>
|
||||
{SKILL_LEVELS.map((level) => {
|
||||
const count = teamsByLevel[level.id]?.length || 0;
|
||||
|
||||
@@ -109,13 +122,16 @@ export function TeamHeroSection({
|
||||
key={level.id}
|
||||
label={level.label}
|
||||
icon={level.icon}
|
||||
color={level.color}
|
||||
bgColor={level.bgColor}
|
||||
borderColor={level.borderColor}
|
||||
count={count}
|
||||
onClick={() => onSkillLevelClick(level.id as SkillLevel)}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</Stack>
|
||||
</Stack>
|
||||
}
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -4,6 +4,7 @@ import { Box } from './Box';
|
||||
import { Card } from './Card';
|
||||
import { Heading } from './Heading';
|
||||
import { Icon } from './Icon';
|
||||
import { Text } from './Text';
|
||||
|
||||
export interface RaceCardProps {
|
||||
children: ReactNode;
|
||||
|
||||
Reference in New Issue
Block a user