@suphark/ui 0.5.1 → 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.
@@ -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 { useEffect, useMemo, useState } from "react";
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
- * 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
- * />
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 [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
- );
51
+ const mounted = useMounted();
52
+ const optionsKey = userOptions ? JSON.stringify(userOptions) : "";
53
+ const numeric = toNumber(value);
83
54
 
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
55
+ const text = useMemo(() => {
56
+ if (!mounted || Number.isNaN(numeric)) return placeholder;
99
57
  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));
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("Error formatting number:", error);
127
- setFormattedValue(placeholder); // Fallback on formatting error
64
+ console.error("FormattedNumber:", error);
65
+ return placeholder;
128
66
  }
129
- }, [isMounted, value, userOptions, locale, placeholder, defaultOptions]);
67
+ }, [mounted, numeric, optionsKey, locale, placeholder]);
130
68
 
131
- // Render placeholder during SSR/hydration, formatted value on client post-mount
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, theme } = useTheme();
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 !== theme) {
26
- setTheme(persistedTheme);
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 { formatDistanceToNow, type Locale } from "date-fns";
4
- import { enUS, th } from "date-fns/locale"; // Import locales you need
5
- import { useCallback, useEffect, useRef, useState } from "react";
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
- // Helper to map locale string to date-fns locale object
53
- const getLocaleObject = (localeString: string): Locale => {
54
- switch (localeString.toLowerCase().split("-")[0]) {
55
- case "th":
56
- return th;
57
- default:
58
- return enUS;
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 [isMounted, setIsMounted] = useState(false);
73
- const intervalRef = useRef<NodeJS.Timeout | null>(null);
74
-
75
- // [EDIT] 👇 Wrap formatDate logic in useCallback
76
- const formatDateCallback = useCallback(() => {
77
- if (date == null) {
78
- return placeholder;
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 formatDistanceToNow(dateObj, {
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("Error formatting date in ClientDateFormatter:", error);
75
+ console.error("ClientDateFormatter:", error);
108
76
  return placeholder;
109
77
  }
110
- // Add dependencies that formatDate relies on
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
- return () => {
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,8 +1,8 @@
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
- import { useSyncExternalStore } from "react";
6
6
 
7
7
  interface RelativeDateProps {
8
8
  date: Date | string | number | null | undefined;
@@ -15,15 +15,6 @@ interface RelativeDateProps {
15
15
  emptyText?: string;
16
16
  }
17
17
 
18
- // true เฉพาะฝั่ง client หลัง hydrate — server/ตอน hydrate ได้ false เสมอ
19
- const subscribeNoop = () => () => {};
20
- const useMounted = () =>
21
- useSyncExternalStore(
22
- subscribeNoop,
23
- () => true,
24
- () => false,
25
- );
26
-
27
18
  /**
28
19
  * วันที่แบบสัมพัทธ์ภาษาไทย ("วันนี้", "เมื่อวาน", "3 วันที่ผ่านมา") + วันที่เต็มใน tooltip
29
20
  * คำนวณเฉพาะฝั่ง client หลัง mount: "ตอนนี้" ของ server (UTC, module โหลดไว้ก่อน) กับของ browser
@@ -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
+ }
@@ -0,0 +1,53 @@
1
+ "use client";
2
+
3
+ import { useSyncExternalStore } from "react";
4
+
5
+ /**
6
+ * นาฬิกากลางต่อ interval — ทุก component ที่ขอ interval เท่ากันแชร์ `setInterval` ตัวเดียว
7
+ * (ตาราง 50 แถวที่แสดง "x นาทีที่แล้ว" เดิมมี 50 timers) — เริ่มเมื่อมีผู้ใช้ตัวแรก หยุดเมื่อตัวสุดท้ายถอด
8
+ */
9
+ type Ticker = { listeners: Set<() => void>; tick: number; handle: number | null };
10
+ const tickers = new Map<number, Ticker>();
11
+
12
+ function getTicker(intervalMs: number): Ticker {
13
+ let t = tickers.get(intervalMs);
14
+ if (!t) {
15
+ t = { listeners: new Set(), tick: 0, handle: null };
16
+ tickers.set(intervalMs, t);
17
+ }
18
+ return t;
19
+ }
20
+
21
+ function subscribe(intervalMs: number, listener: () => void): () => void {
22
+ const t = getTicker(intervalMs);
23
+ t.listeners.add(listener);
24
+ if (t.handle === null) {
25
+ t.handle = window.setInterval(() => {
26
+ t.tick += 1;
27
+ for (const l of t.listeners) l();
28
+ }, intervalMs);
29
+ }
30
+ return () => {
31
+ t.listeners.delete(listener);
32
+ if (t.listeners.size === 0 && t.handle !== null) {
33
+ window.clearInterval(t.handle);
34
+ t.handle = null;
35
+ }
36
+ };
37
+ }
38
+
39
+ const subscribeNoop = () => () => {};
40
+ const zero = () => 0;
41
+
42
+ /**
43
+ * คืนตัวเลขที่เพิ่มทุก `intervalMs` (ใช้เป็น dep ให้คำนวณใหม่) — `0`/undefined = ไม่ tick
44
+ * บน server ได้ 0 เสมอ
45
+ */
46
+ export function useTicker(intervalMs: number | undefined): number {
47
+ const active = typeof intervalMs === "number" && intervalMs > 0;
48
+ return useSyncExternalStore(
49
+ active ? (l) => subscribe(intervalMs, l) : subscribeNoop,
50
+ active ? () => getTicker(intervalMs).tick : zero,
51
+ zero,
52
+ );
53
+ }