@suphark/ui 0.5.0 → 0.6.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 +57 -0
- package/package.json +1 -1
- package/src/components/app-shell/app-nav.tsx +24 -8
- package/src/components/data-table/data-table-faceted-filter.tsx +14 -15
- package/src/components/data-table/data-table-row-actions.tsx +2 -1
- package/src/components/data-table/use-data-table.ts +7 -11
- package/src/components/pwa/pull-to-refresh.tsx +11 -5
- package/src/components/shared/action-form.tsx +78 -0
- package/src/components/shared/form/form-field-combobox.tsx +14 -5
- package/src/components/shared/form/form-field-radio-cards.tsx +61 -24
- package/src/components/shared/form/labeled-field.tsx +61 -0
- package/src/components/shared/form-dialog.tsx +1 -3
- package/src/components/shared/form-message.tsx +3 -0
- package/src/components/shared/navigation/stepper.tsx +96 -0
- package/src/components/shared/number/formatted-number.tsx +39 -102
- package/src/components/shared/theme/theme-toggle.tsx +8 -5
- package/src/components/shared/time/client-date-formatter.tsx +47 -154
- package/src/components/ui/relative-date.tsx +30 -9
- package/src/hooks/use-mounted.ts +16 -0
- package/src/hooks/use-ticker.ts +53 -0
- package/src/index.ts +6 -1
- package/src/lib/intl-cache.ts +31 -0
- package/src/next.ts +1 -0
|
@@ -20,11 +20,9 @@ import {
|
|
|
20
20
|
useEffect,
|
|
21
21
|
useState,
|
|
22
22
|
} from "react";
|
|
23
|
-
import { FormMessage, type FormState } from "./form-message";
|
|
23
|
+
import { type FormAction, FormMessage, type FormState } from "./form-message";
|
|
24
24
|
import { useUiText } from "./ui-text";
|
|
25
25
|
|
|
26
|
-
type FormAction = (prev: FormState, formData: FormData) => Promise<FormState>;
|
|
27
|
-
|
|
28
26
|
/**
|
|
29
27
|
* ฟอร์ม "สร้าง/แก้ไข" ในกล่องโต้ตอบ — เปิดจากปุ่ม (trigger) ปิดเองเมื่อสำเร็จ
|
|
30
28
|
* ใช้แทนฟอร์มที่เคยวางค้างไว้บนหน้า เพื่อให้หน้า list สะอาดและมีการกระทำหลักอันเดียว
|
|
@@ -5,6 +5,9 @@ import { useUiText } from "./ui-text";
|
|
|
5
5
|
|
|
6
6
|
export type FormState = { ok: true; message?: string } | { ok: false; error: string } | null;
|
|
7
7
|
|
|
8
|
+
/** server action สำหรับ FormDialog/ActionForm: (state ก่อนหน้า, FormData) → FormState */
|
|
9
|
+
export type FormAction = (prev: FormState, formData: FormData) => Promise<FormState>;
|
|
10
|
+
|
|
8
11
|
/** ข้อความผลของ server action ใต้ฟอร์ม */
|
|
9
12
|
export function FormMessage({
|
|
10
13
|
state,
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
import { StatusBadge, type Tone } from "@suphark/ui/components/shared/status-badge";
|
|
2
|
+
import { cn } from "@suphark/ui/lib/utils";
|
|
3
|
+
import { AlertTriangle, Check } from "lucide-react";
|
|
4
|
+
import Link from "next/link";
|
|
5
|
+
|
|
6
|
+
export type StepStatus = "done" | "todo" | "problem";
|
|
7
|
+
|
|
8
|
+
export interface StepperStep {
|
|
9
|
+
key: string;
|
|
10
|
+
href: string;
|
|
11
|
+
label: string;
|
|
12
|
+
status: StepStatus;
|
|
13
|
+
/** ป้ายสถานะเล็กใต้ชื่อขั้น เช่น "ตั้งค่าแล้ว", "ยังไม่มี redirect URI" */
|
|
14
|
+
statusLabel?: string;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
const TONE: Record<StepStatus, Tone> = { done: "success", todo: "neutral", problem: "warning" };
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* แถบขั้นตอน wizard ที่ทุกขั้นกดเข้าได้ (ไม่บังคับลำดับ) — แนวนอนเลื่อนได้บนจอแคบ / แนวตั้งบน lg
|
|
21
|
+
* ไม่มี client state → ใช้จาก server component ได้ (อยู่ที่ `@suphark/ui/next` เพราะใช้ next/link)
|
|
22
|
+
*
|
|
23
|
+
* <Stepper current="basics" ariaLabel="ขั้นตอนตั้งค่าแอป" steps={[
|
|
24
|
+
* { key: "basics", href: `/apps/${id}`, label: "ข้อมูลพื้นฐาน", status: "done", statusLabel: "ครบ" },
|
|
25
|
+
* { key: "oauth", href: `/apps/${id}/oauth`, label: "OAuth", status: "problem", statusLabel: "ไม่มี redirect URI" },
|
|
26
|
+
* ]} />
|
|
27
|
+
*/
|
|
28
|
+
export function Stepper({
|
|
29
|
+
steps,
|
|
30
|
+
current,
|
|
31
|
+
ariaLabel = "ขั้นตอน",
|
|
32
|
+
className,
|
|
33
|
+
}: {
|
|
34
|
+
steps: StepperStep[];
|
|
35
|
+
/** key ของขั้นที่กำลังอยู่ */
|
|
36
|
+
current: string;
|
|
37
|
+
ariaLabel?: string;
|
|
38
|
+
className?: string;
|
|
39
|
+
}) {
|
|
40
|
+
return (
|
|
41
|
+
<nav aria-label={ariaLabel} className={className}>
|
|
42
|
+
<ol className="no-scrollbar flex gap-1 overflow-x-auto lg:flex-col lg:overflow-visible">
|
|
43
|
+
{steps.map((step, i) => {
|
|
44
|
+
const active = step.key === current;
|
|
45
|
+
return (
|
|
46
|
+
<li key={step.key} className="shrink-0 lg:shrink">
|
|
47
|
+
<Link
|
|
48
|
+
href={step.href}
|
|
49
|
+
aria-current={active ? "step" : undefined}
|
|
50
|
+
className={cn(
|
|
51
|
+
"flex items-center gap-3 rounded-md px-3 py-2 text-sm no-underline outline-none transition-colors hover:bg-accent focus-visible:ring-2 focus-visible:ring-ring/50",
|
|
52
|
+
active && "bg-accent font-medium",
|
|
53
|
+
)}
|
|
54
|
+
>
|
|
55
|
+
<StepMark status={step.status} index={i + 1} />
|
|
56
|
+
<span className="flex min-w-0 flex-col gap-0.5">
|
|
57
|
+
<span className="truncate">{step.label}</span>
|
|
58
|
+
{step.statusLabel && (
|
|
59
|
+
<StatusBadge
|
|
60
|
+
tone={TONE[step.status]}
|
|
61
|
+
className="w-fit px-1.5 py-0 font-normal text-[0.7rem] leading-4"
|
|
62
|
+
>
|
|
63
|
+
{step.statusLabel}
|
|
64
|
+
</StatusBadge>
|
|
65
|
+
)}
|
|
66
|
+
</span>
|
|
67
|
+
</Link>
|
|
68
|
+
</li>
|
|
69
|
+
);
|
|
70
|
+
})}
|
|
71
|
+
</ol>
|
|
72
|
+
</nav>
|
|
73
|
+
);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function StepMark({ status, index }: { status: StepStatus; index: number }) {
|
|
77
|
+
return (
|
|
78
|
+
<span
|
|
79
|
+
aria-hidden="true"
|
|
80
|
+
className={cn(
|
|
81
|
+
"flex size-6 shrink-0 items-center justify-center rounded-full border font-medium text-xs",
|
|
82
|
+
status === "done" && "border-success bg-success text-success-foreground",
|
|
83
|
+
status === "problem" && "border-warning text-warning",
|
|
84
|
+
status === "todo" && "border-border text-muted-foreground",
|
|
85
|
+
)}
|
|
86
|
+
>
|
|
87
|
+
{status === "done" ? (
|
|
88
|
+
<Check className="size-3.5" />
|
|
89
|
+
) : status === "problem" ? (
|
|
90
|
+
<AlertTriangle className="size-3.5" />
|
|
91
|
+
) : (
|
|
92
|
+
index
|
|
93
|
+
)}
|
|
94
|
+
</span>
|
|
95
|
+
);
|
|
96
|
+
}
|
|
@@ -1,60 +1,45 @@
|
|
|
1
1
|
"use client";
|
|
2
2
|
|
|
3
|
-
import {
|
|
3
|
+
import { useMounted } from "@suphark/ui/hooks/use-mounted";
|
|
4
|
+
import { getNumberFormat } from "@suphark/ui/lib/intl-cache";
|
|
5
|
+
import { useMemo } from "react";
|
|
4
6
|
|
|
5
7
|
/** ค่าทศนิยมแบบ object (เช่น Prisma.Decimal, decimal.js) — ขอแค่ toString() ให้ตัวเลข */
|
|
6
8
|
export interface DecimalLike {
|
|
7
9
|
toString(): string;
|
|
8
10
|
}
|
|
9
11
|
|
|
10
|
-
/**
|
|
11
|
-
* Props for the FormattedNumber component.
|
|
12
|
-
*/
|
|
13
12
|
interface FormattedNumberProps {
|
|
14
|
-
/**
|
|
15
|
-
* The value to format. Can be a Decimal-like object (Prisma.Decimal / decimal.js), number, string, null, or undefined.
|
|
16
|
-
*/
|
|
13
|
+
/** Decimal-like (Prisma.Decimal / decimal.js), number, string, null หรือ undefined */
|
|
17
14
|
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
|
-
*/
|
|
15
|
+
/** Intl.NumberFormatOptions — ค่าเริ่มต้น { style: 'decimal', minimumFractionDigits: 2, maximumFractionDigits: 2 } */
|
|
23
16
|
options?: Intl.NumberFormatOptions;
|
|
24
|
-
|
|
25
|
-
/**
|
|
26
|
-
* The locale to use for formatting. Defaults to 'th-TH'.
|
|
27
|
-
*/
|
|
17
|
+
/** ค่าเริ่มต้น th-TH */
|
|
28
18
|
locale?: string;
|
|
29
|
-
|
|
30
|
-
/**
|
|
31
|
-
* Placeholder text to display while mounting or if the value is invalid/null/undefined.
|
|
32
|
-
* Defaults to "-".
|
|
33
|
-
*/
|
|
19
|
+
/** ข้อความก่อน mount / ค่าไม่ถูกต้อง ค่าเริ่มต้น "-" */
|
|
34
20
|
placeholder?: string;
|
|
35
|
-
|
|
36
|
-
/**
|
|
37
|
-
* Optional CSS class name(s) to apply to the span element.
|
|
38
|
-
*/
|
|
39
21
|
className?: string;
|
|
40
22
|
}
|
|
41
23
|
|
|
24
|
+
const DEFAULT_OPTIONS: Intl.NumberFormatOptions = {
|
|
25
|
+
style: "decimal",
|
|
26
|
+
minimumFractionDigits: 2,
|
|
27
|
+
maximumFractionDigits: 2,
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
function toNumber(value: FormattedNumberProps["value"]): number {
|
|
31
|
+
if (value === null || value === undefined || value === "") return Number.NaN;
|
|
32
|
+
if (typeof value === "object") return Number(value.toString());
|
|
33
|
+
return Number(value);
|
|
34
|
+
}
|
|
35
|
+
|
|
42
36
|
/**
|
|
43
|
-
*
|
|
44
|
-
*
|
|
45
|
-
*
|
|
46
|
-
*
|
|
47
|
-
*
|
|
48
|
-
*
|
|
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
|
-
* />
|
|
37
|
+
* แสดงตัวเลขตาม locale ฝั่ง client หลัง mount (กัน hydration mismatch จาก locale ของ server)
|
|
38
|
+
* ตัว Intl.NumberFormat ถูก cache ต่อ locale+options — ตารางหลายร้อยเซลล์ไม่สร้างซ้ำ
|
|
39
|
+
*
|
|
40
|
+
* <FormattedNumber value={prismaDecimal} />
|
|
41
|
+
* <FormattedNumber value={12345} options={{ maximumFractionDigits: 0 }} />
|
|
42
|
+
* <FormattedNumber value={12345.67} options={{ style: "currency", currency: "THB" }} />
|
|
58
43
|
*/
|
|
59
44
|
export function FormattedNumber({
|
|
60
45
|
value,
|
|
@@ -63,71 +48,23 @@ export function FormattedNumber({
|
|
|
63
48
|
placeholder = "-",
|
|
64
49
|
className,
|
|
65
50
|
}: FormattedNumberProps) {
|
|
66
|
-
const
|
|
67
|
-
const
|
|
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
|
-
);
|
|
51
|
+
const mounted = useMounted();
|
|
52
|
+
const optionsKey = userOptions ? JSON.stringify(userOptions) : "";
|
|
53
|
+
const numeric = toNumber(value);
|
|
83
54
|
|
|
84
|
-
|
|
85
|
-
|
|
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
|
|
55
|
+
const text = useMemo(() => {
|
|
56
|
+
if (!mounted || Number.isNaN(numeric)) return placeholder;
|
|
99
57
|
try {
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
}
|
|
104
|
-
|
|
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));
|
|
58
|
+
const opts = {
|
|
59
|
+
...DEFAULT_OPTIONS,
|
|
60
|
+
...(optionsKey ? (JSON.parse(optionsKey) as Intl.NumberFormatOptions) : {}),
|
|
61
|
+
};
|
|
62
|
+
return getNumberFormat(locale, opts).format(numeric);
|
|
125
63
|
} catch (error) {
|
|
126
|
-
console.error("
|
|
127
|
-
|
|
64
|
+
console.error("FormattedNumber:", error);
|
|
65
|
+
return placeholder;
|
|
128
66
|
}
|
|
129
|
-
}, [
|
|
67
|
+
}, [mounted, numeric, optionsKey, locale, placeholder]);
|
|
130
68
|
|
|
131
|
-
|
|
132
|
-
return <span className={className}>{formattedValue}</span>;
|
|
69
|
+
return <span className={className}>{text}</span>;
|
|
133
70
|
}
|
|
@@ -19,13 +19,16 @@ interface ThemeToggleProps {
|
|
|
19
19
|
}
|
|
20
20
|
|
|
21
21
|
export function ThemeToggle({ persistedTheme, onThemeChange }: ThemeToggleProps) {
|
|
22
|
-
const { setTheme
|
|
22
|
+
const { setTheme } = useTheme();
|
|
23
23
|
|
|
24
|
+
// ใช้ค่าที่บันทึกไว้ (server) เฉพาะเมื่อค่านั้นเปลี่ยน (รวมครั้งแรกตอน mount) — ห้ามผูก `theme` เป็น dep
|
|
25
|
+
// ไม่งั้นผู้ใช้กดเปลี่ยนแล้ว effect เห็น theme ใหม่ ≠ persistedTheme เก่า (server action ยังไม่กลับ) แล้ว set กลับทันที
|
|
26
|
+
const lastPersisted = React.useRef<string | null | undefined>(undefined);
|
|
27
|
+
// biome-ignore lint/correctness/useExhaustiveDependencies: ตั้งใจ sync เฉพาะเมื่อ persistedTheme เปลี่ยน
|
|
24
28
|
React.useEffect(() => {
|
|
25
|
-
if (persistedTheme && persistedTheme !==
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
}, [persistedTheme, theme, setTheme]);
|
|
29
|
+
if (persistedTheme && persistedTheme !== lastPersisted.current) setTheme(persistedTheme);
|
|
30
|
+
lastPersisted.current = persistedTheme;
|
|
31
|
+
}, [persistedTheme]);
|
|
29
32
|
|
|
30
33
|
const handleThemeChange = async (newTheme: ThemeValue) => {
|
|
31
34
|
setTheme(newTheme);
|
|
@@ -1,64 +1,43 @@
|
|
|
1
1
|
"use client";
|
|
2
2
|
|
|
3
|
-
import {
|
|
4
|
-
import {
|
|
5
|
-
import {
|
|
3
|
+
import { useMounted } from "@suphark/ui/hooks/use-mounted";
|
|
4
|
+
import { useTicker } from "@suphark/ui/hooks/use-ticker";
|
|
5
|
+
import { getDateTimeFormat } from "@suphark/ui/lib/intl-cache";
|
|
6
|
+
import { formatDistance, type Locale } from "date-fns";
|
|
7
|
+
import { enUS, th } from "date-fns/locale";
|
|
8
|
+
import { useMemo } from "react";
|
|
6
9
|
|
|
7
|
-
/**
|
|
8
|
-
* Props for the ClientDateFormatter component.
|
|
9
|
-
*/
|
|
10
10
|
interface ClientDateFormatterProps {
|
|
11
|
-
/**
|
|
12
|
-
* The date value to format. Can be a Date object, ISO string, or timestamp number.
|
|
13
|
-
*/
|
|
11
|
+
/** Date, ISO string หรือ timestamp */
|
|
14
12
|
date: Date | string | number | null | undefined;
|
|
15
|
-
/**
|
|
16
|
-
* Determines the format type: 'standard' for regular date/time, 'relative' for distance from now.
|
|
17
|
-
* Defaults to 'standard'.
|
|
18
|
-
*/
|
|
13
|
+
/** `standard` = วันที่/เวลาตาม Intl (ค่าเริ่มต้น), `relative` = "x นาทีที่แล้ว" */
|
|
19
14
|
formatType?: "standard" | "relative";
|
|
20
|
-
/**
|
|
21
|
-
* Optional Intl.DateTimeFormat options (used only if formatType is 'standard').
|
|
22
|
-
* Defaults to { day: 'numeric', month: 'short', year: 'numeric' }.
|
|
23
|
-
* @see https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Intl/DateTimeFormat/DateTimeFormat#options
|
|
24
|
-
*/
|
|
15
|
+
/** ตัวเลือก Intl.DateTimeFormat (เฉพาะ standard) — ค่าเริ่มต้น { day: 'numeric', month: 'short', year: 'numeric' } */
|
|
25
16
|
options?: Intl.DateTimeFormatOptions;
|
|
26
|
-
/**
|
|
27
|
-
* The locale to use for formatting. Affects both 'standard' and 'relative' formats.
|
|
28
|
-
* Defaults to 'th-TH' (maps to 'th' for date-fns).
|
|
29
|
-
*/
|
|
17
|
+
/** ค่าเริ่มต้น th-TH (relative ใช้ date-fns locale th/enUS ตามตัวหน้า) */
|
|
30
18
|
locale?: string;
|
|
31
|
-
/**
|
|
32
|
-
* The time zone to use (used only if formatType is 'standard').
|
|
33
|
-
* Defaults to 'Asia/Bangkok' (GMT+7).
|
|
34
|
-
*/
|
|
19
|
+
/** โซนเวลา (เฉพาะ standard) ค่าเริ่มต้น Asia/Bangkok */
|
|
35
20
|
timeZone?: string;
|
|
36
|
-
/**
|
|
37
|
-
* Placeholder text to display while the component is mounting or if the date is invalid.
|
|
38
|
-
* Defaults to "-".
|
|
39
|
-
*/
|
|
21
|
+
/** ข้อความก่อน mount / วันที่ไม่ถูกต้อง ค่าเริ่มต้น "-" */
|
|
40
22
|
placeholder?: string;
|
|
41
|
-
/**
|
|
42
|
-
* Optional CSS class name(s) to apply to the span element.
|
|
43
|
-
*/
|
|
44
23
|
className?: string;
|
|
45
|
-
/**
|
|
46
|
-
* If formatType is 'relative', automatically update the time every interval (in milliseconds).
|
|
47
|
-
* Set to 0 or undefined to disable automatic updates. Defaults to 60000 (1 minute).
|
|
48
|
-
*/
|
|
24
|
+
/** relative: คำนวณใหม่ทุก N ms (ค่าเริ่มต้น 60000) — 0/undefined = ไม่อัปเดตเอง; ทุก instance แชร์ timer เดียว */
|
|
49
25
|
updateInterval?: number;
|
|
50
26
|
}
|
|
51
27
|
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
}
|
|
28
|
+
const getLocaleObject = (localeString: string): Locale =>
|
|
29
|
+
localeString.toLowerCase().split("-")[0] === "th" ? th : enUS;
|
|
30
|
+
|
|
31
|
+
const DEFAULT_OPTIONS: Intl.DateTimeFormatOptions = {
|
|
32
|
+
day: "numeric",
|
|
33
|
+
month: "short",
|
|
34
|
+
year: "numeric",
|
|
60
35
|
};
|
|
61
36
|
|
|
37
|
+
/**
|
|
38
|
+
* แสดงวันที่ฝั่ง client หลัง mount เท่านั้น (กัน hydration mismatch จากโซนเวลา/"ตอนนี้" ของ server)
|
|
39
|
+
* ก่อน mount และเมื่อวันที่ไม่ถูกต้องแสดง `placeholder`
|
|
40
|
+
*/
|
|
62
41
|
export function ClientDateFormatter({
|
|
63
42
|
date,
|
|
64
43
|
formatType = "standard",
|
|
@@ -69,120 +48,34 @@ export function ClientDateFormatter({
|
|
|
69
48
|
className,
|
|
70
49
|
updateInterval = 60000,
|
|
71
50
|
}: ClientDateFormatterProps) {
|
|
72
|
-
const
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
//
|
|
76
|
-
const
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
51
|
+
const mounted = useMounted();
|
|
52
|
+
// relative เท่านั้นที่ต้อง tick — standard ไม่ขึ้นกับ "ตอนนี้"
|
|
53
|
+
const tick = useTicker(mounted && formatType === "relative" ? updateInterval : 0);
|
|
54
|
+
// key ของ options สำหรับ cache/memo (object literal จากผู้เรียกเปลี่ยน identity ทุก render)
|
|
55
|
+
const optionsKey = userOptions ? JSON.stringify(userOptions) : "";
|
|
56
|
+
// "ตอนนี้" ผูกกับ tick — relative จึงคำนวณใหม่ตาม interval (ไม่ใช่ทุก render)
|
|
57
|
+
// biome-ignore lint/correctness/useExhaustiveDependencies: tick คือสัญญาณให้อ่านเวลาใหม่
|
|
58
|
+
const now = useMemo(() => new Date(), [tick]);
|
|
59
|
+
|
|
60
|
+
const text = useMemo(() => {
|
|
61
|
+
if (!mounted || date == null) return placeholder;
|
|
62
|
+
const dateObj = new Date(date);
|
|
63
|
+
if (Number.isNaN(dateObj.getTime())) return placeholder;
|
|
81
64
|
try {
|
|
82
|
-
const dateObj = new Date(date);
|
|
83
|
-
if (Number.isNaN(dateObj.getTime())) {
|
|
84
|
-
console.warn("ClientDateFormatter received an invalid date:", date);
|
|
85
|
-
return placeholder;
|
|
86
|
-
}
|
|
87
|
-
|
|
88
|
-
const localeObj = getLocaleObject(locale);
|
|
89
|
-
|
|
90
65
|
if (formatType === "relative") {
|
|
91
|
-
return
|
|
92
|
-
addSuffix: true,
|
|
93
|
-
locale: localeObj,
|
|
94
|
-
});
|
|
95
|
-
} else {
|
|
96
|
-
const defaultOptions: Intl.DateTimeFormatOptions = {
|
|
97
|
-
day: "numeric",
|
|
98
|
-
month: "short",
|
|
99
|
-
year: "numeric",
|
|
100
|
-
timeZone: timeZone,
|
|
101
|
-
};
|
|
102
|
-
const finalOptions = { ...defaultOptions, ...userOptions };
|
|
103
|
-
const formatter = new Intl.DateTimeFormat(locale, finalOptions);
|
|
104
|
-
return formatter.format(dateObj);
|
|
66
|
+
return formatDistance(dateObj, now, { addSuffix: true, locale: getLocaleObject(locale) });
|
|
105
67
|
}
|
|
68
|
+
const opts: Intl.DateTimeFormatOptions = {
|
|
69
|
+
...DEFAULT_OPTIONS,
|
|
70
|
+
timeZone,
|
|
71
|
+
...(optionsKey ? (JSON.parse(optionsKey) as Intl.DateTimeFormatOptions) : {}),
|
|
72
|
+
};
|
|
73
|
+
return getDateTimeFormat(locale, opts).format(dateObj);
|
|
106
74
|
} catch (error) {
|
|
107
|
-
console.error("
|
|
75
|
+
console.error("ClientDateFormatter:", error);
|
|
108
76
|
return placeholder;
|
|
109
77
|
}
|
|
110
|
-
|
|
111
|
-
}, [date, formatType, locale, placeholder, timeZone, userOptions]);
|
|
112
|
-
|
|
113
|
-
const [, setTick] = useState(0);
|
|
114
|
-
|
|
115
|
-
useEffect(() => {
|
|
116
|
-
queueMicrotask(() => {
|
|
117
|
-
setIsMounted(true);
|
|
118
|
-
});
|
|
119
|
-
}, []);
|
|
120
|
-
|
|
121
|
-
useEffect(() => {
|
|
122
|
-
if (!isMounted) return;
|
|
123
|
-
|
|
124
|
-
if (formatType === "relative" && updateInterval && updateInterval > 0) {
|
|
125
|
-
intervalRef.current = setInterval(() => {
|
|
126
|
-
setTick((t) => t + 1);
|
|
127
|
-
}, updateInterval);
|
|
128
|
-
}
|
|
78
|
+
}, [mounted, date, formatType, locale, timeZone, optionsKey, placeholder, now]);
|
|
129
79
|
|
|
130
|
-
|
|
131
|
-
if (intervalRef.current) {
|
|
132
|
-
clearInterval(intervalRef.current);
|
|
133
|
-
intervalRef.current = null;
|
|
134
|
-
}
|
|
135
|
-
};
|
|
136
|
-
}, [isMounted, updateInterval, formatType]);
|
|
137
|
-
|
|
138
|
-
if (!isMounted) {
|
|
139
|
-
return <span className={className}>{placeholder}</span>;
|
|
140
|
-
}
|
|
141
|
-
|
|
142
|
-
// Use tick as a dependency so it re-renders (state change triggers it)
|
|
143
|
-
return <span className={className}>{formatDateCallback()}</span>;
|
|
80
|
+
return <span className={className}>{text}</span>;
|
|
144
81
|
}
|
|
145
|
-
|
|
146
|
-
/*
|
|
147
|
-
|
|
148
|
-
ตัวอย่าง
|
|
149
|
-
|
|
150
|
-
import { ClientDateFormatter } from '@/components/shared/client-date-formatter';
|
|
151
|
-
|
|
152
|
-
// ...
|
|
153
|
-
|
|
154
|
-
// แสดงวันที่แบบไทย (Default)
|
|
155
|
-
<ClientDateFormatter date={someDate} />
|
|
156
|
-
|
|
157
|
-
// แสดงแบบ Relative Time (เช่น "ประมาณ 2 ชั่วโมงที่แล้ว") อัปเดตทุกนาที (default)
|
|
158
|
-
<ClientDateFormatter date={auditLog.createdAt} formatType="relative" />
|
|
159
|
-
|
|
160
|
-
// แสดงแบบ Relative Time ภาษาอังกฤษ อัปเดตทุก 30 วินาที
|
|
161
|
-
<ClientDateFormatter
|
|
162
|
-
date={auditLog.createdAt}
|
|
163
|
-
formatType="relative"
|
|
164
|
-
locale="en-US"
|
|
165
|
-
updateInterval={30000}
|
|
166
|
-
/>
|
|
167
|
-
|
|
168
|
-
// แสดงแบบ Relative Time แต่ไม่อัปเดตอัตโนมัติ
|
|
169
|
-
<ClientDateFormatter
|
|
170
|
-
date={auditLog.createdAt}
|
|
171
|
-
formatType="relative"
|
|
172
|
-
updateInterval={0} // Disable interval
|
|
173
|
-
/>
|
|
174
|
-
|
|
175
|
-
// แสดงวันที่เวลาแบบ Standard ภาษาอังกฤษ โซนเวลา New York
|
|
176
|
-
<ClientDateFormatter
|
|
177
|
-
date={someDate}
|
|
178
|
-
formatType="standard" // Explicitly standard
|
|
179
|
-
locale="en-US"
|
|
180
|
-
timeZone="America/New_York"
|
|
181
|
-
options={{ hour: '2-digit', minute: '2-digit' }}
|
|
182
|
-
/>
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
*/
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
"use client";
|
|
2
2
|
|
|
3
|
+
import { useMounted } from "@suphark/ui/hooks/use-mounted";
|
|
3
4
|
import { formatRelativeThaiDate, formatThaiFullDate } from "@suphark/ui/lib/formatters";
|
|
4
5
|
import { Calendar } from "lucide-react";
|
|
5
6
|
|
|
@@ -8,33 +9,53 @@ interface RelativeDateProps {
|
|
|
8
9
|
showIcon?: boolean;
|
|
9
10
|
className?: string;
|
|
10
11
|
iconClassName?: string;
|
|
12
|
+
/** ข้อความนำหน้าใน tooltip (ค่าเริ่มต้น "วันที่") เช่น "วันที่อ้างอิงราคา" */
|
|
13
|
+
titlePrefix?: string;
|
|
14
|
+
/** ข้อความเมื่อไม่มีวันที่ */
|
|
15
|
+
emptyText?: string;
|
|
11
16
|
}
|
|
12
17
|
|
|
18
|
+
/**
|
|
19
|
+
* วันที่แบบสัมพัทธ์ภาษาไทย ("วันนี้", "เมื่อวาน", "3 วันที่ผ่านมา") + วันที่เต็มใน tooltip
|
|
20
|
+
* คำนวณเฉพาะฝั่ง client หลัง mount: "ตอนนี้" ของ server (UTC, module โหลดไว้ก่อน) กับของ browser
|
|
21
|
+
* ต่างกันได้ทั้งโซนเวลาและข้ามวัน → ถ้า render บน server จะ hydration mismatch (เจอจริงบน Vercel)
|
|
22
|
+
*/
|
|
13
23
|
export function RelativeDate({
|
|
14
24
|
date,
|
|
15
25
|
showIcon = true,
|
|
16
|
-
className = "flex items-center gap-0.5 text-
|
|
17
|
-
iconClassName = "h-2.5 w-2.5 text-
|
|
26
|
+
className = "flex items-center gap-0.5 font-medium text-muted-foreground",
|
|
27
|
+
iconClassName = "h-2.5 w-2.5 shrink-0 text-muted-foreground/60",
|
|
28
|
+
titlePrefix = "วันที่",
|
|
29
|
+
emptyText = "ไม่ได้ระบุ",
|
|
18
30
|
}: RelativeDateProps) {
|
|
31
|
+
const mounted = useMounted();
|
|
32
|
+
|
|
19
33
|
if (!date) {
|
|
20
34
|
return (
|
|
21
35
|
<span
|
|
22
|
-
className="flex items-center gap-0.5 font-normal text-
|
|
23
|
-
title=
|
|
36
|
+
className="flex items-center gap-0.5 font-normal text-muted-foreground text-xs italic"
|
|
37
|
+
title={`${titlePrefix}: ${emptyText}`}
|
|
24
38
|
>
|
|
25
39
|
{showIcon && <Calendar className={iconClassName} />}
|
|
26
|
-
|
|
40
|
+
{emptyText}
|
|
27
41
|
</span>
|
|
28
42
|
);
|
|
29
43
|
}
|
|
30
44
|
|
|
31
|
-
|
|
32
|
-
|
|
45
|
+
// ก่อน mount แสดงตัวยึดที่ไม่ขึ้นกับเวลา (กัน layout กระโดดน้อยที่สุด)
|
|
46
|
+
if (!mounted) {
|
|
47
|
+
return (
|
|
48
|
+
<span className={className} aria-hidden="true">
|
|
49
|
+
{showIcon && <Calendar className={iconClassName} />}
|
|
50
|
+
|
|
51
|
+
</span>
|
|
52
|
+
);
|
|
53
|
+
}
|
|
33
54
|
|
|
34
55
|
return (
|
|
35
|
-
<span className={className} title={
|
|
56
|
+
<span className={className} title={`${titlePrefix}: ${formatThaiFullDate(date)}`}>
|
|
36
57
|
{showIcon && <Calendar className={iconClassName} />}
|
|
37
|
-
{
|
|
58
|
+
{formatRelativeThaiDate(date)}
|
|
38
59
|
</span>
|
|
39
60
|
);
|
|
40
61
|
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { useSyncExternalStore } from "react";
|
|
4
|
+
|
|
5
|
+
const subscribeNoop = () => () => {};
|
|
6
|
+
const getClient = () => true;
|
|
7
|
+
const getServer = () => false;
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* true เฉพาะฝั่ง client หลัง hydrate — บน server และระหว่าง hydrate ได้ false เสมอ
|
|
11
|
+
* ใช้กับค่าที่ server กับ browser คำนวณต่างกันได้ (เวลา "ตอนนี้", โซนเวลา, locale ของเครื่อง, localStorage)
|
|
12
|
+
* ผ่าน `useSyncExternalStore` จึงไม่มี render รอบเพิ่มแบบ `useEffect(() => setMounted(true))`
|
|
13
|
+
*/
|
|
14
|
+
export function useMounted(): boolean {
|
|
15
|
+
return useSyncExternalStore(subscribeNoop, getClient, getServer);
|
|
16
|
+
}
|