Merge pull request 'added data table, formatter' (#7) from feature/audit-log into main

Reviewed-on: #7
This commit was merged in pull request #7.
This commit is contained in:
2026-01-14 02:40:59 +00:00
45 changed files with 1519 additions and 149 deletions

View File

@@ -1,11 +1,29 @@
{
"$schema": "https://inlang.com/schema/inlang-message-format",
"common_page_show": [
{
"declarations": ["input count", "local countPlural = count: plural"],
"selectors": ["countPlural"],
"match": {
"countPlural=one": "Hiện chỉ có 1 dữ liệu",
"countPlural=other": "Hiển thị {start} tới {end} của {count} dữ liệu"
}
}
],
"common_per_page": "Show",
"common_select_page_size": "Select page size",
"common_no_list": "Hiện tại chưa có dữ liệu nào!",
"common_is_required": "{field} is required.",
"role_tags_admin": "Administrator",
"role_tags_user": "User",
"role_tags_member": "Member",
"role_tags_owner": "Owner",
"role_tags": [
{
"match": {
"role=admin": "Administrator",
"role=user": "User",
"role=member": "Member",
"role=owner": "Owner"
}
}
],
"ui_login_btn": "Sign in",
"ui_logout_btn": "Sign out",
"ui_cancel_btn": "Cancel",
@@ -15,6 +33,10 @@
"ui_view_btn": "View",
"ui_save_btn": "Save",
"ui_update_btn": "Update",
"ui_delete_btn": "Delete",
"ui_ban_btn": "Lock",
"ui_unban_btn": "Unlock",
"ui_dialog_view_title": "View {type} details",
"ui_view_all_notifications": "View All Notifications",
"ui_label_notifications": "Notifications",
"ui_change_password_btn": "Change password",
@@ -24,7 +46,7 @@
"nav_add_new": "Add new",
"nav_edit": "Edit",
"nav_change_password": "Change password",
"nav_log": "Audit log",
"nav_log": "Logs",
"nav_roles": "Vai trò & quyền hạn",
"nav_box": "Box",
"nav_account": "Account",
@@ -57,6 +79,25 @@
"settings_ui_title": "Settings",
"settings_messages_update_success": "Updated settings successfully!",
"settings_messages_update_fail": "Update fail!",
"logs_page_ui_title": "Logs",
"logs_page_ui_table_header_username": "User name",
"logs_page_ui_table_header_table": "Table",
"logs_page_ui_table_header_action": "Action",
"logs_page_ui_table_header_create_at": "Create date",
"logs_page_ui_badge_action": [
{
"match": {
"action=create": "Create",
"action=update": "Update",
"action=delete": "Delete",
"action=ban": "Lock",
"action=unban": "Unlock",
"action=sign_in": "Sign in",
"action=sign_out": "Sign out",
"action=*": "Other"
}
}
],
"backend_INVALID_EMAIL_OR_PASSWORD": "Email or password incorrect!",
"backend_INVALID_PASSWORD": "Password incorrect!"
}

View File

@@ -1,11 +1,29 @@
{
"$schema": "https://inlang.com/schema/inlang-message-format",
"common_page_show": [
{
"declarations": ["input count", "local countPlural = count: plural"],
"selectors": ["countPlural"],
"match": {
"countPlural=one": "Hiện chỉ có 1 dữ liệu",
"countPlural=other": "Hiển thị {start} tới {end} của {count} dữ liệu"
}
}
],
"common_per_page": "Hiển thị",
"common_select_page_size": "Chọn số lượng",
"common_no_list": "Hiện tại chưa có dữ liệu nào!",
"common_is_required": "{field} là bắt buộc.",
"role_tags_admin": "Quản lý",
"role_tags_user": "Người dùng",
"role_tags_member": "Thành viên",
"role_tags_owner": "Người sở hữu",
"role_tags": [
{
"match": {
"role=admin": "Quản lý",
"role=user": "Người dùng",
"role=member": "Thành viên",
"role=owner": "Người sở hữu"
}
}
],
"ui_login_btn": "Đăng nhập",
"ui_logout_btn": "Đăng xuất",
"ui_cancel_btn": "Hủy",
@@ -15,6 +33,10 @@
"ui_view_btn": "Xem",
"ui_save_btn": "Lưu",
"ui_update_btn": "Cập nhật",
"ui_delete_btn": "Xóa",
"ui_ban_btn": "Khóa",
"ui_unban_btn": "Mở khóa",
"ui_dialog_view_title": "Xem chi tiết {type}",
"ui_view_all_notifications": "Xem tất cả thông báo",
"ui_label_notifications": "Thông báo",
"ui_change_password_btn": "Đổi mật khẩu",
@@ -57,6 +79,27 @@
"settings_ui_title": "Cài đặt",
"settings_messages_update_success": "Cập nhật cài đặt thành công!",
"settings_messages_update_fail": "Cập nhật cài đặt thất bại!",
"logs_page_ui_title": "Lịch sử",
"logs_page_ui_table_header_username": "Người dùng",
"logs_page_ui_table_header_table": "Bảng",
"logs_page_ui_table_header_action": "Tương tác",
"logs_page_ui_table_header_create_at": "Ngày tạo",
"logs_page_ui_table_header_old_value": "Giá trị cũ",
"logs_page_ui_table_header_new_value": "Giá trị mới",
"logs_page_ui_badge_action": [
{
"match": {
"action=create": "Tạo",
"action=update": "Cập nhật",
"action=delete": "Xóa",
"action=ban": "Khóa",
"action=unban": "Mở khóa",
"action=sign_in": "Đăng nhập",
"action=sign_out": "Đăng xuất",
"action=*": "Khác"
}
}
],
"backend_INVALID_EMAIL_OR_PASSWORD": "Email hoặc mật khẩu không đúng!",
"backend_INVALID_PASSWORD": "Mật khẩu hiện tại không đúng!"
}

View File

@@ -32,6 +32,7 @@
"@tanstack/react-router-devtools": "^1.132.0",
"@tanstack/react-router-ssr-query": "^1.131.7",
"@tanstack/react-start": "^1.132.0",
"@tanstack/react-table": "^8.21.3",
"@tanstack/router-plugin": "^1.132.0",
"better-auth": "^1.4.10",
"class-variance-authority": "^0.7.1",

22
pnpm-lock.yaml generated
View File

@@ -50,6 +50,9 @@ importers:
'@tanstack/react-start':
specifier: ^1.132.0
version: 1.146.3(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(vite@7.3.1(@types/node@22.19.3)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.21.0))
'@tanstack/react-table':
specifier: ^8.21.3
version: 8.21.3(react-dom@19.2.3(react@19.2.3))(react@19.2.3)
'@tanstack/router-plugin':
specifier: ^1.132.0
version: 1.146.3(@tanstack/react-router@1.146.2(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(vite@7.3.1(@types/node@22.19.3)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.21.0))
@@ -2013,6 +2016,13 @@ packages:
react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0
react-dom: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0
'@tanstack/react-table@8.21.3':
resolution: {integrity: sha512-5nNMTSETP4ykGegmVkhjcS8tTLW6Vl4axfEGQN3v0zdHYbK4UfoqfPChclTrJ4EoK9QynqAu9oUf8VEmrpZ5Ww==}
engines: {node: '>=12'}
peerDependencies:
react: '>=16.8'
react-dom: '>=16.8'
'@tanstack/router-core@1.146.2':
resolution: {integrity: sha512-MmTDiT6fpe+WBWYAuhp8oyzULBJX4oblm1kCqHDngf9mK3qcnNm5nkKk4d3Fk80QZmHS4DcRNFaFHKbLUVlZog==}
engines: {node: '>=12'}
@@ -2092,6 +2102,10 @@ packages:
'@tanstack/store@0.8.0':
resolution: {integrity: sha512-Om+BO0YfMZe//X2z0uLF2j+75nQga6TpTJgLJQBiq85aOyZNIhkCgleNcud2KQg4k4v9Y9l+Uhru3qWMPGTOzQ==}
'@tanstack/table-core@8.21.3':
resolution: {integrity: sha512-ldZXEhOBb8Is7xLs01fR3YEc3DERiz5silj8tnGkFZytt1abEvl/GhUmCE0PMLaMPTa3Jk4HbKmRlHmu+gCftg==}
engines: {node: '>=12'}
'@tanstack/virtual-file-routes@1.145.4':
resolution: {integrity: sha512-CI75JrfqSluhdGwLssgVeQBaCphgfkMQpi8MCY3UJX1hoGzXa8kHYJcUuIFMOLs1q7zqHy++EVVtMK03osR5wQ==}
engines: {node: '>=12'}
@@ -6826,6 +6840,12 @@ snapshots:
react-dom: 19.2.3(react@19.2.3)
use-sync-external-store: 1.6.0(react@19.2.3)
'@tanstack/react-table@8.21.3(react-dom@19.2.3(react@19.2.3))(react@19.2.3)':
dependencies:
'@tanstack/table-core': 8.21.3
react: 19.2.3
react-dom: 19.2.3(react@19.2.3)
'@tanstack/router-core@1.146.2':
dependencies:
'@tanstack/history': 1.145.7
@@ -6960,6 +6980,8 @@ snapshots:
'@tanstack/store@0.8.0': {}
'@tanstack/table-core@8.21.3': {}
'@tanstack/virtual-file-routes@1.145.4': {}
'@testing-library/dom@10.4.1':

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 }

View File

@@ -0,0 +1,17 @@
import { useEffect, useState } from 'react';
function useDebounced<T>(value: T, delay: number): T {
const [debouncedValue, setDebouncedValue] = useState(value);
useEffect(() => {
const handler = setTimeout(() => {
setDebouncedValue(value);
}, delay);
return () => clearTimeout(handler); // clear timeout nếu value thay đổi trước khi delay hết
}, [value, delay]);
return debouncedValue;
}
export default useDebounced;

View File

@@ -8,7 +8,7 @@ import {
owner,
} from '@/lib/auth/organization-permissions';
import { ac, admin, user } from '@/lib/auth/permissions';
import { createAuditLog } from '@/service/audit.api';
import { createAuditLog } from '@/service/repository';
import { betterAuth } from 'better-auth';
import { prismaAdapter } from 'better-auth/adapters/prisma';
import { admin as adminPlugin, organization } from 'better-auth/plugins';

View File

@@ -16,6 +16,7 @@ import { Route as authSignInRouteImport } from './routes/(auth)/sign-in'
import { Route as appauthRouteRouteImport } from './routes/(app)/(auth)/route'
import { Route as ApiAuthSplatRouteImport } from './routes/api.auth.$'
import { Route as appauthSettingsRouteImport } from './routes/(app)/(auth)/settings'
import { Route as appauthLogsRouteImport } from './routes/(app)/(auth)/logs'
import { Route as appauthDashboardRouteImport } from './routes/(app)/(auth)/dashboard'
import { Route as appauthAccountRouteRouteImport } from './routes/(app)/(auth)/account/route'
import { Route as appauthAccountIndexRouteImport } from './routes/(app)/(auth)/account/index'
@@ -56,6 +57,11 @@ const appauthSettingsRoute = appauthSettingsRouteImport.update({
path: '/settings',
getParentRoute: () => appauthRouteRoute,
} as any)
const appauthLogsRoute = appauthLogsRouteImport.update({
id: '/logs',
path: '/logs',
getParentRoute: () => appauthRouteRoute,
} as any)
const appauthDashboardRoute = appauthDashboardRouteImport.update({
id: '/dashboard',
path: '/dashboard',
@@ -94,6 +100,7 @@ export interface FileRoutesByFullPath {
'/': typeof appIndexRoute
'/account': typeof appauthAccountRouteRouteWithChildren
'/dashboard': typeof appauthDashboardRoute
'/logs': typeof appauthLogsRoute
'/settings': typeof appauthSettingsRoute
'/api/auth/$': typeof ApiAuthSplatRoute
'/account/change-password': typeof appauthAccountChangePasswordRoute
@@ -106,6 +113,7 @@ export interface FileRoutesByTo {
'/sign-up': typeof authSignUpRoute
'/': typeof appIndexRoute
'/dashboard': typeof appauthDashboardRoute
'/logs': typeof appauthLogsRoute
'/settings': typeof appauthSettingsRoute
'/api/auth/$': typeof ApiAuthSplatRoute
'/account/change-password': typeof appauthAccountChangePasswordRoute
@@ -122,6 +130,7 @@ export interface FileRoutesById {
'/(app)/': typeof appIndexRoute
'/(app)/(auth)/account': typeof appauthAccountRouteRouteWithChildren
'/(app)/(auth)/dashboard': typeof appauthDashboardRoute
'/(app)/(auth)/logs': typeof appauthLogsRoute
'/(app)/(auth)/settings': typeof appauthSettingsRoute
'/api/auth/$': typeof ApiAuthSplatRoute
'/(app)/(auth)/account/change-password': typeof appauthAccountChangePasswordRoute
@@ -137,6 +146,7 @@ export interface FileRouteTypes {
| '/'
| '/account'
| '/dashboard'
| '/logs'
| '/settings'
| '/api/auth/$'
| '/account/change-password'
@@ -149,6 +159,7 @@ export interface FileRouteTypes {
| '/sign-up'
| '/'
| '/dashboard'
| '/logs'
| '/settings'
| '/api/auth/$'
| '/account/change-password'
@@ -164,6 +175,7 @@ export interface FileRouteTypes {
| '/(app)/'
| '/(app)/(auth)/account'
| '/(app)/(auth)/dashboard'
| '/(app)/(auth)/logs'
| '/(app)/(auth)/settings'
| '/api/auth/$'
| '/(app)/(auth)/account/change-password'
@@ -230,6 +242,13 @@ declare module '@tanstack/react-router' {
preLoaderRoute: typeof appauthSettingsRouteImport
parentRoute: typeof appauthRouteRoute
}
'/(app)/(auth)/logs': {
id: '/(app)/(auth)/logs'
path: '/logs'
fullPath: '/logs'
preLoaderRoute: typeof appauthLogsRouteImport
parentRoute: typeof appauthRouteRoute
}
'/(app)/(auth)/dashboard': {
id: '/(app)/(auth)/dashboard'
path: '/dashboard'
@@ -295,12 +314,14 @@ const appauthAccountRouteRouteWithChildren =
interface appauthRouteRouteChildren {
appauthAccountRouteRoute: typeof appauthAccountRouteRouteWithChildren
appauthDashboardRoute: typeof appauthDashboardRoute
appauthLogsRoute: typeof appauthLogsRoute
appauthSettingsRoute: typeof appauthSettingsRoute
}
const appauthRouteRouteChildren: appauthRouteRouteChildren = {
appauthAccountRouteRoute: appauthAccountRouteRouteWithChildren,
appauthDashboardRoute: appauthDashboardRoute,
appauthLogsRoute: appauthLogsRoute,
appauthSettingsRoute: appauthSettingsRoute,
}

View File

@@ -3,7 +3,7 @@ import { setupRouterSsrQueryIntegration } from '@tanstack/react-router-ssr-query
import * as TanstackQuery from './integrations/tanstack-query/root-provider'
// Import the generated route tree
import { BreadcrumbValue } from './components/sidebar/RouterBreadcrumb'
import { BreadcrumbValue } from './components/sidebar/router-breadcrumb';
import { routeTree } from './routeTree.gen'
declare module '@tanstack/react-router' {

View File

@@ -7,5 +7,11 @@ export const Route = createFileRoute('/(app)/(auth)/dashboard')({
});
function RouteComponent() {
return <div>Hello "dashboard"!</div>;
return (
<div className="@container/main flex flex-1 flex-col gap-2 p-4">
<div className="*:data-[slot=card]:from-primary/5 *:data-[slot=card]:to-card dark:*:data-[slot=card]:bg-card *:data-[slot=card]:bg-linear-to-br *:data-[slot=card]:shadow-xs grid grid-cols-1 @xl/main:grid-cols-2 @5xl/main:grid-cols-3 gap-4">
Hello Dashboard!
</div>
</div>
);
}

View File

@@ -0,0 +1,110 @@
import { logColumns } from '@/components/audit/audit-columns';
import DataTable from '@/components/DataTable';
import { Button } from '@/components/ui/button';
import { Card, CardHeader, CardTitle } from '@/components/ui/card';
import {
InputGroup,
InputGroupAddon,
InputGroupInput,
} from '@/components/ui/input-group';
import { Skeleton } from '@/components/ui/skeleton';
import useDebounced from '@/hooks/use-debounced';
import { m } from '@/paraglide/messages';
import { auditQueries } from '@/service/queries';
import {
CircuitryIcon,
MagnifyingGlassIcon,
XIcon,
} from '@phosphor-icons/react';
import { useQuery } from '@tanstack/react-query';
import { createFileRoute } from '@tanstack/react-router';
import { useState } from 'react';
export const Route = createFileRoute('/(app)/(auth)/logs')({
component: RouteComponent,
staticData: { breadcrumb: () => m.nav_log() },
});
function RouteComponent() {
const [page, setPage] = useState(1);
const [pageLimit, setPageLimit] = useState(10);
const [searchKeyword, setSearchKeyword] = useState('');
const debouncedSearch = useDebounced(searchKeyword, 500);
const { data, isLoading } = useQuery(
auditQueries.list({
page: page,
limit: pageLimit,
keyword: debouncedSearch,
}),
);
const onSearchChange = (e: React.ChangeEvent<HTMLInputElement>) => {
setSearchKeyword(e.target.value);
setPage(1);
};
const onClearSearch = () => {
setSearchKeyword('');
};
if (isLoading) {
return (
<div className="@container/main flex flex-1 flex-col gap-2 p-4">
<div className="flex flex-col gap-4">
<Skeleton className="h-20 w-full" />
<Skeleton className="h-130 w-full" />
</div>
</div>
);
}
return (
<div className="@container/main flex flex-1 flex-col gap-2 p-4">
<div className="*:data-[slot=card]:from-primary/5 *:data-[slot=card]:to-card dark:*:data-[slot=card]:bg-card *:data-[slot=card]:bg-linear-to-br *:data-[slot=card]:shadow-xs flex flex-col gap-4">
<Card>
<CardHeader>
<CardTitle className="text-xl flex items-center gap-2">
<CircuitryIcon size={24} />
{m.logs_page_ui_title()}
</CardTitle>
</CardHeader>
</Card>
<div className="flex">
<InputGroup className="w-70">
<InputGroupInput
id="keywords"
placeholder="Search...."
value={searchKeyword}
onChange={onSearchChange}
/>
<InputGroupAddon>
<MagnifyingGlassIcon />
</InputGroupAddon>
<InputGroupAddon align="inline-end">
{searchKeyword !== '' && (
<Button
variant="ghost"
size="icon-sm"
className="rounded-full"
onClick={onClearSearch}
>
<XIcon />
</Button>
)}
</InputGroupAddon>
</InputGroup>
</div>
{data && (
<DataTable
data={data.result || []}
columns={logColumns}
page={page}
setPage={setPage}
limit={pageLimit}
setLimit={setPageLimit}
pagination={data.pagination}
/>
)}
</div>
</div>
);
}

View File

@@ -1,11 +1,11 @@
import { createFileRoute, Outlet } from '@tanstack/react-router';
import { createFileRoute, Outlet, redirect } from '@tanstack/react-router';
export const Route = createFileRoute('/(app)/(auth)')({
// beforeLoad: async ({ context }) => {
// if (!context.userSession) {
// throw redirect({ to: '/sign-in' });
// }
// },
beforeLoad: async ({ context }) => {
if (!context.session) {
throw redirect({ to: '/sign-in' });
}
},
component: RouteComponent,
});

View File

@@ -7,5 +7,11 @@ export const Route = createFileRoute('/(app)/')({
});
function App() {
return <div className="min-h-screen bg-linear-to-b ">Home</div>;
return (
<div className="@container/main flex flex-1 flex-col gap-2 p-4">
<div className="*:data-[slot=card]:from-primary/5 *:data-[slot=card]:to-card dark:*:data-[slot=card]:bg-card *:data-[slot=card]:bg-linear-to-br *:data-[slot=card]:shadow-xs grid grid-cols-1 @xl/main:grid-cols-2 @5xl/main:grid-cols-3 gap-4">
Home
</div>
</div>
);
}

View File

@@ -1,11 +1,12 @@
import NotFound from '@/components/NotFound';
import { Toaster } from '@/components/ui/sonner';
import { getLocale } from '@/paraglide/runtime';
import { sessionQueries } from '@/service/queries';
import {
CheckIcon,
InfoIcon,
WarningIcon,
WarningOctagonIcon,
XCircleIcon,
} from '@phosphor-icons/react';
import { TanStackDevtools } from '@tanstack/react-devtools';
import type { QueryClient } from '@tanstack/react-query';
@@ -21,12 +22,12 @@ import appCss from '../styles.css?url';
export const Route = createRootRouteWithContext<{ queryClient: QueryClient }>()(
{
// beforeLoad: async ({ context }) => {
// const userSession = await context.queryClient.fetchQuery(
// sessionQueries.user(),
// );
// return { userSession };
// },
beforeLoad: async ({ context }) => {
const session = await context.queryClient.fetchQuery(
sessionQueries.user(),
);
return { session };
},
head: () => ({
meta: [
{
@@ -64,32 +65,31 @@ function RootDocument({ children }: { children: React.ReactNode }) {
<body>
{children}
<Toaster
// richColors
visibleToasts={5}
position={'top-right'}
offset={{ top: 60, right: 10 }}
closeButton={true}
icons={{
success: <CheckIcon className="text-green-500" size={16} />,
error: <WarningOctagonIcon className="text-red-500" size={16} />,
error: <XCircleIcon className="text-red-500" size={16} />,
info: <InfoIcon className="text-blue-500" size={16} />,
warning: <WarningIcon className="text-yellow-500" size={16} />,
}}
/>
{/* <React.Suspense> */}
<TanStackDevtools
config={{
position: 'bottom-right',
}}
plugins={[
{
name: 'Tanstack Router',
render: <TanStackRouterDevtoolsPanel />,
},
TanStackQueryDevtools,
]}
/>
{/* </React.Suspense> */}
<React.Suspense>
<TanStackDevtools
config={{
position: 'bottom-right',
}}
plugins={[
{
name: 'Tanstack Router',
render: <TanStackRouterDevtoolsPanel />,
},
TanStackQueryDevtools,
]}
/>
</React.Suspense>
<Scripts />
</body>
</html>

View File

@@ -1,15 +1,68 @@
import { prisma } from '@/db';
import { Audit } from '@/generated/prisma/client';
import { AuditModel, AuditWhereInput } from '@/generated/prisma/models';
import { authMiddleware } from '@/lib/middleware';
import { createServerFn } from '@tanstack/react-start';
import { auditListSchema } from './audit.schema';
export async function createAuditLog(data: Omit<Audit, 'id' | 'createdAt'>) {
try {
await prisma.audit.create({
data: {
...data,
},
});
} catch (error) {
console.log(error);
throw error;
}
}
export const getAllAudit = createServerFn({ method: 'GET' })
.middleware([authMiddleware])
.inputValidator(auditListSchema)
.handler(async ({ data }) => {
try {
const skip = (data.page - 1) * data.limit;
const where: AuditWhereInput = {
OR: [
{
oldValue: {
contains: data.keyword,
mode: 'insensitive',
},
},
{
newValue: {
contains: data.keyword,
mode: 'insensitive',
},
},
{
action: {
contains: data.keyword,
mode: 'insensitive',
},
},
],
};
const [auditlog, total]: [AuditModel[], number] = await Promise.all([
await prisma.audit.findMany({
where,
orderBy: { createdAt: 'desc' },
include: {
user: {
select: {
id: true,
name: true,
},
},
},
take: data.limit,
skip,
}),
await prisma.audit.count({ where }),
]);
const totalPage = Math.ceil(+total / data.limit);
return {
result: auditlog,
pagination: {
currentPage: data.page,
totalPage,
totalItem: total,
},
};
} catch (error) {
console.log(error);
throw error;
}
});

View File

@@ -0,0 +1,9 @@
import z from 'zod';
export const auditListSchema = z.object({
page: z.coerce.number().min(1).default(1),
limit: z.coerce.number().min(10).max(100).default(10),
keyword: z.string().optional(),
});
export const auditSchema = z.object({});

View File

@@ -1,5 +1,6 @@
import { getSession } from '@/lib/auth/session';
import { queryOptions } from '@tanstack/react-query';
import { getAllAudit } from './audit.api';
import { getAdminSettings, getUserSettings } from './setting.api';
export const sessionQueries = {
@@ -26,3 +27,12 @@ export const settingQueries = {
queryFn: () => getUserSettings(),
}),
};
export const auditQueries = {
all: ['audit'],
list: (params: { page: number; limit: number; keyword?: string }) =>
queryOptions({
queryKey: [...auditQueries.all, 'list', params],
queryFn: () => getAllAudit({ data: params }),
}),
};

15
src/service/repository.ts Normal file
View File

@@ -0,0 +1,15 @@
import { prisma } from '@/db';
import { Audit } from '@/generated/prisma/client';
export const createAuditLog = async (data: Omit<Audit, 'id' | 'createdAt'>) => {
try {
await prisma.audit.create({
data: {
...data,
},
});
} catch (error) {
console.log(error);
throw error;
}
};

View File

@@ -3,7 +3,7 @@ import { Setting } from '@/generated/prisma/client';
import { authMiddleware } from '@/lib/middleware';
import { extractDiffObjects } from '@/utils/help';
import { createServerFn } from '@tanstack/react-start';
import { createAuditLog } from './audit.api';
import { createAuditLog } from './repository';
import { settingSchema, userSettingSchema } from './setting.schema';
type AdminSettingReturn = {

7
src/types/db.d.ts vendored Normal file
View File

@@ -0,0 +1,7 @@
import { Audit, User } from '@prisma/client';
declare global {
type AuditLog = Audit & {
user: User;
};
}

7
src/types/table.d.ts vendored Normal file
View File

@@ -0,0 +1,7 @@
import '@tanstack/react-table';
declare module '@tanstack/react-table' {
interface ColumnMeta<TData, TValue> {
thClass?: string;
}
}

20
src/utils/formatters.ts Normal file
View File

@@ -0,0 +1,20 @@
import { getLocale } from '@/paraglide/runtime';
const locale = getLocale() === 'vi' ? 'vi-VN' : 'en-US';
export const formatters = {
dateTime: (v: string | Date) => {
const parts = new Intl.DateTimeFormat(locale, {
day: '2-digit',
month: '2-digit',
year: 'numeric',
hour: '2-digit',
minute: '2-digit',
hour12: true,
}).formatToParts(new Date(v));
const map = Object.fromEntries(parts.map((p) => [p.type, p.value]));
return `${map.day}/${map.month}/${map.year} ${map.hour}:${map.minute} ${map.hour12 ? 'AM' : 'PM'}`;
},
};

View File

@@ -7,6 +7,9 @@ import { defineConfig } from 'vite';
import viteTsConfigPaths from 'vite-tsconfig-paths';
const config = defineConfig({
// server: {
// host: true,
// },
plugins: [
paraglideVitePlugin({
project: './project.inlang',