@goplusvn/core 0.1.83 → 0.1.84
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +3 -2
- package/src/crud/components/crud-table.tsx +10 -2
- package/src/hooks/index.tsx +19 -0
- package/src/ui/feedback/index.tsx +4 -1
- package/src/ui/feedback/sonner.tsx +13 -0
- package/src/ui/layout/page-tabs.tsx +11 -1
- package/src/ui/layout/user-dropdown.tsx +20 -34
- package/src/ui/layout/vertical-layout-header.tsx +10 -3
- package/src/ui/layout/vertical-layout.tsx +5 -2
- package/src/ui/primitives/dialog.tsx +9 -1
- package/src/ui/primitives/sidebar.tsx +9 -6
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@goplusvn/core",
|
|
3
3
|
"description": "GoPlusVN Platform Kit - ERP kernel: layout, RBAC, CRUD, multi-tenant, system pages",
|
|
4
|
-
"version": "0.1.
|
|
4
|
+
"version": "0.1.84",
|
|
5
5
|
"private": false,
|
|
6
6
|
"publishConfig": {
|
|
7
7
|
"registry": "https://registry.npmjs.org",
|
|
@@ -110,6 +110,7 @@
|
|
|
110
110
|
"next": ">=14.0.0",
|
|
111
111
|
"react": "^18.0.0 || ^19.0.0",
|
|
112
112
|
"react-dom": "^18.0.0 || ^19.0.0",
|
|
113
|
+
"sonner": "^2.0.0",
|
|
113
114
|
"vitest": ">=2"
|
|
114
115
|
},
|
|
115
116
|
"devDependencies": {
|
|
@@ -134,6 +135,7 @@
|
|
|
134
135
|
"eslint-plugin-react-hooks": "^7.0.1",
|
|
135
136
|
"globals": "16.5.0",
|
|
136
137
|
"jsdom": "^27.2.0",
|
|
138
|
+
"sonner": "2.0.2",
|
|
137
139
|
"tsup": "^8.5.1",
|
|
138
140
|
"typescript": "^5.7.3",
|
|
139
141
|
"typescript-eslint": "^8.50.1",
|
|
@@ -206,7 +208,6 @@
|
|
|
206
208
|
"react-use": "17.5.1",
|
|
207
209
|
"recharts": "^2.15.1",
|
|
208
210
|
"shiki": "3.2.1",
|
|
209
|
-
"sonner": "2.0.2",
|
|
210
211
|
"swr": "^2.3.6",
|
|
211
212
|
"tailwind-merge": "2.5.2",
|
|
212
213
|
"vaul": "1.1.2",
|
|
@@ -46,10 +46,18 @@ function resolveIncludedRelationLabel(
|
|
|
46
46
|
row: Record<string, unknown> | undefined,
|
|
47
47
|
): string | undefined {
|
|
48
48
|
const name = field?.name;
|
|
49
|
-
if (!field?.dataSource || !row || !name
|
|
49
|
+
if (!field?.dataSource || !row || !name) {
|
|
50
50
|
return undefined;
|
|
51
51
|
}
|
|
52
|
-
|
|
52
|
+
let relationKey = "";
|
|
53
|
+
if (name.endsWith("Id")) {
|
|
54
|
+
relationKey = name.slice(0, -2);
|
|
55
|
+
} else if (name.endsWith("_id")) {
|
|
56
|
+
relationKey = name.slice(0, -3);
|
|
57
|
+
} else {
|
|
58
|
+
return undefined;
|
|
59
|
+
}
|
|
60
|
+
|
|
53
61
|
if (!relationKey) return undefined;
|
|
54
62
|
const rel = row[relationKey];
|
|
55
63
|
if (!rel || typeof rel !== "object" || Array.isArray(rel)) return undefined;
|
package/src/hooks/index.tsx
CHANGED
|
@@ -147,6 +147,25 @@ export function useMobile(): boolean {
|
|
|
147
147
|
return useMediaQuery("(max-width: 768px)");
|
|
148
148
|
}
|
|
149
149
|
|
|
150
|
+
/**
|
|
151
|
+
* Mốc "màn hình hẹp" RIÊNG cho sidebar — cố ý KHÁC `useMobile()`.
|
|
152
|
+
*
|
|
153
|
+
* Sidebar desktop hiện từ `lg:` (≥1024px). Nếu hook quyết định off-canvas lại
|
|
154
|
+
* dùng mốc 768px thì sinh ra hai lỗi:
|
|
155
|
+
* 1. Vùng chết 769–1023px (iPad dọc 820, iPad Pro 11" 834, Surface 912):
|
|
156
|
+
* sidebar desktop đã render nhưng KHÔNG nút nào thu gọn được nó — nút
|
|
157
|
+
* `SidebarTrigger` là `lg:` còn `ToggleMobileSidebar` chỉ hiện khi hẹp.
|
|
158
|
+
* Sidebar 16rem ăn cứng 256px của một màn ~800px.
|
|
159
|
+
* 2. Đúng 768px (iPad Mini dọc) thì `max-width:768px` VÀ `min-width:768px`
|
|
160
|
+
* cùng đúng ⇒ Sheet mobile và sidebar desktop mount CÙNG LÚC, đè lên nhau.
|
|
161
|
+
*
|
|
162
|
+
* Dùng 1023.98px để khớp đúng biên `lg:` của Tailwind và tránh off-by-one.
|
|
163
|
+
* `useMobile()` giữ nguyên 768px — đã là API công khai, app khác đang dựa vào.
|
|
164
|
+
*/
|
|
165
|
+
export function useSidebarMobile(): boolean {
|
|
166
|
+
return useMediaQuery("(max-width: 1023.98px)");
|
|
167
|
+
}
|
|
168
|
+
|
|
150
169
|
// ============================================================================
|
|
151
170
|
// Clipboard Hook
|
|
152
171
|
// ============================================================================
|
|
@@ -89,7 +89,10 @@ export function AlertDialogContent({
|
|
|
89
89
|
<AlertDialogPrimitive.Content
|
|
90
90
|
data-slot="alert-dialog-content"
|
|
91
91
|
className={cn(
|
|
92
|
-
"fixed top-[50%] left-[50%] z-50 w-full max-w-[calc(100
|
|
92
|
+
"fixed top-[50%] left-[50%] z-50 w-full max-w-[calc(100%_-_2rem)] grid bg-background translate-x-[-50%] translate-y-[-50%] gap-4 rounded-lg border p-4 sm:p-6 shadow-lg duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 sm:max-w-lg",
|
|
93
|
+
// Cùng lý do với DialogContent: bị neo giữa màn hình nên phải chặn
|
|
94
|
+
// chiều cao, không thì mô tả dài sẽ tràn ra ngoài và không cuộn được.
|
|
95
|
+
"max-h-[calc(100dvh_-_2rem)] overflow-y-auto overscroll-contain",
|
|
93
96
|
className,
|
|
94
97
|
)}
|
|
95
98
|
{...props}
|
|
@@ -1,5 +1,18 @@
|
|
|
1
1
|
"use client";
|
|
2
2
|
|
|
3
|
+
/**
|
|
4
|
+
* `sonner` là PEER dependency của core, cố ý — không phải `dependencies`.
|
|
5
|
+
*
|
|
6
|
+
* `toast()` đẩy vào một store cấp module, còn `<Toaster>` đọc chính store đó.
|
|
7
|
+
* Nếu core tự mang theo bản sonner riêng thì app và core có thể nạp HAI bản
|
|
8
|
+
* khác nhau ⇒ hai store ⇒ `toast()` gọi bên app không bao giờ tới `<Toaster>`
|
|
9
|
+
* dựng bởi core: không nổ lỗi, chỉ im lặng không hiện gì. Đúng cảnh đã gặp khi
|
|
10
|
+
* consumer chạy `pnpm core:link` (checkout core mang node_modules riêng).
|
|
11
|
+
*
|
|
12
|
+
* Để peer thì bản sonner LUÔN là bản của app — một store duy nhất, kể cả khi
|
|
13
|
+
* link. Cả ba app đang dùng (vinhhoa, thingtodo, tanloc-spartronics) đều đã tự
|
|
14
|
+
* khai `sonner` trong dependencies nên đây là thay đổi không phá vỡ ai.
|
|
15
|
+
*/
|
|
3
16
|
import { Toaster as Sonner } from "sonner";
|
|
4
17
|
|
|
5
18
|
import type { ComponentProps } from "react";
|
|
@@ -167,7 +167,17 @@ export function PageTabs({
|
|
|
167
167
|
|
|
168
168
|
return (
|
|
169
169
|
<div className={cn("w-full", variant === "header" && "py-0", className)}>
|
|
170
|
-
|
|
170
|
+
{/* orientation="horizontal": ScrollBar mặc định là "vertical" nên dải tab
|
|
171
|
+
cuộn được bằng cảm ứng nhưng trên desktop KHÔNG có thanh cuộn nào —
|
|
172
|
+
người dùng tưởng các tab bị cắt mất. Bar bóp còn 6px để không đè
|
|
173
|
+
lên chữ trong biến thể header cao 32px. */}
|
|
174
|
+
<ScrollArea
|
|
175
|
+
orientation="horizontal"
|
|
176
|
+
className={cn(
|
|
177
|
+
"w-full [&_[data-slot=scroll-area-scrollbar]]:h-1.5",
|
|
178
|
+
variant === "header" && "h-8",
|
|
179
|
+
)}
|
|
180
|
+
>
|
|
171
181
|
<div
|
|
172
182
|
className={cn(
|
|
173
183
|
"flex items-end gap-0 px-2 py-0.5",
|
|
@@ -26,12 +26,6 @@ import {
|
|
|
26
26
|
DropdownMenuSeparator,
|
|
27
27
|
DropdownMenuTrigger,
|
|
28
28
|
} from "../primitives/dropdown-menu";
|
|
29
|
-
import {
|
|
30
|
-
Tooltip,
|
|
31
|
-
TooltipContent,
|
|
32
|
-
TooltipProvider,
|
|
33
|
-
TooltipTrigger,
|
|
34
|
-
} from "../primitives/client";
|
|
35
29
|
|
|
36
30
|
interface ExtendedUser {
|
|
37
31
|
id: string;
|
|
@@ -86,34 +80,26 @@ export function UserDropdown({
|
|
|
86
80
|
|
|
87
81
|
return (
|
|
88
82
|
<DropdownMenu>
|
|
89
|
-
<
|
|
90
|
-
<
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
</Button>
|
|
110
|
-
</DropdownMenuTrigger>
|
|
111
|
-
</TooltipTrigger>
|
|
112
|
-
<TooltipContent>
|
|
113
|
-
<p>Profile</p>
|
|
114
|
-
</TooltipContent>
|
|
115
|
-
</Tooltip>
|
|
116
|
-
</TooltipProvider>
|
|
83
|
+
<DropdownMenuTrigger asChild>
|
|
84
|
+
<Button
|
|
85
|
+
variant="outline"
|
|
86
|
+
size="icon"
|
|
87
|
+
className="rounded-full bg-primary border-primary hover:bg-primary/90 focus-visible:ring-0 focus-visible:ring-offset-0"
|
|
88
|
+
aria-label="User"
|
|
89
|
+
title="Profile"
|
|
90
|
+
>
|
|
91
|
+
<Avatar className="size-6">
|
|
92
|
+
<AvatarImage
|
|
93
|
+
src={user?.avatar || undefined}
|
|
94
|
+
alt={user?.name || ""}
|
|
95
|
+
className="object-cover"
|
|
96
|
+
/>
|
|
97
|
+
<AvatarFallback className="bg-primary text-white text-[10px] font-bold uppercase">
|
|
98
|
+
{user?.name && getInitials(user.name)}
|
|
99
|
+
</AvatarFallback>
|
|
100
|
+
</Avatar>
|
|
101
|
+
</Button>
|
|
102
|
+
</DropdownMenuTrigger>
|
|
117
103
|
|
|
118
104
|
<DropdownMenuContent
|
|
119
105
|
className="w-56 p-1 bg-background/80 backdrop-blur-xl border border-border/40 shadow-xl rounded-xl animate-in fade-in zoom-in-95 duration-200"
|
|
@@ -28,7 +28,11 @@ export function VerticalLayoutHeader({
|
|
|
28
28
|
|
|
29
29
|
return (
|
|
30
30
|
<header className="sticky top-0 z-50 w-full bg-background/95 border-b border-border/50 backdrop-blur supports-[backdrop-filter]:bg-background/80">
|
|
31
|
-
|
|
31
|
+
{/* KHÔNG dùng `container`: max-width của nó bám theo VIEWPORT, không theo
|
|
32
|
+
phần tử cha. Cha ở đây là SidebarInset = viewport − bề rộng sidebar, và
|
|
33
|
+
SidebarInset có `overflow-hidden`, nên ở 768–1024px hàng này rộng hơn
|
|
34
|
+
cha và cụm nút bên phải (chuông, customizer, user) bị cắt mất. */}
|
|
35
|
+
<div className="flex w-full h-11 justify-between items-center gap-1 px-2 sm:px-3">
|
|
32
36
|
{/* Left: sidebar toggles */}
|
|
33
37
|
<div className="flex items-center gap-2 flex-shrink-0">
|
|
34
38
|
<ToggleMobileSidebar />
|
|
@@ -46,9 +50,12 @@ export function VerticalLayoutHeader({
|
|
|
46
50
|
|
|
47
51
|
{/* Right: user actions */}
|
|
48
52
|
<div className="flex items-center gap-2 flex-shrink-0">
|
|
49
|
-
<div className="
|
|
53
|
+
<div className="flex items-center gap-1 sm:gap-2">
|
|
50
54
|
{notificationSlot ?? <NotificationDropdown dictionary={dictionary} />}
|
|
51
|
-
|
|
55
|
+
{/* Toàn màn hình vô nghĩa trên thiết bị cảm ứng — ẩn dưới lg. */}
|
|
56
|
+
<span className="hidden lg:inline-flex">
|
|
57
|
+
<FullScreenToggle />
|
|
58
|
+
</span>
|
|
52
59
|
</div>
|
|
53
60
|
<Customizer
|
|
54
61
|
trigger={
|
|
@@ -37,12 +37,15 @@ export function VerticalLayout({
|
|
|
37
37
|
searchResults={searchResults}
|
|
38
38
|
searchLoading={searchLoading}
|
|
39
39
|
/>
|
|
40
|
-
<SidebarInset className="w-full min-w-0 overflow-hidden h-
|
|
40
|
+
<SidebarInset className="w-full min-w-0 overflow-hidden h-dvh flex flex-col">
|
|
41
41
|
<VerticalLayoutHeader
|
|
42
42
|
dictionary={dictionary}
|
|
43
43
|
notificationSlot={notificationSlot}
|
|
44
44
|
/>
|
|
45
|
-
|
|
45
|
+
{/* `h-dvh` ở SidebarInset (không phải `h-screen`): trên Safari/Chrome
|
|
46
|
+
mobile 100vh tính CẢ thanh URL co giãn, nên đáy trang — phân trang,
|
|
47
|
+
footer, nút Lưu — nằm khuất dưới thanh trình duyệt. */}
|
|
48
|
+
<main className="w-full mx-auto flex-1 min-h-0 px-3 sm:px-4 md:px-6 xl:px-8 py-3 md:py-4 overflow-auto">
|
|
46
49
|
{children}
|
|
47
50
|
</main>
|
|
48
51
|
<Footer />
|
|
@@ -40,7 +40,15 @@ const DialogContent = React.forwardRef<
|
|
|
40
40
|
<DialogPrimitive.Content
|
|
41
41
|
ref={ref}
|
|
42
42
|
className={cn(
|
|
43
|
-
"fixed left-[50%] top-[50%] z-50 grid
|
|
43
|
+
"fixed left-[50%] top-[50%] z-50 grid translate-x-[-50%] translate-y-[-50%] gap-4 border bg-background p-4 sm:p-6 shadow-lg duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%] rounded-lg",
|
|
44
|
+
// Hộp thoại bị neo `top-1/2 -translate-y-1/2`, nên nếu KHÔNG chặn chiều
|
|
45
|
+
// cao thì nội dung cao hơn màn hình sẽ tràn ra CẢ trên lẫn dưới và
|
|
46
|
+
// không cuộn được — hỏng trên điện thoại và trên tablet xoay ngang.
|
|
47
|
+
// Đây là mặc định: trang nào tự khai `max-h-*` / `overflow-*` thì
|
|
48
|
+
// class của trang đứng sau trong cn() nên vẫn thắng.
|
|
49
|
+
"max-h-[calc(100dvh_-_2rem)] overflow-y-auto overscroll-contain",
|
|
50
|
+
// Bề rộng: chừa 1rem mỗi bên trên màn hẹp thay vì dán sát mép.
|
|
51
|
+
"w-[calc(100%_-_2rem)] max-w-[calc(100%_-_2rem)] sm:w-full sm:max-w-lg",
|
|
44
52
|
className,
|
|
45
53
|
)}
|
|
46
54
|
{...props}
|
|
@@ -6,7 +6,7 @@ import type { VariantProps } from "class-variance-authority";
|
|
|
6
6
|
import { cva } from "class-variance-authority";
|
|
7
7
|
import { PanelLeft } from "lucide-react";
|
|
8
8
|
|
|
9
|
-
import {
|
|
9
|
+
import { useSidebarMobile } from "../../hooks"; // Exported from core/hooks
|
|
10
10
|
import { cn } from "../../utils";
|
|
11
11
|
import { Button } from "./button";
|
|
12
12
|
import { Sheet, SheetContent, SheetTitle } from "../feedback";
|
|
@@ -69,7 +69,10 @@ const SidebarProvider = React.forwardRef<
|
|
|
69
69
|
},
|
|
70
70
|
ref,
|
|
71
71
|
) => {
|
|
72
|
-
|
|
72
|
+
// Mốc sidebar là 1023.98px (khớp `lg:`), KHÔNG phải `useMobile()` 768px —
|
|
73
|
+
// xem chú thích ở useSidebarMobile: 768px để hở vùng chết tablet 769–1023px
|
|
74
|
+
// và làm hai sidebar cùng mount tại đúng 768px.
|
|
75
|
+
const isMobile = useSidebarMobile();
|
|
73
76
|
const [openMobile, setOpenMobile] = React.useState(false);
|
|
74
77
|
// Hover expand state
|
|
75
78
|
const [hoverOpen, setHoverOpen] = React.useState(false);
|
|
@@ -266,7 +269,7 @@ const Sidebar = React.forwardRef<
|
|
|
266
269
|
<div
|
|
267
270
|
ref={ref}
|
|
268
271
|
className={cn(
|
|
269
|
-
"group peer hidden
|
|
272
|
+
"group peer hidden lg:block transition-colors duration-200",
|
|
270
273
|
// Blue bg + white text when collapsed, white bg + dark text when expanded/hover
|
|
271
274
|
state === "collapsed" && !isHoverExpanded
|
|
272
275
|
? "text-sidebar-foreground"
|
|
@@ -294,7 +297,7 @@ const Sidebar = React.forwardRef<
|
|
|
294
297
|
{/* Sidebar Content - expands on hover */}
|
|
295
298
|
<div
|
|
296
299
|
className={cn(
|
|
297
|
-
"duration-200 fixed inset-y-0 z-10 hidden h-svh w-(--sidebar-width) transition-[left,right,width] ease-linear
|
|
300
|
+
"duration-200 fixed inset-y-0 z-10 hidden h-svh w-(--sidebar-width) transition-[left,right,width] ease-linear lg:flex",
|
|
298
301
|
side === "left"
|
|
299
302
|
? "left-0 group-data-[collapsible=offcanvas]:left-[calc(var(--sidebar-width)*-1)]"
|
|
300
303
|
: "right-0 group-data-[collapsible=offcanvas]:right-[calc(var(--sidebar-width)*-1)]",
|
|
@@ -402,14 +405,14 @@ const SidebarInset = React.forwardRef<
|
|
|
402
405
|
// Inset: content is a card floating with a uniform m-2 gap on ALL sides
|
|
403
406
|
// (no ml-0 override) so there's a small gap between the sidebar and the
|
|
404
407
|
// content/header instead of them touching.
|
|
405
|
-
"peer-data-[variant=inset]:min-h-[calc(100svh-theme(spacing.4))]
|
|
408
|
+
"peer-data-[variant=inset]:min-h-[calc(100svh-theme(spacing.4))] lg:peer-data-[variant=inset]:m-2 lg:peer-data-[variant=inset]:rounded-xl lg:peer-data-[variant=inset]:shadow",
|
|
406
409
|
// Floating: the content is full-bleed (no card), and its LEFT edge is
|
|
407
410
|
// the side that meets the floating sidebar. Give it a small left gap
|
|
408
411
|
// (ml-2) so it doesn't touch the sidebar, and round both left corners
|
|
409
412
|
// (top-left clips the sticky header, bottom-left clips the footer via
|
|
410
413
|
// overflow-hidden) so the content hugs the floating sidebar with soft
|
|
411
414
|
// curves instead of hard 90° corners. Right edge stays flush.
|
|
412
|
-
"
|
|
415
|
+
"lg:peer-data-[variant=floating]:ml-2 lg:peer-data-[variant=floating]:rounded-l-xl",
|
|
413
416
|
className,
|
|
414
417
|
)}
|
|
415
418
|
{...props}
|