@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,133 @@
1
+ "use client";
2
+
3
+ import { Field, FieldDescription, FieldLabel } from "@suphark/ui/components/ui/field";
4
+ import { Input } from "@suphark/ui/components/ui/input";
5
+ import { type ReactNode, useId, useState } from "react";
6
+
7
+ /** รูปแบบตัวระบุที่ derive จากชื่อ: kebab (slug/code/key), camel (attribute key), upper (รหัสตำแหน่ง) */
8
+ export type IdentifierStyle = "kebab" | "camel" | "upper";
9
+
10
+ /**
11
+ * แปลงชื่อเป็นตัวระบุมาตรฐาน (เหมือน Clerk: พิมพ์ชื่อแล้ว slug ขึ้นให้เอง) — ตัดเครื่องหมายกำกับ, เก็บเฉพาะ a-z 0-9,
12
+ * แบ่งคำที่ช่องว่าง/เครื่องหมาย; ตัวอักษรที่ไม่ใช่ละติน (เช่นไทย) ตัดทิ้ง → อาจได้ค่าว่าง = ให้ผู้ใช้ตั้งเอง
13
+ */
14
+ export function toIdentifier(name: string, style: IdentifierStyle, maxLength = 64): string {
15
+ const words = name
16
+ .normalize("NFKD")
17
+ .replace(/[\u0300-\u036f]/g, "")
18
+ .replace(/[^A-Za-z0-9]+/g, " ")
19
+ .trim()
20
+ .split(/\s+/)
21
+ .filter(Boolean)
22
+ .map((w) => w.toLowerCase());
23
+ if (words.length === 0) return "";
24
+ let out: string;
25
+ if (style === "camel")
26
+ out = words.map((w, i) => (i === 0 ? w : w[0]!.toUpperCase() + w.slice(1))).join("");
27
+ else if (style === "upper") out = words.join("_").toUpperCase();
28
+ else out = words.join("-");
29
+ // camel ต้องขึ้นต้นด้วยตัวอักษร; kebab/upper ห้ามขึ้นต้น/ลงท้ายด้วยเครื่องหมาย (ตัดความยาวแล้วอาจเหลือ "-" ท้าย)
30
+ out = out.slice(0, maxLength).replace(/[-_]+$/, "");
31
+ if (style === "camel" && /^[0-9]/.test(out)) out = `x${out}`;
32
+ return out;
33
+ }
34
+
35
+ /**
36
+ * คู่ช่อง "ชื่อ" + ตัวระบุ (slug/code/key) แบบ Clerk: พิมพ์ชื่อแล้วตัวระบุขึ้นให้อัตโนมัติ แก้เองได้
37
+ * เมื่อแก้เองแล้วจะไม่ถูกทับอีก (ลบจนว่าง = กลับไป auto); มีปุ่ม "ใช้ตามชื่อ" เพื่อกลับไปค่า derive
38
+ * ส่งค่าเป็น input ธรรมดาชื่อ `nameField`/`idField` — ใช้ในฟอร์ม server action ได้เลย
39
+ */
40
+ export function NameIdFields({
41
+ nameField = "name",
42
+ idField,
43
+ style = "kebab",
44
+ nameLabel,
45
+ namePlaceholder,
46
+ idLabel,
47
+ idHint,
48
+ idPlaceholder,
49
+ idPattern,
50
+ idMaxLength = 64,
51
+ resetLabel,
52
+ autoFocus = true,
53
+ defaultName = "",
54
+ defaultId = "",
55
+ }: {
56
+ nameField?: string;
57
+ idField: string;
58
+ style?: IdentifierStyle;
59
+ nameLabel: ReactNode;
60
+ namePlaceholder?: string;
61
+ idLabel: ReactNode;
62
+ idHint?: ReactNode;
63
+ idPlaceholder?: string;
64
+ /** regex สำหรับ native validation ของ browser (ตรงกับ zod ฝั่ง server) */
65
+ idPattern?: string;
66
+ idMaxLength?: number;
67
+ /** ข้อความปุ่มกลับไปใช้ค่าจากชื่อ (แสดงเมื่อแก้เอง) */
68
+ resetLabel: ReactNode;
69
+ autoFocus?: boolean;
70
+ defaultName?: string;
71
+ defaultId?: string;
72
+ }) {
73
+ const [name, setName] = useState(defaultName);
74
+ const [id, setId] = useState(defaultId || toIdentifier(defaultName, style, idMaxLength));
75
+ const [manual, setManual] = useState(Boolean(defaultId));
76
+ const derived = toIdentifier(name, style, idMaxLength);
77
+ const nameId = useId();
78
+ const idId = useId();
79
+ return (
80
+ <>
81
+ <Field>
82
+ <FieldLabel htmlFor={nameId}>{nameLabel}</FieldLabel>
83
+ <Input
84
+ id={nameId}
85
+ name={nameField}
86
+ required
87
+ placeholder={namePlaceholder}
88
+ autoFocus={autoFocus}
89
+ value={name}
90
+ onChange={(e) => {
91
+ setName(e.target.value);
92
+ if (!manual) setId(toIdentifier(e.target.value, style, idMaxLength));
93
+ }}
94
+ />
95
+ </Field>
96
+ <Field>
97
+ <FieldLabel htmlFor={idId}>{idLabel}</FieldLabel>
98
+ <Input
99
+ id={idId}
100
+ name={idField}
101
+ required
102
+ placeholder={idPlaceholder}
103
+ pattern={idPattern}
104
+ maxLength={idMaxLength}
105
+ value={id}
106
+ className="font-mono"
107
+ onChange={(e) => {
108
+ setId(e.target.value);
109
+ // ลบจนว่าง = กลับไปโหมด auto (จะเติมให้ใหม่เมื่อแก้ชื่อครั้งถัดไป)
110
+ setManual(e.target.value !== "");
111
+ }}
112
+ />
113
+ {(idHint || (manual && derived && derived !== id)) && (
114
+ <FieldDescription className="flex flex-wrap items-center gap-x-2">
115
+ {idHint}
116
+ {manual && derived && derived !== id && (
117
+ <button
118
+ type="button"
119
+ className="underline underline-offset-4 hover:text-foreground"
120
+ onClick={() => {
121
+ setManual(false);
122
+ setId(derived);
123
+ }}
124
+ >
125
+ {resetLabel}
126
+ </button>
127
+ )}
128
+ </FieldDescription>
129
+ )}
130
+ </Field>
131
+ </>
132
+ );
133
+ }
@@ -0,0 +1,100 @@
1
+ import {
2
+ Breadcrumb,
3
+ BreadcrumbItem,
4
+ BreadcrumbLink,
5
+ BreadcrumbList,
6
+ BreadcrumbPage,
7
+ BreadcrumbSeparator,
8
+ } from "@suphark/ui/components/ui/breadcrumb";
9
+ import { cn } from "@suphark/ui/lib/utils";
10
+ import Link from "next/link";
11
+ import * as React from "react";
12
+
13
+ /**
14
+ * Represents a single item in the breadcrumb trail.
15
+ */
16
+ export type PageBreadcrumbItem = {
17
+ /**
18
+ * The text to display for the breadcrumb item.
19
+ */
20
+ label: string;
21
+ /**
22
+ * The URL to link to. If not provided, this item will be rendered
23
+ * as the current, non-clickable page (`<BreadcrumbPage>`).
24
+ */
25
+ href?: string;
26
+ /**
27
+ * An optional icon element to display before the label.
28
+ */
29
+ icon?: React.ReactNode;
30
+ };
31
+
32
+ /**
33
+ * Props for the PageBreadcrumb component.
34
+ */
35
+ type PageBreadcrumbProps = {
36
+ /**
37
+ * An array of breadcrumb items to display.
38
+ */
39
+ items: PageBreadcrumbItem[];
40
+ /**
41
+ * Optional CSS class(es) to apply to the root <Breadcrumb> component.
42
+ */
43
+ className?: string;
44
+ };
45
+
46
+ /**
47
+ * A shared component that renders a breadcrumb trail based on a list of items.
48
+ * It automatically handles rendering items as links or as the current page.
49
+ *
50
+ * @example
51
+ * const items = [
52
+ * { label: "Home", href: "/" },
53
+ * { label: "Projects", href: "/projects" },
54
+ * { label: "My Project", icon: <Briefcase /> },
55
+ * ];
56
+ * <PageBreadcrumb items={items} />
57
+ */
58
+ export function PageBreadcrumb({ items, className }: PageBreadcrumbProps) {
59
+ return (
60
+ <Breadcrumb className={cn("print:hidden", className)}>
61
+ <BreadcrumbList>
62
+ {items.map((item, index) => (
63
+ // biome-ignore lint/suspicious/noArrayIndexKey: ลำดับ breadcrumb คงที่ ไม่ reorder — label อาจซ้ำจึงใช้ตำแหน่งร่วม
64
+ <React.Fragment key={`${item.label}-${index}`}>
65
+ <BreadcrumbItem>
66
+ {item.href ? (
67
+ // Item is a clickable link
68
+ <BreadcrumbLink
69
+ render={
70
+ <Link
71
+ href={item.href}
72
+ // Add flex display for icon alignment
73
+ className="flex items-center gap-1.5"
74
+ />
75
+ }
76
+ >
77
+ {item.icon}
78
+ {item.label}
79
+ </BreadcrumbLink>
80
+ ) : (
81
+ // Item is the current, non-clickable page
82
+ <BreadcrumbPage
83
+ // Add flex display for icon alignment
84
+ className="flex items-center gap-1.5"
85
+ >
86
+ {item.icon}
87
+ {item.label}
88
+ </BreadcrumbPage>
89
+ )}
90
+ </BreadcrumbItem>
91
+ {/* Add a separator if it's not the last item */}
92
+ {index < items.length - 1 && <BreadcrumbSeparator />}
93
+ </React.Fragment>
94
+ ))}
95
+ </BreadcrumbList>
96
+ </Breadcrumb>
97
+ );
98
+ }
99
+
100
+ // export default PageBreadcrumb;
@@ -0,0 +1,35 @@
1
+ import { Alert, AlertDescription, AlertTitle } from "@suphark/ui/components/ui/alert";
2
+ import { cn } from "@suphark/ui/lib/utils";
3
+ import type { ComponentProps, ReactNode } from "react";
4
+ import type { Tone } from "./status-badge";
5
+
6
+ export type NoticeTone = Exclude<Tone, "neutral">;
7
+
8
+ const TONE: Record<NoticeTone, string> = {
9
+ info: "border-sky-200 bg-sky-50 text-sky-900 dark:border-sky-900 dark:bg-sky-950 dark:text-sky-100",
10
+ success:
11
+ "border-emerald-200 bg-emerald-50 text-emerald-900 dark:border-emerald-900 dark:bg-emerald-950 dark:text-emerald-100",
12
+ warning:
13
+ "border-amber-300 bg-amber-50 text-amber-900 dark:border-amber-900 dark:bg-amber-950 dark:text-amber-100",
14
+ danger:
15
+ "border-red-200 bg-red-50 text-red-900 dark:border-red-900 dark:bg-red-950 dark:text-red-100",
16
+ };
17
+
18
+ /** Alert ของ shadcn + สีตามความหมาย (info/success/warning/danger) */
19
+ export function Notice({
20
+ tone = "info",
21
+ title,
22
+ children,
23
+ className,
24
+ ...props
25
+ }: Omit<ComponentProps<typeof Alert>, "variant" | "title"> & {
26
+ tone?: NoticeTone;
27
+ title?: ReactNode;
28
+ }) {
29
+ return (
30
+ <Alert className={cn(TONE[tone], className)} {...props}>
31
+ {title && <AlertTitle>{title}</AlertTitle>}
32
+ <AlertDescription className="text-inherit">{children}</AlertDescription>
33
+ </Alert>
34
+ );
35
+ }
@@ -0,0 +1,133 @@
1
+ "use client";
2
+
3
+ import { useEffect, useMemo, useState } from "react";
4
+
5
+ /** ค่าทศนิยมแบบ object (เช่น Prisma.Decimal, decimal.js) — ขอแค่ toString() ให้ตัวเลข */
6
+ export interface DecimalLike {
7
+ toString(): string;
8
+ }
9
+
10
+ /**
11
+ * Props for the FormattedNumber component.
12
+ */
13
+ interface FormattedNumberProps {
14
+ /**
15
+ * The value to format. Can be a Decimal-like object (Prisma.Decimal / decimal.js), number, string, null, or undefined.
16
+ */
17
+ value: DecimalLike | number | string | null | undefined;
18
+
19
+ /**
20
+ * Optional Intl.NumberFormat options to customize the output.
21
+ * Defaults to { style: 'decimal', minimumFractionDigits: 2, maximumFractionDigits: 2 }.
22
+ */
23
+ options?: Intl.NumberFormatOptions;
24
+
25
+ /**
26
+ * The locale to use for formatting. Defaults to 'th-TH'.
27
+ */
28
+ locale?: string;
29
+
30
+ /**
31
+ * Placeholder text to display while mounting or if the value is invalid/null/undefined.
32
+ * Defaults to "-".
33
+ */
34
+ placeholder?: string;
35
+
36
+ /**
37
+ * Optional CSS class name(s) to apply to the span element.
38
+ */
39
+ className?: string;
40
+ }
41
+
42
+ /**
43
+ * A client-side component to safely format and display numeric or Decimal values,
44
+ * preventing hydration mismatches and handling null/undefined states.
45
+ * * @example
46
+ * // Default formatting (2 decimal places)
47
+ * <FormattedNumber value={prismaDecimalValue} />
48
+ * * @example
49
+ * // No decimals
50
+ * <FormattedNumber value={12345} options={{ minimumFractionDigits: 0, maximumFractionDigits: 0 }} />
51
+ * * @example
52
+ * // Currency format
53
+ * <FormattedNumber
54
+ * value={12345.67}
55
+ * options={{ style: 'currency', currency: 'THB' }}
56
+ * locale="th-TH"
57
+ * />
58
+ */
59
+ export function FormattedNumber({
60
+ value,
61
+ options: userOptions,
62
+ locale = "th-TH",
63
+ placeholder = "-",
64
+ className,
65
+ }: FormattedNumberProps) {
66
+ const [formattedValue, setFormattedValue] = useState<string>(placeholder);
67
+ const [isMounted, setIsMounted] = useState(false);
68
+
69
+ useEffect(() => {
70
+ // Set mounted to true only on the client-side
71
+ setIsMounted(true);
72
+ }, []);
73
+
74
+ // Memoize default options
75
+ const defaultOptions: Intl.NumberFormatOptions = useMemo(
76
+ () => ({
77
+ style: "decimal",
78
+ minimumFractionDigits: 2,
79
+ maximumFractionDigits: 2,
80
+ }),
81
+ [],
82
+ );
83
+
84
+ useEffect(() => {
85
+ // Only run formatting logic on the client *after* mounting
86
+ if (!isMounted) {
87
+ return;
88
+ }
89
+
90
+ let numericValue: number;
91
+
92
+ // 1. Check for null/undefined/empty string explicitly to show placeholder
93
+ if (value === null || value === undefined || value === "") {
94
+ setFormattedValue(placeholder);
95
+ return;
96
+ }
97
+
98
+ // 2. Convert value to number
99
+ try {
100
+ // Handle Prisma Decimal object (or any object with toFixed/toString)
101
+ if (typeof value === "object" && value !== null && "toString" in value) {
102
+ numericValue = Number(value.toString());
103
+ } else {
104
+ // Handle number, string
105
+ numericValue = Number(value);
106
+ }
107
+ } catch (_e) {
108
+ // Handle potential errors during conversion
109
+ numericValue = NaN;
110
+ }
111
+
112
+ // 3. Check if conversion resulted in a valid number
113
+ if (Number.isNaN(numericValue)) {
114
+ setFormattedValue(placeholder);
115
+ return;
116
+ }
117
+
118
+ // 4. Format the valid number
119
+ try {
120
+ // Merge user options with defaults (user options take precedence)
121
+ const finalOptions = { ...defaultOptions, ...userOptions };
122
+
123
+ const formatter = new Intl.NumberFormat(locale, finalOptions);
124
+ setFormattedValue(formatter.format(numericValue));
125
+ } catch (error) {
126
+ console.error("Error formatting number:", error);
127
+ setFormattedValue(placeholder); // Fallback on formatting error
128
+ }
129
+ }, [isMounted, value, userOptions, locale, placeholder, defaultOptions]);
130
+
131
+ // Render placeholder during SSR/hydration, formatted value on client post-mount
132
+ return <span className={className}>{formattedValue}</span>;
133
+ }
@@ -0,0 +1,25 @@
1
+ import { cn } from "@suphark/ui/lib/utils";
2
+ import type { ReactNode } from "react";
3
+
4
+ /** หัวหน้าเพจของ console: ชื่อ + คำอธิบาย + ปุ่มด้านขวา */
5
+ export function PageHeader({
6
+ title,
7
+ description,
8
+ actions,
9
+ className,
10
+ }: {
11
+ title: ReactNode;
12
+ description?: ReactNode;
13
+ actions?: ReactNode;
14
+ className?: string;
15
+ }) {
16
+ return (
17
+ <div className={cn("flex flex-wrap items-end justify-between gap-3", className)}>
18
+ <div className="min-w-0">
19
+ <h1 className="text-xl font-semibold tracking-tight">{title}</h1>
20
+ {description && <p className="mt-1 text-sm text-muted-foreground">{description}</p>}
21
+ </div>
22
+ {actions && <div className="flex items-center gap-2">{actions}</div>}
23
+ </div>
24
+ );
25
+ }
@@ -0,0 +1,18 @@
1
+ import { Skeleton } from "@suphark/ui/components/ui/skeleton";
2
+
3
+ export function PageSkeleton() {
4
+ return (
5
+ <div className="animate-pulse space-y-6">
6
+ <div className="space-y-2">
7
+ <Skeleton className="h-10 w-1/3" />
8
+ <Skeleton className="h-4 w-1/2" />
9
+ </div>
10
+
11
+ <div className="grid gap-6">
12
+ <Skeleton className="h-[200px] w-full rounded-xl" />
13
+ <Skeleton className="h-[200px] w-full rounded-xl" />
14
+ <Skeleton className="h-[200px] w-full rounded-xl" />
15
+ </div>
16
+ </div>
17
+ );
18
+ }
@@ -0,0 +1,93 @@
1
+ "use client";
2
+
3
+ import { Button } from "@suphark/ui/components/ui/button";
4
+ import {
5
+ DropdownMenu,
6
+ DropdownMenuContent,
7
+ DropdownMenuItem,
8
+ DropdownMenuSeparator,
9
+ DropdownMenuTrigger,
10
+ } from "@suphark/ui/components/ui/dropdown-menu";
11
+ import { MoreHorizontalIcon } from "lucide-react";
12
+ import { useRouter } from "next/navigation";
13
+ import { useState, useTransition } from "react";
14
+ import { useUiText } from "./ui-text";
15
+
16
+ export type RowAction =
17
+ | {
18
+ type?: "item";
19
+ label: string;
20
+ href?: string;
21
+ onSelect?: () => void;
22
+ destructive?: boolean;
23
+ confirm?: string;
24
+ action?: (formData: FormData) => Promise<unknown>;
25
+ data?: Record<string, string>;
26
+ disabled?: boolean;
27
+ }
28
+ | { type: "separator" };
29
+
30
+ /**
31
+ * เมนู "…" ท้ายแถว — รวมการกระทำรองไว้ที่เดียวแทนปุ่ม/ฟอร์มหลายอันในแถว
32
+ * รายการเรียก server action ได้โดยตรง (`action` + `data`) หรือไปหน้าอื่น (`href`) หรือเปิด dialog (`onSelect`)
33
+ * `confirm` = ถามยืนยันก่อน (ใช้กับสิ่งที่ย้อนกลับไม่ได้)
34
+ */
35
+ export function RowActions({ items, label }: { items: RowAction[]; label?: string }) {
36
+ const router = useRouter();
37
+ const text = useUiText();
38
+ const [pending, start] = useTransition();
39
+ const [open, setOpen] = useState(false);
40
+ return (
41
+ <DropdownMenu open={open} onOpenChange={setOpen}>
42
+ <DropdownMenuTrigger
43
+ render={
44
+ <Button
45
+ variant="ghost"
46
+ size="icon-sm"
47
+ aria-label={label ?? text.actions}
48
+ disabled={pending}
49
+ />
50
+ }
51
+ >
52
+ <MoreHorizontalIcon />
53
+ </DropdownMenuTrigger>
54
+ <DropdownMenuContent align="end" className="min-w-52">
55
+ {items.map((it, i) =>
56
+ it.type === "separator" ? (
57
+ // biome-ignore lint/suspicious/noArrayIndexKey: separator ไม่มี identity
58
+ <DropdownMenuSeparator key={`sep-${i}`} />
59
+ ) : (
60
+ <DropdownMenuItem
61
+ key={it.label}
62
+ variant={it.destructive ? "destructive" : "default"}
63
+ disabled={it.disabled}
64
+ onClick={() => {
65
+ setOpen(false);
66
+ if (it.confirm && !window.confirm(it.confirm)) return;
67
+ if (it.href) {
68
+ router.push(it.href);
69
+ return;
70
+ }
71
+ if (it.onSelect) {
72
+ it.onSelect();
73
+ return;
74
+ }
75
+ if (it.action) {
76
+ const fd = new FormData();
77
+ for (const [k, v] of Object.entries(it.data ?? {})) fd.set(k, v);
78
+ const run = it.action;
79
+ start(async () => {
80
+ await run(fd);
81
+ router.refresh();
82
+ });
83
+ }
84
+ }}
85
+ >
86
+ {it.label}
87
+ </DropdownMenuItem>
88
+ ),
89
+ )}
90
+ </DropdownMenuContent>
91
+ </DropdownMenu>
92
+ );
93
+ }
@@ -0,0 +1,55 @@
1
+ "use client";
2
+
3
+ import { type ReactNode, useState } from "react";
4
+ import { FormDialog } from "./form-dialog";
5
+ import type { FormState } from "./form-message";
6
+ import { type RowAction, RowActions } from "./row-actions";
7
+
8
+ export type RowMenuItem = RowAction | { label: string; dialog: string; disabled?: boolean };
9
+
10
+ export interface RowMenuDialog {
11
+ title: ReactNode;
12
+ description?: ReactNode;
13
+ action: (prev: FormState, formData: FormData) => Promise<FormState>;
14
+ submitLabel?: string;
15
+ children: ReactNode;
16
+ }
17
+
18
+ /**
19
+ * เมนู "…" ที่บางรายการเปิด dialog ฟอร์ม — ใช้จาก server component ได้
20
+ * (รายการอ้าง dialog ด้วย key; children ของ dialog เป็น JSX ที่ render จาก server ได้)
21
+ */
22
+ export function RowMenu({
23
+ items,
24
+ dialogs = {},
25
+ label,
26
+ }: {
27
+ items: RowMenuItem[];
28
+ dialogs?: Record<string, RowMenuDialog>;
29
+ label?: string;
30
+ }) {
31
+ const [open, setOpen] = useState<string | null>(null);
32
+ const actions: RowAction[] = items.map((it) =>
33
+ "dialog" in it
34
+ ? { label: it.label, disabled: it.disabled, onSelect: () => setOpen(it.dialog) }
35
+ : it,
36
+ );
37
+ return (
38
+ <>
39
+ <RowActions items={actions} label={label} />
40
+ {Object.entries(dialogs).map(([key, d]) => (
41
+ <FormDialog
42
+ key={key}
43
+ open={open === key}
44
+ onOpenChange={(o) => setOpen(o ? key : null)}
45
+ title={d.title}
46
+ description={d.description}
47
+ action={d.action}
48
+ submitLabel={d.submitLabel}
49
+ >
50
+ {d.children}
51
+ </FormDialog>
52
+ ))}
53
+ </>
54
+ );
55
+ }
@@ -0,0 +1,53 @@
1
+ "use client";
2
+
3
+ import { Button } from "@suphark/ui/components/ui/button";
4
+ import { CheckIcon, CopyIcon } from "lucide-react";
5
+ import { useState } from "react";
6
+ import { Notice } from "./notice";
7
+ import { useUiText } from "./ui-text";
8
+
9
+ /** แสดงค่าลับที่เห็นได้ครั้งเดียว (client secret, webhook secret, SCIM token) พร้อมปุ่มคัดลอก */
10
+ export function SecretReveal({
11
+ values,
12
+ title,
13
+ }: {
14
+ values: Record<string, string>;
15
+ title?: string;
16
+ }) {
17
+ const text = useUiText();
18
+ const [copied, setCopied] = useState<string | null>(null);
19
+ async function copy(key: string, value: string) {
20
+ try {
21
+ await navigator.clipboard.writeText(value);
22
+ setCopied(key);
23
+ setTimeout(() => setCopied((c) => (c === key ? null : c)), 1500);
24
+ } catch {
25
+ // clipboard ไม่ได้ (http/permission) — ผู้ใช้ยังคัดลอกจากข้อความได้
26
+ }
27
+ }
28
+ return (
29
+ <Notice tone="warning" title={title ?? text.showOnce}>
30
+ <dl className="flex flex-col gap-2">
31
+ {Object.entries(values).map(([k, v]) => (
32
+ <div key={k} className="flex flex-col gap-1">
33
+ <dt className="font-mono text-[0.7rem] text-muted-foreground">{k}</dt>
34
+ <dd className="flex items-center gap-2">
35
+ <code className="min-w-0 flex-1 break-all rounded-md bg-background/70 px-2 py-1 font-mono text-xs">
36
+ {v}
37
+ </code>
38
+ <Button
39
+ type="button"
40
+ variant="outline"
41
+ size="icon-sm"
42
+ aria-label={`${text.copy} ${k}`}
43
+ onClick={() => copy(k, v)}
44
+ >
45
+ {copied === k ? <CheckIcon /> : <CopyIcon />}
46
+ </Button>
47
+ </dd>
48
+ </div>
49
+ ))}
50
+ </dl>
51
+ </Notice>
52
+ );
53
+ }