feat: update UI

This commit is contained in:
2026-09-03 21:49:25 +07:00
parent fd85fd894b
commit b55e7f97c9
32 changed files with 1616 additions and 958 deletions
+26
View File
@@ -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 };
+50
View File
@@ -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 };
+54
View File
@@ -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 };
+196
View File
@@ -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 };
+17
View File
@@ -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 };
+17
View File
@@ -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 };
+22
View File
@@ -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 };
+43
View File
@@ -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 };
+16
View File
@@ -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 };