@nocobase/app-template-default 1.0.0-beta.88 → 1.0.0-beta.90

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 (103) hide show
  1. package/.agents/rules/components.md +3 -1
  2. package/.agents/rules/shell.md +12 -2
  3. package/AGENTS.md +1 -1
  4. package/LICENSE.txt +129 -0
  5. package/client/components/back-button.tsx +37 -12
  6. package/client/components/brand-loading-mark.tsx +5 -2
  7. package/client/components/brand-mark-geometry.ts +3 -3
  8. package/client/components/breadcrumbs.tsx +47 -3
  9. package/client/components/data-table/data-table.tsx +90 -19
  10. package/client/components/data-table/pagination.tsx +17 -2
  11. package/client/components/list-feedback.tsx +31 -22
  12. package/client/components/loading.tsx +127 -16
  13. package/client/components/pending-button.tsx +111 -0
  14. package/client/components/role-list.tsx +7 -0
  15. package/client/components/route-child-page.tsx +72 -1
  16. package/client/components/route-overlay.tsx +149 -15
  17. package/client/components/task-progress.tsx +155 -0
  18. package/client/components/use-route-overlay.ts +8 -11
  19. package/client/extensions/nocobase-file-component-ui/components/file-upload-field.tsx +62 -32
  20. package/client/extensions/nocobase-file-component-ui/locales/de-DE.ts +2 -0
  21. package/client/extensions/nocobase-file-component-ui/locales/en-US.ts +1 -0
  22. package/client/extensions/nocobase-file-component-ui/locales/es-ES.ts +1 -0
  23. package/client/extensions/nocobase-file-component-ui/locales/fr-FR.ts +1 -0
  24. package/client/extensions/nocobase-file-component-ui/locales/id-ID.ts +1 -0
  25. package/client/extensions/nocobase-file-component-ui/locales/ja-JP.ts +2 -0
  26. package/client/extensions/nocobase-file-component-ui/locales/pt-BR.ts +1 -0
  27. package/client/extensions/nocobase-file-component-ui/locales/vi-VN.ts +1 -0
  28. package/client/extensions/nocobase-file-component-ui/locales/zh-CN.ts +1 -0
  29. package/client/extensions/nocobase-file-component-ui/locales/zh-TW.ts +1 -0
  30. package/client/extensions/nocobase-workflow-management/README.md +5 -5
  31. package/client/extensions/nocobase-workflow-management/definition-list.tsx +2 -9
  32. package/client/extensions/nocobase-workflow-management/execution-list.tsx +2 -4
  33. package/client/layouts/app-layout.tsx +0 -4
  34. package/client/lib/navigation-transform.ts +29 -1
  35. package/client/locales/system/en-US.ts +23 -0
  36. package/client/locales/system/zh-CN.ts +23 -0
  37. package/client/pages/home/index.tsx +66 -51
  38. package/client/pages/settings/database-explorer/en-US.ts +33 -0
  39. package/client/pages/settings/database-explorer/index.tsx +358 -0
  40. package/client/pages/settings/database-explorer/routes.ts +14 -0
  41. package/client/pages/settings/database-explorer/zh-CN.ts +30 -0
  42. package/client/pages/settings/default-access.tsx +1 -1
  43. package/client/pages/settings/roles.tsx +27 -27
  44. package/client/pages/settings/routes.ts +15 -7
  45. package/client/pages/settings/user-detail.tsx +3 -17
  46. package/client/pages/settings/user-form.tsx +9 -15
  47. package/client/pages/settings/users.tsx +11 -27
  48. package/client/plugins.ts +8 -1
  49. package/client/routes.ts +2 -1
  50. package/client/routing/client-route.tsx +199 -37
  51. package/client/routing/navigation-destination.ts +42 -0
  52. package/client/routing/route-leave-boundary.tsx +13 -1
  53. package/client/styles.css +18 -1
  54. package/config.example.yml +17 -11
  55. package/index.html +8 -11
  56. package/package.json +27 -27
  57. package/server/config/auth.ts +1 -1
  58. package/server/config/index.ts +3 -0
  59. package/server/config/jobs.ts +16 -8
  60. package/server/config/queue.ts +14 -13
  61. package/server/config/realtime.ts +4 -0
  62. package/tests/client/auth-i18n.test.tsx +1 -1
  63. package/tests/client/authenticated-client-fixture.test.tsx +37 -0
  64. package/tests/client/back-button.test.tsx +34 -0
  65. package/tests/client/client-route-recovery.test.tsx +93 -1
  66. package/tests/client/client-routes.test.ts +5 -3
  67. package/tests/client/database-explorer-integration.test.tsx +30 -0
  68. package/tests/client/database-explorer.test.tsx +152 -0
  69. package/tests/client/foundation-integration.test.ts +42 -29
  70. package/tests/client/home.test.tsx +26 -85
  71. package/tests/client/layout-i18n.test.tsx +83 -2
  72. package/tests/client/list-feedback.test.tsx +5 -5
  73. package/tests/client/list-loading.test.tsx +397 -0
  74. package/tests/client/loading.test.tsx +20 -11
  75. package/tests/client/manual-list-retry.test.tsx +2 -2
  76. package/tests/client/navigation-parent.test.tsx +172 -0
  77. package/tests/client/pending-button.test.tsx +106 -0
  78. package/tests/client/permission-management-tabs.test.tsx +48 -12
  79. package/tests/client/primitive-labels.test.tsx +2 -2
  80. package/tests/client/role-return.test.tsx +21 -3
  81. package/tests/client/roles.test.tsx +84 -3
  82. package/tests/client/route-leave-recheck.test.tsx +74 -0
  83. package/tests/client/route-presentation.test.tsx +557 -0
  84. package/tests/client/session-preparation.test.tsx +20 -17
  85. package/tests/client/settings-continuity.test.tsx +35 -11
  86. package/tests/client/settings-home.test.tsx +4 -2
  87. package/tests/client/settings-idle-list.test.tsx +124 -0
  88. package/tests/client/settings-navigation.test.ts +20 -0
  89. package/tests/client/user-drawer.test.tsx +3 -4
  90. package/tests/fixtures/dialog-footer.html +12 -0
  91. package/tests/fixtures/dialog-footer.tsx +215 -0
  92. package/tests/helpers/authenticated-client.tsx +77 -22
  93. package/tests/playwright/dialog-footer.test.ts +227 -0
  94. package/tests/project/bootstrap-loading.test.ts +60 -0
  95. package/tests/server/app-server.test.ts +18 -12
  96. package/tests/server/auth-email-config.test.ts +21 -0
  97. package/tests/server/backends-config.test.ts +118 -0
  98. package/tests/server/concurrency.test.ts +1 -1
  99. package/tests/server/config.test.ts +22 -14
  100. package/tests/server/lifecycle.test.ts +2 -2
  101. package/tests/server/realtime-config.test.ts +114 -0
  102. package/client/pages/home/capabilities.ts +0 -62
  103. package/client/pages/home/prompt-dialog.tsx +0 -122
@@ -26,10 +26,14 @@ import {
26
26
  DialogPortal,
27
27
  DialogTitle,
28
28
  } from '#components/ui/dialog';
29
+ import {
30
+ RouteOverlayContext,
31
+ RouteOverlayHostContext,
32
+ type RouteOverlayHostConfig,
33
+ type RouteOverlayHostValue,
34
+ } from '@nocobase/app-client/route-containers';
29
35
  import { cn } from 'cn';
30
36
 
31
- import { RouteOverlayContext } from './use-route-overlay.js';
32
-
33
37
  export interface RouteOverlayProps {
34
38
  readonly title: ReactNode;
35
39
  readonly description?: ReactNode;
@@ -52,8 +56,103 @@ const ParentPopupContext =
52
56
  /**
53
57
  * The shared implementation of `RouteDialog` and `RouteDrawer`: a modal that is open for as long as its route matches,
54
58
  * and closes by navigating away from it. Route registration stays with the application.
59
+ *
60
+ * On a route that declares a `dialog` or `drawer` presentation the application has already opened the panel, and this
61
+ * component configures that one instead of opening a second: its title, description, footer, `closeTo` and
62
+ * `beforeClose` move to the host and its children render in the host's body. On any other route it opens its own.
63
+ * The host context comes from `@nocobase/app-client`, so a plugin's own copy of this component joins the same panel.
64
+ */
65
+ export function RouteOverlay(
66
+ props: RouteOverlayProps & { readonly drawer?: boolean },
67
+ ): ReactElement {
68
+ const host = useContext(RouteOverlayHostContext);
69
+ return host ? (
70
+ <HostedRouteOverlay host={host} {...props} />
71
+ ) : (
72
+ <RouteOverlayFrame {...props} />
73
+ );
74
+ }
75
+
76
+ function HostedRouteOverlay({
77
+ host,
78
+ children,
79
+ drawer: _drawer,
80
+ ...config
81
+ }: RouteOverlayProps & {
82
+ readonly host: RouteOverlayHostValue;
83
+ readonly drawer?: boolean;
84
+ }): ReactElement {
85
+ // Runs after every render so the footer and guard the host shows are always this render's. There is no cleanup:
86
+ // the host stops using the configuration once it is told the page is gone, and an `<Activity>` that only hides
87
+ // the page during a recheck must not take its close guard away.
88
+ useLayoutEffect(() => {
89
+ host.configure(config);
90
+ });
91
+ return (
92
+ <RouteOverlayHostContext.Provider value={null}>
93
+ {children}
94
+ </RouteOverlayHostContext.Provider>
95
+ );
96
+ }
97
+
98
+ export interface RouteOverlayHostProps {
99
+ readonly drawer?: boolean;
100
+ /** Shown until the hosted page configures its own title, and whenever its content is concealed. */
101
+ readonly title: ReactNode;
102
+ /**
103
+ * True while the page component is mounted. While it is false — the module is loading, failed or was denied — the
104
+ * host ignores what an earlier mount configured and closes without a guard.
105
+ */
106
+ readonly ready?: boolean;
107
+ /**
108
+ * True while the mounted page is hidden, for example during an authorization recheck: the host shows only its own
109
+ * title and no footer, so nothing the page derived from protected data stays on screen. The page's close guard
110
+ * is temporarily blocked until the page's effects reconnect, because hiding disconnects form dirty registrations.
111
+ */
112
+ readonly concealed?: boolean;
113
+ readonly children?: ReactNode;
114
+ }
115
+
116
+ /**
117
+ * The panel the application opens for a route that declares a `dialog` or `drawer` presentation, before the page
118
+ * module, its authorization check or its data are ready. Waiting, denial and failure render as its children; once the
119
+ * page renders its own `RouteDialog` or `RouteDrawer` inside, that configures this panel. There is still one panel
120
+ * and one focus trap, and the same close path and focus return as a standalone overlay. A hosted page renders its
121
+ * `RouteDialog` or `RouteDrawer` unconditionally, as its root.
55
122
  */
56
- export function RouteOverlay({
123
+ export function RouteOverlayHost({
124
+ drawer = false,
125
+ title,
126
+ ready = false,
127
+ concealed = false,
128
+ children,
129
+ }: RouteOverlayHostProps): ReactElement {
130
+ const [config, setConfig] = useState<RouteOverlayHostConfig | null>(null);
131
+ const host = useMemo<RouteOverlayHostValue>(
132
+ () => ({ configure: setConfig }),
133
+ [],
134
+ );
135
+ const page = ready ? config : null;
136
+ const shown = concealed ? null : page;
137
+ return (
138
+ <RouteOverlayFrame
139
+ drawer={drawer}
140
+ title={shown?.title ?? title}
141
+ description={shown?.description}
142
+ footer={shown?.footer}
143
+ closeTo={page?.closeTo}
144
+ beforeClose={page?.beforeClose}
145
+ closeBlocked={ready && concealed}
146
+ className={page?.className}
147
+ >
148
+ <RouteOverlayHostContext.Provider value={host}>
149
+ {children}
150
+ </RouteOverlayHostContext.Provider>
151
+ </RouteOverlayFrame>
152
+ );
153
+ }
154
+
155
+ function RouteOverlayFrame({
57
156
  title,
58
157
  description,
59
158
  children,
@@ -62,10 +161,24 @@ export function RouteOverlay({
62
161
  beforeClose,
63
162
  className,
64
163
  drawer = false,
65
- }: RouteOverlayProps & { readonly drawer?: boolean }): ReactElement {
164
+ closeBlocked = false,
165
+ }: RouteOverlayProps & {
166
+ readonly drawer?: boolean;
167
+ readonly closeBlocked?: boolean;
168
+ }): ReactElement {
66
169
  const { t } = useTranslation();
67
170
  const parentPopup = useContext(ParentPopupContext);
68
171
  const popupRef = useRef<HTMLDivElement>(null);
172
+ const [portal, setPortal] = useState<HTMLDivElement | null>(null);
173
+ // A parent route's Activity can stop at its first DOM element, while this portal lives outside that element.
174
+ // Base UI creates the portal after mount, so track the actual node before connecting its visibility lifecycle.
175
+ useLayoutEffect(() => {
176
+ if (!portal) return;
177
+ portal.style.removeProperty('display');
178
+ return () => {
179
+ portal.style.display = 'none';
180
+ };
181
+ }, [portal]);
69
182
  const previousFocusRef = useRef<HTMLElement | null>(null);
70
183
  useLayoutEffect(() => {
71
184
  previousFocusRef.current =
@@ -75,29 +188,37 @@ export function RouteOverlay({
75
188
  }, []);
76
189
  const navigate = useNavigate();
77
190
  const location = useLocation();
78
- const [closingLocation, setClosingLocation] = useState<
79
- typeof location | null
80
- >(null);
81
- const isClosing = closingLocation === location;
191
+ const closeScope = useMemo(
192
+ () => ({ location, closeBlocked }),
193
+ [location, closeBlocked],
194
+ );
195
+ const [closingScope, setClosingScope] = useState<typeof closeScope | null>(
196
+ null,
197
+ );
198
+ const isClosing = closingScope === closeScope;
82
199
  const pendingRef = useRef<Promise<void> | null>(null);
83
200
  const generationRef = useRef(0);
84
201
 
85
- // A still-mounted parent can change location while its confirmation is pending.
86
- // Invalidate that request as well as requests from an unmounted route.
202
+ // A location change or concealment invalidates pending closes. Activity cleanup can resolve a form's old
203
+ // confirmation, but that result must never navigate, including after permission is restored.
87
204
  useLayoutEffect(() => {
88
205
  generationRef.current += 1;
89
206
  pendingRef.current = null;
90
207
  return () => {
91
208
  generationRef.current += 1;
209
+ setClosingScope(null);
92
210
  };
93
- }, [location]);
211
+ }, [location, closeBlocked]);
94
212
 
95
213
  const close = useCallback((): Promise<void> => {
214
+ if (closeBlocked) return Promise.resolve();
96
215
  if (pendingRef.current) return pendingRef.current;
97
216
  const requestGeneration = generationRef.current;
98
- setClosingLocation(location);
217
+ setClosingScope(closeScope);
99
218
  const request = Promise.resolve()
100
219
  .then(async () => {
220
+ // A recheck can start while an earlier confirmation is open, or before this microtask runs.
221
+ if (generationRef.current !== requestGeneration) return;
101
222
  const allowed = beforeClose ? await beforeClose() : true;
102
223
  if (allowed && generationRef.current === requestGeneration) {
103
224
  await navigate(
@@ -109,12 +230,12 @@ export function RouteOverlay({
109
230
  .finally(() => {
110
231
  if (generationRef.current === requestGeneration) {
111
232
  pendingRef.current = null;
112
- setClosingLocation(null);
233
+ setClosingScope(null);
113
234
  }
114
235
  });
115
236
  pendingRef.current = request;
116
237
  return request;
117
- }, [beforeClose, closeTo, location, navigate]);
238
+ }, [beforeClose, closeBlocked, closeScope, closeTo, location, navigate]);
118
239
  const value = useMemo(() => ({ close, isClosing }), [close, isClosing]);
119
240
 
120
241
  return (
@@ -131,7 +252,7 @@ export function RouteOverlay({
131
252
  });
132
253
  }}
133
254
  >
134
- <DialogPortal>
255
+ <DialogPortal ref={setPortal}>
135
256
  {/* Each nested panel needs its own backdrop above its parent panel. */}
136
257
  <DialogOverlay forceRender />
137
258
  <DialogPrimitive.Popup
@@ -212,3 +333,16 @@ export function RouteOverlay({
212
333
  </RouteOverlayContext.Provider>
213
334
  );
214
335
  }
336
+
337
+ /** Ends a `RouteOverlayHost`'s reach, so a route rendered inside one that is not hosted builds its own overlay. */
338
+ export function RouteOverlayHostBoundary({
339
+ children,
340
+ }: {
341
+ readonly children?: ReactNode;
342
+ }): ReactElement {
343
+ return (
344
+ <RouteOverlayHostContext.Provider value={null}>
345
+ {children}
346
+ </RouteOverlayHostContext.Provider>
347
+ );
348
+ }
@@ -0,0 +1,155 @@
1
+ import { Progress } from '@base-ui/react/progress';
2
+ import { useTranslation } from '@nocobase/i18n/client';
3
+ import type { ReactElement, ReactNode } from 'react';
4
+ import { cn } from 'cn';
5
+
6
+ export type TaskProgressStatus =
7
+ 'queued' | 'running' | 'completed' | 'failed' | 'canceled';
8
+
9
+ export interface TaskProgressProps {
10
+ /** The task's real lifecycle status, from the server's task record. */
11
+ readonly status: TaskProgressStatus;
12
+ /** What the task is, such as "Customer export". */
13
+ readonly label: string;
14
+ /** The application's own word for the status; defaults to the standard copy. */
15
+ readonly statusLabel?: string;
16
+ /** Units actually processed, as reported by the task. */
17
+ readonly processed?: number;
18
+ /** The real total; leave it out or pass null while it is unknown. */
19
+ readonly total?: number | null;
20
+ /** For tasks that report only a percentage (0–100) instead of counts. */
21
+ readonly percent?: number | null;
22
+ /** Show that the task keeps running after the user leaves the page. */
23
+ readonly continuesInBackground?: boolean;
24
+ /** Caller-owned detail: a failure reason, or where the result is. */
25
+ readonly message?: ReactNode;
26
+ /** Real actions only: download, retry, or a cancel the server supports. */
27
+ readonly actions?: ReactNode;
28
+ readonly className?: string;
29
+ }
30
+
31
+ /**
32
+ * One long-running task record. A known total shows a determinate bar and
33
+ * count; an unknown total shows an indeterminate bar without a percentage.
34
+ * Status changes are announced once; percentage updates are not.
35
+ */
36
+ export function TaskProgress({
37
+ status,
38
+ label,
39
+ statusLabel,
40
+ processed,
41
+ total,
42
+ percent: reportedPercent,
43
+ continuesInBackground = false,
44
+ message,
45
+ actions,
46
+ className,
47
+ }: TaskProgressProps): ReactElement {
48
+ const { t } = useTranslation();
49
+ const active = status === 'queued' || status === 'running';
50
+ const counted = total !== undefined && total !== null && total > 0;
51
+ const done = processed ?? 0;
52
+ const known =
53
+ counted || (reportedPercent !== undefined && reportedPercent !== null);
54
+ const percent = counted
55
+ ? Math.min(100, Math.floor((done / total) * 100))
56
+ : Math.min(100, Math.max(0, Math.floor(reportedPercent ?? 0)));
57
+ const statusText =
58
+ statusLabel ??
59
+ {
60
+ queued: t('taskProgress.queued', { defaultValue: 'Waiting to start' }),
61
+ running: t('taskProgress.running', { defaultValue: 'In progress' }),
62
+ completed: t('taskProgress.completed', { defaultValue: 'Completed' }),
63
+ failed: t('taskProgress.failed', { defaultValue: 'Failed' }),
64
+ canceled: t('taskProgress.canceled', { defaultValue: 'Canceled' }),
65
+ }[status];
66
+ const countText = counted
67
+ ? t('taskProgress.count', {
68
+ defaultValue: '{{processed}} of {{total}}',
69
+ processed: done,
70
+ total,
71
+ })
72
+ : processed
73
+ ? t('taskProgress.processed', {
74
+ defaultValue: '{{processed}} processed',
75
+ processed: done,
76
+ })
77
+ : undefined;
78
+ return (
79
+ <div
80
+ data-slot='task-progress'
81
+ data-status={status}
82
+ className={cn('flex flex-col gap-2', className)}
83
+ >
84
+ <div className='flex flex-wrap items-baseline justify-between gap-x-3 gap-y-1'>
85
+ <span className='min-w-0 text-sm font-medium wrap-anywhere'>
86
+ {label}
87
+ </span>
88
+ <span
89
+ className={cn(
90
+ 'text-sm',
91
+ status === 'failed' ? 'text-destructive' : 'text-muted-foreground',
92
+ )}
93
+ >
94
+ {statusText}
95
+ </span>
96
+ </div>
97
+ {active ? (
98
+ <Progress.Root
99
+ value={known ? percent : null}
100
+ aria-label={label}
101
+ getAriaValueText={() =>
102
+ [statusText, countText, known ? `${percent}%` : undefined]
103
+ .filter(Boolean)
104
+ .join(', ')
105
+ }
106
+ className='flex flex-col gap-1.5'
107
+ >
108
+ <Progress.Track className='relative h-1.5 w-full overflow-hidden rounded-full bg-muted'>
109
+ {/* An unknown amount fills the whole track faintly, so no segment can be read as a fraction done. */}
110
+ <Progress.Indicator
111
+ data-slot='task-progress-indicator'
112
+ className={
113
+ known
114
+ ? 'h-full bg-primary motion-safe:transition-[width] motion-safe:duration-500'
115
+ : 'h-full w-full bg-primary/25 motion-safe:animate-pulse'
116
+ }
117
+ />
118
+ </Progress.Track>
119
+ {countText || known ? (
120
+ <span className='text-xs text-muted-foreground tabular-nums'>
121
+ {[countText, known ? `${percent}%` : undefined]
122
+ .filter(Boolean)
123
+ .join(' · ')}
124
+ </span>
125
+ ) : null}
126
+ </Progress.Root>
127
+ ) : countText && status !== 'completed' ? (
128
+ <span className='text-xs text-muted-foreground tabular-nums'>
129
+ {countText}
130
+ </span>
131
+ ) : null}
132
+ {active && continuesInBackground ? (
133
+ <p className='text-xs text-muted-foreground'>
134
+ {t('taskProgress.background', {
135
+ defaultValue: 'It keeps running if you leave this page.',
136
+ })}
137
+ </p>
138
+ ) : null}
139
+ {message ? (
140
+ <div
141
+ className={cn(
142
+ 'text-sm',
143
+ status === 'failed' ? 'text-destructive' : 'text-muted-foreground',
144
+ )}
145
+ >
146
+ {message}
147
+ </div>
148
+ ) : null}
149
+ {actions ? <div className='flex flex-wrap gap-2'>{actions}</div> : null}
150
+ <span role='status' aria-live='polite' className='sr-only'>
151
+ {`${label}: ${statusText}`}
152
+ </span>
153
+ </div>
154
+ );
155
+ }
@@ -1,15 +1,12 @@
1
- import { createContext, useContext, type Context } from 'react';
1
+ import {
2
+ RouteOverlayContext,
3
+ type RouteOverlayContextValue,
4
+ } from '@nocobase/app-client/route-containers';
5
+ import { useContext } from 'react';
2
6
 
3
- export interface RouteOverlayContextValue {
4
- /** Runs `beforeClose`, then navigates to the parent route unless it declined. Concurrent calls share one request. */
5
- readonly close: () => Promise<void>;
6
- /** True while a close request for the current location is pending. */
7
- readonly isClosing: boolean;
8
- }
9
-
10
- /** Internal context shared by both route overlay components. */
11
- export const RouteOverlayContext: Context<RouteOverlayContextValue | null> =
12
- createContext<RouteOverlayContextValue | null>(null);
7
+ // The context lives in `@nocobase/app-client` so every copy of the route overlay components, including a plugin's,
8
+ // shares it: a footer button inside a panel the application opened reaches that panel's close request.
9
+ export { RouteOverlayContext, type RouteOverlayContextValue };
13
10
 
14
11
  /**
15
12
  * Closes the enclosing `RouteDialog` or `RouteDrawer`. Call it from a component rendered inside the overlay — its
@@ -1,4 +1,5 @@
1
1
  import { messageKey } from '../lib/message-key.js';
2
+ import { usePendingFeedback } from '@nocobase/app-client';
2
3
  import { useTranslation } from '@nocobase/i18n/client';
3
4
  import { RotateCcw, Trash2, UploadCloud, X } from 'lucide-react';
4
5
  import {
@@ -8,11 +9,12 @@ import {
8
9
  type ChangeEvent,
9
10
  type DragEvent,
10
11
  type ReactElement,
12
+ type ReactNode,
11
13
  } from 'react';
12
14
 
13
15
  import type { FileRecord, FileUploadFieldProps } from '../types.js';
14
16
  import { Button } from '#components/ui/button';
15
- import { Spinner } from '#components/ui/spinner';
17
+ import { BrandLoadingMark } from '#components/brand-loading-mark';
16
18
  import { FileThumbnail } from './file-thumbnail.js';
17
19
 
18
20
  type UploadItem = {
@@ -23,6 +25,48 @@ type UploadItem = {
23
25
  readonly error?: Error;
24
26
  };
25
27
 
28
+ /**
29
+ * The upload request reports no byte progress, so the item shows an honest
30
+ * indeterminate wait from one display hook: the mark appears after the shared
31
+ * display delay and a long upload says so after 10 seconds, next to the real
32
+ * cancel action.
33
+ */
34
+ function UploadItemBody({
35
+ status,
36
+ failure,
37
+ children,
38
+ }: {
39
+ readonly status: UploadItem['status'];
40
+ readonly failure?: string;
41
+ readonly children: ReactNode;
42
+ }): ReactElement {
43
+ const { t } = useTranslation();
44
+ const feedback = usePendingFeedback(status === 'uploading');
45
+ return (
46
+ <>
47
+ <div className='flex h-20 items-center justify-center overflow-hidden rounded-sm bg-muted/30'>
48
+ {feedback.visible ? (
49
+ <BrandLoadingMark className='size-8 text-muted-foreground' />
50
+ ) : (
51
+ <UploadCloud aria-hidden='true' />
52
+ )}
53
+ </div>
54
+ {children}
55
+ <div className='text-xs text-muted-foreground'>
56
+ {status === 'error'
57
+ ? (failure ?? t('fileUi.files.failed', { defaultValue: 'Failed' }))
58
+ : status === 'uploading'
59
+ ? feedback.longWaiting
60
+ ? t('fileUi.files.longWait', {
61
+ defaultValue: 'Still uploading. Keep waiting or cancel.',
62
+ })
63
+ : t('fileUi.files.uploading', { defaultValue: 'Uploading' })
64
+ : t('fileUi.files.pending', { defaultValue: 'Pending' })}
65
+ </div>
66
+ </>
67
+ );
68
+ }
69
+
26
70
  function accepts(file: File, rules: readonly string[]): boolean {
27
71
  if (!rules.length) return true;
28
72
  const name = file.name.toLowerCase();
@@ -249,12 +293,10 @@ export function FileUploadField(
249
293
  };
250
294
 
251
295
  return (
252
- <div
253
- data-slot='file-upload-field'
254
- className='space-y-3'
255
- aria-busy={items.some((item) => item.status === 'uploading')}
256
- >
296
+ <div data-slot='file-upload-field' className='space-y-3'>
297
+ {/* The polite summary below stays outside the busy drop zone. */}
257
298
  <div
299
+ aria-busy={items.some((item) => item.status === 'uploading')}
258
300
  className={`flex min-h-24 flex-wrap gap-3 rounded-md border border-dashed p-3 ${dragging ? 'border-primary bg-primary/5' : ''}`}
259
301
  onDragOver={(event) => {
260
302
  event.preventDefault();
@@ -288,32 +330,20 @@ export function FileUploadField(
288
330
  ))}
289
331
  {items.map((item) => (
290
332
  <div key={item.key} className='w-36 rounded-md border p-2'>
291
- <div className='flex h-20 items-center justify-center overflow-hidden rounded-sm bg-muted/30'>
292
- {item.status === 'uploading' ? (
293
- <Spinner
294
- aria-label={t('fileUi.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('fileUi.files.failed', { defaultValue: 'Failed' }))
313
- : item.status === 'uploading'
314
- ? t('fileUi.files.uploading', { defaultValue: 'Uploading' })
315
- : t('fileUi.files.pending', { defaultValue: 'Pending' })}
316
- </div>
333
+ <UploadItemBody
334
+ status={item.status}
335
+ failure={
336
+ item.status === 'error' && item.error
337
+ ? t(messageKey(item.error.message), {
338
+ defaultValue: item.error.message,
339
+ })
340
+ : undefined
341
+ }
342
+ >
343
+ <div className='mt-2 truncate text-sm' title={item.file.name}>
344
+ {item.file.name}
345
+ </div>
346
+ </UploadItemBody>
317
347
  <div className='mt-2 flex gap-1'>
318
348
  {item.status === 'error' ? (
319
349
  <Button
@@ -26,6 +26,8 @@ const messages = {
26
26
  'files.retry': 'Erneut versuchen',
27
27
  'files.done': 'Erledigt',
28
28
  'files.uploading': 'Hochladen',
29
+ 'files.longWait':
30
+ 'Wird noch hochgeladen. Warten Sie weiter oder brechen Sie ab.',
29
31
  'files.failed': 'Fehlgeschlagen',
30
32
  'files.pending': 'Ausstehend',
31
33
  'files.previous': 'Vorherige Datei',
@@ -24,6 +24,7 @@ const messages = {
24
24
  'files.retry': 'Retry',
25
25
  'files.done': 'Done',
26
26
  'files.uploading': 'Uploading',
27
+ 'files.longWait': 'Still uploading. Keep waiting or cancel.',
27
28
  'files.failed': 'Failed',
28
29
  'files.pending': 'Pending',
29
30
  'files.previous': 'Previous file',
@@ -25,6 +25,7 @@ const messages = {
25
25
  'files.retry': 'Reintentar',
26
26
  'files.done': 'Hecho',
27
27
  'files.uploading': 'Subiendo',
28
+ 'files.longWait': 'Aún se está subiendo. Siga esperando o cancele.',
28
29
  'files.failed': 'Falló',
29
30
  'files.pending': 'Pendiente',
30
31
  'files.previous': 'Archivo anterior',
@@ -26,6 +26,7 @@ const messages = {
26
26
  'files.retry': 'Réessayer',
27
27
  'files.done': 'Terminé',
28
28
  'files.uploading': 'Téléchargement',
29
+ 'files.longWait': 'Envoi toujours en cours. Patientez ou annulez.',
29
30
  'files.failed': 'Échec',
30
31
  'files.pending': 'En attente',
31
32
  'files.previous': 'Fichier précédent',
@@ -24,6 +24,7 @@ const messages = {
24
24
  'files.retry': 'Coba lagi',
25
25
  'files.done': 'Selesai',
26
26
  'files.uploading': 'Mengunggah',
27
+ 'files.longWait': 'Masih mengunggah. Tunggu atau batalkan.',
27
28
  'files.failed': 'Gagal',
28
29
  'files.pending': 'Tertunda',
29
30
  'files.previous': 'Berkas sebelumnya',
@@ -27,6 +27,8 @@ const messages = {
27
27
  'files.retry': '再試行',
28
28
  'files.done': '完了',
29
29
  'files.uploading': 'アップロード中',
30
+ 'files.longWait':
31
+ 'アップロード中です。このまま待つか、キャンセルしてください。',
30
32
  'files.failed': '失敗しました',
31
33
  'files.pending': '保留中',
32
34
  'files.previous': '前のファイル',
@@ -26,6 +26,7 @@ const messages = {
26
26
  'files.retry': 'Tentar novamente',
27
27
  'files.done': 'Concluído',
28
28
  'files.uploading': 'Carregando',
29
+ 'files.longWait': 'Ainda enviando. Continue aguardando ou cancele.',
29
30
  'files.failed': 'Falha',
30
31
  'files.pending': 'Pendente',
31
32
  'files.previous': 'Arquivo anterior',
@@ -25,6 +25,7 @@ const messages = {
25
25
  'files.retry': 'Thử lại',
26
26
  'files.done': 'Hoàn thành',
27
27
  'files.uploading': 'Đang tải lên',
28
+ 'files.longWait': 'Vẫn đang tải lên. Hãy tiếp tục chờ hoặc hủy.',
28
29
  'files.failed': 'Thất bại',
29
30
  'files.pending': 'Đang chờ',
30
31
  'files.previous': 'Tệp trước đó',
@@ -23,6 +23,7 @@ const messages = {
23
23
  'files.retry': '重试',
24
24
  'files.done': '已完成',
25
25
  'files.uploading': '正在上传',
26
+ 'files.longWait': '仍在上传,可继续等待或取消。',
26
27
  'files.failed': '失败',
27
28
  'files.pending': '等待中',
28
29
  'files.previous': '上一个文件',
@@ -23,6 +23,7 @@ const messages = {
23
23
  'files.retry': '重試',
24
24
  'files.done': '已完成',
25
25
  'files.uploading': '上傳中',
26
+ 'files.longWait': '仍在上傳,可繼續等待或取消。',
26
27
  'files.failed': '失敗',
27
28
  'files.pending': '待處理',
28
29
  'files.previous': '上一個檔案',