feat: update UI
This commit is contained in:
@@ -0,0 +1,26 @@
|
||||
import * as React from 'react';
|
||||
import { cva } from 'class-variance-authority';
|
||||
import { cn } from '../../lib/utils';
|
||||
|
||||
const badgeVariants = cva(
|
||||
'inline-flex items-center rounded-md border px-2.5 py-0.5 text-xs font-semibold transition-colors focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2',
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default: 'border-transparent bg-primary text-primary-foreground shadow hover:bg-primary/80',
|
||||
secondary: 'border-transparent bg-secondary text-secondary-foreground hover:bg-secondary/80',
|
||||
destructive: 'border-transparent bg-destructive text-destructive-foreground shadow hover:bg-destructive/80',
|
||||
outline: 'text-foreground',
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: 'default',
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
function Badge({ className, variant, ...props }) {
|
||||
return <div className={cn(badgeVariants({ variant }), className)} {...props} />;
|
||||
}
|
||||
|
||||
export { Badge, badgeVariants };
|
||||
@@ -0,0 +1,50 @@
|
||||
import * as React from 'react';
|
||||
import { Slot } from '@radix-ui/react-slot';
|
||||
import { cva } from 'class-variance-authority';
|
||||
import { cn } from '../../lib/utils';
|
||||
|
||||
const buttonVariants = cva(
|
||||
'inline-flex items-center justify-center whitespace-nowrap rounded-md text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50',
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default:
|
||||
'bg-primary text-primary-foreground shadow hover:bg-primary/90',
|
||||
destructive:
|
||||
'bg-destructive text-destructive-foreground shadow-sm hover:bg-destructive/90',
|
||||
outline:
|
||||
'border border-input bg-background shadow-sm hover:bg-accent hover:text-accent-foreground',
|
||||
secondary:
|
||||
'bg-secondary text-secondary-foreground shadow-sm hover:bg-secondary/80',
|
||||
ghost: 'hover:bg-accent hover:text-accent-foreground',
|
||||
link: 'text-primary underline-offset-4 hover:underline',
|
||||
},
|
||||
size: {
|
||||
default: 'h-9 px-4 py-2',
|
||||
sm: 'h-8 rounded-md px-3 text-xs',
|
||||
lg: 'h-10 rounded-md px-8',
|
||||
icon: 'h-9 w-9',
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: 'default',
|
||||
size: 'default',
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
const Button = React.forwardRef(
|
||||
({ className, variant, size, asChild = false, ...props }, ref) => {
|
||||
const Comp = asChild ? Slot : 'button';
|
||||
return (
|
||||
<Comp
|
||||
className={cn(buttonVariants({ variant, size, className }))}
|
||||
ref={ref}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
);
|
||||
Button.displayName = 'Button';
|
||||
|
||||
export { Button, buttonVariants };
|
||||
@@ -0,0 +1,54 @@
|
||||
import * as React from 'react';
|
||||
import { cn } from '../../lib/utils';
|
||||
|
||||
const Card = React.forwardRef(({ className, ...props }, ref) => (
|
||||
<div
|
||||
ref={ref}
|
||||
className={cn('rounded-xl border bg-card text-card-foreground shadow', className)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
Card.displayName = 'Card';
|
||||
|
||||
const CardHeader = React.forwardRef(({ className, ...props }, ref) => (
|
||||
<div
|
||||
ref={ref}
|
||||
className={cn('flex flex-col space-y-1.5 p-6', className)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
CardHeader.displayName = 'CardHeader';
|
||||
|
||||
const CardTitle = React.forwardRef(({ className, ...props }, ref) => (
|
||||
<div
|
||||
ref={ref}
|
||||
className={cn('font-semibold leading-none tracking-tight', className)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
CardTitle.displayName = 'CardTitle';
|
||||
|
||||
const CardDescription = React.forwardRef(({ className, ...props }, ref) => (
|
||||
<div
|
||||
ref={ref}
|
||||
className={cn('text-sm text-muted-foreground', className)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
CardDescription.displayName = 'CardDescription';
|
||||
|
||||
const CardContent = React.forwardRef(({ className, ...props }, ref) => (
|
||||
<div ref={ref} className={cn('p-6 pt-0', className)} {...props} />
|
||||
));
|
||||
CardContent.displayName = 'CardContent';
|
||||
|
||||
const CardFooter = React.forwardRef(({ className, ...props }, ref) => (
|
||||
<div
|
||||
ref={ref}
|
||||
className={cn('flex items-center p-6 pt-0', className)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
CardFooter.displayName = 'CardFooter';
|
||||
|
||||
export { Card, CardHeader, CardFooter, CardTitle, CardDescription, CardContent };
|
||||
@@ -0,0 +1,196 @@
|
||||
import { useState } from 'react';
|
||||
import { HiOutlineMagnifyingGlass } from 'react-icons/hi2';
|
||||
import { Input } from './input';
|
||||
|
||||
const styles = {
|
||||
wrapper: {
|
||||
overflow: 'auto',
|
||||
borderRadius: 12,
|
||||
border: '1px solid #dedee5',
|
||||
boxShadow: 'rgba(0,0,0,0.03) 0px 4px 24px',
|
||||
background: '#ffffff',
|
||||
maxHeight: 'calc(100vh - 325px)',
|
||||
},
|
||||
table: {
|
||||
width: '100%',
|
||||
borderCollapse: 'collapse',
|
||||
fontFamily: "'Helvetica Neue', Helvetica, Arial, sans-serif",
|
||||
},
|
||||
thead: {
|
||||
position: 'sticky',
|
||||
top: 0,
|
||||
zIndex: 1,
|
||||
background: '#f8f9fa',
|
||||
},
|
||||
th: {
|
||||
padding: '14px 16px',
|
||||
textAlign: 'left',
|
||||
borderBottom: '1px solid #dedee5',
|
||||
fontWeight: 600,
|
||||
fontSize: 12,
|
||||
color: '#9497a9',
|
||||
textTransform: 'uppercase',
|
||||
letterSpacing: '0.06em',
|
||||
background: 'rgba(148,151,169,0.08)',
|
||||
},
|
||||
td: {
|
||||
padding: '14px 16px',
|
||||
borderBottom: '1px solid #dedee5',
|
||||
fontSize: 14,
|
||||
color: '#101114',
|
||||
lineHeight: 1.43,
|
||||
},
|
||||
};
|
||||
|
||||
function DataTable({
|
||||
columns,
|
||||
data,
|
||||
searchKeys,
|
||||
searchPlaceholder = 'Search...',
|
||||
perPage = 10,
|
||||
emptyText = 'No results found.',
|
||||
}) {
|
||||
const [search, setSearch] = useState('');
|
||||
const [page, setPage] = useState(1);
|
||||
|
||||
const handleSearch = (e) => {
|
||||
setSearch(e.target.value);
|
||||
setPage(1);
|
||||
};
|
||||
|
||||
const filtered = data.filter((row) => {
|
||||
if (!search) return true;
|
||||
const q = search.toLowerCase();
|
||||
return searchKeys.some((key) => {
|
||||
const val = row[key];
|
||||
return val != null && String(val).toLowerCase().includes(q);
|
||||
});
|
||||
});
|
||||
|
||||
const totalPages = Math.max(1, Math.ceil(filtered.length / perPage));
|
||||
const paged = filtered.slice((page - 1) * perPage, page * perPage);
|
||||
const startIdx = filtered.length === 0 ? 0 : (page - 1) * perPage + 1;
|
||||
const endIdx = Math.min(page * perPage, filtered.length);
|
||||
|
||||
return (
|
||||
<>
|
||||
{searchKeys && searchKeys.length > 0 && (
|
||||
<div style={{ position: 'relative', width: 280, marginBottom: 12 }}>
|
||||
<HiOutlineMagnifyingGlass style={{ position: 'absolute', left: 10, top: '50%', transform: 'translateY(-50%)', color: '#9497a9', width: 16, height: 16 }} />
|
||||
<Input
|
||||
placeholder={searchPlaceholder}
|
||||
value={search}
|
||||
onChange={handleSearch}
|
||||
style={{ paddingLeft: 34, borderRadius: 10, border: '1px solid #dedee5', fontSize: 14 }}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<div style={styles.wrapper}>
|
||||
<table style={styles.table}>
|
||||
<thead style={styles.thead}>
|
||||
<tr>
|
||||
{columns.map((col) => (
|
||||
<th key={col.key} style={{ ...styles.th, width: col.width }}>
|
||||
{col.label}
|
||||
</th>
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{paged.map((row, idx) => (
|
||||
<tr
|
||||
key={row.id ?? idx}
|
||||
style={{
|
||||
transition: 'background 120ms ease',
|
||||
background: idx % 2 === 1 ? 'rgba(148,151,169,0.04)' : '#ffffff',
|
||||
}}
|
||||
onMouseEnter={(e) => { e.currentTarget.style.background = 'rgba(113,50,245,0.06)'; }}
|
||||
onMouseLeave={(e) => { e.currentTarget.style.background = idx % 2 === 1 ? 'rgba(148,151,169,0.04)' : '#ffffff'; }}
|
||||
>
|
||||
{columns.map((col) => (
|
||||
<td key={col.key} style={{ ...styles.td, ...(col.tdStyle || {}) }}>
|
||||
{col.render
|
||||
? col.render(row, (page - 1) * perPage + idx + 1)
|
||||
: row[col.key]}
|
||||
</td>
|
||||
))}
|
||||
</tr>
|
||||
))}
|
||||
{paged.length === 0 && (
|
||||
<tr>
|
||||
<td colSpan={columns.length} style={{ ...styles.td, textAlign: 'center', color: '#9497a9', padding: 24 }}>
|
||||
{emptyText}
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
{/* Pagination */}
|
||||
<div style={{ display: 'flex', justifyContent: 'flex-end', alignItems: 'center', marginTop: 16, fontSize: 14, color: '#686b82' }}>
|
||||
<span>
|
||||
{startIdx}–{endIdx} of {filtered.length}
|
||||
</span>
|
||||
<div style={{ display: 'flex', gap: 6, marginLeft: 12 }}>
|
||||
<button
|
||||
type="button"
|
||||
disabled={page <= 1}
|
||||
onClick={() => setPage((p) => p - 1)}
|
||||
style={{
|
||||
padding: '6px 12px',
|
||||
borderRadius: 10,
|
||||
border: '1px solid #dedee5',
|
||||
background: page <= 1 ? 'rgba(148,151,169,0.08)' : '#ffffff',
|
||||
color: page <= 1 ? '#9497a9' : '#101114',
|
||||
cursor: page <= 1 ? 'default' : 'pointer',
|
||||
fontSize: 13,
|
||||
fontWeight: 500,
|
||||
}}
|
||||
>
|
||||
Previous
|
||||
</button>
|
||||
{Array.from({ length: totalPages }, (_, i) => (
|
||||
<button
|
||||
key={i + 1}
|
||||
type="button"
|
||||
onClick={() => setPage(i + 1)}
|
||||
style={{
|
||||
width: 32,
|
||||
height: 32,
|
||||
borderRadius: 8,
|
||||
border: 'none',
|
||||
background: page === i + 1 ? '#7132f5' : 'transparent',
|
||||
color: page === i + 1 ? '#ffffff' : '#686b82',
|
||||
cursor: 'pointer',
|
||||
fontSize: 13,
|
||||
fontWeight: 500,
|
||||
}}
|
||||
>
|
||||
{i + 1}
|
||||
</button>
|
||||
))}
|
||||
<button
|
||||
type="button"
|
||||
disabled={page >= totalPages}
|
||||
onClick={() => setPage((p) => p + 1)}
|
||||
style={{
|
||||
padding: '6px 12px',
|
||||
borderRadius: 10,
|
||||
border: '1px solid #dedee5',
|
||||
background: page >= totalPages ? 'rgba(148,151,169,0.08)' : '#ffffff',
|
||||
color: page >= totalPages ? '#9497a9' : '#101114',
|
||||
cursor: page >= totalPages ? 'default' : 'pointer',
|
||||
fontSize: 13,
|
||||
fontWeight: 500,
|
||||
}}
|
||||
>
|
||||
Next
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export { DataTable };
|
||||
@@ -0,0 +1,17 @@
|
||||
import * as React from 'react';
|
||||
import { cn } from '../../lib/utils';
|
||||
|
||||
const Input = React.forwardRef(({ className, type, ...props }, ref) => (
|
||||
<input
|
||||
type={type}
|
||||
className={cn(
|
||||
'flex h-9 w-full rounded-md border border-input bg-transparent px-3 py-1 text-sm shadow-sm transition-colors file:border-0 file:bg-transparent file:text-sm file:font-medium file:text-foreground placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50',
|
||||
className
|
||||
)}
|
||||
ref={ref}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
Input.displayName = 'Input';
|
||||
|
||||
export { Input };
|
||||
@@ -0,0 +1,17 @@
|
||||
import * as React from 'react';
|
||||
import * as LabelPrimitive from '@radix-ui/react-label';
|
||||
import { cn } from '../../lib/utils';
|
||||
|
||||
const Label = React.forwardRef(({ className, ...props }, ref) => (
|
||||
<LabelPrimitive.Root
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
Label.displayName = LabelPrimitive.Root.displayName;
|
||||
|
||||
export { Label };
|
||||
@@ -0,0 +1,22 @@
|
||||
import * as React from 'react';
|
||||
import * as SeparatorPrimitive from '@radix-ui/react-separator';
|
||||
import { cn } from '../../lib/utils';
|
||||
|
||||
const Separator = React.forwardRef(
|
||||
({ className, orientation = 'horizontal', decorative = true, ...props }, ref) => (
|
||||
<SeparatorPrimitive.Root
|
||||
ref={ref}
|
||||
decorative={decorative}
|
||||
orientation={orientation}
|
||||
className={cn(
|
||||
'shrink-0 bg-border',
|
||||
orientation === 'horizontal' ? 'h-[1px] w-full' : 'h-full w-[1px]',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
);
|
||||
Separator.displayName = SeparatorPrimitive.Root.displayName;
|
||||
|
||||
export { Separator };
|
||||
@@ -0,0 +1,43 @@
|
||||
import * as React from 'react';
|
||||
import * as TabsPrimitive from '@radix-ui/react-tabs';
|
||||
import { cn } from '../../lib/utils';
|
||||
|
||||
const Tabs = TabsPrimitive.Root;
|
||||
|
||||
const TabsList = React.forwardRef(({ className, ...props }, ref) => (
|
||||
<TabsPrimitive.List
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'inline-flex h-9 items-center justify-center rounded-lg bg-muted p-1 text-muted-foreground',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
TabsList.displayName = TabsPrimitive.List.displayName;
|
||||
|
||||
const TabsTrigger = React.forwardRef(({ className, ...props }, ref) => (
|
||||
<TabsPrimitive.Trigger
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'inline-flex items-center justify-center whitespace-nowrap rounded-md px-3 py-1 text-sm font-medium ring-offset-background transition-all focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 data-[state=active]:bg-background data-[state=active]:text-foreground data-[state=active]:shadow',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
TabsTrigger.displayName = TabsPrimitive.Trigger.displayName;
|
||||
|
||||
const TabsContent = React.forwardRef(({ className, ...props }, ref) => (
|
||||
<TabsPrimitive.Content
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'mt-2 ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
TabsContent.displayName = TabsPrimitive.Content.displayName;
|
||||
|
||||
export { Tabs, TabsList, TabsTrigger, TabsContent };
|
||||
@@ -0,0 +1,16 @@
|
||||
import * as React from 'react';
|
||||
import { cn } from '../../lib/utils';
|
||||
|
||||
const Textarea = React.forwardRef(({ className, ...props }, ref) => (
|
||||
<textarea
|
||||
className={cn(
|
||||
'flex min-h-[60px] w-full rounded-md border border-input bg-transparent px-3 py-2 text-sm shadow-sm placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50',
|
||||
className
|
||||
)}
|
||||
ref={ref}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
Textarea.displayName = 'Textarea';
|
||||
|
||||
export { Textarea };
|
||||
Reference in New Issue
Block a user