@comitor/ui 1.2.0 → 1.3.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/README.md CHANGED
@@ -104,7 +104,7 @@ Package có **5 entry**, tách theo peer optional mà mỗi tầng kéo theo:
104
104
  | `@comitor/ui` | Tier 1 (primitive) + Tier 2 (composite) + `cn` + token TS + `useIsMac` | **không có** — chạy được ở Vite, Storybook, test runner |
105
105
  | `@comitor/ui/shell` | Tier 3: khung ứng dụng workspace × app, theme, ba trục hiển thị | `next`, `next-themes` |
106
106
  | `@comitor/ui/chart` | Lớp shadcn bọc recharts + `Comitor*Chart` + `CHART_COLORS` | `recharts` (~100KB) |
107
- | `@comitor/ui/uploader` | `FileUpload` — kéo thả + tiến độ, Uppy chạy headless | `@uppy/core`, `@uppy/xhr-upload` (~13KB gzip) |
107
+ | `@comitor/ui/uploader` | `FileUpload` (tệp tài liệu) · `ImageUploadField` (ảnh đại diện, logo) · `useFileUpload` | `@uppy/core`, `@uppy/xhr-upload` (~13KB gzip) |
108
108
  | `@comitor/ui/form` | Binding shadcn ⇄ react-hook-form | `react-hook-form` |
109
109
  | `@comitor/ui/tokens` | Token dạng object TS — cho nơi không có Tailwind: canvas, email HTML, PDF | không có |
110
110
 
@@ -5,13 +5,13 @@
5
5
  * thì mọi chỗ import hằng đó (kể cả Server Component chỉ cần đọc một chuỗi) bị kéo sang phía
6
6
  * client. Đó là lý do file này tách ra, cùng khuôn với `data-table-constants.ts`.
7
7
  */
8
- interface FileUploadLabels {
9
- /** Chữ trong vùng kéo thả. */
10
- dropHint: string;
11
- /** Nhãn nút mở hộp chọn tệp. */
12
- choose: string;
13
- /** Nhãn nút và `aria-label` của thanh tiến độ khi đang tải. */
14
- uploading: string;
8
+ /**
9
+ * Hai chuỗi mà phần MÁY MÓC dùng — `useFileUpload` chỉ nhận đúng chúng.
10
+ *
11
+ * Tách ra vì ba chuỗi còn lại của `FileUploadLabels` thuần bố cục: để chúng lọt vào chữ ký của hook
12
+ * là mời người viết truyền một prop không bao giờ chạy, và kiểu thì không báo gì.
13
+ */
14
+ interface FileUploadLogicLabels {
15
15
  /** Thông điệp dự phòng khi endpoint không trả `error.code` đọc được. */
16
16
  uploadFailed: string;
17
17
  /**
@@ -22,6 +22,33 @@ interface FileUploadLabels {
22
22
  */
23
23
  missingDependency: string;
24
24
  }
25
+ interface FileUploadLabels extends FileUploadLogicLabels {
26
+ /** Chữ trong vùng kéo thả. */
27
+ dropHint: string;
28
+ /** Nhãn nút mở hộp chọn tệp. */
29
+ choose: string;
30
+ /** Nhãn nút và `aria-label` của thanh tiến độ khi đang tải. */
31
+ uploading: string;
32
+ }
25
33
  declare const DEFAULT_FILE_UPLOAD_LABELS: FileUploadLabels;
34
+ /**
35
+ * Nhãn của `ImageUploadField` — ô ảnh đại diện / logo.
36
+ *
37
+ * ⚠ `hint` là chỗ DUY NHẤT người dùng học được rằng kéo-thả chạy được, vì bố cục này KHÔNG có khung
38
+ * đứt nét thường trực để tự nói ra điều đó. Đừng rút gọn nó thành mỗi giới hạn dung lượng.
39
+ */
40
+ interface ImageUploadFieldLabels extends FileUploadLogicLabels {
41
+ /** Nhãn nút mở hộp chọn tệp. */
42
+ choose: string;
43
+ /** Nhãn nút lúc đang tải. */
44
+ uploading: string;
45
+ /** Nhãn nút gỡ ảnh. */
46
+ remove: string;
47
+ /** Dòng chú thích dưới hai nút — nói cả giới hạn LẪN việc kéo-thả được. */
48
+ hint: string;
49
+ /** Chữ hiện đè lên khối khi đang rê tệp qua. */
50
+ dropPrompt: string;
51
+ }
52
+ declare const DEFAULT_IMAGE_UPLOAD_FIELD_LABELS: ImageUploadFieldLabels;
26
53
 
27
- export { DEFAULT_FILE_UPLOAD_LABELS, type FileUploadLabels };
54
+ export { DEFAULT_FILE_UPLOAD_LABELS, DEFAULT_IMAGE_UPLOAD_FIELD_LABELS, type FileUploadLabels, type FileUploadLogicLabels, type ImageUploadFieldLabels };
@@ -5,7 +5,17 @@ const DEFAULT_FILE_UPLOAD_LABELS = {
5
5
  uploadFailed: "Kh\xF4ng t\u1EA3i l\xEAn \u0111\u01B0\u1EE3c. Th\u1EED l\u1EA1i.",
6
6
  missingDependency: "Ch\u1EE9c n\u0103ng t\u1EA3i t\u1EC7p ch\u01B0a s\u1EB5n s\xE0ng: thi\u1EBFu g\xF3i @uppy/core v\xE0 @uppy/xhr-upload."
7
7
  };
8
+ const DEFAULT_IMAGE_UPLOAD_FIELD_LABELS = {
9
+ choose: "T\u1EA3i l\xEAn",
10
+ uploading: "\u0110ang t\u1EA3i l\xEAn\u2026",
11
+ remove: "G\u1EE1",
12
+ hint: "K\xE9o \u1EA3nh v\xE0o \u0111\xE2y ho\u1EB7c b\u1EA5m T\u1EA3i l\xEAn.",
13
+ dropPrompt: "Th\u1EA3 \u1EA3nh v\xE0o \u0111\xE2y",
14
+ uploadFailed: "Kh\xF4ng t\u1EA3i l\xEAn \u0111\u01B0\u1EE3c. Th\u1EED l\u1EA1i.",
15
+ missingDependency: "Ch\u1EE9c n\u0103ng t\u1EA3i t\u1EC7p ch\u01B0a s\u1EB5n s\xE0ng: thi\u1EBFu g\xF3i @uppy/core v\xE0 @uppy/xhr-upload."
16
+ };
8
17
  export {
9
- DEFAULT_FILE_UPLOAD_LABELS
18
+ DEFAULT_FILE_UPLOAD_LABELS,
19
+ DEFAULT_IMAGE_UPLOAD_FIELD_LABELS
10
20
  };
11
21
  //# sourceMappingURL=file-upload-constants.js.map
@@ -1 +1 @@
1
- {"version":3,"sources":["../../src/uploader/file-upload-constants.ts"],"sourcesContent":["/**\n * Nhãn của `FileUpload` — file RIÊNG, không `\"use client\"`.\n *\n * ⚠ `verify:directives` chặn việc khai hằng dữ liệu thuần trong một file `\"use client\"`: làm vậy\n * thì mọi chỗ import hằng đó (kể cả Server Component chỉ cần đọc một chuỗi) bị kéo sang phía\n * client. Đó là lý do file này tách ra, cùng khuôn với `data-table-constants.ts`.\n */\n\nexport interface FileUploadLabels {\n /** Chữ trong vùng kéo thả. */\n dropHint: string;\n /** Nhãn nút mở hộp chọn tệp. */\n choose: string;\n /** Nhãn nút và `aria-label` của thanh tiến độ khi đang tải. */\n uploading: string;\n /** Thông điệp dự phòng khi endpoint không trả `error.code` đọc được. */\n uploadFailed: string;\n /**\n * Khi `@uppy/core` / `@uppy/xhr-upload` chưa được cài.\n *\n * Chúng là peer TUỲ CHỌN nên `import()` sẽ hỏng ở đúng lúc người dùng chọn tệp. Không có câu này\n * thì thứ họ thấy là một `ChunkLoadError`, và người sửa đi tìm nguyên nhân ở webpack.\n */\n missingDependency: string;\n}\n\nexport const DEFAULT_FILE_UPLOAD_LABELS: FileUploadLabels = {\n dropHint: \"Kéo tệp vào đây, hoặc\",\n choose: \"Chọn tệp\",\n uploading: \"Đang tải lên…\",\n uploadFailed: \"Không tải lên được. Thử lại.\",\n missingDependency: \"Chức năng tải tệp chưa sẵn sàng: thiếu gói @uppy/core và @uppy/xhr-upload.\"\n};\n"],"mappings":"AA0BO,MAAM,6BAA+C;AAAA,EAC1D,UAAU;AAAA,EACV,QAAQ;AAAA,EACR,WAAW;AAAA,EACX,cAAc;AAAA,EACd,mBAAmB;AACrB;","names":[]}
1
+ {"version":3,"sources":["../../src/uploader/file-upload-constants.ts"],"sourcesContent":["/**\n * Nhãn của `FileUpload` — file RIÊNG, không `\"use client\"`.\n *\n * ⚠ `verify:directives` chặn việc khai hằng dữ liệu thuần trong một file `\"use client\"`: làm vậy\n * thì mọi chỗ import hằng đó (kể cả Server Component chỉ cần đọc một chuỗi) bị kéo sang phía\n * client. Đó là lý do file này tách ra, cùng khuôn với `data-table-constants.ts`.\n */\n\n/**\n * Hai chuỗi mà phần MÁY MÓC dùng — `useFileUpload` chỉ nhận đúng chúng.\n *\n * Tách ra vì ba chuỗi còn lại của `FileUploadLabels` thuần bố cục: để chúng lọt vào chữ ký của hook\n * là mời người viết truyền một prop không bao giờ chạy, và kiểu thì không báo gì.\n */\nexport interface FileUploadLogicLabels {\n /** Thông điệp dự phòng khi endpoint không trả `error.code` đọc được. */\n uploadFailed: string;\n /**\n * Khi `@uppy/core` / `@uppy/xhr-upload` chưa được cài.\n *\n * Chúng là peer TUỲ CHỌN nên `import()` sẽ hỏng ở đúng lúc người dùng chọn tệp. Không có câu này\n * thì thứ họ thấy là một `ChunkLoadError`, và người sửa đi tìm nguyên nhân ở webpack.\n */\n missingDependency: string;\n}\n\nexport interface FileUploadLabels extends FileUploadLogicLabels {\n /** Chữ trong vùng kéo thả. */\n dropHint: string;\n /** Nhãn nút mở hộp chọn tệp. */\n choose: string;\n /** Nhãn nút và `aria-label` của thanh tiến độ khi đang tải. */\n uploading: string;\n}\n\nexport const DEFAULT_FILE_UPLOAD_LABELS: FileUploadLabels = {\n dropHint: \"Kéo tệp vào đây, hoặc\",\n choose: \"Chọn tệp\",\n uploading: \"Đang tải lên…\",\n uploadFailed: \"Không tải lên được. Thử lại.\",\n missingDependency: \"Chức năng tải tệp chưa sẵn sàng: thiếu gói @uppy/core và @uppy/xhr-upload.\"\n};\n\n/**\n * Nhãn của `ImageUploadField` — ô ảnh đại diện / logo.\n *\n * ⚠ `hint` là chỗ DUY NHẤT người dùng học được rằng kéo-thả chạy được, vì bố cục này KHÔNG có khung\n * đứt nét thường trực để tự nói ra điều đó. Đừng rút gọn nó thành mỗi giới hạn dung lượng.\n */\nexport interface ImageUploadFieldLabels extends FileUploadLogicLabels {\n /** Nhãn nút mở hộp chọn tệp. */\n choose: string;\n /** Nhãn nút lúc đang tải. */\n uploading: string;\n /** Nhãn nút gỡ ảnh. */\n remove: string;\n /** Dòng chú thích dưới hai nút — nói cả giới hạn LẪN việc kéo-thả được. */\n hint: string;\n /** Chữ hiện đè lên khối khi đang rê tệp qua. */\n dropPrompt: string;\n}\n\nexport const DEFAULT_IMAGE_UPLOAD_FIELD_LABELS: ImageUploadFieldLabels = {\n choose: \"Tải lên\",\n uploading: \"Đang tải lên…\",\n remove: \"Gỡ\",\n hint: \"Kéo ảnh vào đây hoặc bấm Tải lên.\",\n dropPrompt: \"Thả ảnh vào đây\",\n uploadFailed: \"Không tải lên được. Thử lại.\",\n missingDependency: \"Chức năng tải tệp chưa sẵn sàng: thiếu gói @uppy/core và @uppy/xhr-upload.\"\n};\n"],"mappings":"AAmCO,MAAM,6BAA+C;AAAA,EAC1D,UAAU;AAAA,EACV,QAAQ;AAAA,EACR,WAAW;AAAA,EACX,cAAc;AAAA,EACd,mBAAmB;AACrB;AAqBO,MAAM,oCAA4D;AAAA,EACvE,QAAQ;AAAA,EACR,WAAW;AAAA,EACX,QAAQ;AAAA,EACR,MAAM;AAAA,EACN,YAAY;AAAA,EACZ,cAAc;AAAA,EACd,mBAAmB;AACrB;","names":[]}
@@ -1,5 +1,6 @@
1
1
  import * as React from 'react';
2
2
  import { FileUploadLabels } from './file-upload-constants.js';
3
+ import { FileUploadResult } from './use-file-upload.js';
3
4
 
4
5
  /**
5
6
  * Ô tải tệp dùng chung cho mọi app Comitor — **Uppy chạy HEADLESS, giao diện là của gói này**.
@@ -32,11 +33,6 @@ import { FileUploadLabels } from './file-upload-constants.js';
32
33
  * Uppy KHÔNG dùng `eval` hay `new Function` (đã grep `@uppy/core`, `@uppy/dashboard`, `@uppy/react`:
33
34
  * 0 file). Nên nó chạy được dưới `script-src 'self' 'nonce-…'` mà không cần nới gì.
34
35
  */
35
- /** Kết quả một tệp tải xong: thân phản hồi JSON của endpoint app. */
36
- interface FileUploadResult<T = unknown> {
37
- body: T;
38
- fileName: string;
39
- }
40
36
  interface FileUploadProps<T = unknown> {
41
37
  /** Đường dẫn endpoint nhận multipart, ví dụ `/api/account/avatar`. */
42
38
  endpoint: string;
@@ -66,4 +62,4 @@ interface FileUploadProps<T = unknown> {
66
62
  }
67
63
  declare function FileUpload<T = unknown>({ endpoint, fieldName, maxFileSize, allowedFileTypes, maxNumberOfFiles, onUploaded, onError, labels, disabled, className }: FileUploadProps<T>): React.JSX.Element;
68
64
 
69
- export { FileUpload, type FileUploadProps, type FileUploadResult };
65
+ export { FileUpload, type FileUploadProps };
@@ -1,26 +1,14 @@
1
1
  "use client";
2
2
  import { jsx, jsxs } from "react/jsx-runtime";
3
- import { useCallback, useEffect, useId, useRef, useState } from "react";
3
+ import { useId } from "react";
4
4
  import { Button } from "../components/ui/button.js";
5
5
  import { Progress } from "../components/ui/progress.js";
6
6
  import { cn } from "../lib/cn.js";
7
7
  import { DEFAULT_FILE_UPLOAD_LABELS } from "./file-upload-constants.js";
8
- async function loadUppy() {
9
- try {
10
- const [core, xhr] = await Promise.all([import("@uppy/core"), import("@uppy/xhr-upload")]);
11
- return {
12
- // `as unknown as` là bắt buộc: kiểu thật của Uppy có generic `<Meta, Body>` mà `UppyLike`
13
- // cố ý không mang. Xem ghi chú ở `UppyLike` về việc vì sao gói này không dùng kiểu thật.
14
- Uppy: core.default ?? core.Uppy,
15
- XHRUpload: xhr.default ?? xhr
16
- };
17
- } catch {
18
- return null;
19
- }
20
- }
8
+ import { useFileUpload } from "./use-file-upload.js";
21
9
  function FileUpload({
22
10
  endpoint,
23
- fieldName = "file",
11
+ fieldName,
24
12
  maxFileSize,
25
13
  allowedFileTypes,
26
14
  maxNumberOfFiles = 1,
@@ -32,147 +20,34 @@ function FileUpload({
32
20
  }) {
33
21
  const text = { ...DEFAULT_FILE_UPLOAD_LABELS, ...labels };
34
22
  const inputId = useId();
35
- const inputRef = useRef(null);
36
- const [busy, setBusy] = useState(false);
37
- const [percent, setPercent] = useState(0);
38
- const [dragging, setDragging] = useState(false);
39
- const handlers = useRef({ onUploaded, onError });
40
- handlers.current = { onUploaded, onError };
41
- const uppyRef = useRef(null);
42
- useEffect(() => {
43
- return () => {
44
- uppyRef.current?.destroy();
45
- uppyRef.current = null;
46
- };
47
- }, []);
48
- const start = useCallback(
49
- async (files) => {
50
- const list = Array.from(files).slice(0, maxNumberOfFiles);
51
- if (list.length === 0) return;
52
- setBusy(true);
53
- setPercent(0);
54
- try {
55
- const mod = await loadUppy();
56
- if (!mod) {
57
- handlers.current.onError?.({ message: text.missingDependency });
58
- return;
59
- }
60
- uppyRef.current?.destroy();
61
- const uppy = new mod.Uppy({
62
- autoProceed: false,
63
- restrictions: { maxNumberOfFiles, maxFileSize, allowedFileTypes }
64
- });
65
- uppyRef.current = uppy;
66
- const lastError = {};
67
- uppy.use(mod.XHRUpload, {
68
- endpoint,
69
- fieldName,
70
- method: "POST",
71
- // Cookie phiên là cách Comitor xác thực mọi route tự viết — thiếu dòng này là 401.
72
- withCredentials: true,
73
- /*
74
- * ⚠ CHỈ THỬ LẠI LỖI 5xx.
75
- *
76
- * Mặc định của Uppy là `shouldRetry = () => true` với `retries = 3` (đọc
77
- * `@uppy/core/lib/utils/fetcher.js`), tức MỌI phản hồi không-2xx bị gửi lại ba lần. Hậu
78
- * quả đo được bằng chính hợp đồng của Comitor:
79
- * · một ảnh sai định dạng 2 MB → BỐN lượt POST 2 MB, tất cả đều biết chắc sẽ hỏng;
80
- * · một **429** của `lib/api/rate-limit.ts` cũng bị thử lại, mỗi lần tiêu thêm một đơn
81
- * vị hạn mức và bỏ qua `Retry-After` — client tự đào sâu cái hố nó vừa rơi vào.
82
- * 4xx là "yêu cầu sai, gửi lại cũng thế"; chỉ 5xx mới đáng thử lại.
83
- */
84
- shouldRetry: (xhr) => xhr.status >= 500,
85
- /*
86
- * ⚠ `getResponseError` KHÔNG TỒN TẠI — nó chỉ còn trong một dòng comment cũ ở
87
- * `@uppy/xhr-upload/lib/index.js`, không có trong `XhrUploadOpts`, và không được đọc ở
88
- * đâu cả. Truyền nó vào là viết một callback không bao giờ chạy. Đường thật là
89
- * `onAfterResponse`, thứ XHRUpload có forward xuống fetcher.
90
- */
91
- onAfterResponse: (xhr) => {
92
- if (xhr.status >= 200 && xhr.status < 300) return;
93
- lastError.status = xhr.status;
94
- try {
95
- const parsed = JSON.parse(xhr.responseText);
96
- lastError.code = parsed.error?.code;
97
- lastError.message = parsed.error?.message;
98
- } catch {
99
- }
100
- },
101
- /*
102
- * ⚠ KHÔNG gửi meta kèm tệp.
103
- *
104
- * Mặc định `allowedMetaFields: true` ghi MỌI meta vào cùng `FormData`, và nó ghi TRƯỚC
105
- * khi append tệp. Route của Comitor đọc `form.get("file")` — hàm đó trả phần tử ĐẦU TIÊN
106
- * cùng tên. Nên chỉ cần một khoá meta tên `file` là route nhận về một chuỗi,
107
- * `file instanceof File` thành false, và nó trả 400 "Thiếu tệp ảnh" trong khi tệp vẫn
108
- * nằm nguyên trong request. Mảng rỗng đóng hẳn đường đó.
109
- */
110
- allowedMetaFields: []
111
- });
112
- uppy.on("progress", ((value) => setPercent(value)));
113
- uppy.on("upload-success", ((file, response) => {
114
- handlers.current.onUploaded({ body: response?.body, fileName: file?.name ?? "" });
115
- }));
116
- uppy.on("upload-error", ((_file, error) => {
117
- handlers.current.onError?.({
118
- code: lastError.code,
119
- status: lastError.status,
120
- message: lastError.message ?? error?.message ?? text.uploadFailed
121
- });
122
- }));
123
- uppy.on("restriction-failed", ((_file, error) => {
124
- handlers.current.onError?.({ message: error?.message ?? text.uploadFailed });
125
- }));
126
- for (const file of list) uppy.addFile({ name: file.name, type: file.type, data: file });
127
- await uppy.upload();
128
- } finally {
129
- setBusy(false);
130
- setPercent(0);
131
- if (inputRef.current) inputRef.current.value = "";
132
- }
133
- },
134
- [endpoint, fieldName, maxFileSize, allowedFileTypes, maxNumberOfFiles, text.missingDependency, text.uploadFailed]
135
- );
23
+ const upload = useFileUpload({
24
+ endpoint,
25
+ ...fieldName ? { fieldName } : {},
26
+ maxFileSize,
27
+ allowedFileTypes,
28
+ maxNumberOfFiles,
29
+ onUploaded,
30
+ ...onError ? { onError } : {},
31
+ labels: { uploadFailed: text.uploadFailed, missingDependency: text.missingDependency },
32
+ disabled
33
+ });
136
34
  return /* @__PURE__ */ jsxs("div", { className: cn("flex flex-col gap-2", className), children: [
137
- /* @__PURE__ */ jsx(
138
- "input",
139
- {
140
- ref: inputRef,
141
- id: inputId,
142
- type: "file",
143
- accept: allowedFileTypes.join(","),
144
- multiple: maxNumberOfFiles > 1,
145
- className: "sr-only",
146
- disabled: disabled || busy,
147
- onChange: (event) => {
148
- if (event.target.files) void start(event.target.files);
149
- }
150
- }
151
- ),
35
+ /* @__PURE__ */ jsx("input", { ...upload.inputProps, id: inputId, className: "sr-only" }),
152
36
  /* @__PURE__ */ jsx(
153
37
  "div",
154
38
  {
155
- onDragOver: (event) => {
156
- event.preventDefault();
157
- if (!disabled && !busy) setDragging(true);
158
- },
159
- onDragLeave: () => setDragging(false),
160
- onDrop: (event) => {
161
- event.preventDefault();
162
- setDragging(false);
163
- if (!disabled && !busy && event.dataTransfer.files.length > 0) void start(event.dataTransfer.files);
164
- },
39
+ ...upload.dropProps,
165
40
  className: cn(
166
41
  "flex flex-col items-center gap-2 rounded-lg border-2 border-dashed p-4 text-center transition-colors",
167
- dragging ? "border-primary-ink" : "border-border"
42
+ upload.dragging ? "border-primary-ink" : "border-border"
168
43
  ),
169
- children: /* @__PURE__ */ jsxs("div", { className: cn("flex flex-col items-center gap-2", (disabled || busy) && "opacity-60"), children: [
44
+ children: /* @__PURE__ */ jsxs("div", { className: cn("flex flex-col items-center gap-2", (disabled || upload.busy) && "opacity-60"), children: [
170
45
  /* @__PURE__ */ jsx("p", { className: "text-muted-foreground text-sm", children: text.dropHint }),
171
- /* @__PURE__ */ jsx(Button, { asChild: true, variant: "outline", size: "sm", disabled: disabled || busy, loading: busy, children: /* @__PURE__ */ jsx("label", { htmlFor: inputId, className: disabled || busy ? "pointer-events-none" : "cursor-pointer", children: busy ? text.uploading : text.choose }) })
46
+ /* @__PURE__ */ jsx(Button, { asChild: true, variant: "outline", size: "sm", disabled: disabled || upload.busy, loading: upload.busy, children: /* @__PURE__ */ jsx("label", { htmlFor: inputId, className: disabled || upload.busy ? "pointer-events-none" : "cursor-pointer", children: upload.busy ? text.uploading : text.choose }) })
172
47
  ] })
173
48
  }
174
49
  ),
175
- busy ? /* @__PURE__ */ jsx(Progress, { value: percent, "aria-label": text.uploading }) : null
50
+ upload.busy ? /* @__PURE__ */ jsx(Progress, { value: upload.percent, "aria-label": text.uploading }) : null
176
51
  ] });
177
52
  }
178
53
  export {
@@ -1 +1 @@
1
- {"version":3,"sources":["../../src/uploader/file-upload.tsx"],"sourcesContent":["\"use client\";\n\nimport { useCallback, useEffect, useId, useRef, useState } from \"react\";\nimport { Button } from \"../components/ui/button.js\";\nimport { Progress } from \"../components/ui/progress.js\";\nimport { cn } from \"../lib/cn.js\";\nimport { DEFAULT_FILE_UPLOAD_LABELS, type FileUploadLabels } from \"./file-upload-constants.js\";\n\n/**\n * Ô tải tệp dùng chung cho mọi app Comitor — **Uppy chạy HEADLESS, giao diện là của gói này**.\n *\n * ── VÌ SAO KHÔNG DÙNG `@uppy/dashboard` HAY `@uppy/react` ──────────────────────────────────\n * Cả hai đều kéo theo **Preact** và **một bộ CSS riêng**, tức app đã có React lại ship thêm một\n * runtime giao diện thứ hai, và bảng màu của Uppy không đi qua token của Comitor nên nó KHÔNG qua\n * được cổng tương phản (`pnpm lint:a11y`).\n *\n * Đo được trên một cây đã cài Uppy 5: `@uppy/dashboard` 1.236 KB, `@uppy/react` 432 KB, `preact`\n * 1.852 KB trên đĩa. Trong khi `@uppy/core` khai `sideEffects: [\"*.css\"]` và chỉ có `UIPlugin.js`\n * import `preact` — nên **chỉ nạp `Uppy` + `XHRUpload` thì bundler cắt được cả nhánh Preact**.\n *\n * Cái ta giữ lại của Uppy là phần đáng giá và khó viết đúng: hàng đợi, tiến độ theo byte, thử lại,\n * huỷ, và bộ luật hạn chế (kiểu tệp / kích thước / số lượng) áp NGAY trên trình duyệt.\n *\n * ── VÌ SAO `XHRUpload` CHỨ KHÔNG PHẢI `AwsS3` (presigned PUT) ──────────────────────────────\n * Một app Comitor khác (babrik) tải thẳng lên S3 bằng URL đã ký. Cách đó đúng cho một bucket CÔNG\n * KHAI, nơi URL trả về chính là URL hiển thị. Comitor thì dùng **bucket RIÊNG TƯ**: database lưu\n * KHOÁ object chứ không lưu URL, và URL hiển thị được sinh lại có chữ ký ở mỗi lần đọc.\n *\n * Hệ quả: máy chủ vẫn phải là nơi đặt tên khoá, kiểm byte thật, và ghi khoá vào database. Đẩy\n * việc tải thẳng lên S3 sẽ lấy mất của máy chủ khả năng nhìn thấy nội dung tệp — với một IdP phục\n * vụ ảnh do người dùng tải lên thì đó là đánh đổi không đáng.\n *\n * `XHRUpload` POST một `multipart/form-data` tới chính endpoint của app, nên **không route nào phải\n * đổi** và không có bề mặt bảo mật mới nào mở ra.\n *\n * ── CSP ─────────────────────────────────────────────────────────────────────────────────────\n * Uppy KHÔNG dùng `eval` hay `new Function` (đã grep `@uppy/core`, `@uppy/dashboard`, `@uppy/react`:\n * 0 file). Nên nó chạy được dưới `script-src 'self' 'nonce-…'` mà không cần nới gì.\n */\n\n/** Kết quả một tệp tải xong: thân phản hồi JSON của endpoint app. */\nexport interface FileUploadResult<T = unknown> {\n body: T;\n fileName: string;\n}\n\nexport interface FileUploadProps<T = unknown> {\n /** Đường dẫn endpoint nhận multipart, ví dụ `/api/account/avatar`. */\n endpoint: string;\n /** Tên trường trong `FormData`. Mặc định `\"file\"` — khớp khuôn route của Comitor. */\n fieldName?: string;\n /** Giới hạn kích thước mỗi tệp, byte. */\n maxFileSize: number;\n /** MIME được nhận, ví dụ `[\"image/jpeg\", \"image/png\", \"image/webp\"]`. */\n allowedFileTypes: string[];\n /** Số tệp tối đa mỗi lượt. Mặc định 1. */\n maxNumberOfFiles?: number;\n /** Chạy khi MỘT tệp tải xong. Nhận nguyên thân JSON của endpoint. */\n onUploaded: (result: FileUploadResult<T>) => void;\n /**\n * Chạy khi tải hỏng. `code` là `error.code` từ thân phản hồi nếu endpoint trả theo khuôn\n * `{ error: { code } }` của Comitor — nhờ đó chỗ gọi tra được bảng dịch của chính nó.\n */\n onError?: (error: { code?: string; status?: number; message: string }) => void;\n /** Nhãn hiển thị. Mặc định tiếng Việt; truyền `Partial<>` để đổi từng chuỗi. */\n labels?: Partial<FileUploadLabels>;\n disabled?: boolean;\n className?: string;\n}\n\n/**\n * Bề mặt Uppy mà component này dùng — khai TAY, không import kiểu thật.\n *\n * Hai lý do, cả hai đều thực dụng: kiểu thật của `Uppy` mang generic `<Meta, Body>` và đổi hình\n * dạng giữa các bản major; và `@uppy/core` là peer TUỲ CHỌN, nên `import type` từ nó sẽ làm\n * `tsc` của một app KHÔNG cài Uppy báo đỏ ở một file họ không hề dùng.\n *\n * Cái giá: nếu Uppy đổi tên một phương thức thì lỗi lộ ra lúc CHẠY, không lúc biên dịch. Bù lại\n * bằng `scripts/smoke-entries.mjs` — nó nạp thật entry này ở mỗi lần publish.\n */\ninterface UppyLike {\n addFile: (file: { name: string; type: string; data: File }) => void;\n upload: () => Promise<unknown>;\n cancelAll: () => void;\n destroy: () => void;\n on: (event: string, handler: (...args: never[]) => void) => void;\n use: (plugin: unknown, options?: unknown) => unknown;\n}\n\n/**\n * Nạp Uppy LƯỜI — chỉ khi người dùng thật sự chọn tệp.\n *\n * `import()` động giữ Uppy ra khỏi bundle của lượt tải trang đầu tiên. Với một màn hồ sơ mà đa số\n * người mở ra chỉ để xem, đó là toàn bộ chi phí của Uppy bị hoãn tới lúc nó thật sự có việc.\n *\n * ⚠ Hai gói này là **peer TUỲ CHỌN**. App không cài mà lại render component này thì `import()` sẽ\n * hỏng — nên lỗi được bắt và trả về qua `onError` với thông điệp nói đúng tên gói phải cài, thay vì\n * một `ChunkLoadError` không gợi gì.\n */\nasync function loadUppy(): Promise<{ Uppy: new (o: unknown) => UppyLike; XHRUpload: unknown } | null> {\n try {\n const [core, xhr] = await Promise.all([import(\"@uppy/core\"), import(\"@uppy/xhr-upload\")]);\n return {\n // `as unknown as` là bắt buộc: kiểu thật của Uppy có generic `<Meta, Body>` mà `UppyLike`\n // cố ý không mang. Xem ghi chú ở `UppyLike` về việc vì sao gói này không dùng kiểu thật.\n Uppy: (core.default ?? (core as unknown as { Uppy: unknown }).Uppy) as unknown as new (o: unknown) => UppyLike,\n XHRUpload: xhr.default ?? xhr\n };\n } catch {\n return null;\n }\n}\n\nexport function FileUpload<T = unknown>({\n endpoint,\n fieldName = \"file\",\n maxFileSize,\n allowedFileTypes,\n maxNumberOfFiles = 1,\n onUploaded,\n onError,\n labels,\n disabled = false,\n className\n}: FileUploadProps<T>) {\n const text = { ...DEFAULT_FILE_UPLOAD_LABELS, ...labels };\n const inputId = useId();\n const inputRef = useRef<HTMLInputElement>(null);\n const [busy, setBusy] = useState(false);\n const [percent, setPercent] = useState(0);\n const [dragging, setDragging] = useState(false);\n\n /*\n * `onUploaded`/`onError` đi qua ref: chỗ gọi thường truyền hàm mới ở mỗi lần render, và để chúng\n * trong mảng phụ thuộc là dựng lại Uppy sau mỗi phím gõ vào biểu mẫu bên cạnh.\n */\n const handlers = useRef({ onUploaded, onError });\n handlers.current = { onUploaded, onError };\n\n const uppyRef = useRef<UppyLike | null>(null);\n useEffect(() => {\n return () => {\n uppyRef.current?.destroy();\n uppyRef.current = null;\n };\n }, []);\n\n const start = useCallback(\n async (files: FileList | File[]) => {\n const list = Array.from(files).slice(0, maxNumberOfFiles);\n if (list.length === 0) return;\n\n setBusy(true);\n setPercent(0);\n try {\n const mod = await loadUppy();\n if (!mod) {\n handlers.current.onError?.({ message: text.missingDependency });\n return;\n }\n\n /*\n * Dựng MỘT Uppy cho MỘT lượt tải rồi huỷ. Giữ lại một instance dài hạn nghe hấp dẫn hơn,\n * nhưng Uppy nhớ cả những tệp đã hỏng ở lượt trước — chọn lại đúng tệp đó sẽ bị chặn vì\n * \"trùng\", và triệu chứng là một nút bấm không làm gì.\n */\n uppyRef.current?.destroy();\n const uppy = new mod.Uppy({\n autoProceed: false,\n restrictions: { maxNumberOfFiles, maxFileSize, allowedFileTypes }\n });\n uppyRef.current = uppy;\n\n /*\n * Chỗ giữ lỗi của lượt gọi gần nhất. `upload-error` của Uppy KHÔNG mang thân phản hồi —\n * thứ nó ném là `new NetworkError(xhr.statusText, xhr)`, mà trên HTTP/2 `statusText` là\n * chuỗi RỖNG. Nên `code` phải được nhặt ở `onAfterResponse`, nơi còn nhìn thấy `xhr`.\n */\n const lastError: { code?: string; status?: number; message?: string } = {};\n\n uppy.use(mod.XHRUpload, {\n endpoint,\n fieldName,\n method: \"POST\",\n // Cookie phiên là cách Comitor xác thực mọi route tự viết — thiếu dòng này là 401.\n withCredentials: true,\n\n /*\n * ⚠ CHỈ THỬ LẠI LỖI 5xx.\n *\n * Mặc định của Uppy là `shouldRetry = () => true` với `retries = 3` (đọc\n * `@uppy/core/lib/utils/fetcher.js`), tức MỌI phản hồi không-2xx bị gửi lại ba lần. Hậu\n * quả đo được bằng chính hợp đồng của Comitor:\n * · một ảnh sai định dạng 2 MB → BỐN lượt POST 2 MB, tất cả đều biết chắc sẽ hỏng;\n * · một **429** của `lib/api/rate-limit.ts` cũng bị thử lại, mỗi lần tiêu thêm một đơn\n * vị hạn mức và bỏ qua `Retry-After` — client tự đào sâu cái hố nó vừa rơi vào.\n * 4xx là \"yêu cầu sai, gửi lại cũng thế\"; chỉ 5xx mới đáng thử lại.\n */\n shouldRetry: (xhr: XMLHttpRequest) => xhr.status >= 500,\n\n /*\n * ⚠ `getResponseError` KHÔNG TỒN TẠI — nó chỉ còn trong một dòng comment cũ ở\n * `@uppy/xhr-upload/lib/index.js`, không có trong `XhrUploadOpts`, và không được đọc ở\n * đâu cả. Truyền nó vào là viết một callback không bao giờ chạy. Đường thật là\n * `onAfterResponse`, thứ XHRUpload có forward xuống fetcher.\n */\n onAfterResponse: (xhr: XMLHttpRequest) => {\n if (xhr.status >= 200 && xhr.status < 300) return;\n lastError.status = xhr.status;\n try {\n const parsed = JSON.parse(xhr.responseText) as { error?: { code?: string; message?: string } };\n lastError.code = parsed.error?.code;\n lastError.message = parsed.error?.message;\n } catch {\n // Phản hồi không phải JSON (proxy trả HTML, 413 của tầng mạng…). Giữ `status` là đủ.\n }\n },\n\n /*\n * ⚠ KHÔNG gửi meta kèm tệp.\n *\n * Mặc định `allowedMetaFields: true` ghi MỌI meta vào cùng `FormData`, và nó ghi TRƯỚC\n * khi append tệp. Route của Comitor đọc `form.get(\"file\")` — hàm đó trả phần tử ĐẦU TIÊN\n * cùng tên. Nên chỉ cần một khoá meta tên `file` là route nhận về một chuỗi,\n * `file instanceof File` thành false, và nó trả 400 \"Thiếu tệp ảnh\" trong khi tệp vẫn\n * nằm nguyên trong request. Mảng rỗng đóng hẳn đường đó.\n */\n allowedMetaFields: []\n });\n\n uppy.on(\"progress\", ((value: number) => setPercent(value)) as never);\n uppy.on(\"upload-success\", ((file: { name?: string } | undefined, response: { body?: T }) => {\n handlers.current.onUploaded({ body: response?.body as T, fileName: file?.name ?? \"\" });\n }) as never);\n uppy.on(\"upload-error\", ((_file: unknown, error: Error) => {\n // `lastError` là nguồn duy nhất có `code` — xem `onAfterResponse` ở trên.\n handlers.current.onError?.({\n code: lastError.code,\n status: lastError.status,\n message: lastError.message ?? error?.message ?? text.uploadFailed\n });\n }) as never);\n /*\n * `restriction-failed` là đường RIÊNG, không đi qua `upload-error`. Bỏ sót nó thì một tệp\n * quá lớn hay sai định dạng bị Uppy chặn trong im lặng — người dùng bấm rồi không thấy gì.\n */\n uppy.on(\"restriction-failed\", ((_file: unknown, error: Error) => {\n handlers.current.onError?.({ message: error?.message ?? text.uploadFailed });\n }) as never);\n\n for (const file of list) uppy.addFile({ name: file.name, type: file.type, data: file });\n await uppy.upload();\n } finally {\n setBusy(false);\n setPercent(0);\n /*\n * Xoá giá trị input: chọn LẠI cùng một tệp sau một lần lỗi sẽ KHÔNG bắn `change` nếu giá\n * trị không đổi — và triệu chứng là thử lại im lặng không làm gì.\n */\n if (inputRef.current) inputRef.current.value = \"\";\n }\n },\n [endpoint, fieldName, maxFileSize, allowedFileTypes, maxNumberOfFiles, text.missingDependency, text.uploadFailed]\n );\n\n return (\n <div className={cn(\"flex flex-col gap-2\", className)}>\n <input\n ref={inputRef}\n id={inputId}\n type=\"file\"\n accept={allowedFileTypes.join(\",\")}\n multiple={maxNumberOfFiles > 1}\n className=\"sr-only\"\n disabled={disabled || busy}\n onChange={(event) => {\n if (event.target.files) void start(event.target.files);\n }}\n />\n\n {/*\n Vùng kéo thả. `<label>` bọc lấy nút nên bấm vào đâu trong vùng cũng mở hộp chọn tệp, và\n bàn phím vẫn tới được qua chính `<input>` (nó `sr-only`, không phải `display:none`).\n */}\n {/* biome-ignore lint/a11y/noStaticElementInteractions: vùng kéo-thả là bổ trợ; đường bàn phím đi qua <label> + <input> bên dưới. */}\n <div\n onDragOver={(event) => {\n event.preventDefault();\n if (!disabled && !busy) setDragging(true);\n }}\n onDragLeave={() => setDragging(false)}\n onDrop={(event) => {\n event.preventDefault();\n setDragging(false);\n if (!disabled && !busy && event.dataTransfer.files.length > 0) void start(event.dataTransfer.files);\n }}\n /*\n * ⚠ Trạng thái \"đang kéo tệp qua\" được vẽ bằng VIỀN, không bằng nền mờ.\n *\n * Bản đầu dùng `bg-primary/5` và cổng `pnpm lint:a11y` bác ngay: **1,03:1** ở nền sáng,\n * 1,09:1 ở nền tối — mắt không phân biệt được, tức dấu hiệu kéo-thả là một dấu hiệu GIẢ.\n *\n * ⚠ Và phải là `border-primary-INK`, không phải `border-primary`. Cổng bác cả bản thứ hai:\n * `--primary` là bậc TÔ NỀN của tone, vai trò nét/chữ phải đi qua `--primary-ink` (1,40:1\n * so với 3:1 cần thiết). Cùng luật ba bậc mà CLAUDE.md của Account ghi cho `iconColor`.\n *\n * Giữ `border-2` ở CẢ hai trạng thái để bố cục không nhảy 1px khi kéo tệp qua.\n */\n className={cn(\n \"flex flex-col items-center gap-2 rounded-lg border-2 border-dashed p-4 text-center transition-colors\",\n dragging ? \"border-primary-ink\" : \"border-border\"\n )}\n >\n {/*\n * ⚠ `opacity-60` của trạng thái vô hiệu nằm ở NỘI DUNG, không ở khung.\n *\n * Không phải chuyện thẩm mỹ: cổng `lint:a11y` nhân opacity vào mọi lớp cùng khối, nên để nó\n * ở khung thì `border-primary-ink` rớt còn 2,64:1. Hai trạng thái ấy không bao giờ xảy ra\n * cùng lúc (kéo-thả đã bị chặn khi `disabled || busy`), nhưng cổng đọc TĨNH nên nó ghép —\n * và ở đây cổng đúng về hướng: làm mờ cả khung đứt nét thì người dùng mất luôn dấu hiệu\n * \"chỗ này thả tệp được\".\n */}\n <div className={cn(\"flex flex-col items-center gap-2\", (disabled || busy) && \"opacity-60\")}>\n <p className=\"text-muted-foreground text-sm\">{text.dropHint}</p>\n <Button asChild variant=\"outline\" size=\"sm\" disabled={disabled || busy} loading={busy}>\n <label htmlFor={inputId} className={disabled || busy ? \"pointer-events-none\" : \"cursor-pointer\"}>\n {busy ? text.uploading : text.choose}\n </label>\n </Button>\n </div>\n </div>\n\n {/* Thanh tiến độ chỉ hiện khi đang tải — một thanh 0% đứng yên trông như hỏng. */}\n {busy ? <Progress value={percent} aria-label={text.uploading} /> : null}\n </div>\n );\n}\n"],"mappings":"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","names":[]}
1
+ {"version":3,"sources":["../../src/uploader/file-upload.tsx"],"sourcesContent":["\"use client\";\n\nimport { useId } from \"react\";\nimport { Button } from \"../components/ui/button.js\";\nimport { Progress } from \"../components/ui/progress.js\";\nimport { cn } from \"../lib/cn.js\";\nimport { DEFAULT_FILE_UPLOAD_LABELS, type FileUploadLabels } from \"./file-upload-constants.js\";\nimport { type FileUploadResult, useFileUpload } from \"./use-file-upload.js\";\n\n/**\n * Ô tải tệp dùng chung cho mọi app Comitor — **Uppy chạy HEADLESS, giao diện là của gói này**.\n *\n * ── VÌ SAO KHÔNG DÙNG `@uppy/dashboard` HAY `@uppy/react` ──────────────────────────────────\n * Cả hai đều kéo theo **Preact** và **một bộ CSS riêng**, tức app đã có React lại ship thêm một\n * runtime giao diện thứ hai, và bảng màu của Uppy không đi qua token của Comitor nên nó KHÔNG qua\n * được cổng tương phản (`pnpm lint:a11y`).\n *\n * Đo được trên một cây đã cài Uppy 5: `@uppy/dashboard` 1.236 KB, `@uppy/react` 432 KB, `preact`\n * 1.852 KB trên đĩa. Trong khi `@uppy/core` khai `sideEffects: [\"*.css\"]` và chỉ có `UIPlugin.js`\n * import `preact` — nên **chỉ nạp `Uppy` + `XHRUpload` thì bundler cắt được cả nhánh Preact**.\n *\n * Cái ta giữ lại của Uppy là phần đáng giá và khó viết đúng: hàng đợi, tiến độ theo byte, thử lại,\n * huỷ, và bộ luật hạn chế (kiểu tệp / kích thước / số lượng) áp NGAY trên trình duyệt.\n *\n * ── VÌ SAO `XHRUpload` CHỨ KHÔNG PHẢI `AwsS3` (presigned PUT) ──────────────────────────────\n * Một app Comitor khác (babrik) tải thẳng lên S3 bằng URL đã ký. Cách đó đúng cho một bucket CÔNG\n * KHAI, nơi URL trả về chính là URL hiển thị. Comitor thì dùng **bucket RIÊNG TƯ**: database lưu\n * KHOÁ object chứ không lưu URL, và URL hiển thị được sinh lại có chữ ký ở mỗi lần đọc.\n *\n * Hệ quả: máy chủ vẫn phải là nơi đặt tên khoá, kiểm byte thật, và ghi khoá vào database. Đẩy\n * việc tải thẳng lên S3 sẽ lấy mất của máy chủ khả năng nhìn thấy nội dung tệp — với một IdP phục\n * vụ ảnh do người dùng tải lên thì đó là đánh đổi không đáng.\n *\n * `XHRUpload` POST một `multipart/form-data` tới chính endpoint của app, nên **không route nào phải\n * đổi** và không có bề mặt bảo mật mới nào mở ra.\n *\n * ── CSP ─────────────────────────────────────────────────────────────────────────────────────\n * Uppy KHÔNG dùng `eval` hay `new Function` (đã grep `@uppy/core`, `@uppy/dashboard`, `@uppy/react`:\n * 0 file). Nên nó chạy được dưới `script-src 'self' 'nonce-…'` mà không cần nới gì.\n */\n\nexport interface FileUploadProps<T = unknown> {\n /** Đường dẫn endpoint nhận multipart, ví dụ `/api/account/avatar`. */\n endpoint: string;\n /** Tên trường trong `FormData`. Mặc định `\"file\"` — khớp khuôn route của Comitor. */\n fieldName?: string;\n /** Giới hạn kích thước mỗi tệp, byte. */\n maxFileSize: number;\n /** MIME được nhận, ví dụ `[\"image/jpeg\", \"image/png\", \"image/webp\"]`. */\n allowedFileTypes: string[];\n /** Số tệp tối đa mỗi lượt. Mặc định 1. */\n maxNumberOfFiles?: number;\n /** Chạy khi MỘT tệp tải xong. Nhận nguyên thân JSON của endpoint. */\n onUploaded: (result: FileUploadResult<T>) => void;\n /**\n * Chạy khi tải hỏng. `code` là `error.code` từ thân phản hồi nếu endpoint trả theo khuôn\n * `{ error: { code } }` của Comitor — nhờ đó chỗ gọi tra được bảng dịch của chính nó.\n */\n onError?: (error: { code?: string; status?: number; message: string }) => void;\n /** Nhãn hiển thị. Mặc định tiếng Việt; truyền `Partial<>` để đổi từng chuỗi. */\n labels?: Partial<FileUploadLabels>;\n disabled?: boolean;\n className?: string;\n}\n\nexport function FileUpload<T = unknown>({\n endpoint,\n fieldName,\n maxFileSize,\n allowedFileTypes,\n maxNumberOfFiles = 1,\n onUploaded,\n onError,\n labels,\n disabled = false,\n className\n}: FileUploadProps<T>) {\n const text = { ...DEFAULT_FILE_UPLOAD_LABELS, ...labels };\n const inputId = useId();\n\n /*\n * Toàn bộ máy móc nằm ở `useFileUpload` — cùng một bộ với `ImageUploadField`. Hai component này\n * khác nhau ĐÚNG ở phần hình dạng, và đó là lý do bốn cạm bẫy của `@uppy/xhr-upload` chỉ được\n * xử lý ở MỘT chỗ.\n */\n const upload = useFileUpload<T>({\n endpoint,\n ...(fieldName ? { fieldName } : {}),\n maxFileSize,\n allowedFileTypes,\n maxNumberOfFiles,\n onUploaded,\n ...(onError ? { onError } : {}),\n labels: { uploadFailed: text.uploadFailed, missingDependency: text.missingDependency },\n disabled\n });\n\n return (\n <div className={cn(\"flex flex-col gap-2\", className)}>\n <input {...upload.inputProps} id={inputId} className=\"sr-only\" />\n\n {/*\n Vùng kéo thả. `<label>` bọc lấy nút nên bấm vào đâu trong vùng cũng mở hộp chọn tệp, và\n bàn phím vẫn tới được qua chính `<input>` (nó `sr-only`, không phải `display:none`).\n */}\n {/* biome-ignore lint/a11y/noStaticElementInteractions: vùng kéo-thả là bổ trợ; đường bàn phím đi qua <label> + <input> bên dưới. */}\n <div\n {...upload.dropProps}\n /*\n * ⚠ Trạng thái \"đang kéo tệp qua\" được vẽ bằng VIỀN, không bằng nền mờ.\n *\n * Bản đầu dùng `bg-primary/5` và cổng `pnpm lint:a11y` bác ngay: **1,03:1** ở nền sáng,\n * 1,09:1 ở nền tối — mắt không phân biệt được, tức dấu hiệu kéo-thả là một dấu hiệu GIẢ.\n *\n * ⚠ Và phải là `border-primary-INK`, không phải `border-primary`. Cổng bác cả bản thứ hai:\n * `--primary` là bậc TÔ NỀN của tone, vai trò nét/chữ phải đi qua `--primary-ink` (1,40:1\n * so với 3:1 cần thiết). Cùng luật ba bậc mà CLAUDE.md của Account ghi cho `iconColor`.\n *\n * Giữ `border-2` ở CẢ hai trạng thái để bố cục không nhảy 1px khi kéo tệp qua.\n */\n className={cn(\n \"flex flex-col items-center gap-2 rounded-lg border-2 border-dashed p-4 text-center transition-colors\",\n upload.dragging ? \"border-primary-ink\" : \"border-border\"\n )}\n >\n {/*\n * ⚠ `opacity-60` của trạng thái vô hiệu nằm ở NỘI DUNG, không ở khung.\n *\n * Không phải chuyện thẩm mỹ: cổng `lint:a11y` nhân opacity vào mọi lớp cùng khối, nên để nó\n * ở khung thì `border-primary-ink` rớt còn 2,64:1. Hai trạng thái ấy không bao giờ xảy ra\n * cùng lúc (kéo-thả đã bị chặn khi `disabled || busy`), nhưng cổng đọc TĨNH nên nó ghép —\n * và ở đây cổng đúng về hướng: làm mờ cả khung đứt nét thì người dùng mất luôn dấu hiệu\n * \"chỗ này thả tệp được\".\n */}\n <div className={cn(\"flex flex-col items-center gap-2\", (disabled || upload.busy) && \"opacity-60\")}>\n <p className=\"text-muted-foreground text-sm\">{text.dropHint}</p>\n <Button asChild variant=\"outline\" size=\"sm\" disabled={disabled || upload.busy} loading={upload.busy}>\n <label htmlFor={inputId} className={disabled || upload.busy ? \"pointer-events-none\" : \"cursor-pointer\"}>\n {upload.busy ? text.uploading : text.choose}\n </label>\n </Button>\n </div>\n </div>\n\n {/* Thanh tiến độ chỉ hiện khi đang tải — một thanh 0% đứng yên trông như hỏng. */}\n {upload.busy ? <Progress value={upload.percent} aria-label={text.uploading} /> : null}\n </div>\n );\n}\n"],"mappings":";AAmGM,cAmCE,YAnCF;AAjGN,SAAS,aAAa;AACtB,SAAS,cAAc;AACvB,SAAS,gBAAgB;AACzB,SAAS,UAAU;AACnB,SAAS,kCAAyD;AAClE,SAAgC,qBAAqB;AA0D9C,SAAS,WAAwB;AAAA,EACtC;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA,mBAAmB;AAAA,EACnB;AAAA,EACA;AAAA,EACA;AAAA,EACA,WAAW;AAAA,EACX;AACF,GAAuB;AACrB,QAAM,OAAO,EAAE,GAAG,4BAA4B,GAAG,OAAO;AACxD,QAAM,UAAU,MAAM;AAOtB,QAAM,SAAS,cAAiB;AAAA,IAC9B;AAAA,IACA,GAAI,YAAY,EAAE,UAAU,IAAI,CAAC;AAAA,IACjC;AAAA,IACA;AAAA,IACA;AAAA,IACA;AAAA,IACA,GAAI,UAAU,EAAE,QAAQ,IAAI,CAAC;AAAA,IAC7B,QAAQ,EAAE,cAAc,KAAK,cAAc,mBAAmB,KAAK,kBAAkB;AAAA,IACrF;AAAA,EACF,CAAC;AAED,SACE,qBAAC,SAAI,WAAW,GAAG,uBAAuB,SAAS,GACjD;AAAA,wBAAC,WAAO,GAAG,OAAO,YAAY,IAAI,SAAS,WAAU,WAAU;AAAA,IAO/D;AAAA,MAAC;AAAA;AAAA,QACE,GAAG,OAAO;AAAA,QAaX,WAAW;AAAA,UACT;AAAA,UACA,OAAO,WAAW,uBAAuB;AAAA,QAC3C;AAAA,QAWA,+BAAC,SAAI,WAAW,GAAG,qCAAqC,YAAY,OAAO,SAAS,YAAY,GAC9F;AAAA,8BAAC,OAAE,WAAU,iCAAiC,eAAK,UAAS;AAAA,UAC5D,oBAAC,UAAO,SAAO,MAAC,SAAQ,WAAU,MAAK,MAAK,UAAU,YAAY,OAAO,MAAM,SAAS,OAAO,MAC7F,8BAAC,WAAM,SAAS,SAAS,WAAW,YAAY,OAAO,OAAO,wBAAwB,kBACnF,iBAAO,OAAO,KAAK,YAAY,KAAK,QACvC,GACF;AAAA,WACF;AAAA;AAAA,IACF;AAAA,IAGC,OAAO,OAAO,oBAAC,YAAS,OAAO,OAAO,SAAS,cAAY,KAAK,WAAW,IAAK;AAAA,KACnF;AAEJ;","names":[]}
@@ -0,0 +1,62 @@
1
+ import * as React from 'react';
2
+ import { ReactNode } from 'react';
3
+ import { ImageUploadFieldLabels } from './file-upload-constants.js';
4
+ import { FileUploadResult } from './use-file-upload.js';
5
+
6
+ /**
7
+ * Ô tải MỘT tấm ảnh gắn với một thực thể — ảnh đại diện người dùng, logo workspace, ảnh nhân sự.
8
+ *
9
+ * ── VÌ SAO KHÔNG DÙNG `FileUpload` CHO CA NÀY ────────────────────────────────────────────────
10
+ * `FileUpload` vẽ một khung kéo-thả viền đứt nét chiếm cả bề rộng thẻ. Đó là bố cục ĐÚNG khi người
11
+ * dùng sắp thả vào đó nhiều tài liệu, và là bố cục SAI cho một tấm ảnh vuông 56px: cái khung to
12
+ * hơn chính thứ nó đang sửa, và nó đứng tách khỏi tấm ảnh nên người dùng phải tự ghép "hộp bên
13
+ * phải này là để đổi ảnh bên trái".
14
+ *
15
+ * Ở đây bố cục là: **ảnh · nút · nút · một dòng chú thích** — đúng khuôn mà GitHub, Google, Slack
16
+ * và Linear đều dùng cho avatar.
17
+ *
18
+ * ── KÉO-THẢ VẪN CÒN, NHƯNG VÔ HÌNH CHO TỚI KHI CẦN ─────────────────────────────────────────
19
+ * Cả hàng là vùng nhận thả. Viền đứt nét CÓ MẶT ở mọi trạng thái nhưng `border-transparent` lúc
20
+ * nghỉ — cùng độ dày, nên nó không chiếm một pixel nào và không có cú nhảy bố cục khi tệp được rê
21
+ * qua. Đây là lý do `border-2` nằm ở lớp ngoài cùng chứ không được thêm vào lúc `dragging`.
22
+ *
23
+ * ⚠ Vì không có khung thường trực, `labels.hint` là chỗ DUY NHẤT người dùng học được rằng kéo-thả
24
+ * chạy được. Rút gọn nó thành mỗi "tối đa 2 MB" là bỏ đi tính năng trong mắt người dùng.
25
+ */
26
+ interface ImageUploadFieldProps<T = unknown> {
27
+ /** Đường dẫn endpoint nhận multipart, ví dụ `/api/account/avatar`. */
28
+ endpoint: string;
29
+ fieldName?: string;
30
+ maxFileSize: number;
31
+ allowedFileTypes: string[];
32
+ /**
33
+ * Ảnh xem trước — một `ReactNode`, KHÔNG phải một chuỗi `src`.
34
+ *
35
+ * Gói không được biết `LetterAvatar` rơi về initials kiểu gì, tone màu nào, tròn hay vuông. Nhận
36
+ * cả node còn cho một cái nữa: ảnh bìa 16:9 cắm vừa cùng component này mà không thêm prop nào.
37
+ */
38
+ preview: ReactNode;
39
+ /** Có ảnh thật hay không — quyết định nút "Gỡ" có bấm được không. */
40
+ hasImage?: boolean;
41
+ /**
42
+ * `false` = chỉ hiện ảnh, không nút, không vùng thả.
43
+ *
44
+ * Dành cho người xem không có quyền sửa. Hiện một khung tải bị vô hiệu với họ là mời họ bấm vào
45
+ * một thứ sẽ không bao giờ chạy.
46
+ */
47
+ editable?: boolean;
48
+ /** Đang chạy một thao tác khác (ví dụ gỡ ảnh) — khoá cả hai nút. */
49
+ busy?: boolean;
50
+ onRemove?: () => void;
51
+ onUploaded: (result: FileUploadResult<T>) => void;
52
+ onError?: (error: {
53
+ code?: string;
54
+ status?: number;
55
+ message: string;
56
+ }) => void;
57
+ labels?: Partial<ImageUploadFieldLabels>;
58
+ className?: string;
59
+ }
60
+ declare function ImageUploadField<T = unknown>({ endpoint, fieldName, maxFileSize, allowedFileTypes, preview, hasImage, editable, busy, onRemove, onUploaded, onError, labels, className }: ImageUploadFieldProps<T>): React.JSX.Element;
61
+
62
+ export { ImageUploadField, type ImageUploadFieldProps };
@@ -0,0 +1,64 @@
1
+ "use client";
2
+ import { jsx, jsxs } from "react/jsx-runtime";
3
+ import { Button } from "../components/ui/button.js";
4
+ import { Progress } from "../components/ui/progress.js";
5
+ import { cn } from "../lib/cn.js";
6
+ import { DEFAULT_IMAGE_UPLOAD_FIELD_LABELS } from "./file-upload-constants.js";
7
+ import { useFileUpload } from "./use-file-upload.js";
8
+ function ImageUploadField({
9
+ endpoint,
10
+ fieldName,
11
+ maxFileSize,
12
+ allowedFileTypes,
13
+ preview,
14
+ hasImage = false,
15
+ editable = true,
16
+ busy = false,
17
+ onRemove,
18
+ onUploaded,
19
+ onError,
20
+ labels,
21
+ className
22
+ }) {
23
+ const text = { ...DEFAULT_IMAGE_UPLOAD_FIELD_LABELS, ...labels };
24
+ const upload = useFileUpload({
25
+ endpoint,
26
+ ...fieldName ? { fieldName } : {},
27
+ maxFileSize,
28
+ allowedFileTypes,
29
+ maxNumberOfFiles: 1,
30
+ onUploaded,
31
+ ...onError ? { onError } : {},
32
+ labels: { uploadFailed: text.uploadFailed, missingDependency: text.missingDependency },
33
+ disabled: !editable || busy
34
+ });
35
+ const blocked = !editable || busy || upload.busy;
36
+ if (!editable) return /* @__PURE__ */ jsx("div", { className: cn("flex items-center gap-4", className), children: preview });
37
+ return /* @__PURE__ */ jsxs("div", { className: cn("flex flex-col gap-2", className), children: [
38
+ /* @__PURE__ */ jsx("input", { ...upload.inputProps, className: "sr-only" }),
39
+ /* @__PURE__ */ jsxs(
40
+ "div",
41
+ {
42
+ ...upload.dropProps,
43
+ className: cn(
44
+ "relative flex flex-wrap items-center gap-4 rounded-lg border-2 border-dashed p-2 transition-colors",
45
+ upload.dragging ? "border-primary-ink" : "border-transparent"
46
+ ),
47
+ children: [
48
+ preview,
49
+ /* @__PURE__ */ jsxs("div", { className: cn("flex flex-wrap items-center gap-2", blocked && "opacity-60"), children: [
50
+ /* @__PURE__ */ jsx(Button, { type: "button", variant: "outline", size: "sm", loading: upload.busy, onClick: upload.open, children: upload.busy ? text.uploading : text.choose }),
51
+ /* @__PURE__ */ jsx(Button, { type: "button", variant: "ghost", size: "sm", disabled: !hasImage || blocked, onClick: onRemove, children: text.remove })
52
+ ] }),
53
+ upload.dragging ? /* @__PURE__ */ jsx("div", { className: "pointer-events-none absolute inset-0 flex items-center justify-center rounded-lg bg-card/90", children: /* @__PURE__ */ jsx("span", { className: "font-medium text-sm", children: text.dropPrompt }) }) : null
54
+ ]
55
+ }
56
+ ),
57
+ /* @__PURE__ */ jsx("p", { className: "text-muted-foreground text-xs", children: text.hint }),
58
+ upload.busy ? /* @__PURE__ */ jsx(Progress, { value: upload.percent, "aria-label": text.uploading }) : null
59
+ ] });
60
+ }
61
+ export {
62
+ ImageUploadField
63
+ };
64
+ //# sourceMappingURL=image-upload-field.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../../src/uploader/image-upload-field.tsx"],"sourcesContent":["\"use client\";\n\nimport type { ReactNode } from \"react\";\nimport { Button } from \"../components/ui/button.js\";\nimport { Progress } from \"../components/ui/progress.js\";\nimport { cn } from \"../lib/cn.js\";\nimport { DEFAULT_IMAGE_UPLOAD_FIELD_LABELS, type ImageUploadFieldLabels } from \"./file-upload-constants.js\";\nimport { type FileUploadResult, useFileUpload } from \"./use-file-upload.js\";\n\n/**\n * Ô tải MỘT tấm ảnh gắn với một thực thể — ảnh đại diện người dùng, logo workspace, ảnh nhân sự.\n *\n * ── VÌ SAO KHÔNG DÙNG `FileUpload` CHO CA NÀY ────────────────────────────────────────────────\n * `FileUpload` vẽ một khung kéo-thả viền đứt nét chiếm cả bề rộng thẻ. Đó là bố cục ĐÚNG khi người\n * dùng sắp thả vào đó nhiều tài liệu, và là bố cục SAI cho một tấm ảnh vuông 56px: cái khung to\n * hơn chính thứ nó đang sửa, và nó đứng tách khỏi tấm ảnh nên người dùng phải tự ghép \"hộp bên\n * phải này là để đổi ảnh bên trái\".\n *\n * Ở đây bố cục là: **ảnh · nút · nút · một dòng chú thích** — đúng khuôn mà GitHub, Google, Slack\n * và Linear đều dùng cho avatar.\n *\n * ── KÉO-THẢ VẪN CÒN, NHƯNG VÔ HÌNH CHO TỚI KHI CẦN ─────────────────────────────────────────\n * Cả hàng là vùng nhận thả. Viền đứt nét CÓ MẶT ở mọi trạng thái nhưng `border-transparent` lúc\n * nghỉ — cùng độ dày, nên nó không chiếm một pixel nào và không có cú nhảy bố cục khi tệp được rê\n * qua. Đây là lý do `border-2` nằm ở lớp ngoài cùng chứ không được thêm vào lúc `dragging`.\n *\n * ⚠ Vì không có khung thường trực, `labels.hint` là chỗ DUY NHẤT người dùng học được rằng kéo-thả\n * chạy được. Rút gọn nó thành mỗi \"tối đa 2 MB\" là bỏ đi tính năng trong mắt người dùng.\n */\nexport interface ImageUploadFieldProps<T = unknown> {\n /** Đường dẫn endpoint nhận multipart, ví dụ `/api/account/avatar`. */\n endpoint: string;\n fieldName?: string;\n maxFileSize: number;\n allowedFileTypes: string[];\n /**\n * Ảnh xem trước — một `ReactNode`, KHÔNG phải một chuỗi `src`.\n *\n * Gói không được biết `LetterAvatar` rơi về initials kiểu gì, tone màu nào, tròn hay vuông. Nhận\n * cả node còn cho một cái nữa: ảnh bìa 16:9 cắm vừa cùng component này mà không thêm prop nào.\n */\n preview: ReactNode;\n /** Có ảnh thật hay không — quyết định nút \"Gỡ\" có bấm được không. */\n hasImage?: boolean;\n /**\n * `false` = chỉ hiện ảnh, không nút, không vùng thả.\n *\n * Dành cho người xem không có quyền sửa. Hiện một khung tải bị vô hiệu với họ là mời họ bấm vào\n * một thứ sẽ không bao giờ chạy.\n */\n editable?: boolean;\n /** Đang chạy một thao tác khác (ví dụ gỡ ảnh) — khoá cả hai nút. */\n busy?: boolean;\n onRemove?: () => void;\n onUploaded: (result: FileUploadResult<T>) => void;\n onError?: (error: { code?: string; status?: number; message: string }) => void;\n labels?: Partial<ImageUploadFieldLabels>;\n className?: string;\n}\n\nexport function ImageUploadField<T = unknown>({\n endpoint,\n fieldName,\n maxFileSize,\n allowedFileTypes,\n preview,\n hasImage = false,\n editable = true,\n busy = false,\n onRemove,\n onUploaded,\n onError,\n labels,\n className\n}: ImageUploadFieldProps<T>) {\n const text = { ...DEFAULT_IMAGE_UPLOAD_FIELD_LABELS, ...labels };\n\n const upload = useFileUpload<T>({\n endpoint,\n ...(fieldName ? { fieldName } : {}),\n maxFileSize,\n allowedFileTypes,\n maxNumberOfFiles: 1,\n onUploaded,\n ...(onError ? { onError } : {}),\n labels: { uploadFailed: text.uploadFailed, missingDependency: text.missingDependency },\n disabled: !editable || busy\n });\n\n const blocked = !editable || busy || upload.busy;\n\n /*\n * Không sửa được thì chỉ còn tấm ảnh. Không vùng thả, không nút, không `<input>` — không có gì\n * để người dùng bấm nhầm rồi tự hỏi vì sao không có chuyện gì xảy ra.\n */\n if (!editable) return <div className={cn(\"flex items-center gap-4\", className)}>{preview}</div>;\n\n return (\n <div className={cn(\"flex flex-col gap-2\", className)}>\n <input {...upload.inputProps} className=\"sr-only\" />\n\n {/*\n ⚠ `border-2 border-dashed` có mặt ở CẢ HAI trạng thái; chỉ MÀU đổi. Thêm viền lúc `dragging`\n sẽ đẩy nội dung 2px và làm cả hàng giật đúng lúc người dùng đang nhắm chỗ thả.\n */}\n {/* biome-ignore lint/a11y/noStaticElementInteractions: kéo-thả là bổ trợ; đường bàn phím đi qua nút \"Tải lên\" bên dưới. */}\n <div\n {...upload.dropProps}\n className={cn(\n \"relative flex flex-wrap items-center gap-4 rounded-lg border-2 border-dashed p-2 transition-colors\",\n upload.dragging ? \"border-primary-ink\" : \"border-transparent\"\n )}\n >\n {preview}\n\n <div className={cn(\"flex flex-wrap items-center gap-2\", blocked && \"opacity-60\")}>\n <Button type=\"button\" variant=\"outline\" size=\"sm\" loading={upload.busy} onClick={upload.open}>\n {upload.busy ? text.uploading : text.choose}\n </Button>\n {/* Luôn render, chỉ `disabled` khi chưa có ảnh — bố cục không nhảy một nút. */}\n <Button type=\"button\" variant=\"ghost\" size=\"sm\" disabled={!hasImage || blocked} onClick={onRemove}>\n {text.remove}\n </Button>\n </div>\n\n {/*\n Chữ \"Thả ảnh vào đây\" phủ lên cả khối khi đang rê tệp qua. `pointer-events-none` là BẮT\n BUỘC: thiếu nó, lớp phủ tự nuốt sự kiện `drop` của khối bên dưới và việc thả im lặng\n không làm gì.\n */}\n {upload.dragging ? (\n <div className=\"pointer-events-none absolute inset-0 flex items-center justify-center rounded-lg bg-card/90\">\n <span className=\"font-medium text-sm\">{text.dropPrompt}</span>\n </div>\n ) : null}\n </div>\n\n <p className=\"text-muted-foreground text-xs\">{text.hint}</p>\n\n {/* Thanh tiến độ chỉ hiện khi đang tải — một thanh 0% đứng yên trông như hỏng. */}\n {upload.busy ? <Progress value={upload.percent} aria-label={text.uploading} /> : null}\n </div>\n );\n}\n"],"mappings":";AA+FwB,cAoBhB,YApBgB;AA5FxB,SAAS,cAAc;AACvB,SAAS,gBAAgB;AACzB,SAAS,UAAU;AACnB,SAAS,yCAAsE;AAC/E,SAAgC,qBAAqB;AAqD9C,SAAS,iBAA8B;AAAA,EAC5C;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA,WAAW;AAAA,EACX,WAAW;AAAA,EACX,OAAO;AAAA,EACP;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AACF,GAA6B;AAC3B,QAAM,OAAO,EAAE,GAAG,mCAAmC,GAAG,OAAO;AAE/D,QAAM,SAAS,cAAiB;AAAA,IAC9B;AAAA,IACA,GAAI,YAAY,EAAE,UAAU,IAAI,CAAC;AAAA,IACjC;AAAA,IACA;AAAA,IACA,kBAAkB;AAAA,IAClB;AAAA,IACA,GAAI,UAAU,EAAE,QAAQ,IAAI,CAAC;AAAA,IAC7B,QAAQ,EAAE,cAAc,KAAK,cAAc,mBAAmB,KAAK,kBAAkB;AAAA,IACrF,UAAU,CAAC,YAAY;AAAA,EACzB,CAAC;AAED,QAAM,UAAU,CAAC,YAAY,QAAQ,OAAO;AAM5C,MAAI,CAAC,SAAU,QAAO,oBAAC,SAAI,WAAW,GAAG,2BAA2B,SAAS,GAAI,mBAAQ;AAEzF,SACE,qBAAC,SAAI,WAAW,GAAG,uBAAuB,SAAS,GACjD;AAAA,wBAAC,WAAO,GAAG,OAAO,YAAY,WAAU,WAAU;AAAA,IAOlD;AAAA,MAAC;AAAA;AAAA,QACE,GAAG,OAAO;AAAA,QACX,WAAW;AAAA,UACT;AAAA,UACA,OAAO,WAAW,uBAAuB;AAAA,QAC3C;AAAA,QAEC;AAAA;AAAA,UAED,qBAAC,SAAI,WAAW,GAAG,qCAAqC,WAAW,YAAY,GAC7E;AAAA,gCAAC,UAAO,MAAK,UAAS,SAAQ,WAAU,MAAK,MAAK,SAAS,OAAO,MAAM,SAAS,OAAO,MACrF,iBAAO,OAAO,KAAK,YAAY,KAAK,QACvC;AAAA,YAEA,oBAAC,UAAO,MAAK,UAAS,SAAQ,SAAQ,MAAK,MAAK,UAAU,CAAC,YAAY,SAAS,SAAS,UACtF,eAAK,QACR;AAAA,aACF;AAAA,UAOC,OAAO,WACN,oBAAC,SAAI,WAAU,+FACb,8BAAC,UAAK,WAAU,uBAAuB,eAAK,YAAW,GACzD,IACE;AAAA;AAAA;AAAA,IACN;AAAA,IAEA,oBAAC,OAAE,WAAU,iCAAiC,eAAK,MAAK;AAAA,IAGvD,OAAO,OAAO,oBAAC,YAAS,OAAO,OAAO,SAAS,cAAY,KAAK,WAAW,IAAK;AAAA,KACnF;AAEJ;","names":[]}
@@ -1,3 +1,5 @@
1
- export { FileUpload, FileUploadProps, FileUploadResult } from './file-upload.js';
2
- export { DEFAULT_FILE_UPLOAD_LABELS, FileUploadLabels } from './file-upload-constants.js';
1
+ export { FileUpload, FileUploadProps } from './file-upload.js';
2
+ export { DEFAULT_FILE_UPLOAD_LABELS, DEFAULT_IMAGE_UPLOAD_FIELD_LABELS, FileUploadLabels, FileUploadLogicLabels, ImageUploadFieldLabels } from './file-upload-constants.js';
3
+ export { ImageUploadField, ImageUploadFieldProps } from './image-upload-field.js';
4
+ export { FileUploadResult, UseFileUploadOptions, UseFileUploadResult, useFileUpload } from './use-file-upload.js';
3
5
  import 'react';
@@ -1,7 +1,17 @@
1
1
  import { FileUpload } from "./file-upload.js";
2
- import { DEFAULT_FILE_UPLOAD_LABELS } from "./file-upload-constants.js";
2
+ import {
3
+ DEFAULT_FILE_UPLOAD_LABELS,
4
+ DEFAULT_IMAGE_UPLOAD_FIELD_LABELS
5
+ } from "./file-upload-constants.js";
6
+ import { ImageUploadField } from "./image-upload-field.js";
7
+ import {
8
+ useFileUpload
9
+ } from "./use-file-upload.js";
3
10
  export {
4
11
  DEFAULT_FILE_UPLOAD_LABELS,
5
- FileUpload
12
+ DEFAULT_IMAGE_UPLOAD_FIELD_LABELS,
13
+ FileUpload,
14
+ ImageUploadField,
15
+ useFileUpload
6
16
  };
7
17
  //# sourceMappingURL=index.js.map
@@ -1 +1 @@
1
- {"version":3,"sources":["../../src/uploader/index.ts"],"sourcesContent":["/**\n * Entry `@comitor/ui/uploader` — ô tải tệp dựng trên Uppy chạy HEADLESS.\n *\n * ── VÌ SAO LÀ MỘT ENTRY RIÊNG ────────────────────────────────────────────────────────────────\n * Cùng lý do `@comitor/ui/chart` tách khỏi entry gốc: `@uppy/core` và `@uppy/xhr-upload` là peer\n * **TUỲ CHỌN**, và `tsup` chạy `bundle: false` — nên một component chạm peer tuỳ chọn mà nằm ở\n * entry `.` sẽ kéo cả cây import đó vào MỌI app dùng gói, kể cả app không tải tệp bao giờ.\n *\n * Ranh giới module là thứ bảo đảm chắc chắn hơn phép tree-shaking: app nào không `import` từ\n * `@comitor/ui/uploader` thì không có một byte Uppy nào trong bundle.\n *\n * ⚠ Uppy còn được nạp LƯỜI lần nữa BÊN TRONG component (`import()` động), nên ngay cả app CÓ dùng\n * cũng chỉ trả giá khi người dùng thật sự chọn tệp — xem `loadUppy()`.\n *\n * Cài ở app:\n * ```bash\n * pnpm add @uppy/core @uppy/xhr-upload\n * ```\n */\n\nexport { FileUpload, type FileUploadProps, type FileUploadResult } from \"./file-upload.js\";\nexport { DEFAULT_FILE_UPLOAD_LABELS, type FileUploadLabels } from \"./file-upload-constants.js\";\n"],"mappings":"AAoBA,SAAS,kBAA+D;AACxE,SAAS,kCAAyD;","names":[]}
1
+ {"version":3,"sources":["../../src/uploader/index.ts"],"sourcesContent":["/**\n * Entry `@comitor/ui/uploader` — ô tải tệp dựng trên Uppy chạy HEADLESS.\n *\n * ── VÌ SAO LÀ MỘT ENTRY RIÊNG ────────────────────────────────────────────────────────────────\n * Cùng lý do `@comitor/ui/chart` tách khỏi entry gốc: `@uppy/core` và `@uppy/xhr-upload` là peer\n * **TUỲ CHỌN**, và `tsup` chạy `bundle: false` — nên một component chạm peer tuỳ chọn mà nằm ở\n * entry `.` sẽ kéo cả cây import đó vào MỌI app dùng gói, kể cả app không tải tệp bao giờ.\n *\n * Ranh giới module là thứ bảo đảm chắc chắn hơn phép tree-shaking: app nào không `import` từ\n * `@comitor/ui/uploader` thì không có một byte Uppy nào trong bundle.\n *\n * ⚠ Uppy còn được nạp LƯỜI lần nữa BÊN TRONG component (`import()` động), nên ngay cả app CÓ dùng\n * cũng chỉ trả giá khi người dùng thật sự chọn tệp — xem `loadUppy()`.\n *\n * Cài ở app:\n * ```bash\n * pnpm add @uppy/core @uppy/xhr-upload\n * ```\n */\n\nexport { FileUpload, type FileUploadProps } from \"./file-upload.js\";\nexport {\n DEFAULT_FILE_UPLOAD_LABELS,\n DEFAULT_IMAGE_UPLOAD_FIELD_LABELS,\n type FileUploadLabels,\n type FileUploadLogicLabels,\n type ImageUploadFieldLabels\n} from \"./file-upload-constants.js\";\nexport { ImageUploadField, type ImageUploadFieldProps } from \"./image-upload-field.js\";\nexport {\n type FileUploadResult,\n type UseFileUploadOptions,\n type UseFileUploadResult,\n useFileUpload\n} from \"./use-file-upload.js\";\n"],"mappings":"AAoBA,SAAS,kBAAwC;AACjD;AAAA,EACE;AAAA,EACA;AAAA,OAIK;AACP,SAAS,wBAAoD;AAC7D;AAAA,EAIE;AAAA,OACK;","names":[]}
@@ -0,0 +1,59 @@
1
+ import { FileUploadLogicLabels } from './file-upload-constants.js';
2
+
3
+ interface UseFileUploadOptions<T = unknown> {
4
+ /** Đường dẫn endpoint nhận multipart, ví dụ `/api/account/avatar`. */
5
+ endpoint: string;
6
+ /** Tên trường trong `FormData`. Mặc định `"file"`. */
7
+ fieldName?: string;
8
+ maxFileSize: number;
9
+ allowedFileTypes: string[];
10
+ maxNumberOfFiles?: number;
11
+ onUploaded: (result: FileUploadResult<T>) => void;
12
+ onError?: (error: {
13
+ code?: string;
14
+ status?: number;
15
+ message: string;
16
+ }) => void;
17
+ /**
18
+ * CHỈ hai chuỗi mà phần máy móc thật sự dùng.
19
+ *
20
+ * Cố ý KHÔNG nhận cả `FileUploadLabels`: ba chuỗi còn lại (`dropHint`/`choose`/`uploading`) là
21
+ * của bố cục, và để chúng lọt vào đây là mời người viết truyền một prop không bao giờ chạy.
22
+ */
23
+ labels?: Partial<FileUploadLogicLabels>;
24
+ disabled?: boolean;
25
+ }
26
+ /** Kết quả một tệp tải xong: thân phản hồi JSON của endpoint app. */
27
+ interface FileUploadResult<T = unknown> {
28
+ body: T;
29
+ fileName: string;
30
+ }
31
+ interface UseFileUploadResult {
32
+ /** Mở hộp chọn tệp. Cần `<input {...inputProps} />` có mặt trong cây. */
33
+ open: () => void;
34
+ /** Bắt đầu tải một danh sách tệp có sẵn (dùng cho kéo-thả tự xử lý). */
35
+ start: (files: FileList | File[]) => void;
36
+ /** Huỷ lượt đang chạy. */
37
+ cancel: () => void;
38
+ inputProps: {
39
+ ref: React.RefObject<HTMLInputElement | null>;
40
+ type: "file";
41
+ accept: string;
42
+ multiple: boolean;
43
+ disabled: boolean;
44
+ onChange: (event: React.ChangeEvent<HTMLInputElement>) => void;
45
+ };
46
+ dropProps: {
47
+ onDragOver: (event: React.DragEvent) => void;
48
+ onDragLeave: () => void;
49
+ onDrop: (event: React.DragEvent) => void;
50
+ };
51
+ /** Có tệp đang được rê TRÊN khối này. */
52
+ dragging: boolean;
53
+ busy: boolean;
54
+ /** 0–100. */
55
+ percent: number;
56
+ }
57
+ declare function useFileUpload<T = unknown>(options: UseFileUploadOptions<T>): UseFileUploadResult;
58
+
59
+ export { type FileUploadResult, type UseFileUploadOptions, type UseFileUploadResult, useFileUpload };
@@ -0,0 +1,175 @@
1
+ "use client";
2
+ import { useCallback, useEffect, useRef, useState } from "react";
3
+ import { DEFAULT_FILE_UPLOAD_LABELS } from "./file-upload-constants.js";
4
+ async function loadUppy() {
5
+ try {
6
+ const [core, xhr] = await Promise.all([import("@uppy/core"), import("@uppy/xhr-upload")]);
7
+ return {
8
+ // `as unknown as` là bắt buộc: kiểu thật của Uppy có generic `<Meta, Body>` mà `UppyLike`
9
+ // cố ý không mang. Xem ghi chú ở `UppyLike` về việc vì sao gói này không dùng kiểu thật.
10
+ Uppy: core.default ?? core.Uppy,
11
+ XHRUpload: xhr.default ?? xhr
12
+ };
13
+ } catch {
14
+ return null;
15
+ }
16
+ }
17
+ function useFileUpload(options) {
18
+ const {
19
+ endpoint,
20
+ fieldName = "file",
21
+ maxFileSize,
22
+ allowedFileTypes,
23
+ maxNumberOfFiles = 1,
24
+ onUploaded,
25
+ onError,
26
+ disabled = false
27
+ } = options;
28
+ const labels = { ...DEFAULT_FILE_UPLOAD_LABELS, ...options.labels };
29
+ const inputRef = useRef(null);
30
+ const [busy, setBusy] = useState(false);
31
+ const [percent, setPercent] = useState(0);
32
+ const [dragging, setDragging] = useState(false);
33
+ const handlers = useRef({ onUploaded, onError });
34
+ handlers.current = { onUploaded, onError };
35
+ const uppyRef = useRef(null);
36
+ useEffect(() => {
37
+ return () => {
38
+ uppyRef.current?.destroy();
39
+ uppyRef.current = null;
40
+ };
41
+ }, []);
42
+ const start = useCallback(
43
+ async (files) => {
44
+ const list = Array.from(files).slice(0, maxNumberOfFiles);
45
+ if (list.length === 0) return;
46
+ setBusy(true);
47
+ setPercent(0);
48
+ try {
49
+ const mod = await loadUppy();
50
+ if (!mod) {
51
+ handlers.current.onError?.({ message: labels.missingDependency });
52
+ return;
53
+ }
54
+ uppyRef.current?.destroy();
55
+ const uppy = new mod.Uppy({
56
+ autoProceed: false,
57
+ restrictions: { maxNumberOfFiles, maxFileSize, allowedFileTypes }
58
+ });
59
+ uppyRef.current = uppy;
60
+ const lastError = {};
61
+ uppy.use(mod.XHRUpload, {
62
+ endpoint,
63
+ fieldName,
64
+ method: "POST",
65
+ // Cookie phiên là cách Comitor xác thực mọi route tự viết — thiếu dòng này là 401.
66
+ withCredentials: true,
67
+ /*
68
+ * ⚠ CHỈ THỬ LẠI LỖI 5xx.
69
+ *
70
+ * Mặc định của Uppy là `shouldRetry = () => true` với `retries = 3` (đọc
71
+ * `@uppy/core/lib/utils/fetcher.js`), tức MỌI phản hồi không-2xx bị gửi lại ba lần. Hậu
72
+ * quả đo được bằng chính hợp đồng của Comitor:
73
+ * · một ảnh sai định dạng 2 MB → BỐN lượt POST 2 MB, tất cả đều biết chắc sẽ hỏng;
74
+ * · một **429** của `lib/api/rate-limit.ts` cũng bị thử lại, mỗi lần tiêu thêm một đơn
75
+ * vị hạn mức và bỏ qua `Retry-After` — client tự đào sâu cái hố nó vừa rơi vào.
76
+ * 4xx là "yêu cầu sai, gửi lại cũng thế"; chỉ 5xx mới đáng thử lại.
77
+ */
78
+ shouldRetry: (xhr) => xhr.status >= 500,
79
+ /*
80
+ * ⚠ `getResponseError` KHÔNG TỒN TẠI — nó chỉ còn trong một dòng comment cũ ở
81
+ * `@uppy/xhr-upload/lib/index.js`, không có trong `XhrUploadOpts`, và không được đọc ở
82
+ * đâu cả. Truyền nó vào là viết một callback không bao giờ chạy. Đường thật là
83
+ * `onAfterResponse`, thứ XHRUpload có forward xuống fetcher.
84
+ */
85
+ onAfterResponse: (xhr) => {
86
+ if (xhr.status >= 200 && xhr.status < 300) return;
87
+ lastError.status = xhr.status;
88
+ try {
89
+ const parsed = JSON.parse(xhr.responseText);
90
+ lastError.code = parsed.error?.code;
91
+ lastError.message = parsed.error?.message;
92
+ } catch {
93
+ }
94
+ },
95
+ /*
96
+ * ⚠ KHÔNG gửi meta kèm tệp.
97
+ *
98
+ * Mặc định `allowedMetaFields: true` ghi MỌI meta vào cùng `FormData`, và nó ghi TRƯỚC
99
+ * khi append tệp. Route của Comitor đọc `form.get("file")` — hàm đó trả phần tử ĐẦU TIÊN
100
+ * cùng tên. Nên chỉ cần một khoá meta tên `file` là route nhận về một chuỗi,
101
+ * `file instanceof File` thành false, và nó trả 400 "Thiếu tệp ảnh" trong khi tệp vẫn
102
+ * nằm nguyên trong request. Mảng rỗng đóng hẳn đường đó.
103
+ */
104
+ allowedMetaFields: []
105
+ });
106
+ uppy.on("progress", ((value) => setPercent(value)));
107
+ uppy.on("upload-success", ((file, response) => {
108
+ handlers.current.onUploaded({ body: response?.body, fileName: file?.name ?? "" });
109
+ }));
110
+ uppy.on("upload-error", ((_file, error) => {
111
+ handlers.current.onError?.({
112
+ code: lastError.code,
113
+ status: lastError.status,
114
+ message: lastError.message ?? error?.message ?? labels.uploadFailed
115
+ });
116
+ }));
117
+ uppy.on("restriction-failed", ((_file, error) => {
118
+ handlers.current.onError?.({ message: error?.message ?? labels.uploadFailed });
119
+ }));
120
+ for (const file of list) uppy.addFile({ name: file.name, type: file.type, data: file });
121
+ await uppy.upload();
122
+ } finally {
123
+ setBusy(false);
124
+ setPercent(0);
125
+ if (inputRef.current) inputRef.current.value = "";
126
+ }
127
+ },
128
+ [
129
+ endpoint,
130
+ fieldName,
131
+ maxFileSize,
132
+ allowedFileTypes,
133
+ maxNumberOfFiles,
134
+ labels.missingDependency,
135
+ labels.uploadFailed
136
+ ]
137
+ );
138
+ const cancel = useCallback(() => {
139
+ uppyRef.current?.cancelAll();
140
+ }, []);
141
+ return {
142
+ open: () => inputRef.current?.click(),
143
+ start,
144
+ cancel,
145
+ inputProps: {
146
+ ref: inputRef,
147
+ type: "file",
148
+ accept: allowedFileTypes.join(","),
149
+ multiple: maxNumberOfFiles > 1,
150
+ disabled: disabled || busy,
151
+ onChange: (event) => {
152
+ if (event.target.files) void start(event.target.files);
153
+ }
154
+ },
155
+ dropProps: {
156
+ onDragOver: (event) => {
157
+ event.preventDefault();
158
+ if (!disabled && !busy) setDragging(true);
159
+ },
160
+ onDragLeave: () => setDragging(false),
161
+ onDrop: (event) => {
162
+ event.preventDefault();
163
+ setDragging(false);
164
+ if (!disabled && !busy && event.dataTransfer.files.length > 0) void start(event.dataTransfer.files);
165
+ }
166
+ },
167
+ dragging,
168
+ busy,
169
+ percent
170
+ };
171
+ }
172
+ export {
173
+ useFileUpload
174
+ };
175
+ //# sourceMappingURL=use-file-upload.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../../src/uploader/use-file-upload.ts"],"sourcesContent":["\"use client\";\n\nimport { useCallback, useEffect, useRef, useState } from \"react\";\nimport { DEFAULT_FILE_UPLOAD_LABELS, type FileUploadLogicLabels } from \"./file-upload-constants.js\";\n\n/**\n * Phần MÁY MÓC của việc tải tệp, tách khỏi mọi quyết định về hình dạng.\n *\n * ── VÌ SAO TÁCH ───────────────────────────────────────────────────────────────────────────────\n * `FileUpload` vẽ một khung kéo-thả viền đứt nét — đúng cho tài liệu nhiều tệp, **sai** cho một ô\n * ảnh đại diện 56px. Nhưng phần bên dưới thì hai ca cần y hệt nhau: hàng đợi Uppy, tiến độ theo\n * byte, bốn cạm bẫy của `@uppy/xhr-upload`, và việc xoá `input.value` sau mỗi lượt.\n *\n * Đường cắt này không phải một trừu tượng mới bịa ra: trong `file-upload.tsx` bản 1.2.0, toàn bộ\n * khối `start` + `useEffect` dọn dẹp + ba `useState` **không chạm một dòng JSX nào**, còn JSX thì\n * chỉ đọc `busy`/`percent`/`dragging`. Đây là việc đặt tên cho một ranh giới đã có sẵn.\n *\n * ⚠ `inputProps` và `dropProps` là **prop-getter**: chỗ gọi phải rải chúng lên đúng phần tử. Thiếu\n * `<input {...inputProps} />` thì `open()` không làm gì, và triệu chứng là một nút bấm im lặng.\n * `dropProps` đã tự gọi `preventDefault` — lỗi kinh điển khi để app tự viết.\n */\n/**\n * Bề mặt Uppy mà component này dùng — khai TAY, không import kiểu thật.\n *\n * Hai lý do, cả hai đều thực dụng: kiểu thật của `Uppy` mang generic `<Meta, Body>` và đổi hình\n * dạng giữa các bản major; và `@uppy/core` là peer TUỲ CHỌN, nên `import type` từ nó sẽ làm\n * `tsc` của một app KHÔNG cài Uppy báo đỏ ở một file họ không hề dùng.\n *\n * Cái giá: nếu Uppy đổi tên một phương thức thì lỗi lộ ra lúc CHẠY, không lúc biên dịch. Bù lại\n * bằng `scripts/smoke-entries.mjs` — nó nạp thật entry này ở mỗi lần publish.\n */\ninterface UppyLike {\n addFile: (file: { name: string; type: string; data: File }) => void;\n upload: () => Promise<unknown>;\n cancelAll: () => void;\n destroy: () => void;\n on: (event: string, handler: (...args: never[]) => void) => void;\n use: (plugin: unknown, options?: unknown) => unknown;\n}\n\n/**\n * Nạp Uppy LƯỜI — chỉ khi người dùng thật sự chọn tệp.\n *\n * `import()` động giữ Uppy ra khỏi bundle của lượt tải trang đầu tiên. Với một màn hồ sơ mà đa số\n * người mở ra chỉ để xem, đó là toàn bộ chi phí của Uppy bị hoãn tới lúc nó thật sự có việc.\n *\n * ⚠ Hai gói này là **peer TUỲ CHỌN**. App không cài mà lại render component này thì `import()` sẽ\n * hỏng — nên lỗi được bắt và trả về qua `onError` với thông điệp nói đúng tên gói phải cài, thay vì\n * một `ChunkLoadError` không gợi gì.\n */\nasync function loadUppy(): Promise<{ Uppy: new (o: unknown) => UppyLike; XHRUpload: unknown } | null> {\n try {\n const [core, xhr] = await Promise.all([import(\"@uppy/core\"), import(\"@uppy/xhr-upload\")]);\n return {\n // `as unknown as` là bắt buộc: kiểu thật của Uppy có generic `<Meta, Body>` mà `UppyLike`\n // cố ý không mang. Xem ghi chú ở `UppyLike` về việc vì sao gói này không dùng kiểu thật.\n Uppy: (core.default ?? (core as unknown as { Uppy: unknown }).Uppy) as unknown as new (o: unknown) => UppyLike,\n XHRUpload: xhr.default ?? xhr\n };\n } catch {\n return null;\n }\n}\n\nexport interface UseFileUploadOptions<T = unknown> {\n /** Đường dẫn endpoint nhận multipart, ví dụ `/api/account/avatar`. */\n endpoint: string;\n /** Tên trường trong `FormData`. Mặc định `\"file\"`. */\n fieldName?: string;\n maxFileSize: number;\n allowedFileTypes: string[];\n maxNumberOfFiles?: number;\n onUploaded: (result: FileUploadResult<T>) => void;\n onError?: (error: { code?: string; status?: number; message: string }) => void;\n /**\n * CHỈ hai chuỗi mà phần máy móc thật sự dùng.\n *\n * Cố ý KHÔNG nhận cả `FileUploadLabels`: ba chuỗi còn lại (`dropHint`/`choose`/`uploading`) là\n * của bố cục, và để chúng lọt vào đây là mời người viết truyền một prop không bao giờ chạy.\n */\n labels?: Partial<FileUploadLogicLabels>;\n disabled?: boolean;\n}\n\n/** Kết quả một tệp tải xong: thân phản hồi JSON của endpoint app. */\nexport interface FileUploadResult<T = unknown> {\n body: T;\n fileName: string;\n}\n\nexport interface UseFileUploadResult {\n /** Mở hộp chọn tệp. Cần `<input {...inputProps} />` có mặt trong cây. */\n open: () => void;\n /** Bắt đầu tải một danh sách tệp có sẵn (dùng cho kéo-thả tự xử lý). */\n start: (files: FileList | File[]) => void;\n /** Huỷ lượt đang chạy. */\n cancel: () => void;\n inputProps: {\n ref: React.RefObject<HTMLInputElement | null>;\n type: \"file\";\n accept: string;\n multiple: boolean;\n disabled: boolean;\n onChange: (event: React.ChangeEvent<HTMLInputElement>) => void;\n };\n dropProps: {\n onDragOver: (event: React.DragEvent) => void;\n onDragLeave: () => void;\n onDrop: (event: React.DragEvent) => void;\n };\n /** Có tệp đang được rê TRÊN khối này. */\n dragging: boolean;\n busy: boolean;\n /** 0–100. */\n percent: number;\n}\n\nexport function useFileUpload<T = unknown>(options: UseFileUploadOptions<T>): UseFileUploadResult {\n const {\n endpoint,\n fieldName = \"file\",\n maxFileSize,\n allowedFileTypes,\n maxNumberOfFiles = 1,\n onUploaded,\n onError,\n disabled = false\n } = options;\n const labels = { ...DEFAULT_FILE_UPLOAD_LABELS, ...options.labels };\n\n const inputRef = useRef<HTMLInputElement>(null);\n const [busy, setBusy] = useState(false);\n const [percent, setPercent] = useState(0);\n const [dragging, setDragging] = useState(false);\n\n /*\n * `onUploaded`/`onError` đi qua ref: chỗ gọi thường truyền hàm mới ở mỗi lần render, và để chúng\n * trong mảng phụ thuộc là dựng lại Uppy sau mỗi phím gõ vào biểu mẫu bên cạnh.\n */\n const handlers = useRef({ onUploaded, onError });\n handlers.current = { onUploaded, onError };\n\n const uppyRef = useRef<UppyLike | null>(null);\n useEffect(() => {\n return () => {\n uppyRef.current?.destroy();\n uppyRef.current = null;\n };\n }, []);\n\n const start = useCallback(\n async (files: FileList | File[]) => {\n const list = Array.from(files).slice(0, maxNumberOfFiles);\n if (list.length === 0) return;\n\n setBusy(true);\n setPercent(0);\n try {\n const mod = await loadUppy();\n if (!mod) {\n handlers.current.onError?.({ message: labels.missingDependency });\n return;\n }\n\n /*\n * Dựng MỘT Uppy cho MỘT lượt tải rồi huỷ. Giữ lại một instance dài hạn nghe hấp dẫn hơn,\n * nhưng Uppy nhớ cả những tệp đã hỏng ở lượt trước — chọn lại đúng tệp đó sẽ bị chặn vì\n * \"trùng\", và triệu chứng là một nút bấm không làm gì.\n */\n uppyRef.current?.destroy();\n const uppy = new mod.Uppy({\n autoProceed: false,\n restrictions: { maxNumberOfFiles, maxFileSize, allowedFileTypes }\n });\n uppyRef.current = uppy;\n\n /*\n * Chỗ giữ lỗi của lượt gọi gần nhất. `upload-error` của Uppy KHÔNG mang thân phản hồi —\n * thứ nó ném là `new NetworkError(xhr.statusText, xhr)`, mà trên HTTP/2 `statusText` là\n * chuỗi RỖNG. Nên `code` phải được nhặt ở `onAfterResponse`, nơi còn nhìn thấy `xhr`.\n */\n const lastError: { code?: string; status?: number; message?: string } = {};\n\n uppy.use(mod.XHRUpload, {\n endpoint,\n fieldName,\n method: \"POST\",\n // Cookie phiên là cách Comitor xác thực mọi route tự viết — thiếu dòng này là 401.\n withCredentials: true,\n\n /*\n * ⚠ CHỈ THỬ LẠI LỖI 5xx.\n *\n * Mặc định của Uppy là `shouldRetry = () => true` với `retries = 3` (đọc\n * `@uppy/core/lib/utils/fetcher.js`), tức MỌI phản hồi không-2xx bị gửi lại ba lần. Hậu\n * quả đo được bằng chính hợp đồng của Comitor:\n * · một ảnh sai định dạng 2 MB → BỐN lượt POST 2 MB, tất cả đều biết chắc sẽ hỏng;\n * · một **429** của `lib/api/rate-limit.ts` cũng bị thử lại, mỗi lần tiêu thêm một đơn\n * vị hạn mức và bỏ qua `Retry-After` — client tự đào sâu cái hố nó vừa rơi vào.\n * 4xx là \"yêu cầu sai, gửi lại cũng thế\"; chỉ 5xx mới đáng thử lại.\n */\n shouldRetry: (xhr: XMLHttpRequest) => xhr.status >= 500,\n\n /*\n * ⚠ `getResponseError` KHÔNG TỒN TẠI — nó chỉ còn trong một dòng comment cũ ở\n * `@uppy/xhr-upload/lib/index.js`, không có trong `XhrUploadOpts`, và không được đọc ở\n * đâu cả. Truyền nó vào là viết một callback không bao giờ chạy. Đường thật là\n * `onAfterResponse`, thứ XHRUpload có forward xuống fetcher.\n */\n onAfterResponse: (xhr: XMLHttpRequest) => {\n if (xhr.status >= 200 && xhr.status < 300) return;\n lastError.status = xhr.status;\n try {\n const parsed = JSON.parse(xhr.responseText) as { error?: { code?: string; message?: string } };\n lastError.code = parsed.error?.code;\n lastError.message = parsed.error?.message;\n } catch {\n // Phản hồi không phải JSON (proxy trả HTML, 413 của tầng mạng…). Giữ `status` là đủ.\n }\n },\n\n /*\n * ⚠ KHÔNG gửi meta kèm tệp.\n *\n * Mặc định `allowedMetaFields: true` ghi MỌI meta vào cùng `FormData`, và nó ghi TRƯỚC\n * khi append tệp. Route của Comitor đọc `form.get(\"file\")` — hàm đó trả phần tử ĐẦU TIÊN\n * cùng tên. Nên chỉ cần một khoá meta tên `file` là route nhận về một chuỗi,\n * `file instanceof File` thành false, và nó trả 400 \"Thiếu tệp ảnh\" trong khi tệp vẫn\n * nằm nguyên trong request. Mảng rỗng đóng hẳn đường đó.\n */\n allowedMetaFields: []\n });\n\n uppy.on(\"progress\", ((value: number) => setPercent(value)) as never);\n uppy.on(\"upload-success\", ((file: { name?: string } | undefined, response: { body?: T }) => {\n handlers.current.onUploaded({ body: response?.body as T, fileName: file?.name ?? \"\" });\n }) as never);\n uppy.on(\"upload-error\", ((_file: unknown, error: Error) => {\n // `lastError` là nguồn duy nhất có `code` — xem `onAfterResponse` ở trên.\n handlers.current.onError?.({\n code: lastError.code,\n status: lastError.status,\n message: lastError.message ?? error?.message ?? labels.uploadFailed\n });\n }) as never);\n /*\n * `restriction-failed` là đường RIÊNG, không đi qua `upload-error`. Bỏ sót nó thì một tệp\n * quá lớn hay sai định dạng bị Uppy chặn trong im lặng — người dùng bấm rồi không thấy gì.\n */\n uppy.on(\"restriction-failed\", ((_file: unknown, error: Error) => {\n handlers.current.onError?.({ message: error?.message ?? labels.uploadFailed });\n }) as never);\n\n for (const file of list) uppy.addFile({ name: file.name, type: file.type, data: file });\n await uppy.upload();\n } finally {\n setBusy(false);\n setPercent(0);\n /*\n * Xoá giá trị input: chọn LẠI cùng một tệp sau một lần lỗi sẽ KHÔNG bắn `change` nếu giá\n * trị không đổi — và triệu chứng là thử lại im lặng không làm gì.\n */\n if (inputRef.current) inputRef.current.value = \"\";\n }\n },\n [\n endpoint,\n fieldName,\n maxFileSize,\n allowedFileTypes,\n maxNumberOfFiles,\n labels.missingDependency,\n labels.uploadFailed\n ]\n );\n\n const cancel = useCallback(() => {\n uppyRef.current?.cancelAll();\n }, []);\n\n return {\n open: () => inputRef.current?.click(),\n start,\n cancel,\n inputProps: {\n ref: inputRef,\n type: \"file\",\n accept: allowedFileTypes.join(\",\"),\n multiple: maxNumberOfFiles > 1,\n disabled: disabled || busy,\n onChange: (event) => {\n if (event.target.files) void start(event.target.files);\n }\n },\n dropProps: {\n onDragOver: (event) => {\n // `preventDefault` là BẮT BUỘC — thiếu nó trình duyệt mở tệp thay vì bắn `onDrop`.\n event.preventDefault();\n if (!disabled && !busy) setDragging(true);\n },\n onDragLeave: () => setDragging(false),\n onDrop: (event) => {\n event.preventDefault();\n setDragging(false);\n if (!disabled && !busy && event.dataTransfer.files.length > 0) void start(event.dataTransfer.files);\n }\n },\n dragging,\n busy,\n percent\n };\n}\n"],"mappings":"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","names":[]}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@comitor/ui",
3
- "version": "1.2.0",
3
+ "version": "1.3.0",
4
4
  "description": "Comitor design system — Shadcn/UI tùy biến theo brand Comitor + component dùng chung cho hệ sinh thái SaaS (Tasks, Chat, CRM, HR…)",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -57,6 +57,19 @@
57
57
  "./styles.css": "./styles.css",
58
58
  "./package.json": "./package.json"
59
59
  },
60
+ "scripts": {
61
+ "build": "tsup",
62
+ "typecheck": "tsc --noEmit",
63
+ "lint": "biome check .",
64
+ "lint:fix": "biome check --write .",
65
+ "lint:a11y": "node scripts/check-contrast.mjs",
66
+ "test:a11y-gate": "node scripts/a11y/self-test.mjs",
67
+ "test:nav-active": "node scripts/check-nav-active.mjs",
68
+ "verify:directives": "node scripts/verify-directives.mjs",
69
+ "smoke:entries": "node scripts/smoke-entries.mjs",
70
+ "check:exports": "publint --strict && attw --pack . --profile esm-only --exclude-entrypoints styles.css",
71
+ "prepublishOnly": "pnpm run typecheck && pnpm run build && pnpm run verify:directives && pnpm run smoke:entries && pnpm run check:exports && pnpm run test:nav-active && pnpm run test:a11y-gate && pnpm run lint:a11y"
72
+ },
60
73
  "peerDependencies": {
61
74
  "@uppy/core": "^6.0.0",
62
75
  "@uppy/xhr-upload": "^6.0.0",
@@ -148,17 +161,5 @@
148
161
  "tailwindcss": "^4.2.0",
149
162
  "tsup": "^8.3.5",
150
163
  "typescript": "^5.7.0"
151
- },
152
- "scripts": {
153
- "build": "tsup",
154
- "typecheck": "tsc --noEmit",
155
- "lint": "biome check .",
156
- "lint:fix": "biome check --write .",
157
- "lint:a11y": "node scripts/check-contrast.mjs",
158
- "test:a11y-gate": "node scripts/a11y/self-test.mjs",
159
- "test:nav-active": "node scripts/check-nav-active.mjs",
160
- "verify:directives": "node scripts/verify-directives.mjs",
161
- "smoke:entries": "node scripts/smoke-entries.mjs",
162
- "check:exports": "publint --strict && attw --pack . --profile esm-only --exclude-entrypoints styles.css"
163
164
  }
164
- }
165
+ }