website refactor
This commit is contained in:
@@ -1,56 +0,0 @@
|
||||
import { ChevronDown, ChevronUp } from 'lucide-react';
|
||||
import { ReactNode, useState } from 'react';
|
||||
import { Box } from './Box';
|
||||
import { Icon } from './Icon';
|
||||
import { Surface } from './Surface';
|
||||
import { Text } from './Text';
|
||||
|
||||
export interface AccordionProps {
|
||||
title: string;
|
||||
children: ReactNode;
|
||||
defaultOpen?: boolean;
|
||||
isOpen?: boolean;
|
||||
onToggle?: () => void;
|
||||
}
|
||||
|
||||
export const Accordion = ({
|
||||
title,
|
||||
children,
|
||||
defaultOpen = false,
|
||||
isOpen: controlledIsOpen,
|
||||
onToggle
|
||||
}: AccordionProps) => {
|
||||
const [internalIsOpen, setInternalIsOpen] = useState(defaultOpen);
|
||||
|
||||
const isControlled = controlledIsOpen !== undefined;
|
||||
const isOpen = isControlled ? controlledIsOpen : internalIsOpen;
|
||||
|
||||
const handleToggle = () => {
|
||||
if (onToggle) {
|
||||
onToggle();
|
||||
}
|
||||
if (!isControlled) {
|
||||
setInternalIsOpen(!internalIsOpen);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Surface variant="muted" rounded="lg" style={{ border: '1px solid var(--ui-color-border-default)', overflow: 'hidden' }}>
|
||||
<button
|
||||
onClick={handleToggle}
|
||||
className="w-full flex items-center justify-between px-4 py-3 hover:bg-white/5 transition-colors"
|
||||
>
|
||||
<Text weight="bold" size="sm" variant="high">
|
||||
{title}
|
||||
</Text>
|
||||
<Icon icon={isOpen ? ChevronUp : ChevronDown} size={4} intent="low" />
|
||||
</button>
|
||||
|
||||
{isOpen && (
|
||||
<Box padding={4} borderTop>
|
||||
{children}
|
||||
</Box>
|
||||
)}
|
||||
</Surface>
|
||||
);
|
||||
};
|
||||
@@ -184,6 +184,7 @@ export interface BoxProps<T extends ElementType> {
|
||||
title?: string;
|
||||
size?: string | number | ResponsiveValue<string | number>;
|
||||
accept?: string;
|
||||
multiple?: boolean;
|
||||
autoPlay?: boolean;
|
||||
loop?: boolean;
|
||||
muted?: boolean;
|
||||
@@ -335,6 +336,7 @@ export const Box = forwardRef(<T extends ElementType = 'div'>(
|
||||
title,
|
||||
size,
|
||||
accept,
|
||||
multiple,
|
||||
autoPlay,
|
||||
loop,
|
||||
muted,
|
||||
@@ -564,6 +566,7 @@ export const Box = forwardRef(<T extends ElementType = 'div'>(
|
||||
rows={rows}
|
||||
href={href}
|
||||
name={name}
|
||||
multiple={multiple}
|
||||
onPointerDown={onPointerDown}
|
||||
onPointerMove={onPointerMove}
|
||||
onPointerUp={onPointerUp}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { Box } from '@/ui/Box';
|
||||
import { Link } from '@/ui/Link';
|
||||
import Image from 'next/image';
|
||||
import { Image } from '@/ui/Image';
|
||||
|
||||
interface BrandMarkProps {
|
||||
href?: string;
|
||||
@@ -11,7 +11,7 @@ interface BrandMarkProps {
|
||||
* BrandMark provides the consistent logo/wordmark for the application.
|
||||
* Aligned with "Precision Racing Minimal" theme.
|
||||
*/
|
||||
export function BrandMark({ href = '/', priority = false }: BrandMarkProps) {
|
||||
export function BrandMark({ href = '/' }: BrandMarkProps) {
|
||||
return (
|
||||
<Link href={href} variant="inherit" underline="none">
|
||||
<Box position="relative" display="inline-flex" alignItems="center">
|
||||
@@ -19,10 +19,7 @@ export function BrandMark({ href = '/', priority = false }: BrandMarkProps) {
|
||||
<Image
|
||||
src="/images/logos/wordmark-rectangle-dark.svg"
|
||||
alt="GridPilot"
|
||||
width={160}
|
||||
height={30}
|
||||
priority={priority}
|
||||
style={{ height: '100%', width: 'auto' }}
|
||||
style={{ height: '100%', width: 'auto', display: 'block' }}
|
||||
/>
|
||||
</Box>
|
||||
<Box
|
||||
|
||||
18
apps/website/ui/CardStack.tsx
Normal file
18
apps/website/ui/CardStack.tsx
Normal file
@@ -0,0 +1,18 @@
|
||||
import React from 'react';
|
||||
import { Box } from './Box';
|
||||
|
||||
interface CardStackProps {
|
||||
children: React.ReactNode;
|
||||
gap?: 2 | 3 | 4 | 6 | 8;
|
||||
}
|
||||
|
||||
/**
|
||||
* CardStack - A semantic UI component for stacking cards or panels.
|
||||
*/
|
||||
export function CardStack({ children, gap = 4 }: CardStackProps) {
|
||||
return (
|
||||
<Box display="flex" flexDirection="col" gap={gap}>
|
||||
{children}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
25
apps/website/ui/Center.tsx
Normal file
25
apps/website/ui/Center.tsx
Normal file
@@ -0,0 +1,25 @@
|
||||
import React from 'react';
|
||||
import { Box } from './Box';
|
||||
|
||||
interface CenterProps {
|
||||
children: React.ReactNode;
|
||||
fullWidth?: boolean;
|
||||
fullHeight?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Center - A semantic UI component for centering content.
|
||||
*/
|
||||
export function Center({ children, fullWidth, fullHeight }: CenterProps) {
|
||||
return (
|
||||
<Box
|
||||
display="flex"
|
||||
alignItems="center"
|
||||
justifyContent="center"
|
||||
fullWidth={fullWidth}
|
||||
fullHeight={fullHeight}
|
||||
>
|
||||
{children}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
@@ -1,47 +0,0 @@
|
||||
import { Button } from '@/ui/Button';
|
||||
import { Modal } from '@/ui/Modal';
|
||||
import { Text } from '@/ui/Text';
|
||||
import { Icon } from '@/ui/Icon';
|
||||
import { AlertCircle } from 'lucide-react';
|
||||
import React from 'react';
|
||||
|
||||
interface ConfirmDialogProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
onConfirm: () => void;
|
||||
title: string;
|
||||
description: string;
|
||||
confirmLabel?: string;
|
||||
cancelLabel?: string;
|
||||
variant?: 'danger' | 'primary';
|
||||
isLoading?: boolean;
|
||||
}
|
||||
|
||||
export function ConfirmDialog({
|
||||
isOpen,
|
||||
onClose,
|
||||
onConfirm,
|
||||
title,
|
||||
description,
|
||||
confirmLabel = 'Confirm',
|
||||
cancelLabel = 'Cancel',
|
||||
variant = 'primary',
|
||||
isLoading = false,
|
||||
}: ConfirmDialogProps) {
|
||||
return (
|
||||
<Modal
|
||||
isOpen={isOpen}
|
||||
onClose={onClose}
|
||||
title={title}
|
||||
primaryActionLabel={isLoading ? 'Processing...' : confirmLabel}
|
||||
onPrimaryAction={onConfirm}
|
||||
secondaryActionLabel={cancelLabel}
|
||||
onSecondaryAction={onClose}
|
||||
icon={variant === 'danger' ? <Icon icon={AlertCircle} size={5} intent="critical" /> : undefined}
|
||||
>
|
||||
<Text variant="low" size="sm">
|
||||
{description}
|
||||
</Text>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
@@ -21,7 +21,7 @@ export const ContentViewport = ({
|
||||
};
|
||||
|
||||
return (
|
||||
<Box as="main" flex={1} overflow="auto">
|
||||
<Box flexGrow={1}>
|
||||
<Container size={fullWidth ? 'full' : 'xl'}>
|
||||
<Box paddingY={paddingMap[padding]}>
|
||||
{children}
|
||||
|
||||
@@ -17,6 +17,9 @@ export const ControlBar = ({
|
||||
<Surface
|
||||
variant={variant === 'dark' ? 'dark' : 'muted'}
|
||||
padding={4}
|
||||
position="sticky"
|
||||
top="0"
|
||||
zIndex={50}
|
||||
style={{ borderBottom: '1px solid var(--ui-color-border-default)' }}
|
||||
>
|
||||
<Box display="flex" alignItems="center" justifyContent="between" flexWrap="wrap" gap={4}>
|
||||
|
||||
@@ -1,159 +0,0 @@
|
||||
import { connectionMonitor } from '@/lib/api/base/ApiConnectionMonitor';
|
||||
import { ApiError } from '@/lib/api/base/ApiError';
|
||||
import { CircuitBreakerRegistry } from '@/lib/api/base/RetryHandler';
|
||||
import { Badge } from '@/ui/Badge';
|
||||
import { Box } from '@/ui/Box';
|
||||
import { Button } from '@/ui/Button';
|
||||
import { Card } from '@/ui/Card';
|
||||
import { Heading } from '@/ui/Heading';
|
||||
import { Icon } from '@/ui/Icon';
|
||||
import { Text } from '@/ui/Text';
|
||||
import { Copy, RefreshCw, Terminal, X } from 'lucide-react';
|
||||
import { useEffect, useState } from 'react';
|
||||
|
||||
interface DevErrorPanelProps {
|
||||
error: ApiError;
|
||||
onReset: () => void;
|
||||
}
|
||||
|
||||
export function DevErrorPanel({ error, onReset }: DevErrorPanelProps) {
|
||||
const [connectionStatus, setConnectionStatus] = useState(connectionMonitor.getHealth());
|
||||
const [circuitBreakers, setCircuitBreakers] = useState(CircuitBreakerRegistry.getInstance().getStatus());
|
||||
const [copied, setCopied] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
const handleStatusChange = () => {
|
||||
setConnectionStatus(connectionMonitor.getHealth());
|
||||
setCircuitBreakers(CircuitBreakerRegistry.getInstance().getStatus());
|
||||
};
|
||||
|
||||
connectionMonitor.on('success', handleStatusChange);
|
||||
connectionMonitor.on('failure', handleStatusChange);
|
||||
connectionMonitor.on('connected', handleStatusChange);
|
||||
connectionMonitor.on('disconnected', handleStatusChange);
|
||||
connectionMonitor.on('degraded', handleStatusChange);
|
||||
|
||||
return () => {
|
||||
connectionMonitor.off('success', handleStatusChange);
|
||||
connectionMonitor.off('failure', handleStatusChange);
|
||||
connectionMonitor.off('connected', handleStatusChange);
|
||||
connectionMonitor.off('disconnected', handleStatusChange);
|
||||
connectionMonitor.off('degraded', handleStatusChange);
|
||||
};
|
||||
}, []);
|
||||
|
||||
const copyToClipboard = async () => {
|
||||
const debugInfo = {
|
||||
error: {
|
||||
type: error.type,
|
||||
message: error.message,
|
||||
context: error.context,
|
||||
stack: error.stack,
|
||||
},
|
||||
connection: connectionStatus,
|
||||
circuitBreakers,
|
||||
timestamp: new Date().toISOString(),
|
||||
userAgent: navigator.userAgent,
|
||||
url: window.location.href,
|
||||
};
|
||||
|
||||
try {
|
||||
await navigator.clipboard.writeText(JSON.stringify(debugInfo, null, 2));
|
||||
setCopied(true);
|
||||
setTimeout(() => setCopied(false), 2000);
|
||||
} catch (err) {
|
||||
// Silent failure
|
||||
}
|
||||
};
|
||||
|
||||
const getSeverityVariant = (): 'critical' | 'warning' | 'primary' | 'default' => {
|
||||
switch (error.getSeverity()) {
|
||||
case 'error': return 'critical';
|
||||
case 'warn': return 'warning';
|
||||
case 'info': return 'primary';
|
||||
default: return 'default';
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Box position="fixed" inset={0} zIndex={100} bg="var(--ui-color-bg-base)" padding={4} style={{ overflowY: 'auto' }}>
|
||||
<Box maxWidth="80rem" marginX="auto" fullWidth>
|
||||
<Box display="flex" flexDirection="col" gap={4}>
|
||||
{/* Header */}
|
||||
<Card variant="dark" padding={4}>
|
||||
<Box display="flex" alignItems="center" justifyContent="between">
|
||||
<Box display="flex" alignItems="center" gap={3}>
|
||||
<Icon icon={Terminal} size={5} intent="primary" />
|
||||
<Heading level={2}>API Error Debug Panel</Heading>
|
||||
<Badge variant={getSeverityVariant() as any}>
|
||||
{error.type}
|
||||
</Badge>
|
||||
</Box>
|
||||
<Box display="flex" gap={2}>
|
||||
<Button variant="secondary" onClick={copyToClipboard} icon={<Icon icon={Copy} size={4} />}>
|
||||
{copied ? 'Copied!' : 'Copy'}
|
||||
</Button>
|
||||
<Button variant="primary" onClick={onReset} icon={<Icon icon={X} size={4} />}>
|
||||
Close
|
||||
</Button>
|
||||
</Box>
|
||||
</Box>
|
||||
</Card>
|
||||
|
||||
{/* Details Grid */}
|
||||
<Box display="grid" gridCols={{ base: 1, lg: 2 }} gap={4}>
|
||||
<Box display="flex" flexDirection="col" gap={4}>
|
||||
<Card title="Error Details" variant="outline">
|
||||
<Box display="flex" flexDirection="col" gap={2}>
|
||||
<DetailRow label="Type" value={error.type} intent="critical" />
|
||||
<DetailRow label="Message" value={error.message} />
|
||||
<DetailRow label="Endpoint" value={error.context.endpoint || 'N/A'} intent="primary" />
|
||||
<DetailRow label="Method" value={error.context.method || 'N/A'} intent="warning" />
|
||||
<DetailRow label="Status" value={error.context.statusCode || 'N/A'} />
|
||||
</Box>
|
||||
</Card>
|
||||
|
||||
<Card title="Connection Health" variant="outline">
|
||||
<Box display="flex" flexDirection="col" gap={2}>
|
||||
<DetailRow label="Status" value={connectionStatus.status.toUpperCase()} intent={connectionStatus.status === 'connected' ? 'success' : 'critical'} />
|
||||
<DetailRow label="Reliability" value={`${connectionMonitor.getReliability().toFixed(2)}%`} />
|
||||
<DetailRow label="Total Requests" value={connectionStatus.totalRequests} />
|
||||
</Box>
|
||||
</Card>
|
||||
</Box>
|
||||
|
||||
<Box display="flex" flexDirection="col" gap={4}>
|
||||
<Card title="Actions" variant="outline">
|
||||
<Box display="flex" flexDirection="col" gap={2}>
|
||||
<Button variant="primary" onClick={() => connectionMonitor.performHealthCheck()} fullWidth icon={<Icon icon={RefreshCw} size={4} />}>
|
||||
Run Health Check
|
||||
</Button>
|
||||
<Button variant="secondary" onClick={() => CircuitBreakerRegistry.getInstance().resetAll()} fullWidth>
|
||||
Reset Circuit Breakers
|
||||
</Button>
|
||||
</Box>
|
||||
</Card>
|
||||
|
||||
<Card title="Raw Error" variant="outline">
|
||||
<Box padding={2} bg="var(--ui-color-bg-surface-muted)" rounded="md" style={{ maxHeight: '10rem', overflow: 'auto' }}>
|
||||
<Text size="xs" variant="low" font="mono">
|
||||
{JSON.stringify(error.context, null, 2)}
|
||||
</Text>
|
||||
</Box>
|
||||
</Card>
|
||||
</Box>
|
||||
</Box>
|
||||
</Box>
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
const DetailRow = ({ label, value, intent = 'low' }: { label: string; value: any; intent?: any }) => (
|
||||
<Box display="grid" gridCols={3} gap={2}>
|
||||
<Text size="xs" variant="low" weight="bold">{label}:</Text>
|
||||
<Box style={{ gridColumn: 'span 2' }}>
|
||||
<Text size="xs" variant={intent} weight="bold">{String(value)}</Text>
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
99
apps/website/ui/DiscordCTA.tsx
Normal file
99
apps/website/ui/DiscordCTA.tsx
Normal file
@@ -0,0 +1,99 @@
|
||||
import React from 'react';
|
||||
import { Section } from './Section';
|
||||
import { Container } from './Container';
|
||||
import { Card } from './Card';
|
||||
import { Box } from './Box';
|
||||
import { Glow } from './Glow';
|
||||
import { Heading } from './Heading';
|
||||
import { Text } from './Text';
|
||||
import { Button } from './Button';
|
||||
import { DiscordIcon } from './icons/DiscordIcon';
|
||||
import { Icon } from './Icon';
|
||||
import { LucideIcon } from 'lucide-react';
|
||||
|
||||
interface DiscordCTAProps {
|
||||
title: string;
|
||||
description: string;
|
||||
lead: string;
|
||||
benefits: Array<{
|
||||
icon: LucideIcon;
|
||||
title: string;
|
||||
description: string;
|
||||
}>;
|
||||
discordUrl: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* DiscordCTA - A semantic UI component for the Discord call to action.
|
||||
*/
|
||||
export function DiscordCTA({
|
||||
title,
|
||||
description,
|
||||
lead,
|
||||
benefits,
|
||||
discordUrl,
|
||||
}: DiscordCTAProps) {
|
||||
return (
|
||||
<Section variant="dark" padding="lg">
|
||||
<Glow color="primary" size="xl" position="center" opacity={0.05} />
|
||||
|
||||
<Container>
|
||||
<Card variant="outline">
|
||||
<Box display="flex" flexDirection="col" alignItems="center" gap={12} padding={8}>
|
||||
{/* Header */}
|
||||
<Box display="flex" flexDirection="col" alignItems="center" gap={6}>
|
||||
<DiscordIcon size={40} />
|
||||
<Heading level={2} weight="bold" align="center">
|
||||
{title}
|
||||
</Heading>
|
||||
</Box>
|
||||
|
||||
{/* Personal message */}
|
||||
<Box display="flex" flexDirection="col" alignItems="center" gap={6} maxWidth="2xl">
|
||||
<Text size="lg" variant="high" weight="medium" align="center">
|
||||
{lead}
|
||||
</Text>
|
||||
<Text size="base" variant="low" align="center">
|
||||
{description}
|
||||
</Text>
|
||||
</Box>
|
||||
|
||||
{/* Benefits grid */}
|
||||
<Box display="grid" gridCols={{ base: 1, md: 2 }} gap={6} fullWidth>
|
||||
{benefits.map((benefit, index) => (
|
||||
<Card key={index} variant="dark">
|
||||
<Box display="flex" flexDirection="col" gap={5} padding={6}>
|
||||
<Icon icon={benefit.icon} size={5} intent="primary" />
|
||||
<Box display="flex" flexDirection="col" gap={2}>
|
||||
<Text size="base" weight="bold" variant="high">{benefit.title}</Text>
|
||||
<Text size="sm" variant="low">{benefit.description}</Text>
|
||||
</Box>
|
||||
</Box>
|
||||
</Card>
|
||||
))}
|
||||
</Box>
|
||||
|
||||
{/* CTA Button */}
|
||||
<Box display="flex" flexDirection="col" alignItems="center" gap={6}>
|
||||
<Button
|
||||
as="a"
|
||||
href={discordUrl}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
variant="primary"
|
||||
size="lg"
|
||||
icon={<DiscordIcon size={24} />}
|
||||
>
|
||||
Join Discord
|
||||
</Button>
|
||||
|
||||
<Text size="xs" variant="primary" weight="bold" font="mono" uppercase letterSpacing="widest">
|
||||
Early Alpha Access Available
|
||||
</Text>
|
||||
</Box>
|
||||
</Box>
|
||||
</Card>
|
||||
</Container>
|
||||
</Section>
|
||||
);
|
||||
}
|
||||
57
apps/website/ui/DiscoverySection.tsx
Normal file
57
apps/website/ui/DiscoverySection.tsx
Normal file
@@ -0,0 +1,57 @@
|
||||
import React from 'react';
|
||||
import { Section } from './Section';
|
||||
import { Container } from './Container';
|
||||
import { Box } from './Box';
|
||||
import { Heading } from './Heading';
|
||||
import { Text } from './Text';
|
||||
import { Grid } from './Grid';
|
||||
|
||||
interface DiscoverySectionProps {
|
||||
title: string;
|
||||
subtitle: string;
|
||||
description: string;
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
/**
|
||||
* DiscoverySection - A semantic section for discovering content.
|
||||
*/
|
||||
export function DiscoverySection({
|
||||
title,
|
||||
subtitle,
|
||||
description,
|
||||
children,
|
||||
}: DiscoverySectionProps) {
|
||||
return (
|
||||
<Section variant="dark" padding="lg">
|
||||
<Container>
|
||||
<Box maxWidth="2xl" marginBottom={16}>
|
||||
<Box
|
||||
display="flex"
|
||||
alignItems="center"
|
||||
borderLeft
|
||||
borderStyle="solid"
|
||||
borderWidth="2px"
|
||||
borderColor="var(--ui-color-intent-primary)"
|
||||
paddingLeft={4}
|
||||
marginBottom={4}
|
||||
>
|
||||
<Text size="xs" weight="bold" uppercase letterSpacing="widest" variant="primary">
|
||||
{subtitle}
|
||||
</Text>
|
||||
</Box>
|
||||
<Heading level={2} weight="bold" marginBottom={6}>
|
||||
{title}
|
||||
</Heading>
|
||||
<Text size="lg" variant="low" leading="relaxed">
|
||||
{description}
|
||||
</Text>
|
||||
</Box>
|
||||
|
||||
<Grid cols={{ base: 1, lg: 3 }} gap={8}>
|
||||
{children}
|
||||
</Grid>
|
||||
</Container>
|
||||
</Section>
|
||||
);
|
||||
}
|
||||
@@ -1,240 +0,0 @@
|
||||
import { ApiError } from '@/lib/api/base/ApiError';
|
||||
import { Box } from '@/ui/Box';
|
||||
import { Button } from '@/ui/Button';
|
||||
import { Heading } from '@/ui/Heading';
|
||||
import { Icon } from '@/ui/Icon';
|
||||
import { ErrorDisplayAction, ErrorDisplayProps } from '@/ui/state-types';
|
||||
import { Surface } from '@/ui/Surface';
|
||||
import { Text } from '@/ui/Text';
|
||||
import { AlertCircle, ArrowLeft, Home, RefreshCw } from 'lucide-react';
|
||||
|
||||
export function ErrorDisplay({
|
||||
error,
|
||||
onRetry,
|
||||
variant = 'full-screen',
|
||||
actions = [],
|
||||
showRetry = true,
|
||||
showNavigation = true,
|
||||
hideTechnicalDetails = false,
|
||||
}: ErrorDisplayProps) {
|
||||
const getErrorInfo = () => {
|
||||
const isApiError = error instanceof ApiError;
|
||||
|
||||
return {
|
||||
title: isApiError ? 'API Error' : 'Unexpected Error',
|
||||
message: error.message || 'Something went wrong',
|
||||
statusCode: isApiError ? error.context.statusCode : undefined,
|
||||
details: isApiError ? error.context.responseText : undefined,
|
||||
isApiError,
|
||||
};
|
||||
};
|
||||
|
||||
const errorInfo = getErrorInfo();
|
||||
|
||||
const defaultActions: ErrorDisplayAction[] = [
|
||||
...(showRetry && onRetry ? [{ label: 'Retry', onClick: onRetry, variant: 'primary' as const, icon: RefreshCw }] : []),
|
||||
...(showNavigation ? [
|
||||
{ label: 'Go Back', onClick: () => window.history.back(), variant: 'secondary' as const, icon: ArrowLeft },
|
||||
{ label: 'Home', onClick: () => window.location.href = '/', variant: 'secondary' as const, icon: Home },
|
||||
] : []),
|
||||
...actions,
|
||||
];
|
||||
|
||||
const ErrorIcon = () => (
|
||||
<Box
|
||||
display="flex"
|
||||
width={20}
|
||||
height={20}
|
||||
alignItems="center"
|
||||
justifyContent="center"
|
||||
rounded="xl"
|
||||
bg="rgba(227, 92, 92, 0.1)"
|
||||
style={{ border: '1px solid rgba(227, 92, 92, 0.3)' }}
|
||||
>
|
||||
<Icon icon={AlertCircle} size={10} intent="critical" />
|
||||
</Box>
|
||||
);
|
||||
|
||||
switch (variant) {
|
||||
case 'full-screen':
|
||||
return (
|
||||
<Box
|
||||
position="fixed"
|
||||
inset={0}
|
||||
zIndex={100}
|
||||
bg="var(--ui-color-bg-base)"
|
||||
display="flex"
|
||||
alignItems="center"
|
||||
justifyContent="center"
|
||||
padding={6}
|
||||
role="alert"
|
||||
>
|
||||
<Box maxWidth="32rem" fullWidth textAlign="center">
|
||||
<Box display="flex" justifyContent="center" marginBottom={6}>
|
||||
<ErrorIcon />
|
||||
</Box>
|
||||
|
||||
<Heading level={2} marginBottom={3}>
|
||||
{errorInfo.title}
|
||||
</Heading>
|
||||
|
||||
<Text size="lg" variant="low" block marginBottom={6} leading="relaxed">
|
||||
{errorInfo.message}
|
||||
</Text>
|
||||
|
||||
{errorInfo.isApiError && errorInfo.statusCode && (
|
||||
<Box marginBottom={6} display="inline-flex" alignItems="center" gap={2} paddingX={4} paddingY={2} bg="var(--ui-color-bg-surface-muted)" rounded="lg">
|
||||
<Text size="sm" variant="med" font="mono">HTTP {errorInfo.statusCode}</Text>
|
||||
{errorInfo.details && !hideTechnicalDetails && (
|
||||
<Text size="sm" variant="low">- {errorInfo.details}</Text>
|
||||
)}
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{defaultActions.length > 0 && (
|
||||
<Box display="flex" flexDirection={{ base: 'col', md: 'row' }} gap={3} justifyContent="center">
|
||||
{defaultActions.map((action, index) => (
|
||||
<Button
|
||||
key={index}
|
||||
onClick={action.onClick}
|
||||
variant={action.variant === 'primary' ? 'danger' : 'secondary'}
|
||||
icon={action.icon && <Icon icon={action.icon} size={4} />}
|
||||
>
|
||||
{action.label}
|
||||
</Button>
|
||||
))}
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{!hideTechnicalDetails && process.env.NODE_ENV === 'development' && error.stack && (
|
||||
<Box marginTop={8} textAlign="left">
|
||||
<details style={{ cursor: 'pointer' }}>
|
||||
<summary>
|
||||
<Text as="span" size="sm" variant="low">Technical Details</Text>
|
||||
</summary>
|
||||
<Box marginTop={2} padding={4} bg="var(--ui-color-bg-surface-muted)" rounded="lg" style={{ overflowX: 'auto' }}>
|
||||
<Text as="pre" size="xs" variant="low" font="mono">
|
||||
{error.stack}
|
||||
</Text>
|
||||
</Box>
|
||||
</details>
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
|
||||
case 'card':
|
||||
return (
|
||||
<Surface
|
||||
variant="muted"
|
||||
rounded="xl"
|
||||
padding={6}
|
||||
style={{ border: '1px solid rgba(227, 92, 92, 0.3)' }}
|
||||
role="alert"
|
||||
>
|
||||
<Box display="flex" gap={4} alignItems="start">
|
||||
<Icon icon={AlertCircle} size={6} intent="critical" />
|
||||
<Box flex={1}>
|
||||
<Heading level={3} marginBottom={1}>
|
||||
{errorInfo.title}
|
||||
</Heading>
|
||||
<Text size="sm" variant="low" block marginBottom={3}>
|
||||
{errorInfo.message}
|
||||
</Text>
|
||||
|
||||
{errorInfo.isApiError && errorInfo.statusCode && (
|
||||
<Text size="xs" variant="low" font="mono" block marginBottom={3}>
|
||||
HTTP {errorInfo.statusCode}
|
||||
{errorInfo.details && !hideTechnicalDetails && ` - ${errorInfo.details}`}
|
||||
</Text>
|
||||
)}
|
||||
|
||||
{defaultActions.length > 0 && (
|
||||
<Box display="flex" gap={2}>
|
||||
{defaultActions.map((action, index) => (
|
||||
<Button
|
||||
key={index}
|
||||
onClick={action.onClick}
|
||||
variant={action.variant === 'primary' ? 'danger' : 'secondary'}
|
||||
size="sm"
|
||||
>
|
||||
{action.label}
|
||||
</Button>
|
||||
))}
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
</Box>
|
||||
</Surface>
|
||||
);
|
||||
|
||||
case 'inline':
|
||||
return (
|
||||
<Box
|
||||
display="inline-flex"
|
||||
alignItems="center"
|
||||
gap={2}
|
||||
paddingX={3}
|
||||
paddingY={2}
|
||||
bg="rgba(227, 92, 92, 0.1)"
|
||||
rounded="lg"
|
||||
style={{ border: '1px solid rgba(227, 92, 92, 0.3)' }}
|
||||
role="alert"
|
||||
>
|
||||
<Icon icon={AlertCircle} size={4} intent="critical" />
|
||||
<Text size="sm" variant="critical">{errorInfo.message}</Text>
|
||||
{onRetry && showRetry && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
onClick={onRetry}
|
||||
size="sm"
|
||||
style={{ marginLeft: '0.5rem', padding: 0, height: 'auto' }}
|
||||
>
|
||||
Retry
|
||||
</Button>
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export function ApiErrorDisplay({
|
||||
error,
|
||||
onRetry,
|
||||
variant = 'full-screen',
|
||||
hideTechnicalDetails = false,
|
||||
}: {
|
||||
error: ApiError;
|
||||
onRetry?: () => void;
|
||||
variant?: 'full-screen' | 'card' | 'inline';
|
||||
hideTechnicalDetails?: boolean;
|
||||
}) {
|
||||
return (
|
||||
<ErrorDisplay
|
||||
error={error}
|
||||
onRetry={onRetry}
|
||||
variant={variant}
|
||||
hideTechnicalDetails={hideTechnicalDetails}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export function NetworkErrorDisplay({
|
||||
onRetry,
|
||||
variant = 'full-screen',
|
||||
}: {
|
||||
onRetry?: () => void;
|
||||
variant?: 'full-screen' | 'card' | 'inline';
|
||||
}) {
|
||||
return (
|
||||
<ErrorDisplay
|
||||
error={new Error('Network connection failed. Please check your internet connection.')}
|
||||
onRetry={onRetry}
|
||||
variant={variant}
|
||||
/>
|
||||
);
|
||||
}
|
||||
24
apps/website/ui/FeatureList.tsx
Normal file
24
apps/website/ui/FeatureList.tsx
Normal file
@@ -0,0 +1,24 @@
|
||||
import React from 'react';
|
||||
import { Stack } from './Stack';
|
||||
import { Text } from './Text';
|
||||
|
||||
interface FeatureListProps {
|
||||
items: string[];
|
||||
intent?: 'primary' | 'aqua' | 'amber' | 'low';
|
||||
}
|
||||
|
||||
/**
|
||||
* FeatureList - A semantic list of features.
|
||||
*/
|
||||
export function FeatureList({ items, intent = 'primary' }: FeatureListProps) {
|
||||
return (
|
||||
<Stack as="ul" gap={2}>
|
||||
{items.map((item, index) => (
|
||||
<Stack as="li" key={index} direction="row" align="start" gap={2}>
|
||||
<Text variant={intent === 'low' ? 'low' : 'primary'}>•</Text>
|
||||
<Text size="sm" variant="med">{item}</Text>
|
||||
</Stack>
|
||||
))}
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
50
apps/website/ui/FeatureQuote.tsx
Normal file
50
apps/website/ui/FeatureQuote.tsx
Normal file
@@ -0,0 +1,50 @@
|
||||
import React from 'react';
|
||||
import { Box } from './Box';
|
||||
import { Text } from './Text';
|
||||
|
||||
interface FeatureQuoteProps {
|
||||
children: string;
|
||||
intent?: 'primary' | 'aqua' | 'amber' | 'low';
|
||||
}
|
||||
|
||||
/**
|
||||
* FeatureQuote - A semantic quote component for features.
|
||||
*/
|
||||
export function FeatureQuote({ children, intent = 'primary' }: FeatureQuoteProps) {
|
||||
const borderColor = {
|
||||
primary: 'var(--ui-color-intent-primary)',
|
||||
aqua: 'var(--ui-color-intent-telemetry)',
|
||||
amber: 'var(--ui-color-intent-warning)',
|
||||
low: 'var(--ui-color-border-default)',
|
||||
}[intent];
|
||||
|
||||
const bgColor = {
|
||||
primary: 'rgba(25, 140, 255, 0.05)',
|
||||
aqua: 'rgba(78, 212, 224, 0.05)',
|
||||
amber: 'rgba(255, 190, 77, 0.05)',
|
||||
low: 'rgba(255, 255, 255, 0.02)',
|
||||
}[intent];
|
||||
|
||||
return (
|
||||
<Box
|
||||
borderLeft
|
||||
borderStyle="solid"
|
||||
borderWidth="2px"
|
||||
borderColor={borderColor}
|
||||
paddingLeft={4}
|
||||
paddingY={2}
|
||||
bg={bgColor}
|
||||
>
|
||||
<Text
|
||||
variant="low"
|
||||
font="mono"
|
||||
size="xs"
|
||||
uppercase
|
||||
letterSpacing="widest"
|
||||
leading="relaxed"
|
||||
>
|
||||
{children}
|
||||
</Text>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
73
apps/website/ui/FeatureSection.tsx
Normal file
73
apps/website/ui/FeatureSection.tsx
Normal file
@@ -0,0 +1,73 @@
|
||||
import React from 'react';
|
||||
import { Section } from './Section';
|
||||
import { Container } from './Container';
|
||||
import { Box } from './Box';
|
||||
import { Glow } from './Glow';
|
||||
import { Heading } from './Heading';
|
||||
|
||||
interface FeatureSectionProps {
|
||||
heading: string;
|
||||
description: React.ReactNode;
|
||||
mockup: React.ReactNode;
|
||||
layout?: 'text-left' | 'text-right';
|
||||
intent?: 'primary' | 'aqua' | 'amber';
|
||||
}
|
||||
|
||||
/**
|
||||
* FeatureSection - A semantic UI component for highlighting a feature.
|
||||
* Encapsulates layout and styling.
|
||||
*/
|
||||
export function FeatureSection({
|
||||
heading,
|
||||
description,
|
||||
mockup,
|
||||
layout = 'text-left',
|
||||
intent = 'primary',
|
||||
}: FeatureSectionProps) {
|
||||
return (
|
||||
<Section variant="dark" padding="lg">
|
||||
<Glow
|
||||
color={intent}
|
||||
size="lg"
|
||||
position={layout === 'text-left' ? 'bottom-left' : 'top-right'}
|
||||
opacity={0.02}
|
||||
/>
|
||||
|
||||
<Container>
|
||||
<Box
|
||||
display="grid"
|
||||
gridCols={{ base: 1, lg: 2 }}
|
||||
gap={12}
|
||||
alignItems="center"
|
||||
>
|
||||
{/* Text Content */}
|
||||
<Box
|
||||
display="flex"
|
||||
flexDirection="col"
|
||||
gap={8}
|
||||
order={layout === 'text-right' ? { base: 1, lg: 2 } : 1}
|
||||
>
|
||||
<Heading level={2} weight="bold">
|
||||
{heading}
|
||||
</Heading>
|
||||
<Box display="flex" flexDirection="col" gap={6}>
|
||||
{description}
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
{/* Mockup Area */}
|
||||
<Box
|
||||
order={layout === 'text-right' ? { base: 2, lg: 1 } : 2}
|
||||
bg="rgba(255, 255, 255, 0.02)"
|
||||
rounded="xl"
|
||||
padding={8}
|
||||
border
|
||||
borderColor="var(--ui-color-border-muted)"
|
||||
>
|
||||
{mockup}
|
||||
</Box>
|
||||
</Box>
|
||||
</Container>
|
||||
</Section>
|
||||
);
|
||||
}
|
||||
@@ -1,53 +0,0 @@
|
||||
import { Box } from './Box';
|
||||
import { Container } from './Container';
|
||||
import { Link } from './Link';
|
||||
import { Text } from './Text';
|
||||
|
||||
export const Footer = () => {
|
||||
return (
|
||||
<Box as="footer" bg="var(--ui-color-bg-surface)" borderTop paddingY={12}>
|
||||
<Container size="xl">
|
||||
<Box display="grid" gridCols={{ base: 1, md: 4 }} gap={12}>
|
||||
<Box>
|
||||
<Text weight="bold" variant="high" marginBottom={4}>GridPilot</Text>
|
||||
<Text size="sm" variant="low">
|
||||
The ultimate companion for sim racers. Track your performance, manage your team, and compete in leagues.
|
||||
</Text>
|
||||
</Box>
|
||||
|
||||
<Box>
|
||||
<Text weight="bold" variant="high" marginBottom={4}>Platform</Text>
|
||||
<Box display="flex" flexDirection="col" gap={2}>
|
||||
<Link href="/leagues" variant="secondary">Leagues</Link>
|
||||
<Link href="/teams" variant="secondary">Teams</Link>
|
||||
<Link href="/leaderboards" variant="secondary">Leaderboards</Link>
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
<Box>
|
||||
<Text weight="bold" variant="high" marginBottom={4}>Support</Text>
|
||||
<Box display="flex" flexDirection="col" gap={2}>
|
||||
<Link href="/docs" variant="secondary">Documentation</Link>
|
||||
<Link href="/status" variant="secondary">System Status</Link>
|
||||
<Link href="/contact" variant="secondary">Contact Us</Link>
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
<Box>
|
||||
<Text weight="bold" variant="high" marginBottom={4}>Legal</Text>
|
||||
<Box display="flex" flexDirection="col" gap={2}>
|
||||
<Link href="/privacy" variant="secondary">Privacy Policy</Link>
|
||||
<Link href="/terms" variant="secondary">Terms of Service</Link>
|
||||
</Box>
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
<Box borderTop marginTop={12} paddingTop={8} textAlign="center">
|
||||
<Text size="xs" variant="low">
|
||||
© {new Date().getFullYear()} GridPilot. All rights reserved.
|
||||
</Text>
|
||||
</Box>
|
||||
</Container>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
29
apps/website/ui/Form.tsx
Normal file
29
apps/website/ui/Form.tsx
Normal file
@@ -0,0 +1,29 @@
|
||||
import React, { ReactNode, FormEventHandler, forwardRef } from 'react';
|
||||
|
||||
export interface FormProps {
|
||||
children: ReactNode;
|
||||
onSubmit?: FormEventHandler<HTMLFormElement>;
|
||||
noValidate?: boolean;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export const Form = forwardRef<HTMLFormElement, FormProps>(({
|
||||
children,
|
||||
onSubmit,
|
||||
noValidate = true,
|
||||
className
|
||||
}, ref) => {
|
||||
return (
|
||||
<form
|
||||
ref={ref}
|
||||
onSubmit={onSubmit}
|
||||
noValidate={noValidate}
|
||||
className={className}
|
||||
style={{ width: '100%' }}
|
||||
>
|
||||
{children}
|
||||
</form>
|
||||
);
|
||||
});
|
||||
|
||||
Form.displayName = 'Form';
|
||||
@@ -1,35 +1,28 @@
|
||||
import React, { useState } from 'react';
|
||||
import React from 'react';
|
||||
import { Box, BoxProps } from './Box';
|
||||
import { ImagePlaceholder } from './ImagePlaceholder';
|
||||
|
||||
export interface ImageProps extends Omit<BoxProps<'img'>, 'children'> {
|
||||
src: string;
|
||||
alt: string;
|
||||
fallbackSrc?: string;
|
||||
fallbackComponent?: React.ReactNode;
|
||||
}
|
||||
|
||||
/**
|
||||
* Image
|
||||
*
|
||||
* Stateless UI primitive for images.
|
||||
* For error handling, use SafeImage component.
|
||||
*/
|
||||
export const Image = ({
|
||||
src,
|
||||
alt,
|
||||
fallbackSrc,
|
||||
fallbackComponent,
|
||||
...props
|
||||
}: ImageProps) => {
|
||||
const [error, setError] = useState(false);
|
||||
|
||||
if (error) {
|
||||
if (fallbackComponent) return <>{fallbackComponent}</>;
|
||||
if (fallbackSrc) return <Box as="img" src={fallbackSrc} alt={alt} {...props} />;
|
||||
return <ImagePlaceholder />;
|
||||
}
|
||||
|
||||
return (
|
||||
<Box
|
||||
as="img"
|
||||
src={src}
|
||||
alt={alt}
|
||||
onError={() => setError(true)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -1,63 +0,0 @@
|
||||
import { HelpCircle, X } from 'lucide-react';
|
||||
import React, { ReactNode, useEffect, useRef, useState } from 'react';
|
||||
import { createPortal } from 'react-dom';
|
||||
import { Box } from './Box';
|
||||
import { Heading } from './Heading';
|
||||
import { Icon } from './Icon';
|
||||
import { IconButton } from './IconButton';
|
||||
import { Surface } from './Surface';
|
||||
|
||||
export interface InfoFlyoutProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
title: string;
|
||||
children: ReactNode;
|
||||
anchorRef: React.RefObject<HTMLElement>;
|
||||
}
|
||||
|
||||
export const InfoFlyout = ({ isOpen, onClose, title, children, anchorRef }: InfoFlyoutProps) => {
|
||||
const [position, setPosition] = useState({ top: 0, left: 0 });
|
||||
const flyoutRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (isOpen && anchorRef.current) {
|
||||
const rect = anchorRef.current.getBoundingClientRect();
|
||||
const flyoutWidth = 380;
|
||||
const padding = 16;
|
||||
|
||||
let left = rect.right + 12;
|
||||
let top = rect.top;
|
||||
|
||||
if (left + flyoutWidth > window.innerWidth - padding) {
|
||||
left = rect.left - flyoutWidth - 12;
|
||||
}
|
||||
|
||||
setPosition({ top, left });
|
||||
}
|
||||
}, [isOpen, anchorRef]);
|
||||
|
||||
if (!isOpen) return null;
|
||||
|
||||
return createPortal(
|
||||
<Box
|
||||
ref={flyoutRef as any}
|
||||
position="fixed"
|
||||
zIndex={100}
|
||||
style={{ top: position.top, left: position.left, width: '24rem' }}
|
||||
>
|
||||
<Surface variant="muted" rounded="xl" shadow="xl" style={{ border: '1px solid var(--ui-color-border-default)', overflow: 'hidden' }}>
|
||||
<Box display="flex" alignItems="center" justifyContent="between" padding={4} bg="var(--ui-color-bg-surface-muted)" style={{ borderBottom: '1px solid var(--ui-color-border-default)' }}>
|
||||
<Box display="flex" alignItems="center" gap={2}>
|
||||
<Icon icon={HelpCircle} size={4} intent="primary" />
|
||||
<Heading level={6}>{title}</Heading>
|
||||
</Box>
|
||||
<IconButton icon={X} size="sm" variant="ghost" onClick={onClose} title="Close" />
|
||||
</Box>
|
||||
<Box padding={4} style={{ maxHeight: '20rem', overflowY: 'auto' }}>
|
||||
{children}
|
||||
</Box>
|
||||
</Surface>
|
||||
</Box>,
|
||||
document.body
|
||||
);
|
||||
};
|
||||
75
apps/website/ui/LandingHero.tsx
Normal file
75
apps/website/ui/LandingHero.tsx
Normal file
@@ -0,0 +1,75 @@
|
||||
import React from 'react';
|
||||
import { Section } from './Section';
|
||||
import { Container } from './Container';
|
||||
import { Box } from './Box';
|
||||
import { Glow } from './Glow';
|
||||
import { Heading } from './Heading';
|
||||
import { Text } from './Text';
|
||||
import { ButtonGroup } from './ButtonGroup';
|
||||
import { Button } from './Button';
|
||||
|
||||
interface LandingHeroProps {
|
||||
title: string;
|
||||
subtitle: string;
|
||||
description: string;
|
||||
primaryAction: {
|
||||
label: string;
|
||||
href: string;
|
||||
};
|
||||
secondaryAction: {
|
||||
label: string;
|
||||
href: string;
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* LandingHero - A semantic hero section for landing pages.
|
||||
*/
|
||||
export function LandingHero({
|
||||
title,
|
||||
subtitle,
|
||||
description,
|
||||
primaryAction,
|
||||
secondaryAction,
|
||||
}: LandingHeroProps) {
|
||||
return (
|
||||
<Section variant="dark" padding="lg">
|
||||
<Glow color="primary" size="xl" position="top-right" opacity={0.1} />
|
||||
|
||||
<Container>
|
||||
<Box display="flex" flexDirection="col" gap={8} maxWidth="3xl">
|
||||
<Text size="xs" weight="bold" uppercase variant="primary">
|
||||
{subtitle}
|
||||
</Text>
|
||||
|
||||
<Heading level={1} weight="bold">
|
||||
{title}
|
||||
</Heading>
|
||||
|
||||
<Text size="lg" variant="low">
|
||||
{description}
|
||||
</Text>
|
||||
|
||||
<ButtonGroup gap={4}>
|
||||
<Button
|
||||
as="a"
|
||||
href={primaryAction.href}
|
||||
variant="primary"
|
||||
size="lg"
|
||||
>
|
||||
{primaryAction.label}
|
||||
</Button>
|
||||
<Button
|
||||
as="a"
|
||||
href={secondaryAction.href}
|
||||
variant="secondary"
|
||||
size="lg"
|
||||
>
|
||||
{secondaryAction.label}
|
||||
</Button>
|
||||
</ButtonGroup>
|
||||
</Box>
|
||||
</Container>
|
||||
</Section>
|
||||
);
|
||||
}
|
||||
@@ -1,151 +0,0 @@
|
||||
import { X } from 'lucide-react';
|
||||
import { ReactNode, useEffect } from 'react';
|
||||
import { createPortal } from 'react-dom';
|
||||
import { Box } from './Box';
|
||||
import { Button } from './Button';
|
||||
import { Heading } from './Heading';
|
||||
import { IconButton } from './IconButton';
|
||||
import { Surface } from './Surface';
|
||||
import { Text } from './Text';
|
||||
|
||||
export interface ModalProps {
|
||||
children: ReactNode;
|
||||
isOpen: boolean;
|
||||
onClose?: () => void;
|
||||
onOpenChange?: (isOpen: boolean) => void;
|
||||
title?: string;
|
||||
size?: 'sm' | 'md' | 'lg' | 'xl' | 'full';
|
||||
primaryActionLabel?: string;
|
||||
onPrimaryAction?: () => void;
|
||||
secondaryActionLabel?: string;
|
||||
onSecondaryAction?: () => void;
|
||||
footer?: ReactNode;
|
||||
description?: string;
|
||||
icon?: ReactNode;
|
||||
actions?: ReactNode;
|
||||
}
|
||||
|
||||
export const Modal = ({
|
||||
children,
|
||||
isOpen,
|
||||
onClose,
|
||||
onOpenChange,
|
||||
title,
|
||||
size = 'md',
|
||||
primaryActionLabel,
|
||||
onPrimaryAction,
|
||||
secondaryActionLabel,
|
||||
onSecondaryAction,
|
||||
footer,
|
||||
description,
|
||||
icon,
|
||||
actions
|
||||
}: ModalProps) => {
|
||||
useEffect(() => {
|
||||
if (isOpen) {
|
||||
document.body.style.overflow = 'hidden';
|
||||
} else {
|
||||
document.body.style.overflow = 'unset';
|
||||
}
|
||||
return () => {
|
||||
document.body.style.overflow = 'unset';
|
||||
};
|
||||
}, [isOpen]);
|
||||
|
||||
if (!isOpen) return null;
|
||||
|
||||
const sizeMap = {
|
||||
sm: '24rem',
|
||||
md: '32rem',
|
||||
lg: '48rem',
|
||||
xl: '64rem',
|
||||
full: '100%',
|
||||
};
|
||||
|
||||
const handleClose = () => {
|
||||
if (onClose) onClose();
|
||||
if (onOpenChange) onOpenChange(false);
|
||||
};
|
||||
|
||||
return createPortal(
|
||||
<Box
|
||||
position="fixed"
|
||||
inset={0}
|
||||
zIndex={100}
|
||||
display="flex"
|
||||
alignItems="center"
|
||||
justifyContent="center"
|
||||
padding={4}
|
||||
bg="rgba(0, 0, 0, 0.8)"
|
||||
>
|
||||
<Box
|
||||
position="absolute"
|
||||
inset={0}
|
||||
onClick={handleClose}
|
||||
/>
|
||||
|
||||
<Surface
|
||||
variant="default"
|
||||
rounded="lg"
|
||||
shadow="xl"
|
||||
style={{
|
||||
width: '100%',
|
||||
maxWidth: sizeMap[size],
|
||||
maxHeight: '90vh',
|
||||
overflow: 'hidden',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
position: 'relative',
|
||||
border: '1px solid var(--ui-color-border-default)'
|
||||
}}
|
||||
>
|
||||
<Box
|
||||
display="flex"
|
||||
alignItems="center"
|
||||
justifyContent="between"
|
||||
padding={4}
|
||||
borderBottom
|
||||
>
|
||||
<Box display="flex" alignItems="center" gap={3}>
|
||||
{icon}
|
||||
<Box>
|
||||
{title && <Heading level={3}>{title}</Heading>}
|
||||
{description && <Box marginTop={1}><Text size="sm" variant="low">{description}</Text></Box>}
|
||||
</Box>
|
||||
</Box>
|
||||
<Box display="flex" alignItems="center" gap={2}>
|
||||
{actions}
|
||||
<IconButton icon={X} onClick={handleClose} variant="ghost" title="Close modal" />
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
<Box flex={1} overflow="auto" padding={6}>
|
||||
{children}
|
||||
</Box>
|
||||
|
||||
{(footer || primaryActionLabel || secondaryActionLabel) && (
|
||||
<Box padding={4} borderTop bg="rgba(255,255,255,0.02)" display="flex" justifyContent="end" gap={3}>
|
||||
{footer}
|
||||
{secondaryActionLabel && (
|
||||
<Button
|
||||
onClick={onSecondaryAction || handleClose}
|
||||
variant="ghost"
|
||||
>
|
||||
{secondaryActionLabel}
|
||||
</Button>
|
||||
)}
|
||||
{primaryActionLabel && (
|
||||
<Button
|
||||
onClick={onPrimaryAction}
|
||||
variant="primary"
|
||||
>
|
||||
{primaryActionLabel}
|
||||
</Button>
|
||||
)}
|
||||
</Box>
|
||||
)}
|
||||
</Surface>
|
||||
</Box>,
|
||||
document.body
|
||||
);
|
||||
};
|
||||
@@ -1,5 +1,4 @@
|
||||
import { Eye, EyeOff } from 'lucide-react';
|
||||
import { useState } from 'react';
|
||||
import { Box } from './Box';
|
||||
import { IconButton } from './IconButton';
|
||||
import { Input, InputProps } from './Input';
|
||||
@@ -9,27 +8,19 @@ export interface PasswordFieldProps extends InputProps {
|
||||
onTogglePassword?: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* PasswordField
|
||||
*
|
||||
* Stateless UI primitive for password inputs.
|
||||
* For stateful behavior, manage showPassword state in the parent component/template.
|
||||
*/
|
||||
export const PasswordField = ({
|
||||
showPassword: controlledShowPassword,
|
||||
showPassword = false,
|
||||
onTogglePassword,
|
||||
...props
|
||||
}: PasswordFieldProps) => {
|
||||
const [internalShowPassword, setInternalShowPassword] = useState(false);
|
||||
|
||||
const isControlled = controlledShowPassword !== undefined;
|
||||
const showPassword = isControlled ? controlledShowPassword : internalShowPassword;
|
||||
|
||||
const handleToggle = () => {
|
||||
if (onTogglePassword) {
|
||||
onTogglePassword();
|
||||
}
|
||||
if (!isControlled) {
|
||||
setInternalShowPassword(!internalShowPassword);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Box position="relative">
|
||||
<Box position="relative" fullWidth>
|
||||
<Input
|
||||
{...props}
|
||||
type={showPassword ? 'text' : 'password'}
|
||||
@@ -43,7 +34,7 @@ export const PasswordField = ({
|
||||
>
|
||||
<IconButton
|
||||
icon={showPassword ? EyeOff : Eye}
|
||||
onClick={handleToggle}
|
||||
onClick={onTogglePassword}
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
title={showPassword ? 'Hide password' : 'Show password'}
|
||||
|
||||
@@ -1,41 +0,0 @@
|
||||
'use client';
|
||||
|
||||
import { Box } from '@/ui/Box';
|
||||
import { motion } from 'framer-motion';
|
||||
|
||||
interface ProgressLineProps {
|
||||
isLoading: boolean;
|
||||
}
|
||||
|
||||
export function ProgressLine({ isLoading }: ProgressLineProps) {
|
||||
if (!isLoading) return null;
|
||||
|
||||
return (
|
||||
<Box
|
||||
fullWidth
|
||||
height="2px"
|
||||
bg="var(--ui-color-bg-surface-muted)"
|
||||
style={{ overflow: 'hidden', position: 'relative' }}
|
||||
>
|
||||
<motion.div
|
||||
style={{
|
||||
position: 'absolute',
|
||||
top: 0,
|
||||
left: 0,
|
||||
height: '100%',
|
||||
backgroundColor: 'var(--ui-color-intent-primary)'
|
||||
}}
|
||||
initial={{ width: '0%', left: '0%' }}
|
||||
animate={{
|
||||
width: ['20%', '50%', '20%'],
|
||||
left: ['-20%', '100%', '-20%'],
|
||||
}}
|
||||
transition={{
|
||||
duration: 1.5,
|
||||
repeat: Infinity,
|
||||
ease: 'linear',
|
||||
}}
|
||||
/>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
42
apps/website/ui/StatsStrip.tsx
Normal file
42
apps/website/ui/StatsStrip.tsx
Normal file
@@ -0,0 +1,42 @@
|
||||
import React from 'react';
|
||||
import { Container } from './Container';
|
||||
import { Grid } from './Grid';
|
||||
import { MetricCard } from './MetricCard';
|
||||
import { LucideIcon } from 'lucide-react';
|
||||
|
||||
interface StatItem {
|
||||
label: string;
|
||||
value: string;
|
||||
icon: LucideIcon;
|
||||
intent?: 'primary' | 'telemetry' | 'warning' | 'success' | 'critical';
|
||||
trend?: {
|
||||
value: number;
|
||||
isPositive: boolean;
|
||||
};
|
||||
}
|
||||
|
||||
interface StatsStripProps {
|
||||
stats: StatItem[];
|
||||
}
|
||||
|
||||
/**
|
||||
* StatsStrip - A semantic UI component for showing a strip of metrics.
|
||||
*/
|
||||
export function StatsStrip({ stats }: StatsStripProps) {
|
||||
return (
|
||||
<Container>
|
||||
<Grid cols={{ base: 2, md: 4 }} gap={4}>
|
||||
{stats.map((stat, index) => (
|
||||
<MetricCard
|
||||
key={index}
|
||||
label={stat.label}
|
||||
value={stat.value}
|
||||
icon={stat.icon}
|
||||
intent={stat.intent as any}
|
||||
trend={stat.trend}
|
||||
/>
|
||||
))}
|
||||
</Grid>
|
||||
</Container>
|
||||
);
|
||||
}
|
||||
@@ -1,173 +0,0 @@
|
||||
import { Box } from '@/ui/Box';
|
||||
import { Icon } from '@/ui/Icon';
|
||||
import { IconButton } from '@/ui/IconButton';
|
||||
import { ListItem, ListItemActions, ListItemInfo } from '@/ui/ListItem';
|
||||
import { Surface } from '@/ui/Surface';
|
||||
import { Text } from '@/ui/Text';
|
||||
import { AlertCircle, CheckCircle2, Upload, X } from 'lucide-react';
|
||||
import React, { useRef, useState } from 'react';
|
||||
|
||||
export interface UploadDropzoneProps {
|
||||
onFilesSelected: (files: File[]) => void;
|
||||
accept?: string;
|
||||
multiple?: boolean;
|
||||
maxSize?: number; // in bytes
|
||||
isLoading?: boolean;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
export function UploadDropzone({
|
||||
onFilesSelected,
|
||||
accept,
|
||||
multiple = false,
|
||||
maxSize,
|
||||
isLoading,
|
||||
error,
|
||||
}: UploadDropzoneProps) {
|
||||
const [isDragging, setIsDragging] = useState(false);
|
||||
const [selectedFiles, setSelectedFiles] = useState<File[]>([]);
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
const handleDragOver = (e: React.DragEvent) => {
|
||||
e.preventDefault();
|
||||
setIsDragging(true);
|
||||
};
|
||||
|
||||
const handleDragLeave = () => {
|
||||
setIsDragging(false);
|
||||
};
|
||||
|
||||
const handleDrop = (e: React.DragEvent) => {
|
||||
e.preventDefault();
|
||||
setIsDragging(false);
|
||||
const files = Array.from(e.dataTransfer.files);
|
||||
validateAndSelectFiles(files);
|
||||
};
|
||||
|
||||
const handleFileSelect = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
if (e.target.files) {
|
||||
const files = Array.from(e.target.files);
|
||||
validateAndSelectFiles(files);
|
||||
}
|
||||
};
|
||||
|
||||
const validateAndSelectFiles = (files: File[]) => {
|
||||
let filteredFiles = files;
|
||||
|
||||
if (accept) {
|
||||
const acceptedTypes = accept.split(',').map(t => t.trim());
|
||||
filteredFiles = filteredFiles.filter(file => {
|
||||
return acceptedTypes.some(type => {
|
||||
if (type.startsWith('.')) {
|
||||
return file.name.endsWith(type);
|
||||
}
|
||||
if (type.endsWith('/*')) {
|
||||
return file.type.startsWith(type.replace('/*', ''));
|
||||
}
|
||||
return file.type === type;
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
if (maxSize) {
|
||||
filteredFiles = filteredFiles.filter(file => file.size <= maxSize);
|
||||
}
|
||||
|
||||
if (!multiple) {
|
||||
filteredFiles = filteredFiles.slice(0, 1);
|
||||
}
|
||||
|
||||
setSelectedFiles(filteredFiles);
|
||||
onFilesSelected(filteredFiles);
|
||||
};
|
||||
|
||||
const removeFile = (index: number) => {
|
||||
const newFiles = [...selectedFiles];
|
||||
newFiles.splice(index, 1);
|
||||
setSelectedFiles(newFiles);
|
||||
onFilesSelected(newFiles);
|
||||
};
|
||||
|
||||
return (
|
||||
<Box
|
||||
fullWidth
|
||||
onDragOver={handleDragOver}
|
||||
onDragLeave={handleDragLeave}
|
||||
onDrop={handleDrop}
|
||||
>
|
||||
<Surface
|
||||
variant="muted"
|
||||
rounded="xl"
|
||||
padding={8}
|
||||
onClick={() => fileInputRef.current?.click()}
|
||||
style={{
|
||||
border: `2px dashed ${isDragging ? 'var(--ui-color-intent-primary)' : (error ? 'var(--ui-color-intent-critical)' : 'var(--ui-color-border-default)')}`,
|
||||
backgroundColor: isDragging ? 'rgba(25, 140, 255, 0.05)' : 'var(--ui-color-bg-surface-muted)',
|
||||
textAlign: 'center',
|
||||
cursor: 'pointer'
|
||||
}}
|
||||
>
|
||||
<input
|
||||
type="file"
|
||||
ref={fileInputRef}
|
||||
onChange={handleFileSelect}
|
||||
accept={accept}
|
||||
multiple={multiple}
|
||||
style={{ display: 'none' }}
|
||||
/>
|
||||
|
||||
<Box display="flex" flexDirection="col" alignItems="center" gap={4}>
|
||||
<Icon
|
||||
icon={isLoading ? Upload : (selectedFiles.length > 0 ? CheckCircle2 : Upload)}
|
||||
size={10}
|
||||
intent={isDragging ? 'primary' : (error ? 'critical' : 'low')}
|
||||
animate={isLoading ? 'pulse' : 'none'}
|
||||
/>
|
||||
|
||||
<Box>
|
||||
<Text weight="bold" variant="high" size="lg" block marginBottom={1}>
|
||||
{isDragging ? 'Drop files here' : 'Click or drag to upload'}
|
||||
</Text>
|
||||
|
||||
<Text size="sm" variant="low" block>
|
||||
{accept ? `Accepted formats: ${accept}` : 'All file types accepted'}
|
||||
{maxSize && ` (Max ${Math.round(maxSize / 1024 / 1024)}MB)`}
|
||||
</Text>
|
||||
</Box>
|
||||
|
||||
{error && (
|
||||
<Box display="flex" alignItems="center" gap={2} marginTop={2}>
|
||||
<Icon icon={AlertCircle} size={4} intent="warning" />
|
||||
<Text size="sm" variant="warning" weight="medium">{error}</Text>
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
</Surface>
|
||||
|
||||
{selectedFiles.length > 0 && (
|
||||
<Box marginTop={4} display="flex" flexDirection="col" gap={2}>
|
||||
{selectedFiles.map((file, index) => (
|
||||
<ListItem key={`${file.name}-${index}`}>
|
||||
<ListItemInfo
|
||||
title={file.name}
|
||||
description={`${Math.round(file.size / 1024)} KB`}
|
||||
/>
|
||||
<ListItemActions>
|
||||
<IconButton
|
||||
icon={X}
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
removeFile(index);
|
||||
}}
|
||||
title="Remove"
|
||||
/>
|
||||
</ListItemActions>
|
||||
</ListItem>
|
||||
))}
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
@@ -1,174 +0,0 @@
|
||||
'use client';
|
||||
|
||||
import { Box } from '@/ui/Box';
|
||||
import { Icon } from '@/ui/Icon';
|
||||
import { Surface } from '@/ui/Surface';
|
||||
import { Text } from '@/ui/Text';
|
||||
import { AnimatePresence, motion, useReducedMotion } from 'framer-motion';
|
||||
import { CheckCircle2, LucideIcon } from 'lucide-react';
|
||||
import { useEffect, useState } from 'react';
|
||||
|
||||
export interface WorkflowStep {
|
||||
id: number;
|
||||
icon: LucideIcon;
|
||||
title: string;
|
||||
description: string;
|
||||
intent: 'primary' | 'success' | 'warning' | 'critical' | 'telemetry';
|
||||
}
|
||||
|
||||
interface WorkflowMockupProps {
|
||||
steps: WorkflowStep[];
|
||||
}
|
||||
|
||||
export function WorkflowMockup({ steps }: WorkflowMockupProps) {
|
||||
const shouldReduceMotion = useReducedMotion();
|
||||
const [isMounted, setIsMounted] = useState(false);
|
||||
const [activeStep, setActiveStep] = useState(0);
|
||||
|
||||
useEffect(() => {
|
||||
setIsMounted(true);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isMounted) return;
|
||||
|
||||
const interval = setInterval(() => {
|
||||
setActiveStep((prev) => (prev + 1) % steps.length);
|
||||
}, 3000);
|
||||
|
||||
return () => clearInterval(interval);
|
||||
}, [isMounted, steps.length]);
|
||||
|
||||
if (!isMounted) {
|
||||
return (
|
||||
<Box position="relative" fullWidth>
|
||||
<Surface variant="muted" rounded="none" padding={6}>
|
||||
<Box display="flex" justifyContent="between" gap={2}>
|
||||
{steps.map((step) => (
|
||||
<Box key={step.id} display="flex" alignItems="center" justifyContent="center" flexDirection="col">
|
||||
<Box width="10" height="10" rounded="none" bg="var(--ui-color-bg-base)" style={{ border: '1px solid var(--ui-color-border-default)' }} display="flex" alignItems="center" justifyContent="center" mb={2}>
|
||||
<Text variant={step.intent}>
|
||||
<Icon icon={step.icon} size={4} />
|
||||
</Text>
|
||||
</Box>
|
||||
<Text size="xs" weight="bold" variant="high" uppercase>{step.title}</Text>
|
||||
</Box>
|
||||
))}
|
||||
</Box>
|
||||
</Surface>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Box position="relative" fullWidth>
|
||||
<Surface variant="muted" rounded="none" padding={6} overflow="hidden">
|
||||
{/* Connection Lines */}
|
||||
<Box position="absolute" top="3.5rem" left="8%" right="8%" display={{ base: 'none', sm: 'block' }}>
|
||||
<Box height="0.5" bg="rgba(255,255,255,0.05)" position="relative">
|
||||
<Box
|
||||
as={motion.div}
|
||||
position="absolute"
|
||||
fullHeight
|
||||
bg="var(--ui-color-intent-primary)"
|
||||
initial={{ width: '0%' }}
|
||||
animate={{ width: `${(activeStep / (steps.length - 1)) * 100}%` }}
|
||||
transition={{ duration: 0.5, ease: 'easeInOut' }}
|
||||
/>
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
{/* Steps */}
|
||||
<Box display="flex" justifyContent="between" gap={2} position="relative">
|
||||
{steps.map((step, index) => {
|
||||
const isActive = index === activeStep;
|
||||
const isCompleted = index < activeStep;
|
||||
const StepIcon = step.icon;
|
||||
|
||||
return (
|
||||
<Box
|
||||
as={motion.div}
|
||||
key={step.id}
|
||||
display="flex"
|
||||
flexDirection="col"
|
||||
alignItems="center"
|
||||
justifyContent="center"
|
||||
cursor="pointer"
|
||||
flexGrow={1}
|
||||
onClick={() => setActiveStep(index)}
|
||||
whileHover={{ scale: 1.05 }}
|
||||
whileTap={{ scale: 0.95 }}
|
||||
>
|
||||
<Box
|
||||
as={motion.div}
|
||||
w={{ base: '10', sm: '12' }}
|
||||
h={{ base: '10', sm: '12' }}
|
||||
display="flex"
|
||||
alignItems="center"
|
||||
justifyContent="center"
|
||||
mb={2}
|
||||
transition
|
||||
style={{
|
||||
backgroundColor: isActive ? 'rgba(25, 140, 255, 0.1)' : isCompleted ? 'rgba(16, 185, 129, 0.1)' : 'var(--ui-color-bg-base)',
|
||||
opacity: isActive ? 1 : isCompleted ? 0.8 : 0.5,
|
||||
border: `1px solid ${isActive ? 'var(--ui-color-intent-primary)' : isCompleted ? 'var(--ui-color-intent-success)' : 'var(--ui-color-border-default)'}`
|
||||
}}
|
||||
animate={isActive && !shouldReduceMotion ? {
|
||||
opacity: [0.7, 1, 0.7],
|
||||
transition: { duration: 1.5, repeat: Infinity }
|
||||
} : {}}
|
||||
className="relative"
|
||||
>
|
||||
{isActive && (
|
||||
<Box position="absolute" top="-1px" left="-1px" w="2" h="2" style={{ borderTop: '1px solid var(--ui-color-intent-primary)', borderLeft: '1px solid var(--ui-color-intent-primary)' }} />
|
||||
)}
|
||||
{isCompleted ? (
|
||||
<Icon icon={CheckCircle2} size={5} intent="success" />
|
||||
) : (
|
||||
<Text variant={isActive ? 'primary' : 'low'}>
|
||||
<Icon icon={StepIcon} size={5} />
|
||||
</Text>
|
||||
)}
|
||||
</Box>
|
||||
<Text
|
||||
size="xs"
|
||||
weight="bold"
|
||||
variant={isActive ? 'high' : 'low'}
|
||||
display={{ base: 'none', sm: 'block' }}
|
||||
uppercase
|
||||
>
|
||||
{step.title}
|
||||
</Text>
|
||||
</Box>
|
||||
);
|
||||
})}
|
||||
</Box>
|
||||
|
||||
{/* Active Step Preview - Mobile */}
|
||||
<AnimatePresence mode="wait">
|
||||
<Box
|
||||
as={motion.div}
|
||||
key={activeStep}
|
||||
initial={{ opacity: 0, y: 5 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
exit={{ opacity: 0, y: -5 }}
|
||||
transition={{ duration: 0.2 }}
|
||||
marginTop={4}
|
||||
paddingTop={4}
|
||||
style={{ borderTop: '1px solid var(--ui-color-border-muted)' }}
|
||||
display={{ base: 'block', sm: 'none' }}
|
||||
>
|
||||
<Box textAlign="center">
|
||||
<Text size="xs" variant="med" block marginBottom={1} font="mono" weight="bold" uppercase>
|
||||
STEP {activeStep + 1}: {steps[activeStep]?.title || ''}
|
||||
</Text>
|
||||
<Text size="xs" variant="low" block font="mono" uppercase>
|
||||
{steps[activeStep]?.description || ''}
|
||||
</Text>
|
||||
</Box>
|
||||
</Box>
|
||||
</AnimatePresence>
|
||||
</Surface>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
@@ -1,33 +0,0 @@
|
||||
'use client';
|
||||
|
||||
import React, { createContext, useContext, ReactNode } from 'react';
|
||||
import { Theme } from './Theme';
|
||||
import { defaultTheme } from './themes/default';
|
||||
|
||||
interface ThemeContextType {
|
||||
theme: Theme;
|
||||
}
|
||||
|
||||
const ThemeContext = createContext<ThemeContextType | undefined>(undefined);
|
||||
|
||||
export function ThemeProvider({ children }: { children: ReactNode }) {
|
||||
// For now, we only have the default theme.
|
||||
// In the future, this could be driven by state, cookies, or user preferences.
|
||||
const value = {
|
||||
theme: defaultTheme,
|
||||
};
|
||||
|
||||
return (
|
||||
<ThemeContext.Provider value={value}>
|
||||
{children}
|
||||
</ThemeContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
export function useTheme() {
|
||||
const context = useContext(ThemeContext);
|
||||
if (context === undefined) {
|
||||
throw new Error('useTheme must be used within a ThemeProvider');
|
||||
}
|
||||
return context;
|
||||
}
|
||||
Reference in New Issue
Block a user