@comitor/ui 3.12.0 → 3.13.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.
Files changed (55) hide show
  1. package/dist/chat/chat-thread.js +1 -1
  2. package/dist/chat/chat-thread.js.map +1 -1
  3. package/dist/components/composite/data-table.d.ts +2 -5
  4. package/dist/components/composite/data-table.js +9 -33
  5. package/dist/components/composite/data-table.js.map +1 -1
  6. package/dist/components/composite/form-section.js +5 -5
  7. package/dist/components/composite/form-section.js.map +1 -1
  8. package/dist/components/composite/gantt.js +47 -28
  9. package/dist/components/composite/gantt.js.map +1 -1
  10. package/dist/components/composite/inline-edit.js +33 -38
  11. package/dist/components/composite/inline-edit.js.map +1 -1
  12. package/dist/components/composite/search-filter-bar.js +2 -1
  13. package/dist/components/composite/search-filter-bar.js.map +1 -1
  14. package/dist/components/composite/status-config.d.ts +17 -15
  15. package/dist/components/composite/status-config.js +6 -2
  16. package/dist/components/composite/status-config.js.map +1 -1
  17. package/dist/components/composite/tree-view.js +4 -1
  18. package/dist/components/composite/tree-view.js.map +1 -1
  19. package/dist/components/ui/breadcrumb.js +1 -4
  20. package/dist/components/ui/breadcrumb.js.map +1 -1
  21. package/dist/components/ui/button.js +7 -7
  22. package/dist/components/ui/button.js.map +1 -1
  23. package/dist/components/ui/circular-progress.d.ts +9 -4
  24. package/dist/components/ui/circular-progress.js +58 -24
  25. package/dist/components/ui/circular-progress.js.map +1 -1
  26. package/dist/components/ui/progress.d.ts +6 -0
  27. package/dist/components/ui/progress.js +2 -2
  28. package/dist/components/ui/progress.js.map +1 -1
  29. package/dist/components/ui/table.d.ts +7 -13
  30. package/dist/components/ui/table.js +1 -1
  31. package/dist/components/ui/table.js.map +1 -1
  32. package/dist/components/ui/tabs-variants.js +2 -2
  33. package/dist/components/ui/tabs-variants.js.map +1 -1
  34. package/dist/components/ui/timeline.js +19 -14
  35. package/dist/components/ui/timeline.js.map +1 -1
  36. package/dist/i18n/namespaces/file-upload.js +5 -1
  37. package/dist/i18n/namespaces/file-upload.js.map +1 -1
  38. package/dist/i18n/namespaces/image-upload-field.js +10 -0
  39. package/dist/i18n/namespaces/image-upload-field.js.map +1 -1
  40. package/dist/index.d.ts +2 -2
  41. package/dist/index.js +2 -1
  42. package/dist/index.js.map +1 -1
  43. package/dist/lib/avatar-tone.d.ts +4 -0
  44. package/dist/lib/avatar-tone.js.map +1 -1
  45. package/dist/uploader/file-upload-constants.d.ts +26 -2
  46. package/dist/uploader/file-upload-constants.js.map +1 -1
  47. package/dist/uploader/file-upload.d.ts +18 -1
  48. package/dist/uploader/file-upload.js +50 -5
  49. package/dist/uploader/file-upload.js.map +1 -1
  50. package/dist/uploader/image-upload-field.d.ts +31 -2
  51. package/dist/uploader/image-upload-field.js +139 -8
  52. package/dist/uploader/image-upload-field.js.map +1 -1
  53. package/dist/uploader/index.d.ts +2 -2
  54. package/dist/uploader/index.js.map +1 -1
  55. package/package.json +1 -1
@@ -1,11 +1,23 @@
1
1
  "use client";
2
2
  import { jsx, jsxs } from "react/jsx-runtime";
3
+ import { ImageIcon } from "lucide-react";
3
4
  import { useId } from "react";
4
5
  import { Button } from "../components/ui/button.js";
5
6
  import { Progress } from "../components/ui/progress.js";
6
- import { useComitorLabels } from "../i18n/locale-provider.js";
7
+ import { useComitorLabels, useComitorLocale } from "../i18n/locale-provider.js";
8
+ import { fileUploadEn } from "../i18n/namespaces/file-upload.js";
7
9
  import { cn } from "../lib/cn.js";
8
10
  import { useFileUpload } from "./use-file-upload.js";
11
+ function formatSize(bytes, locale) {
12
+ const units = ["KB", "MB", "GB"];
13
+ let value = bytes / 1024;
14
+ let unit = 0;
15
+ while (value >= 1024 && unit < units.length - 1) {
16
+ value /= 1024;
17
+ unit++;
18
+ }
19
+ return `${new Intl.NumberFormat(locale, { maximumFractionDigits: 1 }).format(value)} ${units[unit]}`;
20
+ }
9
21
  function FileUpload({
10
22
  endpoint,
11
23
  fieldName,
@@ -15,10 +27,16 @@ function FileUpload({
15
27
  onUploaded,
16
28
  onError,
17
29
  labels,
30
+ layout = "default",
31
+ icon = /* @__PURE__ */ jsx(ImageIcon, {}),
32
+ title,
33
+ description,
34
+ showButton = true,
18
35
  disabled = false,
19
36
  className
20
37
  }) {
21
38
  const text = useComitorLabels("fileUpload", labels);
39
+ const { locale } = useComitorLocale();
22
40
  const inputId = useId();
23
41
  const upload = useFileUpload({
24
42
  endpoint,
@@ -31,19 +49,46 @@ function FileUpload({
31
49
  labels: { uploadFailed: text.uploadFailed, missingDependency: text.missingDependency },
32
50
  disabled
33
51
  });
52
+ const blocked = disabled || upload.busy;
53
+ const compact = layout === "compact";
54
+ const describe = text.dropDescription ?? fileUploadEn.dropDescription;
55
+ const titleNode = title === void 0 ? text.dropTitle ?? fileUploadEn.dropTitle : title;
56
+ const descriptionNode = description === void 0 ? describe?.({ maxFiles: maxNumberOfFiles, maxSize: formatSize(maxFileSize, locale) }) : description;
57
+ const button = showButton ? /* @__PURE__ */ jsx(Button, { asChild: true, variant: "outline", size: "sm", disabled: blocked, loading: upload.busy, children: /* @__PURE__ */ jsx("label", { htmlFor: inputId, className: blocked ? "pointer-events-none" : "cursor-pointer", children: upload.busy ? text.uploading : text.choose }) }) : null;
34
58
  return /* @__PURE__ */ jsxs("div", { className: cn("flex flex-col gap-2", className), children: [
35
- /* @__PURE__ */ jsx("input", { ...upload.inputProps, id: inputId, className: "sr-only" }),
59
+ /* @__PURE__ */ jsx("input", { ...upload.inputProps, id: inputId, className: "peer sr-only" }),
36
60
  /* @__PURE__ */ jsx(
37
61
  "div",
38
62
  {
39
63
  ...upload.dropProps,
64
+ onClick: (event) => {
65
+ if (blocked || event.target.closest("label")) return;
66
+ upload.open();
67
+ },
40
68
  className: cn(
41
- "flex flex-col items-center gap-2 rounded-lg border-2 border-dashed p-4 text-center transition-colors",
69
+ "flex flex-col items-center rounded-lg border-2 border-dashed text-center transition-colors",
70
+ compact ? "gap-2 p-4" : "gap-3 px-6 py-8",
71
+ !blocked && "cursor-pointer",
72
+ !showButton && "peer-focus-visible:outline-2 peer-focus-visible:outline-ring peer-focus-visible:outline-offset-2",
42
73
  upload.dragging ? "border-primary-ink" : "border-border"
43
74
  ),
44
- children: /* @__PURE__ */ jsxs("div", { className: cn("flex flex-col items-center gap-2", (disabled || upload.busy) && "opacity-60"), children: [
75
+ children: compact ? /* @__PURE__ */ jsxs("div", { className: cn("flex flex-col items-center gap-2", blocked && "opacity-60"), children: [
45
76
  /* @__PURE__ */ jsx("p", { className: "text-muted-foreground text-sm", children: text.dropHint }),
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 }) })
77
+ button
78
+ ] }) : /* @__PURE__ */ jsxs("div", { className: cn("flex flex-col items-center gap-3", blocked && "opacity-60"), children: [
79
+ icon === null ? null : /* @__PURE__ */ jsx(
80
+ "span",
81
+ {
82
+ "aria-hidden": "true",
83
+ className: "flex size-12 items-center justify-center rounded-full border border-border bg-background text-muted-foreground [&>svg]:size-5",
84
+ children: icon
85
+ }
86
+ ),
87
+ titleNode === null && descriptionNode === null ? null : /* @__PURE__ */ jsxs("div", { className: "flex flex-col items-center gap-1", children: [
88
+ titleNode === null ? null : /* @__PURE__ */ jsx("p", { className: "font-medium text-foreground text-sm", children: titleNode }),
89
+ descriptionNode === null ? null : /* @__PURE__ */ jsx("p", { className: "text-muted-foreground text-xs", children: descriptionNode })
90
+ ] }),
91
+ button
47
92
  ] })
48
93
  }
49
94
  ),
@@ -1 +1 @@
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 { useComitorLabels } from \"../i18n/locale-provider.js\";\nimport { cn } from \"../lib/cn.js\";\nimport 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 /**\n * Nhãn hiển thị. Không truyền thì theo namespace `fileUpload` của `ComitorLocaleProvider`\n * (không có provider → tiếng Anh); truyền `Partial<>` để đổi từng chuỗi.\n */\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 = useComitorLabels(\"fileUpload\", 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 <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":";AAuGM,cAkCE,YAlCF;AArGN,SAAS,aAAa;AACtB,SAAS,cAAc;AACvB,SAAS,gBAAgB;AACzB,SAAS,wBAAwB;AACjC,SAAS,UAAU;AAEnB,SAAgC,qBAAqB;AA6D9C,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,iBAAiB,cAAc,MAAM;AAClD,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,IAM/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":[]}
1
+ {"version":3,"sources":["../../src/uploader/file-upload.tsx"],"sourcesContent":["\"use client\";\n\nimport { ImageIcon } from \"lucide-react\";\nimport { type ReactNode, useId } from \"react\";\nimport { Button } from \"../components/ui/button.js\";\nimport { Progress } from \"../components/ui/progress.js\";\nimport { useComitorLabels, useComitorLocale } from \"../i18n/locale-provider.js\";\nimport { fileUploadEn } from \"../i18n/namespaces/file-upload.js\";\nimport { cn } from \"../lib/cn.js\";\nimport 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 /**\n * Nhãn hiển thị. Không truyền thì theo namespace `fileUpload` của `ComitorLocaleProvider`\n * (không có provider → tiếng Anh); truyền `Partial<>` để đổi từng chuỗi.\n */\n labels?: Partial<FileUploadLabels>;\n /**\n * `\"default\"`: khối lớn — icon trong vòng tròn, tiêu đề, dòng giới hạn, nút. `\"compact\"`: một dòng chữ +\n * nút (bố cục cũ, cho form chật). Mọi phần của `\"default\"` còn ẩn/thay được riêng qua các prop dưới.\n */\n layout?: \"default\" | \"compact\";\n /** Icon trong vòng tròn (bố cục `default`). Mặc định `ImageIcon` của lucide; `null` = bỏ cả vòng tròn. */\n icon?: ReactNode | null;\n /** Dòng tiêu đề. Mặc định `labels.dropTitle`; `null` = ẩn. */\n title?: ReactNode | null;\n /** Dòng phụ. Mặc định `labels.dropDescription` dựng từ giới hạn; `null` = ẩn. */\n description?: ReactNode | null;\n /**\n * Hiện nút mở hộp chọn tệp (mặc định `true`). Tắt thì cả vùng vẫn bấm được, và Tab vẫn tới ô chọn tệp —\n * vùng nhận vòng focus thay cho nút.\n */\n showButton?: boolean;\n disabled?: boolean;\n className?: string;\n}\n\n/** Byte → chữ theo locale: `5 MB`, `512 KB`, `1,5 GB`. Không `B` lẻ — giới hạn dưới 1 KB vô nghĩa với tệp. */\nfunction formatSize(bytes: number, locale: string): string {\n const units = [\"KB\", \"MB\", \"GB\"];\n let value = bytes / 1024;\n let unit = 0;\n while (value >= 1024 && unit < units.length - 1) {\n value /= 1024;\n unit++;\n }\n return `${new Intl.NumberFormat(locale, { maximumFractionDigits: 1 }).format(value)} ${units[unit]}`;\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 layout = \"default\",\n icon = <ImageIcon />,\n title,\n description,\n showButton = true,\n disabled = false,\n className\n}: FileUploadProps<T>) {\n const text = useComitorLabels(\"fileUpload\", labels);\n const { locale } = useComitorLocale();\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 const blocked = disabled || upload.busy;\n const compact = layout === \"compact\";\n // Hai khoá này tuỳ chọn trong kiểu (xem `FileUploadLabels`) — bảng nhãn cũ thiếu chúng thì rơi về tiếng Anh.\n const describe = text.dropDescription ?? fileUploadEn.dropDescription;\n const titleNode = title === undefined ? (text.dropTitle ?? fileUploadEn.dropTitle) : title;\n const descriptionNode =\n description === undefined\n ? describe?.({ maxFiles: maxNumberOfFiles, maxSize: formatSize(maxFileSize, locale) })\n : description;\n\n const button = showButton ? (\n <Button asChild variant=\"outline\" size=\"sm\" disabled={blocked} loading={upload.busy}>\n <label htmlFor={inputId} className={blocked ? \"pointer-events-none\" : \"cursor-pointer\"}>\n {upload.busy ? text.uploading : text.choose}\n </label>\n </Button>\n ) : null;\n\n return (\n <div className={cn(\"flex flex-col gap-2\", className)}>\n {/*\n `peer` cho vùng bên dưới: không có nút thì Tab dừng ở chính ô này (nó `sr-only`, không phải\n `display:none`), và vùng vẽ vòng focus thay cho nó.\n */}\n <input {...upload.inputProps} id={inputId} className=\"peer sr-only\" />\n\n {/*\n Vùng kéo thả. Bấm vào đâu trong vùng cũng mở hộp chọn tệp (\"Or click to browse\"); bấm trúng nút\n thì để `<label>` của nút tự lo, không mở hai lần.\n */}\n {/* biome-ignore lint/a11y/noStaticElementInteractions: đường bàn phím là ô `<input>` ngay trên (Tab tới được), vùng chỉ mở rộng chỗ bấm cho chuột/chạm */}\n {/* biome-ignore lint/a11y/useKeyWithClickEvents: như trên — phím đã do ô `<input>` xử lý */}\n <div\n {...upload.dropProps}\n onClick={(event) => {\n if (blocked || (event.target as HTMLElement).closest(\"label\")) return;\n upload.open();\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 rounded-lg border-2 border-dashed text-center transition-colors\",\n compact ? \"gap-2 p-4\" : \"gap-3 px-6 py-8\",\n !blocked && \"cursor-pointer\",\n !showButton &&\n \"peer-focus-visible:outline-2 peer-focus-visible:outline-ring peer-focus-visible:outline-offset-2\",\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 {compact ? (\n <div className={cn(\"flex flex-col items-center gap-2\", blocked && \"opacity-60\")}>\n <p className=\"text-muted-foreground text-sm\">{text.dropHint}</p>\n {button}\n </div>\n ) : (\n <div className={cn(\"flex flex-col items-center gap-3\", blocked && \"opacity-60\")}>\n {icon === null ? null : (\n <span\n aria-hidden=\"true\"\n className=\"flex size-12 items-center justify-center rounded-full border border-border bg-background text-muted-foreground [&>svg]:size-5\"\n >\n {icon}\n </span>\n )}\n {titleNode === null && descriptionNode === null ? null : (\n <div className=\"flex flex-col items-center gap-1\">\n {titleNode === null ? null : <p className=\"font-medium text-foreground text-sm\">{titleNode}</p>}\n {descriptionNode === null ? null : <p className=\"text-muted-foreground text-xs\">{descriptionNode}</p>}\n </div>\n )}\n {button}\n </div>\n )}\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":"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","names":[]}
@@ -59,8 +59,37 @@ interface ImageUploadFieldProps<T = unknown> {
59
59
  * (không có provider → tiếng Anh); truyền `Partial<>` để đổi từng chuỗi.
60
60
  */
61
61
  labels?: Partial<ImageUploadFieldLabels>;
62
+ /**
63
+ * Cắt ảnh TRƯỚC khi tải lên — mặc định TẮT. `true` = khung tròn 1:1, xuất JPEG 512px; truyền object để đổi
64
+ * từng thứ. Bật thì ảnh chọn/thả vào mở trong hộp `Cropper`, và chỉ phần đã cắt (vẽ lại trên canvas) được tải
65
+ * lên khi bấm `cropSave`. ⚠ `outputType` phải nằm trong `allowedFileTypes`, vì tệp xuất ra đi qua đúng luật đó.
66
+ */
67
+ crop?: boolean | ImageUploadCropOptions;
68
+ /**
69
+ * Hiện nút `choose` (mặc định `true`). Tắt khi chính tấm ảnh là chỗ bấm — ảnh vẫn mở hộp chọn tệp, và lúc đó
70
+ * nó trở thành nút THẬT cho bàn phím và trình đọc màn hình (tên là `labels.choose`).
71
+ */
72
+ showUploadButton?: boolean;
73
+ /**
74
+ * Hiện nút `remove` khi có ảnh (mặc định `true`). Chưa có ảnh (`hasImage` false) thì nút không bao giờ hiện,
75
+ * bất kể prop này.
76
+ */
77
+ showRemoveButton?: boolean;
62
78
  className?: string;
63
79
  }
64
- declare function ImageUploadField<T = unknown>({ endpoint, fieldName, maxFileSize, allowedFileTypes, preview, hasImage, editable, busy, onRemove, onUploaded, onError, labels, className }: ImageUploadFieldProps<T>): React.JSX.Element;
80
+ /** Cấu hình của `crop`. */
81
+ interface ImageUploadCropOptions {
82
+ /** Tỉ lệ khung cắt. Mặc định `1`. */
83
+ aspectRatio?: number;
84
+ /** Hình khung — CHỈ để nhìn; vùng xuất ra luôn là hình chữ nhật bao ngoài. Mặc định `"circle"`. */
85
+ shape?: "circle" | "rectangle";
86
+ /** Cạnh dài của ảnh xuất ra, px. Mặc định `512`. */
87
+ outputSize?: number;
88
+ /** Định dạng ảnh xuất ra. Mặc định `"image/jpeg"`. */
89
+ outputType?: "image/jpeg" | "image/png" | "image/webp";
90
+ /** Chất lượng nén cho JPEG/WebP, 0–1. Mặc định `0.9`. */
91
+ quality?: number;
92
+ }
93
+ declare function ImageUploadField<T = unknown>({ endpoint, fieldName, maxFileSize, allowedFileTypes, preview, hasImage, editable, busy, onRemove, onUploaded, onError, labels, crop, showUploadButton, showRemoveButton, className }: ImageUploadFieldProps<T>): React.JSX.Element;
65
94
 
66
- export { ImageUploadField, type ImageUploadFieldProps };
95
+ export { type ImageUploadCropOptions, ImageUploadField, type ImageUploadFieldProps };
@@ -1,10 +1,28 @@
1
1
  "use client";
2
2
  import { jsx, jsxs } from "react/jsx-runtime";
3
+ import { useEffect, useRef, useState } from "react";
3
4
  import { Button } from "../components/ui/button.js";
5
+ import { Cropper, CropperArea, CropperImage } from "../components/ui/cropper.js";
4
6
  import { Progress } from "../components/ui/progress.js";
7
+ import {
8
+ ResponsiveDialog,
9
+ ResponsiveDialogClose,
10
+ ResponsiveDialogContent,
11
+ ResponsiveDialogDescription,
12
+ ResponsiveDialogFooter,
13
+ ResponsiveDialogHeader,
14
+ ResponsiveDialogTitle
15
+ } from "../components/ui/responsive-dialog.js";
16
+ import { Slider } from "../components/ui/slider.js";
5
17
  import { useComitorLabels } from "../i18n/locale-provider.js";
18
+ import { imageUploadFieldEn } from "../i18n/namespaces/image-upload-field.js";
6
19
  import { cn } from "../lib/cn.js";
7
20
  import { useFileUpload } from "./use-file-upload.js";
21
+ const OUTPUT_EXTENSION = {
22
+ "image/jpeg": "jpg",
23
+ "image/png": "png",
24
+ "image/webp": "webp"
25
+ };
8
26
  function ImageUploadField({
9
27
  endpoint,
10
28
  fieldName,
@@ -18,9 +36,24 @@ function ImageUploadField({
18
36
  onUploaded,
19
37
  onError,
20
38
  labels,
39
+ crop,
40
+ showUploadButton = true,
41
+ showRemoveButton = true,
21
42
  className
22
43
  }) {
23
44
  const text = useComitorLabels("imageUploadField", labels);
45
+ const cropOptions = crop === true ? {} : crop || null;
46
+ const aspectRatio = cropOptions?.aspectRatio ?? 1;
47
+ const [source, setSource] = useState(null);
48
+ const [zoom, setZoom] = useState(1);
49
+ const area = useRef(null);
50
+ const picture = useRef(null);
51
+ useEffect(
52
+ () => () => {
53
+ if (source) URL.revokeObjectURL(source.url);
54
+ },
55
+ [source]
56
+ );
24
57
  const upload = useFileUpload({
25
58
  endpoint,
26
59
  ...fieldName ? { fieldName } : {},
@@ -33,29 +66,127 @@ function ImageUploadField({
33
66
  disabled: !editable || busy
34
67
  });
35
68
  const blocked = !editable || busy || upload.busy;
69
+ function pick(file) {
70
+ if (!file?.type.startsWith("image/")) return;
71
+ area.current = null;
72
+ setZoom(1);
73
+ setSource({ url: URL.createObjectURL(file), name: file.name });
74
+ }
75
+ async function saveCrop() {
76
+ const image = picture.current;
77
+ if (!image || !area.current || !source) return;
78
+ const outputSize = cropOptions?.outputSize ?? 512;
79
+ const type = cropOptions?.outputType ?? "image/jpeg";
80
+ const width = aspectRatio >= 1 ? outputSize : Math.round(outputSize * aspectRatio);
81
+ const height = Math.round(width / aspectRatio);
82
+ const { x, y, width: w, height: h } = area.current;
83
+ const canvas = document.createElement("canvas");
84
+ canvas.width = width;
85
+ canvas.height = height;
86
+ canvas.getContext("2d")?.drawImage(image, x, y, w, h, 0, 0, width, height);
87
+ const blob = await new Promise((resolve) => canvas.toBlob(resolve, type, cropOptions?.quality ?? 0.9));
88
+ if (!blob) return;
89
+ const extension = OUTPUT_EXTENSION[blob.type] ?? "png";
90
+ const stem = source.name.replace(/\.[^.]+$/, "") || "image";
91
+ setSource(null);
92
+ upload.start([new File([blob], `${stem}.${extension}`, { type: blob.type })]);
93
+ }
94
+ const inputProps = cropOptions ? {
95
+ ...upload.inputProps,
96
+ onChange: (event) => {
97
+ pick(event.target.files?.[0]);
98
+ event.target.value = "";
99
+ }
100
+ } : upload.inputProps;
101
+ const dropProps = cropOptions ? {
102
+ ...upload.dropProps,
103
+ onDrop: (event) => {
104
+ event.preventDefault();
105
+ upload.dropProps.onDragLeave();
106
+ if (!blocked) pick(event.dataTransfer.files[0]);
107
+ }
108
+ } : upload.dropProps;
36
109
  if (!editable) return /* @__PURE__ */ jsx("div", { className: cn("flex items-center gap-4", className), children: preview });
37
110
  return /* @__PURE__ */ jsxs("div", { className: cn("flex flex-col gap-2", className), children: [
38
- /* @__PURE__ */ jsx("input", { ...upload.inputProps, className: "sr-only" }),
111
+ /* @__PURE__ */ jsx("input", { ...inputProps, className: "sr-only" }),
39
112
  /* @__PURE__ */ jsxs(
40
113
  "div",
41
114
  {
42
- ...upload.dropProps,
115
+ ...dropProps,
43
116
  className: cn(
44
117
  "relative flex flex-wrap items-center gap-4 rounded-lg border-2 border-dashed p-2 transition-colors",
45
118
  upload.dragging ? "border-primary-ink" : "border-transparent"
46
119
  ),
47
120
  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
- ] }),
121
+ /* @__PURE__ */ jsx(
122
+ "button",
123
+ {
124
+ type: "button",
125
+ ...showUploadButton ? { tabIndex: -1, "aria-hidden": true } : { "aria-label": upload.busy ? text.uploading : text.choose },
126
+ disabled: blocked,
127
+ onClick: upload.open,
128
+ className: "shrink-0 cursor-pointer rounded-md transition-opacity hover:opacity-80 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-ring disabled:cursor-default disabled:hover:opacity-100",
129
+ children: preview
130
+ }
131
+ ),
132
+ showUploadButton || showRemoveButton && hasImage ? /* @__PURE__ */ jsxs("div", { className: cn("flex flex-wrap items-center gap-2", blocked && "opacity-60"), children: [
133
+ showUploadButton ? /* @__PURE__ */ jsx(Button, { type: "button", variant: "outline", size: "sm", loading: upload.busy, onClick: upload.open, children: upload.busy ? text.uploading : text.choose }) : null,
134
+ showRemoveButton && hasImage ? /* @__PURE__ */ jsx(Button, { type: "button", variant: "ghost", size: "sm", disabled: blocked, onClick: onRemove, children: text.remove }) : null
135
+ ] }) : null,
53
136
  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
137
  ]
55
138
  }
56
139
  ),
57
140
  /* @__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
141
+ upload.busy ? /* @__PURE__ */ jsx(Progress, { value: upload.percent, "aria-label": text.uploading }) : null,
142
+ cropOptions ? /* @__PURE__ */ jsx(
143
+ ResponsiveDialog,
144
+ {
145
+ open: source !== null,
146
+ onOpenChange: (open) => {
147
+ if (!open) setSource(null);
148
+ },
149
+ children: /* @__PURE__ */ jsxs(ResponsiveDialogContent, { children: [
150
+ /* @__PURE__ */ jsxs(ResponsiveDialogHeader, { children: [
151
+ /* @__PURE__ */ jsx(ResponsiveDialogTitle, { children: text.cropTitle ?? imageUploadFieldEn.cropTitle }),
152
+ /* @__PURE__ */ jsx(ResponsiveDialogDescription, { children: text.cropDescription ?? imageUploadFieldEn.cropDescription })
153
+ ] }),
154
+ source ? /* @__PURE__ */ jsxs(
155
+ Cropper,
156
+ {
157
+ aspectRatio,
158
+ shape: cropOptions.shape ?? "circle",
159
+ zoom,
160
+ onZoomChange: setZoom,
161
+ className: "min-h-72 rounded-xl",
162
+ "data-vaul-no-drag": "",
163
+ onCropAreaChange: (_, pixels) => {
164
+ area.current = pixels;
165
+ },
166
+ children: [
167
+ /* @__PURE__ */ jsx(CropperImage, { ref: picture, src: source.url, alt: "" }),
168
+ /* @__PURE__ */ jsx(CropperArea, {})
169
+ ]
170
+ }
171
+ ) : null,
172
+ /* @__PURE__ */ jsx(
173
+ Slider,
174
+ {
175
+ min: 1,
176
+ max: 3,
177
+ step: 0.01,
178
+ value: [zoom],
179
+ onValueChange: ([next]) => setZoom(next ?? 1),
180
+ "aria-label": text.cropZoom ?? imageUploadFieldEn.cropZoom
181
+ }
182
+ ),
183
+ /* @__PURE__ */ jsxs(ResponsiveDialogFooter, { children: [
184
+ /* @__PURE__ */ jsx(ResponsiveDialogClose, { asChild: true, children: /* @__PURE__ */ jsx(Button, { variant: "outline", children: text.cropCancel ?? imageUploadFieldEn.cropCancel }) }),
185
+ /* @__PURE__ */ jsx(Button, { onClick: () => void saveCrop(), children: text.cropSave ?? imageUploadFieldEn.cropSave })
186
+ ] })
187
+ ] })
188
+ }
189
+ ) : null
59
190
  ] });
60
191
  }
61
192
  export {
@@ -1 +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 { useComitorLabels } from \"../i18n/locale-provider.js\";\nimport { cn } from \"../lib/cn.js\";\nimport 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** — khuôn quen thuộc của ô ảnh đại diện,\n * người dùng nhận ra ngay mà không phải đọc.\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ỡ ảnh (`labels.remove`) 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 /**\n * Nhãn hiển thị. Không truyền thì theo namespace `imageUploadField` của `ComitorLocaleProvider`\n * (không có provider → tiếng Anh); truyền `Partial<>` để đổi từng chuỗi.\n */\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 = useComitorLabels(\"imageUploadField\", 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 <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ữ `dropPrompt` 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":";AAoGwB,cAmBhB,YAnBgB;AAjGxB,SAAS,cAAc;AACvB,SAAS,gBAAgB;AACzB,SAAS,wBAAwB;AACjC,SAAS,UAAU;AAEnB,SAAgC,qBAAqB;AAyD9C,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,iBAAiB,oBAAoB,MAAM;AAExD,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,IAMlD;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
+ {"version":3,"sources":["../../src/uploader/image-upload-field.tsx"],"sourcesContent":["\"use client\";\n\nimport { type ReactNode, useEffect, useRef, useState } from \"react\";\nimport { Button } from \"../components/ui/button.js\";\nimport { Cropper, CropperArea, type CropperAreaData, CropperImage } from \"../components/ui/cropper.js\";\nimport { Progress } from \"../components/ui/progress.js\";\nimport {\n ResponsiveDialog,\n ResponsiveDialogClose,\n ResponsiveDialogContent,\n ResponsiveDialogDescription,\n ResponsiveDialogFooter,\n ResponsiveDialogHeader,\n ResponsiveDialogTitle\n} from \"../components/ui/responsive-dialog.js\";\nimport { Slider } from \"../components/ui/slider.js\";\nimport { useComitorLabels } from \"../i18n/locale-provider.js\";\nimport { imageUploadFieldEn } from \"../i18n/namespaces/image-upload-field.js\";\nimport { cn } from \"../lib/cn.js\";\nimport 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** — khuôn quen thuộc của ô ảnh đại diện,\n * người dùng nhận ra ngay mà không phải đọc.\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ỡ ảnh (`labels.remove`) 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 /**\n * Nhãn hiển thị. Không truyền thì theo namespace `imageUploadField` của `ComitorLocaleProvider`\n * (không có provider → tiếng Anh); truyền `Partial<>` để đổi từng chuỗi.\n */\n labels?: Partial<ImageUploadFieldLabels>;\n /**\n * Cắt ảnh TRƯỚC khi tải lên — mặc định TẮT. `true` = khung tròn 1:1, xuất JPEG 512px; truyền object để đổi\n * từng thứ. Bật thì ảnh chọn/thả vào mở trong hộp `Cropper`, và chỉ phần đã cắt (vẽ lại trên canvas) được tải\n * lên khi bấm `cropSave`. ⚠ `outputType` phải nằm trong `allowedFileTypes`, vì tệp xuất ra đi qua đúng luật đó.\n */\n crop?: boolean | ImageUploadCropOptions;\n /**\n * Hiện nút `choose` (mặc định `true`). Tắt khi chính tấm ảnh là chỗ bấm — ảnh vẫn mở hộp chọn tệp, và lúc đó\n * nó trở thành nút THẬT cho bàn phím và trình đọc màn hình (tên là `labels.choose`).\n */\n showUploadButton?: boolean;\n /**\n * Hiện nút `remove` khi có ảnh (mặc định `true`). Chưa có ảnh (`hasImage` false) thì nút không bao giờ hiện,\n * bất kể prop này.\n */\n showRemoveButton?: boolean;\n className?: string;\n}\n\n/** Cấu hình của `crop`. */\nexport interface ImageUploadCropOptions {\n /** Tỉ lệ khung cắt. Mặc định `1`. */\n aspectRatio?: number;\n /** Hình khung — CHỈ để nhìn; vùng xuất ra luôn là hình chữ nhật bao ngoài. Mặc định `\"circle\"`. */\n shape?: \"circle\" | \"rectangle\";\n /** Cạnh dài của ảnh xuất ra, px. Mặc định `512`. */\n outputSize?: number;\n /** Định dạng ảnh xuất ra. Mặc định `\"image/jpeg\"`. */\n outputType?: \"image/jpeg\" | \"image/png\" | \"image/webp\";\n /** Chất lượng nén cho JPEG/WebP, 0–1. Mặc định `0.9`. */\n quality?: number;\n}\n\nconst OUTPUT_EXTENSION: Record<NonNullable<ImageUploadCropOptions[\"outputType\"]>, string> = {\n \"image/jpeg\": \"jpg\",\n \"image/png\": \"png\",\n \"image/webp\": \"webp\"\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 crop,\n showUploadButton = true,\n showRemoveButton = true,\n className\n}: ImageUploadFieldProps<T>) {\n const text = useComitorLabels(\"imageUploadField\", labels);\n const cropOptions = crop === true ? {} : crop || null;\n const aspectRatio = cropOptions?.aspectRatio ?? 1;\n\n // Ảnh đang chờ cắt (object URL cùng origin — canvas không bị \"nhiễm\" nên `toBlob` chạy được).\n const [source, setSource] = useState<{ url: string; name: string } | null>(null);\n const [zoom, setZoom] = useState(1);\n const area = useRef<CropperAreaData | null>(null);\n const picture = useRef<HTMLImageElement>(null);\n\n // Object URL giữ nguyên tệp trong bộ nhớ tới khi bị thu hồi.\n useEffect(\n () => () => {\n if (source) URL.revokeObjectURL(source.url);\n },\n [source]\n );\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 function pick(file: File | undefined) {\n if (!file?.type.startsWith(\"image/\")) return;\n area.current = null; // vùng của ảnh cũ vô nghĩa với ảnh mới\n setZoom(1);\n setSource({ url: URL.createObjectURL(file), name: file.name });\n }\n\n async function saveCrop() {\n const image = picture.current;\n if (!image || !area.current || !source) return;\n const outputSize = cropOptions?.outputSize ?? 512;\n const type = cropOptions?.outputType ?? \"image/jpeg\";\n const width = aspectRatio >= 1 ? outputSize : Math.round(outputSize * aspectRatio);\n const height = Math.round(width / aspectRatio);\n const { x, y, width: w, height: h } = area.current;\n const canvas = document.createElement(\"canvas\");\n canvas.width = width;\n canvas.height = height;\n canvas.getContext(\"2d\")?.drawImage(image, x, y, w, h, 0, 0, width, height);\n const blob = await new Promise<Blob | null>((resolve) => canvas.toBlob(resolve, type, cropOptions?.quality ?? 0.9));\n if (!blob) return;\n // Trình duyệt không mã hoá được định dạng xin thì lặng lẽ trả PNG — tin `blob.type`, không tin tham số.\n const extension = OUTPUT_EXTENSION[blob.type as keyof typeof OUTPUT_EXTENSION] ?? \"png\";\n const stem = source.name.replace(/\\.[^.]+$/, \"\") || \"image\";\n setSource(null);\n upload.start([new File([blob], `${stem}.${extension}`, { type: blob.type })]);\n }\n\n // Có `crop` thì tệp chọn/thả đi vào hộp cắt; không có thì đi thẳng vào hàng đợi như cũ.\n const inputProps = cropOptions\n ? {\n ...upload.inputProps,\n onChange: (event: React.ChangeEvent<HTMLInputElement>) => {\n pick(event.target.files?.[0]);\n event.target.value = \"\"; // chọn lại đúng tệp đó phải bắn `change` lần nữa\n }\n }\n : upload.inputProps;\n const dropProps = cropOptions\n ? {\n ...upload.dropProps,\n onDrop: (event: React.DragEvent) => {\n event.preventDefault();\n upload.dropProps.onDragLeave();\n if (!blocked) pick(event.dataTransfer.files[0]);\n }\n }\n : upload.dropProps;\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 {...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 <div\n {...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 {/*\n Bấm vào ảnh cũng mở hộp chọn tệp, như nút \"choose\" — người dùng nhắm vào ảnh trước tiên.\n Còn nút \"choose\" thì ảnh chỉ cho CHUỘT/CHẠM (`tabIndex={-1}` + `aria-hidden`): nút kia đã là đường bàn\n phím và trình đọc màn hình, một điểm dừng Tab thứ hai cùng việc chỉ thêm nhiễu. Ẩn nút đi\n (`showUploadButton={false}`) thì ảnh là đường DUY NHẤT — nó thành nút thật, có tên và vòng focus.\n */}\n <button\n type=\"button\"\n {...(showUploadButton\n ? { tabIndex: -1, \"aria-hidden\": true }\n : { \"aria-label\": upload.busy ? text.uploading : text.choose })}\n disabled={blocked}\n onClick={upload.open}\n className=\"shrink-0 cursor-pointer rounded-md transition-opacity hover:opacity-80 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-ring disabled:cursor-default disabled:hover:opacity-100\"\n >\n {preview}\n </button>\n\n {showUploadButton || (showRemoveButton && hasImage) ? (\n <div className={cn(\"flex flex-wrap items-center gap-2\", blocked && \"opacity-60\")}>\n {showUploadButton ? (\n <Button type=\"button\" variant=\"outline\" size=\"sm\" loading={upload.busy} onClick={upload.open}>\n {upload.busy ? text.uploading : text.choose}\n </Button>\n ) : null}\n {/* Chỉ khi CÓ ảnh — chưa có gì để gỡ thì một nút vô hiệu chỉ là nhiễu. */}\n {showRemoveButton && hasImage ? (\n <Button type=\"button\" variant=\"ghost\" size=\"sm\" disabled={blocked} onClick={onRemove}>\n {text.remove}\n </Button>\n ) : null}\n </div>\n ) : null}\n\n {/*\n Chữ `dropPrompt` 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\n {cropOptions ? (\n <ResponsiveDialog\n open={source !== null}\n onOpenChange={(open) => {\n if (!open) setSource(null);\n }}\n >\n <ResponsiveDialogContent>\n <ResponsiveDialogHeader>\n <ResponsiveDialogTitle>{text.cropTitle ?? imageUploadFieldEn.cropTitle}</ResponsiveDialogTitle>\n <ResponsiveDialogDescription>\n {text.cropDescription ?? imageUploadFieldEn.cropDescription}\n </ResponsiveDialogDescription>\n </ResponsiveDialogHeader>\n {source ? (\n <Cropper\n aspectRatio={aspectRatio}\n shape={cropOptions.shape ?? \"circle\"}\n zoom={zoom}\n onZoomChange={setZoom}\n className=\"min-h-72 rounded-xl\"\n // Trên điện thoại hộp là Drawer của vaul: thiếu dòng này, kéo ảnh xuống cũng kéo luôn hộp đóng lại.\n data-vaul-no-drag=\"\"\n // Giữ vùng MỚI NHẤT — `onCropComplete` bỏ qua lần nạp đầu.\n onCropAreaChange={(_, pixels) => {\n area.current = pixels;\n }}\n >\n <CropperImage ref={picture} src={source.url} alt=\"\" />\n <CropperArea />\n </Cropper>\n ) : null}\n <Slider\n min={1}\n max={3}\n step={0.01}\n value={[zoom]}\n onValueChange={([next]) => setZoom(next ?? 1)}\n aria-label={text.cropZoom ?? imageUploadFieldEn.cropZoom}\n />\n <ResponsiveDialogFooter>\n <ResponsiveDialogClose asChild>\n <Button variant=\"outline\">{text.cropCancel ?? imageUploadFieldEn.cropCancel}</Button>\n </ResponsiveDialogClose>\n <Button onClick={() => void saveCrop()}>{text.cropSave ?? imageUploadFieldEn.cropSave}</Button>\n </ResponsiveDialogFooter>\n </ResponsiveDialogContent>\n </ResponsiveDialog>\n ) : null}\n </div>\n );\n}\n"],"mappings":"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","names":[]}
@@ -1,5 +1,5 @@
1
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';
2
+ export { DEFAULT_FILE_UPLOAD_LABELS, DEFAULT_IMAGE_UPLOAD_FIELD_LABELS, FileUploadLabels, FileUploadLimits, FileUploadLogicLabels, ImageUploadFieldLabels } from './file-upload-constants.js';
3
+ export { ImageUploadCropOptions, ImageUploadField, ImageUploadFieldProps } from './image-upload-field.js';
4
4
  export { FileUploadResult, UseFileUploadOptions, UseFileUploadResult, useFileUpload } from './use-file-upload.js';
5
5
  import 'react';
@@ -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 } 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":[]}
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 FileUploadLimits,\n type FileUploadLogicLabels,\n type ImageUploadFieldLabels\n} from \"./file-upload-constants.js\";\nexport { type ImageUploadCropOptions, 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,OAKK;AACP,SAAsC,wBAAoD;AAC1F;AAAA,EAIE;AAAA,OACK;","names":[]}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@comitor/ui",
3
- "version": "3.12.0",
3
+ "version": "3.13.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",