@suphark/ui 0.7.0 → 0.8.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 +15 -0
- package/README.md +3 -2
- package/package.json +1 -1
- package/src/components/app-shell/command-palette.tsx +63 -13
- package/src/components/app-shell/index.ts +1 -0
- package/src/components/shared/action-form.tsx +29 -13
- package/src/components/shared/color-picker.tsx +180 -0
- package/src/components/shared/dropzone.tsx +333 -0
- package/src/components/shared/file-type-icon.tsx +171 -0
- package/src/components/shared/form-dialog.tsx +3 -3
- package/src/components/shared/form-message.tsx +13 -6
- package/src/components/shared/tree-view.tsx +325 -0
- package/src/components/shared/upload-list.tsx +233 -0
- package/src/index.ts +24 -1
- package/src/lib/formatters.ts +17 -0
|
@@ -0,0 +1,333 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { Button } from "@suphark/ui/components/ui/button";
|
|
4
|
+
import { formatBytes } from "@suphark/ui/lib/formatters";
|
|
5
|
+
import { cn } from "@suphark/ui/lib/utils";
|
|
6
|
+
import { UploadCloud } from "lucide-react";
|
|
7
|
+
import {
|
|
8
|
+
type ChangeEvent,
|
|
9
|
+
type DragEvent,
|
|
10
|
+
type ReactNode,
|
|
11
|
+
useCallback,
|
|
12
|
+
useEffect,
|
|
13
|
+
useId,
|
|
14
|
+
useRef,
|
|
15
|
+
useState,
|
|
16
|
+
} from "react";
|
|
17
|
+
|
|
18
|
+
export type DropzoneRejectReason = "size" | "type" | "multiple";
|
|
19
|
+
|
|
20
|
+
export interface DropzoneText {
|
|
21
|
+
/** ข้อความหลักในการ์ด */
|
|
22
|
+
title: string;
|
|
23
|
+
/** "หรือ" ก่อนปุ่ม */
|
|
24
|
+
or: string;
|
|
25
|
+
/** ปุ่มเลือกไฟล์ */
|
|
26
|
+
browse: string;
|
|
27
|
+
/** ข้อความบน overlay ตอนลากไฟล์เข้าหน้า */
|
|
28
|
+
overlay: string;
|
|
29
|
+
/** ขนาดสูงสุด — รับข้อความขนาดที่ฟอร์แมตแล้ว */
|
|
30
|
+
maxSize: (size: string) => string;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
const TEXT: Record<"th" | "en", DropzoneText> = {
|
|
34
|
+
th: {
|
|
35
|
+
title: "ลากไฟล์มาวางที่นี่",
|
|
36
|
+
or: "หรือ",
|
|
37
|
+
browse: "เลือกไฟล์",
|
|
38
|
+
overlay: "วางไฟล์ที่นี่",
|
|
39
|
+
maxSize: (s) => `ไฟล์ละไม่เกิน ${s}`,
|
|
40
|
+
},
|
|
41
|
+
en: {
|
|
42
|
+
title: "Drag files here",
|
|
43
|
+
or: "or",
|
|
44
|
+
browse: "Browse files",
|
|
45
|
+
overlay: "Drop files here",
|
|
46
|
+
maxSize: (s) => `Up to ${s} per file`,
|
|
47
|
+
},
|
|
48
|
+
};
|
|
49
|
+
|
|
50
|
+
export interface DropzoneProps {
|
|
51
|
+
onFiles: (files: File[]) => void;
|
|
52
|
+
/** ไฟล์ที่ไม่ผ่าน (เกินขนาด / ชนิดไม่ตรง accept / เกิน 1 ไฟล์เมื่อ multiple=false) */
|
|
53
|
+
onRejected?: (files: File[], reason: DropzoneRejectReason) => void;
|
|
54
|
+
/** รับหลายไฟล์ (ค่าเริ่มต้น true) */
|
|
55
|
+
multiple?: boolean;
|
|
56
|
+
/** เหมือน `<input accept>`: "image/*,.pdf,application/zip" — ใช้กรองทั้งตอนเลือกและตอนวาง */
|
|
57
|
+
accept?: string;
|
|
58
|
+
/** ขนาดสูงสุดต่อไฟล์ (byte) */
|
|
59
|
+
maxBytes?: number;
|
|
60
|
+
disabled?: boolean;
|
|
61
|
+
/** เลือกทั้งโฟลเดอร์ (`webkitdirectory`) — ตอนวางโฟลเดอร์จะไล่อ่านไฟล์ข้างในให้ */
|
|
62
|
+
directory?: boolean;
|
|
63
|
+
/** ข้อความช่วยใต้ปุ่ม (ค่าเริ่มต้นแสดง maxBytes ถ้ามี) */
|
|
64
|
+
hint?: ReactNode;
|
|
65
|
+
/** ไอคอนในการ์ด (ค่าเริ่มต้น UploadCloud) */
|
|
66
|
+
icon?: ReactNode;
|
|
67
|
+
/**
|
|
68
|
+
* โหมด overlay: ห่อ `children` (เช่น ตารางไฟล์) แล้วโชว์ชั้น "วางไฟล์ที่นี่" ทับเฉพาะตอนลากไฟล์เข้าหน้า
|
|
69
|
+
* ไม่ส่ง children = การ์ดเดี่ยว (เส้นประ + ไอคอน + ข้อความ + ปุ่มเลือกไฟล์)
|
|
70
|
+
*/
|
|
71
|
+
overlay?: boolean;
|
|
72
|
+
children?: ReactNode;
|
|
73
|
+
language?: "th" | "en";
|
|
74
|
+
text?: Partial<DropzoneText>;
|
|
75
|
+
className?: string;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/** ตรวจตาม `accept` ของ input: ".pdf" (นามสกุล), "image/*" (กลุ่ม), "application/pdf" (ตรงตัว) */
|
|
79
|
+
export function matchesAccept(file: File, accept?: string): boolean {
|
|
80
|
+
if (!accept?.trim()) return true;
|
|
81
|
+
const name = file.name.toLowerCase();
|
|
82
|
+
const type = file.type.toLowerCase();
|
|
83
|
+
return accept
|
|
84
|
+
.split(",")
|
|
85
|
+
.map((a) => a.trim().toLowerCase())
|
|
86
|
+
.filter(Boolean)
|
|
87
|
+
.some((a) => {
|
|
88
|
+
if (a.startsWith(".")) return name.endsWith(a);
|
|
89
|
+
if (a.endsWith("/*")) return type.startsWith(a.slice(0, -1));
|
|
90
|
+
return type === a;
|
|
91
|
+
});
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
function hasFiles(e: DragEvent | globalThis.DragEvent): boolean {
|
|
95
|
+
return Array.from(e.dataTransfer?.types ?? []).includes("Files");
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/** ไล่อ่านไฟล์จาก DataTransfer — รองรับโฟลเดอร์ผ่าน webkitGetAsEntry (Chrome/Edge/Safari) */
|
|
99
|
+
async function readDataTransfer(dt: DataTransfer, recurse: boolean): Promise<File[]> {
|
|
100
|
+
const items = Array.from(dt.items ?? []);
|
|
101
|
+
const entries = recurse
|
|
102
|
+
? items.map((it) => (it.kind === "file" ? it.webkitGetAsEntry?.() : null))
|
|
103
|
+
: [];
|
|
104
|
+
if (!recurse || entries.every((e) => !e)) return Array.from(dt.files ?? []);
|
|
105
|
+
const out: File[] = [];
|
|
106
|
+
async function walk(entry: FileSystemEntry | null): Promise<void> {
|
|
107
|
+
if (!entry) return;
|
|
108
|
+
if (entry.isFile) {
|
|
109
|
+
const file = await new Promise<File | null>((resolve) =>
|
|
110
|
+
(entry as FileSystemFileEntry).file(resolve, () => resolve(null)),
|
|
111
|
+
);
|
|
112
|
+
if (file) out.push(file);
|
|
113
|
+
} else if (entry.isDirectory) {
|
|
114
|
+
const reader = (entry as FileSystemDirectoryEntry).createReader();
|
|
115
|
+
// readEntries คืนเป็นชุด (Chrome สูงสุด 100/ครั้ง) ต้องเรียกซ้ำจนว่าง
|
|
116
|
+
for (;;) {
|
|
117
|
+
const batch = await new Promise<FileSystemEntry[]>((resolve) =>
|
|
118
|
+
reader.readEntries(resolve, () => resolve([])),
|
|
119
|
+
);
|
|
120
|
+
if (batch.length === 0) break;
|
|
121
|
+
for (const child of batch) await walk(child);
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
for (const entry of entries) await walk(entry);
|
|
126
|
+
return out;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/**
|
|
130
|
+
* พื้นที่ลากวางไฟล์ 2 โหมด — การ์ดเดี่ยว หรือ overlay ห่อเนื้อหา (แบบ Google Drive):
|
|
131
|
+
* กรอง `accept`/`maxBytes`/`multiple` ให้แล้วส่ง `onFiles` เฉพาะที่ผ่าน ที่ไม่ผ่านไป `onRejected` แยกตามเหตุผล
|
|
132
|
+
* คลิกการ์ด/ปุ่ม/Enter เปิด file picker (input ซ่อน); `directory` เลือกและวางทั้งโฟลเดอร์
|
|
133
|
+
*/
|
|
134
|
+
export function Dropzone({
|
|
135
|
+
onFiles,
|
|
136
|
+
onRejected,
|
|
137
|
+
multiple = true,
|
|
138
|
+
accept,
|
|
139
|
+
maxBytes,
|
|
140
|
+
disabled = false,
|
|
141
|
+
directory = false,
|
|
142
|
+
hint,
|
|
143
|
+
icon,
|
|
144
|
+
overlay = false,
|
|
145
|
+
children,
|
|
146
|
+
language = "th",
|
|
147
|
+
text: textProp,
|
|
148
|
+
className,
|
|
149
|
+
}: DropzoneProps) {
|
|
150
|
+
const text: DropzoneText = { ...TEXT[language], ...textProp };
|
|
151
|
+
const inputRef = useRef<HTMLInputElement>(null);
|
|
152
|
+
const inputId = useId();
|
|
153
|
+
const [active, setActive] = useState(false);
|
|
154
|
+
// dragenter/dragleave ยิงซ้ำตามลูกทุกตัว — นับลึกเพื่อรู้ว่าออกนอกจริง
|
|
155
|
+
const depth = useRef(0);
|
|
156
|
+
|
|
157
|
+
const submit = useCallback(
|
|
158
|
+
(all: File[]) => {
|
|
159
|
+
const bySize: File[] = [];
|
|
160
|
+
const byType: File[] = [];
|
|
161
|
+
let ok = all.filter((f) => {
|
|
162
|
+
if (!matchesAccept(f, accept)) {
|
|
163
|
+
byType.push(f);
|
|
164
|
+
return false;
|
|
165
|
+
}
|
|
166
|
+
if (maxBytes != null && f.size > maxBytes) {
|
|
167
|
+
bySize.push(f);
|
|
168
|
+
return false;
|
|
169
|
+
}
|
|
170
|
+
return true;
|
|
171
|
+
});
|
|
172
|
+
if (!multiple && ok.length > 1) {
|
|
173
|
+
onRejected?.(ok.slice(1), "multiple");
|
|
174
|
+
ok = ok.slice(0, 1);
|
|
175
|
+
}
|
|
176
|
+
if (byType.length) onRejected?.(byType, "type");
|
|
177
|
+
if (bySize.length) onRejected?.(bySize, "size");
|
|
178
|
+
if (ok.length) onFiles(ok);
|
|
179
|
+
},
|
|
180
|
+
[accept, maxBytes, multiple, onFiles, onRejected],
|
|
181
|
+
);
|
|
182
|
+
|
|
183
|
+
function openPicker() {
|
|
184
|
+
if (!disabled) inputRef.current?.click();
|
|
185
|
+
}
|
|
186
|
+
function onInputChange(e: ChangeEvent<HTMLInputElement>) {
|
|
187
|
+
const files = Array.from(e.target.files ?? []);
|
|
188
|
+
e.target.value = ""; // เลือกไฟล์เดิมซ้ำได้
|
|
189
|
+
if (files.length) submit(files);
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
// โหมด overlay: รู้ว่ากำลังลากไฟล์เข้าหน้า (window) — ไม่ต้องลากให้ถึงตัว wrapper ก่อน
|
|
193
|
+
useEffect(() => {
|
|
194
|
+
if (!overlay || disabled) return;
|
|
195
|
+
const enter = (e: globalThis.DragEvent) => {
|
|
196
|
+
if (!hasFiles(e)) return;
|
|
197
|
+
depth.current += 1;
|
|
198
|
+
setActive(true);
|
|
199
|
+
};
|
|
200
|
+
const leave = () => {
|
|
201
|
+
depth.current = Math.max(0, depth.current - 1);
|
|
202
|
+
if (depth.current === 0) setActive(false);
|
|
203
|
+
};
|
|
204
|
+
const over = (e: globalThis.DragEvent) => {
|
|
205
|
+
if (hasFiles(e)) e.preventDefault(); // ไม่งั้น browser เปิดไฟล์เมื่อวางนอก dropzone
|
|
206
|
+
};
|
|
207
|
+
const drop = () => {
|
|
208
|
+
depth.current = 0;
|
|
209
|
+
setActive(false);
|
|
210
|
+
};
|
|
211
|
+
window.addEventListener("dragenter", enter);
|
|
212
|
+
window.addEventListener("dragleave", leave);
|
|
213
|
+
window.addEventListener("dragover", over);
|
|
214
|
+
window.addEventListener("drop", drop);
|
|
215
|
+
return () => {
|
|
216
|
+
window.removeEventListener("dragenter", enter);
|
|
217
|
+
window.removeEventListener("dragleave", leave);
|
|
218
|
+
window.removeEventListener("dragover", over);
|
|
219
|
+
window.removeEventListener("drop", drop);
|
|
220
|
+
};
|
|
221
|
+
}, [overlay, disabled]);
|
|
222
|
+
|
|
223
|
+
const dragProps = {
|
|
224
|
+
onDragEnter: (e: DragEvent) => {
|
|
225
|
+
if (disabled || !hasFiles(e)) return;
|
|
226
|
+
e.preventDefault();
|
|
227
|
+
if (!overlay) {
|
|
228
|
+
depth.current += 1;
|
|
229
|
+
setActive(true);
|
|
230
|
+
}
|
|
231
|
+
},
|
|
232
|
+
onDragOver: (e: DragEvent) => {
|
|
233
|
+
if (disabled || !hasFiles(e)) return;
|
|
234
|
+
e.preventDefault();
|
|
235
|
+
e.dataTransfer.dropEffect = "copy";
|
|
236
|
+
},
|
|
237
|
+
onDragLeave: () => {
|
|
238
|
+
if (overlay) return;
|
|
239
|
+
depth.current = Math.max(0, depth.current - 1);
|
|
240
|
+
if (depth.current === 0) setActive(false);
|
|
241
|
+
},
|
|
242
|
+
onDrop: async (e: DragEvent) => {
|
|
243
|
+
if (disabled || !hasFiles(e)) return;
|
|
244
|
+
e.preventDefault();
|
|
245
|
+
depth.current = 0;
|
|
246
|
+
setActive(false);
|
|
247
|
+
const files = await readDataTransfer(e.dataTransfer, directory);
|
|
248
|
+
if (files.length) submit(files);
|
|
249
|
+
},
|
|
250
|
+
};
|
|
251
|
+
|
|
252
|
+
const input = (
|
|
253
|
+
<input
|
|
254
|
+
ref={inputRef}
|
|
255
|
+
id={inputId}
|
|
256
|
+
type="file"
|
|
257
|
+
className="sr-only"
|
|
258
|
+
tabIndex={-1}
|
|
259
|
+
multiple={multiple}
|
|
260
|
+
accept={accept}
|
|
261
|
+
disabled={disabled}
|
|
262
|
+
onChange={onInputChange}
|
|
263
|
+
// input.click() ก็ยิง click ที่ bubble ขึ้นการ์ด → จะเปิด picker ซ้ำ
|
|
264
|
+
onClick={(e) => e.stopPropagation()}
|
|
265
|
+
// React ไม่รู้จัก prop นี้ — ส่งเป็น attribute ตรง ๆ
|
|
266
|
+
{...(directory ? { webkitdirectory: "" } : {})}
|
|
267
|
+
/>
|
|
268
|
+
);
|
|
269
|
+
|
|
270
|
+
if (overlay) {
|
|
271
|
+
return (
|
|
272
|
+
<div
|
|
273
|
+
data-slot="dropzone"
|
|
274
|
+
data-active={active || undefined}
|
|
275
|
+
className={cn("relative", className)}
|
|
276
|
+
{...dragProps}
|
|
277
|
+
>
|
|
278
|
+
{children}
|
|
279
|
+
{input}
|
|
280
|
+
{active && (
|
|
281
|
+
<div
|
|
282
|
+
role="status"
|
|
283
|
+
className="absolute inset-0 z-10 flex flex-col items-center justify-center gap-2 rounded-xl border-2 border-primary border-dashed bg-primary/5 text-primary backdrop-blur-[1px]"
|
|
284
|
+
>
|
|
285
|
+
{icon ?? <UploadCloud className="size-8" aria-hidden="true" />}
|
|
286
|
+
<p className="font-semibold text-sm">{text.overlay}</p>
|
|
287
|
+
</div>
|
|
288
|
+
)}
|
|
289
|
+
</div>
|
|
290
|
+
);
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
const defaultHint =
|
|
294
|
+
hint ?? (maxBytes != null ? text.maxSize(formatBytes(maxBytes, "en-US")) : null);
|
|
295
|
+
return (
|
|
296
|
+
<div
|
|
297
|
+
data-slot="dropzone"
|
|
298
|
+
data-active={active || undefined}
|
|
299
|
+
data-disabled={disabled || undefined}
|
|
300
|
+
onClick={openPicker}
|
|
301
|
+
className={cn(
|
|
302
|
+
"flex cursor-pointer flex-col items-center justify-center gap-2 rounded-xl border-2 border-dashed bg-muted/30 px-4 py-8 text-center transition-colors",
|
|
303
|
+
"hover:border-primary/50 hover:bg-muted/50",
|
|
304
|
+
"data-active:border-primary data-active:bg-primary/5",
|
|
305
|
+
"data-disabled:cursor-not-allowed data-disabled:opacity-60 data-disabled:hover:border-border data-disabled:hover:bg-muted/30",
|
|
306
|
+
className,
|
|
307
|
+
)}
|
|
308
|
+
{...dragProps}
|
|
309
|
+
>
|
|
310
|
+
<span className="text-muted-foreground [&_svg]:size-8">
|
|
311
|
+
{icon ?? <UploadCloud aria-hidden="true" />}
|
|
312
|
+
</span>
|
|
313
|
+
<p className="font-medium text-sm">{text.title}</p>
|
|
314
|
+
<p className="flex items-center gap-2 text-muted-foreground text-xs">
|
|
315
|
+
{text.or}
|
|
316
|
+
<Button
|
|
317
|
+
type="button"
|
|
318
|
+
variant="outline"
|
|
319
|
+
size="sm"
|
|
320
|
+
disabled={disabled}
|
|
321
|
+
onClick={(e) => {
|
|
322
|
+
e.stopPropagation();
|
|
323
|
+
openPicker();
|
|
324
|
+
}}
|
|
325
|
+
>
|
|
326
|
+
{text.browse}
|
|
327
|
+
</Button>
|
|
328
|
+
</p>
|
|
329
|
+
{defaultHint && <p className="text-muted-foreground text-xs">{defaultHint}</p>}
|
|
330
|
+
{input}
|
|
331
|
+
</div>
|
|
332
|
+
);
|
|
333
|
+
}
|
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
import { cn } from "@suphark/ui/lib/utils";
|
|
2
|
+
import {
|
|
3
|
+
File,
|
|
4
|
+
FileArchive,
|
|
5
|
+
FileAudio,
|
|
6
|
+
FileCode,
|
|
7
|
+
FileImage,
|
|
8
|
+
FileSpreadsheet,
|
|
9
|
+
FileText,
|
|
10
|
+
FileVideo,
|
|
11
|
+
Folder,
|
|
12
|
+
type LucideIcon,
|
|
13
|
+
Presentation,
|
|
14
|
+
} from "lucide-react";
|
|
15
|
+
import type { ComponentProps } from "react";
|
|
16
|
+
|
|
17
|
+
export type FileKind =
|
|
18
|
+
| "folder"
|
|
19
|
+
| "image"
|
|
20
|
+
| "video"
|
|
21
|
+
| "audio"
|
|
22
|
+
| "pdf"
|
|
23
|
+
| "doc"
|
|
24
|
+
| "sheet"
|
|
25
|
+
| "slides"
|
|
26
|
+
| "zip"
|
|
27
|
+
| "code"
|
|
28
|
+
| "text"
|
|
29
|
+
| "default";
|
|
30
|
+
|
|
31
|
+
const EXT: Record<string, FileKind> = {
|
|
32
|
+
png: "image",
|
|
33
|
+
jpg: "image",
|
|
34
|
+
jpeg: "image",
|
|
35
|
+
gif: "image",
|
|
36
|
+
webp: "image",
|
|
37
|
+
svg: "image",
|
|
38
|
+
bmp: "image",
|
|
39
|
+
heic: "image",
|
|
40
|
+
avif: "image",
|
|
41
|
+
mp4: "video",
|
|
42
|
+
mov: "video",
|
|
43
|
+
webm: "video",
|
|
44
|
+
mkv: "video",
|
|
45
|
+
avi: "video",
|
|
46
|
+
mp3: "audio",
|
|
47
|
+
m4a: "audio",
|
|
48
|
+
wav: "audio",
|
|
49
|
+
flac: "audio",
|
|
50
|
+
ogg: "audio",
|
|
51
|
+
aac: "audio",
|
|
52
|
+
pdf: "pdf",
|
|
53
|
+
doc: "doc",
|
|
54
|
+
docx: "doc",
|
|
55
|
+
odt: "doc",
|
|
56
|
+
rtf: "doc",
|
|
57
|
+
xls: "sheet",
|
|
58
|
+
xlsx: "sheet",
|
|
59
|
+
csv: "sheet",
|
|
60
|
+
ods: "sheet",
|
|
61
|
+
ppt: "slides",
|
|
62
|
+
pptx: "slides",
|
|
63
|
+
odp: "slides",
|
|
64
|
+
key: "slides",
|
|
65
|
+
zip: "zip",
|
|
66
|
+
rar: "zip",
|
|
67
|
+
"7z": "zip",
|
|
68
|
+
gz: "zip",
|
|
69
|
+
tar: "zip",
|
|
70
|
+
tgz: "zip",
|
|
71
|
+
txt: "text",
|
|
72
|
+
md: "text",
|
|
73
|
+
log: "text",
|
|
74
|
+
js: "code",
|
|
75
|
+
ts: "code",
|
|
76
|
+
tsx: "code",
|
|
77
|
+
jsx: "code",
|
|
78
|
+
json: "code",
|
|
79
|
+
html: "code",
|
|
80
|
+
css: "code",
|
|
81
|
+
py: "code",
|
|
82
|
+
go: "code",
|
|
83
|
+
rs: "code",
|
|
84
|
+
java: "code",
|
|
85
|
+
sql: "code",
|
|
86
|
+
sh: "code",
|
|
87
|
+
yml: "code",
|
|
88
|
+
yaml: "code",
|
|
89
|
+
xml: "code",
|
|
90
|
+
};
|
|
91
|
+
|
|
92
|
+
/** ประเภทไฟล์จาก mime (หลัก) หรือนามสกุลของ `filename` (สำรอง) — ใช้เลือกไอคอน/สี */
|
|
93
|
+
export function getFileKind(mime?: string | null, filename?: string | null): FileKind {
|
|
94
|
+
const m = (mime ?? "").toLowerCase();
|
|
95
|
+
if (m === "inode/directory" || m === "folder") return "folder";
|
|
96
|
+
if (m.startsWith("image/")) return "image";
|
|
97
|
+
if (m.startsWith("video/")) return "video";
|
|
98
|
+
if (m.startsWith("audio/")) return "audio";
|
|
99
|
+
if (m === "application/pdf") return "pdf";
|
|
100
|
+
if (m.includes("spreadsheet") || m.includes("excel") || m === "text/csv") return "sheet";
|
|
101
|
+
if (m.includes("presentation") || m.includes("powerpoint")) return "slides";
|
|
102
|
+
if (m.includes("wordprocessing") || m.includes("msword") || m.includes("opendocument.text"))
|
|
103
|
+
return "doc";
|
|
104
|
+
if (m.includes("zip") || m.includes("compressed") || m.includes("x-tar") || m.includes("gzip"))
|
|
105
|
+
return "zip";
|
|
106
|
+
if (m.includes("json") || m.includes("javascript") || m.includes("xml") || m.includes("html"))
|
|
107
|
+
return "code";
|
|
108
|
+
if (m.startsWith("text/")) return "text";
|
|
109
|
+
const ext = (filename ?? "").split(".").pop()?.toLowerCase() ?? "";
|
|
110
|
+
return (filename?.includes(".") && EXT[ext]) || "default";
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
const ICON: Record<FileKind, LucideIcon> = {
|
|
114
|
+
folder: Folder,
|
|
115
|
+
image: FileImage,
|
|
116
|
+
video: FileVideo,
|
|
117
|
+
audio: FileAudio,
|
|
118
|
+
pdf: FileText,
|
|
119
|
+
doc: FileText,
|
|
120
|
+
sheet: FileSpreadsheet,
|
|
121
|
+
slides: Presentation,
|
|
122
|
+
zip: FileArchive,
|
|
123
|
+
code: FileCode,
|
|
124
|
+
text: FileText,
|
|
125
|
+
default: File,
|
|
126
|
+
};
|
|
127
|
+
|
|
128
|
+
/** สีตาม token (ไม่ใช้ palette ดิบ — ธีม dark ตามไปเอง) */
|
|
129
|
+
const COLOR: Record<FileKind, string> = {
|
|
130
|
+
folder: "text-warning",
|
|
131
|
+
image: "text-primary",
|
|
132
|
+
video: "text-primary",
|
|
133
|
+
audio: "text-primary",
|
|
134
|
+
pdf: "text-destructive",
|
|
135
|
+
doc: "text-primary",
|
|
136
|
+
sheet: "text-success",
|
|
137
|
+
slides: "text-warning",
|
|
138
|
+
zip: "text-muted-foreground",
|
|
139
|
+
code: "text-muted-foreground",
|
|
140
|
+
text: "text-muted-foreground",
|
|
141
|
+
default: "text-muted-foreground",
|
|
142
|
+
};
|
|
143
|
+
|
|
144
|
+
/**
|
|
145
|
+
* ไอคอน lucide ตามชนิดไฟล์ (image/video/audio/pdf/doc/sheet/slides/zip/code/text/folder) สีตาม token
|
|
146
|
+
* <FileTypeIcon mime="application/pdf" /> · <FileTypeIcon filename="report.xlsx" size={20} />
|
|
147
|
+
*/
|
|
148
|
+
export function FileTypeIcon({
|
|
149
|
+
mime,
|
|
150
|
+
filename,
|
|
151
|
+
size = 16,
|
|
152
|
+
className,
|
|
153
|
+
...props
|
|
154
|
+
}: Omit<ComponentProps<LucideIcon>, "size"> & {
|
|
155
|
+
mime?: string | null;
|
|
156
|
+
filename?: string | null;
|
|
157
|
+
/** ขนาดเป็น px (ค่าเริ่มต้น 16) */
|
|
158
|
+
size?: number;
|
|
159
|
+
}) {
|
|
160
|
+
const kind = getFileKind(mime, filename);
|
|
161
|
+
const Icon = ICON[kind];
|
|
162
|
+
return (
|
|
163
|
+
<Icon
|
|
164
|
+
data-kind={kind}
|
|
165
|
+
aria-hidden="true"
|
|
166
|
+
className={cn("shrink-0", COLOR[kind], className)}
|
|
167
|
+
style={{ width: size, height: size }}
|
|
168
|
+
{...props}
|
|
169
|
+
/>
|
|
170
|
+
);
|
|
171
|
+
}
|
|
@@ -33,7 +33,7 @@ import { useUiText } from "./ui-text";
|
|
|
33
33
|
* ฟอร์ม "สร้าง/แก้ไข" ในกล่องโต้ตอบ — เปิดจากปุ่ม (trigger) ปิดเองเมื่อสำเร็จ
|
|
34
34
|
* ใช้แทนฟอร์มที่เคยวางค้างไว้บนหน้า เพื่อให้หน้า list สะอาดและมีการกระทำหลักอันเดียว
|
|
35
35
|
*/
|
|
36
|
-
export function FormDialog({
|
|
36
|
+
export function FormDialog<TData = undefined>({
|
|
37
37
|
trigger,
|
|
38
38
|
title,
|
|
39
39
|
description,
|
|
@@ -49,7 +49,7 @@ export function FormDialog({
|
|
|
49
49
|
trigger?: ReactElement;
|
|
50
50
|
title: ReactNode;
|
|
51
51
|
description?: ReactNode;
|
|
52
|
-
action: FormAction
|
|
52
|
+
action: FormAction<TData>;
|
|
53
53
|
submitLabel?: string;
|
|
54
54
|
/** เนื้อฟอร์ม — เป็นฟังก์ชันได้เพื่อรับ `{ state, pending, errors, fieldError }` */
|
|
55
55
|
children: ReactNode | ((ctx: FormRenderContext) => ReactNode);
|
|
@@ -66,7 +66,7 @@ export function FormDialog({
|
|
|
66
66
|
const [uncontrolled, setUncontrolled] = useState(false);
|
|
67
67
|
const open = controlledOpen ?? uncontrolled;
|
|
68
68
|
const setOpen = onOpenChange ?? setUncontrolled;
|
|
69
|
-
const [state, formAction, pending] = useActionState<FormState
|
|
69
|
+
const [state, formAction, pending] = useActionState<FormState<TData>, FormData>(action, null);
|
|
70
70
|
const [submitted, setSubmitted] = useState(0);
|
|
71
71
|
|
|
72
72
|
// biome-ignore lint/correctness/useExhaustiveDependencies: ทำงานเมื่อผลของการ submit เปลี่ยนเท่านั้น
|
|
@@ -3,8 +3,12 @@
|
|
|
3
3
|
import { cn } from "@suphark/ui/lib/utils";
|
|
4
4
|
import { useUiText } from "./ui-text";
|
|
5
5
|
|
|
6
|
-
|
|
7
|
-
|
|
6
|
+
/**
|
|
7
|
+
* ผลของ server action — `TData` = payload เมื่อสำเร็จ (เช่น key ที่ออกใหม่แสดงครั้งเดียว) อ่านได้จาก `onSuccess(state.data)`
|
|
8
|
+
* ของ ActionForm/FormDialog โดยไม่ต้อง cast
|
|
9
|
+
*/
|
|
10
|
+
export type FormState<TData = undefined> =
|
|
11
|
+
| { ok: true; message?: string; data?: TData }
|
|
8
12
|
| {
|
|
9
13
|
ok: false;
|
|
10
14
|
/** ข้อความรวม (แสดงใน FormMessage) */
|
|
@@ -16,7 +20,7 @@ export type FormState =
|
|
|
16
20
|
|
|
17
21
|
/** ค่าที่ ActionForm/FormDialog ส่งให้ children แบบฟังก์ชัน */
|
|
18
22
|
export interface FormRenderContext {
|
|
19
|
-
state: FormState
|
|
23
|
+
state: FormState<unknown>;
|
|
20
24
|
pending: boolean;
|
|
21
25
|
/** error รายช่องจาก state ล่าสุด (ว่างเมื่อสำเร็จ/ยังไม่ส่ง) */
|
|
22
26
|
errors: Record<string, string | undefined>;
|
|
@@ -24,20 +28,23 @@ export interface FormRenderContext {
|
|
|
24
28
|
fieldError: (name: string) => string | undefined;
|
|
25
29
|
}
|
|
26
30
|
|
|
27
|
-
export function formRenderContext(state: FormState
|
|
31
|
+
export function formRenderContext(state: FormState<unknown>, pending: boolean): FormRenderContext {
|
|
28
32
|
const errors = state && !state.ok ? (state.errors ?? {}) : {};
|
|
29
33
|
return { state, pending, errors, fieldError: (name) => errors[name] };
|
|
30
34
|
}
|
|
31
35
|
|
|
32
36
|
/** server action สำหรับ FormDialog/ActionForm: (state ก่อนหน้า, FormData) → FormState */
|
|
33
|
-
export type FormAction =
|
|
37
|
+
export type FormAction<TData = undefined> = (
|
|
38
|
+
prev: FormState<TData>,
|
|
39
|
+
formData: FormData,
|
|
40
|
+
) => Promise<FormState<TData>>;
|
|
34
41
|
|
|
35
42
|
/** ข้อความผลของ server action ใต้ฟอร์ม */
|
|
36
43
|
export function FormMessage({
|
|
37
44
|
state,
|
|
38
45
|
className,
|
|
39
46
|
}: {
|
|
40
|
-
state: FormState | undefined;
|
|
47
|
+
state: FormState<unknown> | undefined;
|
|
41
48
|
className?: string;
|
|
42
49
|
}) {
|
|
43
50
|
const text = useUiText();
|