website refactor

This commit is contained in:
2026-01-19 18:01:30 +01:00
parent 6154d54435
commit 61b5cf3b64
120 changed files with 2226 additions and 2021 deletions

View File

@@ -0,0 +1,64 @@
import { ChevronDown, ChevronUp } from 'lucide-react';
import { ReactNode, useState } from 'react';
import { Icon } from '@/ui/Icon';
import { Surface } from '@/ui/Surface';
import { Text } from '@/ui/Text';
import { Box } from '@/ui/Box';
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' }}>
<Box
as="button"
onClick={handleToggle}
fullWidth
display="flex"
alignItems="center"
justifyContent="between"
paddingX={4}
paddingY={3}
hoverBg="rgba(255,255,255,0.05)"
transition
>
<Text weight="bold" size="sm" variant="high">
{title}
</Text>
<Icon icon={isOpen ? ChevronUp : ChevronDown} size={4} intent="low" />
</Box>
{isOpen && (
<Box padding={4} borderTop>
{children}
</Box>
)}
</Surface>
);
};

View File

@@ -0,0 +1,47 @@
import { Button } from '@/ui/Button';
import { Modal } from '@/components/shared/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>
);
}

View File

@@ -1,6 +1,3 @@
/* eslint-disable gridpilot-rules/no-raw-html-in-app */
import { Check, ChevronDown, Globe, Search } from 'lucide-react';
import { useEffect, useRef, useState } from 'react';
import { CountryFlag } from '@/ui/CountryFlag';
@@ -10,6 +7,7 @@ import { Stack } from '@/ui/Stack';
import { Text } from '@/ui/Text';
import { Surface } from '@/ui/Surface';
import { Icon } from '@/ui/Icon';
import { Input } from '@/ui/Input';
export interface Country {
code: string;
@@ -115,32 +113,31 @@ export function CountrySelect({
return (
<Box ref={containerRef} position="relative">
{/* Trigger Button */}
<button
<Box
as="button"
type="button"
onClick={() => !disabled && setIsOpen(!isOpen)}
disabled={disabled}
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'}
display="flex"
alignItems="center"
justifyContent="between"
fullWidth
rounded="md"
paddingX={4}
paddingY={3}
bg="var(--ui-color-bg-surface-muted)"
color="white"
shadow="sm"
transition="all 150ms"
cursor={disabled ? 'not-allowed' : 'pointer'}
opacity={disabled ? 0.5 : 1}
outline="none"
border
borderColor={error ? 'var(--ui-color-intent-critical)' : 'var(--ui-color-border-default)'}
hoverBorderColor={!disabled ? 'var(--ui-color-intent-primary)' : undefined}
>
<Group gap={3}>
<Globe className="w-4 h-4 text-gray-500" />
<Icon icon={Globe} size={4} intent="low" />
{selectedCountry ? (
<Group gap={2}>
<CountryFlag countryCode={selectedCountry.code} size="md" />
@@ -150,8 +147,16 @@ export function CountrySelect({
<Text variant="low">{placeholder}</Text>
)}
</Group>
<ChevronDown className={`w-4 h-4 text-gray-500 transition-transform ${isOpen ? 'rotate-180' : ''}`} />
</button>
<Icon
icon={ChevronDown}
size={4}
intent="low"
style={{
transition: 'transform 200ms',
transform: isOpen ? 'rotate(180deg)' : 'rotate(0deg)'
}}
/>
</Box>
{/* Dropdown */}
{isOpen && (
@@ -167,60 +172,49 @@ export function CountrySelect({
overflow="hidden"
>
{/* Search Input */}
<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}
type="text"
value={search}
onChange={(e) => setSearch(e.target.value)}
placeholder="Search countries..."
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'
}}
/>
</Box>
<Box padding={2} borderBottom>
<Input
ref={inputRef}
value={search}
onChange={(e) => setSearch(e.target.value)}
placeholder="Search countries..."
fullWidth
size="sm"
icon={<Icon icon={Search} size={4} intent="low" />}
/>
</Box>
{/* Country List */}
<Box overflowY="auto" maxHeight="15rem">
{filteredCountries.length > 0 ? (
filteredCountries.map((country) => (
<button
<Box
as="button"
key={country.code}
type="button"
onClick={() => handleSelect(country.code)}
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'
}}
display="flex"
alignItems="center"
justifyContent="between"
fullWidth
paddingX={4}
paddingY={2.5}
textAlign="left"
transition="colors 150ms"
border="none"
bg={value === country.code ? 'rgba(25, 140, 255, 0.2)' : 'transparent'}
color={value === country.code ? 'white' : 'var(--ui-color-text-med)'}
cursor="pointer"
hoverBg="rgba(255, 255, 255, 0.05)"
>
<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" />
<Icon icon={Check} size={4} intent="primary" />
)}
</button>
</Box>
))
) : (
<Box paddingX={4} paddingY={6} textAlign="center">

View File

@@ -0,0 +1,159 @@
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>
);

View File

@@ -0,0 +1,240 @@
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}
/>
);
}

View File

@@ -0,0 +1,63 @@
import { HelpCircle, X } from 'lucide-react';
import React, { ReactNode, useEffect, useRef, useState } from 'react';
import { createPortal } from 'react-dom';
import { Box } from '@/ui/Box';
import { Heading } from '@/ui/Heading';
import { Icon } from '@/ui/Icon';
import { IconButton } from '@/ui/IconButton';
import { Surface } from '@/ui/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
);
};

View File

@@ -0,0 +1,151 @@
import { X } from 'lucide-react';
import { ReactNode, useEffect } from 'react';
import { createPortal } from 'react-dom';
import { Box } from '@/ui/Box';
import { Button } from '@/ui/Button';
import { Heading } from '@/ui/Heading';
import { IconButton } from '@/ui/IconButton';
import { Surface } from '@/ui/Surface';
import { Text } from '@/ui/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
);
};

View File

@@ -0,0 +1,41 @@
'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>
);
}

View File

@@ -0,0 +1,33 @@
'use client';
import React, { createContext, useContext, ReactNode } from 'react';
import { Theme } from '@/ui/theme/Theme';
import { defaultTheme } from '@/ui/theme/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;
}

View File

@@ -3,7 +3,7 @@ import { Text } from '@/ui/Text';
import { Box } from '@/ui/Box';
import { Stack } from '@/ui/Stack';
import { Container } from '@/ui/Container';
import { ConfirmDialog } from '@/ui/ConfirmDialog';
import { ConfirmDialog } from '@/components/shared/ConfirmDialog';
import { Button } from '@/ui/Button';
import { Icon } from '@/ui/Icon';
import { Card } from '@/ui/Card';
@@ -17,7 +17,7 @@ import { Skeleton } from '@/ui/Skeleton';
import { LoadingSpinner } from '@/ui/LoadingSpinner';
import { Badge } from '@/ui/Badge';
import { ProgressLine } from '@/ui/ProgressLine';
import { ProgressLine } from '@/components/shared/ProgressLine';
import { SharedEmptyState } from './SharedEmptyState';
export {

View File

@@ -0,0 +1,174 @@
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'
}}
>
<Box
as="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>
);
}

View File

@@ -1,5 +1,5 @@
import { EmptyState } from '@/ui/EmptyState';
import { ErrorDisplay } from '@/ui/ErrorDisplay';
import { ErrorDisplay } from '@/components/shared/ErrorDisplay';
import { LoadingWrapper } from '@/ui/LoadingWrapper';
import { ApiError } from '@/lib/api/base/ApiError';
import { Inbox, List, LucideIcon } from 'lucide-react';

View File

@@ -1,7 +1,7 @@
'use client';
import { EmptyState } from '@/ui/EmptyState';
import { ErrorDisplay } from '@/ui/ErrorDisplay';
import { ErrorDisplay } from '@/components/shared/ErrorDisplay';
import { LoadingWrapper } from '@/ui/LoadingWrapper';
import { Box } from '@/ui/Box';
import { Heading } from '@/ui/Heading';