@suphark/ui 0.25.0 → 0.26.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 +60 -0
- package/package.json +1 -2
- package/src/components/app-shell/command-palette.tsx +8 -1
- package/src/components/data-table/data-table-sortable.tsx +154 -0
- package/src/components/data-table/data-table-toolbar.tsx +33 -4
- package/src/components/data-table/data-table.tsx +30 -142
- package/src/components/data-table/use-data-table.ts +8 -5
- package/src/components/design-system/design-system-showcase.tsx +63 -15
- package/src/components/pwa/pull-to-refresh.tsx +21 -1
- package/src/components/shared/action-menu.tsx +25 -6
- package/src/components/shared/auth-parts.tsx +5 -3
- package/src/components/shared/button/base/action-button.tsx +7 -14
- package/src/components/shared/button/base/link-button.tsx +5 -14
- package/src/components/shared/button/confirm-action-button.tsx +17 -3
- package/src/components/shared/chat-thread.tsx +7 -0
- package/src/components/shared/client-redirect.tsx +16 -1
- package/src/components/shared/form/date-time-picker.tsx +150 -0
- package/src/components/shared/form/form-field-autocomplete.tsx +93 -0
- package/src/components/shared/form/form-field-input.tsx +23 -81
- package/src/components/shared/form/number-input.tsx +19 -2
- package/src/components/shared/form/tags-input.tsx +7 -2
- package/src/components/shared/form/time-picker.tsx +34 -124
- package/src/components/shared/month-stepper.tsx +10 -4
- package/src/components/shared/row-actions.tsx +9 -3
- package/src/components/shared/settings-layout.tsx +17 -6
- package/src/components/shared/step-form.tsx +25 -4
- package/src/components/shared/time/client-date-formatter.tsx +2 -1
- package/src/components/shared/tree-view.tsx +6 -2
- package/src/components/shared/ui-text.tsx +3 -0
- package/src/components/ui/input.tsx +1 -1
- package/src/components/ui/native-select.tsx +1 -1
- package/src/components/ui/select.tsx +1 -1
- package/src/components/ui/textarea.tsx +1 -1
- package/src/hooks/use-table-search-params.ts +10 -2
- package/src/hooks/use-ticker.ts +11 -5
- package/src/index.ts +1 -1
- package/src/lib/formatters.ts +1 -1
- package/src/lib/month.ts +14 -2
|
@@ -12,14 +12,38 @@ import {
|
|
|
12
12
|
Zap,
|
|
13
13
|
} from "lucide-react";
|
|
14
14
|
import * as React from "react";
|
|
15
|
-
import { ShowcaseDataDisplay } from "./sections/showcase-data-display";
|
|
16
|
-
import { ShowcaseFeedback } from "./sections/showcase-feedback";
|
|
17
|
-
import { ShowcaseForms } from "./sections/showcase-forms";
|
|
18
|
-
import { ShowcaseFoundations } from "./sections/showcase-foundations";
|
|
19
|
-
import { ShowcaseGuidelinesRecipes } from "./sections/showcase-guidelines-recipes";
|
|
20
|
-
import { ShowcaseOverlaysNavigation } from "./sections/showcase-overlays-navigation";
|
|
21
15
|
import { ShowcaseOverview } from "./sections/showcase-overview";
|
|
22
|
-
|
|
16
|
+
|
|
17
|
+
// แท็บอื่นนอกจากภาพรวมโหลดเมื่อเปิดแท็บนั้น — เดิม import ทุกแท็บ (ปฏิทิน, ตาราง, dnd-kit, cmdk …) ~1 MB ทั้งที่เห็นทีละแท็บ
|
|
18
|
+
const ShowcaseDataDisplay = React.lazy(() =>
|
|
19
|
+
import("./sections/showcase-data-display").then((m) => ({ default: m.ShowcaseDataDisplay })),
|
|
20
|
+
);
|
|
21
|
+
const ShowcaseFeedback = React.lazy(() =>
|
|
22
|
+
import("./sections/showcase-feedback").then((m) => ({ default: m.ShowcaseFeedback })),
|
|
23
|
+
);
|
|
24
|
+
const ShowcaseForms = React.lazy(() =>
|
|
25
|
+
import("./sections/showcase-forms").then((m) => ({ default: m.ShowcaseForms })),
|
|
26
|
+
);
|
|
27
|
+
const ShowcaseFoundations = React.lazy(() =>
|
|
28
|
+
import("./sections/showcase-foundations").then((m) => ({ default: m.ShowcaseFoundations })),
|
|
29
|
+
);
|
|
30
|
+
const ShowcaseGuidelinesRecipes = React.lazy(() =>
|
|
31
|
+
import("./sections/showcase-guidelines-recipes").then((m) => ({
|
|
32
|
+
default: m.ShowcaseGuidelinesRecipes,
|
|
33
|
+
})),
|
|
34
|
+
);
|
|
35
|
+
const ShowcaseOverlaysNavigation = React.lazy(() =>
|
|
36
|
+
import("./sections/showcase-overlays-navigation").then((m) => ({
|
|
37
|
+
default: m.ShowcaseOverlaysNavigation,
|
|
38
|
+
})),
|
|
39
|
+
);
|
|
40
|
+
const ShowcasePrimitives = React.lazy(() =>
|
|
41
|
+
import("./sections/showcase-primitives").then((m) => ({ default: m.ShowcasePrimitives })),
|
|
42
|
+
);
|
|
43
|
+
|
|
44
|
+
function SectionLoading() {
|
|
45
|
+
return <div className="h-96 animate-pulse rounded-xl bg-muted/40" aria-busy="true" />;
|
|
46
|
+
}
|
|
23
47
|
|
|
24
48
|
export function DesignSystemShowcase({
|
|
25
49
|
links,
|
|
@@ -32,10 +56,13 @@ export function DesignSystemShowcase({
|
|
|
32
56
|
|
|
33
57
|
/** สลับแท็บแล้วเลื่อนไปหัวข้อ — เลื่อนใน effect หลังแท็บใหม่ render แล้ว (ตำแหน่งจึงถูก) ชดเชย header ที่ sticky */
|
|
34
58
|
const [pendingAnchor, setPendingAnchor] = React.useState<string | null>(null);
|
|
59
|
+
const tries = React.useRef(0);
|
|
60
|
+
const [retry, setRetry] = React.useState(0);
|
|
35
61
|
const jumpTo = React.useCallback((tab: string, anchor?: string) => {
|
|
36
62
|
setActiveTab(tab);
|
|
37
63
|
setPendingAnchor(anchor ?? "__top__");
|
|
38
64
|
}, []);
|
|
65
|
+
// biome-ignore lint/correctness/useExhaustiveDependencies: retry ใช้เป็นตัวกระตุ้นให้ลองหาหัวข้อซ้ำระหว่างรอแท็บ lazy
|
|
39
66
|
React.useEffect(() => {
|
|
40
67
|
if (!pendingAnchor) return;
|
|
41
68
|
// หน่วงเล็กน้อยให้ Tabs โฟกัส trigger ใหม่เสร็จก่อน (การโฟกัสเลื่อนหน้าไปที่แถบแท็บ) แล้วค่อยเลื่อนไปหัวข้อ
|
|
@@ -47,15 +74,22 @@ export function DesignSystemShowcase({
|
|
|
47
74
|
}
|
|
48
75
|
const el = document.getElementById(pendingAnchor);
|
|
49
76
|
if (!el) {
|
|
77
|
+
// แท็บที่โหลดแบบ lazy อาจยัง render ไม่เสร็จ — ลองใหม่ได้ถึง ~2 วินาที
|
|
78
|
+
if (tries.current++ < 25) {
|
|
79
|
+
setRetry((r) => r + 1);
|
|
80
|
+
return;
|
|
81
|
+
}
|
|
82
|
+
tries.current = 0;
|
|
50
83
|
setPendingAnchor(null);
|
|
51
84
|
return;
|
|
52
85
|
}
|
|
86
|
+
tries.current = 0;
|
|
53
87
|
el.style.scrollMarginTop = "88px"; // ชดเชย header ที่ sticky
|
|
54
88
|
el.scrollIntoView({ behavior: "smooth", block: "start" });
|
|
55
89
|
setPendingAnchor(null);
|
|
56
90
|
}, 80);
|
|
57
91
|
return () => window.clearTimeout(timer);
|
|
58
|
-
}, [pendingAnchor]);
|
|
92
|
+
}, [pendingAnchor, retry]);
|
|
59
93
|
|
|
60
94
|
const tabItems = [
|
|
61
95
|
{ value: "overview", label: "ภาพรวม", icon: LayoutGrid },
|
|
@@ -109,31 +143,45 @@ export function DesignSystemShowcase({
|
|
|
109
143
|
</TabsContent>
|
|
110
144
|
|
|
111
145
|
<TabsContent value="foundations" className="mt-0 outline-none">
|
|
112
|
-
<
|
|
146
|
+
<React.Suspense fallback={<SectionLoading />}>
|
|
147
|
+
<ShowcaseFoundations />
|
|
148
|
+
</React.Suspense>
|
|
113
149
|
</TabsContent>
|
|
114
150
|
|
|
115
151
|
<TabsContent value="primitives" className="mt-0 outline-none">
|
|
116
|
-
<
|
|
152
|
+
<React.Suspense fallback={<SectionLoading />}>
|
|
153
|
+
<ShowcasePrimitives />
|
|
154
|
+
</React.Suspense>
|
|
117
155
|
</TabsContent>
|
|
118
156
|
|
|
119
157
|
<TabsContent value="forms" className="mt-0 outline-none">
|
|
120
|
-
<
|
|
158
|
+
<React.Suspense fallback={<SectionLoading />}>
|
|
159
|
+
<ShowcaseForms />
|
|
160
|
+
</React.Suspense>
|
|
121
161
|
</TabsContent>
|
|
122
162
|
|
|
123
163
|
<TabsContent value="data-display" className="mt-0 outline-none">
|
|
124
|
-
<
|
|
164
|
+
<React.Suspense fallback={<SectionLoading />}>
|
|
165
|
+
<ShowcaseDataDisplay />
|
|
166
|
+
</React.Suspense>
|
|
125
167
|
</TabsContent>
|
|
126
168
|
|
|
127
169
|
<TabsContent value="overlays" className="mt-0 outline-none">
|
|
128
|
-
<
|
|
170
|
+
<React.Suspense fallback={<SectionLoading />}>
|
|
171
|
+
<ShowcaseOverlaysNavigation />
|
|
172
|
+
</React.Suspense>
|
|
129
173
|
</TabsContent>
|
|
130
174
|
|
|
131
175
|
<TabsContent value="feedback" className="mt-0 outline-none">
|
|
132
|
-
<
|
|
176
|
+
<React.Suspense fallback={<SectionLoading />}>
|
|
177
|
+
<ShowcaseFeedback />
|
|
178
|
+
</React.Suspense>
|
|
133
179
|
</TabsContent>
|
|
134
180
|
|
|
135
181
|
<TabsContent value="guidelines-recipes" className="mt-0 outline-none">
|
|
136
|
-
<
|
|
182
|
+
<React.Suspense fallback={<SectionLoading />}>
|
|
183
|
+
<ShowcaseGuidelinesRecipes />
|
|
184
|
+
</React.Suspense>
|
|
137
185
|
</TabsContent>
|
|
138
186
|
</Tabs>
|
|
139
187
|
</div>
|
|
@@ -113,11 +113,31 @@ export function PullToRefresh({
|
|
|
113
113
|
window.setTimeout(() => setIsRefreshing(false), 700);
|
|
114
114
|
};
|
|
115
115
|
|
|
116
|
+
// touchmove ต้อง non-passive (preventDefault ระหว่างดึง) ซึ่งทำให้ browser ต้องรอ JS ก่อนเลื่อนหน้าทุกครั้ง
|
|
117
|
+
// → ติดตั้งเฉพาะตอนที่ดึงได้จริง (จอมือถือ + หน้าอยู่บนสุด) ถอดออกเมื่อเลื่อนลงไปแล้ว
|
|
118
|
+
let moveAttached = false;
|
|
119
|
+
const syncMoveListener = () => {
|
|
120
|
+
const root = getRoot();
|
|
121
|
+
const want = isMobileViewport() && !!root && isRootAtTop(root);
|
|
122
|
+
if (want && !moveAttached) {
|
|
123
|
+
window.addEventListener("touchmove", handleTouchMove, { passive: false });
|
|
124
|
+
moveAttached = true;
|
|
125
|
+
} else if (!want && moveAttached && !isPullingRef.current) {
|
|
126
|
+
window.removeEventListener("touchmove", handleTouchMove);
|
|
127
|
+
moveAttached = false;
|
|
128
|
+
}
|
|
129
|
+
};
|
|
130
|
+
const root = getRoot();
|
|
131
|
+
syncMoveListener();
|
|
132
|
+
root?.addEventListener("scroll", syncMoveListener, { passive: true });
|
|
133
|
+
window.addEventListener("resize", syncMoveListener, { passive: true });
|
|
134
|
+
|
|
116
135
|
window.addEventListener("touchstart", handleTouchStart, { passive: true });
|
|
117
|
-
window.addEventListener("touchmove", handleTouchMove, { passive: false });
|
|
118
136
|
window.addEventListener("touchend", handleTouchEnd, { passive: true });
|
|
119
137
|
window.addEventListener("touchcancel", resetPull, { passive: true });
|
|
120
138
|
return () => {
|
|
139
|
+
root?.removeEventListener("scroll", syncMoveListener);
|
|
140
|
+
window.removeEventListener("resize", syncMoveListener);
|
|
121
141
|
window.removeEventListener("touchstart", handleTouchStart);
|
|
122
142
|
window.removeEventListener("touchmove", handleTouchMove);
|
|
123
143
|
window.removeEventListener("touchend", handleTouchEnd);
|
|
@@ -13,6 +13,7 @@ import {
|
|
|
13
13
|
} from "@suphark/ui/components/ui/dropdown-menu";
|
|
14
14
|
import { MoreHorizontal } from "lucide-react";
|
|
15
15
|
import { type ReactElement, type ReactNode, useState } from "react";
|
|
16
|
+
import { toast } from "sonner";
|
|
16
17
|
import { ConfirmDialog, type ConfirmSpec, normalizeConfirm } from "./confirm-dialog";
|
|
17
18
|
import { useUiText } from "./ui-text";
|
|
18
19
|
|
|
@@ -64,16 +65,32 @@ export function ActionMenu({
|
|
|
64
65
|
const [confirming, setConfirming] = useState<ActionMenuCommand | null>(null);
|
|
65
66
|
const [pending, setPending] = useState(false);
|
|
66
67
|
|
|
67
|
-
|
|
68
|
-
|
|
68
|
+
/** คืน true เมื่อสำเร็จ — throw/reject = toast แจ้ง (ไม่ปล่อยเป็น unhandled rejection เงียบ ๆ) */
|
|
69
|
+
const run = async (fn?: () => unknown): Promise<boolean> => {
|
|
70
|
+
if (!fn) return true;
|
|
69
71
|
setPending(true);
|
|
70
72
|
try {
|
|
71
73
|
await fn();
|
|
74
|
+
return true;
|
|
75
|
+
} catch (err) {
|
|
76
|
+
toast.error(
|
|
77
|
+
err instanceof Error && err.message
|
|
78
|
+
? err.message
|
|
79
|
+
: (text.actionFailed ?? "ดำเนินการไม่สำเร็จ"),
|
|
80
|
+
);
|
|
81
|
+
return false;
|
|
72
82
|
} finally {
|
|
73
83
|
setPending(false);
|
|
74
84
|
}
|
|
75
85
|
};
|
|
76
86
|
|
|
87
|
+
/** ลิงก์ที่มี confirm: ไปหลังยืนยันแล้วเท่านั้น */
|
|
88
|
+
const follow = (item: ActionMenuCommand) => {
|
|
89
|
+
if (!item.href) return;
|
|
90
|
+
if (item.external) window.open(item.href, "_blank", "noopener,noreferrer");
|
|
91
|
+
else window.location.assign(item.href);
|
|
92
|
+
};
|
|
93
|
+
|
|
77
94
|
const spec = confirming?.confirm ? normalizeConfirm(confirming.confirm) : null;
|
|
78
95
|
|
|
79
96
|
return (
|
|
@@ -113,7 +130,8 @@ export function ActionMenu({
|
|
|
113
130
|
variant={item.destructive ? "destructive" : "default"}
|
|
114
131
|
disabled={item.disabled}
|
|
115
132
|
render={
|
|
116
|
-
|
|
133
|
+
// มี confirm = ยังไม่เป็นลิงก์จริง (กดแล้วถามก่อน แล้วค่อยไป)
|
|
134
|
+
item.href && !item.confirm ? (
|
|
117
135
|
<a
|
|
118
136
|
href={item.href}
|
|
119
137
|
target={item.external ? "_blank" : undefined}
|
|
@@ -122,9 +140,8 @@ export function ActionMenu({
|
|
|
122
140
|
) : undefined
|
|
123
141
|
}
|
|
124
142
|
onClick={() => {
|
|
125
|
-
if (item.href) return;
|
|
126
143
|
if (item.confirm) setConfirming(item);
|
|
127
|
-
else void run(item.onClick);
|
|
144
|
+
else if (!item.href) void run(item.onClick);
|
|
128
145
|
}}
|
|
129
146
|
>
|
|
130
147
|
{content}
|
|
@@ -147,8 +164,10 @@ export function ActionMenu({
|
|
|
147
164
|
destructive={spec.destructive ?? confirming.destructive}
|
|
148
165
|
pending={pending}
|
|
149
166
|
onConfirm={async () => {
|
|
150
|
-
|
|
167
|
+
// ล้มเหลว = คง dialog ไว้ให้ลองใหม่ (toast แจ้งแล้วใน run)
|
|
168
|
+
if (!(await run(confirming.onClick))) return;
|
|
151
169
|
setConfirming(null);
|
|
170
|
+
follow(confirming);
|
|
152
171
|
}}
|
|
153
172
|
/>
|
|
154
173
|
)}
|
|
@@ -7,6 +7,7 @@ import {
|
|
|
7
7
|
InputGroupButton,
|
|
8
8
|
InputGroupInput,
|
|
9
9
|
} from "@suphark/ui/components/ui/input-group";
|
|
10
|
+
import { Spinner } from "@suphark/ui/components/ui/spinner";
|
|
10
11
|
import { cn } from "@suphark/ui/lib/utils";
|
|
11
12
|
import { EyeIcon, EyeOffIcon } from "lucide-react";
|
|
12
13
|
import { type ComponentProps, type ReactNode, useState } from "react";
|
|
@@ -73,9 +74,10 @@ export function ProviderButtons({
|
|
|
73
74
|
p.label,
|
|
74
75
|
);
|
|
75
76
|
const label = p.text ?? (grid ? p.label : full);
|
|
77
|
+
const busy = pendingId === p.id;
|
|
76
78
|
const content = (
|
|
77
79
|
<>
|
|
78
|
-
{p.icon}
|
|
80
|
+
{busy ? <Spinner /> : p.icon}
|
|
79
81
|
<span className={grid ? "truncate" : "min-w-0 text-balance"}>{label}</span>
|
|
80
82
|
{p.badge &&
|
|
81
83
|
(badgePlacement === "corner" ? (
|
|
@@ -97,14 +99,14 @@ export function ProviderButtons({
|
|
|
97
99
|
p.badge && badgePlacement === "corner" && "relative",
|
|
98
100
|
);
|
|
99
101
|
const a11y = grid ? { "aria-label": full, title: full } : {};
|
|
100
|
-
const busy = pendingId === p.id;
|
|
101
102
|
const disabled = p.disabled || (pendingId != null && !busy);
|
|
102
103
|
return p.href ? (
|
|
103
104
|
<Button
|
|
104
105
|
key={p.id}
|
|
105
106
|
variant="outline"
|
|
106
107
|
className={buttonClass}
|
|
107
|
-
disabled={disabled}
|
|
108
|
+
disabled={disabled || busy}
|
|
109
|
+
aria-busy={busy || undefined}
|
|
108
110
|
nativeButton={false}
|
|
109
111
|
render={<a href={p.href} {...a11y} />}
|
|
110
112
|
>
|
|
@@ -1,12 +1,7 @@
|
|
|
1
1
|
"use client";
|
|
2
2
|
|
|
3
3
|
import { Button } from "@suphark/ui/components/ui/button";
|
|
4
|
-
import {
|
|
5
|
-
Tooltip,
|
|
6
|
-
TooltipContent,
|
|
7
|
-
TooltipProvider,
|
|
8
|
-
TooltipTrigger,
|
|
9
|
-
} from "@suphark/ui/components/ui/tooltip";
|
|
4
|
+
import { Tooltip, TooltipContent, TooltipTrigger } from "@suphark/ui/components/ui/tooltip";
|
|
10
5
|
import {
|
|
11
6
|
type CheckPermissionOptions,
|
|
12
7
|
type CheckPermissionRequirement,
|
|
@@ -118,14 +113,12 @@ export function ActionButton({
|
|
|
118
113
|
// 5. Wrap with Tooltip if provided or auto-generated
|
|
119
114
|
if (finalTooltip) {
|
|
120
115
|
return (
|
|
121
|
-
<
|
|
122
|
-
<
|
|
123
|
-
|
|
124
|
-
<
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
</Tooltip>
|
|
128
|
-
</TooltipProvider>
|
|
116
|
+
<Tooltip>
|
|
117
|
+
<TooltipTrigger delay={0} render={buttonContent as React.ReactElement} />
|
|
118
|
+
<TooltipContent side={tooltipSide}>
|
|
119
|
+
{typeof finalTooltip === "string" ? <p>{finalTooltip}</p> : finalTooltip}
|
|
120
|
+
</TooltipContent>
|
|
121
|
+
</Tooltip>
|
|
129
122
|
);
|
|
130
123
|
}
|
|
131
124
|
|
|
@@ -2,12 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
// [FIX 1.1] นำเข้าเฉพาะ Button
|
|
4
4
|
import { Button } from "@suphark/ui/components/ui/button";
|
|
5
|
-
import {
|
|
6
|
-
Tooltip,
|
|
7
|
-
TooltipContent,
|
|
8
|
-
TooltipProvider,
|
|
9
|
-
TooltipTrigger,
|
|
10
|
-
} from "@suphark/ui/components/ui/tooltip";
|
|
5
|
+
import { Tooltip, TooltipContent, TooltipTrigger } from "@suphark/ui/components/ui/tooltip";
|
|
11
6
|
import {
|
|
12
7
|
type CheckPermissionOptions,
|
|
13
8
|
type CheckPermissionRequirement,
|
|
@@ -114,14 +109,10 @@ export function LinkButton({
|
|
|
114
109
|
// 3. Wrap with Tooltip if provided
|
|
115
110
|
if (tooltip) {
|
|
116
111
|
return (
|
|
117
|
-
<
|
|
118
|
-
<
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
{typeof tooltip === "string" ? <p>{tooltip}</p> : tooltip}
|
|
122
|
-
</TooltipContent>
|
|
123
|
-
</Tooltip>
|
|
124
|
-
</TooltipProvider>
|
|
112
|
+
<Tooltip>
|
|
113
|
+
<TooltipTrigger delay={0} render={buttonContent as React.ReactElement} />
|
|
114
|
+
<TooltipContent>{typeof tooltip === "string" ? <p>{tooltip}</p> : tooltip}</TooltipContent>
|
|
115
|
+
</Tooltip>
|
|
125
116
|
);
|
|
126
117
|
}
|
|
127
118
|
|
|
@@ -19,6 +19,15 @@ export function errorMessage(err: unknown, fallback: string): string {
|
|
|
19
19
|
return err instanceof Error && err.message ? err.message : fallback;
|
|
20
20
|
}
|
|
21
21
|
|
|
22
|
+
/** action คืน `FormState` แบบ `{ ok: false, error }` (ไม่ได้ throw) = ล้มเหลว → ข้อความ error; อย่างอื่น = null */
|
|
23
|
+
export function failedResult(result: unknown, fallback: string): string | null {
|
|
24
|
+
if (result && typeof result === "object" && "ok" in result && result.ok === false) {
|
|
25
|
+
const error = "error" in result ? result.error : undefined;
|
|
26
|
+
return typeof error === "string" && error ? error : fallback;
|
|
27
|
+
}
|
|
28
|
+
return null;
|
|
29
|
+
}
|
|
30
|
+
|
|
22
31
|
/**
|
|
23
32
|
* ปุ่มเดี่ยวที่ถามยืนยัน (ConfirmDialog) ก่อนเรียก server action — ใช้แทน `<form action>` + window.confirm
|
|
24
33
|
* `confirm` เหมือน `RowAction.confirm`; `action` + `data` เหมือน RowAction; refresh หน้าเมื่อเสร็จ
|
|
@@ -74,11 +83,16 @@ export function ConfirmActionButton({
|
|
|
74
83
|
const fd = new FormData();
|
|
75
84
|
for (const [k, v] of Object.entries(data ?? {})) fd.set(k, v);
|
|
76
85
|
start(async () => {
|
|
77
|
-
// action โยน error → แจ้งด้วย toast แล้วคง dialog ไว้ให้ลองใหม่ (ไม่ปล่อยขึ้น error boundary ทั้งหน้า)
|
|
86
|
+
// action โยน error หรือคืน { ok: false } → แจ้งด้วย toast แล้วคง dialog ไว้ให้ลองใหม่ (ไม่ปล่อยขึ้น error boundary ทั้งหน้า)
|
|
87
|
+
const fallback = text.actionFailed ?? "ดำเนินการไม่สำเร็จ";
|
|
78
88
|
try {
|
|
79
|
-
await action(fd);
|
|
89
|
+
const failed = failedResult(await action(fd), fallback);
|
|
90
|
+
if (failed) {
|
|
91
|
+
toast.error(failed);
|
|
92
|
+
return;
|
|
93
|
+
}
|
|
80
94
|
} catch (err) {
|
|
81
|
-
toast.error(errorMessage(err,
|
|
95
|
+
toast.error(errorMessage(err, fallback));
|
|
82
96
|
return;
|
|
83
97
|
}
|
|
84
98
|
onDone?.();
|
|
@@ -27,6 +27,7 @@ import { Spinner } from "@suphark/ui/components/ui/spinner";
|
|
|
27
27
|
import { cn } from "@suphark/ui/lib/utils";
|
|
28
28
|
import { ArrowDown, SendHorizontal } from "lucide-react";
|
|
29
29
|
import { type ReactNode, useState } from "react";
|
|
30
|
+
import { toast } from "sonner";
|
|
30
31
|
import { useUiText } from "./ui-text";
|
|
31
32
|
|
|
32
33
|
export interface ChatMessage {
|
|
@@ -150,6 +151,7 @@ function ChatComposer({
|
|
|
150
151
|
}) {
|
|
151
152
|
const [value, setValue] = useState("");
|
|
152
153
|
const [pending, setPending] = useState(false);
|
|
154
|
+
const failedText = useUiText().actionFailed;
|
|
153
155
|
const canSend = value.trim() !== "" && !disabled && !pending;
|
|
154
156
|
|
|
155
157
|
const send = async () => {
|
|
@@ -158,6 +160,11 @@ function ChatComposer({
|
|
|
158
160
|
try {
|
|
159
161
|
await onSend(value.trim());
|
|
160
162
|
setValue("");
|
|
163
|
+
} catch (err) {
|
|
164
|
+
// ส่งไม่สำเร็จ: คงข้อความไว้ในช่องให้ลองใหม่ + แจ้งผู้ใช้ (เดิมเงียบ — unhandled rejection)
|
|
165
|
+
toast.error(
|
|
166
|
+
err instanceof Error && err.message ? err.message : (failedText ?? "ดำเนินการไม่สำเร็จ"),
|
|
167
|
+
);
|
|
161
168
|
} finally {
|
|
162
169
|
setPending(false);
|
|
163
170
|
}
|
|
@@ -2,9 +2,24 @@
|
|
|
2
2
|
|
|
3
3
|
import { useEffect } from "react";
|
|
4
4
|
|
|
5
|
+
/**
|
|
6
|
+
* redirect ฝั่ง browser (`location.replace`) — รับเฉพาะ http(s) และ path ภายใน
|
|
7
|
+
* `javascript:`/`data:` ถูกปฏิเสธ (location.replace ไม่มีตัวกันแบบ `<a href>`/router ของ Next)
|
|
8
|
+
* ถ้า `to` มาจาก URL/ผู้ใช้ แอปต้องเช็กเองว่าเป็นปลายทางที่อนุญาต (กัน open redirect ไปโดเมนอื่น)
|
|
9
|
+
*/
|
|
5
10
|
export function ClientRedirect({ to }: { to: string }) {
|
|
6
11
|
useEffect(() => {
|
|
7
|
-
|
|
12
|
+
let url: URL;
|
|
13
|
+
try {
|
|
14
|
+
url = new URL(to, window.location.href);
|
|
15
|
+
} catch {
|
|
16
|
+
return;
|
|
17
|
+
}
|
|
18
|
+
if (url.protocol !== "http:" && url.protocol !== "https:") {
|
|
19
|
+
console.error(`ClientRedirect: ปฏิเสธปลายทางที่ไม่ใช่ http(s): ${url.protocol}`);
|
|
20
|
+
return;
|
|
21
|
+
}
|
|
22
|
+
window.location.replace(url.href);
|
|
8
23
|
}, [to]);
|
|
9
24
|
|
|
10
25
|
return null;
|
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { Button } from "@suphark/ui/components/ui/button";
|
|
4
|
+
import { Calendar } from "@suphark/ui/components/ui/calendar";
|
|
5
|
+
import { Popover, PopoverContent, PopoverTrigger } from "@suphark/ui/components/ui/popover";
|
|
6
|
+
import { useMounted } from "@suphark/ui/hooks/use-mounted";
|
|
7
|
+
import { cn } from "@suphark/ui/lib/utils";
|
|
8
|
+
import { CalendarClockIcon } from "lucide-react";
|
|
9
|
+
import { useState } from "react";
|
|
10
|
+
import { th } from "react-day-picker/locale/th";
|
|
11
|
+
import { useUiText } from "../ui-text";
|
|
12
|
+
import {
|
|
13
|
+
pad,
|
|
14
|
+
splitTime,
|
|
15
|
+
TimeColumns,
|
|
16
|
+
type TimeValue,
|
|
17
|
+
toLocalDateTimeString,
|
|
18
|
+
useControllable,
|
|
19
|
+
} from "./time-picker";
|
|
20
|
+
|
|
21
|
+
/** "YYYY-MM-DDTHH:mm+07:00" — เวลาท้องถิ่นพร้อม offset (ISO 8601) ให้ server ที่อยู่โซนอื่น parse ได้ถูก */
|
|
22
|
+
function toOffsetDateTimeString(date: Date): string {
|
|
23
|
+
const offset = -date.getTimezoneOffset();
|
|
24
|
+
const sign = offset >= 0 ? "+" : "-";
|
|
25
|
+
const abs = Math.abs(offset);
|
|
26
|
+
return `${toLocalDateTimeString(date)}${sign}${pad(Math.floor(abs / 60))}:${pad(abs % 60)}`;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* เลือกวัน + เวลาในกล่องเดียว (ปฏิทิน + คอลัมน์ชั่วโมง/นาที) — นัดหมาย, กำหนดส่ง, เวลาเตือน
|
|
31
|
+
* ค่าเป็น `Date | null`; เลือกวันก่อนเวลา = ใช้ `defaultTime`; `name` ส่ง "YYYY-MM-DDTHH:mm+07:00" (เวลาท้องถิ่น + offset)
|
|
32
|
+
* ใน FormData → `new Date(formData.get(name))` บน server (UTC) ได้เวลาที่ถูกต้อง
|
|
33
|
+
* ข้อความวัน/เวลาแสดงหลัง hydrate (server กับ browser อยู่คนละโซนเวลา — กัน hydration mismatch)
|
|
34
|
+
*
|
|
35
|
+
* <DateTimePicker value={dueAt} onValueChange={setDueAt} minuteStep={15} />
|
|
36
|
+
*/
|
|
37
|
+
export function DateTimePicker({
|
|
38
|
+
value,
|
|
39
|
+
defaultValue = null,
|
|
40
|
+
onValueChange,
|
|
41
|
+
minuteStep = 5,
|
|
42
|
+
defaultTime = "09:00",
|
|
43
|
+
name,
|
|
44
|
+
id,
|
|
45
|
+
disabled,
|
|
46
|
+
placeholder,
|
|
47
|
+
className,
|
|
48
|
+
"aria-label": ariaLabel,
|
|
49
|
+
"aria-invalid": ariaInvalid,
|
|
50
|
+
}: {
|
|
51
|
+
value?: Date | null;
|
|
52
|
+
defaultValue?: Date | null;
|
|
53
|
+
onValueChange?: (value: Date | null) => void;
|
|
54
|
+
minuteStep?: number;
|
|
55
|
+
/** เวลาที่ใช้เมื่อเลือกวันครั้งแรก */
|
|
56
|
+
defaultTime?: TimeValue;
|
|
57
|
+
name?: string;
|
|
58
|
+
id?: string;
|
|
59
|
+
disabled?: boolean;
|
|
60
|
+
placeholder?: string;
|
|
61
|
+
className?: string;
|
|
62
|
+
"aria-label"?: string;
|
|
63
|
+
"aria-invalid"?: boolean;
|
|
64
|
+
}) {
|
|
65
|
+
const ui = useUiText();
|
|
66
|
+
const text = ui.time;
|
|
67
|
+
const [date, setDate] = useControllable<Date | null>(value, defaultValue, onValueChange);
|
|
68
|
+
const [open, setOpen] = useState(false);
|
|
69
|
+
const mounted = useMounted();
|
|
70
|
+
const time = date ? `${pad(date.getHours())}:${pad(date.getMinutes())}` : null;
|
|
71
|
+
|
|
72
|
+
const withTime = (day: Date, t: TimeValue) => {
|
|
73
|
+
const [h, m] = splitTime(t) ?? [9, 0];
|
|
74
|
+
const d = new Date(day);
|
|
75
|
+
d.setHours(h, m, 0, 0);
|
|
76
|
+
return d;
|
|
77
|
+
};
|
|
78
|
+
|
|
79
|
+
const label = !date
|
|
80
|
+
? (placeholder ?? text?.dateTimePlaceholder ?? "เลือกวันและเวลา")
|
|
81
|
+
: !mounted
|
|
82
|
+
? " " // ก่อน hydrate: เว้นที่ไว้ ไม่แสดงเวลาของ server
|
|
83
|
+
: date.toLocaleString("th-TH", { dateStyle: "medium", timeStyle: "short" });
|
|
84
|
+
|
|
85
|
+
return (
|
|
86
|
+
<Popover open={open} onOpenChange={setOpen}>
|
|
87
|
+
<PopoverTrigger
|
|
88
|
+
render={
|
|
89
|
+
<Button
|
|
90
|
+
id={id}
|
|
91
|
+
type="button"
|
|
92
|
+
variant="outline"
|
|
93
|
+
disabled={disabled}
|
|
94
|
+
aria-label={ariaLabel}
|
|
95
|
+
aria-invalid={ariaInvalid || undefined}
|
|
96
|
+
data-slot="date-time-picker"
|
|
97
|
+
className={cn(
|
|
98
|
+
"w-full justify-start gap-2 font-normal",
|
|
99
|
+
!date && "text-muted-foreground",
|
|
100
|
+
className,
|
|
101
|
+
)}
|
|
102
|
+
/>
|
|
103
|
+
}
|
|
104
|
+
>
|
|
105
|
+
<CalendarClockIcon className="text-muted-foreground" />
|
|
106
|
+
<span className="truncate">{label}</span>
|
|
107
|
+
</PopoverTrigger>
|
|
108
|
+
{name && (
|
|
109
|
+
<input
|
|
110
|
+
type="hidden"
|
|
111
|
+
name={name}
|
|
112
|
+
value={date && mounted ? toOffsetDateTimeString(date) : ""}
|
|
113
|
+
/>
|
|
114
|
+
)}
|
|
115
|
+
<PopoverContent align="start" className="w-auto gap-0 p-0">
|
|
116
|
+
<div className="flex flex-col sm:flex-row">
|
|
117
|
+
<Calendar
|
|
118
|
+
mode="single"
|
|
119
|
+
locale={th}
|
|
120
|
+
selected={date ?? undefined}
|
|
121
|
+
defaultMonth={date ?? undefined}
|
|
122
|
+
onSelect={(day) => day && setDate(withTime(day, time ?? defaultTime))}
|
|
123
|
+
className="p-3"
|
|
124
|
+
/>
|
|
125
|
+
<div className="flex justify-center border-t p-2 sm:border-t-0 sm:border-l">
|
|
126
|
+
<TimeColumns
|
|
127
|
+
value={time}
|
|
128
|
+
minuteStep={minuteStep}
|
|
129
|
+
onValueChange={(t) => setDate(withTime(date ?? new Date(), t))}
|
|
130
|
+
/>
|
|
131
|
+
</div>
|
|
132
|
+
</div>
|
|
133
|
+
<div className="flex justify-between gap-2 border-t p-2">
|
|
134
|
+
<Button
|
|
135
|
+
type="button"
|
|
136
|
+
size="xs"
|
|
137
|
+
variant="ghost"
|
|
138
|
+
disabled={!date}
|
|
139
|
+
onClick={() => setDate(null)}
|
|
140
|
+
>
|
|
141
|
+
{text?.clear ?? "ล้าง"}
|
|
142
|
+
</Button>
|
|
143
|
+
<Button type="button" size="xs" onClick={() => setOpen(false)}>
|
|
144
|
+
{ui.done}
|
|
145
|
+
</Button>
|
|
146
|
+
</div>
|
|
147
|
+
</PopoverContent>
|
|
148
|
+
</Popover>
|
|
149
|
+
);
|
|
150
|
+
}
|