@nocobase/app-template-default 0.0.0-stage → 1.0.0-beta.57

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 (250) hide show
  1. package/.cursor/mcp.json +8 -0
  2. package/.mcp.json +8 -0
  3. package/.prettierignore +20 -0
  4. package/.vscode/mcp.json +8 -0
  5. package/AGENTS.md +432 -0
  6. package/CLAUDE.md +9 -0
  7. package/Dockerfile +132 -0
  8. package/Dockerfile.dockerignore +45 -0
  9. package/README.MD +481 -0
  10. package/cli/plugins.ts +14 -0
  11. package/client/agent-annotations-host.ts +19 -0
  12. package/client/app.ts +38 -0
  13. package/client/components/back-button.tsx +49 -0
  14. package/client/components/breadcrumbs.tsx +167 -0
  15. package/client/components/inbox-badge.ts +49 -0
  16. package/client/components/inbox-button.tsx +132 -0
  17. package/client/components/inbox-header-button.tsx +25 -0
  18. package/client/components/loading.tsx +35 -0
  19. package/client/components/page-container.tsx +24 -0
  20. package/client/components/page-header.tsx +33 -0
  21. package/client/components/route-child-page.tsx +121 -0
  22. package/client/components/route-dialog.tsx +10 -0
  23. package/client/components/route-drawer.tsx +10 -0
  24. package/client/components/route-overlay.tsx +190 -0
  25. package/client/components/ui/alert.tsx +75 -0
  26. package/client/components/ui/badge.tsx +51 -0
  27. package/client/components/ui/breadcrumb.tsx +121 -0
  28. package/client/components/ui/button.tsx +58 -0
  29. package/client/components/ui/collapsible.tsx +19 -0
  30. package/client/components/ui/context-menu.tsx +271 -0
  31. package/client/components/ui/dialog.tsx +160 -0
  32. package/client/components/ui/dropdown-menu.tsx +270 -0
  33. package/client/components/ui/empty.tsx +103 -0
  34. package/client/components/ui/field.tsx +236 -0
  35. package/client/components/ui/input-group.tsx +158 -0
  36. package/client/components/ui/input.tsx +20 -0
  37. package/client/components/ui/kbd.tsx +26 -0
  38. package/client/components/ui/label.tsx +18 -0
  39. package/client/components/ui/popover.tsx +89 -0
  40. package/client/components/ui/select.tsx +200 -0
  41. package/client/components/ui/separator.tsx +22 -0
  42. package/client/components/ui/sheet.tsx +137 -0
  43. package/client/components/ui/sidebar.tsx +723 -0
  44. package/client/components/ui/skeleton.tsx +13 -0
  45. package/client/components/ui/spinner.tsx +20 -0
  46. package/client/components/ui/tabs.tsx +79 -0
  47. package/client/components/ui/textarea.tsx +17 -0
  48. package/client/components/ui/toast.tsx +231 -0
  49. package/client/components/ui/tooltip.tsx +63 -0
  50. package/client/components/use-route-overlay.ts +26 -0
  51. package/client/config/api.ts +13 -0
  52. package/client/config/app.ts +8 -0
  53. package/client/config/auth.ts +18 -0
  54. package/client/config/index.ts +17 -0
  55. package/client/extensions/nocobase-auth-forms/field-props.ts +13 -0
  56. package/client/extensions/nocobase-auth-forms/form-parts.tsx +101 -0
  57. package/client/extensions/nocobase-auth-forms/password-input.tsx +41 -0
  58. package/client/extensions/nocobase-auth-forms/password-login-form.tsx +123 -0
  59. package/client/extensions/nocobase-auth-forms/password-registration-form.tsx +193 -0
  60. package/client/extensions/nocobase-auth-forms/password-reset-form.tsx +132 -0
  61. package/client/extensions/nocobase-auth-forms/password-reset-request-form.tsx +99 -0
  62. package/client/extensions/nocobase-auth-methods/auth-methods.tsx +170 -0
  63. package/client/extensions/nocobase-auth-split-layout/auth-split-layout.tsx +126 -0
  64. package/client/extensions/nocobase-device-approval/device-approval.tsx +277 -0
  65. package/client/extensions/nocobase-device-approval/locales/en-US.ts +55 -0
  66. package/client/extensions/nocobase-device-approval/locales/zh-CN.ts +27 -0
  67. package/client/extensions/nocobase-device-approval/use-device-approval.ts +176 -0
  68. package/client/extensions/nocobase-file-component-ui/README.md +13 -0
  69. package/client/extensions/nocobase-file-component-ui/components/file-list.tsx +147 -0
  70. package/client/extensions/nocobase-file-component-ui/components/file-preview-dialog.tsx +263 -0
  71. package/client/extensions/nocobase-file-component-ui/components/file-preview-field.tsx +68 -0
  72. package/client/extensions/nocobase-file-component-ui/components/file-thumbnail.tsx +73 -0
  73. package/client/extensions/nocobase-file-component-ui/components/file-upload-field.tsx +386 -0
  74. package/client/extensions/nocobase-file-component-ui/components/previewers/file-preview-content.tsx +255 -0
  75. package/client/extensions/nocobase-file-component-ui/components/previewers/office-open-xml-preview.tsx +226 -0
  76. package/client/extensions/nocobase-file-component-ui/index.ts +8 -0
  77. package/client/extensions/nocobase-file-component-ui/lib/file-preview.ts +194 -0
  78. package/client/extensions/nocobase-file-component-ui/lib/file-url.ts +22 -0
  79. package/client/extensions/nocobase-file-component-ui/lib/message-key.ts +18 -0
  80. package/client/extensions/nocobase-file-component-ui/types.ts +69 -0
  81. package/client/extensions/nocobase-inbox/decision-actions-bar.tsx +140 -0
  82. package/client/extensions/nocobase-inbox/frame.ts +31 -0
  83. package/client/extensions/nocobase-inbox/inbox-actions.ts +21 -0
  84. package/client/extensions/nocobase-inbox/inbox-detail.tsx +386 -0
  85. package/client/extensions/nocobase-inbox/inbox-item.tsx +274 -0
  86. package/client/extensions/nocobase-inbox/inbox-list.tsx +433 -0
  87. package/client/extensions/nocobase-inbox/inbox-page.tsx +545 -0
  88. package/client/extensions/nocobase-inbox/locales/en-US.ts +111 -0
  89. package/client/extensions/nocobase-inbox/locales/zh-CN.ts +57 -0
  90. package/client/extensions/nocobase-inbox/model.ts +428 -0
  91. package/client/extensions/nocobase-inbox/registry-scope.tsx +49 -0
  92. package/client/extensions/nocobase-inbox/registry.ts +303 -0
  93. package/client/extensions/nocobase-inbox/source.ts +117 -0
  94. package/client/hooks/use-mobile.ts +21 -0
  95. package/client/index.tsx +60 -0
  96. package/client/layouts/app-layout.tsx +105 -0
  97. package/client/layouts/components/app-brand.tsx +49 -0
  98. package/client/layouts/components/app-sidebar.tsx +196 -0
  99. package/client/layouts/components/header-actions.tsx +50 -0
  100. package/client/layouts/components/language-switcher.tsx +95 -0
  101. package/client/layouts/components/layout-header.tsx +17 -0
  102. package/client/layouts/components/navigation-menu.tsx +411 -0
  103. package/client/layouts/components/sidebar-footer.tsx +107 -0
  104. package/client/layouts/components/surface-empty.tsx +35 -0
  105. package/client/layouts/components/user-menu.tsx +127 -0
  106. package/client/layouts/index.ts +2 -0
  107. package/client/layouts/settings-layout.tsx +172 -0
  108. package/client/layouts/use-sidebar-preference.ts +57 -0
  109. package/client/lib/constants.ts +5 -0
  110. package/client/lib/toaster.ts +56 -0
  111. package/client/lib/utils.ts +1 -0
  112. package/client/locales/en-US.ts +178 -0
  113. package/client/locales/index.ts +9 -0
  114. package/client/locales/zh-CN.ts +161 -0
  115. package/client/pages/auth/device.tsx +41 -0
  116. package/client/pages/auth/forgot-password.tsx +39 -0
  117. package/client/pages/auth/login.tsx +66 -0
  118. package/client/pages/auth/register.tsx +46 -0
  119. package/client/pages/auth/reset-password.tsx +38 -0
  120. package/client/pages/auth/shared.tsx +111 -0
  121. package/client/pages/home.tsx +16 -0
  122. package/client/pages/inbox.tsx +12 -0
  123. package/client/plugins.ts +41 -0
  124. package/client/react-providers.ts +27 -0
  125. package/client/route-overrides.ts +9 -0
  126. package/client/routes.ts +75 -0
  127. package/client/routing/app-router.tsx +127 -0
  128. package/client/routing/client-route.tsx +129 -0
  129. package/client/routing/route-context.tsx +85 -0
  130. package/client/routing/route-navigation.ts +153 -0
  131. package/client/routing/route-tree.tsx +42 -0
  132. package/client/routing/standalone-page-layout.tsx +15 -0
  133. package/client/runtime.ts +26 -0
  134. package/client/service-provider.ts +40 -0
  135. package/client/source-extensions.ts +19 -0
  136. package/client/startup.tsx +22 -0
  137. package/client/styles.css +122 -0
  138. package/client/theme/index.ts +4 -0
  139. package/client/theme/theme-context.ts +12 -0
  140. package/client/theme/theme-preferences.ts +84 -0
  141. package/client/theme/theme-presets.ts +7 -0
  142. package/client/theme/theme-provider.tsx +106 -0
  143. package/client/theme/theme-settings.tsx +176 -0
  144. package/client/theme/themes/compact.css +140 -0
  145. package/client/theme/themes/default.css +117 -0
  146. package/client/vite-env.d.ts +5 -0
  147. package/components.json +27 -0
  148. package/config.example.yml +172 -0
  149. package/database/main/migrations/.gitkeep +0 -0
  150. package/database/main/migrations/202610060001_create_device_code.ts +37 -0
  151. package/database/main/seeds/.gitkeep +0 -0
  152. package/database/tsconfig.json +4 -0
  153. package/ecosystem.config.js +35 -0
  154. package/eslint.config.js +24 -0
  155. package/index.html +100 -0
  156. package/package.json +200 -3
  157. package/playwright.config.ts +10 -0
  158. package/public/assets/favicon.ico +0 -0
  159. package/public/assets/logo-dark.png +0 -0
  160. package/public/assets/logo-mark-dark.png +0 -0
  161. package/public/assets/logo-mark.png +0 -0
  162. package/public/assets/logo.png +0 -0
  163. package/server/app.ts +50 -0
  164. package/server/config/ai.ts +27 -0
  165. package/server/config/api.ts +8 -0
  166. package/server/config/app.ts +40 -0
  167. package/server/config/auth.ts +36 -0
  168. package/server/config/authorization.ts +18 -0
  169. package/server/config/caching.ts +21 -0
  170. package/server/config/database.ts +22 -0
  171. package/server/config/drive.ts +30 -0
  172. package/server/config/i18n.ts +17 -0
  173. package/server/config/index.ts +73 -0
  174. package/server/config/jobs.ts +27 -0
  175. package/server/config/logging.ts +23 -0
  176. package/server/config/notification.ts +11 -0
  177. package/server/config/queue.ts +28 -0
  178. package/server/config/scheduler.ts +23 -0
  179. package/server/config/secrets.ts +10 -0
  180. package/server/config/server.ts +26 -0
  181. package/server/config/session.ts +60 -0
  182. package/server/config/snowflake.ts +21 -0
  183. package/server/config/spa.ts +24 -0
  184. package/server/config/users.ts +11 -0
  185. package/server/config/workflow.ts +24 -0
  186. package/server/config.ts +24 -0
  187. package/server/embedded.ts +23 -0
  188. package/server/locales/en-US.ts +10 -0
  189. package/server/locales/index.ts +10 -0
  190. package/server/locales/zh-CN.ts +5 -0
  191. package/server/plugins.ts +41 -0
  192. package/server/providers/index.ts +5 -0
  193. package/server/routes/index.ts +6 -0
  194. package/server/runtime.ts +24 -0
  195. package/server/standalone.ts +29 -0
  196. package/server/tsconfig.json +4 -0
  197. package/tailwind.config.mjs +59 -0
  198. package/tests/components/app-layout-routes.test.tsx +132 -0
  199. package/tests/components/app-title.test.tsx +57 -0
  200. package/tests/components/auth-i18n.test.tsx +87 -0
  201. package/tests/components/back-button.test.tsx +80 -0
  202. package/tests/components/file-preview.test.tsx +101 -0
  203. package/tests/components/header-hover.test.tsx +87 -0
  204. package/tests/components/inbox.test.tsx +139 -0
  205. package/tests/components/language-switcher.test.tsx +298 -0
  206. package/tests/components/layout-containers.test.tsx +15 -0
  207. package/tests/components/layout-i18n.test.tsx +187 -0
  208. package/tests/components/loading.test.tsx +25 -0
  209. package/tests/components/navigation-menu.test.tsx +355 -0
  210. package/tests/components/page-harness.test.tsx +318 -0
  211. package/tests/components/primitive-labels.test.tsx +80 -0
  212. package/tests/components/sidebar-permissions.test.tsx +110 -0
  213. package/tests/components/sidebar-preference.test.tsx +42 -0
  214. package/tests/fixtures/theme-tokens.html +12 -0
  215. package/tests/fixtures/theme-tokens.tsx +111 -0
  216. package/tests/logic/account-permissions.test.tsx +204 -0
  217. package/tests/logic/app-locale-coverage.test.ts +136 -0
  218. package/tests/logic/app-server.test.ts +1589 -0
  219. package/tests/logic/asset-url-build.test.ts +106 -0
  220. package/tests/logic/client-auth.test.tsx +122 -0
  221. package/tests/logic/client-plugin-registry.test.ts +63 -0
  222. package/tests/logic/client-route-recovery.test.tsx +80 -0
  223. package/tests/logic/client-routes.test.ts +126 -0
  224. package/tests/logic/client-runtime.test.ts +112 -0
  225. package/tests/logic/client-settings.test.tsx +841 -0
  226. package/tests/logic/client-shell.test.tsx +466 -0
  227. package/tests/logic/client-theme.test.tsx +441 -0
  228. package/tests/logic/config.test.ts +197 -0
  229. package/tests/logic/dev-client-watch.test.ts +108 -0
  230. package/tests/logic/dev-routes-production-build.test.ts +168 -0
  231. package/tests/logic/device-code-migration.test.ts +70 -0
  232. package/tests/logic/lifecycle.test.ts +221 -0
  233. package/tests/logic/plugin-commands.test.ts +69 -0
  234. package/tests/logic/plugin-page-authz.test.ts +76 -0
  235. package/tests/logic/route-navigation.test.ts +66 -0
  236. package/tests/logic/route-overlay.test.tsx +353 -0
  237. package/tests/logic/server-imports.test.ts +88 -0
  238. package/tests/logic/tailwind-sources.test.ts +151 -0
  239. package/tests/logic/template-package-name.test.ts +88 -0
  240. package/tests/logic/theme-preferences.test.ts +86 -0
  241. package/tests/logic/theme-tokens.test.ts +248 -0
  242. package/tests/logic/toaster.test.tsx +108 -0
  243. package/tests/logic/user-menu-sign-out.test.tsx +98 -0
  244. package/tests/setup/client-config.ts +27 -0
  245. package/tsconfig.json +11 -0
  246. package/tsconfig.node.json +12 -0
  247. package/tsconfig.server.json +19 -0
  248. package/vite.config.ts +92 -0
  249. package/vitest.config.ts +37 -0
  250. package/README.md +0 -3
@@ -0,0 +1,73 @@
1
+ import {
2
+ FileAudio,
3
+ FileCode2,
4
+ FileIcon,
5
+ FileImage,
6
+ FileText,
7
+ FileVideo,
8
+ } from 'lucide-react';
9
+ import { useState, type ReactElement } from 'react';
10
+
11
+ import { isSafeImagePreview } from '../lib/file-preview';
12
+ import type { FileThumbnailProps } from '../types';
13
+ import { resolveSafeFileUrl } from '../lib/file-url';
14
+
15
+ function extension(filename: string): string {
16
+ const dot = filename.lastIndexOf('.');
17
+ return dot < 0 ? '' : filename.slice(dot).toLowerCase();
18
+ }
19
+
20
+ function icon(file: FileThumbnailProps['file']): ReactElement {
21
+ if (isSafeImagePreview(file)) return <FileImage aria-hidden='true' />;
22
+ if (file.mimeType.startsWith('audio/'))
23
+ return <FileAudio aria-hidden='true' />;
24
+ if (file.mimeType.startsWith('video/'))
25
+ return <FileVideo aria-hidden='true' />;
26
+ if (
27
+ file.mimeType.startsWith('text/') ||
28
+ file.mimeType === 'application/json' ||
29
+ ['.css', '.html', '.js', '.json', '.md', '.ts', '.tsx', '.xml'].includes(
30
+ extension(file.filename),
31
+ )
32
+ ) {
33
+ return <FileCode2 aria-hidden='true' />;
34
+ }
35
+ if (
36
+ file.mimeType === 'application/pdf' ||
37
+ extension(file.filename) === '.pdf'
38
+ )
39
+ return <FileText aria-hidden='true' />;
40
+ return <FileIcon aria-hidden='true' />;
41
+ }
42
+
43
+ export function FileThumbnail({
44
+ file,
45
+ url,
46
+ alt = file.filename,
47
+ iconOnly = false,
48
+ }: FileThumbnailProps): ReactElement {
49
+ const imageUrl = iconOnly
50
+ ? undefined
51
+ : resolveSafeFileUrl(
52
+ url ?? (isSafeImagePreview(file) ? (file.contentUrl ?? '') : ''),
53
+ );
54
+ // Remember which URL failed rather than a flag, so a new URL is tried again.
55
+ const [failedUrl, setFailedUrl] = useState<string>();
56
+ return imageUrl && imageUrl !== failedUrl ? (
57
+ <img
58
+ data-slot='file-thumbnail'
59
+ src={imageUrl}
60
+ alt={alt}
61
+ className='h-full w-full object-cover'
62
+ onError={() => setFailedUrl(imageUrl)}
63
+ />
64
+ ) : (
65
+ <span
66
+ data-slot='file-thumbnail'
67
+ aria-label={file.filename}
68
+ className='flex h-full w-full items-center justify-center rounded-md bg-muted/50 p-3 text-muted-foreground'
69
+ >
70
+ {icon(file)}
71
+ </span>
72
+ );
73
+ }
@@ -0,0 +1,386 @@
1
+ import { messageKey } from '../lib/message-key.js';
2
+ import { useTranslation } from '@nocobase/i18n/client';
3
+ import { LoaderCircle, RotateCcw, Trash2, UploadCloud, X } from 'lucide-react';
4
+ import {
5
+ useEffect,
6
+ useRef,
7
+ useState,
8
+ type ChangeEvent,
9
+ type DragEvent,
10
+ type ReactElement,
11
+ } from 'react';
12
+
13
+ import type { FileRecord, FileUploadFieldProps } from '../types';
14
+ import { Button } from '#components/ui/button';
15
+ import { FileThumbnail } from './file-thumbnail';
16
+
17
+ type UploadItem = {
18
+ readonly key: string;
19
+ readonly file: File;
20
+ readonly status: 'pending' | 'uploading' | 'error';
21
+ readonly controller?: AbortController;
22
+ readonly error?: Error;
23
+ };
24
+
25
+ function accepts(file: File, rules: readonly string[]): boolean {
26
+ if (!rules.length) return true;
27
+ const name = file.name.toLowerCase();
28
+ return rules.some((rule) => {
29
+ const value = rule.trim().toLowerCase();
30
+ if (value === '*' || value === '*/*') return true;
31
+ return value.endsWith('/*')
32
+ ? file.type.toLowerCase().startsWith(value.slice(0, -1))
33
+ : value.startsWith('.')
34
+ ? name.endsWith(value)
35
+ : file.type.toLowerCase() === value;
36
+ });
37
+ }
38
+
39
+ export function FileUploadField(
40
+ inputProps: FileUploadFieldProps,
41
+ ): ReactElement {
42
+ const { t } = useTranslation('@nocobase/app-plugin-file');
43
+ const {
44
+ repository,
45
+ value,
46
+ onChange,
47
+ onError,
48
+ onStatusChange,
49
+ multiple = false,
50
+ accept = [],
51
+ maxSize,
52
+ maxFiles,
53
+ disabled = false,
54
+ removeOnDelete = false,
55
+ labels,
56
+ } = inputProps;
57
+
58
+ const [items, setItems] = useState<UploadItem[]>([]);
59
+ const [dragging, setDragging] = useState(false);
60
+ const inputRef = useRef<HTMLInputElement>(null);
61
+ const valueRef = useRef(value);
62
+ const completedRecordsRef = useRef<FileRecord[]>([]);
63
+ const commitScheduledRef = useRef(false);
64
+ const statusChangeRef = useRef(onStatusChange);
65
+ const controllersRef = useRef(new Map<string, AbortController>());
66
+ const mountedRef = useRef(true);
67
+ const maximum = multiple ? (maxFiles ?? Infinity) : 1;
68
+ const chooseLabel =
69
+ labels?.choose ??
70
+ (multiple
71
+ ? t('files.chooseMany', { defaultValue: 'Choose files' })
72
+ : t('files.chooseOne', { defaultValue: 'Choose file' }));
73
+ const removeLabel =
74
+ labels?.remove ?? t('files.remove', { defaultValue: 'Remove' });
75
+ const retryLabel =
76
+ labels?.retry ?? t('files.retry', { defaultValue: 'Retry' });
77
+
78
+ useEffect(() => {
79
+ valueRef.current = value;
80
+ }, [value]);
81
+ useEffect(() => {
82
+ statusChangeRef.current = onStatusChange;
83
+ }, [onStatusChange]);
84
+ useEffect(() => {
85
+ onStatusChange?.(
86
+ items.some((item) => item.status === 'error')
87
+ ? 'error'
88
+ : items.length
89
+ ? 'uploading'
90
+ : 'idle',
91
+ );
92
+ }, [items, onStatusChange]);
93
+ useEffect(() => {
94
+ mountedRef.current = true;
95
+ const controllers = controllersRef.current;
96
+ return () => {
97
+ mountedRef.current = false;
98
+ for (const controller of controllers.values()) controller.abort();
99
+ statusChangeRef.current?.('idle');
100
+ };
101
+ }, []);
102
+
103
+ const commitCompletedRecord = (record: FileRecord): void => {
104
+ completedRecordsRef.current.push(record);
105
+ if (commitScheduledRef.current) return;
106
+ commitScheduledRef.current = true;
107
+ queueMicrotask(() => {
108
+ commitScheduledRef.current = false;
109
+ const completed = completedRecordsRef.current.splice(0);
110
+ if (!mountedRef.current || !completed.length) return;
111
+ onChange(
112
+ multiple
113
+ ? [...valueRef.current, ...completed]
114
+ : [completed.at(-1) as FileRecord],
115
+ );
116
+ });
117
+ };
118
+
119
+ const upload = async (item: UploadItem): Promise<void> => {
120
+ const controller = new AbortController();
121
+ controllersRef.current.set(item.key, controller);
122
+ setItems((current) =>
123
+ current.map((candidate) =>
124
+ candidate.key === item.key
125
+ ? { ...candidate, status: 'uploading', controller, error: undefined }
126
+ : candidate,
127
+ ),
128
+ );
129
+ try {
130
+ const { record } = await repository.uploadOne(
131
+ { file: item.file },
132
+ { signal: controller.signal },
133
+ );
134
+ controllersRef.current.delete(item.key);
135
+ if (!mountedRef.current || controller.signal.aborted) return;
136
+ setItems((current) =>
137
+ current.filter((candidate) => candidate.key !== item.key),
138
+ );
139
+ commitCompletedRecord(record);
140
+ } catch (error) {
141
+ controllersRef.current.delete(item.key);
142
+ if (
143
+ controller.signal.aborted ||
144
+ (error instanceof Error && error.name === 'AbortError')
145
+ ) {
146
+ if (mountedRef.current) {
147
+ setItems((current) =>
148
+ current.filter((candidate) => candidate.key !== item.key),
149
+ );
150
+ }
151
+ return;
152
+ }
153
+ if (!mountedRef.current) return;
154
+ const uploadError =
155
+ error instanceof Error ? error : new Error('File upload failed.');
156
+ setItems((current) =>
157
+ current.map((candidate) =>
158
+ candidate.key === item.key
159
+ ? { ...candidate, error: uploadError, status: 'error' }
160
+ : candidate,
161
+ ),
162
+ );
163
+ onError?.(uploadError);
164
+ }
165
+ };
166
+
167
+ const addFiles = (files: readonly File[]): void => {
168
+ if (disabled) return;
169
+ if (files.length + (multiple ? value.length : 0) + items.length > maximum) {
170
+ onError?.(
171
+ new Error(
172
+ t('fileLimitReached', {
173
+ defaultValue: 'The maximum number of files has been reached.',
174
+ }),
175
+ ),
176
+ );
177
+ return;
178
+ }
179
+ const selected = (multiple ? files : files.slice(0, 1)).filter((file) => {
180
+ if (maxSize !== undefined && file.size > maxSize) {
181
+ onError?.(
182
+ new Error(
183
+ t('fileTooLarge', {
184
+ defaultValue: 'File exceeds the maximum size.',
185
+ }),
186
+ ),
187
+ );
188
+ return false;
189
+ }
190
+ if (!accepts(file, accept)) {
191
+ onError?.(
192
+ new Error(
193
+ t('fileTypeNotAllowed', {
194
+ defaultValue: 'File type is not allowed.',
195
+ }),
196
+ ),
197
+ );
198
+ return false;
199
+ }
200
+ return true;
201
+ });
202
+ const nextItems = selected.map((file): UploadItem => ({
203
+ key: `${file.name}:${file.size}:${file.lastModified}:${Math.random()}`,
204
+ file,
205
+ status: 'pending',
206
+ }));
207
+ setItems((current) => [...current, ...nextItems]);
208
+ nextItems.forEach((item) => void upload(item));
209
+ };
210
+
211
+ const removeRecord = async (record: FileRecord): Promise<void> => {
212
+ if (removeOnDelete) {
213
+ try {
214
+ await repository.deleteOne({ filter: { id: record.id } });
215
+ } catch (error) {
216
+ onError?.(
217
+ error instanceof Error
218
+ ? error
219
+ : new Error(
220
+ t('removalFailed', {
221
+ defaultValue: 'File removal failed.',
222
+ }),
223
+ ),
224
+ );
225
+ return;
226
+ }
227
+ }
228
+ const next = valueRef.current.filter(
229
+ (candidate) => candidate.id !== record.id,
230
+ );
231
+ onChange(next);
232
+ };
233
+ const cancel = (item: UploadItem): void => {
234
+ controllersRef.current.get(item.key)?.abort();
235
+ controllersRef.current.delete(item.key);
236
+ setItems((current) =>
237
+ current.filter((candidate) => candidate.key !== item.key),
238
+ );
239
+ };
240
+ const handleChange = (event: ChangeEvent<HTMLInputElement>): void => {
241
+ addFiles(Array.from(event.currentTarget.files ?? []));
242
+ event.currentTarget.value = '';
243
+ };
244
+ const handleDrop = (event: DragEvent<HTMLDivElement>): void => {
245
+ event.preventDefault();
246
+ setDragging(false);
247
+ addFiles(Array.from(event.dataTransfer.files));
248
+ };
249
+
250
+ return (
251
+ <div
252
+ data-slot='file-upload-field'
253
+ className='space-y-3'
254
+ aria-busy={items.some((item) => item.status === 'uploading')}
255
+ >
256
+ <div
257
+ className={`flex min-h-24 flex-wrap gap-3 rounded-md border border-dashed p-3 ${dragging ? 'border-primary bg-primary/5' : ''}`}
258
+ onDragOver={(event) => {
259
+ event.preventDefault();
260
+ setDragging(true);
261
+ }}
262
+ onDragLeave={() => setDragging(false)}
263
+ onDrop={handleDrop}
264
+ >
265
+ {value.map((record) => (
266
+ <div key={record.id} className='w-36 rounded-md border p-2'>
267
+ <div className='flex h-20 items-center justify-center overflow-hidden rounded-sm bg-muted/30'>
268
+ <FileThumbnail file={record} />
269
+ </div>
270
+ <div className='mt-2 truncate text-sm' title={record.filename}>
271
+ {record.filename}
272
+ </div>
273
+ <div className='text-xs text-muted-foreground'>
274
+ {t('files.done', { defaultValue: 'Done' })}
275
+ </div>
276
+ <Button
277
+ type='button'
278
+ size='icon'
279
+ variant='ghost'
280
+ aria-label={`${removeLabel}: ${record.filename}`}
281
+ onClick={() => void removeRecord(record)}
282
+ disabled={disabled}
283
+ >
284
+ <Trash2 aria-hidden='true' />
285
+ </Button>
286
+ </div>
287
+ ))}
288
+ {items.map((item) => (
289
+ <div key={item.key} className='w-36 rounded-md border p-2'>
290
+ <div className='flex h-20 items-center justify-center overflow-hidden rounded-sm bg-muted/30'>
291
+ {item.status === 'uploading' ? (
292
+ <LoaderCircle
293
+ className='animate-spin'
294
+ aria-label={t('files.uploading', {
295
+ defaultValue: 'Uploading',
296
+ })}
297
+ />
298
+ ) : (
299
+ <UploadCloud aria-hidden='true' />
300
+ )}
301
+ </div>
302
+ <div className='mt-2 truncate text-sm' title={item.file.name}>
303
+ {item.file.name}
304
+ </div>
305
+ <div className='text-xs text-muted-foreground'>
306
+ {item.status === 'error'
307
+ ? ((item.error
308
+ ? t(messageKey(item.error.message), {
309
+ defaultValue: item.error.message,
310
+ })
311
+ : undefined) ??
312
+ t('files.failed', { defaultValue: 'Failed' }))
313
+ : item.status === 'uploading'
314
+ ? t('files.uploading', { defaultValue: 'Uploading' })
315
+ : t('files.pending', { defaultValue: 'Pending' })}
316
+ </div>
317
+ <div className='mt-2 flex gap-1'>
318
+ {item.status === 'error' ? (
319
+ <Button
320
+ type='button'
321
+ size='icon'
322
+ variant='ghost'
323
+ aria-label={`${retryLabel}: ${item.file.name}`}
324
+ onClick={() => void upload(item)}
325
+ disabled={disabled}
326
+ >
327
+ <RotateCcw aria-hidden='true' />
328
+ </Button>
329
+ ) : null}
330
+ <Button
331
+ type='button'
332
+ size='icon'
333
+ variant='ghost'
334
+ aria-label={t('files.cancelFile', {
335
+ filename: item.file.name,
336
+ defaultValue: `Cancel ${item.file.name}`,
337
+ })}
338
+ onClick={() => cancel(item)}
339
+ disabled={disabled}
340
+ >
341
+ <X aria-hidden='true' />
342
+ </Button>
343
+ </div>
344
+ </div>
345
+ ))}
346
+ <Button
347
+ type='button'
348
+ variant='outline'
349
+ className='min-h-20 min-w-32'
350
+ onClick={() => inputRef.current?.click()}
351
+ disabled={
352
+ disabled ||
353
+ (multiple
354
+ ? value.length + items.length >= maximum
355
+ : items.length >= 1)
356
+ }
357
+ >
358
+ <UploadCloud aria-hidden='true' />
359
+ {chooseLabel}
360
+ </Button>
361
+ <input
362
+ ref={inputRef}
363
+ className='sr-only'
364
+ type='file'
365
+ multiple={multiple}
366
+ accept={accept.join(',')}
367
+ onChange={handleChange}
368
+ aria-label={chooseLabel}
369
+ disabled={disabled}
370
+ />
371
+ </div>
372
+ <div className='sr-only' aria-live='polite'>
373
+ {[
374
+ ...value.map(
375
+ (record) =>
376
+ `${record.filename}: ${t('files.done', { defaultValue: 'done' })}`,
377
+ ),
378
+ ...items.map(
379
+ (item) =>
380
+ `${item.file.name}: ${t(`files.status.${item.status}`, { defaultValue: item.status })}`,
381
+ ),
382
+ ].join('. ')}
383
+ </div>
384
+ </div>
385
+ );
386
+ }