From edb4ebe11c08b425da3828c8b1b9f9bfaef0a0cc Mon Sep 17 00:00:00 2001 From: Sam Date: Wed, 14 Jan 2026 09:35:46 +0700 Subject: [PATCH] added data table, formatter revert context on __root beforeLoad refactor project structure refactor role badge dynamic nav menu --- messages/en.json | 51 +++++- messages/vi.json | 51 +++++- package.json | 1 + pnpm-lock.yaml | 22 +++ src/components/DataTable.tsx | 165 ++++++++++++++++++ src/components/Header.tsx | 2 +- src/components/Pagination.tsx | 97 ++++++++++ src/components/audit/action-badge.tsx | 43 +++++ src/components/audit/audit-columns.tsx | 159 +++++++++++++++++ .../{AvatarUser.tsx => avatar-user.tsx} | 4 +- .../avatar/{RoleBadge.tsx => role-badge.tsx} | 17 +- .../avatar/{RoleRing.tsx => role-ring.tsx} | 0 src/components/form/profile-form.tsx | 4 +- src/components/sidebar/app-sidebar.tsx | 29 ++- src/components/sidebar/nav-main.tsx | 119 +++++++++---- src/components/sidebar/nav-user.tsx | 4 +- ...erBreadcrumb.tsx => router-breadcrumb.tsx} | 0 src/components/ui/badge.tsx | 1 + src/components/ui/button-group.tsx | 83 +++++++++ src/components/ui/button.tsx | 20 +-- src/components/ui/dialog.tsx | 153 ++++++++++++++++ src/components/ui/input-group.tsx | 145 +++++++++++++++ src/components/ui/input.tsx | 10 +- src/components/ui/sidebar.tsx | 1 + src/components/ui/table.tsx | 99 +++++++++++ src/components/ui/textarea.tsx | 6 +- src/components/ui/tooltip.tsx | 4 +- src/hooks/use-debounced.ts | 17 ++ src/lib/auth.ts | 2 +- src/routeTree.gen.ts | 21 +++ src/router.tsx | 2 +- src/routes/(app)/(auth)/dashboard.tsx | 8 +- src/routes/(app)/(auth)/logs.tsx | 110 ++++++++++++ src/routes/(app)/(auth)/route.tsx | 12 +- src/routes/(app)/index.tsx | 8 +- src/routes/__root.tsx | 46 ++--- src/service/audit.api.ts | 79 +++++++-- src/service/audit.schema.ts | 9 + src/service/queries.ts | 10 ++ src/service/repository.ts | 15 ++ src/service/setting.api.ts | 2 +- src/types/db.d.ts | 7 + src/types/table.d.ts | 7 + src/utils/formatters.ts | 20 +++ vite.config.ts | 3 + 45 files changed, 1519 insertions(+), 149 deletions(-) create mode 100644 src/components/DataTable.tsx create mode 100644 src/components/Pagination.tsx create mode 100644 src/components/audit/action-badge.tsx create mode 100644 src/components/audit/audit-columns.tsx rename src/components/avatar/{AvatarUser.tsx => avatar-user.tsx} (92%) rename src/components/avatar/{RoleBadge.tsx => role-badge.tsx} (72%) rename src/components/avatar/{RoleRing.tsx => role-ring.tsx} (100%) rename src/components/sidebar/{RouterBreadcrumb.tsx => router-breadcrumb.tsx} (100%) create mode 100644 src/components/ui/button-group.tsx create mode 100644 src/components/ui/dialog.tsx create mode 100644 src/components/ui/input-group.tsx create mode 100644 src/components/ui/table.tsx create mode 100644 src/hooks/use-debounced.ts create mode 100644 src/routes/(app)/(auth)/logs.tsx create mode 100644 src/service/audit.schema.ts create mode 100644 src/service/repository.ts create mode 100644 src/types/db.d.ts create mode 100644 src/types/table.d.ts create mode 100644 src/utils/formatters.ts diff --git a/messages/en.json b/messages/en.json index 7eebd66..0a48f32 100644 --- a/messages/en.json +++ b/messages/en.json @@ -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!" } diff --git a/messages/vi.json b/messages/vi.json index 9d86956..3329bda 100644 --- a/messages/vi.json +++ b/messages/vi.json @@ -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!" } diff --git a/package.json b/package.json index dc1ce76..f24ae65 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 3c58f8f..79e38ae 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -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': diff --git a/src/components/DataTable.tsx b/src/components/DataTable.tsx new file mode 100644 index 0000000..b078d6f --- /dev/null +++ b/src/components/DataTable.tsx @@ -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 { + columns: ColumnDef[]; + data: TData[]; + page: number; + setPage: (page: number) => void; + limit: number; + setLimit: (page: number) => void; + pagination: { + currentPage: number; + totalPage: number; + totalItem: number; + }; +} + +const DataTable = ({ + columns, + data, + page, + setPage, + limit, + setLimit, + pagination, +}: DataTableProps) => { + const table = useReactTable({ + data, + columns, + getCoreRowModel: getCoreRowModel(), + }); + + return ( + <> +
+ + + {table.getHeaderGroups().map((headerGroup) => ( + + {headerGroup.headers.map((header) => { + return ( + + {header.isPlaceholder + ? null + : flexRender( + header.column.columnDef.header, + header.getContext(), + )} + + ); + })} + + ))} + + + {table.getRowModel().rows?.length ? ( + table.getRowModel().rows.map((row) => ( + + {row.getVisibleCells().map((cell) => ( + + {flexRender( + cell.column.columnDef.cell, + cell.getContext(), + )} + + ))} + + )) + ) : ( + + + No results. + + + )} + +
+
+
+
+ + {m.common_page_show({ + count: pagination.totalItem, + start: (pagination.currentPage - 1) * 10 + 1, + end: Math.min(pagination.currentPage * 10, pagination.totalItem), + })} + +
+
+
+ + +
+ setPage(newPage)} + /> +
+
+ + ); +}; + +export default DataTable; diff --git a/src/components/Header.tsx b/src/components/Header.tsx index 3e1ac3f..c3490cd 100644 --- a/src/components/Header.tsx +++ b/src/components/Header.tsx @@ -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 { diff --git a/src/components/Pagination.tsx b/src/components/Pagination.tsx new file mode 100644 index 0000000..5e3bcfe --- /dev/null +++ b/src/components/Pagination.tsx @@ -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 ( +
+ + + + + + {pages.map((page, idx) => + page === 'dot' ? ( + + ) : ( + + ), + )} + + + + + +
+ ); +}; + +export default Pagination; diff --git a/src/components/audit/action-badge.tsx b/src/components/audit/action-badge.tsx new file mode 100644 index 0000000..6fc5a6a --- /dev/null +++ b/src/components/audit/action-badge.tsx @@ -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 ( + + {m.logs_page_ui_badge_action({ action })} + + ); +}; + +export default ActionBadge; diff --git a/src/components/audit/audit-columns.tsx b/src/components/audit/audit-columns.tsx new file mode 100644 index 0000000..ed6341c --- /dev/null +++ b/src/components/audit/audit-columns.tsx @@ -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[] = [ + { + 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 ( + + {row.original.tableName} + + ); + }, + }, + { + accessorKey: 'action', + header: m.logs_page_ui_table_header_action(), + meta: { + thClass: 'w-1/6', + }, + cell: ({ row }) => { + return ( + + ); + }, + }, + { + 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 }) => ( +
+ +
+ ), + }, +]; + +type ViewDetailProps = { + data: AuditLog; +}; + +const ViewDetail = ({ data }: ViewDetailProps) => { + return ( + + + + + + + + + + + + + + + {m.ui_dialog_view_title({ type: m.nav_log() })} + + +
+
+ + {m.logs_page_ui_table_header_username()}: + + +
+
+ + {m.logs_page_ui_table_header_table()}: + + + {data.tableName} + +
+
+ + {m.logs_page_ui_table_header_action()}: + + +
+ {data.oldValue && ( +
+ + {m.logs_page_ui_table_header_old_value()}: + +
+                {jsonSupport(data.oldValue)}
+              
+
+ )} +
+ + {m.logs_page_ui_table_header_new_value()}: + +
+              {data.newValue ? jsonSupport(data.newValue) : ''}
+            
+
+
+ + {m.logs_page_ui_table_header_create_at()}: + + {formatters.dateTime(new Date(data.createdAt))} +
+
+
+
+ ); +}; diff --git a/src/components/avatar/AvatarUser.tsx b/src/components/avatar/avatar-user.tsx similarity index 92% rename from src/components/avatar/AvatarUser.tsx rename to src/components/avatar/avatar-user.tsx index 08949dc..3f535bc 100644 --- a/src/components/avatar/AvatarUser.tsx +++ b/src/components/avatar/avatar-user.tsx @@ -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 ( - + { // 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 ( - {LABEL_VALUE[(type as keyof typeof LABEL_VALUE) || 'default']} + {m.role_tags({ role: type as string })} ); }; diff --git a/src/components/avatar/RoleRing.tsx b/src/components/avatar/role-ring.tsx similarity index 100% rename from src/components/avatar/RoleRing.tsx rename to src/components/avatar/role-ring.tsx diff --git a/src/components/form/profile-form.tsx b/src/components/form/profile-form.tsx index caef89d..0012fa0 100644 --- a/src/components/form/profile-form.tsx +++ b/src/components/form/profile-form.tsx @@ -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'; diff --git a/src/components/sidebar/app-sidebar.tsx b/src/components/sidebar/app-sidebar.tsx index ab232bd..7208542 100644 --- a/src/components/sidebar/app-sidebar.tsx +++ b/src/components/sidebar/app-sidebar.tsx @@ -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) => { return ( @@ -17,14 +16,12 @@ const AppSidebar = ({ ...props }: React.ComponentProps) => { - -

- - Fuware Logo - Fuware - -

-
+

+ + Fuware Logo + Fuware + +

@@ -35,7 +32,7 @@ const AppSidebar = ({ ...props }: React.ComponentProps) => { - ) -} + ); +}; -export default AppSidebar +export default AppSidebar; diff --git a/src/components/sidebar/nav-main.tsx b/src/components/sidebar/nav-main.tsx index 5c16206..5ad57a1 100644 --- a/src/components/sidebar/nav-main.tsx +++ b/src/components/sidebar/nav-main.tsx @@ -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 ( - - - - - - {m.nav_home()} - - - - - {m.nav_dashboard()} - - - - - {m.nav_settings()} - - - - - - + <> + {NAV_MAIN.map((nav) => ( + + {nav.title} + + + {nav.items.map((item) => { + const Icon = item.icon; + const Menu = ( + + + + {item.title} + + + ); + return item.isAuth ? ( + {Menu} + ) : item.admin ? ( + {Menu} + ) : ( + Menu + ); + })} + + + + ))} + ); }; diff --git a/src/components/sidebar/nav-user.tsx b/src/components/sidebar/nav-user.tsx index b677b1e..17d1c35 100644 --- a/src/components/sidebar/nav-user.tsx +++ b/src/components/sidebar/nav-user.tsx @@ -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, diff --git a/src/components/sidebar/RouterBreadcrumb.tsx b/src/components/sidebar/router-breadcrumb.tsx similarity index 100% rename from src/components/sidebar/RouterBreadcrumb.tsx rename to src/components/sidebar/router-breadcrumb.tsx diff --git a/src/components/ui/badge.tsx b/src/components/ui/badge.tsx index f9fc6f5..5e37384 100644 --- a/src/components/ui/badge.tsx +++ b/src/components/ui/badge.tsx @@ -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', diff --git a/src/components/ui/button-group.tsx b/src/components/ui/button-group.tsx new file mode 100644 index 0000000..9d448bb --- /dev/null +++ b/src/components/ui/button-group.tsx @@ -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) { + return ( +
+ ) +} + +function ButtonGroupText({ + className, + asChild = false, + ...props +}: React.ComponentProps<"div"> & { + asChild?: boolean +}) { + const Comp = asChild ? Slot.Root : "div" + + return ( + + ) +} + +function ButtonGroupSeparator({ + className, + orientation = "vertical", + ...props +}: React.ComponentProps) { + return ( + + ) +} + +export { + ButtonGroup, + ButtonGroupSeparator, + ButtonGroupText, + buttonGroupVariants, +} diff --git a/src/components/ui/button.tsx b/src/components/ui/button.tsx index b453a57..386e33b 100644 --- a/src/components/ui/button.tsx +++ b/src/components/ui/button.tsx @@ -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 & { - asChild?: boolean + asChild?: boolean; }) { - const Comp = asChild ? Slot.Root : 'button' + const Comp = asChild ? Slot.Root : 'button'; return ( - ) + ); } -export { Button, buttonVariants } +export { Button, buttonVariants }; diff --git a/src/components/ui/dialog.tsx b/src/components/ui/dialog.tsx new file mode 100644 index 0000000..06183f5 --- /dev/null +++ b/src/components/ui/dialog.tsx @@ -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) { + return +} + +function DialogTrigger({ + ...props +}: React.ComponentProps) { + return +} + +function DialogPortal({ + ...props +}: React.ComponentProps) { + return +} + +function DialogClose({ + ...props +}: React.ComponentProps) { + return +} + +function DialogOverlay({ + className, + ...props +}: React.ComponentProps) { + return ( + + ) +} + +function DialogContent({ + className, + children, + showCloseButton = true, + ...props +}: React.ComponentProps & { + showCloseButton?: boolean +}) { + return ( + + + + {children} + {showCloseButton && ( + + + + )} + + + ) +} + +function DialogHeader({ className, ...props }: React.ComponentProps<"div">) { + return ( +
+ ) +} + +function DialogFooter({ + className, + showCloseButton = false, + children, + ...props +}: React.ComponentProps<"div"> & { + showCloseButton?: boolean +}) { + return ( +
+ {children} + {showCloseButton && ( + + + + )} +
+ ) +} + +function DialogTitle({ + className, + ...props +}: React.ComponentProps) { + return ( + + ) +} + +function DialogDescription({ + className, + ...props +}: React.ComponentProps) { + return ( + + ) +} + +export { + Dialog, + DialogClose, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogOverlay, + DialogPortal, + DialogTitle, + DialogTrigger, +} diff --git a/src/components/ui/input-group.tsx b/src/components/ui/input-group.tsx new file mode 100644 index 0000000..1161ffe --- /dev/null +++ b/src/components/ui/input-group.tsx @@ -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 ( +
[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) { + return ( +
{ + 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, "size"> & + VariantProps) { + return ( +