@comitor/ui 3.12.1 → 3.13.1
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/dist/components/composite/data-table.d.ts +2 -5
- package/dist/components/composite/data-table.js +9 -33
- package/dist/components/composite/data-table.js.map +1 -1
- package/dist/components/composite/form-section.js +5 -5
- package/dist/components/composite/form-section.js.map +1 -1
- package/dist/components/composite/gantt.js +47 -28
- package/dist/components/composite/gantt.js.map +1 -1
- package/dist/components/composite/inline-edit.js +33 -38
- package/dist/components/composite/inline-edit.js.map +1 -1
- package/dist/components/composite/search-filter-bar.js +2 -1
- package/dist/components/composite/search-filter-bar.js.map +1 -1
- package/dist/components/composite/status-config.d.ts +17 -15
- package/dist/components/composite/status-config.js +6 -2
- package/dist/components/composite/status-config.js.map +1 -1
- package/dist/components/ui/breadcrumb.js +1 -4
- package/dist/components/ui/breadcrumb.js.map +1 -1
- package/dist/components/ui/button.js +7 -7
- package/dist/components/ui/button.js.map +1 -1
- package/dist/components/ui/circular-progress.d.ts +9 -4
- package/dist/components/ui/circular-progress.js +58 -24
- package/dist/components/ui/circular-progress.js.map +1 -1
- package/dist/components/ui/progress.d.ts +6 -0
- package/dist/components/ui/progress.js.map +1 -1
- package/dist/components/ui/table.d.ts +7 -13
- package/dist/components/ui/table.js +1 -1
- package/dist/components/ui/table.js.map +1 -1
- package/dist/components/ui/tabs-variants.js +2 -2
- package/dist/components/ui/tabs-variants.js.map +1 -1
- package/dist/components/ui/timeline.js +19 -14
- package/dist/components/ui/timeline.js.map +1 -1
- package/dist/i18n/namespaces/file-upload.js +5 -1
- package/dist/i18n/namespaces/file-upload.js.map +1 -1
- package/dist/i18n/namespaces/image-upload-field.js +10 -0
- package/dist/i18n/namespaces/image-upload-field.js.map +1 -1
- package/dist/index.d.ts +1 -1
- package/dist/uploader/file-upload-constants.d.ts +26 -2
- package/dist/uploader/file-upload-constants.js.map +1 -1
- package/dist/uploader/file-upload.d.ts +18 -1
- package/dist/uploader/file-upload.js +50 -5
- package/dist/uploader/file-upload.js.map +1 -1
- package/dist/uploader/image-upload-field.d.ts +31 -2
- package/dist/uploader/image-upload-field.js +139 -8
- package/dist/uploader/image-upload-field.js.map +1 -1
- package/dist/uploader/index.d.ts +2 -2
- package/dist/uploader/index.js.map +1 -1
- package/package.json +1 -1
|
@@ -1,11 +1,15 @@
|
|
|
1
1
|
const fileUploadEn = {
|
|
2
|
+
dropTitle: "Drag & drop files here",
|
|
3
|
+
dropDescription: ({ maxFiles, maxSize }) => maxFiles > 1 ? `Or click to browse (max ${maxFiles} files, up to ${maxSize} each)` : `Or click to browse (up to ${maxSize})`,
|
|
2
4
|
dropHint: "Drag files here, or",
|
|
3
|
-
choose: "
|
|
5
|
+
choose: "Browse files",
|
|
4
6
|
uploading: "Uploading\u2026",
|
|
5
7
|
uploadFailed: "Upload failed. Try again.",
|
|
6
8
|
missingDependency: "File upload isn't available: the @uppy/core and @uppy/xhr-upload packages are missing."
|
|
7
9
|
};
|
|
8
10
|
const fileUploadVi = {
|
|
11
|
+
dropTitle: "K\xE9o th\u1EA3 t\u1EC7p v\xE0o \u0111\xE2y",
|
|
12
|
+
dropDescription: ({ maxFiles, maxSize }) => maxFiles > 1 ? `Ho\u1EB7c b\u1EA5m \u0111\u1EC3 ch\u1ECDn (t\u1ED1i \u0111a ${maxFiles} t\u1EC7p, m\u1ED7i t\u1EC7p \u0111\u1EBFn ${maxSize})` : `Ho\u1EB7c b\u1EA5m \u0111\u1EC3 ch\u1ECDn (t\u1ED1i \u0111a ${maxSize})`,
|
|
9
13
|
dropHint: "K\xE9o t\u1EC7p v\xE0o \u0111\xE2y, ho\u1EB7c",
|
|
10
14
|
choose: "Ch\u1ECDn t\u1EC7p",
|
|
11
15
|
uploading: "\u0110ang t\u1EA3i l\xEAn\u2026",
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../../../src/i18n/namespaces/file-upload.ts"],"sourcesContent":["import type { FileUploadLabels } from \"../../uploader/file-upload-constants.js\";\n\n/**\n * Chữ của `FileUpload` — và của `useFileUpload` khi hook được gọi thẳng mà không truyền `labels`\n * (hai khoá `uploadFailed` / `missingDependency`). Hình dạng + chú thích từng khoá:\n * `FileUploadLabels`.\n */\nexport const fileUploadEn: FileUploadLabels = {\n dropHint: \"Drag files here, or\",\n choose: \"
|
|
1
|
+
{"version":3,"sources":["../../../src/i18n/namespaces/file-upload.ts"],"sourcesContent":["import type { FileUploadLabels } from \"../../uploader/file-upload-constants.js\";\n\n/**\n * Chữ của `FileUpload` — và của `useFileUpload` khi hook được gọi thẳng mà không truyền `labels`\n * (hai khoá `uploadFailed` / `missingDependency`). Hình dạng + chú thích từng khoá:\n * `FileUploadLabels`.\n */\nexport const fileUploadEn: FileUploadLabels = {\n dropTitle: \"Drag & drop files here\",\n dropDescription: ({ maxFiles, maxSize }) =>\n maxFiles > 1\n ? `Or click to browse (max ${maxFiles} files, up to ${maxSize} each)`\n : `Or click to browse (up to ${maxSize})`,\n dropHint: \"Drag files here, or\",\n choose: \"Browse files\",\n uploading: \"Uploading…\",\n uploadFailed: \"Upload failed. Try again.\",\n missingDependency: \"File upload isn't available: the @uppy/core and @uppy/xhr-upload packages are missing.\"\n};\n\nexport const fileUploadVi: FileUploadLabels = {\n dropTitle: \"Kéo thả tệp vào đây\",\n dropDescription: ({ maxFiles, maxSize }) =>\n maxFiles > 1\n ? `Hoặc bấm để chọn (tối đa ${maxFiles} tệp, mỗi tệp đến ${maxSize})`\n : `Hoặc bấm để chọn (tối đa ${maxSize})`,\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":"AAOO,MAAM,eAAiC;AAAA,EAC5C,WAAW;AAAA,EACX,iBAAiB,CAAC,EAAE,UAAU,QAAQ,MACpC,WAAW,IACP,2BAA2B,QAAQ,iBAAiB,OAAO,WAC3D,6BAA6B,OAAO;AAAA,EAC1C,UAAU;AAAA,EACV,QAAQ;AAAA,EACR,WAAW;AAAA,EACX,cAAc;AAAA,EACd,mBAAmB;AACrB;AAEO,MAAM,eAAiC;AAAA,EAC5C,WAAW;AAAA,EACX,iBAAiB,CAAC,EAAE,UAAU,QAAQ,MACpC,WAAW,IACP,+DAA4B,QAAQ,8CAAqB,OAAO,MAChE,+DAA4B,OAAO;AAAA,EACzC,UAAU;AAAA,EACV,QAAQ;AAAA,EACR,WAAW;AAAA,EACX,cAAc;AAAA,EACd,mBAAmB;AACrB;","names":[]}
|
|
@@ -4,6 +4,11 @@ const imageUploadFieldEn = {
|
|
|
4
4
|
remove: "Remove",
|
|
5
5
|
hint: "Drag an image here or click Upload.",
|
|
6
6
|
dropPrompt: "Drop image here",
|
|
7
|
+
cropTitle: "Crop your picture",
|
|
8
|
+
cropDescription: "Drag to move it. Scroll, pinch or use the slider to zoom.",
|
|
9
|
+
cropZoom: "Zoom",
|
|
10
|
+
cropSave: "Save",
|
|
11
|
+
cropCancel: "Cancel",
|
|
7
12
|
uploadFailed: "Upload failed. Try again.",
|
|
8
13
|
missingDependency: "File upload isn't available: the @uppy/core and @uppy/xhr-upload packages are missing."
|
|
9
14
|
};
|
|
@@ -13,6 +18,11 @@ const imageUploadFieldVi = {
|
|
|
13
18
|
remove: "G\u1EE1",
|
|
14
19
|
hint: "K\xE9o \u1EA3nh v\xE0o \u0111\xE2y ho\u1EB7c b\u1EA5m T\u1EA3i l\xEAn.",
|
|
15
20
|
dropPrompt: "Th\u1EA3 \u1EA3nh v\xE0o \u0111\xE2y",
|
|
21
|
+
cropTitle: "C\u1EAFt \u1EA3nh",
|
|
22
|
+
cropDescription: "K\xE9o \u0111\u1EC3 di chuy\u1EC3n. Cu\u1ED9n, ch\u1EE5m hai ng\xF3n ho\u1EB7c d\xF9ng thanh tr\u01B0\u1EE3t \u0111\u1EC3 ph\xF3ng to.",
|
|
23
|
+
cropZoom: "Ph\xF3ng to",
|
|
24
|
+
cropSave: "L\u01B0u",
|
|
25
|
+
cropCancel: "Hu\u1EF7",
|
|
16
26
|
uploadFailed: "Kh\xF4ng t\u1EA3i l\xEAn \u0111\u01B0\u1EE3c. Th\u1EED l\u1EA1i.",
|
|
17
27
|
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."
|
|
18
28
|
};
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../../../src/i18n/namespaces/image-upload-field.ts"],"sourcesContent":["import type { ImageUploadFieldLabels } from \"../../uploader/file-upload-constants.js\";\n\n/**\n * Chữ của `ImageUploadField`. Hình dạng + chú thích từng khoá: `ImageUploadFieldLabels`.\n *\n * ⚠ `hint` nói CẢ việc kéo-thả được — nó là chỗ duy nhất người dùng học ra điều đó (xem\n * `ImageUploadFieldLabels`). Và nó nhắc đúng chữ của nút `choose`, nên dịch hai khoá cùng nhau.\n */\nexport const imageUploadFieldEn: ImageUploadFieldLabels = {\n choose: \"Upload\",\n uploading: \"Uploading…\",\n remove: \"Remove\",\n hint: \"Drag an image here or click Upload.\",\n dropPrompt: \"Drop image here\",\n uploadFailed: \"Upload failed. Try again.\",\n missingDependency: \"File upload isn't available: the @uppy/core and @uppy/xhr-upload packages are missing.\"\n};\n\nexport const imageUploadFieldVi: 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":"AAQO,MAAM,qBAA6C;AAAA,EACxD,QAAQ;AAAA,EACR,WAAW;AAAA,EACX,QAAQ;AAAA,EACR,MAAM;AAAA,EACN,YAAY;AAAA,EACZ,cAAc;AAAA,EACd,mBAAmB;AACrB;AAEO,MAAM,qBAA6C;AAAA,EACxD,QAAQ;AAAA,EACR,WAAW;AAAA,EACX,QAAQ;AAAA,EACR,MAAM;AAAA,EACN,YAAY;AAAA,EACZ,cAAc;AAAA,EACd,mBAAmB;AACrB;","names":[]}
|
|
1
|
+
{"version":3,"sources":["../../../src/i18n/namespaces/image-upload-field.ts"],"sourcesContent":["import type { ImageUploadFieldLabels } from \"../../uploader/file-upload-constants.js\";\n\n/**\n * Chữ của `ImageUploadField`. Hình dạng + chú thích từng khoá: `ImageUploadFieldLabels`.\n *\n * ⚠ `hint` nói CẢ việc kéo-thả được — nó là chỗ duy nhất người dùng học ra điều đó (xem\n * `ImageUploadFieldLabels`). Và nó nhắc đúng chữ của nút `choose`, nên dịch hai khoá cùng nhau.\n */\nexport const imageUploadFieldEn: ImageUploadFieldLabels = {\n choose: \"Upload\",\n uploading: \"Uploading…\",\n remove: \"Remove\",\n hint: \"Drag an image here or click Upload.\",\n dropPrompt: \"Drop image here\",\n cropTitle: \"Crop your picture\",\n cropDescription: \"Drag to move it. Scroll, pinch or use the slider to zoom.\",\n cropZoom: \"Zoom\",\n cropSave: \"Save\",\n cropCancel: \"Cancel\",\n uploadFailed: \"Upload failed. Try again.\",\n missingDependency: \"File upload isn't available: the @uppy/core and @uppy/xhr-upload packages are missing.\"\n};\n\nexport const imageUploadFieldVi: 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 cropTitle: \"Cắt ảnh\",\n cropDescription: \"Kéo để di chuyển. Cuộn, chụm hai ngón hoặc dùng thanh trượt để phóng to.\",\n cropZoom: \"Phóng to\",\n cropSave: \"Lưu\",\n cropCancel: \"Huỷ\",\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":"AAQO,MAAM,qBAA6C;AAAA,EACxD,QAAQ;AAAA,EACR,WAAW;AAAA,EACX,QAAQ;AAAA,EACR,MAAM;AAAA,EACN,YAAY;AAAA,EACZ,WAAW;AAAA,EACX,iBAAiB;AAAA,EACjB,UAAU;AAAA,EACV,UAAU;AAAA,EACV,YAAY;AAAA,EACZ,cAAc;AAAA,EACd,mBAAmB;AACrB;AAEO,MAAM,qBAA6C;AAAA,EACxD,QAAQ;AAAA,EACR,WAAW;AAAA,EACX,QAAQ;AAAA,EACR,MAAM;AAAA,EACN,YAAY;AAAA,EACZ,WAAW;AAAA,EACX,iBAAiB;AAAA,EACjB,UAAU;AAAA,EACV,UAAU;AAAA,EACV,YAAY;AAAA,EACZ,cAAc;AAAA,EACd,mBAAmB;AACrB;","names":[]}
|
package/dist/index.d.ts
CHANGED
|
@@ -54,7 +54,7 @@ export { Calendar, CalendarDayButton, CalendarDayButtonProps, CalendarProps } fr
|
|
|
54
54
|
export { Card, CardAction, CardActionProps, CardContent, CardContentProps, CardDescription, CardDescriptionProps, CardFooter, CardFooterProps, CardHeader, CardHeaderProps, CardProps, CardTitle, CardTitleProps, cardVariants } from './components/ui/card.js';
|
|
55
55
|
export { Carousel, CarouselApi, CarouselContent, CarouselContentProps, CarouselContextProps, CarouselItem, CarouselItemProps, CarouselNext, CarouselNextProps, CarouselOptions, CarouselOwnProps, CarouselPlugin, CarouselPrevious, CarouselPreviousProps, CarouselProps, useCarousel } from './components/ui/carousel.js';
|
|
56
56
|
export { Checkbox, CheckboxProps } from './components/ui/checkbox.js';
|
|
57
|
-
export { CircularProgress, CircularProgressCombined, CircularProgressIndicator, CircularProgressProps, CircularProgressRange, CircularProgressTrack, CircularProgressValueText, CircularProgressValueTextProps } from './components/ui/circular-progress.js';
|
|
57
|
+
export { CircularProgress, CircularProgressCombined, CircularProgressIndicator, CircularProgressProps, CircularProgressRange, CircularProgressTone, CircularProgressTrack, CircularProgressValueText, CircularProgressValueTextProps } from './components/ui/circular-progress.js';
|
|
58
58
|
export { Collapsible, CollapsibleContent, CollapsibleContentProps, CollapsibleProps, CollapsibleTrigger, CollapsibleTriggerProps } from './components/ui/collapsible.js';
|
|
59
59
|
export { Command, CommandDialog, CommandDialogProps, CommandEmpty, CommandEmptyProps, CommandGroup, CommandGroupProps, CommandInput, CommandInputProps, CommandItem, CommandItemProps, CommandList, CommandListProps, CommandProps, CommandSeparator, CommandSeparatorProps, CommandShortcut, CommandShortcutProps } from './components/ui/command.js';
|
|
60
60
|
export { ContextMenu, ContextMenuCheckboxItem, ContextMenuCheckboxItemProps, ContextMenuContent, ContextMenuContentProps, ContextMenuGroup, ContextMenuGroupProps, ContextMenuItem, ContextMenuItemProps, ContextMenuItemVariant, ContextMenuLabel, ContextMenuLabelProps, ContextMenuPortal, ContextMenuPortalProps, ContextMenuProps, ContextMenuRadioGroup, ContextMenuRadioGroupProps, ContextMenuRadioItem, ContextMenuRadioItemProps, ContextMenuSeparator, ContextMenuSeparatorProps, ContextMenuShortcut, ContextMenuShortcutProps, ContextMenuSub, ContextMenuSubContent, ContextMenuSubContentProps, ContextMenuSubProps, ContextMenuSubTrigger, ContextMenuSubTriggerProps, ContextMenuTrigger, ContextMenuTriggerProps } from './components/ui/context-menu.js';
|
|
@@ -25,8 +25,22 @@ interface FileUploadLogicLabels {
|
|
|
25
25
|
*/
|
|
26
26
|
missingDependency: string;
|
|
27
27
|
}
|
|
28
|
+
/** Giới hạn đã định dạng sẵn, đưa vào `dropDescription`. */
|
|
29
|
+
interface FileUploadLimits {
|
|
30
|
+
/** `maxNumberOfFiles` của component. */
|
|
31
|
+
maxFiles: number;
|
|
32
|
+
/** `maxFileSize` đã đổi sang chữ theo locale, vd `"5 MB"`. */
|
|
33
|
+
maxSize: string;
|
|
34
|
+
}
|
|
28
35
|
interface FileUploadLabels extends FileUploadLogicLabels {
|
|
29
|
-
/**
|
|
36
|
+
/**
|
|
37
|
+
* Dòng tiêu đề của vùng kéo thả (bố cục mặc định). TUỲ CHỌN trong kiểu — thêm sau `3.12.1`, để bảng nhãn
|
|
38
|
+
* app tự dựng đủ khoá từ trước không gãy type-check; thiếu thì dùng bản tiếng Anh.
|
|
39
|
+
*/
|
|
40
|
+
dropTitle?: string;
|
|
41
|
+
/** Dòng phụ dưới tiêu đề — nhận giới hạn để nói "tối đa mấy tệp, mỗi tệp bao nhiêu". Tuỳ chọn như `dropTitle`. */
|
|
42
|
+
dropDescription?: (limits: FileUploadLimits) => string;
|
|
43
|
+
/** Chữ của bố cục `compact` (một dòng chữ + nút). */
|
|
30
44
|
dropHint: string;
|
|
31
45
|
/** Nhãn nút mở hộp chọn tệp. */
|
|
32
46
|
choose: string;
|
|
@@ -56,6 +70,16 @@ interface ImageUploadFieldLabels extends FileUploadLogicLabels {
|
|
|
56
70
|
hint: string;
|
|
57
71
|
/** Chữ hiện đè lên khối khi đang rê tệp qua. */
|
|
58
72
|
dropPrompt: string;
|
|
73
|
+
/** Tiêu đề hộp cắt ảnh. */
|
|
74
|
+
cropTitle?: string;
|
|
75
|
+
/** Dòng hướng dẫn dưới tiêu đề. */
|
|
76
|
+
cropDescription?: string;
|
|
77
|
+
/** `aria-label` của thanh trượt phóng to. */
|
|
78
|
+
cropZoom?: string;
|
|
79
|
+
/** Nút xác nhận — cắt rồi tải lên. */
|
|
80
|
+
cropSave?: string;
|
|
81
|
+
/** Nút huỷ. */
|
|
82
|
+
cropCancel?: string;
|
|
59
83
|
}
|
|
60
84
|
/**
|
|
61
85
|
* Nhãn TIẾNG ANH mặc định của `ImageUploadField` (từ `3.0.0`; trước đó là tiếng Việt). Bảng tiếng
|
|
@@ -64,4 +88,4 @@ interface ImageUploadFieldLabels extends FileUploadLogicLabels {
|
|
|
64
88
|
*/
|
|
65
89
|
declare const DEFAULT_IMAGE_UPLOAD_FIELD_LABELS: ImageUploadFieldLabels;
|
|
66
90
|
|
|
67
|
-
export { DEFAULT_FILE_UPLOAD_LABELS, DEFAULT_IMAGE_UPLOAD_FIELD_LABELS, type FileUploadLabels, type FileUploadLogicLabels, type ImageUploadFieldLabels };
|
|
91
|
+
export { DEFAULT_FILE_UPLOAD_LABELS, DEFAULT_IMAGE_UPLOAD_FIELD_LABELS, type FileUploadLabels, type FileUploadLimits, type FileUploadLogicLabels, type ImageUploadFieldLabels };
|
|
@@ -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 * Chữ theo `ComitorLocaleProvider` (namespace `fileUpload` / `imageUploadField`, mặc định tiếng\n * Anh, bảng tiếng Việt dựng sẵn); override từng khoá qua prop `labels`.\n */\n\nimport { fileUploadEn } from \"../i18n/namespaces/file-upload.js\";\nimport { imageUploadFieldEn } from \"../i18n/namespaces/image-upload-field.js\";\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
|
|
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 * Chữ theo `ComitorLocaleProvider` (namespace `fileUpload` / `imageUploadField`, mặc định tiếng\n * Anh, bảng tiếng Việt dựng sẵn); override từng khoá qua prop `labels`.\n */\n\nimport { fileUploadEn } from \"../i18n/namespaces/file-upload.js\";\nimport { imageUploadFieldEn } from \"../i18n/namespaces/image-upload-field.js\";\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\n/** Giới hạn đã định dạng sẵn, đưa vào `dropDescription`. */\nexport interface FileUploadLimits {\n /** `maxNumberOfFiles` của component. */\n maxFiles: number;\n /** `maxFileSize` đã đổi sang chữ theo locale, vd `\"5 MB\"`. */\n maxSize: string;\n}\n\nexport interface FileUploadLabels extends FileUploadLogicLabels {\n /**\n * Dòng tiêu đề của vùng kéo thả (bố cục mặc định). TUỲ CHỌN trong kiểu — thêm sau `3.12.1`, để bảng nhãn\n * app tự dựng đủ khoá từ trước không gãy type-check; thiếu thì dùng bản tiếng Anh.\n */\n dropTitle?: string;\n /** Dòng phụ dưới tiêu đề — nhận giới hạn để nói \"tối đa mấy tệp, mỗi tệp bao nhiêu\". Tuỳ chọn như `dropTitle`. */\n dropDescription?: (limits: FileUploadLimits) => string;\n /** Chữ của bố cục `compact` (một dòng chữ + nút). */\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\n/**\n * Nhãn TIẾNG ANH mặc định của `FileUpload` (từ `3.0.0`; trước đó là tiếng Việt). Bảng tiếng Việt:\n * `comitorMessagesVi.fileUpload`. Component (và `useFileUpload`) không đọc thẳng hằng này — chúng\n * đọc bảng của `ComitorLocaleProvider`; hằng ở đây để app trải ra khi tự dựng nhãn.\n */\nexport const DEFAULT_FILE_UPLOAD_LABELS: FileUploadLabels = { ...fileUploadEn };\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 * Năm khoá của hộp cắt ảnh (`crop`). TUỲ CHỌN trong kiểu — thêm sau `3.12.1`, để bảng nhãn app tự dựng\n * đủ khoá từ trước không gãy type-check; thiếu thì dùng bản tiếng Anh.\n */\n /** Tiêu đề hộp cắt ảnh. */\n cropTitle?: string;\n /** Dòng hướng dẫn dưới tiêu đề. */\n cropDescription?: string;\n /** `aria-label` của thanh trượt phóng to. */\n cropZoom?: string;\n /** Nút xác nhận — cắt rồi tải lên. */\n cropSave?: string;\n /** Nút huỷ. */\n cropCancel?: string;\n}\n\n/**\n * Nhãn TIẾNG ANH mặc định của `ImageUploadField` (từ `3.0.0`; trước đó là tiếng Việt). Bảng tiếng\n * Việt: `comitorMessagesVi.imageUploadField`. Component không đọc thẳng hằng này — nó đọc bảng của\n * `ComitorLocaleProvider`; hằng ở đây để app trải ra khi tự dựng nhãn.\n */\nexport const DEFAULT_IMAGE_UPLOAD_FIELD_LABELS: ImageUploadFieldLabels = { ...imageUploadFieldEn };\n"],"mappings":"AAWA,SAAS,oBAAoB;AAC7B,SAAS,0BAA0B;AAiD5B,MAAM,6BAA+C,EAAE,GAAG,aAAa;AAwCvE,MAAM,oCAA4D,EAAE,GAAG,mBAAmB;","names":[]}
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import * as React from 'react';
|
|
2
|
+
import { ReactNode } from 'react';
|
|
2
3
|
import { FileUploadLabels } from './file-upload-constants.js';
|
|
3
4
|
import { FileUploadResult } from './use-file-upload.js';
|
|
4
5
|
|
|
@@ -60,9 +61,25 @@ interface FileUploadProps<T = unknown> {
|
|
|
60
61
|
* (không có provider → tiếng Anh); truyền `Partial<>` để đổi từng chuỗi.
|
|
61
62
|
*/
|
|
62
63
|
labels?: Partial<FileUploadLabels>;
|
|
64
|
+
/**
|
|
65
|
+
* `"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ữ +
|
|
66
|
+
* 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.
|
|
67
|
+
*/
|
|
68
|
+
layout?: "default" | "compact";
|
|
69
|
+
/** Icon trong vòng tròn (bố cục `default`). Mặc định `ImageIcon` của lucide; `null` = bỏ cả vòng tròn. */
|
|
70
|
+
icon?: ReactNode | null;
|
|
71
|
+
/** Dòng tiêu đề. Mặc định `labels.dropTitle`; `null` = ẩn. */
|
|
72
|
+
title?: ReactNode | null;
|
|
73
|
+
/** Dòng phụ. Mặc định `labels.dropDescription` dựng từ giới hạn; `null` = ẩn. */
|
|
74
|
+
description?: ReactNode | null;
|
|
75
|
+
/**
|
|
76
|
+
* 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 —
|
|
77
|
+
* vùng nhận vòng focus thay cho nút.
|
|
78
|
+
*/
|
|
79
|
+
showButton?: boolean;
|
|
63
80
|
disabled?: boolean;
|
|
64
81
|
className?: string;
|
|
65
82
|
}
|
|
66
|
-
declare function FileUpload<T = unknown>({ endpoint, fieldName, maxFileSize, allowedFileTypes, maxNumberOfFiles, onUploaded, onError, labels, disabled, className }: FileUploadProps<T>): React.JSX.Element;
|
|
83
|
+
declare function FileUpload<T = unknown>({ endpoint, fieldName, maxFileSize, allowedFileTypes, maxNumberOfFiles, onUploaded, onError, labels, layout, icon, title, description, showButton, disabled, className }: FileUploadProps<T>): React.JSX.Element;
|
|
67
84
|
|
|
68
85
|
export { FileUpload, type FileUploadProps };
|
|
@@ -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
|
|
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",
|
|
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
|
-
|
|
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":";AA6GS,cAiGC,YAjGD;AA3GT,SAAS,iBAAiB;AAC1B,SAAyB,aAAa;AACtC,SAAS,cAAc;AACvB,SAAS,gBAAgB;AACzB,SAAS,kBAAkB,wBAAwB;AACnD,SAAS,oBAAoB;AAC7B,SAAS,UAAU;AAEnB,SAAgC,qBAAqB;AA8ErD,SAAS,WAAW,OAAe,QAAwB;AACzD,QAAM,QAAQ,CAAC,MAAM,MAAM,IAAI;AAC/B,MAAI,QAAQ,QAAQ;AACpB,MAAI,OAAO;AACX,SAAO,SAAS,QAAQ,OAAO,MAAM,SAAS,GAAG;AAC/C,aAAS;AACT;AAAA,EACF;AACA,SAAO,GAAG,IAAI,KAAK,aAAa,QAAQ,EAAE,uBAAuB,EAAE,CAAC,EAAE,OAAO,KAAK,CAAC,IAAI,MAAM,IAAI,CAAC;AACpG;AAEO,SAAS,WAAwB;AAAA,EACtC;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA,mBAAmB;AAAA,EACnB;AAAA,EACA;AAAA,EACA;AAAA,EACA,SAAS;AAAA,EACT,OAAO,oBAAC,aAAU;AAAA,EAClB;AAAA,EACA;AAAA,EACA,aAAa;AAAA,EACb,WAAW;AAAA,EACX;AACF,GAAuB;AACrB,QAAM,OAAO,iBAAiB,cAAc,MAAM;AAClD,QAAM,EAAE,OAAO,IAAI,iBAAiB;AACpC,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,QAAM,UAAU,YAAY,OAAO;AACnC,QAAM,UAAU,WAAW;AAE3B,QAAM,WAAW,KAAK,mBAAmB,aAAa;AACtD,QAAM,YAAY,UAAU,SAAa,KAAK,aAAa,aAAa,YAAa;AACrF,QAAM,kBACJ,gBAAgB,SACZ,WAAW,EAAE,UAAU,kBAAkB,SAAS,WAAW,aAAa,MAAM,EAAE,CAAC,IACnF;AAEN,QAAM,SAAS,aACb,oBAAC,UAAO,SAAO,MAAC,SAAQ,WAAU,MAAK,MAAK,UAAU,SAAS,SAAS,OAAO,MAC7E,8BAAC,WAAM,SAAS,SAAS,WAAW,UAAU,wBAAwB,kBACnE,iBAAO,OAAO,KAAK,YAAY,KAAK,QACvC,GACF,IACE;AAEJ,SACE,qBAAC,SAAI,WAAW,GAAG,uBAAuB,SAAS,GAKjD;AAAA,wBAAC,WAAO,GAAG,OAAO,YAAY,IAAI,SAAS,WAAU,gBAAe;AAAA,IAQpE;AAAA,MAAC;AAAA;AAAA,QACE,GAAG,OAAO;AAAA,QACX,SAAS,CAAC,UAAU;AAClB,cAAI,WAAY,MAAM,OAAuB,QAAQ,OAAO,EAAG;AAC/D,iBAAO,KAAK;AAAA,QACd;AAAA,QAaA,WAAW;AAAA,UACT;AAAA,UACA,UAAU,cAAc;AAAA,UACxB,CAAC,WAAW;AAAA,UACZ,CAAC,cACC;AAAA,UACF,OAAO,WAAW,uBAAuB;AAAA,QAC3C;AAAA,QAWC,oBACC,qBAAC,SAAI,WAAW,GAAG,oCAAoC,WAAW,YAAY,GAC5E;AAAA,8BAAC,OAAE,WAAU,iCAAiC,eAAK,UAAS;AAAA,UAC3D;AAAA,WACH,IAEA,qBAAC,SAAI,WAAW,GAAG,oCAAoC,WAAW,YAAY,GAC3E;AAAA,mBAAS,OAAO,OACf;AAAA,YAAC;AAAA;AAAA,cACC,eAAY;AAAA,cACZ,WAAU;AAAA,cAET;AAAA;AAAA,UACH;AAAA,UAED,cAAc,QAAQ,oBAAoB,OAAO,OAChD,qBAAC,SAAI,WAAU,oCACZ;AAAA,0BAAc,OAAO,OAAO,oBAAC,OAAE,WAAU,uCAAuC,qBAAU;AAAA,YAC1F,oBAAoB,OAAO,OAAO,oBAAC,OAAE,WAAU,iCAAiC,2BAAgB;AAAA,aACnG;AAAA,UAED;AAAA,WACH;AAAA;AAAA,IAEJ;AAAA,IAGC,OAAO,OAAO,oBAAC,YAAS,OAAO,OAAO,SAAS,cAAY,KAAK,WAAW,IAAK;AAAA,KACnF;AAEJ;","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
|
-
|
|
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", { ...
|
|
111
|
+
/* @__PURE__ */ jsx("input", { ...inputProps, className: "sr-only" }),
|
|
39
112
|
/* @__PURE__ */ jsxs(
|
|
40
113
|
"div",
|
|
41
114
|
{
|
|
42
|
-
...
|
|
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
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
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 {
|