added data table, formatter

revert context on __root beforeLoad
refactor project structure
refactor role badge
dynamic nav menu
This commit is contained in:
2026-01-14 09:35:46 +07:00
parent a44fa70500
commit edb4ebe11c
45 changed files with 1519 additions and 149 deletions

View File

@@ -0,0 +1,165 @@
import { cn } from '@/lib/utils';
import { m } from '@/paraglide/messages';
import {
type ColumnDef,
flexRender,
getCoreRowModel,
useReactTable,
} from '@tanstack/react-table';
import Pagination from './Pagination';
import { Label } from './ui/label';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from './ui/select';
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from './ui/table';
interface DataTableProps<TData, TValue> {
columns: ColumnDef<TData, TValue>[];
data: TData[];
page: number;
setPage: (page: number) => void;
limit: number;
setLimit: (page: number) => void;
pagination: {
currentPage: number;
totalPage: number;
totalItem: number;
};
}
const DataTable = <TData, TValue>({
columns,
data,
page,
setPage,
limit,
setLimit,
pagination,
}: DataTableProps<TData, TValue>) => {
const table = useReactTable({
data,
columns,
getCoreRowModel: getCoreRowModel(),
});
return (
<>
<div className="overflow-hidden rounded-md border">
<Table>
<TableHeader>
{table.getHeaderGroups().map((headerGroup) => (
<TableRow key={headerGroup.id}>
{headerGroup.headers.map((header) => {
return (
<TableHead
key={header.id}
colSpan={header.colSpan}
className={cn(
'px-4',
header.column.columnDef.meta?.thClass,
)}
>
{header.isPlaceholder
? null
: flexRender(
header.column.columnDef.header,
header.getContext(),
)}
</TableHead>
);
})}
</TableRow>
))}
</TableHeader>
<TableBody className="**:data-[slot=table-cell]:first:w-8">
{table.getRowModel().rows?.length ? (
table.getRowModel().rows.map((row) => (
<TableRow
key={row.id}
data-state={row.getIsSelected() && 'selected'}
>
{row.getVisibleCells().map((cell) => (
<TableCell key={cell.id} className="px-4">
{flexRender(
cell.column.columnDef.cell,
cell.getContext(),
)}
</TableCell>
))}
</TableRow>
))
) : (
<TableRow>
<TableCell
colSpan={columns.length}
className="h-24 text-center"
>
No results.
</TableCell>
</TableRow>
)}
</TableBody>
</Table>
</div>
<div className="flex items-center justify-between">
<div className="hidden text-sm gap-2 xl:flex">
<span>
{m.common_page_show({
count: pagination.totalItem,
start: (pagination.currentPage - 1) * 10 + 1,
end: Math.min(pagination.currentPage * 10, pagination.totalItem),
})}
</span>
</div>
<div className="flex max-w-full items-center gap-8 xl:w-ft">
<div className="hidden items-center gap-2 lg:flex">
<Label htmlFor="rows-per-page" className="text-sm font-medium">
{m.common_per_page()}
</Label>
<Select
value={`${limit}`}
onValueChange={(value) => {
setLimit(+value);
setPage(1);
}}
>
<SelectTrigger
type="button"
size="sm"
className="w-30"
id="rows-per-page"
>
<SelectValue placeholder={m.common_select_page_size()} />
</SelectTrigger>
<SelectContent side="top">
{[10, 30, 50, 100].map((pageSize) => (
<SelectItem key={pageSize} value={`${pageSize}`}>
{pageSize}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<Pagination
currentPage={page}
totalPages={pagination.totalPage}
onPageChange={(newPage) => setPage(newPage)}
/>
</div>
</div>
</>
);
};
export default DataTable;

View File

@@ -2,7 +2,7 @@ import { m } from '@/paraglide/messages';
import { Separator } from '@base-ui/react/separator';
import { BellIcon } from '@phosphor-icons/react';
import { useAuth } from './auth/auth-provider';
import RouterBreadcrumb from './sidebar/RouterBreadcrumb';
import RouterBreadcrumb from './sidebar/router-breadcrumb';
import { Badge } from './ui/badge';
import { Button } from './ui/button';
import {

View File

@@ -0,0 +1,97 @@
import {
CaretLeftIcon,
CaretRightIcon,
DotsThreeIcon,
} from '@phosphor-icons/react';
import { Button } from './ui/button';
import { ButtonGroup } from './ui/button-group';
type PaginationProps = {
currentPage: number;
totalPages: number;
onPageChange: (page: number) => void;
};
const Pagination = ({
currentPage,
totalPages,
onPageChange,
}: PaginationProps) => {
const getPageNumbers = () => {
const pages: (number | string)[] = [];
if (totalPages <= 5) {
// Hiển thị tất cả nếu trang ít
for (let i = 1; i <= totalPages; i++) {
pages.push(i);
}
} else {
if (currentPage <= 3) {
pages.push(1, 2, 3, 'dot', totalPages);
} else if (currentPage >= totalPages - 2) {
pages.push(1, 'dot', totalPages - 2, totalPages - 1, totalPages);
} else {
pages.push(1, 'dot', currentPage, 'dot', totalPages);
}
}
return pages;
};
const pages = getPageNumbers();
return (
<div className="flex items-center justify-center gap-2">
<ButtonGroup>
<ButtonGroup>
<Button
variant="outline"
size="icon-sm"
disabled={currentPage === 1}
className="cursor-pointer"
>
<CaretLeftIcon />
</Button>
</ButtonGroup>
<ButtonGroup>
{pages.map((page, idx) =>
page === 'dot' ? (
<Button
variant="outline"
size="icon-sm"
key={idx}
disabled={true}
>
<DotsThreeIcon size={14} />
</Button>
) : (
<Button
variant="outline"
key={idx}
size="icon-sm"
data-active={currentPage === page}
onClick={() => onPageChange(Number(page))}
disabled={currentPage === page}
className="cursor-pointer"
>
{page}
</Button>
),
)}
</ButtonGroup>
<ButtonGroup>
<Button
variant="outline"
size="icon-sm"
disabled={currentPage === totalPages}
className="cursor-pointer"
>
<CaretRightIcon />
</Button>
</ButtonGroup>
</ButtonGroup>
</div>
);
};
export default Pagination;

View File

@@ -0,0 +1,43 @@
import { m } from '@/paraglide/messages';
import { Badge } from '../ui/badge';
export type UserActionType = {
create: string;
update: string;
delete: string;
ban: string;
unban: string;
sign_in: string;
sign_out: string;
};
const ActionBadge = ({ action }: { action: keyof UserActionType }) => {
const USER_ACTION = Object.freeze(
new Proxy(
{
create: 'bg-green-400',
update: 'bg-blue-400',
delete: 'bg-red-400',
sign_in: 'bg-lime-400',
sign_out: 'bg-yellow-400',
ban: 'bg-rose-400',
unban: 'bg-emerald-400',
} as UserActionType,
{
get: function (target: UserActionType, name: string | symbol) {
return target.hasOwnProperty(name as string)
? target[name as keyof UserActionType]
: '';
},
},
),
);
return (
<Badge variant="default" className={USER_ACTION[action]}>
{m.logs_page_ui_badge_action({ action })}
</Badge>
);
};
export default ActionBadge;

View File

@@ -0,0 +1,159 @@
import { m } from '@/paraglide/messages';
import { formatters } from '@/utils/formatters';
import { jsonSupport } from '@/utils/help';
import { EyeIcon } from '@phosphor-icons/react';
import { ColumnDef } from '@tanstack/react-table';
import { Badge } from '../ui/badge';
import { Button } from '../ui/button';
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogTrigger,
} from '../ui/dialog';
import { Label } from '../ui/label';
import { Tooltip, TooltipContent, TooltipTrigger } from '../ui/tooltip';
import ActionBadge, { UserActionType } from './action-badge';
export const logColumns: ColumnDef<AuditLog>[] = [
{
accessorKey: 'user.name',
header: m.logs_page_ui_table_header_username(),
meta: {
thClass: 'w-1/6',
},
},
{
accessorKey: 'tableName',
header: m.logs_page_ui_table_header_table(),
meta: {
thClass: 'w-1/6',
},
cell: ({ row }) => {
return (
<Badge variant="table" className="px-3 py-1 text-xs">
{row.original.tableName}
</Badge>
);
},
},
{
accessorKey: 'action',
header: m.logs_page_ui_table_header_action(),
meta: {
thClass: 'w-1/6',
},
cell: ({ row }) => {
return (
<ActionBadge action={row.original.action as keyof UserActionType} />
);
},
},
{
accessorKey: 'createdAt',
header: m.logs_page_ui_table_header_action(),
meta: {
thClass: 'w-2/6',
},
cell: ({ row }) => {
return formatters.dateTime(new Date(row.original.createdAt));
},
},
{
id: 'actions',
meta: {
thClass: 'w-1/6',
},
cell: ({ row }) => (
<div className="flex justify-end">
<ViewDetail data={row.original} />
</div>
),
},
];
type ViewDetailProps = {
data: AuditLog;
};
const ViewDetail = ({ data }: ViewDetailProps) => {
return (
<Dialog>
<Tooltip>
<TooltipTrigger asChild>
<DialogTrigger asChild>
<Button
type="button"
variant="ghost"
size="icon"
className="rounded-full cursor-pointer text-blue-500 hover:bg-blue-100 hover:text-blue-600"
>
<EyeIcon size={16} />
<span className="sr-only">View</span>
</Button>
</DialogTrigger>
</TooltipTrigger>
<TooltipContent
side="left"
className="bg-blue-300 [&_svg]:bg-blue-300 [&_svg]:fill-blue-300 text-white"
>
<Label>View</Label>
</TooltipContent>
</Tooltip>
<DialogContent className="max-w-100 xl:max-w-2xl">
<DialogHeader>
<DialogTitle>
{m.ui_dialog_view_title({ type: m.nav_log() })}
</DialogTitle>
</DialogHeader>
<div className="flex flex-col gap-2">
<div className="flex items-center gap-2">
<span className="font-bold">
{m.logs_page_ui_table_header_username()}:
</span>
<Label>{data.user.name}</Label>
</div>
<div className="flex items-center gap-2">
<span className="font-bold">
{m.logs_page_ui_table_header_table()}:
</span>
<Badge variant="table" className="px-3 py-1 text-xs">
{data.tableName}
</Badge>
</div>
<div className="flex items-center gap-2">
<span className="font-bold">
{m.logs_page_ui_table_header_action()}:
</span>
<ActionBadge action={data.action as keyof UserActionType} />
</div>
{data.oldValue && (
<div className="flex flex-col gap-2">
<span className="font-bold">
{m.logs_page_ui_table_header_old_value()}:
</span>
<pre className="whitespace-pre-wrap wrap-break-word">
{jsonSupport(data.oldValue)}
</pre>
</div>
)}
<div className="flex flex-col gap-2">
<span className="font-bold">
{m.logs_page_ui_table_header_new_value()}:
</span>
<pre className="whitespace-pre-wrap wrap-break-word">
{data.newValue ? jsonSupport(data.newValue) : ''}
</pre>
</div>
<div className="flex items-center gap-2">
<span className="font-bold">
{m.logs_page_ui_table_header_create_at()}:
</span>
<span>{formatters.dateTime(new Date(data.createdAt))}</span>
</div>
</div>
</DialogContent>
</Dialog>
);
};

View File

@@ -1,7 +1,7 @@
import { cn } from '@/lib/utils';
import { useAuth } from '../auth/auth-provider';
import { Avatar, AvatarFallback, AvatarImage } from '../ui/avatar';
import RoleRing from './RoleRing';
import RoleRing from './role-ring';
interface AvatarUserProps {
className?: string;
@@ -24,7 +24,7 @@ const AvatarUser = ({ className, textSize = 'md' }: AvatarUserProps) => {
return (
<RoleRing type={session?.user?.role}>
<Avatar className={className}>
<AvatarImage src={imagePath} />
<AvatarImage src={imagePath} title={shortName} />
<AvatarFallback
className={cn(
'bg-orange-400 text-white',

View File

@@ -12,35 +12,22 @@ type RoleProps = {
const RoleBadge = ({ type, className }: RoleProps) => {
// List all valid badge variant keys
const validBadgeVariants: BadgeVariant[] = [
'default',
'secondary',
'destructive',
'outline',
'ghost',
'link',
'admin',
'user',
'member',
'owner',
];
const LABEL_VALUE = {
admin: m.role_tags_admin(),
user: m.role_tags_user(),
member: m.role_tags_member(),
owner: m.role_tags_owner(),
};
// Determine the actual variant to apply.
// If 'type' is a valid variant key, use it. Otherwise, fallback to 'default'.
const displayVariant: BadgeVariant =
type && validBadgeVariants.includes(type as BadgeVariant)
? (type as BadgeVariant)
: 'default';
: 'user';
return (
<Badge variant={displayVariant} className={className}>
{LABEL_VALUE[(type as keyof typeof LABEL_VALUE) || 'default']}
{m.role_tags({ role: type as string })}
</Badge>
);
};

View File

@@ -8,8 +8,8 @@ import { useQueryClient } from '@tanstack/react-query';
import { useRef } from 'react';
import { toast } from 'sonner';
import { useAuth } from '../auth/auth-provider';
import AvatarUser from '../avatar/AvatarUser';
import RoleBadge from '../avatar/RoleBadge';
import AvatarUser from '../avatar/avatar-user';
import RoleBadge from '../avatar/role-badge';
import { Button } from '../ui/button';
import { Card, CardContent, CardHeader, CardTitle } from '../ui/card';
import { Field, FieldError, FieldGroup, FieldLabel } from '../ui/field';

View File

@@ -4,12 +4,11 @@ import {
SidebarFooter,
SidebarHeader,
SidebarMenu,
SidebarMenuButton,
SidebarMenuItem,
} from '@/components/ui/sidebar'
import { Link } from '@tanstack/react-router'
import NavMain from './nav-main'
import NavUser from './nav-user'
} from '@/components/ui/sidebar';
import { Link } from '@tanstack/react-router';
import NavMain from './nav-main';
import NavUser from './nav-user';
const AppSidebar = ({ ...props }: React.ComponentProps<typeof Sidebar>) => {
return (
@@ -17,14 +16,12 @@ const AppSidebar = ({ ...props }: React.ComponentProps<typeof Sidebar>) => {
<SidebarHeader>
<SidebarMenu>
<SidebarMenuItem>
<SidebarMenuButton size="lg">
<h1 className="text-xl font-semibold">
<Link to="/" className="flex items-center gap-2" title="Fuware">
<img src="/logo.svg" alt="Fuware Logo" className="h-8" />
Fuware
</Link>
</h1>
</SidebarMenuButton>
<h1 className="text-xl font-semibold">
<Link to="/" className="flex items-center gap-2" title="Fuware">
<img src="/logo.svg" alt="Fuware Logo" className="h-8" />
Fuware
</Link>
</h1>
</SidebarMenuItem>
</SidebarMenu>
</SidebarHeader>
@@ -35,7 +32,7 @@ const AppSidebar = ({ ...props }: React.ComponentProps<typeof Sidebar>) => {
<NavUser />
</SidebarFooter>
</Sidebar>
)
}
);
};
export default AppSidebar
export default AppSidebar;

View File

@@ -1,10 +1,17 @@
import { m } from '@/paraglide/messages';
import { GaugeIcon, GearIcon, HouseIcon } from '@phosphor-icons/react';
import {
CircuitryIcon,
GaugeIcon,
GearIcon,
HouseIcon,
} from '@phosphor-icons/react';
import { createLink } from '@tanstack/react-router';
import AdminShow from '../auth/AdminShow';
import AuthShow from '../auth/AuthShow';
import {
SidebarGroup,
SidebarGroupContent,
SidebarGroupLabel,
SidebarMenu,
SidebarMenuButton,
SidebarMenuItem,
@@ -12,42 +19,84 @@ import {
const SidebarMenuButtonLink = createLink(SidebarMenuButton);
const NAV_MAIN = [
{
id: '1',
title: 'Basic',
items: [
{
title: m.nav_home(),
path: '/',
icon: HouseIcon,
isAuth: true,
admin: false,
},
{
title: m.nav_dashboard(),
path: '/dashboard',
icon: GaugeIcon,
isAuth: true,
admin: false,
},
],
},
{
id: '2',
title: 'Management',
items: [
{
title: m.nav_log(),
path: '/logs',
icon: CircuitryIcon,
isAuth: false,
admin: true,
},
{
title: m.nav_settings(),
path: '/settings',
icon: GearIcon,
isAuth: false,
admin: true,
},
],
},
];
const NavMain = () => {
return (
<SidebarGroup>
<SidebarMenu>
<SidebarMenuItem>
<SidebarMenuButtonLink
to="/"
className="cursor-pointer"
tooltip={m.nav_home()}
>
<HouseIcon size={24} />
{m.nav_home()}
</SidebarMenuButtonLink>
<AuthShow>
<SidebarMenuButtonLink
to="/dashboard"
className="cursor-pointer"
tooltip={m.nav_dashboard()}
>
<GaugeIcon size={24} />
{m.nav_dashboard()}
</SidebarMenuButtonLink>
<AdminShow>
<SidebarMenuButtonLink
to="/settings"
className="cursor-pointer"
tooltip={m.nav_settings()}
>
<GearIcon size={24} />
{m.nav_settings()}
</SidebarMenuButtonLink>
</AdminShow>
</AuthShow>
</SidebarMenuItem>
</SidebarMenu>
</SidebarGroup>
<>
{NAV_MAIN.map((nav) => (
<SidebarGroup key={nav.id}>
<SidebarGroupLabel>{nav.title}</SidebarGroupLabel>
<SidebarGroupContent>
<SidebarMenu>
{nav.items.map((item) => {
const Icon = item.icon;
const Menu = (
<SidebarMenuItem>
<SidebarMenuButtonLink
to={item.path}
className="cursor-pointer"
tooltip={item.title}
>
<Icon size={24} />
{item.title}
</SidebarMenuButtonLink>
</SidebarMenuItem>
);
return item.isAuth ? (
<AuthShow key={item.path}>{Menu}</AuthShow>
) : item.admin ? (
<AdminShow key={item.path}>{Menu}</AdminShow>
) : (
Menu
);
})}
</SidebarMenu>
</SidebarGroupContent>
</SidebarGroup>
))}
</>
);
};

View File

@@ -11,8 +11,8 @@ import { useQueryClient } from '@tanstack/react-query';
import { createLink, Link, useNavigate } from '@tanstack/react-router';
import { toast } from 'sonner';
import { useAuth } from '../auth/auth-provider';
import AvatarUser from '../avatar/AvatarUser';
import RoleBadge from '../avatar/RoleBadge';
import AvatarUser from '../avatar/avatar-user';
import RoleBadge from '../avatar/role-badge';
import {
DropdownMenu,
DropdownMenuContent,

View File

@@ -18,6 +18,7 @@ const badgeVariants = cva(
'border-border text-foreground [a]:hover:bg-muted [a]:hover:text-muted-foreground bg-input/20 dark:bg-input/30',
ghost:
'hover:bg-muted hover:text-muted-foreground dark:hover:bg-muted/50',
table: 'border-border border-teal-600 text-teal-600',
link: 'text-primary underline-offset-4 hover:underline',
admin: 'bg-cyan-100 text-cyan-600 [a]:hover:bg-cyan-200 ',
user: 'bg-green-100 text-green-600 [a]:hover:bg-green-200',

View File

@@ -0,0 +1,83 @@
import { cva, type VariantProps } from "class-variance-authority"
import { Slot } from "radix-ui"
import { cn } from "@/lib/utils"
import { Separator } from "@/components/ui/separator"
const buttonGroupVariants = cva(
"has-[>[data-slot=button-group]]:gap-2 has-[select[aria-hidden=true]:last-child]:[&>[data-slot=select-trigger]:last-of-type]:rounded-r-md flex w-fit items-stretch [&>*]:focus-visible:z-10 [&>*]:focus-visible:relative [&>[data-slot=select-trigger]:not([class*='w-'])]:w-fit [&>input]:flex-1",
{
variants: {
orientation: {
horizontal:
"[&>[data-slot]:not(:has(~[data-slot]))]:rounded-r-md! [&>*:not(:first-child)]:rounded-l-none [&>*:not(:first-child)]:border-l-0 [&>*:not(:last-child)]:rounded-r-none",
vertical:
"[&>[data-slot]:not(:has(~[data-slot]))]:rounded-b-md! flex-col [&>*:not(:first-child)]:rounded-t-none [&>*:not(:first-child)]:border-t-0 [&>*:not(:last-child)]:rounded-b-none",
},
},
defaultVariants: {
orientation: "horizontal",
},
}
)
function ButtonGroup({
className,
orientation,
...props
}: React.ComponentProps<"div"> & VariantProps<typeof buttonGroupVariants>) {
return (
<div
role="group"
data-slot="button-group"
data-orientation={orientation}
className={cn(buttonGroupVariants({ orientation }), className)}
{...props}
/>
)
}
function ButtonGroupText({
className,
asChild = false,
...props
}: React.ComponentProps<"div"> & {
asChild?: boolean
}) {
const Comp = asChild ? Slot.Root : "div"
return (
<Comp
className={cn(
"bg-muted gap-2 rounded-md border px-2.5 text-xs/relaxed font-medium [&_svg:not([class*='size-'])]:size-4 flex items-center [&_svg]:pointer-events-none",
className
)}
{...props}
/>
)
}
function ButtonGroupSeparator({
className,
orientation = "vertical",
...props
}: React.ComponentProps<typeof Separator>) {
return (
<Separator
data-slot="button-group-separator"
orientation={orientation}
className={cn(
"bg-input relative self-stretch data-[orientation=horizontal]:mx-px data-[orientation=horizontal]:w-auto data-[orientation=vertical]:my-px data-[orientation=vertical]:h-auto",
className
)}
{...props}
/>
)
}
export {
ButtonGroup,
ButtonGroupSeparator,
ButtonGroupText,
buttonGroupVariants,
}

View File

@@ -1,11 +1,11 @@
import { cva, type VariantProps } from 'class-variance-authority'
import { Slot } from 'radix-ui'
import * as React from 'react'
import { cva, type VariantProps } from 'class-variance-authority';
import { Slot } from 'radix-ui';
import * as React from 'react';
import { cn } from '@/lib/utils'
import { cn } from '@/lib/utils';
const buttonVariants = cva(
"focus-visible:border-ring focus-visible:ring-ring/30 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:aria-invalid:border-destructive/50 rounded-md border border-transparent bg-clip-padding text-xs/relaxed font-medium focus-visible:ring-[2px] aria-invalid:ring-[2px] [&_svg:not([class*='size-'])]:size-4 inline-flex items-center justify-center whitespace-nowrap transition-all disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none shrink-0 [&_svg]:shrink-0 outline-none group/button select-none",
"focus-visible:border-ring focus-visible:ring-ring/30 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:aria-invalid:border-destructive/50 rounded-md border border-transparent bg-clip-padding text-xs/relaxed font-medium focus-visible:ring-[2px] aria-invalid:ring-[2px] [&_svg:not([class*='size-'])]:size-4 inline-flex items-center justify-center whitespace-nowrap transition-all disabled:pointer-events-none disabled:opacity-50 disabled:data-[active=true]:opacity-100 disabled:data-[active=true]:bg-teal-400 disabled:data-[active=true]:border-teal-400 [&_svg]:pointer-events-none shrink-0 [&_svg]:shrink-0 outline-none group/button select-none",
{
variants: {
variant: {
@@ -37,7 +37,7 @@ const buttonVariants = cva(
size: 'default',
},
},
)
);
function Button({
className,
@@ -47,9 +47,9 @@ function Button({
...props
}: React.ComponentProps<'button'> &
VariantProps<typeof buttonVariants> & {
asChild?: boolean
asChild?: boolean;
}) {
const Comp = asChild ? Slot.Root : 'button'
const Comp = asChild ? Slot.Root : 'button';
return (
<Comp
@@ -59,7 +59,7 @@ function Button({
className={cn(buttonVariants({ variant, size, className }))}
{...props}
/>
)
);
}
export { Button, buttonVariants }
export { Button, buttonVariants };

View File

@@ -0,0 +1,153 @@
import * as React from "react"
import { Dialog as DialogPrimitive } from "radix-ui"
import { cn } from "@/lib/utils"
import { Button } from "@/components/ui/button"
import { XIcon } from "@phosphor-icons/react"
function Dialog({
...props
}: React.ComponentProps<typeof DialogPrimitive.Root>) {
return <DialogPrimitive.Root data-slot="dialog" {...props} />
}
function DialogTrigger({
...props
}: React.ComponentProps<typeof DialogPrimitive.Trigger>) {
return <DialogPrimitive.Trigger data-slot="dialog-trigger" {...props} />
}
function DialogPortal({
...props
}: React.ComponentProps<typeof DialogPrimitive.Portal>) {
return <DialogPrimitive.Portal data-slot="dialog-portal" {...props} />
}
function DialogClose({
...props
}: React.ComponentProps<typeof DialogPrimitive.Close>) {
return <DialogPrimitive.Close data-slot="dialog-close" {...props} />
}
function DialogOverlay({
className,
...props
}: React.ComponentProps<typeof DialogPrimitive.Overlay>) {
return (
<DialogPrimitive.Overlay
data-slot="dialog-overlay"
className={cn("data-open:animate-in data-closed:animate-out data-closed:fade-out-0 data-open:fade-in-0 bg-black/80 duration-100 supports-backdrop-filter:backdrop-blur-xs fixed inset-0 isolate z-50", className)}
{...props}
/>
)
}
function DialogContent({
className,
children,
showCloseButton = true,
...props
}: React.ComponentProps<typeof DialogPrimitive.Content> & {
showCloseButton?: boolean
}) {
return (
<DialogPortal>
<DialogOverlay />
<DialogPrimitive.Content
data-slot="dialog-content"
className={cn(
"bg-background data-open:animate-in data-closed:animate-out data-closed:fade-out-0 data-open:fade-in-0 data-closed:zoom-out-95 data-open:zoom-in-95 ring-foreground/10 grid max-w-[calc(100%-2rem)] gap-4 rounded-xl p-4 text-xs/relaxed ring-1 duration-100 sm:max-w-sm fixed top-1/2 left-1/2 z-50 w-full -translate-x-1/2 -translate-y-1/2",
className
)}
{...props}
>
{children}
{showCloseButton && (
<DialogPrimitive.Close data-slot="dialog-close" asChild>
<Button variant="ghost" className="absolute top-2 right-2" size="icon-sm">
<XIcon
/>
<span className="sr-only">Close</span>
</Button>
</DialogPrimitive.Close>
)}
</DialogPrimitive.Content>
</DialogPortal>
)
}
function DialogHeader({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="dialog-header"
className={cn("gap-1 flex flex-col", className)}
{...props}
/>
)
}
function DialogFooter({
className,
showCloseButton = false,
children,
...props
}: React.ComponentProps<"div"> & {
showCloseButton?: boolean
}) {
return (
<div
data-slot="dialog-footer"
className={cn(
"gap-2 flex flex-col-reverse gap-2 sm:flex-row sm:justify-end",
className
)}
{...props}
>
{children}
{showCloseButton && (
<DialogPrimitive.Close asChild>
<Button variant="outline">Close</Button>
</DialogPrimitive.Close>
)}
</div>
)
}
function DialogTitle({
className,
...props
}: React.ComponentProps<typeof DialogPrimitive.Title>) {
return (
<DialogPrimitive.Title
data-slot="dialog-title"
className={cn("text-sm font-medium", className)}
{...props}
/>
)
}
function DialogDescription({
className,
...props
}: React.ComponentProps<typeof DialogPrimitive.Description>) {
return (
<DialogPrimitive.Description
data-slot="dialog-description"
className={cn("text-muted-foreground *:[a]:hover:text-foreground text-xs/relaxed *:[a]:underline *:[a]:underline-offset-3", className)}
{...props}
/>
)
}
export {
Dialog,
DialogClose,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogOverlay,
DialogPortal,
DialogTitle,
DialogTrigger,
}

View File

@@ -0,0 +1,145 @@
import * as React from "react"
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@/lib/utils"
import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
import { Textarea } from "@/components/ui/textarea"
function InputGroup({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="input-group"
role="group"
className={cn(
"border-input bg-input/20 dark:bg-input/30 has-[[data-slot=input-group-control]:focus-visible]:border-ring has-[[data-slot=input-group-control]:focus-visible]:ring-ring/30 has-[[data-slot][aria-invalid=true]]:ring-destructive/20 has-[[data-slot][aria-invalid=true]]:border-destructive dark:has-[[data-slot][aria-invalid=true]]:ring-destructive/40 h-7 rounded-md border transition-colors has-data-[align=block-end]:rounded-md has-data-[align=block-start]:rounded-md has-[[data-slot=input-group-control]:focus-visible]:ring-[2px] has-[[data-slot][aria-invalid=true]]:ring-[2px] has-[textarea]:rounded-md has-[>[data-align=block-end]]:h-auto has-[>[data-align=block-end]]:flex-col has-[>[data-align=block-start]]:h-auto has-[>[data-align=block-start]]:flex-col has-[>[data-align=block-end]]:[&>input]:pt-3 has-[>[data-align=block-start]]:[&>input]:pb-3 has-[>[data-align=inline-end]]:[&>input]:pr-1.5 has-[>[data-align=inline-start]]:[&>input]:pl-1.5 [[data-slot=combobox-content]_&]:focus-within:border-inherit [[data-slot=combobox-content]_&]:focus-within:ring-0 group/input-group relative flex w-full min-w-0 items-center outline-none has-[>textarea]:h-auto",
className
)}
{...props}
/>
)
}
const inputGroupAddonVariants = cva(
"text-muted-foreground **:data-[slot=kbd]:bg-muted-foreground/10 h-auto gap-1 py-2 text-xs/relaxed font-medium group-data-[disabled=true]/input-group:opacity-50 **:data-[slot=kbd]:rounded-[calc(var(--radius-sm)-2px)] **:data-[slot=kbd]:px-1 **:data-[slot=kbd]:text-[0.625rem] [&>svg:not([class*='size-'])]:size-3.5 flex cursor-text items-center justify-center select-none",
{
variants: {
align: {
"inline-start": "pl-2 has-[>button]:ml-[-0.275rem] has-[>kbd]:ml-[-0.275rem] order-first",
"inline-end": "pr-2 has-[>button]:mr-[-0.275rem] has-[>kbd]:mr-[-0.275rem] order-last",
"block-start":
"px-2 pt-2 group-has-[>input]/input-group:pt-2 [.border-b]:pb-2 order-first w-full justify-start",
"block-end":
"px-2 pb-2 group-has-[>input]/input-group:pb-2 [.border-t]:pt-2 order-last w-full justify-start",
},
},
defaultVariants: {
align: "inline-start",
},
}
)
function InputGroupAddon({
className,
align = "inline-start",
...props
}: React.ComponentProps<"div"> & VariantProps<typeof inputGroupAddonVariants>) {
return (
<div
role="group"
data-slot="input-group-addon"
data-align={align}
className={cn(inputGroupAddonVariants({ align }), className)}
onClick={(e) => {
if ((e.target as HTMLElement).closest("button")) {
return
}
e.currentTarget.parentElement?.querySelector("input")?.focus()
}}
{...props}
/>
)
}
const inputGroupButtonVariants = cva(
"gap-2 rounded-md text-xs/relaxed shadow-none flex items-center",
{
variants: {
size: {
xs: "h-5 gap-1 rounded-[calc(var(--radius-sm)-2px)] px-1 [&>svg:not([class*='size-'])]:size-3",
sm: "",
"icon-xs": "size-6 p-0 has-[>svg]:p-0",
"icon-sm": "size-8 p-0 has-[>svg]:p-0",
},
},
defaultVariants: {
size: "xs",
},
}
)
function InputGroupButton({
className,
type = "button",
variant = "ghost",
size = "xs",
...props
}: Omit<React.ComponentProps<typeof Button>, "size"> &
VariantProps<typeof inputGroupButtonVariants>) {
return (
<Button
type={type}
data-size={size}
variant={variant}
className={cn(inputGroupButtonVariants({ size }), className)}
{...props}
/>
)
}
function InputGroupText({ className, ...props }: React.ComponentProps<"span">) {
return (
<span
className={cn(
"text-muted-foreground gap-2 text-xs/relaxed [&_svg:not([class*='size-'])]:size-4 flex items-center [&_svg]:pointer-events-none",
className
)}
{...props}
/>
)
}
function InputGroupInput({
className,
...props
}: React.ComponentProps<"input">) {
return (
<Input
data-slot="input-group-control"
className={cn("rounded-none border-0 bg-transparent shadow-none ring-0 focus-visible:ring-0 aria-invalid:ring-0 dark:bg-transparent flex-1", className)}
{...props}
/>
)
}
function InputGroupTextarea({
className,
...props
}: React.ComponentProps<"textarea">) {
return (
<Textarea
data-slot="input-group-control"
className={cn("rounded-none border-0 bg-transparent py-2 shadow-none ring-0 focus-visible:ring-0 aria-invalid:ring-0 dark:bg-transparent flex-1 resize-none", className)}
{...props}
/>
)
}
export {
InputGroup,
InputGroupAddon,
InputGroupButton,
InputGroupText,
InputGroupInput,
InputGroupTextarea,
}

View File

@@ -1,15 +1,15 @@
import * as React from 'react'
import * as React from "react"
import { cn } from '@/lib/utils'
import { cn } from "@/lib/utils"
function Input({ className, type, ...props }: React.ComponentProps<'input'>) {
function Input({ className, type, ...props }: React.ComponentProps<"input">) {
return (
<input
type={type}
data-slot="input"
className={cn(
'bg-input/20 dark:bg-input/30 border-input focus-visible:border-ring focus-visible:ring-ring/30 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:aria-invalid:border-destructive/50 h-7 rounded-md border px-2 py-0.5 text-sm transition-colors file:h-6 file:text-xs/relaxed file:font-medium focus-visible:ring-[2px] aria-invalid:ring-[2px] md:text-xs/relaxed file:text-foreground placeholder:text-muted-foreground w-full min-w-0 outline-none file:inline-flex file:border-0 file:bg-transparent disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50 disabled:border-none disabled:p-0 disabled:bg-transparent disabled:text-black',
className,
"bg-input/20 dark:bg-input/30 border-input focus-visible:border-ring focus-visible:ring-ring/30 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:aria-invalid:border-destructive/50 h-7 rounded-md border px-2 py-0.5 text-sm transition-colors file:h-6 file:text-xs/relaxed file:font-medium focus-visible:ring-[2px] aria-invalid:ring-[2px] md:text-xs/relaxed file:text-foreground placeholder:text-muted-foreground w-full min-w-0 outline-none file:inline-flex file:border-0 file:bg-transparent disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50",
className
)}
{...props}
/>

View File

@@ -259,6 +259,7 @@ function SidebarTrigger({
return (
<Button
type="button"
data-sidebar="trigger"
data-slot="sidebar-trigger"
variant="ghost"

View File

@@ -0,0 +1,99 @@
import * as React from "react"
import { cn } from "@/lib/utils"
function Table({ className, ...props }: React.ComponentProps<"table">) {
return (
<div data-slot="table-container" className="relative w-full overflow-x-auto">
<table
data-slot="table"
className={cn("w-full caption-bottom text-xs", className)}
{...props}
/>
</div>
)
}
function TableHeader({ className, ...props }: React.ComponentProps<"thead">) {
return (
<thead
data-slot="table-header"
className={cn("[&_tr]:border-b", className)}
{...props}
/>
)
}
function TableBody({ className, ...props }: React.ComponentProps<"tbody">) {
return (
<tbody
data-slot="table-body"
className={cn("[&_tr:last-child]:border-0", className)}
{...props}
/>
)
}
function TableFooter({ className, ...props }: React.ComponentProps<"tfoot">) {
return (
<tfoot
data-slot="table-footer"
className={cn("bg-muted/50 border-t font-medium [&>tr]:last:border-b-0", className)}
{...props}
/>
)
}
function TableRow({ className, ...props }: React.ComponentProps<"tr">) {
return (
<tr
data-slot="table-row"
className={cn("hover:bg-muted/50 data-[state=selected]:bg-muted border-b transition-colors", className)}
{...props}
/>
)
}
function TableHead({ className, ...props }: React.ComponentProps<"th">) {
return (
<th
data-slot="table-head"
className={cn("text-foreground h-10 px-2 text-left align-middle font-medium whitespace-nowrap [&:has([role=checkbox])]:pr-0", className)}
{...props}
/>
)
}
function TableCell({ className, ...props }: React.ComponentProps<"td">) {
return (
<td
data-slot="table-cell"
className={cn("p-2 align-middle whitespace-nowrap [&:has([role=checkbox])]:pr-0", className)}
{...props}
/>
)
}
function TableCaption({
className,
...props
}: React.ComponentProps<"caption">) {
return (
<caption
data-slot="table-caption"
className={cn("text-muted-foreground mt-4 text-xs", className)}
{...props}
/>
)
}
export {
Table,
TableHeader,
TableBody,
TableFooter,
TableHead,
TableRow,
TableCell,
TableCaption,
}

View File

@@ -7,12 +7,12 @@ function Textarea({ className, ...props }: React.ComponentProps<"textarea">) {
<textarea
data-slot="textarea"
className={cn(
'border-input bg-input/20 dark:bg-input/30 focus-visible:border-ring focus-visible:ring-ring/30 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:aria-invalid:border-destructive/50 resize-none rounded-md border px-2 py-2 text-sm transition-colors focus-visible:ring-2 aria-invalid:ring-2 md:text-xs/relaxed placeholder:text-muted-foreground flex field-sizing-content min-h-16 w-full outline-none disabled:cursor-not-allowed disabled:opacity-50',
className,
"border-input bg-input/20 dark:bg-input/30 focus-visible:border-ring focus-visible:ring-ring/30 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:aria-invalid:border-destructive/50 resize-none rounded-md border px-2 py-2 text-sm transition-colors focus-visible:ring-[2px] aria-invalid:ring-[2px] md:text-xs/relaxed placeholder:text-muted-foreground flex field-sizing-content min-h-16 w-full outline-none disabled:cursor-not-allowed disabled:opacity-50",
className
)}
{...props}
/>
);
)
}
export { Textarea }

View File

@@ -52,10 +52,10 @@ function TooltipContent({
{...props}
>
{children}
<TooltipPrimitive.Arrow className="size-2.5 translate-y-[calc(-50%_-_2px)] rotate-45 rounded-[2px] bg-foreground fill-foreground z-50" />
<TooltipPrimitive.Arrow className="size-2.5 translate-y-[calc(-50%-2px)] rotate-45 rounded-[2px] bg-foreground fill-foreground z-50" />
</TooltipPrimitive.Content>
</TooltipPrimitive.Portal>
)
);
}
export { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger }