website refactor
This commit is contained in:
105
apps/website/components/media/MediaCard.tsx
Normal file
105
apps/website/components/media/MediaCard.tsx
Normal file
@@ -0,0 +1,105 @@
|
||||
'use client';
|
||||
|
||||
import React from 'react';
|
||||
import { motion } from 'framer-motion';
|
||||
import { Box } from '@/ui/Box';
|
||||
import { Text } from '@/ui/Text';
|
||||
import { Image } from '@/ui/Image';
|
||||
import { ImagePlaceholder } from '@/ui/ImagePlaceholder';
|
||||
|
||||
export interface MediaCardProps {
|
||||
src?: string;
|
||||
alt?: string;
|
||||
title?: string;
|
||||
subtitle?: string;
|
||||
aspectRatio?: string;
|
||||
isLoading?: boolean;
|
||||
error?: string;
|
||||
onClick?: () => void;
|
||||
actions?: React.ReactNode;
|
||||
}
|
||||
|
||||
export function MediaCard({
|
||||
src,
|
||||
alt = 'Media asset',
|
||||
title,
|
||||
subtitle,
|
||||
aspectRatio = '16/9',
|
||||
isLoading,
|
||||
error,
|
||||
onClick,
|
||||
actions,
|
||||
}: MediaCardProps) {
|
||||
return (
|
||||
<Box
|
||||
as={motion.div}
|
||||
whileHover={{ y: -4 }}
|
||||
transition={{ duration: 0.2, ease: [0.25, 0.1, 0.25, 1] }} // Fast ease-out
|
||||
h="full"
|
||||
>
|
||||
<Box
|
||||
display="flex"
|
||||
flexDirection="col"
|
||||
bg="bg-charcoal/40"
|
||||
border
|
||||
borderColor="border-charcoal-outline/30"
|
||||
rounded="lg"
|
||||
overflow="hidden"
|
||||
cursor={onClick ? 'pointer' : 'default'}
|
||||
onClick={onClick}
|
||||
group
|
||||
h="full"
|
||||
>
|
||||
<Box position="relative" width="full" aspectRatio={aspectRatio}>
|
||||
{isLoading ? (
|
||||
<ImagePlaceholder variant="loading" aspectRatio={aspectRatio} rounded="none" />
|
||||
) : error ? (
|
||||
<ImagePlaceholder variant="error" message={error} aspectRatio={aspectRatio} rounded="none" />
|
||||
) : src ? (
|
||||
<Box w="full" h="full" overflow="hidden">
|
||||
<Image
|
||||
src={src}
|
||||
alt={alt}
|
||||
objectFit="cover"
|
||||
fullWidth
|
||||
fullHeight
|
||||
/>
|
||||
</Box>
|
||||
) : (
|
||||
<ImagePlaceholder aspectRatio={aspectRatio} rounded="none" />
|
||||
)}
|
||||
|
||||
{actions && (
|
||||
<Box
|
||||
position="absolute"
|
||||
top="2"
|
||||
right="2"
|
||||
display="flex"
|
||||
gap={2}
|
||||
opacity={0}
|
||||
groupHoverOpacity={1}
|
||||
transition
|
||||
>
|
||||
{actions}
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
|
||||
{(title || subtitle) && (
|
||||
<Box p={3} borderTop borderColor="border-charcoal-outline/20">
|
||||
{title && (
|
||||
<Text block size="sm" weight="semibold" truncate color="text-white">
|
||||
{title}
|
||||
</Text>
|
||||
)}
|
||||
{subtitle && (
|
||||
<Text block size="xs" color="text-gray-400" truncate mt={0.5}>
|
||||
{subtitle}
|
||||
</Text>
|
||||
)}
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
81
apps/website/components/media/MediaFiltersBar.tsx
Normal file
81
apps/website/components/media/MediaFiltersBar.tsx
Normal file
@@ -0,0 +1,81 @@
|
||||
import React from 'react';
|
||||
import { Search, Grid, List } from 'lucide-react';
|
||||
import { Box } from '@/ui/Box';
|
||||
import { Input } from '@/ui/Input';
|
||||
import { IconButton } from '@/ui/IconButton';
|
||||
import { Select } from '@/ui/Select';
|
||||
|
||||
export interface MediaFiltersBarProps {
|
||||
searchQuery: string;
|
||||
onSearchChange: (query: string) => void;
|
||||
category: string;
|
||||
onCategoryChange: (category: string) => void;
|
||||
categories: { label: string; value: string }[];
|
||||
viewMode?: 'grid' | 'list';
|
||||
onViewModeChange?: (mode: 'grid' | 'list') => void;
|
||||
}
|
||||
|
||||
export function MediaFiltersBar({
|
||||
searchQuery,
|
||||
onSearchChange,
|
||||
category,
|
||||
onCategoryChange,
|
||||
categories,
|
||||
viewMode = 'grid',
|
||||
onViewModeChange,
|
||||
}: MediaFiltersBarProps) {
|
||||
return (
|
||||
<Box
|
||||
display="flex"
|
||||
flexDirection={{ base: 'col', md: 'row' }}
|
||||
alignItems={{ base: 'stretch', md: 'center' }}
|
||||
justifyContent="between"
|
||||
gap={4}
|
||||
p={4}
|
||||
bg="bg-charcoal/20"
|
||||
border
|
||||
borderColor="border-charcoal-outline/20"
|
||||
rounded="xl"
|
||||
>
|
||||
<Box display="flex" flexGrow={1} maxWidth={{ md: 'md' }}>
|
||||
<Input
|
||||
placeholder="Search media assets..."
|
||||
value={searchQuery}
|
||||
onChange={(e) => onSearchChange(e.target.value)}
|
||||
icon={<Search size={18} />}
|
||||
/>
|
||||
</Box>
|
||||
|
||||
<Box display="flex" alignItems="center" gap={3}>
|
||||
<Box w="40">
|
||||
<Select
|
||||
value={category}
|
||||
onChange={(e) => onCategoryChange(e.target.value)}
|
||||
options={categories}
|
||||
/>
|
||||
</Box>
|
||||
|
||||
{onViewModeChange && (
|
||||
<Box display="flex" bg="bg-charcoal/40" p={1} rounded="lg" border borderColor="border-charcoal-outline/20">
|
||||
<IconButton
|
||||
icon={Grid}
|
||||
variant={viewMode === 'grid' ? 'primary' : 'ghost'}
|
||||
size="sm"
|
||||
onClick={() => onViewModeChange('grid')}
|
||||
color={viewMode === 'grid' ? 'text-white' : 'text-gray-400'}
|
||||
backgroundColor={viewMode === 'grid' ? 'bg-blue-600' : undefined}
|
||||
/>
|
||||
<IconButton
|
||||
icon={List}
|
||||
variant={viewMode === 'list' ? 'primary' : 'ghost'}
|
||||
size="sm"
|
||||
onClick={() => onViewModeChange('list')}
|
||||
color={viewMode === 'list' ? 'text-white' : 'text-gray-400'}
|
||||
backgroundColor={viewMode === 'list' ? 'bg-blue-600' : undefined}
|
||||
/>
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
114
apps/website/components/media/MediaGallery.tsx
Normal file
114
apps/website/components/media/MediaGallery.tsx
Normal file
@@ -0,0 +1,114 @@
|
||||
'use client';
|
||||
|
||||
import React, { useState } from 'react';
|
||||
import { Box } from '@/ui/Box';
|
||||
import { MediaGrid } from './MediaGrid';
|
||||
import { MediaCard } from './MediaCard';
|
||||
import { MediaFiltersBar } from './MediaFiltersBar';
|
||||
import { MediaViewerModal } from './MediaViewerModal';
|
||||
import { Text } from '@/ui/Text';
|
||||
|
||||
export interface MediaAsset {
|
||||
id: string;
|
||||
src: string;
|
||||
title: string;
|
||||
category: string;
|
||||
date?: string;
|
||||
dimensions?: string;
|
||||
}
|
||||
|
||||
export interface MediaGalleryProps {
|
||||
assets: MediaAsset[];
|
||||
categories: { label: string; value: string }[];
|
||||
title?: string;
|
||||
description?: string;
|
||||
}
|
||||
|
||||
export function MediaGallery({
|
||||
assets,
|
||||
categories,
|
||||
title = 'Media Gallery',
|
||||
description,
|
||||
}: MediaGalleryProps) {
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
const [selectedCategory, setSelectedCategory] = useState('all');
|
||||
const [viewMode, setViewMode] = useState<'grid' | 'list'>('grid');
|
||||
const [viewerAsset, setViewerAsset] = useState<MediaAsset | null>(null);
|
||||
|
||||
const filteredAssets = assets.filter((asset) => {
|
||||
const matchesSearch = asset.title.toLowerCase().includes(searchQuery.toLowerCase());
|
||||
const matchesCategory = selectedCategory === 'all' || asset.category === selectedCategory;
|
||||
return matchesSearch && matchesCategory;
|
||||
});
|
||||
|
||||
const handleNext = () => {
|
||||
if (!viewerAsset) return;
|
||||
const currentIndex = filteredAssets.findIndex((a) => a.id === viewerAsset.id);
|
||||
if (currentIndex < filteredAssets.length - 1) {
|
||||
const nextAsset = filteredAssets[currentIndex + 1];
|
||||
if (nextAsset) setViewerAsset(nextAsset);
|
||||
}
|
||||
};
|
||||
|
||||
const handlePrev = () => {
|
||||
if (!viewerAsset) return;
|
||||
const currentIndex = filteredAssets.findIndex((a) => a.id === viewerAsset.id);
|
||||
if (currentIndex > 0) {
|
||||
const prevAsset = filteredAssets[currentIndex - 1];
|
||||
if (prevAsset) setViewerAsset(prevAsset);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Box display="flex" flexDirection="col" gap={6}>
|
||||
<Box>
|
||||
<Text as="h1" size="3xl" weight="bold" color="text-white">
|
||||
{title}
|
||||
</Text>
|
||||
{description && (
|
||||
<Text block size="sm" color="text-gray-400" mt={1}>
|
||||
{description}
|
||||
</Text>
|
||||
)}
|
||||
</Box>
|
||||
|
||||
<MediaFiltersBar
|
||||
searchQuery={searchQuery}
|
||||
onSearchChange={setSearchQuery}
|
||||
category={selectedCategory}
|
||||
onCategoryChange={setSelectedCategory}
|
||||
categories={categories}
|
||||
viewMode={viewMode}
|
||||
onViewModeChange={setViewMode}
|
||||
/>
|
||||
|
||||
{filteredAssets.length > 0 ? (
|
||||
<MediaGrid columns={viewMode === 'grid' ? { base: 1, sm: 2, md: 3, lg: 4 } : { base: 1 }}>
|
||||
{filteredAssets.map((asset) => (
|
||||
<MediaCard
|
||||
key={asset.id}
|
||||
src={asset.src}
|
||||
title={asset.title}
|
||||
subtitle={`${asset.category}${asset.dimensions ? ` • ${asset.dimensions}` : ''}`}
|
||||
onClick={() => setViewerAsset(asset)}
|
||||
/>
|
||||
))}
|
||||
</MediaGrid>
|
||||
) : (
|
||||
<Box py={20} center bg="bg-charcoal/10" rounded="xl" border borderStyle="dashed" borderColor="border-charcoal-outline/20">
|
||||
<Text color="text-gray-500">No media assets found matching your criteria.</Text>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
<MediaViewerModal
|
||||
isOpen={!!viewerAsset}
|
||||
onClose={() => setViewerAsset(null)}
|
||||
src={viewerAsset?.src}
|
||||
alt={viewerAsset?.title}
|
||||
title={viewerAsset?.title}
|
||||
onNext={handleNext}
|
||||
onPrev={handlePrev}
|
||||
/>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
34
apps/website/components/media/MediaGrid.tsx
Normal file
34
apps/website/components/media/MediaGrid.tsx
Normal file
@@ -0,0 +1,34 @@
|
||||
import React from 'react';
|
||||
import { Box } from '@/ui/Box';
|
||||
|
||||
export interface MediaGridProps {
|
||||
children: React.ReactNode;
|
||||
columns?: {
|
||||
base?: number;
|
||||
sm?: number;
|
||||
md?: number;
|
||||
lg?: number;
|
||||
xl?: number;
|
||||
};
|
||||
gap?: 2 | 3 | 4 | 6 | 8;
|
||||
}
|
||||
|
||||
export function MediaGrid({
|
||||
children,
|
||||
columns = { base: 1, sm: 2, md: 3, lg: 4 },
|
||||
gap = 4,
|
||||
}: MediaGridProps) {
|
||||
return (
|
||||
<Box
|
||||
display="grid"
|
||||
responsiveGridCols={{
|
||||
base: columns.base,
|
||||
md: columns.md,
|
||||
lg: columns.lg,
|
||||
}}
|
||||
gap={gap}
|
||||
>
|
||||
{children}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
170
apps/website/components/media/MediaViewerModal.tsx
Normal file
170
apps/website/components/media/MediaViewerModal.tsx
Normal file
@@ -0,0 +1,170 @@
|
||||
'use client';
|
||||
|
||||
import React from 'react';
|
||||
import { motion, AnimatePresence } from 'framer-motion';
|
||||
import { X, ChevronLeft, ChevronRight, Download } from 'lucide-react';
|
||||
import { Box } from '@/ui/Box';
|
||||
import { IconButton } from '@/ui/IconButton';
|
||||
import { Image } from '@/ui/Image';
|
||||
import { Text } from '@/ui/Text';
|
||||
|
||||
export interface MediaViewerModalProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
src?: string;
|
||||
alt?: string;
|
||||
title?: string;
|
||||
onNext?: () => void;
|
||||
onPrev?: () => void;
|
||||
}
|
||||
|
||||
export function MediaViewerModal({
|
||||
isOpen,
|
||||
onClose,
|
||||
src,
|
||||
alt = 'Media viewer',
|
||||
title,
|
||||
onNext,
|
||||
onPrev,
|
||||
}: MediaViewerModalProps) {
|
||||
return (
|
||||
<AnimatePresence>
|
||||
{isOpen && (
|
||||
<Box
|
||||
position="fixed"
|
||||
inset="0"
|
||||
zIndex={100}
|
||||
display="flex"
|
||||
alignItems="center"
|
||||
justifyContent="center"
|
||||
p={{ base: 4, md: 8 }}
|
||||
>
|
||||
{/* Backdrop with frosted blur */}
|
||||
<Box
|
||||
as={motion.div}
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
exit={{ opacity: 0 }}
|
||||
transition={{ duration: 0.2, ease: 'easeOut' }}
|
||||
position="absolute"
|
||||
inset="0"
|
||||
bg="bg-black/80"
|
||||
blur="md"
|
||||
onClick={onClose}
|
||||
/>
|
||||
|
||||
{/* Content Container */}
|
||||
<Box
|
||||
as={motion.div}
|
||||
initial={{ opacity: 0, scale: 0.95 }}
|
||||
animate={{ opacity: 1, scale: 1 }}
|
||||
exit={{ opacity: 0, scale: 0.95 }}
|
||||
transition={{ duration: 0.2, ease: [0.25, 0.1, 0.25, 1] }}
|
||||
position="relative"
|
||||
zIndex={10}
|
||||
w="full"
|
||||
maxWidth="6xl"
|
||||
maxHeight="full"
|
||||
display="flex"
|
||||
flexDirection="col"
|
||||
>
|
||||
{/* Header */}
|
||||
<Box
|
||||
display="flex"
|
||||
alignItems="center"
|
||||
justifyContent="between"
|
||||
mb={4}
|
||||
color="text-white"
|
||||
>
|
||||
<Box>
|
||||
{title && (
|
||||
<Text size="lg" weight="semibold" color="text-white">
|
||||
{title}
|
||||
</Text>
|
||||
)}
|
||||
</Box>
|
||||
<Box display="flex" gap={2}>
|
||||
<IconButton
|
||||
icon={Download}
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
onClick={() => src && window.open(src, '_blank')}
|
||||
color="text-white"
|
||||
backgroundColor="bg-white/10"
|
||||
/>
|
||||
<IconButton
|
||||
icon={X}
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
onClick={onClose}
|
||||
color="text-white"
|
||||
backgroundColor="bg-white/10"
|
||||
/>
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
{/* Image Area */}
|
||||
<Box
|
||||
position="relative"
|
||||
display="flex"
|
||||
alignItems="center"
|
||||
justifyContent="center"
|
||||
bg="bg-black/40"
|
||||
rounded="xl"
|
||||
overflow="hidden"
|
||||
border
|
||||
borderColor="border-white/10"
|
||||
flexGrow={1}
|
||||
minHeight="0"
|
||||
>
|
||||
{src ? (
|
||||
<Image
|
||||
src={src}
|
||||
alt={alt}
|
||||
objectFit="contain"
|
||||
fullWidth
|
||||
fullHeight
|
||||
/>
|
||||
) : (
|
||||
<Box p={20}>
|
||||
<Text color="text-gray-500">No image selected</Text>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{/* Navigation Controls */}
|
||||
{onPrev && (
|
||||
<Box position="absolute" left="4" top="1/2" translateY="-1/2">
|
||||
<IconButton
|
||||
icon={ChevronLeft}
|
||||
variant="secondary"
|
||||
onClick={onPrev}
|
||||
color="text-white"
|
||||
backgroundColor="bg-black/50"
|
||||
/>
|
||||
</Box>
|
||||
)}
|
||||
{onNext && (
|
||||
<Box position="absolute" right="4" top="1/2" translateY="-1/2">
|
||||
<IconButton
|
||||
icon={ChevronRight}
|
||||
variant="secondary"
|
||||
onClick={onNext}
|
||||
color="text-white"
|
||||
backgroundColor="bg-black/50"
|
||||
/>
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
|
||||
{/* Footer / Info */}
|
||||
<Box mt={4} display="flex" justifyContent="center">
|
||||
<Text size="xs" color="text-gray-400" uppercase letterSpacing="widest">
|
||||
Precision Racing Media Viewer
|
||||
</Text>
|
||||
</Box>
|
||||
</Box>
|
||||
</Box>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
);
|
||||
}
|
||||
187
apps/website/components/media/UploadDropzone.tsx
Normal file
187
apps/website/components/media/UploadDropzone.tsx
Normal file
@@ -0,0 +1,187 @@
|
||||
import React, { useState, useRef } from 'react';
|
||||
import { Upload, File, X, CheckCircle2, AlertCircle } from 'lucide-react';
|
||||
import { Box } from '@/ui/Box';
|
||||
import { Text } from '@/ui/Text';
|
||||
import { Icon } from '@/ui/Icon';
|
||||
import { Button } from '@/ui/Button';
|
||||
|
||||
export interface UploadDropzoneProps {
|
||||
onFilesSelected: (files: File[]) => void;
|
||||
accept?: string;
|
||||
multiple?: boolean;
|
||||
maxSize?: number; // in bytes
|
||||
isLoading?: boolean;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
export function UploadDropzone({
|
||||
onFilesSelected,
|
||||
accept,
|
||||
multiple = false,
|
||||
maxSize,
|
||||
isLoading,
|
||||
error,
|
||||
}: UploadDropzoneProps) {
|
||||
const [isDragging, setIsDragging] = useState(false);
|
||||
const [selectedFiles, setSelectedFiles] = useState<File[]>([]);
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
const handleDragOver = (e: React.DragEvent) => {
|
||||
e.preventDefault();
|
||||
setIsDragging(true);
|
||||
};
|
||||
|
||||
const handleDragLeave = () => {
|
||||
setIsDragging(false);
|
||||
};
|
||||
|
||||
const handleDrop = (e: React.DragEvent) => {
|
||||
e.preventDefault();
|
||||
setIsDragging(false);
|
||||
const files = Array.from(e.dataTransfer.files);
|
||||
validateAndSelectFiles(files);
|
||||
};
|
||||
|
||||
const handleFileSelect = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
if (e.target.files) {
|
||||
const files = Array.from(e.target.files);
|
||||
validateAndSelectFiles(files);
|
||||
}
|
||||
};
|
||||
|
||||
const validateAndSelectFiles = (files: File[]) => {
|
||||
let filteredFiles = files;
|
||||
|
||||
if (accept) {
|
||||
const acceptedTypes = accept.split(',').map(t => t.trim());
|
||||
filteredFiles = filteredFiles.filter(file => {
|
||||
return acceptedTypes.some(type => {
|
||||
if (type.startsWith('.')) {
|
||||
return file.name.endsWith(type);
|
||||
}
|
||||
if (type.endsWith('/*')) {
|
||||
return file.type.startsWith(type.replace('/*', ''));
|
||||
}
|
||||
return file.type === type;
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
if (maxSize) {
|
||||
filteredFiles = filteredFiles.filter(file => file.size <= maxSize);
|
||||
}
|
||||
|
||||
if (!multiple) {
|
||||
filteredFiles = filteredFiles.slice(0, 1);
|
||||
}
|
||||
|
||||
setSelectedFiles(filteredFiles);
|
||||
onFilesSelected(filteredFiles);
|
||||
};
|
||||
|
||||
const removeFile = (index: number) => {
|
||||
const newFiles = [...selectedFiles];
|
||||
newFiles.splice(index, 1);
|
||||
setSelectedFiles(newFiles);
|
||||
onFilesSelected(newFiles);
|
||||
};
|
||||
|
||||
return (
|
||||
<Box w="full">
|
||||
<Box
|
||||
onDragOver={handleDragOver}
|
||||
onDragLeave={handleDragLeave}
|
||||
onDrop={handleDrop}
|
||||
onClick={() => fileInputRef.current?.click()}
|
||||
display="flex"
|
||||
flexDirection="col"
|
||||
alignItems="center"
|
||||
justifyContent="center"
|
||||
p={8}
|
||||
border
|
||||
borderStyle="dashed"
|
||||
borderColor={isDragging ? 'border-blue-500' : error ? 'border-amber-500' : 'border-charcoal-outline'}
|
||||
bg={isDragging ? 'bg-blue-500/5' : 'bg-charcoal-outline/10'}
|
||||
rounded="xl"
|
||||
cursor="pointer"
|
||||
transition
|
||||
hoverBg="bg-charcoal-outline/20"
|
||||
>
|
||||
<Box as="input"
|
||||
type="file"
|
||||
ref={fileInputRef}
|
||||
onChange={handleFileSelect}
|
||||
accept={accept}
|
||||
multiple={multiple}
|
||||
display="none"
|
||||
/>
|
||||
|
||||
<Icon
|
||||
icon={isLoading ? Upload : (selectedFiles.length > 0 ? CheckCircle2 : Upload)}
|
||||
size={10}
|
||||
color={isDragging ? 'text-blue-500' : error ? 'text-amber-500' : 'text-gray-500'}
|
||||
animate={isLoading ? 'pulse' : 'none'}
|
||||
mb={4}
|
||||
/>
|
||||
|
||||
<Text weight="semibold" size="lg" mb={1}>
|
||||
{isDragging ? 'Drop files here' : 'Click or drag to upload'}
|
||||
</Text>
|
||||
|
||||
<Text size="sm" color="text-gray-500" align="center">
|
||||
{accept ? `Accepted formats: ${accept}` : 'All file types accepted'}
|
||||
{maxSize && ` (Max ${Math.round(maxSize / 1024 / 1024)}MB)`}
|
||||
</Text>
|
||||
|
||||
{error && (
|
||||
<Box display="flex" alignItems="center" gap={2} mt={4} color="text-amber-500">
|
||||
<Icon icon={AlertCircle} size={4} />
|
||||
<Text size="sm" weight="medium">{error}</Text>
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
|
||||
{selectedFiles.length > 0 && (
|
||||
<Box mt={4} display="flex" flexDirection="col" gap={2}>
|
||||
{selectedFiles.map((file, index) => (
|
||||
<Box
|
||||
key={`${file.name}-${index}`}
|
||||
display="flex"
|
||||
alignItems="center"
|
||||
justifyContent="between"
|
||||
p={3}
|
||||
bg="bg-charcoal-outline/20"
|
||||
rounded="lg"
|
||||
border
|
||||
borderColor="border-charcoal-outline/30"
|
||||
>
|
||||
<Box display="flex" alignItems="center" gap={3}>
|
||||
<Icon icon={File} size={5} color="text-gray-400" />
|
||||
<Box>
|
||||
<Text block size="sm" weight="medium" truncate maxWidth="200px">
|
||||
{file.name}
|
||||
</Text>
|
||||
<Text block size="xs" color="text-gray-500">
|
||||
{Math.round(file.size / 1024)} KB
|
||||
</Text>
|
||||
</Box>
|
||||
</Box>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
removeFile(index);
|
||||
}}
|
||||
p={1}
|
||||
h="auto"
|
||||
>
|
||||
<Icon icon={X} size={4} />
|
||||
</Button>
|
||||
</Box>
|
||||
))}
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user