@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,18 @@
1
+ // Built-in errors remain readable when passed between Registry components.
2
+ const messageKeys = new Map<string, string>([
3
+ ['File upload failed.', 'uploadFailed'],
4
+ ['The maximum number of files has been reached.', 'fileLimitReached'],
5
+ ['File exceeds the maximum size.', 'fileTooLarge'],
6
+ ['File type is not allowed.', 'fileTypeNotAllowed'],
7
+ ['File removal failed.', 'removalFailed'],
8
+ ['File URL is not allowed.', 'urlNotAllowed'],
9
+ ['File download failed.', 'downloadFailed'],
10
+ ['File URL is missing or not allowed.', 'invalidUrl'],
11
+ ['Unable to load the PDF preview.', 'pdfPreviewFailed'],
12
+ ['The file URL returned HTML or XML instead of a PDF.', 'pdfPreviewMarkup'],
13
+ ['Unable to load the file preview.', 'previewFailed'],
14
+ ]);
15
+
16
+ export function messageKey(message: string): string {
17
+ return messageKeys.get(message) ?? message;
18
+ }
@@ -0,0 +1,69 @@
1
+ import type { ReactNode } from 'react';
2
+ import type {
3
+ ClientFileRepository,
4
+ FileRecord,
5
+ } from '@nocobase/app-plugin-file/client';
6
+
7
+ export type { FileRecord } from '@nocobase/app-plugin-file/client';
8
+ export type FileUploadStatus = 'idle' | 'uploading' | 'error';
9
+
10
+ export interface FileUiLabels {
11
+ readonly choose?: string;
12
+ readonly empty?: string;
13
+ readonly preview?: string;
14
+ readonly download?: string;
15
+ readonly remove?: string;
16
+ readonly retry?: string;
17
+ }
18
+
19
+ export interface FileUploadFieldProps {
20
+ readonly repository: ClientFileRepository;
21
+ readonly value: readonly FileRecord[];
22
+ readonly onChange: (value: readonly FileRecord[]) => void;
23
+ readonly onError?: (error: Error) => void;
24
+ readonly onStatusChange?: (status: FileUploadStatus) => void;
25
+ readonly multiple?: boolean;
26
+ readonly accept?: readonly string[];
27
+ readonly maxSize?: number;
28
+ readonly maxFiles?: number;
29
+ readonly disabled?: boolean;
30
+ /** Deletes metadata only; false removes the selection without deleting a record. */
31
+ readonly removeOnDelete?: boolean;
32
+ readonly labels?: FileUiLabels;
33
+ }
34
+
35
+ export interface FileListProps {
36
+ readonly files: readonly FileRecord[];
37
+ readonly onPreview?: (file: FileRecord) => void;
38
+ readonly onDownload?: (file: FileRecord) => void;
39
+ readonly onRemove?: (file: FileRecord) => void | Promise<void>;
40
+ readonly onError?: (error: Error) => void;
41
+ readonly labels?: FileUiLabels;
42
+ readonly emptyState?: ReactNode;
43
+ }
44
+
45
+ export interface FilePreviewDialogProps {
46
+ readonly files: readonly FileRecord[];
47
+ readonly initialIndex?: number;
48
+ readonly open: boolean;
49
+ readonly onOpenChange: (open: boolean) => void;
50
+ readonly onError?: (error: Error) => void;
51
+ readonly download?: boolean;
52
+ readonly labels?: FileUiLabels;
53
+ }
54
+
55
+ export interface FilePreviewFieldProps {
56
+ readonly files: readonly FileRecord[];
57
+ readonly labels?: FileUiLabels;
58
+ readonly emptyState?: ReactNode;
59
+ readonly showFilenames?: boolean;
60
+ readonly onError?: (error: Error) => void;
61
+ }
62
+
63
+ export interface FileThumbnailProps {
64
+ readonly file: FileRecord;
65
+ readonly url?: string;
66
+ readonly alt?: string;
67
+ /** Show the file-type icon without loading the image. */
68
+ readonly iconOnly?: boolean;
69
+ }
@@ -0,0 +1,140 @@
1
+ import { useTranslation } from '@nocobase/i18n/client';
2
+ import { SendIcon } from 'lucide-react';
3
+ import { type KeyboardEvent, type ReactElement, useState } from 'react';
4
+
5
+ import { Button } from '#components/ui/button';
6
+ import { Kbd } from '#components/ui/kbd';
7
+ import { Spinner } from '#components/ui/spinner';
8
+ import { Textarea } from '#components/ui/textarea';
9
+
10
+ export type ApprovalDecision = 'approve' | 'reject';
11
+
12
+ export interface DecisionActionsBarProps {
13
+ readonly itemTitle: string;
14
+ /** Other words for the two decisions, such as a design proposal's "Approve for development" and "Send back". */
15
+ readonly labels?: Partial<Record<ApprovalDecision, string>>;
16
+ /** The decision in flight. */
17
+ readonly pending: ApprovalDecision | null;
18
+ readonly disabled: boolean;
19
+ readonly onRun: (decision: ApprovalDecision, comment: string) => void;
20
+ }
21
+
22
+ /**
23
+ * The buttons of an approve-or-reject decision, for a renderer's `Actions`: approve (primary, first) decides at once;
24
+ * reject (outline) opens an inline comment field first, which it needs (Enter sends, Shift + Enter starts a new line,
25
+ * as the hint beside the buttons says). What the decision is about opens from the detail pane's title.
26
+ */
27
+ export function DecisionActionsBar({
28
+ itemTitle,
29
+ pending,
30
+ disabled,
31
+ onRun,
32
+ labels,
33
+ }: DecisionActionsBarProps): ReactElement {
34
+ const { t } = useTranslation();
35
+ const [commenting, setCommenting] = useState<ApprovalDecision | null>(null);
36
+ const [comment, setComment] = useState('');
37
+ const label = (decision: ApprovalDecision): string =>
38
+ labels?.[decision] ??
39
+ (decision === 'approve'
40
+ ? t('inbox.request.approve', { defaultValue: 'Approve' })
41
+ : t('inbox.request.reject', { defaultValue: 'Reject' }));
42
+
43
+ function reset(): void {
44
+ setCommenting(null);
45
+ setComment('');
46
+ }
47
+
48
+ function send(): void {
49
+ if (!commenting || !comment.trim()) return;
50
+ onRun(commenting, comment.trim());
51
+ reset();
52
+ }
53
+
54
+ if (commenting)
55
+ return (
56
+ <div className='w-full space-y-2' data-commenting={commenting}>
57
+ <Textarea
58
+ value={comment}
59
+ rows={3}
60
+ autoFocus
61
+ placeholder={t('inbox.request.commentPlaceholder', {
62
+ defaultValue: 'Write something…',
63
+ })}
64
+ aria-label={t('inbox.request.commentFor', {
65
+ action: label(commenting),
66
+ title: itemTitle,
67
+ defaultValue: '{{action}}: {{title}}',
68
+ })}
69
+ onChange={(event) => setComment(event.target.value)}
70
+ onKeyDown={(event) => {
71
+ if (isSubmitEnter(event)) {
72
+ event.preventDefault();
73
+ send();
74
+ }
75
+ if (event.key === 'Escape') {
76
+ event.stopPropagation();
77
+ reset();
78
+ }
79
+ }}
80
+ />
81
+ <div className='flex flex-wrap items-center justify-end gap-2'>
82
+ <SubmitHint />
83
+ <Button variant='outline' onClick={reset}>
84
+ {t('inbox.cancel', { defaultValue: 'Cancel' })}
85
+ </Button>
86
+ <Button disabled={disabled || !comment.trim()} onClick={send}>
87
+ <SendIcon data-icon='inline-start' />
88
+ {label(commenting)}
89
+ </Button>
90
+ </div>
91
+ </div>
92
+ );
93
+
94
+ return (
95
+ <div className='flex flex-wrap items-center gap-2'>
96
+ <Button
97
+ disabled={disabled || pending !== null}
98
+ data-action='approve'
99
+ onClick={() => onRun('approve', '')}
100
+ >
101
+ {pending === 'approve' ? <Spinner data-icon='inline-start' /> : null}
102
+ {label('approve')}
103
+ </Button>
104
+ <Button
105
+ variant='outline'
106
+ disabled={disabled || pending !== null}
107
+ data-action='reject'
108
+ onClick={() => setCommenting('reject')}
109
+ >
110
+ {pending === 'reject' ? <Spinner data-icon='inline-start' /> : null}
111
+ {label('reject')}
112
+ </Button>
113
+ </div>
114
+ );
115
+ }
116
+
117
+ /** Enter sends a message box; Shift + Enter starts a new line, Alt + Enter is left alone, and an IME's Enter only confirms. */
118
+ function isSubmitEnter(event: KeyboardEvent): boolean {
119
+ return (
120
+ event.key === 'Enter' &&
121
+ !event.shiftKey &&
122
+ !event.altKey &&
123
+ !event.nativeEvent.isComposing &&
124
+ event.keyCode !== 229
125
+ );
126
+ }
127
+
128
+ /** The keys of the comment box. */
129
+ function SubmitHint(): ReactElement {
130
+ const { t } = useTranslation();
131
+ return (
132
+ <span className='mr-auto inline-flex items-center gap-1 text-xs text-muted-foreground'>
133
+ <Kbd>Enter</Kbd>
134
+ {t('inbox.request.quickSend', { defaultValue: 'to send' })}
135
+ <Kbd className='ml-2'>Shift</Kbd>
136
+ <Kbd>Enter</Kbd>
137
+ {t('inbox.request.newLine', { defaultValue: 'for a new line' })}
138
+ </span>
139
+ );
140
+ }
@@ -0,0 +1,31 @@
1
+ /**
2
+ * The inbox's own wording (the frame around each contributor's parts). It is taken outside a contributor's namespace
3
+ * (`ContributorScope`) and passed in, so the frame reads the same whichever namespace the contributor's parts use.
4
+ */
5
+ import { useTranslation } from '@nocobase/i18n/client';
6
+
7
+ import type { InboxAction } from './inbox-actions.js';
8
+ import type { InboxTranslate } from './model.js';
9
+
10
+ export interface InboxFrameText {
11
+ readonly t: InboxTranslate;
12
+ readonly language: string;
13
+ }
14
+
15
+ /** The inbox's own wording, in the namespace the inbox renders in. Call it outside `ContributorScope`. */
16
+ export function useInboxFrameText(): InboxFrameText {
17
+ const { t, i18n } = useTranslation();
18
+ return { t, language: i18n.language };
19
+ }
20
+
21
+ /** The label of an action, in the inbox's wording. */
22
+ export function actionLabel(t: InboxTranslate, action: InboxAction): string {
23
+ switch (action) {
24
+ case 'read':
25
+ return t('inbox.actions.read', { defaultValue: 'Mark as read' });
26
+ case 'unread':
27
+ return t('inbox.actions.unread', { defaultValue: 'Mark as unread' });
28
+ case 'delete':
29
+ return t('inbox.actions.delete', { defaultValue: 'Delete' });
30
+ }
31
+ }
@@ -0,0 +1,21 @@
1
+ import {
2
+ MailIcon,
3
+ MailOpenIcon,
4
+ Trash2Icon,
5
+ type LucideIcon,
6
+ } from 'lucide-react';
7
+
8
+ import type { InboxEntry } from './model.js';
9
+
10
+ /** What a card offers: read ↔ unread, and delete (the in-app inbox has no archive). */
11
+ export type InboxAction = 'read' | 'unread' | 'delete';
12
+
13
+ export const INBOX_ACTION_ICON: Readonly<Record<InboxAction, LucideIcon>> = {
14
+ read: MailOpenIcon,
15
+ unread: MailIcon,
16
+ delete: Trash2Icon,
17
+ };
18
+
19
+ export function inboxActionsFor(entry: InboxEntry): readonly InboxAction[] {
20
+ return [entry.item.readAt ? 'unread' : 'read', 'delete'];
21
+ }
@@ -0,0 +1,386 @@
1
+ import { ArrowLeftIcon, ArrowUpRightIcon, InboxIcon } from 'lucide-react';
2
+ import type { ReactElement, ReactNode } from 'react';
3
+ import { Link } from 'react-router';
4
+
5
+ import { Button } from '#components/ui/button';
6
+ import {
7
+ Empty,
8
+ EmptyHeader,
9
+ EmptyMedia,
10
+ EmptyTitle,
11
+ } from '#components/ui/empty';
12
+ import { Skeleton } from '#components/ui/skeleton';
13
+ import {
14
+ Tooltip,
15
+ TooltipContent,
16
+ TooltipTrigger,
17
+ } from '#components/ui/tooltip';
18
+
19
+ import {
20
+ INBOX_ACTION_ICON,
21
+ inboxActionsFor,
22
+ type InboxAction,
23
+ } from './inbox-actions.js';
24
+ import {
25
+ actionLabel,
26
+ useInboxFrameText,
27
+ type InboxFrameText,
28
+ } from './frame.js';
29
+ import { InboxTypeIcon } from './inbox-item.js';
30
+ import { entryLink, isSettled, type InboxEntry } from './model.js';
31
+ import { ContributorScope } from './registry-scope.js';
32
+ import {
33
+ rendererKey,
34
+ useRendererOf,
35
+ type InboxCanAct,
36
+ type InboxEntryContext,
37
+ type InboxEntryRenderer,
38
+ } from './registry.js';
39
+
40
+ /** What the detail pane's toolbar slot receives about the item shown. */
41
+ export interface InboxDetailToolbarInput {
42
+ readonly entry: InboxEntry;
43
+ /** The title shown, as its contributor words it. */
44
+ readonly title: string;
45
+ /** What its contributor says the item is about (`InboxEntryRenderer.context`). */
46
+ readonly context: InboxEntryContext;
47
+ }
48
+
49
+ export interface InboxDetailProps {
50
+ readonly entry: InboxEntry | null;
51
+ readonly busy: boolean;
52
+ readonly onBack: () => void;
53
+ readonly onAction: (entry: InboxEntry, action: InboxAction) => void;
54
+ readonly onOpen: (entry: InboxEntry) => void;
55
+ /**
56
+ * What the application places before the read and delete buttons, such as an "Ask agent" button; rendered inside the
57
+ * item's contributor scope, so it may also register what the item is about with a page context.
58
+ */
59
+ readonly toolbar?: (input: InboxDetailToolbarInput) => ReactNode;
60
+ /** The prefix of the element ids it renders. */
61
+ readonly idPrefix?: string;
62
+ }
63
+
64
+ /**
65
+ * The inbox's detail pane: everything needed to decide without leaving the inbox. Its header (`InboxDetailHeader`)
66
+ * holds what kind of item it is, with read / delete icon buttons; the title, which opens what the item is about when it
67
+ * has somewhere to go; and right under it the contributor's decision actions while the viewer may still act. Below
68
+ * it, the contributor's body.
69
+ *
70
+ * What the item says and offers is its contributor's (`registry.ts`), rendered in the contributor's namespace; an item
71
+ * nobody renders shows its title and body as sent, and nothing to act on.
72
+ */
73
+ export function InboxDetail({
74
+ entry,
75
+ idPrefix = 'nocobase-inbox',
76
+ ...props
77
+ }: InboxDetailProps): ReactElement {
78
+ const frame = useInboxFrameText();
79
+ const rendererOf = useRendererOf();
80
+ if (!entry)
81
+ return (
82
+ <div className='flex h-full items-center justify-center p-6'>
83
+ <Empty className='min-h-48'>
84
+ <EmptyHeader>
85
+ <EmptyMedia variant='icon'>
86
+ <InboxIcon />
87
+ </EmptyMedia>
88
+ <EmptyTitle>
89
+ {frame.t('inbox.nothingSelected', {
90
+ defaultValue: 'Select an item to view it',
91
+ })}
92
+ </EmptyTitle>
93
+ </EmptyHeader>
94
+ </Empty>
95
+ </div>
96
+ );
97
+ const renderer = rendererOf(entry);
98
+ return (
99
+ <ContributorScope
100
+ key={`${entry.item.id}:${rendererKey(renderer)}`}
101
+ namespace={renderer.namespace}
102
+ resources={renderer.resources}
103
+ >
104
+ <EntryDetail
105
+ {...props}
106
+ entry={entry}
107
+ renderer={renderer}
108
+ frame={frame}
109
+ idPrefix={idPrefix}
110
+ />
111
+ </ContributorScope>
112
+ );
113
+ }
114
+
115
+ const noModel = (): never => null as never;
116
+ const noAction = (): InboxCanAct => ({ state: 'none' });
117
+
118
+ function EntryDetail({
119
+ entry,
120
+ renderer,
121
+ frame,
122
+ busy,
123
+ onBack,
124
+ onAction,
125
+ onOpen,
126
+ toolbar,
127
+ idPrefix,
128
+ }: Omit<InboxDetailProps, 'entry'> & {
129
+ readonly entry: InboxEntry;
130
+ readonly renderer: InboxEntryRenderer<never>;
131
+ readonly frame: InboxFrameText;
132
+ readonly idPrefix: string;
133
+ }): ReactElement {
134
+ const { t } = frame;
135
+ const { item, notice } = entry;
136
+ // The renderer is fixed for this component (keyed by it), so its hooks always run in the same order.
137
+ const wording = renderer.useWording();
138
+ const useModel = renderer.useModel ?? noModel;
139
+ const model = useModel(entry);
140
+ const useCanAct = renderer.useCanAct ?? noAction;
141
+ const canAct = useCanAct(entry, model);
142
+ const settled = isSettled(entry);
143
+ const { title, sentence } = wording.text(entry, model);
144
+ const link = entryLink(entry);
145
+ const context = renderer.context?.(entry, model) ?? {};
146
+ const { Actions, Body } = renderer;
147
+ const parts = {
148
+ entry,
149
+ model,
150
+ title,
151
+ onOpen: link ? () => onOpen(entry) : undefined,
152
+ onDecided: () => {
153
+ if (!item.readAt) onAction(entry, 'read');
154
+ },
155
+ };
156
+ const titleId = `${idPrefix}-detail-title`;
157
+ const outcome = notice?.outcome
158
+ ? (wording.outcome?.(notice.outcome) ??
159
+ t(`inbox.outcomes.${notice.outcome}`, { defaultValue: notice.outcome }))
160
+ : null;
161
+
162
+ return (
163
+ <article
164
+ className='flex min-h-full flex-col'
165
+ aria-labelledby={titleId}
166
+ data-testid={`${idPrefix}-detail`}
167
+ >
168
+ <InboxDetailHeader
169
+ titleId={titleId}
170
+ icon={<InboxTypeIcon entry={entry} renderer={renderer} />}
171
+ kind={wording.label(entry, 'detail')}
172
+ status={
173
+ settled
174
+ ? [t('inbox.resolved', { defaultValue: 'Handled' }), outcome]
175
+ .filter(Boolean)
176
+ .join(' · ')
177
+ : undefined
178
+ }
179
+ tools={
180
+ <>
181
+ {toolbar?.({ entry, title, context })}
182
+ {inboxActionsFor(entry).map((action) => {
183
+ const Icon = INBOX_ACTION_ICON[action];
184
+ return (
185
+ <IconAction
186
+ key={action}
187
+ label={actionLabel(t, action)}
188
+ disabled={busy}
189
+ onClick={() => onAction(entry, action)}
190
+ >
191
+ <Icon />
192
+ </IconAction>
193
+ );
194
+ })}
195
+ </>
196
+ }
197
+ title={title}
198
+ href={link}
199
+ openLabel={wording.open ?? t('inbox.open', { defaultValue: 'Open' })}
200
+ onOpen={() => onOpen(entry)}
201
+ sentence={sentence}
202
+ actions={
203
+ canAct.state === 'loading' ? (
204
+ <div
205
+ role='status'
206
+ aria-label={t('inbox.loading', { defaultValue: 'Loading' })}
207
+ className='flex gap-2'
208
+ >
209
+ <Skeleton className='h-8 w-20' />
210
+ <Skeleton className='h-8 w-20' />
211
+ </div>
212
+ ) : canAct.state === 'no' ? (
213
+ <p className='text-sm text-muted-foreground'>{canAct.reason}</p>
214
+ ) : canAct.state === 'yes' && Actions ? (
215
+ <Actions {...parts} />
216
+ ) : null
217
+ }
218
+ onBack={onBack}
219
+ backLabel={t('inbox.back', { defaultValue: 'Back to the list' })}
220
+ />
221
+ <div className='flex flex-1 flex-col gap-6 p-5 md:px-6'>
222
+ {Body ? <Body {...parts} /> : null}
223
+ </div>
224
+ </article>
225
+ );
226
+ }
227
+
228
+ export interface InboxDetailHeaderProps {
229
+ /** The id of the title, for the pane's `aria-labelledby`. */
230
+ readonly titleId: string;
231
+ /** The kind's icon. */
232
+ readonly icon: ReactNode;
233
+ /** What kind of item it is, in words. */
234
+ readonly kind: string;
235
+ /** How it ended, beside the kind, such as "Handled · Approved". */
236
+ readonly status?: ReactNode;
237
+ /** Compact icon buttons at the end of the kind line: read, delete, and what the application adds. */
238
+ readonly tools?: ReactNode;
239
+ /** The title, or a placeholder for it while the item loads. */
240
+ readonly title: ReactNode;
241
+ /** Where the title goes; a plain heading without it. */
242
+ readonly href?: string | null;
243
+ /** What following the title does, such as "Open issue", shown on hover. */
244
+ readonly openLabel?: string;
245
+ /** Called on a plain click of the title instead of following `href`; a click with a modifier key still follows it. */
246
+ readonly onOpen?: () => void;
247
+ /** The one sentence under the title. */
248
+ readonly sentence?: ReactNode;
249
+ /** A line of facts under the sentence, such as a status and who proposed it. */
250
+ readonly meta?: ReactNode;
251
+ /** The decision's buttons, or why there are none, right under the title. */
252
+ readonly actions?: ReactNode;
253
+ readonly onBack: () => void;
254
+ readonly backLabel: string;
255
+ }
256
+
257
+ /**
258
+ * The header every kind of item shares in the detail pane: a small kind line (icon, kind, how it ended, and compact
259
+ * icon buttons at its end), then the title, which is itself the link to what the item is about (an arrow after it says
260
+ * so), the sentence, and the decision's buttons right under them. A category with a detail of its own (a collection's
261
+ * `Detail`) renders it too, so every kind reads the same.
262
+ */
263
+ export function InboxDetailHeader({
264
+ titleId,
265
+ icon,
266
+ kind,
267
+ status,
268
+ tools,
269
+ title,
270
+ href,
271
+ openLabel,
272
+ onOpen,
273
+ sentence,
274
+ meta,
275
+ actions,
276
+ onBack,
277
+ backLabel,
278
+ }: InboxDetailHeaderProps): ReactElement {
279
+ return (
280
+ <header className='sticky top-0 z-10 flex flex-col gap-3 border-b bg-background/95 p-5 backdrop-blur-md md:px-6'>
281
+ <div className='flex min-h-7 items-center gap-2'>
282
+ <Button
283
+ variant='ghost'
284
+ size='icon-sm'
285
+ className='-ml-1.5 lg:hidden'
286
+ aria-label={backLabel}
287
+ onClick={onBack}
288
+ >
289
+ <ArrowLeftIcon />
290
+ </Button>
291
+ {icon}
292
+ <span className='min-w-0 truncate text-sm text-muted-foreground'>
293
+ {kind}
294
+ </span>
295
+ {status ? (
296
+ <span className='shrink-0 text-xs text-muted-foreground'>
297
+ {status}
298
+ </span>
299
+ ) : null}
300
+ {tools ? (
301
+ <div className='ml-auto flex items-center gap-0.5'>{tools}</div>
302
+ ) : null}
303
+ </div>
304
+ <div className='flex flex-col gap-1'>
305
+ <h2
306
+ id={titleId}
307
+ className='font-heading text-lg font-semibold tracking-tight wrap-anywhere'
308
+ >
309
+ {href ? (
310
+ <Link
311
+ to={href}
312
+ title={openLabel}
313
+ data-slot='inbox-detail-link'
314
+ className='rounded-sm underline-offset-4 outline-none hover:underline focus-visible:ring-3 focus-visible:ring-ring/50'
315
+ onClick={(event) => {
316
+ if (
317
+ !onOpen ||
318
+ event.button !== 0 ||
319
+ event.metaKey ||
320
+ event.ctrlKey ||
321
+ event.shiftKey ||
322
+ event.altKey
323
+ )
324
+ return;
325
+ event.preventDefault();
326
+ onOpen();
327
+ }}
328
+ >
329
+ {title}
330
+ <ArrowUpRightIcon
331
+ aria-hidden='true'
332
+ className='ml-1 inline size-4 align-baseline text-muted-foreground'
333
+ />
334
+ </Link>
335
+ ) : (
336
+ title
337
+ )}
338
+ </h2>
339
+ {sentence ? (
340
+ <p className='text-sm text-muted-foreground wrap-anywhere'>
341
+ {sentence}
342
+ </p>
343
+ ) : null}
344
+ {meta ? (
345
+ <div className='flex flex-wrap items-center gap-x-2 gap-y-1 text-xs text-muted-foreground'>
346
+ {meta}
347
+ </div>
348
+ ) : null}
349
+ </div>
350
+ {actions ? (
351
+ <div className='flex flex-wrap items-center gap-2'>{actions}</div>
352
+ ) : null}
353
+ </header>
354
+ );
355
+ }
356
+
357
+ function IconAction({
358
+ label,
359
+ disabled,
360
+ onClick,
361
+ children,
362
+ }: {
363
+ readonly label: string;
364
+ readonly disabled?: boolean;
365
+ readonly onClick: () => void;
366
+ readonly children: ReactNode;
367
+ }): ReactElement {
368
+ return (
369
+ <Tooltip>
370
+ <TooltipTrigger
371
+ render={
372
+ <Button
373
+ variant='ghost'
374
+ size='icon-sm'
375
+ aria-label={label}
376
+ disabled={disabled}
377
+ onClick={onClick}
378
+ />
379
+ }
380
+ >
381
+ {children}
382
+ </TooltipTrigger>
383
+ <TooltipContent side='bottom'>{label}</TooltipContent>
384
+ </Tooltip>
385
+ );
386
+ }