@nocobase/app-template-default 1.0.0-beta.80 → 1.0.0-beta.82

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 (48) hide show
  1. package/.agents/rules/shell.md +2 -0
  2. package/.agents/rules/workspace.md +9 -0
  3. package/.prettierignore +3 -0
  4. package/AGENTS.md +2 -1
  5. package/README.MD +4 -0
  6. package/client/components/attachment-list.tsx +589 -0
  7. package/client/components/back-button.tsx +15 -4
  8. package/client/components/breadcrumbs.tsx +12 -1
  9. package/client/extensions/nocobase-file-component-ui/components/file-list.tsx +68 -71
  10. package/client/extensions/nocobase-file-component-ui/components/file-preview-dialog.tsx +26 -12
  11. package/client/extensions/nocobase-file-component-ui/components/file-preview-field.tsx +6 -6
  12. package/client/extensions/nocobase-file-component-ui/components/file-thumbnail.tsx +3 -3
  13. package/client/extensions/nocobase-file-component-ui/components/file-upload-field.tsx +19 -19
  14. package/client/extensions/nocobase-file-component-ui/components/previewers/file-preview-content.tsx +27 -19
  15. package/client/extensions/nocobase-file-component-ui/components/previewers/office-open-xml-preview.tsx +12 -8
  16. package/client/extensions/nocobase-file-component-ui/index.ts +6 -6
  17. package/client/extensions/nocobase-file-component-ui/lib/message-key.ts +14 -11
  18. package/client/extensions/nocobase-file-component-ui/locales/de-DE.ts +49 -0
  19. package/client/extensions/nocobase-file-component-ui/locales/en-US.ts +47 -0
  20. package/client/extensions/nocobase-file-component-ui/locales/es-ES.ts +48 -0
  21. package/client/extensions/nocobase-file-component-ui/locales/fr-FR.ts +49 -0
  22. package/client/extensions/nocobase-file-component-ui/locales/id-ID.ts +46 -0
  23. package/client/extensions/nocobase-file-component-ui/locales/ja-JP.ts +51 -0
  24. package/client/extensions/nocobase-file-component-ui/locales/pt-BR.ts +49 -0
  25. package/client/extensions/nocobase-file-component-ui/locales/vi-VN.ts +47 -0
  26. package/client/extensions/nocobase-file-component-ui/locales/zh-CN.ts +43 -0
  27. package/client/extensions/nocobase-file-component-ui/locales/zh-TW.ts +43 -0
  28. package/client/extensions/nocobase-file-component-ui/types.ts +3 -1
  29. package/client/locales/en-US.ts +2 -0
  30. package/client/locales/system/settings-en-US.ts +3 -0
  31. package/client/locales/system/settings-zh-CN.ts +2 -0
  32. package/client/locales/zh-CN.ts +2 -0
  33. package/client/pages/settings/role-form.tsx +5 -1
  34. package/client/pages/settings/user-form.tsx +13 -1
  35. package/client/pages/settings/users.tsx +1 -1
  36. package/client/routing/route-context.tsx +33 -1
  37. package/eslint.config.js +2 -0
  38. package/package.json +23 -21
  39. package/scripts/check-packages.mjs +46 -0
  40. package/tailwind.config.mjs +3 -9
  41. package/tests/client/list-return-state.test.tsx +202 -0
  42. package/tests/client/role-return.test.tsx +104 -0
  43. package/tests/client/user-drawer.test.tsx +48 -0
  44. package/tests/project/internal-packages.test.ts +98 -0
  45. package/tests/project/tailwind-sources.test.ts +2 -2
  46. package/tests/server/authorization-assignment-safety.test.ts +10 -9
  47. package/tests/server/member-role.test.ts +299 -0
  48. package/client/extensions/nocobase-file-component-ui/README.md +0 -13
@@ -6,6 +6,8 @@ Read this before changing the layouts, header, sidebar, breadcrumbs, account men
6
6
 
7
7
  Layouts own breadcrumb route context and render the current page's trail in the header after the sidebar toggle (`client/components/breadcrumbs.tsx`): the route trail by default, or the trail a page declares with `usePageBreadcrumb` from `@nocobase/app-client` when it names records. Pages do not render breadcrumbs themselves. `AppRouter` selects routes and layouts. See [pages and routes](../skills/nocobase-app-development/references/frontend/references/page.md) for each layout's scope.
8
8
 
9
+ The route-tree provider remembers each visited ancestor's query while its descendants are open. Default breadcrumbs and back links restore that query, including an empty one, instead of applying detail-only parameters to the list. Keep this provider mounted above page navigation. Explicit breadcrumb/back destinations remain application-owned. See the linked pages-and-routes reference for deep-link and reload fallbacks.
10
+
9
11
  ## Header and sidebar
10
12
 
11
13
  `client/layouts/components/layout-header.tsx` is a presentation container accepting children. The sidebar is shadcn's Sidebar: `components/app-sidebar.tsx` wraps its provider, desktop icon mode and phone sheet, and `components/navigation-menu.tsx` draws the permission-filtered route tree with `SidebarMenu*`: a top-level group with no page of its own is a section, its entries always shown under a `SidebarGroupLabel` heading, while clickable parents and nested groups are controlled `Collapsible` sub-menus, and an item `client/lib/navigation-transform.ts` marks `section` deeper in the tree is a heading over its direct links inside that menu; a count beside an entry, such as unread items, is passed by the App layout in `NavigationMenu`'s `badges`, keyed by route name. Keep `client/components/ui/sidebar.tsx` as the shadcn CLI writes it except for removing its unused origin-wide `sidebar_state` cookie write; persistence belongs to the application-scoped preference. Customize from outside: widths go through the provider's `style` in spacing units, the phone sheet is rendered in `app-sidebar.tsx` with a translated title, and the provider's Ctrl/Cmd+B shortcut is stopped at `document`. The App layout owns menus, branding, permissions and redirects. Desktop icon mode hides top-level section headings and keeps their entries' icons, shows leaf labels as zero-delay tooltips and opens groups in hover and keyboard-focus popovers, preserving filtered navigation, parent-page links, nested groups and the selected group's highlight. Keep this behavior aligned across layouts. Desktop collapse state is shared through `useSidebarPreference` at `nocobase:sidebar:collapsed:app:<basename>` across layouts and tabs of the same application. `createApp(runtime)` provides a stable store using `runtime.basename`, with only empty/root and boundary slashes normalized; paths and case remain intact. Missing new keys default to expanded without migrating the old shared preference; whether the phone sheet is open stays local to each layout.
@@ -0,0 +1,9 @@
1
+ # Internal workspace packages
2
+
3
+ The application stays at the workspace root: keep `client/`, `server/`, `cli/`, `database/`, configuration and storage here. New projects declare `packages/*` in their root `pnpm-workspace.yaml`; an existing project adds that pattern while preserving its other workspace and install settings. Run one `pnpm install` at the application root and commit its shared lockfile. Do not create another workspace or lockfile inside a child package.
4
+
5
+ Use ordinary packages for shared types, utilities or UI. Application-specific pages, routes and migrations still belong in the application. A package boundary does not require publishing or plugin registration. Read [workspace package contracts](../skills/nocobase-app-development/references/dependencies.md#internal-workspace-packages) before adding one, and use the [runnable example](../skills/nocobase-app-development/references/internal-package-example.md) for configuration, application imports and declaration watching.
6
+
7
+ `pnpm dev`, `pnpm build` and `pnpm build --tar` still run from this directory. `pnpm check` first runs each internal package's `check` script, then checks the application; a missing or empty package `check` script fails before any package checks run. An application with no internal packages succeeds. Root ESLint and Prettier exclude `packages/`, because every package owns its TypeScript, lint and formatting configuration. Run a package's `fix` with `pnpm --filter <package-name> fix`.
8
+
9
+ Keep internal package directories, workspace patterns and `workspace:` dependencies when merging a newer template. Template upgrades evolve the root application; they do not recreate its internal packages.
package/.prettierignore CHANGED
@@ -26,3 +26,6 @@ database/*/seeds/
26
26
  .agents/*
27
27
  !.agents/rules/
28
28
  .claude/skills/
29
+
30
+ # Internal packages run their own formatting through check:packages.
31
+ packages/
package/AGENTS.md CHANGED
@@ -2,7 +2,7 @@
2
2
 
3
3
  This is a NocoBase 3 application. Do not apply globally installed NocoBase 2 Skills. You are building the application itself — its pages, its API, its database tables. Everything under this directory is application-owned source code that you may edit directly.
4
4
 
5
- Do not create a plugin to add a feature. Plugins are separately published packages for capabilities shared across several applications; building one for this application's own feature adds a package boundary, a version, and a release process to work that belongs in `client/` and `server/`. Create one only when the user explicitly asks for a reusable published package.
5
+ Do not create a plugin to add a feature. Plugins are separately published packages for capabilities shared across several applications; building one for this application's own feature adds a package boundary, a version, and a release process to work that belongs in `client/` and `server/`. Create one only when the user explicitly asks for a reusable published package. Ordinary internal workspace packages under `packages/` may share code without becoming plugins or being published; follow [internal packages](.agents/rules/workspace.md) when the application needs that boundary.
6
6
 
7
7
  ## Default template scope
8
8
 
@@ -120,6 +120,7 @@ When the built-in mechanism genuinely cannot express what is being asked, changi
120
120
 
121
121
  | Read | When |
122
122
  | ---------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------ |
123
+ | [.agents/rules/workspace.md](.agents/rules/workspace.md) | Adding internal packages or changing workspace development and validation |
123
124
  | [.agents/rules/components.md](.agents/rules/components.md) | Adding or changing a component, a shadcn primitive, a UI Library item, or the visual design |
124
125
  | [.agents/rules/shell.md](.agents/rules/shell.md) | Changing layouts, the header, sidebar, breadcrumbs, the account menu or loading indicators |
125
126
  | [.agents/rules/build-and-deploy.md](.agents/rules/build-and-deploy.md) | Changing the build, packing a deployment, the Docker image, `CDN_BASE_URL`, or building for another platform |
package/README.MD CHANGED
@@ -441,3 +441,7 @@ MIT
441
441
  ## Strict startup verification
442
442
 
443
443
  Set `NOCOBASE_STRICT_STARTUP=true` when running `pnpm dev` or `pnpm start` in automated verification. Startup failures, including job import failures, exit nonzero after resource cleanup. Strict dev runs the server without watch mode so a failed server cannot remain hidden behind a watcher; restart the command after server or configuration changes. Client HMR remains available. Omit the variable or set it to `false` for normal development with server hot reload. Request errors and individual job execution failures do not terminate the application.
444
+
445
+ ## Internal workspace packages
446
+
447
+ The application can also be the root of a pnpm workspace, with internal packages under `packages/*` and the existing `client/`, `server/` and `database/` directories unchanged. New projects receive this workspace pattern automatically. Internal packages use `workspace:*` dependencies and may remain private. Run installation, development and deployment builds from the application root; `pnpm check` includes the internal packages' own checks. See [internal packages](.agents/rules/workspace.md) for configuration and development conventions.
@@ -0,0 +1,589 @@
1
+ /**
2
+ * Files on a record or a message, presented: safe images as thumbnails and everything else as a chip with its name,
3
+ * size and a download (`AttachmentList`); the files waiting to go with a message (`PendingAttachments`); and a section
4
+ * of a record's files that takes uploads by picking, pasting or dropping (`AttachmentPanel`). Purely presentational:
5
+ * the consumer gives the files and hears about previews, uploads and removals. Without `onPreview`, images open in
6
+ * the item's own dialog and other files download. Every word comes from `labels`, English by default; `{name}`-style
7
+ * placeholders are filled in here.
8
+ */
9
+ import {
10
+ ChevronLeftIcon,
11
+ ChevronRightIcon,
12
+ DownloadIcon,
13
+ FileIcon,
14
+ Loader2Icon,
15
+ PaperclipIcon,
16
+ UploadIcon,
17
+ XIcon,
18
+ } from 'lucide-react';
19
+ import {
20
+ useRef,
21
+ useState,
22
+ type ClipboardEvent,
23
+ type DragEvent,
24
+ type ReactElement,
25
+ type ReactNode,
26
+ } from 'react';
27
+
28
+ import {
29
+ AlertDialog,
30
+ AlertDialogAction,
31
+ AlertDialogCancel,
32
+ AlertDialogContent,
33
+ AlertDialogDescription,
34
+ AlertDialogFooter,
35
+ AlertDialogHeader,
36
+ AlertDialogTitle,
37
+ } from '#components/ui/alert-dialog';
38
+ import { Button } from '#components/ui/button';
39
+ import { Dialog, DialogContent, DialogTitle } from '#components/ui/dialog';
40
+ import { cn } from 'cn';
41
+
42
+ export interface AttachmentFile {
43
+ readonly id: string;
44
+ readonly name: string;
45
+ /** In bytes, when the source provides it. */
46
+ readonly size?: number;
47
+ /** Shown inline for an image. */
48
+ readonly url: string;
49
+ readonly downloadUrl: string;
50
+ /** A safe image the browser may show. */
51
+ readonly image: boolean;
52
+ /** The reader may remove it. */
53
+ readonly canRemove?: boolean;
54
+ }
55
+
56
+ export interface AttachmentLabels {
57
+ readonly title: string;
58
+ readonly images: string;
59
+ readonly files: string;
60
+ readonly pending: string;
61
+ readonly upload: string;
62
+ /** `{name}` is the file. */
63
+ readonly preview: string;
64
+ readonly download: string;
65
+ readonly remove: string;
66
+ readonly uploading: string;
67
+ readonly removeTitle: string;
68
+ readonly removeDescription: string;
69
+ readonly removeConfirm: string;
70
+ readonly cancel: string;
71
+ readonly previous: string;
72
+ readonly next: string;
73
+ }
74
+
75
+ const defaultLabels: AttachmentLabels = {
76
+ title: 'Attachments',
77
+ images: 'Images',
78
+ files: 'Files',
79
+ pending: 'Files to send',
80
+ upload: 'Upload',
81
+ preview: 'Preview {name}',
82
+ download: 'Download {name}',
83
+ remove: 'Remove {name}',
84
+ uploading: 'Uploading {name}…',
85
+ removeTitle: 'Remove this file?',
86
+ removeDescription: '“{name}” is deleted for everyone.',
87
+ removeConfirm: 'Remove',
88
+ cancel: 'Cancel',
89
+ previous: 'Previous file',
90
+ next: 'Next file',
91
+ };
92
+
93
+ function fill(text: string, values: Readonly<Record<string, string>>): string {
94
+ return text.replace(/\{(\w+)\}/gu, (whole, key: string) =>
95
+ key in values ? (values[key] ?? whole) : whole,
96
+ );
97
+ }
98
+
99
+ function bytes(size: number | undefined, locale: string | undefined): string {
100
+ if (size === undefined) return '';
101
+ const units = ['B', 'KB', 'MB', 'GB'] as const;
102
+ let value = size;
103
+ let unit = 0;
104
+ while (value >= 1024 && unit < units.length - 1) {
105
+ value /= 1024;
106
+ unit += 1;
107
+ }
108
+ const number = new Intl.NumberFormat(locale, {
109
+ maximumFractionDigits: unit === 0 ? 0 : 1,
110
+ }).format(value);
111
+ return `${number} ${units[unit] ?? 'B'}`;
112
+ }
113
+
114
+ export interface AttachmentListProps {
115
+ /** Override downloads, for example to validate URLs or authenticate a request. */
116
+ readonly onDownload?: (file: AttachmentFile) => void;
117
+ readonly files: readonly AttachmentFile[];
118
+ /** Previews a file the consumer's way, stepping through `files` from `index`; every file opens it. */
119
+ readonly onPreview?: (
120
+ files: readonly AttachmentFile[],
121
+ index: number,
122
+ ) => void;
123
+ /** Asks to remove a file the reader may remove; the consumer confirms it. */
124
+ readonly onRemove?: (file: AttachmentFile) => void;
125
+ readonly removingId?: string | null;
126
+ readonly locale?: string;
127
+ readonly labels?: AttachmentLabels;
128
+ readonly className?: string;
129
+ }
130
+
131
+ export function AttachmentList({
132
+ files,
133
+ onDownload,
134
+ onPreview,
135
+ onRemove,
136
+ removingId = null,
137
+ locale,
138
+ labels = defaultLabels,
139
+ className,
140
+ }: AttachmentListProps): ReactElement | null {
141
+ const [shown, setShown] = useState<number | null>(null);
142
+ const images = files.filter((file) => file.image);
143
+ const others = files.filter((file) => !file.image);
144
+ // The consumer's preview steps through every file; the item's own only through the images.
145
+ const previewed = onPreview ? [...images, ...others] : images;
146
+ if (files.length === 0) return null;
147
+ const open = (file: AttachmentFile): void => {
148
+ const index = previewed.indexOf(file);
149
+ if (onPreview) onPreview(previewed, index);
150
+ else setShown(index);
151
+ };
152
+
153
+ const removeButton = (file: AttachmentFile, overlay: boolean): ReactNode =>
154
+ onRemove && file.canRemove ? (
155
+ <Button
156
+ variant={overlay ? 'secondary' : 'ghost'}
157
+ size='icon-xs'
158
+ className={cn(
159
+ overlay &&
160
+ 'absolute top-1 right-1 opacity-90 shadow-sm sm:opacity-0 sm:group-hover:opacity-100 sm:focus-visible:opacity-100',
161
+ )}
162
+ aria-label={fill(labels.remove, { name: file.name })}
163
+ disabled={removingId === file.id}
164
+ onClick={() => onRemove(file)}
165
+ >
166
+ {removingId === file.id ? (
167
+ <Loader2Icon className='animate-spin' />
168
+ ) : (
169
+ <XIcon />
170
+ )}
171
+ </Button>
172
+ ) : null;
173
+
174
+ return (
175
+ <div
176
+ className={cn('flex min-w-0 flex-col gap-2', className)}
177
+ data-slot='attachment-list'
178
+ >
179
+ {images.length > 0 ? (
180
+ <ul className='flex flex-wrap gap-2' aria-label={labels.images}>
181
+ {images.map((file) => (
182
+ <li key={file.id} className='group relative'>
183
+ <button
184
+ type='button'
185
+ className='block size-20 overflow-hidden rounded-md border bg-muted focus-visible:ring-2 focus-visible:ring-ring focus-visible:outline-none sm:size-24'
186
+ aria-label={fill(labels.preview, { name: file.name })}
187
+ title={
188
+ file.size === undefined
189
+ ? file.name
190
+ : `${file.name} · ${bytes(file.size, locale)}`
191
+ }
192
+ onClick={() => open(file)}
193
+ >
194
+ <img
195
+ src={file.url}
196
+ alt={file.name}
197
+ loading='lazy'
198
+ className='size-full object-cover'
199
+ />
200
+ </button>
201
+ {removeButton(file, true)}
202
+ </li>
203
+ ))}
204
+ </ul>
205
+ ) : null}
206
+ {others.length > 0 ? (
207
+ <ul className='flex min-w-0 flex-wrap gap-2' aria-label={labels.files}>
208
+ {others.map((file) => (
209
+ <li
210
+ key={file.id}
211
+ className='flex max-w-full min-w-0 items-center gap-1 rounded-md border bg-background py-1 pr-1 pl-2 text-sm'
212
+ >
213
+ <FileIcon
214
+ className='size-3.5 shrink-0 text-muted-foreground'
215
+ aria-hidden='true'
216
+ />
217
+ {onPreview ? (
218
+ <button
219
+ type='button'
220
+ className='min-w-0 truncate text-left hover:underline focus-visible:underline focus-visible:outline-none'
221
+ aria-label={fill(labels.preview, { name: file.name })}
222
+ onClick={() => open(file)}
223
+ >
224
+ {file.name}
225
+ </button>
226
+ ) : (
227
+ <a
228
+ href={file.downloadUrl}
229
+ download={file.name}
230
+ className='min-w-0 truncate hover:underline'
231
+ >
232
+ {file.name}
233
+ </a>
234
+ )}
235
+ <span className='shrink-0 text-xs text-muted-foreground tabular-nums'>
236
+ {bytes(file.size, locale)}
237
+ </span>
238
+ <Button
239
+ variant='ghost'
240
+ size='icon-xs'
241
+ className='shrink-0 text-muted-foreground'
242
+ aria-label={fill(labels.download, { name: file.name })}
243
+ render={
244
+ onDownload ? undefined : (
245
+ <a href={file.downloadUrl} download={file.name} />
246
+ )
247
+ }
248
+ nativeButton={Boolean(onDownload)}
249
+ onClick={onDownload ? () => onDownload(file) : undefined}
250
+ >
251
+ <DownloadIcon />
252
+ </Button>
253
+ {removeButton(file, false)}
254
+ </li>
255
+ ))}
256
+ </ul>
257
+ ) : null}
258
+ {shown !== null && shown >= 0 ? (
259
+ <ImagePreview
260
+ files={previewed}
261
+ index={shown}
262
+ labels={labels}
263
+ onClose={() => setShown(null)}
264
+ />
265
+ ) : null}
266
+ </div>
267
+ );
268
+ }
269
+
270
+ /** The item's own preview: images one at a time, with stepping and a download. */
271
+ function ImagePreview({
272
+ files,
273
+ index: initial,
274
+ labels,
275
+ onClose,
276
+ }: {
277
+ readonly files: readonly AttachmentFile[];
278
+ readonly index: number;
279
+ readonly labels: AttachmentLabels;
280
+ readonly onClose: () => void;
281
+ }): ReactElement | null {
282
+ const [index, setIndex] = useState(initial);
283
+ const file = files[index];
284
+ if (!file) return null;
285
+ const step = (by: number): void =>
286
+ setIndex((value) => (value + by + files.length) % files.length);
287
+ return (
288
+ <Dialog
289
+ open
290
+ onOpenChange={(open) => {
291
+ if (!open) onClose();
292
+ }}
293
+ >
294
+ <DialogContent className='flex max-h-[calc(100dvh-2rem)] max-w-[calc(100%-2rem)] flex-col gap-3 sm:max-w-4xl'>
295
+ <div className='flex min-w-0 items-center gap-2 pr-8'>
296
+ <DialogTitle className='min-w-0 flex-1 truncate'>
297
+ {file.name}
298
+ </DialogTitle>
299
+ {files.length > 1 ? (
300
+ <>
301
+ <Button
302
+ variant='ghost'
303
+ size='icon-sm'
304
+ aria-label={labels.previous}
305
+ onClick={() => step(-1)}
306
+ >
307
+ <ChevronLeftIcon />
308
+ </Button>
309
+ <Button
310
+ variant='ghost'
311
+ size='icon-sm'
312
+ aria-label={labels.next}
313
+ onClick={() => step(1)}
314
+ >
315
+ <ChevronRightIcon />
316
+ </Button>
317
+ </>
318
+ ) : null}
319
+ <Button
320
+ variant='ghost'
321
+ size='icon-sm'
322
+ aria-label={fill(labels.download, { name: file.name })}
323
+ render={<a href={file.downloadUrl} download={file.name} />}
324
+ nativeButton={false}
325
+ >
326
+ <DownloadIcon />
327
+ </Button>
328
+ </div>
329
+ <div className='flex min-h-0 flex-1 items-center justify-center overflow-auto'>
330
+ <img
331
+ src={file.url}
332
+ alt={file.name}
333
+ className='max-h-[75dvh] max-w-full object-contain'
334
+ />
335
+ </div>
336
+ </DialogContent>
337
+ </Dialog>
338
+ );
339
+ }
340
+
341
+ /** A file being added: its name while it uploads, then a chip that can be taken back. */
342
+ export interface PendingAttachment {
343
+ readonly key: string;
344
+ readonly name: string;
345
+ readonly size: number;
346
+ /** Uploaded: its thumbnail when it is an image. */
347
+ readonly done: boolean;
348
+ readonly thumbnailUrl?: string | null;
349
+ }
350
+
351
+ /** The files added to a message before it is sent. */
352
+ export function PendingAttachments({
353
+ files,
354
+ onRemove,
355
+ locale,
356
+ labels = defaultLabels,
357
+ }: {
358
+ readonly files: readonly PendingAttachment[];
359
+ readonly onRemove: (key: string) => void;
360
+ readonly locale?: string;
361
+ readonly labels?: AttachmentLabels;
362
+ }): ReactElement | null {
363
+ if (files.length === 0) return null;
364
+ return (
365
+ <ul
366
+ className='flex min-w-0 flex-wrap gap-2'
367
+ aria-label={labels.pending}
368
+ data-testid='pending-attachments'
369
+ >
370
+ {files.map((file) => (
371
+ <li
372
+ key={file.key}
373
+ className='flex max-w-full min-w-0 items-center gap-1.5 rounded-md border bg-background py-1 pr-1 pl-2 text-sm'
374
+ >
375
+ {file.done && file.thumbnailUrl ? (
376
+ <img
377
+ src={file.thumbnailUrl}
378
+ alt=''
379
+ className='size-5 shrink-0 rounded-sm object-cover'
380
+ />
381
+ ) : file.done ? (
382
+ <FileIcon
383
+ className='size-3.5 shrink-0 text-muted-foreground'
384
+ aria-hidden='true'
385
+ />
386
+ ) : (
387
+ <Loader2Icon
388
+ className='size-3.5 shrink-0 animate-spin'
389
+ aria-label={fill(labels.uploading, { name: file.name })}
390
+ />
391
+ )}
392
+ <span className='min-w-0 truncate'>{file.name}</span>
393
+ <span className='shrink-0 text-xs text-muted-foreground tabular-nums'>
394
+ {bytes(file.size, locale)}
395
+ </span>
396
+ <Button
397
+ variant='ghost'
398
+ size='icon-xs'
399
+ aria-label={fill(labels.remove, { name: file.name })}
400
+ onClick={() => onRemove(file.key)}
401
+ >
402
+ <XIcon />
403
+ </Button>
404
+ </li>
405
+ ))}
406
+ </ul>
407
+ );
408
+ }
409
+
410
+ export interface AttachmentPanelProps extends Omit<
411
+ AttachmentListProps,
412
+ 'onRemove' | 'className'
413
+ > {
414
+ /** Takes picked, pasted or dropped files; without it, the panel only lists. Nothing renders without files then. */
415
+ readonly onUpload?: (files: readonly File[]) => void;
416
+ /** The names of the files uploading now. */
417
+ readonly uploading?: readonly string[];
418
+ /** Under the title, such as the size limit. */
419
+ readonly hint?: string;
420
+ /** Removes a file after the item confirmed it. */
421
+ readonly onRemove?: (file: AttachmentFile) => Promise<void>;
422
+ readonly className?: string;
423
+ }
424
+
425
+ /**
426
+ * A record's own files as a section: "Upload", or paste and drop while it has focus; removing asks first. Without
427
+ * files it is one compact row.
428
+ */
429
+ export function AttachmentPanel({
430
+ files,
431
+ onUpload,
432
+ uploading = [],
433
+ hint,
434
+ onRemove,
435
+ labels = defaultLabels,
436
+ className,
437
+ ...list
438
+ }: AttachmentPanelProps): ReactElement | null {
439
+ const inputRef = useRef<HTMLInputElement>(null);
440
+ const [dragging, setDragging] = useState(false);
441
+ const [removing, setRemoving] = useState<AttachmentFile | null>(null);
442
+ const [busyId, setBusyId] = useState<string | null>(null);
443
+ if (files.length === 0 && !onUpload) return null;
444
+ const empty = files.length === 0;
445
+ const status =
446
+ uploading.length > 0
447
+ ? fill(labels.uploading, { name: uploading[0] ?? '' })
448
+ : hint;
449
+
450
+ const dropHandlers = onUpload
451
+ ? {
452
+ onDragOver: (event: DragEvent) => {
453
+ if (!event.dataTransfer.types.includes('Files')) return;
454
+ event.preventDefault();
455
+ setDragging(true);
456
+ },
457
+ onDragLeave: () => setDragging(false),
458
+ onDrop: (event: DragEvent) => {
459
+ setDragging(false);
460
+ if (event.dataTransfer.files.length === 0) return;
461
+ event.preventDefault();
462
+ onUpload([...event.dataTransfer.files]);
463
+ },
464
+ onPaste: (event: ClipboardEvent) => {
465
+ const pasted = [...event.clipboardData.files];
466
+ if (pasted.length === 0) return;
467
+ event.preventDefault();
468
+ onUpload(pasted);
469
+ },
470
+ }
471
+ : {};
472
+
473
+ const uploadButton = onUpload ? (
474
+ <>
475
+ <Button
476
+ variant='ghost'
477
+ size='sm'
478
+ disabled={uploading.length > 0}
479
+ onClick={() => inputRef.current?.click()}
480
+ >
481
+ {uploading.length > 0 ? (
482
+ <Loader2Icon data-icon='inline-start' className='animate-spin' />
483
+ ) : (
484
+ <UploadIcon data-icon='inline-start' />
485
+ )}
486
+ {labels.upload}
487
+ </Button>
488
+ <input
489
+ ref={inputRef}
490
+ type='file'
491
+ multiple
492
+ hidden
493
+ data-testid='attachment-file-input'
494
+ onChange={(event) => {
495
+ onUpload([...(event.target.files ?? [])]);
496
+ event.target.value = '';
497
+ }}
498
+ />
499
+ </>
500
+ ) : null;
501
+
502
+ return (
503
+ <section
504
+ aria-label={labels.title}
505
+ tabIndex={onUpload ? 0 : undefined}
506
+ className={cn(
507
+ 'rounded-lg outline-none focus-visible:ring-2 focus-visible:ring-ring',
508
+ empty
509
+ ? 'flex flex-wrap items-center justify-between gap-2 border border-dashed px-4 py-2'
510
+ : 'space-y-3 border bg-card p-4 text-card-foreground',
511
+ dragging && 'border-primary bg-primary/5',
512
+ className,
513
+ )}
514
+ data-slot='attachment-panel'
515
+ {...dropHandlers}
516
+ >
517
+ {empty ? (
518
+ <>
519
+ <div className='flex min-w-0 items-center gap-2 text-sm text-muted-foreground'>
520
+ <PaperclipIcon className='size-4 shrink-0' aria-hidden='true' />
521
+ <h2 className='font-medium'>{labels.title}</h2>
522
+ {status ? (
523
+ <span className='hidden truncate sm:inline'>{status}</span>
524
+ ) : null}
525
+ </div>
526
+ {uploadButton}
527
+ </>
528
+ ) : (
529
+ <>
530
+ <div className='flex items-start justify-between gap-2'>
531
+ <div className='min-w-0'>
532
+ <h2 className='flex items-center gap-2 text-sm font-semibold'>
533
+ {labels.title}
534
+ <span className='text-xs font-normal text-muted-foreground tabular-nums'>
535
+ {files.length}
536
+ </span>
537
+ </h2>
538
+ {status ? (
539
+ <p className='text-xs text-muted-foreground'>{status}</p>
540
+ ) : null}
541
+ </div>
542
+ {uploadButton}
543
+ </div>
544
+ <AttachmentList
545
+ files={files}
546
+ labels={labels}
547
+ removingId={busyId}
548
+ {...list}
549
+ {...(onRemove ? { onRemove: setRemoving } : {})}
550
+ />
551
+ </>
552
+ )}
553
+ <AlertDialog
554
+ open={removing !== null}
555
+ onOpenChange={(open) => {
556
+ if (!open) setRemoving(null);
557
+ }}
558
+ >
559
+ <AlertDialogContent>
560
+ <AlertDialogHeader>
561
+ <AlertDialogTitle>{labels.removeTitle}</AlertDialogTitle>
562
+ <AlertDialogDescription>
563
+ {fill(labels.removeDescription, { name: removing?.name ?? '' })}
564
+ </AlertDialogDescription>
565
+ </AlertDialogHeader>
566
+ <AlertDialogFooter>
567
+ <AlertDialogCancel disabled={busyId !== null}>
568
+ {labels.cancel}
569
+ </AlertDialogCancel>
570
+ <AlertDialogAction
571
+ variant='destructive'
572
+ disabled={busyId !== null}
573
+ onClick={() => {
574
+ if (!removing || !onRemove) return;
575
+ setBusyId(removing.id);
576
+ void onRemove(removing)
577
+ .then(() => setRemoving(null))
578
+ .catch(() => undefined)
579
+ .finally(() => setBusyId(null));
580
+ }}
581
+ >
582
+ {labels.removeConfirm}
583
+ </AlertDialogAction>
584
+ </AlertDialogFooter>
585
+ </AlertDialogContent>
586
+ </AlertDialog>
587
+ </section>
588
+ );
589
+ }