@suphark/ui 0.1.1 → 0.3.0
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/CHANGELOG.md +22 -0
- package/README.md +45 -3
- package/package.json +14 -2
- package/src/components/app-shell/app-brand.tsx +81 -0
- package/src/components/app-shell/app-nav.tsx +260 -0
- package/src/components/app-shell/app-shell.tsx +270 -0
- package/src/components/app-shell/header-breadcrumbs.tsx +78 -0
- package/src/components/app-shell/index.ts +14 -0
- package/src/components/app-shell/nav-link.tsx +35 -0
- package/src/components/app-shell/types.ts +92 -0
- package/src/components/app-shell/user-menu.tsx +133 -0
- package/src/components/data-table/data-table-column-header.tsx +71 -0
- package/src/components/data-table/data-table-date-range-filter.tsx +25 -0
- package/src/components/data-table/data-table-faceted-filter.tsx +181 -0
- package/src/components/data-table/data-table-pagination.tsx +114 -0
- package/src/components/data-table/data-table-row-actions.tsx +166 -0
- package/src/components/data-table/data-table-toolbar.tsx +207 -0
- package/src/components/data-table/data-table-view-option.tsx +59 -0
- package/src/components/data-table/data-table.tsx +399 -0
- package/src/components/data-table/index.ts +9 -0
- package/src/components/data-table/types.ts +106 -0
- package/src/components/data-table/use-data-table.ts +229 -0
- package/src/components/design-system/sections/showcase-data-display.tsx +71 -0
- package/src/components/design-system/sections/showcase-guidelines-recipes.tsx +8 -8
- package/src/components/shared/button/delete-many-button.tsx +85 -0
- package/src/components/shared/resource-property-filter.tsx +399 -0
- package/src/data-table.ts +21 -0
- package/src/hooks/use-table-search-params.ts +147 -0
- package/src/index.ts +2 -1
- package/src/next.ts +8 -0
|
@@ -0,0 +1,270 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import {
|
|
4
|
+
InputGroup,
|
|
5
|
+
InputGroupAddon,
|
|
6
|
+
InputGroupButton,
|
|
7
|
+
InputGroupInput,
|
|
8
|
+
} from "@suphark/ui/components/ui/input-group";
|
|
9
|
+
import { Separator } from "@suphark/ui/components/ui/separator";
|
|
10
|
+
import {
|
|
11
|
+
Sidebar,
|
|
12
|
+
SidebarContent,
|
|
13
|
+
SidebarFooter,
|
|
14
|
+
SidebarHeader,
|
|
15
|
+
SidebarInset,
|
|
16
|
+
SidebarProvider,
|
|
17
|
+
SidebarRail,
|
|
18
|
+
SidebarTrigger,
|
|
19
|
+
useSidebar,
|
|
20
|
+
} from "@suphark/ui/components/ui/sidebar";
|
|
21
|
+
import { cn } from "@suphark/ui/lib/utils";
|
|
22
|
+
import { Search, X } from "lucide-react";
|
|
23
|
+
import {
|
|
24
|
+
type ComponentProps,
|
|
25
|
+
type CSSProperties,
|
|
26
|
+
type ReactNode,
|
|
27
|
+
useEffect,
|
|
28
|
+
useMemo,
|
|
29
|
+
useState,
|
|
30
|
+
} from "react";
|
|
31
|
+
import { AppBrand, type AppBrandProps } from "./app-brand";
|
|
32
|
+
import { AppNav, type AppNavPinProps } from "./app-nav";
|
|
33
|
+
import { HeaderBreadcrumbs, type HeaderBreadcrumbsProps } from "./header-breadcrumbs";
|
|
34
|
+
import { type AppNavGroup, searchNav } from "./types";
|
|
35
|
+
|
|
36
|
+
export interface AppShellProps {
|
|
37
|
+
/** โลโก้/ชื่อแอปมุมบน sidebar */
|
|
38
|
+
brand: AppBrandProps;
|
|
39
|
+
/** เมนู (ตัดตามสิทธิ์ด้วย `filterNav` ก่อนส่งเข้ามา) */
|
|
40
|
+
nav: AppNavGroup[];
|
|
41
|
+
/** ช่องค้นหาเมนูใต้โลโก้ (supplychain) */
|
|
42
|
+
searchable?: boolean;
|
|
43
|
+
searchPlaceholder?: string;
|
|
44
|
+
/** ปุ่ม pin เมนูขึ้น header (แอปเก็บ state เอง) */
|
|
45
|
+
pin?: AppNavPinProps;
|
|
46
|
+
/** ส่วนเพิ่มใน sidebar ใต้โลโก้ เช่น TenantSwitcher / CommandPalette */
|
|
47
|
+
sidebarHeader?: ReactNode;
|
|
48
|
+
/** ท้าย sidebar — ปกติคือ `<UserMenu variant="sidebar" />` */
|
|
49
|
+
sidebarFooter?: ReactNode;
|
|
50
|
+
/**
|
|
51
|
+
* header ของเนื้อหา: `false` = ไม่มี header, `true`/ไม่ส่ง = SidebarTrigger + breadcrumb อัตโนมัติ
|
|
52
|
+
* ส่ง object เพื่อกำหนดเอง
|
|
53
|
+
*/
|
|
54
|
+
header?:
|
|
55
|
+
| boolean
|
|
56
|
+
| {
|
|
57
|
+
/** breadcrumb: `false` ปิด, object = ตั้งค่า HeaderBreadcrumbs, ReactNode = ใส่เอง */
|
|
58
|
+
breadcrumbs?: false | HeaderBreadcrumbsProps | ReactNode;
|
|
59
|
+
/** กลางบรรทัด (เช่น pinned menus) */
|
|
60
|
+
center?: ReactNode;
|
|
61
|
+
/** ขวาสุด: notification, feedback, `<UserMenu variant="header" />` … */
|
|
62
|
+
actions?: ReactNode;
|
|
63
|
+
/** แถบเหนือ header เช่น impersonation banner */
|
|
64
|
+
banner?: ReactNode;
|
|
65
|
+
className?: string;
|
|
66
|
+
};
|
|
67
|
+
/** สีแบรนด์ต่อ tenant (ตั้ง --brand บน wrapper → primary/ring/sidebar-primary ตาม) */
|
|
68
|
+
brandColor?: string | null;
|
|
69
|
+
brandForeground?: string | null;
|
|
70
|
+
/** `icon` (ค่าเริ่มต้น) ยุบเหลือไอคอน · `offcanvas` ซ่อนทั้งแผง · `none` ตรึงตลอด */
|
|
71
|
+
collapsible?: ComponentProps<typeof Sidebar>["collapsible"];
|
|
72
|
+
/** เปิด sidebar ตอนโหลด (อ่านจาก cookie `sidebar_state` ฝั่ง server แล้วส่งมาได้) */
|
|
73
|
+
defaultOpen?: boolean;
|
|
74
|
+
/** class ของกล่องเนื้อหา (ค่าเริ่มต้น padding ตาม supplychain) */
|
|
75
|
+
contentClassName?: string;
|
|
76
|
+
/** จำกัดความกว้างเนื้อหา เช่น `max-w-5xl` (Pi Auth) — ไม่ส่ง = เต็มจอ (supplychain) */
|
|
77
|
+
contentMaxWidth?: string;
|
|
78
|
+
children: ReactNode;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* โครงแอปมาตรฐาน: sidebar ซ้าย (shadcn Sidebar — ยุบเป็นไอคอนบนเดสก์ท็อป, Sheet บนมือถือ) + header + เนื้อหา
|
|
83
|
+
* ยกจาก supplychain (main layout + AppSidebar) และ Pi Auth ConsoleShell — ใช้จาก layout.tsx ของแอปได้ทั้ง server/client
|
|
84
|
+
*
|
|
85
|
+
* ```tsx
|
|
86
|
+
* <AppShell brand={{ name: "Pi Auth" }} nav={filterNav(NAV, can)}
|
|
87
|
+
* sidebarFooter={<UserMenu user={user} onSignOut={signOut}><ThemeMenuItems /></UserMenu>}>
|
|
88
|
+
* {children}
|
|
89
|
+
* </AppShell>
|
|
90
|
+
* ```
|
|
91
|
+
*/
|
|
92
|
+
export function AppShell({
|
|
93
|
+
brand,
|
|
94
|
+
nav,
|
|
95
|
+
searchable = false,
|
|
96
|
+
searchPlaceholder = "ค้นหาเมนู…",
|
|
97
|
+
pin,
|
|
98
|
+
sidebarHeader,
|
|
99
|
+
sidebarFooter,
|
|
100
|
+
header = true,
|
|
101
|
+
brandColor,
|
|
102
|
+
brandForeground,
|
|
103
|
+
collapsible = "icon",
|
|
104
|
+
defaultOpen = true,
|
|
105
|
+
contentClassName,
|
|
106
|
+
contentMaxWidth,
|
|
107
|
+
children,
|
|
108
|
+
}: AppShellProps) {
|
|
109
|
+
const style = brandColor
|
|
110
|
+
? ({
|
|
111
|
+
"--brand": brandColor,
|
|
112
|
+
...(brandForeground ? { "--brand-foreground": brandForeground } : {}),
|
|
113
|
+
} as CSSProperties)
|
|
114
|
+
: undefined;
|
|
115
|
+
|
|
116
|
+
const headerOpts = typeof header === "object" ? header : {};
|
|
117
|
+
const showHeader = header !== false;
|
|
118
|
+
const breadcrumbs =
|
|
119
|
+
headerOpts.breadcrumbs === false ? null : headerOpts.breadcrumbs === undefined ||
|
|
120
|
+
isBreadcrumbOptions(headerOpts.breadcrumbs) ? (
|
|
121
|
+
<HeaderBreadcrumbs {...(headerOpts.breadcrumbs ?? {})} />
|
|
122
|
+
) : (
|
|
123
|
+
headerOpts.breadcrumbs
|
|
124
|
+
);
|
|
125
|
+
|
|
126
|
+
return (
|
|
127
|
+
// base-ui-ok: defaultOpen อ่านจาก cookie ครั้งเดียวตอน mount — หลังจากนั้น Sidebar จัดการ state เอง (ตั้งใจให้ uncontrolled)
|
|
128
|
+
<SidebarProvider defaultOpen={defaultOpen} style={style}>
|
|
129
|
+
<ShellSidebar
|
|
130
|
+
brand={brand}
|
|
131
|
+
nav={nav}
|
|
132
|
+
searchable={searchable}
|
|
133
|
+
searchPlaceholder={searchPlaceholder}
|
|
134
|
+
pin={pin}
|
|
135
|
+
sidebarHeader={sidebarHeader}
|
|
136
|
+
sidebarFooter={sidebarFooter}
|
|
137
|
+
collapsible={collapsible}
|
|
138
|
+
/>
|
|
139
|
+
|
|
140
|
+
<SidebarInset>
|
|
141
|
+
{showHeader && (
|
|
142
|
+
<div className="sticky top-0 z-40 shrink-0 bg-background print:static">
|
|
143
|
+
{headerOpts.banner}
|
|
144
|
+
<header
|
|
145
|
+
className={cn(
|
|
146
|
+
"flex h-auto flex-col border-b px-4 md:h-14 md:flex-row md:items-center print:hidden",
|
|
147
|
+
headerOpts.className,
|
|
148
|
+
)}
|
|
149
|
+
>
|
|
150
|
+
<div className="flex h-12 min-w-0 flex-1 items-center gap-2 md:h-full">
|
|
151
|
+
<SidebarTrigger className="-ml-1" />
|
|
152
|
+
{breadcrumbs && (
|
|
153
|
+
<>
|
|
154
|
+
<Separator orientation="vertical" className="mr-2 hidden h-4 md:block" />
|
|
155
|
+
<div className="min-w-0 flex-1">{breadcrumbs}</div>
|
|
156
|
+
</>
|
|
157
|
+
)}
|
|
158
|
+
</div>
|
|
159
|
+
{(headerOpts.center || headerOpts.actions) && (
|
|
160
|
+
<div className="flex h-10 min-w-0 items-center gap-2 pb-1 md:ml-auto md:h-full md:pb-0">
|
|
161
|
+
{headerOpts.center && (
|
|
162
|
+
<div className="min-w-0 flex-1 overflow-hidden md:flex-none">
|
|
163
|
+
{headerOpts.center}
|
|
164
|
+
</div>
|
|
165
|
+
)}
|
|
166
|
+
{headerOpts.actions && (
|
|
167
|
+
<div className="ml-auto flex shrink-0 items-center gap-2">
|
|
168
|
+
{headerOpts.actions}
|
|
169
|
+
</div>
|
|
170
|
+
)}
|
|
171
|
+
</div>
|
|
172
|
+
)}
|
|
173
|
+
</header>
|
|
174
|
+
</div>
|
|
175
|
+
)}
|
|
176
|
+
<div
|
|
177
|
+
className={cn(
|
|
178
|
+
"flex flex-1 flex-col gap-4 p-4 md:gap-6 md:p-6 print:overflow-visible",
|
|
179
|
+
contentMaxWidth && cn("mx-auto w-full", contentMaxWidth),
|
|
180
|
+
contentClassName,
|
|
181
|
+
)}
|
|
182
|
+
>
|
|
183
|
+
{children}
|
|
184
|
+
</div>
|
|
185
|
+
</SidebarInset>
|
|
186
|
+
</SidebarProvider>
|
|
187
|
+
);
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
/** แผง sidebar — แยกเพื่อใช้ useSidebar (ล้างคำค้นเมื่อยุบ ไม่ให้เมนูถูกกรองทั้งที่มองไม่เห็นช่องค้นหา) */
|
|
191
|
+
function ShellSidebar({
|
|
192
|
+
brand,
|
|
193
|
+
nav,
|
|
194
|
+
searchable,
|
|
195
|
+
searchPlaceholder,
|
|
196
|
+
pin,
|
|
197
|
+
sidebarHeader,
|
|
198
|
+
sidebarFooter,
|
|
199
|
+
collapsible,
|
|
200
|
+
}: Pick<
|
|
201
|
+
AppShellProps,
|
|
202
|
+
| "brand"
|
|
203
|
+
| "nav"
|
|
204
|
+
| "searchable"
|
|
205
|
+
| "searchPlaceholder"
|
|
206
|
+
| "pin"
|
|
207
|
+
| "sidebarHeader"
|
|
208
|
+
| "sidebarFooter"
|
|
209
|
+
| "collapsible"
|
|
210
|
+
>) {
|
|
211
|
+
const { state, isMobile } = useSidebar();
|
|
212
|
+
const [query, setQuery] = useState("");
|
|
213
|
+
const collapsed = state === "collapsed" && !isMobile;
|
|
214
|
+
useEffect(() => {
|
|
215
|
+
if (collapsed) setQuery("");
|
|
216
|
+
}, [collapsed]);
|
|
217
|
+
const visibleNav = useMemo(() => searchNav(nav, query), [nav, query]);
|
|
218
|
+
const searching = query.trim().length > 0;
|
|
219
|
+
|
|
220
|
+
return (
|
|
221
|
+
<Sidebar collapsible={collapsible} className="print:hidden">
|
|
222
|
+
<SidebarHeader>
|
|
223
|
+
<AppBrand {...brand} />
|
|
224
|
+
{searchable && (
|
|
225
|
+
<div className="group-data-[collapsible=icon]:hidden">
|
|
226
|
+
<InputGroup className="h-8 bg-background shadow-none">
|
|
227
|
+
<InputGroupAddon>
|
|
228
|
+
<Search aria-hidden="true" />
|
|
229
|
+
</InputGroupAddon>
|
|
230
|
+
<InputGroupInput
|
|
231
|
+
name="sidebar-search"
|
|
232
|
+
aria-label={searchPlaceholder}
|
|
233
|
+
autoComplete="off"
|
|
234
|
+
placeholder={searchPlaceholder}
|
|
235
|
+
value={query}
|
|
236
|
+
onChange={(e) => setQuery(e.target.value)}
|
|
237
|
+
/>
|
|
238
|
+
{query && (
|
|
239
|
+
<InputGroupAddon align="inline-end">
|
|
240
|
+
<InputGroupButton
|
|
241
|
+
onClick={() => setQuery("")}
|
|
242
|
+
aria-label="ล้างคำค้นหาเมนู"
|
|
243
|
+
className="size-6 p-0 hover:bg-transparent"
|
|
244
|
+
>
|
|
245
|
+
<X aria-hidden="true" />
|
|
246
|
+
</InputGroupButton>
|
|
247
|
+
</InputGroupAddon>
|
|
248
|
+
)}
|
|
249
|
+
</InputGroup>
|
|
250
|
+
</div>
|
|
251
|
+
)}
|
|
252
|
+
{sidebarHeader}
|
|
253
|
+
</SidebarHeader>
|
|
254
|
+
<SidebarContent>
|
|
255
|
+
<AppNav nav={visibleNav} pin={pin} forceOpen={searching} />
|
|
256
|
+
</SidebarContent>
|
|
257
|
+
{sidebarFooter && <SidebarFooter>{sidebarFooter}</SidebarFooter>}
|
|
258
|
+
{collapsible !== "none" && <SidebarRail />}
|
|
259
|
+
</Sidebar>
|
|
260
|
+
);
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
function isBreadcrumbOptions(value: unknown): value is HeaderBreadcrumbsProps {
|
|
264
|
+
return (
|
|
265
|
+
typeof value === "object" &&
|
|
266
|
+
value !== null &&
|
|
267
|
+
!("$$typeof" in (value as object)) &&
|
|
268
|
+
!Array.isArray(value)
|
|
269
|
+
);
|
|
270
|
+
}
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import {
|
|
4
|
+
Breadcrumb,
|
|
5
|
+
BreadcrumbItem,
|
|
6
|
+
BreadcrumbLink,
|
|
7
|
+
BreadcrumbList,
|
|
8
|
+
BreadcrumbPage,
|
|
9
|
+
BreadcrumbSeparator,
|
|
10
|
+
} from "@suphark/ui/components/ui/breadcrumb";
|
|
11
|
+
import Link from "next/link";
|
|
12
|
+
import { usePathname } from "next/navigation";
|
|
13
|
+
import { Fragment } from "react";
|
|
14
|
+
|
|
15
|
+
export interface HeaderBreadcrumbsProps {
|
|
16
|
+
/** ชื่อแสดงของแต่ละ segment (`{ "po-tracking": "PO Tracking" }`) — ไม่พบ = ตัวแรกพิมพ์ใหญ่ แทน - ด้วยช่องว่าง */
|
|
17
|
+
labels?: Record<string, string>;
|
|
18
|
+
homeHref?: string;
|
|
19
|
+
homeLabel?: string;
|
|
20
|
+
/** ตัด segment นำหน้าออก เช่น `["console"]` เมื่อทุกหน้าอยู่ใต้ /console */
|
|
21
|
+
skip?: string[];
|
|
22
|
+
/** ซ่อน segment ที่ดูเหมือน id (ตัวเลข/uuid/cuid) */
|
|
23
|
+
hideIds?: boolean;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
const ID_LIKE = /^(\d+|[0-9a-f]{8}-[0-9a-f-]{27}|c[a-z0-9]{20,}|[0-9a-z]{16,})$/i;
|
|
27
|
+
|
|
28
|
+
function labelOf(segment: string, labels?: Record<string, string>) {
|
|
29
|
+
const known = labels?.[segment];
|
|
30
|
+
if (known) return known;
|
|
31
|
+
const decoded = decodeURIComponent(segment);
|
|
32
|
+
return decoded.charAt(0).toUpperCase() + decoded.slice(1).replace(/-/g, " ");
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** breadcrumb อัตโนมัติจาก pathname (ยกจาก supplychain HeaderBreadcrumbs) */
|
|
36
|
+
export function HeaderBreadcrumbs({
|
|
37
|
+
labels,
|
|
38
|
+
homeHref = "/",
|
|
39
|
+
homeLabel = "Home",
|
|
40
|
+
skip = [],
|
|
41
|
+
hideIds = false,
|
|
42
|
+
}: HeaderBreadcrumbsProps) {
|
|
43
|
+
const pathname = usePathname();
|
|
44
|
+
const all = pathname.split("/").filter(Boolean);
|
|
45
|
+
// segments ที่ยังคง href เต็ม แต่ไม่แสดงตัวที่อยู่ใน skip / เป็น id
|
|
46
|
+
const shown = all
|
|
47
|
+
.map((segment, index) => ({ segment, href: `/${all.slice(0, index + 1).join("/")}` }))
|
|
48
|
+
.filter(({ segment }) => !skip.includes(segment) && !(hideIds && ID_LIKE.test(segment)));
|
|
49
|
+
|
|
50
|
+
return (
|
|
51
|
+
<Breadcrumb>
|
|
52
|
+
<BreadcrumbList>
|
|
53
|
+
<BreadcrumbItem className="hidden md:block">
|
|
54
|
+
<BreadcrumbLink render={<Link href={homeHref} />}>{homeLabel}</BreadcrumbLink>
|
|
55
|
+
</BreadcrumbItem>
|
|
56
|
+
{shown.length > 0 && <BreadcrumbSeparator className="hidden md:block" />}
|
|
57
|
+
{shown.map(({ segment, href }, index) => {
|
|
58
|
+
const isLast = index === shown.length - 1;
|
|
59
|
+
const label = labelOf(segment, labels);
|
|
60
|
+
return (
|
|
61
|
+
<Fragment key={href}>
|
|
62
|
+
<BreadcrumbItem>
|
|
63
|
+
{isLast ? (
|
|
64
|
+
<BreadcrumbPage className="max-w-[160px] truncate md:max-w-none">
|
|
65
|
+
{label}
|
|
66
|
+
</BreadcrumbPage>
|
|
67
|
+
) : (
|
|
68
|
+
<BreadcrumbLink render={<Link href={href} />}>{label}</BreadcrumbLink>
|
|
69
|
+
)}
|
|
70
|
+
</BreadcrumbItem>
|
|
71
|
+
{!isLast && <BreadcrumbSeparator className="hidden md:block" />}
|
|
72
|
+
</Fragment>
|
|
73
|
+
);
|
|
74
|
+
})}
|
|
75
|
+
</BreadcrumbList>
|
|
76
|
+
</Breadcrumb>
|
|
77
|
+
);
|
|
78
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
export { AppBrand, type AppBrandProps } from "./app-brand";
|
|
2
|
+
export { AppNav, AppNavGroupMenu, type AppNavPinProps } from "./app-nav";
|
|
3
|
+
export { AppShell, type AppShellProps } from "./app-shell";
|
|
4
|
+
export { HeaderBreadcrumbs, type HeaderBreadcrumbsProps } from "./header-breadcrumbs";
|
|
5
|
+
export { NavLink } from "./nav-link";
|
|
6
|
+
export {
|
|
7
|
+
type AppNavGroup,
|
|
8
|
+
type AppNavItem,
|
|
9
|
+
filterNav,
|
|
10
|
+
flattenNav,
|
|
11
|
+
isNavActive,
|
|
12
|
+
searchNav,
|
|
13
|
+
} from "./types";
|
|
14
|
+
export { UserMenu, type UserMenuProps, type UserMenuUser } from "./user-menu";
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { cn } from "@suphark/ui/lib/utils";
|
|
4
|
+
import Link from "next/link";
|
|
5
|
+
import { usePathname } from "next/navigation";
|
|
6
|
+
import type { ComponentProps, ReactNode } from "react";
|
|
7
|
+
import { isNavActive } from "./types";
|
|
8
|
+
|
|
9
|
+
/** ลิงก์ที่ไฮไลต์เมื่อ path ปัจจุบันอยู่ใต้ href (prefix match) — ยกจาก Pi Auth */
|
|
10
|
+
export function NavLink({
|
|
11
|
+
href,
|
|
12
|
+
children,
|
|
13
|
+
exact = false,
|
|
14
|
+
className,
|
|
15
|
+
activeClassName = "bg-accent text-accent-foreground font-medium",
|
|
16
|
+
...props
|
|
17
|
+
}: Omit<ComponentProps<typeof Link>, "href"> & {
|
|
18
|
+
href: string;
|
|
19
|
+
children: ReactNode;
|
|
20
|
+
exact?: boolean;
|
|
21
|
+
activeClassName?: string;
|
|
22
|
+
}) {
|
|
23
|
+
const pathname = usePathname();
|
|
24
|
+
const active = isNavActive(pathname, href, exact);
|
|
25
|
+
return (
|
|
26
|
+
<Link
|
|
27
|
+
href={href}
|
|
28
|
+
className={cn(className, active && activeClassName)}
|
|
29
|
+
aria-current={active ? "page" : undefined}
|
|
30
|
+
{...props}
|
|
31
|
+
>
|
|
32
|
+
{children}
|
|
33
|
+
</Link>
|
|
34
|
+
);
|
|
35
|
+
}
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
import type { CheckPermissionRequirement } from "@suphark/ui/lib/permission";
|
|
2
|
+
import type { ReactNode } from "react";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* โมเดลเมนูของ AppShell — ใช้ร่วมกันทุกแอป
|
|
6
|
+
*
|
|
7
|
+
* `icon` เป็น ReactNode (เช่น `<UsersIcon />`) ไม่ใช่ component เพื่อให้ประกาศเมนูใน server component
|
|
8
|
+
* (layout.tsx) แล้วส่งเข้า client component ได้ (function ส่งข้าม boundary ไม่ได้)
|
|
9
|
+
*/
|
|
10
|
+
export interface AppNavItem {
|
|
11
|
+
title: string;
|
|
12
|
+
/** ไม่มี href = หัวกลุ่มย่อยที่กางออก (ต้องมี items) */
|
|
13
|
+
href?: string;
|
|
14
|
+
icon?: ReactNode;
|
|
15
|
+
/** เมนูย่อย (รองรับ 2 ชั้น: item → items) */
|
|
16
|
+
items?: AppNavItem[];
|
|
17
|
+
/** ไฮไลต์เฉพาะเมื่อ path ตรงทั้งหมด (ค่าเริ่มต้น: prefix match) */
|
|
18
|
+
exact?: boolean;
|
|
19
|
+
/** สิทธิ์ที่ต้องมีจึงเห็นเมนูนี้ — ตัดด้วย `filterNav` */
|
|
20
|
+
permission?: CheckPermissionRequirement;
|
|
21
|
+
/** ป้ายท้ายเมนู เช่นจำนวนงานค้าง */
|
|
22
|
+
badge?: ReactNode;
|
|
23
|
+
/** ฟีเจอร์ทดลอง — แสดงสีอำพัน + ไอคอนขวดทดลอง (ตาม supplychain) */
|
|
24
|
+
isLabs?: boolean;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export interface AppNavGroup {
|
|
28
|
+
/** หัวกลุ่ม (ไม่ใส่ = ไม่มีหัว) */
|
|
29
|
+
title?: string;
|
|
30
|
+
items: AppNavItem[];
|
|
31
|
+
permission?: CheckPermissionRequirement;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* ตัดเมนูที่ผู้ใช้ไม่มีสิทธิ์ออกทุกชั้น — ไม่ให้กดแล้วเจอหน้า error (หน้าเองยังต้องตรวจสิทธิ์ซ้ำ)
|
|
36
|
+
* `can` = ตัวตรวจของแอป (เช่น `ctx.can`, หรือ `usePermissionChecker()` ฝั่ง client)
|
|
37
|
+
*/
|
|
38
|
+
export function filterNav(
|
|
39
|
+
nav: AppNavGroup[],
|
|
40
|
+
can: (permission: CheckPermissionRequirement) => boolean,
|
|
41
|
+
): AppNavGroup[] {
|
|
42
|
+
const filterItems = (items: AppNavItem[]): AppNavItem[] =>
|
|
43
|
+
items
|
|
44
|
+
.filter((item) => !item.permission || can(item.permission))
|
|
45
|
+
.map((item) => (item.items ? { ...item, items: filterItems(item.items) } : item))
|
|
46
|
+
// กลุ่มย่อยที่ลูกโดนตัดหมดและตัวเองไม่มี href → ตัดทิ้ง
|
|
47
|
+
.filter((item) => item.href || (item.items && item.items.length > 0));
|
|
48
|
+
|
|
49
|
+
return nav
|
|
50
|
+
.filter((group) => !group.permission || can(group.permission))
|
|
51
|
+
.map((group) => ({ ...group, items: filterItems(group.items) }))
|
|
52
|
+
.filter((group) => group.items.length > 0);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** true เมื่อ pathname อยู่ใต้ href (หรือตรงทั้งหมดเมื่อ exact) */
|
|
56
|
+
export function isNavActive(pathname: string, href: string | undefined, exact = false): boolean {
|
|
57
|
+
if (!href || href === "#") return false;
|
|
58
|
+
if (exact || href === "/") return pathname === href;
|
|
59
|
+
return pathname === href || pathname.startsWith(`${href}/`);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/** กรองเมนูตามคำค้น (ช่องค้นหาเมนูใน sidebar) — เก็บ parent ไว้ถ้าลูกตรง */
|
|
63
|
+
export function searchNav(nav: AppNavGroup[], query: string): AppNavGroup[] {
|
|
64
|
+
const q = query.trim().toLowerCase();
|
|
65
|
+
if (!q) return nav;
|
|
66
|
+
const filterItems = (items: AppNavItem[]): AppNavItem[] =>
|
|
67
|
+
items
|
|
68
|
+
.map((item) => {
|
|
69
|
+
if (item.title.toLowerCase().includes(q)) return item;
|
|
70
|
+
const children = item.items ? filterItems(item.items) : [];
|
|
71
|
+
return children.length > 0 ? { ...item, items: children } : null;
|
|
72
|
+
})
|
|
73
|
+
.filter((item): item is AppNavItem => item !== null);
|
|
74
|
+
return nav
|
|
75
|
+
.map((group) => ({ ...group, items: filterItems(group.items) }))
|
|
76
|
+
.filter((group) => group.items.length > 0);
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/** รายการเมนูทั้งหมดแบบแบน (สำหรับ command palette / pinned menus) */
|
|
80
|
+
export function flattenNav(
|
|
81
|
+
nav: AppNavGroup[],
|
|
82
|
+
): Array<AppNavItem & { href: string; group?: string }> {
|
|
83
|
+
const out: Array<AppNavItem & { href: string; group?: string }> = [];
|
|
84
|
+
const walk = (items: AppNavItem[], group?: string) => {
|
|
85
|
+
for (const item of items) {
|
|
86
|
+
if (item.href) out.push({ ...item, href: item.href, group });
|
|
87
|
+
if (item.items) walk(item.items, group);
|
|
88
|
+
}
|
|
89
|
+
};
|
|
90
|
+
for (const g of nav) walk(g.items, g.title);
|
|
91
|
+
return out;
|
|
92
|
+
}
|
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { Initials } from "@suphark/ui/components/shared/initials";
|
|
4
|
+
import { Button } from "@suphark/ui/components/ui/button";
|
|
5
|
+
import {
|
|
6
|
+
DropdownMenu,
|
|
7
|
+
DropdownMenuContent,
|
|
8
|
+
DropdownMenuGroup,
|
|
9
|
+
DropdownMenuItem,
|
|
10
|
+
DropdownMenuLabel,
|
|
11
|
+
DropdownMenuSeparator,
|
|
12
|
+
DropdownMenuTrigger,
|
|
13
|
+
} from "@suphark/ui/components/ui/dropdown-menu";
|
|
14
|
+
import { cn } from "@suphark/ui/lib/utils";
|
|
15
|
+
import { ChevronsUpDown, LogOut } from "lucide-react";
|
|
16
|
+
import { type ReactNode, useTransition } from "react";
|
|
17
|
+
|
|
18
|
+
export interface UserMenuUser {
|
|
19
|
+
name: string;
|
|
20
|
+
email?: string | null;
|
|
21
|
+
/** URL รูป */
|
|
22
|
+
image?: string | null;
|
|
23
|
+
/** บรรทัดรอง เช่น ตำแหน่ง · องค์กร */
|
|
24
|
+
description?: ReactNode;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export interface UserMenuProps {
|
|
28
|
+
user: UserMenuUser;
|
|
29
|
+
/**
|
|
30
|
+
* รายการเมนู (DropdownMenuItem / DropdownMenuGroup / ThemeMenuItems …) — วางระหว่างหัวกับปุ่มออกจากระบบ
|
|
31
|
+
* แอปเป็นคนใส่เอง เช่น บัญชีผู้ใช้, การแจ้งเตือน, ธีม, สลับภาษา
|
|
32
|
+
*/
|
|
33
|
+
children?: ReactNode;
|
|
34
|
+
/** ไม่ส่ง = ไม่มีปุ่มออกจากระบบ */
|
|
35
|
+
onSignOut?: () => void | Promise<void>;
|
|
36
|
+
signOutLabel?: ReactNode;
|
|
37
|
+
/**
|
|
38
|
+
* `sidebar` = ปุ่มกว้างเต็ม footer ของ sidebar (Pi Auth) · `header` = ปุ่มกะทัดรัดใน header (supplychain)
|
|
39
|
+
*/
|
|
40
|
+
variant?: "sidebar" | "header";
|
|
41
|
+
align?: "start" | "end";
|
|
42
|
+
className?: string;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** เมนูผู้ใช้ (ยกจาก supplychain NavUser + Pi Auth UserMenu) */
|
|
46
|
+
export function UserMenu({
|
|
47
|
+
user,
|
|
48
|
+
children,
|
|
49
|
+
onSignOut,
|
|
50
|
+
signOutLabel = "ออกจากระบบ",
|
|
51
|
+
variant = "sidebar",
|
|
52
|
+
align = variant === "header" ? "end" : "start",
|
|
53
|
+
className,
|
|
54
|
+
}: UserMenuProps) {
|
|
55
|
+
const [pending, start] = useTransition();
|
|
56
|
+
const description = user.description ?? user.email;
|
|
57
|
+
return (
|
|
58
|
+
<DropdownMenu>
|
|
59
|
+
<DropdownMenuTrigger
|
|
60
|
+
render={
|
|
61
|
+
<Button
|
|
62
|
+
variant="ghost"
|
|
63
|
+
aria-label={`เปิดเมนูผู้ใช้ ${user.name}`}
|
|
64
|
+
className={cn(
|
|
65
|
+
variant === "sidebar"
|
|
66
|
+
? "h-auto w-full justify-start gap-2.5 px-2 py-2 text-left group-data-[collapsible=icon]:size-8 group-data-[collapsible=icon]:p-0"
|
|
67
|
+
: "h-9 min-w-0 gap-2 px-1.5 data-[state=open]:bg-accent sm:px-2",
|
|
68
|
+
className,
|
|
69
|
+
)}
|
|
70
|
+
/>
|
|
71
|
+
}
|
|
72
|
+
>
|
|
73
|
+
<Initials name={user.name} src={user.image} size="sm" />
|
|
74
|
+
<span
|
|
75
|
+
className={cn(
|
|
76
|
+
"min-w-0 flex-1 flex-col text-left leading-tight",
|
|
77
|
+
variant === "sidebar" ? "flex group-data-[collapsible=icon]:hidden" : "hidden xl:flex",
|
|
78
|
+
)}
|
|
79
|
+
>
|
|
80
|
+
<span className="truncate font-medium text-sm">{user.name}</span>
|
|
81
|
+
{description && (
|
|
82
|
+
<span className="truncate text-muted-foreground text-xs">{description}</span>
|
|
83
|
+
)}
|
|
84
|
+
</span>
|
|
85
|
+
<ChevronsUpDown
|
|
86
|
+
aria-hidden="true"
|
|
87
|
+
className={cn(
|
|
88
|
+
"text-muted-foreground",
|
|
89
|
+
variant === "sidebar" ? "group-data-[collapsible=icon]:hidden" : "hidden xl:block",
|
|
90
|
+
)}
|
|
91
|
+
/>
|
|
92
|
+
</DropdownMenuTrigger>
|
|
93
|
+
<DropdownMenuContent
|
|
94
|
+
align={align}
|
|
95
|
+
side={variant === "header" ? "bottom" : undefined}
|
|
96
|
+
sideOffset={variant === "header" ? 8 : 4}
|
|
97
|
+
className="min-w-60 rounded-lg"
|
|
98
|
+
>
|
|
99
|
+
<DropdownMenuGroup>
|
|
100
|
+
<DropdownMenuLabel className="p-0 font-normal">
|
|
101
|
+
<div className="flex min-w-0 items-center gap-2 px-2 py-2 text-left text-sm">
|
|
102
|
+
<Initials name={user.name} src={user.image} size="sm" />
|
|
103
|
+
<div className="grid min-w-0 flex-1 leading-tight">
|
|
104
|
+
<span className="truncate font-medium">{user.name}</span>
|
|
105
|
+
{user.description && (
|
|
106
|
+
<span className="truncate text-muted-foreground text-xs">{user.description}</span>
|
|
107
|
+
)}
|
|
108
|
+
{user.email && (
|
|
109
|
+
<span className="truncate text-muted-foreground text-xs">{user.email}</span>
|
|
110
|
+
)}
|
|
111
|
+
</div>
|
|
112
|
+
</div>
|
|
113
|
+
</DropdownMenuLabel>
|
|
114
|
+
</DropdownMenuGroup>
|
|
115
|
+
{children && (
|
|
116
|
+
<>
|
|
117
|
+
<DropdownMenuSeparator />
|
|
118
|
+
{children}
|
|
119
|
+
</>
|
|
120
|
+
)}
|
|
121
|
+
{onSignOut && (
|
|
122
|
+
<>
|
|
123
|
+
<DropdownMenuSeparator />
|
|
124
|
+
<DropdownMenuItem disabled={pending} onClick={() => start(() => onSignOut())}>
|
|
125
|
+
<LogOut aria-hidden="true" />
|
|
126
|
+
{signOutLabel}
|
|
127
|
+
</DropdownMenuItem>
|
|
128
|
+
</>
|
|
129
|
+
)}
|
|
130
|
+
</DropdownMenuContent>
|
|
131
|
+
</DropdownMenu>
|
|
132
|
+
);
|
|
133
|
+
}
|