@suphark/ui 0.2.0 → 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 +11 -0
- package/README.md +40 -2
- package/package.json +2 -1
- 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/index.ts +2 -1
- package/src/next.ts +3 -0
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,17 @@
|
|
|
2
2
|
|
|
3
3
|
รูปแบบตาม [Keep a Changelog](https://keepachangelog.com/) · semver: patch = หน้าตา/บั๊ก, minor = component/prop ใหม่, major = เปลี่ยน/ลบ props
|
|
4
4
|
|
|
5
|
+
## [0.3.0] — 2026-09-18
|
|
6
|
+
|
|
7
|
+
### Added
|
|
8
|
+
|
|
9
|
+
- **AppShell** (`@suphark/ui/next`) — โครงแอปกลางแทน ConsoleShell ของ Pi Auth และ main layout ของ supplychain: shadcn Sidebar (ยุบเป็นไอคอน/offcanvas/none, Sheet บนมือถือ, ⌘B, cookie `sidebar_state`), โลโก้ `AppBrand` (name/logo/subtitle/version), ค้นหาเมนู, เมนู 2 ชั้น `AppNav` (ไฮไลต์ตาม pathname, กางอัตโนมัติเมื่อลูก active, dropdown เมื่อยุบ, badge, labs, pin ผ่าน `pin` prop), slot `sidebarHeader`/`sidebarFooter`, header (SidebarTrigger + `HeaderBreadcrumbs` อัตโนมัติ + `center`/`actions`/`banner`), `brandColor` ต่อ tenant, `contentMaxWidth`
|
|
10
|
+
- `UserMenu` (variant `sidebar` | `header`): หัวผู้ใช้ + children (ใส่ DropdownMenuItem/ThemeMenuItems เอง) + `onSignOut`
|
|
11
|
+
- `HeaderBreadcrumbs` (labels/skip/hideIds/homeHref), `NavLink` (จาก Pi Auth)
|
|
12
|
+
- โมเดลเมนู `AppNavGroup`/`AppNavItem` (icon เป็น ReactNode — ประกาศใน server component ได้) + helpers `filterNav` (ตัดตามสิทธิ์ทุกชั้น), `searchNav`, `flattenNav`, `isNavActive` (+tests รวม 40)
|
|
13
|
+
- barrel หลัก export `toast` จาก sonner (แอปไม่ต้องติดตั้ง sonner เอง)
|
|
14
|
+
- showcase: `/shell` ตัวอย่าง layout ที่ใช้ AppShell + UserMenu + ThemeMenuItems + filterNav + cookie defaultOpen
|
|
15
|
+
|
|
5
16
|
## [0.2.0] — 2026-09-18
|
|
6
17
|
|
|
7
18
|
### Added
|
package/README.md
CHANGED
|
@@ -55,8 +55,8 @@ import { ThemeProvider, Toaster } from "@suphark/ui";
|
|
|
55
55
|
// ทุกอย่างที่ไม่พึ่ง Next — primitives, shared, theme, hooks, cn
|
|
56
56
|
import { Button, Card, FormFieldInput, StatusBadge, ThemeToggle, cn } from "@suphark/ui";
|
|
57
57
|
|
|
58
|
-
// ของที่ใช้ next/navigation หรือ next/link
|
|
59
|
-
import { FormDialog, RowActions, LinkButton, RefreshButton, PageShell } from "@suphark/ui/next";
|
|
58
|
+
// ของที่ใช้ next/navigation หรือ next/link + โครงแอป
|
|
59
|
+
import { AppShell, UserMenu, filterNav, FormDialog, RowActions, LinkButton, RefreshButton, PageShell } from "@suphark/ui/next";
|
|
60
60
|
|
|
61
61
|
// ตารางข้อมูล (ต้องติดตั้ง @tanstack/react-table เอง)
|
|
62
62
|
import { DataTable, DataTableColumnHeader, useDataTable } from "@suphark/ui/data-table";
|
|
@@ -72,11 +72,49 @@ import { ChartContainer } from "@suphark/ui/components/ui/chart";
|
|
|
72
72
|
| `src/components/ui/*` | shadcn/ui ต้นฉบับ 59 ตัว (+ `__tests__`) | **ห้ามแก้มือ** — เพิ่ม/อัปเดตด้วย `pnpm dlx shadcn@latest add <name>` ใน `packages/ui` (`components.json` ตั้ง alias ไว้แล้ว) |
|
|
73
73
|
| `src/components/shared/*` | ของที่เราประกอบเอง: ปุ่มมาตรฐาน (`button/`), form-field บน react-hook-form (`form/`), layout, theme, combobox, และชุดจาก Pi Auth (FormDialog, NameIdFields, StatusBadge, RowMenu, …) | แก้ได้ แต่คง props เดิมให้มากที่สุด (minor = เพิ่ม, major = เปลี่ยน) |
|
|
74
74
|
| `src/components/data-table/*` | DataTable บน tanstack (toolbar, faceted filter, pagination, view options, row actions, ลากเรียงแถว) | entry `@suphark/ui/data-table` |
|
|
75
|
+
| `src/components/app-shell/*` | AppShell (sidebar + header + เนื้อหา), AppNav, AppBrand, UserMenu, HeaderBreadcrumbs, NavLink, filterNav/searchNav | export ผ่าน `@suphark/ui/next` (ใช้ next/link, next/navigation) |
|
|
75
76
|
| `src/components/design-system/*` | หน้า showcase (render ใน `apps/showcase`) | ไม่อยู่ใน barrel |
|
|
76
77
|
| `src/lib/*`, `src/hooks/*` | `cn`, formatter วันที่ไทย, สีสถานะ, permission context, `useIsMobile`, `useCopyToClipboard` | |
|
|
77
78
|
| `src/styles.css` | token (slate + success/warning/sidebar/chart), radius, ฟอนต์, scrollbar, dark variant | |
|
|
78
79
|
| `scripts/*.mjs` | audit บังคับ: Base UI props (`default*`/`onSelect`), typography scale | แอปปลายทางรันได้จาก `node_modules/@suphark/ui/scripts` |
|
|
79
80
|
|
|
81
|
+
### โครงแอป (AppShell)
|
|
82
|
+
|
|
83
|
+
```tsx
|
|
84
|
+
// app/(app)/layout.tsx — server component ได้ (icon เป็น element)
|
|
85
|
+
import { ThemeMenuItems } from "@suphark/ui";
|
|
86
|
+
import { AppShell, type AppNavGroup, filterNav, UserMenu } from "@suphark/ui/next";
|
|
87
|
+
import { cookies } from "next/headers";
|
|
88
|
+
|
|
89
|
+
const NAV: AppNavGroup[] = [
|
|
90
|
+
{ title: "งาน", items: [
|
|
91
|
+
{ title: "แดชบอร์ด", href: "/", icon: <LayoutDashboard />, exact: true },
|
|
92
|
+
{ title: "จัดซื้อ", icon: <ShoppingCart />, items: [{ title: "แพ็กเกจ", href: "/packages", permission: "package:read" }] },
|
|
93
|
+
]},
|
|
94
|
+
];
|
|
95
|
+
|
|
96
|
+
export default async function Layout({ children }) {
|
|
97
|
+
const defaultOpen = (await cookies()).get("sidebar_state")?.value !== "false";
|
|
98
|
+
return (
|
|
99
|
+
<AppShell
|
|
100
|
+
brand={{ name: "Pi Auth", subtitle: tenant.name, version: "v1.2.0", versionHref: "/change-log" }}
|
|
101
|
+
nav={filterNav(NAV, can)}
|
|
102
|
+
searchable
|
|
103
|
+
defaultOpen={defaultOpen}
|
|
104
|
+
brandColor={tenant.primaryColor}
|
|
105
|
+
header={{ breadcrumbs: { skip: ["console"] }, actions: <NotificationBell /> }}
|
|
106
|
+
sidebarFooter={<UserMenu user={{ name, email, image }} onSignOut={signOut}><ThemeMenuItems /></UserMenu>}
|
|
107
|
+
>
|
|
108
|
+
{children}
|
|
109
|
+
</AppShell>
|
|
110
|
+
);
|
|
111
|
+
}
|
|
112
|
+
```
|
|
113
|
+
|
|
114
|
+
- `header={false}` ไม่มี header · `collapsible="offcanvas"` ซ่อนทั้งแผง (supplychain) · `contentMaxWidth="max-w-5xl"` จำกัดความกว้าง (Pi Auth)
|
|
115
|
+
- `pin={{ pinnedHrefs, onTogglePin }}` เปิดปุ่มปักหมุดเมนู (แอปเก็บ state เอง) · `header.center` วาง pinned menus
|
|
116
|
+
- ตัวอย่างรันได้ที่ `apps/showcase/src/app/shell`
|
|
117
|
+
|
|
80
118
|
### สีแบรนด์ต่อ tenant/แอป (`--brand`)
|
|
81
119
|
|
|
82
120
|
ตั้ง `--brand` / `--brand-foreground` inline บน wrapper (หรือใส่ `data-brand`) แล้ว `primary`/`ring`/`sidebar-primary` ข้างในตามไปด้วย — ค่าเริ่มต้นไม่ตั้ง `--brand` (primary = slate) ดูตัวอย่างที่ `apps/showcase/src/app/brand`
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@suphark/ui",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.3.0",
|
|
4
4
|
"private": false,
|
|
5
5
|
"type": "module",
|
|
6
6
|
"description": "Design system กลางของ Suphark apps: shadcn/ui (base-nova, Base UI, Tailwind v4) + shared components + token/ฟอนต์ Noto Sans Thai Looped — ส่งเป็น source ให้แอป Next transpile เอง",
|
|
@@ -43,6 +43,7 @@
|
|
|
43
43
|
"./components/shared/button": "./src/components/shared/button/index.ts",
|
|
44
44
|
"./components/shared/theme": "./src/components/shared/theme/index.ts",
|
|
45
45
|
"./components/data-table": "./src/components/data-table/index.ts",
|
|
46
|
+
"./components/app-shell": "./src/components/app-shell/index.ts",
|
|
46
47
|
"./components/*": "./src/components/*.tsx",
|
|
47
48
|
"./lib/*": "./src/lib/*.ts",
|
|
48
49
|
"./hooks/*": "./src/hooks/*.ts",
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import {
|
|
4
|
+
SidebarMenu,
|
|
5
|
+
SidebarMenuButton,
|
|
6
|
+
SidebarMenuItem,
|
|
7
|
+
useSidebar,
|
|
8
|
+
} from "@suphark/ui/components/ui/sidebar";
|
|
9
|
+
import { cn } from "@suphark/ui/lib/utils";
|
|
10
|
+
import Link from "next/link";
|
|
11
|
+
import type { ReactNode } from "react";
|
|
12
|
+
|
|
13
|
+
export interface AppBrandProps {
|
|
14
|
+
/** ชื่อแอป/องค์กร */
|
|
15
|
+
name: ReactNode;
|
|
16
|
+
/** โลโก้ (ไอคอน lucide, <img>, หรือตัวอักษร) — ถ้าไม่ส่งจะใช้ตัวอักษรแรกของ name */
|
|
17
|
+
logo?: ReactNode;
|
|
18
|
+
/** ข้อความบรรทัดล่าง เช่น ชื่อ tenant / แผน */
|
|
19
|
+
subtitle?: ReactNode;
|
|
20
|
+
/** ลิงก์เมื่อกดโลโก้ (ค่าเริ่มต้น "/") */
|
|
21
|
+
href?: string;
|
|
22
|
+
/** ป้ายเล็กด้านขวา เช่น เวอร์ชัน `v1.4.0` — กดแล้วไป `versionHref` (เช่น /change-log) */
|
|
23
|
+
version?: ReactNode;
|
|
24
|
+
versionHref?: string;
|
|
25
|
+
className?: string;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* โลโก้ + ชื่อแอปมุมบน sidebar (ยกจาก supplychain NavLogo + Pi Auth ConsoleShell header)
|
|
30
|
+
* ยุบเป็นไอคอนแล้วเหลือแค่โลโก้
|
|
31
|
+
*/
|
|
32
|
+
export function AppBrand({
|
|
33
|
+
name,
|
|
34
|
+
logo,
|
|
35
|
+
subtitle,
|
|
36
|
+
href = "/",
|
|
37
|
+
version,
|
|
38
|
+
versionHref,
|
|
39
|
+
className,
|
|
40
|
+
}: AppBrandProps) {
|
|
41
|
+
const { setOpenMobile } = useSidebar();
|
|
42
|
+
const closeMobile = () => setOpenMobile(false);
|
|
43
|
+
const mark = (
|
|
44
|
+
<div className="flex aspect-square size-8 shrink-0 items-center justify-center overflow-hidden rounded-lg bg-sidebar-primary font-bold text-sidebar-primary-foreground text-sm [&_img]:size-full [&_img]:object-contain [&_svg]:size-4">
|
|
45
|
+
{logo ?? (typeof name === "string" ? name.charAt(0).toUpperCase() : "•")}
|
|
46
|
+
</div>
|
|
47
|
+
);
|
|
48
|
+
const versionNode =
|
|
49
|
+
version !== undefined &&
|
|
50
|
+
(versionHref ? (
|
|
51
|
+
<Link
|
|
52
|
+
href={versionHref}
|
|
53
|
+
onClick={closeMobile}
|
|
54
|
+
className="shrink-0 rounded-md px-2 py-1 text-muted-foreground text-xs no-underline transition-colors hover:bg-sidebar-accent hover:text-primary group-data-[collapsible=icon]:hidden"
|
|
55
|
+
>
|
|
56
|
+
{version}
|
|
57
|
+
</Link>
|
|
58
|
+
) : (
|
|
59
|
+
<span className="shrink-0 px-2 text-muted-foreground text-xs group-data-[collapsible=icon]:hidden">
|
|
60
|
+
{version}
|
|
61
|
+
</span>
|
|
62
|
+
));
|
|
63
|
+
return (
|
|
64
|
+
<SidebarMenu>
|
|
65
|
+
<SidebarMenuItem className={cn("flex items-center", className)}>
|
|
66
|
+
<SidebarMenuButton
|
|
67
|
+
size="lg"
|
|
68
|
+
className="min-w-0 flex-1 group-data-[collapsible=icon]:p-0!"
|
|
69
|
+
render={<Link href={href} onClick={closeMobile} />}
|
|
70
|
+
>
|
|
71
|
+
{mark}
|
|
72
|
+
<div className="grid flex-1 overflow-hidden text-left text-sm leading-tight group-data-[collapsible=icon]:hidden">
|
|
73
|
+
<span className="truncate font-medium text-sidebar-foreground">{name}</span>
|
|
74
|
+
{subtitle && <span className="truncate text-muted-foreground text-xs">{subtitle}</span>}
|
|
75
|
+
</div>
|
|
76
|
+
</SidebarMenuButton>
|
|
77
|
+
{versionNode}
|
|
78
|
+
</SidebarMenuItem>
|
|
79
|
+
</SidebarMenu>
|
|
80
|
+
);
|
|
81
|
+
}
|
|
@@ -0,0 +1,260 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import {
|
|
4
|
+
Collapsible,
|
|
5
|
+
CollapsibleContent,
|
|
6
|
+
CollapsibleTrigger,
|
|
7
|
+
} from "@suphark/ui/components/ui/collapsible";
|
|
8
|
+
import {
|
|
9
|
+
DropdownMenu,
|
|
10
|
+
DropdownMenuContent,
|
|
11
|
+
DropdownMenuGroup,
|
|
12
|
+
DropdownMenuItem,
|
|
13
|
+
DropdownMenuLabel,
|
|
14
|
+
DropdownMenuSeparator,
|
|
15
|
+
DropdownMenuTrigger,
|
|
16
|
+
} from "@suphark/ui/components/ui/dropdown-menu";
|
|
17
|
+
import {
|
|
18
|
+
SidebarGroup,
|
|
19
|
+
SidebarGroupLabel,
|
|
20
|
+
SidebarMenu,
|
|
21
|
+
SidebarMenuAction,
|
|
22
|
+
SidebarMenuBadge,
|
|
23
|
+
SidebarMenuButton,
|
|
24
|
+
SidebarMenuItem,
|
|
25
|
+
SidebarMenuSub,
|
|
26
|
+
SidebarMenuSubButton,
|
|
27
|
+
SidebarMenuSubItem,
|
|
28
|
+
useSidebar,
|
|
29
|
+
} from "@suphark/ui/components/ui/sidebar";
|
|
30
|
+
import { Tooltip, TooltipContent, TooltipTrigger } from "@suphark/ui/components/ui/tooltip";
|
|
31
|
+
import { cn } from "@suphark/ui/lib/utils";
|
|
32
|
+
import { ChevronRight, FlaskConical, Pin } from "lucide-react";
|
|
33
|
+
import Link from "next/link";
|
|
34
|
+
import { usePathname } from "next/navigation";
|
|
35
|
+
import { type AppNavGroup, type AppNavItem, isNavActive } from "./types";
|
|
36
|
+
|
|
37
|
+
/** ปุ่ม pin — แสดงเมื่อแอปส่ง `pinnedHrefs` + `onTogglePin` (supplychain เก็บใน user preferences) */
|
|
38
|
+
export interface AppNavPinProps {
|
|
39
|
+
pinnedHrefs?: string[];
|
|
40
|
+
onTogglePin?: (href: string) => void;
|
|
41
|
+
pinLabel?: string;
|
|
42
|
+
unpinLabel?: string;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function MenuTitle({ title, isLabs }: { title: string; isLabs?: boolean }) {
|
|
46
|
+
return (
|
|
47
|
+
<Tooltip>
|
|
48
|
+
<TooltipTrigger
|
|
49
|
+
render={<span className="flex w-full items-center gap-1.5 overflow-hidden" />}
|
|
50
|
+
>
|
|
51
|
+
<span
|
|
52
|
+
className={cn("truncate", isLabs && "font-medium text-amber-600 dark:text-amber-500")}
|
|
53
|
+
>
|
|
54
|
+
{title}
|
|
55
|
+
</span>
|
|
56
|
+
{isLabs && <FlaskConical className="h-3 w-3 shrink-0 text-amber-600 dark:text-amber-500" />}
|
|
57
|
+
</TooltipTrigger>
|
|
58
|
+
<TooltipContent side="right" align="center" className="max-w-xs break-words">
|
|
59
|
+
{title}
|
|
60
|
+
</TooltipContent>
|
|
61
|
+
</Tooltip>
|
|
62
|
+
);
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function PinAction({
|
|
66
|
+
href,
|
|
67
|
+
pin,
|
|
68
|
+
className,
|
|
69
|
+
}: {
|
|
70
|
+
href: string;
|
|
71
|
+
pin: AppNavPinProps;
|
|
72
|
+
className?: string;
|
|
73
|
+
}) {
|
|
74
|
+
if (!pin.onTogglePin) return null;
|
|
75
|
+
const isPinned = pin.pinnedHrefs?.includes(href) ?? false;
|
|
76
|
+
const label = isPinned ? (pin.unpinLabel ?? "เลิกปักหมุด") : (pin.pinLabel ?? "ปักหมุดที่ header");
|
|
77
|
+
return (
|
|
78
|
+
<SidebarMenuAction
|
|
79
|
+
showOnHover
|
|
80
|
+
onClick={() => pin.onTogglePin?.(href)}
|
|
81
|
+
title={label}
|
|
82
|
+
className={className}
|
|
83
|
+
>
|
|
84
|
+
<Pin className={cn("size-3.5", isPinned && "fill-current")} />
|
|
85
|
+
<span className="sr-only">{label}</span>
|
|
86
|
+
</SidebarMenuAction>
|
|
87
|
+
);
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* กลุ่มเมนูใน sidebar (ยกจาก supplychain NavMain): 2 ชั้น, กางได้, ยุบเป็นไอคอนแล้วเป็น dropdown,
|
|
92
|
+
* ไฮไลต์ตาม pathname (prefix match), ป้าย badge, ฟีเจอร์ labs, และ pin (ถ้าแอปส่งมา)
|
|
93
|
+
*/
|
|
94
|
+
export function AppNavGroupMenu({
|
|
95
|
+
group,
|
|
96
|
+
pin = {},
|
|
97
|
+
forceOpen = false,
|
|
98
|
+
}: {
|
|
99
|
+
group: AppNavGroup;
|
|
100
|
+
pin?: AppNavPinProps;
|
|
101
|
+
/** กางทุกกลุ่มย่อย (ใช้ตอนค้นหาเมนู) */
|
|
102
|
+
forceOpen?: boolean;
|
|
103
|
+
}) {
|
|
104
|
+
const { state, isMobile, setOpenMobile } = useSidebar();
|
|
105
|
+
const pathname = usePathname();
|
|
106
|
+
const isCollapsed = state === "collapsed" && !isMobile;
|
|
107
|
+
// บนมือถือ sidebar เป็น Sheet — กดลิงก์แล้วปิดให้
|
|
108
|
+
const closeMobile = () => setOpenMobile(false);
|
|
109
|
+
|
|
110
|
+
const renderLeaf = (item: AppNavItem & { href: string }) => {
|
|
111
|
+
const active = isNavActive(pathname, item.href, item.exact);
|
|
112
|
+
return (
|
|
113
|
+
<SidebarMenuItem key={item.href}>
|
|
114
|
+
<SidebarMenuButton
|
|
115
|
+
isActive={active}
|
|
116
|
+
tooltip={item.title}
|
|
117
|
+
render={<Link href={item.href} onClick={closeMobile} />}
|
|
118
|
+
>
|
|
119
|
+
{item.icon}
|
|
120
|
+
<MenuTitle title={item.title} isLabs={item.isLabs} />
|
|
121
|
+
</SidebarMenuButton>
|
|
122
|
+
{item.badge !== undefined && <SidebarMenuBadge>{item.badge}</SidebarMenuBadge>}
|
|
123
|
+
<PinAction href={item.href} pin={pin} />
|
|
124
|
+
</SidebarMenuItem>
|
|
125
|
+
);
|
|
126
|
+
};
|
|
127
|
+
|
|
128
|
+
return (
|
|
129
|
+
<SidebarGroup>
|
|
130
|
+
{group.title && <SidebarGroupLabel>{group.title}</SidebarGroupLabel>}
|
|
131
|
+
<SidebarMenu>
|
|
132
|
+
{group.items.map((item) => {
|
|
133
|
+
const children = item.items ?? [];
|
|
134
|
+
const hasChildren = children.length > 0;
|
|
135
|
+
|
|
136
|
+
// ใบ (ไม่มีลูก)
|
|
137
|
+
if (!hasChildren) {
|
|
138
|
+
if (!item.href) return null;
|
|
139
|
+
return renderLeaf({ ...item, href: item.href });
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
const childActive = children.some((c) => isNavActive(pathname, c.href, c.exact));
|
|
143
|
+
|
|
144
|
+
// ยุบเป็นไอคอน + มีลูก → dropdown ด้านขวา
|
|
145
|
+
if (isCollapsed) {
|
|
146
|
+
return (
|
|
147
|
+
<SidebarMenuItem key={item.title}>
|
|
148
|
+
<DropdownMenu>
|
|
149
|
+
<DropdownMenuTrigger
|
|
150
|
+
render={<SidebarMenuButton tooltip={item.title} isActive={childActive} />}
|
|
151
|
+
>
|
|
152
|
+
{item.icon}
|
|
153
|
+
<MenuTitle title={item.title} isLabs={item.isLabs} />
|
|
154
|
+
<ChevronRight className="ml-auto" />
|
|
155
|
+
</DropdownMenuTrigger>
|
|
156
|
+
<DropdownMenuContent side="right" align="start" sideOffset={4}>
|
|
157
|
+
<DropdownMenuGroup>
|
|
158
|
+
<DropdownMenuLabel>{item.title}</DropdownMenuLabel>
|
|
159
|
+
<DropdownMenuSeparator />
|
|
160
|
+
{children.map((sub) => (
|
|
161
|
+
<DropdownMenuItem
|
|
162
|
+
key={sub.href ?? sub.title}
|
|
163
|
+
render={
|
|
164
|
+
<Link
|
|
165
|
+
href={sub.href ?? "#"}
|
|
166
|
+
className="flex cursor-pointer items-center gap-2"
|
|
167
|
+
/>
|
|
168
|
+
}
|
|
169
|
+
>
|
|
170
|
+
{sub.icon}
|
|
171
|
+
<MenuTitle title={sub.title} isLabs={sub.isLabs} />
|
|
172
|
+
</DropdownMenuItem>
|
|
173
|
+
))}
|
|
174
|
+
</DropdownMenuGroup>
|
|
175
|
+
</DropdownMenuContent>
|
|
176
|
+
</DropdownMenu>
|
|
177
|
+
</SidebarMenuItem>
|
|
178
|
+
);
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
// กางปกติ — เปิดค้างเมื่อลูกตัวใดตัวหนึ่ง active หรือกำลังค้นหา
|
|
182
|
+
const open = forceOpen || childActive;
|
|
183
|
+
return (
|
|
184
|
+
<Collapsible
|
|
185
|
+
key={`${item.title}-${open}`}
|
|
186
|
+
render={<SidebarMenuItem />}
|
|
187
|
+
defaultOpen={open}
|
|
188
|
+
className="group/collapsible"
|
|
189
|
+
>
|
|
190
|
+
<CollapsibleTrigger
|
|
191
|
+
render={<SidebarMenuButton tooltip={item.title} suppressHydrationWarning />}
|
|
192
|
+
>
|
|
193
|
+
{item.icon}
|
|
194
|
+
<MenuTitle title={item.title} isLabs={item.isLabs} />
|
|
195
|
+
<ChevronRight className="ml-auto transition-transform duration-200 group-data-[state=open]/collapsible:rotate-90" />
|
|
196
|
+
</CollapsibleTrigger>
|
|
197
|
+
<CollapsibleContent suppressHydrationWarning>
|
|
198
|
+
<SidebarMenuSub>
|
|
199
|
+
{children.map((sub) => {
|
|
200
|
+
if (!sub.href) return null;
|
|
201
|
+
const active = isNavActive(pathname, sub.href, sub.exact);
|
|
202
|
+
return (
|
|
203
|
+
<SidebarMenuSubItem key={sub.href}>
|
|
204
|
+
<SidebarMenuSubButton
|
|
205
|
+
isActive={active}
|
|
206
|
+
render={
|
|
207
|
+
<Link
|
|
208
|
+
href={sub.href}
|
|
209
|
+
className="flex items-center gap-2"
|
|
210
|
+
onClick={closeMobile}
|
|
211
|
+
/>
|
|
212
|
+
}
|
|
213
|
+
>
|
|
214
|
+
{sub.icon}
|
|
215
|
+
<MenuTitle title={sub.title} isLabs={sub.isLabs} />
|
|
216
|
+
</SidebarMenuSubButton>
|
|
217
|
+
{sub.badge !== undefined && (
|
|
218
|
+
<SidebarMenuBadge>{sub.badge}</SidebarMenuBadge>
|
|
219
|
+
)}
|
|
220
|
+
<PinAction
|
|
221
|
+
href={sub.href}
|
|
222
|
+
pin={pin}
|
|
223
|
+
className="top-1 right-2 opacity-0 transition-opacity group-hover/menu-sub-item:opacity-100"
|
|
224
|
+
/>
|
|
225
|
+
</SidebarMenuSubItem>
|
|
226
|
+
);
|
|
227
|
+
})}
|
|
228
|
+
</SidebarMenuSub>
|
|
229
|
+
</CollapsibleContent>
|
|
230
|
+
</Collapsible>
|
|
231
|
+
);
|
|
232
|
+
})}
|
|
233
|
+
</SidebarMenu>
|
|
234
|
+
</SidebarGroup>
|
|
235
|
+
);
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
/** ทุกกลุ่มเมนู */
|
|
239
|
+
export function AppNav({
|
|
240
|
+
nav,
|
|
241
|
+
pin,
|
|
242
|
+
forceOpen,
|
|
243
|
+
}: {
|
|
244
|
+
nav: AppNavGroup[];
|
|
245
|
+
pin?: AppNavPinProps;
|
|
246
|
+
forceOpen?: boolean;
|
|
247
|
+
}) {
|
|
248
|
+
return (
|
|
249
|
+
<>
|
|
250
|
+
{nav.map((group, i) => (
|
|
251
|
+
<AppNavGroupMenu
|
|
252
|
+
key={group.title ?? `group-${i}`}
|
|
253
|
+
group={group}
|
|
254
|
+
pin={pin}
|
|
255
|
+
forceOpen={forceOpen}
|
|
256
|
+
/>
|
|
257
|
+
))}
|
|
258
|
+
</>
|
|
259
|
+
);
|
|
260
|
+
}
|
|
@@ -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
|
+
}
|
package/src/index.ts
CHANGED
|
@@ -9,6 +9,8 @@
|
|
|
9
9
|
* `chart` (recharts) ไม่อยู่ใน barrel: import ตรง `@suphark/ui/components/ui/chart` เมื่อแอปติดตั้ง recharts เอง
|
|
10
10
|
*/
|
|
11
11
|
|
|
12
|
+
// sonner: `toast()` คู่กับ <Toaster /> — แอปไม่ต้องติดตั้ง sonner เอง
|
|
13
|
+
export { toast } from "sonner";
|
|
12
14
|
export * from "./components/shared/button";
|
|
13
15
|
export { ClientRedirect } from "./components/shared/client-redirect";
|
|
14
16
|
export { Combobox } from "./components/shared/combobox";
|
|
@@ -131,7 +133,6 @@ export * from "./components/ui/textarea";
|
|
|
131
133
|
export * from "./components/ui/toggle";
|
|
132
134
|
export * from "./components/ui/toggle-group";
|
|
133
135
|
export * from "./components/ui/tooltip";
|
|
134
|
-
|
|
135
136
|
// ---------- lib / hooks ----------
|
|
136
137
|
export { useCopyToClipboard } from "./hooks/use-copy-to-clipboard";
|
|
137
138
|
export { useIsMobile } from "./hooks/use-mobile";
|
package/src/next.ts
CHANGED
|
@@ -2,6 +2,9 @@
|
|
|
2
2
|
* @suphark/ui/next — component ที่ต้องรันใน Next.js (ใช้ next/navigation หรือ next/link)
|
|
3
3
|
* แยกจาก barrel หลักเพื่อให้ `@suphark/ui` ใช้ได้ในสภาพแวดล้อมที่ไม่มี next (vitest, Vite, Storybook)
|
|
4
4
|
*/
|
|
5
|
+
|
|
6
|
+
// ---------- app shell (sidebar + header + เนื้อหา) ----------
|
|
7
|
+
export * from "./components/app-shell";
|
|
5
8
|
export { LinkButton, type LinkButtonProps } from "./components/shared/button/base/link-button";
|
|
6
9
|
export { RefreshButton } from "./components/shared/button/refresh-button";
|
|
7
10
|
export { FormDialog } from "./components/shared/form-dialog";
|