@suphark/ui 0.1.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.
Files changed (147) hide show
  1. package/CHANGELOG.md +36 -0
  2. package/LICENSE +21 -0
  3. package/README.md +120 -0
  4. package/components.json +21 -0
  5. package/package.json +108 -0
  6. package/scripts/audit-base-ui-props.mjs +179 -0
  7. package/scripts/audit-tailwind-typography.mjs +100 -0
  8. package/src/components/design-system/design-system-showcase.tsx +138 -0
  9. package/src/components/design-system/sections/showcase-data-display.tsx +454 -0
  10. package/src/components/design-system/sections/showcase-feedback.tsx +273 -0
  11. package/src/components/design-system/sections/showcase-forms.tsx +373 -0
  12. package/src/components/design-system/sections/showcase-foundations.tsx +384 -0
  13. package/src/components/design-system/sections/showcase-guidelines-recipes.tsx +397 -0
  14. package/src/components/design-system/sections/showcase-overlays-navigation.tsx +479 -0
  15. package/src/components/design-system/sections/showcase-overview.tsx +368 -0
  16. package/src/components/design-system/sections/showcase-primitives.tsx +463 -0
  17. package/src/components/shared/__tests__/multi-select-combobox.test.tsx +180 -0
  18. package/src/components/shared/__tests__/name-id-fields.test.ts +24 -0
  19. package/src/components/shared/button/add-new-button.tsx +19 -0
  20. package/src/components/shared/button/base/action-button.tsx +134 -0
  21. package/src/components/shared/button/base/link-button.tsx +130 -0
  22. package/src/components/shared/button/cancel-button.tsx +27 -0
  23. package/src/components/shared/button/confirm-button.tsx +26 -0
  24. package/src/components/shared/button/delete-button.tsx +36 -0
  25. package/src/components/shared/button/floating-save-button.tsx +89 -0
  26. package/src/components/shared/button/index.ts +13 -0
  27. package/src/components/shared/button/print-button.tsx +67 -0
  28. package/src/components/shared/button/refresh-button.tsx +63 -0
  29. package/src/components/shared/button/save-button.tsx +29 -0
  30. package/src/components/shared/button/submit-button.tsx +58 -0
  31. package/src/components/shared/client-redirect.tsx +11 -0
  32. package/src/components/shared/combobox.tsx +116 -0
  33. package/src/components/shared/data-list.tsx +65 -0
  34. package/src/components/shared/date/date-range-picker.tsx +106 -0
  35. package/src/components/shared/description-list.tsx +27 -0
  36. package/src/components/shared/empty-row.tsx +13 -0
  37. package/src/components/shared/empty-state.tsx +31 -0
  38. package/src/components/shared/env-snippet.tsx +62 -0
  39. package/src/components/shared/form/__tests__/simple-select.test.tsx +72 -0
  40. package/src/components/shared/form/form-field-boolean.tsx +93 -0
  41. package/src/components/shared/form/form-field-color-select.tsx +86 -0
  42. package/src/components/shared/form/form-field-combobox.tsx +167 -0
  43. package/src/components/shared/form/form-field-date.tsx +55 -0
  44. package/src/components/shared/form/form-field-formatted-number.tsx +103 -0
  45. package/src/components/shared/form/form-field-input.tsx +372 -0
  46. package/src/components/shared/form/form-field-radio-cards.tsx +127 -0
  47. package/src/components/shared/form/form-field-radio-group.tsx +56 -0
  48. package/src/components/shared/form/form-field-select.tsx +312 -0
  49. package/src/components/shared/form/simple-select.tsx +152 -0
  50. package/src/components/shared/form-dialog.tsx +109 -0
  51. package/src/components/shared/form-message.tsx +30 -0
  52. package/src/components/shared/initials.tsx +33 -0
  53. package/src/components/shared/layout/error-state.tsx +160 -0
  54. package/src/components/shared/layout/page-shell.tsx +57 -0
  55. package/src/components/shared/link/transition-link.tsx +72 -0
  56. package/src/components/shared/multi-select-combobox.tsx +392 -0
  57. package/src/components/shared/name-id-fields.tsx +133 -0
  58. package/src/components/shared/navigation/page-breadcrumb.tsx +100 -0
  59. package/src/components/shared/notice.tsx +35 -0
  60. package/src/components/shared/number/formatted-number.tsx +133 -0
  61. package/src/components/shared/page-header.tsx +25 -0
  62. package/src/components/shared/page-skeleton.tsx +18 -0
  63. package/src/components/shared/row-actions.tsx +93 -0
  64. package/src/components/shared/row-menu.tsx +55 -0
  65. package/src/components/shared/secret-reveal.tsx +53 -0
  66. package/src/components/shared/section-card.tsx +44 -0
  67. package/src/components/shared/stat.tsx +26 -0
  68. package/src/components/shared/status-badge.tsx +47 -0
  69. package/src/components/shared/text/primary-secondary-text.tsx +186 -0
  70. package/src/components/shared/theme/index.ts +4 -0
  71. package/src/components/shared/theme/theme-menu-items.tsx +43 -0
  72. package/src/components/shared/theme/theme-provider.tsx +39 -0
  73. package/src/components/shared/theme/theme-switch.tsx +36 -0
  74. package/src/components/shared/theme/theme-toggle.tsx +76 -0
  75. package/src/components/shared/time/client-date-formatter.tsx +188 -0
  76. package/src/components/shared/ui-text.tsx +47 -0
  77. package/src/components/ui/__tests__/combobox.test.tsx +40 -0
  78. package/src/components/ui/__tests__/date-picker.test.tsx +53 -0
  79. package/src/components/ui/__tests__/slider.test.tsx +18 -0
  80. package/src/components/ui/__tests__/tabs.test.tsx +33 -0
  81. package/src/components/ui/accordion.tsx +69 -0
  82. package/src/components/ui/alert-dialog.tsx +161 -0
  83. package/src/components/ui/alert.tsx +68 -0
  84. package/src/components/ui/aspect-ratio.tsx +22 -0
  85. package/src/components/ui/attachment.tsx +194 -0
  86. package/src/components/ui/avatar.tsx +92 -0
  87. package/src/components/ui/badge.tsx +48 -0
  88. package/src/components/ui/breadcrumb.tsx +102 -0
  89. package/src/components/ui/button-group.tsx +77 -0
  90. package/src/components/ui/button.tsx +57 -0
  91. package/src/components/ui/calendar.tsx +184 -0
  92. package/src/components/ui/card.tsx +87 -0
  93. package/src/components/ui/carousel.tsx +230 -0
  94. package/src/components/ui/chart.tsx +338 -0
  95. package/src/components/ui/checkbox.tsx +27 -0
  96. package/src/components/ui/collapsible.tsx +17 -0
  97. package/src/components/ui/combobox.tsx +272 -0
  98. package/src/components/ui/command.tsx +180 -0
  99. package/src/components/ui/context-menu.tsx +244 -0
  100. package/src/components/ui/date-picker.tsx +308 -0
  101. package/src/components/ui/dialog.tsx +137 -0
  102. package/src/components/ui/drawer.tsx +208 -0
  103. package/src/components/ui/dropdown-menu.tsx +257 -0
  104. package/src/components/ui/empty.tsx +93 -0
  105. package/src/components/ui/field.tsx +223 -0
  106. package/src/components/ui/form.tsx +161 -0
  107. package/src/components/ui/hover-card.tsx +46 -0
  108. package/src/components/ui/input-group.tsx +145 -0
  109. package/src/components/ui/input-otp.tsx +85 -0
  110. package/src/components/ui/input.tsx +19 -0
  111. package/src/components/ui/item.tsx +187 -0
  112. package/src/components/ui/kbd.tsx +26 -0
  113. package/src/components/ui/label.tsx +19 -0
  114. package/src/components/ui/menubar.tsx +266 -0
  115. package/src/components/ui/navigation-menu.tsx +160 -0
  116. package/src/components/ui/pagination.tsx +117 -0
  117. package/src/components/ui/popover.tsx +76 -0
  118. package/src/components/ui/progress.tsx +66 -0
  119. package/src/components/ui/radio-group.tsx +38 -0
  120. package/src/components/ui/relative-date.tsx +40 -0
  121. package/src/components/ui/resizable.tsx +41 -0
  122. package/src/components/ui/scroll-area.tsx +67 -0
  123. package/src/components/ui/searchable-select.tsx +124 -0
  124. package/src/components/ui/select.tsx +189 -0
  125. package/src/components/ui/separator.tsx +21 -0
  126. package/src/components/ui/sheet.tsx +124 -0
  127. package/src/components/ui/sidebar.tsx +691 -0
  128. package/src/components/ui/skeleton.tsx +13 -0
  129. package/src/components/ui/slider.tsx +55 -0
  130. package/src/components/ui/sonner.tsx +45 -0
  131. package/src/components/ui/spinner.tsx +16 -0
  132. package/src/components/ui/switch.tsx +32 -0
  133. package/src/components/ui/table.tsx +95 -0
  134. package/src/components/ui/tabs.tsx +79 -0
  135. package/src/components/ui/textarea.tsx +17 -0
  136. package/src/components/ui/toggle-group.tsx +86 -0
  137. package/src/components/ui/toggle.tsx +44 -0
  138. package/src/components/ui/tooltip.tsx +54 -0
  139. package/src/hooks/use-copy-to-clipboard.ts +27 -0
  140. package/src/hooks/use-mobile.ts +21 -0
  141. package/src/index.ts +156 -0
  142. package/src/lib/formatters.ts +44 -0
  143. package/src/lib/permission.ts +75 -0
  144. package/src/lib/status-colors.ts +52 -0
  145. package/src/lib/utils.ts +6 -0
  146. package/src/next.ts +15 -0
  147. package/src/styles.css +214 -0
@@ -0,0 +1,44 @@
1
+ import {
2
+ Card,
3
+ CardAction,
4
+ CardContent,
5
+ CardDescription,
6
+ CardHeader,
7
+ CardTitle,
8
+ } from "@suphark/ui/components/ui/card";
9
+ import { cn } from "@suphark/ui/lib/utils";
10
+ import type { ReactNode } from "react";
11
+
12
+ /**
13
+ * Card ที่มีหัว (title/description/actions) แบบสั้น ๆ สำหรับหน้า console —
14
+ * ประกอบจาก Card ของ shadcn (ไม่แก้ต้นฉบับ); ถ้าต้องการ layout อื่นใช้ Card* ตรง ๆ ได้
15
+ */
16
+ export function SectionCard({
17
+ title,
18
+ description,
19
+ actions,
20
+ children,
21
+ className,
22
+ contentClassName,
23
+ }: {
24
+ title?: ReactNode;
25
+ description?: ReactNode;
26
+ actions?: ReactNode;
27
+ children: ReactNode;
28
+ className?: string;
29
+ /** ตารางเต็มความกว้างใช้ "p-0" */
30
+ contentClassName?: string;
31
+ }) {
32
+ return (
33
+ <Card className={cn("gap-4", className)}>
34
+ {(title || description || actions) && (
35
+ <CardHeader>
36
+ {title && <CardTitle>{title}</CardTitle>}
37
+ {description && <CardDescription>{description}</CardDescription>}
38
+ {actions && <CardAction>{actions}</CardAction>}
39
+ </CardHeader>
40
+ )}
41
+ <CardContent className={contentClassName}>{children}</CardContent>
42
+ </Card>
43
+ );
44
+ }
@@ -0,0 +1,26 @@
1
+ import { Card, CardContent } from "@suphark/ui/components/ui/card";
2
+ import { cn } from "@suphark/ui/lib/utils";
3
+ import type { ReactNode } from "react";
4
+
5
+ /** ตัวเลขสรุปบนหน้าภาพรวม/สุขภาพระบบ */
6
+ export function Stat({
7
+ label,
8
+ value,
9
+ hint,
10
+ className,
11
+ }: {
12
+ label: ReactNode;
13
+ value: ReactNode;
14
+ hint?: ReactNode;
15
+ className?: string;
16
+ }) {
17
+ return (
18
+ <Card className={cn("gap-1 py-4", className)}>
19
+ <CardContent className="flex flex-col gap-1">
20
+ <span className="text-xs font-medium text-muted-foreground">{label}</span>
21
+ <span className="text-2xl font-semibold tabular-nums tracking-tight">{value}</span>
22
+ {hint && <span className="text-xs text-muted-foreground">{hint}</span>}
23
+ </CardContent>
24
+ </Card>
25
+ );
26
+ }
@@ -0,0 +1,47 @@
1
+ import { Badge } from "@suphark/ui/components/ui/badge";
2
+ import { cn } from "@suphark/ui/lib/utils";
3
+ import type { ComponentProps } from "react";
4
+
5
+ export type Tone = "neutral" | "success" | "warning" | "danger" | "info";
6
+
7
+ const TONE: Record<Tone, string> = {
8
+ neutral: "border-transparent bg-muted text-muted-foreground",
9
+ success:
10
+ "border-emerald-200 bg-emerald-50 text-emerald-800 dark:border-emerald-900 dark:bg-emerald-950 dark:text-emerald-200",
11
+ warning:
12
+ "border-amber-200 bg-amber-50 text-amber-800 dark:border-amber-900 dark:bg-amber-950 dark:text-amber-200",
13
+ danger:
14
+ "border-red-200 bg-red-50 text-red-800 dark:border-red-900 dark:bg-red-950 dark:text-red-200",
15
+ info: "border-sky-200 bg-sky-50 text-sky-800 dark:border-sky-900 dark:bg-sky-950 dark:text-sky-200",
16
+ };
17
+
18
+ /** Badge ของ shadcn (variant outline) + สีตามความหมายของสถานะ */
19
+ export function StatusBadge({
20
+ tone = "neutral",
21
+ className,
22
+ ...props
23
+ }: Omit<ComponentProps<typeof Badge>, "variant"> & { tone?: Tone }) {
24
+ return <Badge variant="outline" className={cn(TONE[tone], className)} {...props} />;
25
+ }
26
+
27
+ /** สถานะที่ใช้บ่อยในระบบ → tone */
28
+ export function statusTone(status: string): Tone {
29
+ switch (status) {
30
+ case "ACTIVE":
31
+ case "SUCCEEDED":
32
+ case "DISPATCHED":
33
+ return "success";
34
+ case "SUSPENDED":
35
+ case "PENDING":
36
+ case "INVITED":
37
+ case "FAILED":
38
+ return "warning";
39
+ case "LEFT":
40
+ case "DEAD":
41
+ case "DELETED":
42
+ case "ARCHIVED":
43
+ return "danger";
44
+ default:
45
+ return "neutral";
46
+ }
47
+ }
@@ -0,0 +1,186 @@
1
+ import { cn } from "@suphark/ui/lib/utils";
2
+ import type * as React from "react";
3
+
4
+ // แยก Props พื้นฐานที่ใช้ร่วมกันออกมา (Common props)
5
+ interface PrimarySecondaryTextBaseProps {
6
+ /**
7
+ * The main, primary content to display.
8
+ */
9
+ primary: React.ReactNode;
10
+
11
+ /**
12
+ * The secondary, supporting content.
13
+ * If not provided, this element will not be rendered.
14
+ */
15
+ secondary?: React.ReactNode;
16
+
17
+ /**
18
+ * Optional CSS class(es) to apply to the root container div.
19
+ */
20
+ className?: string;
21
+
22
+ /**
23
+ * Optional CSS class(es) to apply to the primary (top/left) text span.
24
+ */
25
+ primaryClassName?: string;
26
+
27
+ /**
28
+ * Optional CSS class(es) to apply to the secondary (bottom/right) text span.
29
+ * Defaults to "text-xs text-muted-foreground".
30
+ */
31
+ secondaryClassName?: string;
32
+ }
33
+
34
+ // สร้าง Type ที่แตกต่างกันเพื่อใช้ทำ Discriminated Union
35
+ // แบบที่ 1: แนวนอน (default)
36
+ type VerticalProps = {
37
+ /**
38
+ * Specifies the display layout direction.
39
+ * @default 'vertical'
40
+ */
41
+ orientation?: "vertical";
42
+ /**
43
+ * Separator is not allowed for vertical orientation.
44
+ * Providing this prop when orientation is 'vertical' will cause a TypeScript error.
45
+ */
46
+ separator?: never; // สำคัญ! ห้ามใช้ 'separator' เมื่อ orientation เป็น 'vertical'
47
+ };
48
+
49
+ // แบบที่ 2: แนวนอน
50
+ type HorizontalProps = {
51
+ orientation: "horizontal";
52
+ /**
53
+ * Specifies the separator style *only* when orientation is 'horizontal'.
54
+ * - 'pipe': Displays |
55
+ * - 'dash': Displays -
56
+ * - 'parentheses': Wraps the secondary text in ( ).
57
+ * If omitted, primary and secondary are separated by a space.
58
+ */
59
+ separator?: "pipe" | "parentheses" | "dash"; // [EDIT] กลับเป็น string union + เพิ่ม 'dash'
60
+ };
61
+
62
+ /**
63
+ * รวม Props ทั้งหมดเข้าด้วยกัน
64
+ * Combines base props with the orientation-specific props.
65
+ * This creates a discriminated union based on the 'orientation' prop.
66
+ */
67
+ export type PrimarySecondaryTextProps = PrimarySecondaryTextBaseProps &
68
+ (VerticalProps | HorizontalProps) & {
69
+ /**
70
+ * Array of items to display. If provided, primary/secondary props are ignored (or used as fallback if logic dictates, but typically mutually exclusive in usage).
71
+ */
72
+ items?: {
73
+ primary: React.ReactNode;
74
+ secondary?: React.ReactNode;
75
+ }[];
76
+ /**
77
+ * Style of the list when items are provided.
78
+ * @default 'none'
79
+ */
80
+ listStyle?: "none" | "bullet";
81
+ /**
82
+ * Gap between items in the list (tailwind class).
83
+ * @default 'gap-1' for vertical, 'gap-2' for horizontal (if supported later) or standard vertical stack gap.
84
+ */
85
+ gap?: string;
86
+ };
87
+
88
+ /**
89
+ * A reusable component to display two lines of text, stacked vertically (default)
90
+ * or horizontally. Ideal for showing primary info (like a name) with
91
+ * secondary info (like an ID or email) next to or below it.
92
+ *
93
+ * Supports rendering a list of items via the `items` prop.
94
+ */
95
+ export function PrimarySecondaryText({
96
+ primary,
97
+ secondary,
98
+ className,
99
+ primaryClassName,
100
+ secondaryClassName,
101
+ orientation = "vertical",
102
+ separator,
103
+ items,
104
+ listStyle = "none",
105
+ gap,
106
+ }: PrimarySecondaryTextProps) {
107
+ const isHorizontal = orientation === "horizontal";
108
+ const defaultSecondaryStyles = "text-xs text-muted-foreground";
109
+
110
+ // Helper to render a single item
111
+ const renderItem = (p: React.ReactNode, s: React.ReactNode, idx?: number) => {
112
+ let separatorElement: React.ReactNode = null;
113
+ if (isHorizontal && s) {
114
+ if (separator === "pipe") {
115
+ separatorElement = (
116
+ <span
117
+ className={cn("mx-1.5", defaultSecondaryStyles, secondaryClassName)}
118
+ aria-hidden="true"
119
+ >
120
+ |
121
+ </span>
122
+ );
123
+ } else if (separator === "dash") {
124
+ separatorElement = (
125
+ <span
126
+ className={cn("mx-1.5", defaultSecondaryStyles, secondaryClassName)}
127
+ aria-hidden="true"
128
+ >
129
+ -
130
+ </span>
131
+ );
132
+ } else if (!separator && separator !== "parentheses") {
133
+ separatorElement = <span className="ml-1.5"></span>;
134
+ }
135
+ }
136
+
137
+ const content = (
138
+ <div className={cn("flex", isHorizontal ? "flex-row items-baseline" : "flex-col")}>
139
+ <span className={cn(primaryClassName)}>{p}</span>
140
+ {s && (
141
+ <>
142
+ {separatorElement}
143
+ <span
144
+ className={cn(
145
+ defaultSecondaryStyles,
146
+ isHorizontal && separator === "parentheses" && "ml-0.5",
147
+ secondaryClassName,
148
+ )}
149
+ >
150
+ {isHorizontal && separator === "parentheses" ? <>({s})</> : s}
151
+ </span>
152
+ </>
153
+ )}
154
+ </div>
155
+ );
156
+
157
+ if (listStyle === "bullet") {
158
+ return (
159
+ <div key={idx} className="flex items-start gap-2">
160
+ <span className="mt-1.5 text-[0.6rem] text-muted-foreground leading-none">•</span>
161
+ {content}
162
+ </div>
163
+ );
164
+ }
165
+
166
+ return <div key={idx}>{content}</div>;
167
+ };
168
+
169
+ // Render List
170
+ if (items && items.length > 0) {
171
+ return (
172
+ <div
173
+ className={cn(
174
+ "flex flex-col",
175
+ gap ?? (listStyle === "bullet" ? "gap-1" : "gap-2"), // Default gaps
176
+ className,
177
+ )}
178
+ >
179
+ {items.map((item, idx) => renderItem(item.primary, item.secondary, idx))}
180
+ </div>
181
+ );
182
+ }
183
+
184
+ // Render Single (Legacy/Direct mode)
185
+ return <div className={cn("flex flex-col", className)}>{renderItem(primary, secondary)}</div>;
186
+ }
@@ -0,0 +1,4 @@
1
+ export { ThemeMenuItems } from "./theme-menu-items";
2
+ export { type Theme, ThemeProvider, useTheme } from "./theme-provider";
3
+ export { ThemeSwitch } from "./theme-switch";
4
+ export { ThemeToggle } from "./theme-toggle";
@@ -0,0 +1,43 @@
1
+ "use client";
2
+
3
+ import {
4
+ DropdownMenuGroup,
5
+ DropdownMenuLabel,
6
+ DropdownMenuRadioGroup,
7
+ DropdownMenuRadioItem,
8
+ } from "@suphark/ui/components/ui/dropdown-menu";
9
+ import { MonitorIcon, MoonIcon, SunIcon } from "lucide-react";
10
+ import { useTheme } from "next-themes";
11
+ import type { ReactNode } from "react";
12
+ import type { Theme } from "./theme-provider";
13
+
14
+ const OPTIONS: Array<{ value: Theme; label: string; icon: ReactNode }> = [
15
+ { value: "light", label: "สว่าง", icon: <SunIcon /> },
16
+ { value: "dark", label: "มืด", icon: <MoonIcon /> },
17
+ { value: "system", label: "ตามระบบ", icon: <MonitorIcon /> },
18
+ ];
19
+
20
+ /** กลุ่มตัวเลือกธีมสำหรับใส่ใน DropdownMenu (เช่นเมนูผู้ใช้) — ยกจาก Pi Auth มาบน next-themes */
21
+ export function ThemeMenuItems({
22
+ label = "ธีม",
23
+ labels,
24
+ }: {
25
+ label?: ReactNode;
26
+ /** ข้อความแต่ละตัวเลือก (ค่าเริ่มต้นภาษาไทย) */
27
+ labels?: Partial<Record<Theme, ReactNode>>;
28
+ }) {
29
+ const { theme = "system", setTheme } = useTheme();
30
+ return (
31
+ <DropdownMenuGroup>
32
+ <DropdownMenuLabel className="text-muted-foreground">{label}</DropdownMenuLabel>
33
+ <DropdownMenuRadioGroup value={theme} onValueChange={(v) => setTheme(v as Theme)}>
34
+ {OPTIONS.map((o) => (
35
+ <DropdownMenuRadioItem key={o.value} value={o.value}>
36
+ {o.icon}
37
+ {labels?.[o.value] ?? o.label}
38
+ </DropdownMenuRadioItem>
39
+ ))}
40
+ </DropdownMenuRadioGroup>
41
+ </DropdownMenuGroup>
42
+ );
43
+ }
@@ -0,0 +1,39 @@
1
+ "use client";
2
+
3
+ import { ThemeProvider as NextThemesProvider } from "next-themes";
4
+ import type * as React from "react";
5
+
6
+ export type Theme = "light" | "dark" | "system";
7
+
8
+ /**
9
+ * ครอบ root layout ครั้งเดียว (ใส่ `suppressHydrationWarning` ที่ <html>)
10
+ * ค่าเริ่มต้น: สลับด้วย class `.dark` (ตรง @custom-variant ใน styles.css), ตามระบบ, ไม่กระพริบตอนโหลด
11
+ *
12
+ * ```tsx
13
+ * <html lang="th" suppressHydrationWarning>
14
+ * <body><ThemeProvider>{children}<Toaster /></ThemeProvider></body>
15
+ * </html>
16
+ * ```
17
+ */
18
+ export function ThemeProvider({
19
+ children,
20
+ attribute = "class",
21
+ defaultTheme = "system",
22
+ enableSystem = true,
23
+ disableTransitionOnChange = true,
24
+ ...props
25
+ }: React.ComponentProps<typeof NextThemesProvider>) {
26
+ return (
27
+ <NextThemesProvider
28
+ attribute={attribute}
29
+ defaultTheme={defaultTheme}
30
+ enableSystem={enableSystem}
31
+ disableTransitionOnChange={disableTransitionOnChange}
32
+ {...props}
33
+ >
34
+ {children}
35
+ </NextThemesProvider>
36
+ );
37
+ }
38
+
39
+ export { useTheme } from "next-themes";
@@ -0,0 +1,36 @@
1
+ "use client";
2
+
3
+ import { Button } from "@suphark/ui/components/ui/button";
4
+ import { MoonIcon, SunIcon } from "lucide-react";
5
+ import { useTheme } from "next-themes";
6
+ import { useEffect, useState } from "react";
7
+
8
+ /**
9
+ * ปุ่มเดี่ยวสลับ สว่าง ↔ มืด (หน้าที่ไม่มีเมนูผู้ใช้ เช่น login) — ยกจาก Pi Auth มาบน next-themes
10
+ * ต้องการเมนู 3 ตัวเลือก (light/dark/system) ใช้ `ThemeToggle` แทน
11
+ */
12
+ export function ThemeSwitch({
13
+ className,
14
+ labels = { toLight: "สลับเป็นโหมดสว่าง", toDark: "สลับเป็นโหมดมืด" },
15
+ }: {
16
+ className?: string;
17
+ labels?: { toLight: string; toDark: string };
18
+ }) {
19
+ const { resolvedTheme, setTheme } = useTheme();
20
+ // อ่านค่าหลัง mount เท่านั้น — ถ้าอ่านตอน render แรก ไอคอนจะไม่ตรงกับ server (hydration mismatch)
21
+ const [mounted, setMounted] = useState(false);
22
+ useEffect(() => setMounted(true), []);
23
+ const dark = mounted && resolvedTheme === "dark";
24
+ return (
25
+ <Button
26
+ type="button"
27
+ variant="ghost"
28
+ size="icon-sm"
29
+ className={className}
30
+ aria-label={dark ? labels.toLight : labels.toDark}
31
+ onClick={() => setTheme(dark ? "light" : "dark")}
32
+ >
33
+ {dark ? <SunIcon /> : <MoonIcon />}
34
+ </Button>
35
+ );
36
+ }
@@ -0,0 +1,76 @@
1
+ "use client";
2
+
3
+ import { Button } from "@suphark/ui/components/ui/button";
4
+ import {
5
+ DropdownMenu,
6
+ DropdownMenuContent,
7
+ DropdownMenuItem,
8
+ DropdownMenuTrigger,
9
+ } from "@suphark/ui/components/ui/dropdown-menu";
10
+ import { Laptop, Moon, Sun } from "lucide-react";
11
+ import { useTheme } from "next-themes";
12
+ import * as React from "react";
13
+
14
+ type ThemeValue = "light" | "dark" | "system";
15
+
16
+ interface ThemeToggleProps {
17
+ persistedTheme?: string | null;
18
+ onThemeChange?: (theme: ThemeValue) => Promise<void> | void;
19
+ }
20
+
21
+ export function ThemeToggle({ persistedTheme, onThemeChange }: ThemeToggleProps) {
22
+ const { setTheme, theme } = useTheme();
23
+
24
+ React.useEffect(() => {
25
+ if (persistedTheme && persistedTheme !== theme) {
26
+ setTheme(persistedTheme);
27
+ }
28
+ }, [persistedTheme, theme, setTheme]);
29
+
30
+ const handleThemeChange = async (newTheme: ThemeValue) => {
31
+ setTheme(newTheme);
32
+ await onThemeChange?.(newTheme);
33
+ };
34
+
35
+ return (
36
+ <DropdownMenu>
37
+ <DropdownMenuTrigger
38
+ render={
39
+ <Button
40
+ variant="ghost"
41
+ size="icon"
42
+ className="relative h-9 w-9 rounded-md hover:bg-accent hover:text-accent-foreground"
43
+ title="Change theme"
44
+ />
45
+ }
46
+ >
47
+ <Sun className="h-[1.2rem] w-[1.2rem] rotate-0 scale-100 transition-all dark:-rotate-90 dark:scale-0" />
48
+ <Moon className="absolute h-[1.2rem] w-[1.2rem] rotate-90 scale-0 transition-all dark:rotate-0 dark:scale-100" />
49
+ <span className="sr-only">Change theme</span>
50
+ </DropdownMenuTrigger>
51
+ <DropdownMenuContent align="end" className="w-36">
52
+ <DropdownMenuItem
53
+ onClick={() => handleThemeChange("light")}
54
+ className="flex cursor-pointer items-center gap-2"
55
+ >
56
+ <Sun className="h-4 w-4 text-amber-500" />
57
+ <span>Light</span>
58
+ </DropdownMenuItem>
59
+ <DropdownMenuItem
60
+ onClick={() => handleThemeChange("dark")}
61
+ className="flex cursor-pointer items-center gap-2"
62
+ >
63
+ <Moon className="h-4 w-4 text-blue-500" />
64
+ <span>Dark</span>
65
+ </DropdownMenuItem>
66
+ <DropdownMenuItem
67
+ onClick={() => handleThemeChange("system")}
68
+ className="flex cursor-pointer items-center gap-2"
69
+ >
70
+ <Laptop className="h-4 w-4 text-slate-500" />
71
+ <span>System</span>
72
+ </DropdownMenuItem>
73
+ </DropdownMenuContent>
74
+ </DropdownMenu>
75
+ );
76
+ }