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

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 (249) 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 +434 -0
  6. package/CLAUDE.md +9 -0
  7. package/Dockerfile +132 -0
  8. package/Dockerfile.dockerignore +45 -0
  9. package/README.MD +485 -0
  10. package/cli/plugins.ts +8 -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 +37 -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 +156 -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 +198 -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/api.ts +8 -0
  165. package/server/config/app.ts +40 -0
  166. package/server/config/auth.ts +36 -0
  167. package/server/config/authorization.ts +18 -0
  168. package/server/config/caching.ts +21 -0
  169. package/server/config/database.ts +22 -0
  170. package/server/config/drive.ts +30 -0
  171. package/server/config/i18n.ts +17 -0
  172. package/server/config/index.ts +67 -0
  173. package/server/config/jobs.ts +27 -0
  174. package/server/config/logging.ts +23 -0
  175. package/server/config/notification.ts +11 -0
  176. package/server/config/queue.ts +28 -0
  177. package/server/config/scheduler.ts +23 -0
  178. package/server/config/secrets.ts +10 -0
  179. package/server/config/server.ts +26 -0
  180. package/server/config/session.ts +60 -0
  181. package/server/config/snowflake.ts +21 -0
  182. package/server/config/spa.ts +24 -0
  183. package/server/config/users.ts +11 -0
  184. package/server/config/workflow.ts +24 -0
  185. package/server/config.ts +24 -0
  186. package/server/embedded.ts +23 -0
  187. package/server/locales/en-US.ts +10 -0
  188. package/server/locales/index.ts +10 -0
  189. package/server/locales/zh-CN.ts +5 -0
  190. package/server/plugins.ts +37 -0
  191. package/server/providers/index.ts +5 -0
  192. package/server/routes/index.ts +6 -0
  193. package/server/runtime.ts +24 -0
  194. package/server/standalone.ts +29 -0
  195. package/server/tsconfig.json +4 -0
  196. package/tailwind.config.mjs +59 -0
  197. package/tests/components/app-layout-routes.test.tsx +132 -0
  198. package/tests/components/app-title.test.tsx +57 -0
  199. package/tests/components/auth-i18n.test.tsx +87 -0
  200. package/tests/components/back-button.test.tsx +80 -0
  201. package/tests/components/file-preview.test.tsx +101 -0
  202. package/tests/components/header-hover.test.tsx +87 -0
  203. package/tests/components/inbox.test.tsx +139 -0
  204. package/tests/components/language-switcher.test.tsx +298 -0
  205. package/tests/components/layout-containers.test.tsx +15 -0
  206. package/tests/components/layout-i18n.test.tsx +187 -0
  207. package/tests/components/loading.test.tsx +25 -0
  208. package/tests/components/navigation-menu.test.tsx +355 -0
  209. package/tests/components/page-harness.test.tsx +318 -0
  210. package/tests/components/primitive-labels.test.tsx +80 -0
  211. package/tests/components/sidebar-permissions.test.tsx +110 -0
  212. package/tests/components/sidebar-preference.test.tsx +42 -0
  213. package/tests/fixtures/theme-tokens.html +12 -0
  214. package/tests/fixtures/theme-tokens.tsx +111 -0
  215. package/tests/logic/account-permissions.test.tsx +204 -0
  216. package/tests/logic/app-locale-coverage.test.ts +136 -0
  217. package/tests/logic/app-server.test.ts +1589 -0
  218. package/tests/logic/asset-url-build.test.ts +106 -0
  219. package/tests/logic/client-auth.test.tsx +122 -0
  220. package/tests/logic/client-plugin-registry.test.ts +63 -0
  221. package/tests/logic/client-route-recovery.test.tsx +80 -0
  222. package/tests/logic/client-routes.test.ts +126 -0
  223. package/tests/logic/client-runtime.test.ts +112 -0
  224. package/tests/logic/client-settings.test.tsx +841 -0
  225. package/tests/logic/client-shell.test.tsx +466 -0
  226. package/tests/logic/client-theme.test.tsx +441 -0
  227. package/tests/logic/config.test.ts +197 -0
  228. package/tests/logic/dev-client-watch.test.ts +108 -0
  229. package/tests/logic/dev-routes-production-build.test.ts +168 -0
  230. package/tests/logic/device-code-migration.test.ts +70 -0
  231. package/tests/logic/lifecycle.test.ts +221 -0
  232. package/tests/logic/plugin-commands.test.ts +69 -0
  233. package/tests/logic/plugin-page-authz.test.ts +53 -0
  234. package/tests/logic/route-navigation.test.ts +66 -0
  235. package/tests/logic/route-overlay.test.tsx +353 -0
  236. package/tests/logic/server-imports.test.ts +88 -0
  237. package/tests/logic/tailwind-sources.test.ts +151 -0
  238. package/tests/logic/template-package-name.test.ts +88 -0
  239. package/tests/logic/theme-preferences.test.ts +86 -0
  240. package/tests/logic/theme-tokens.test.ts +248 -0
  241. package/tests/logic/toaster.test.tsx +108 -0
  242. package/tests/logic/user-menu-sign-out.test.tsx +98 -0
  243. package/tests/setup/client-config.ts +27 -0
  244. package/tsconfig.json +11 -0
  245. package/tsconfig.node.json +12 -0
  246. package/tsconfig.server.json +19 -0
  247. package/vite.config.ts +92 -0
  248. package/vitest.config.ts +44 -0
  249. package/README.md +0 -3
@@ -0,0 +1,57 @@
1
+ import type { InboxLocale } from './en-US.js';
2
+
3
+ const zhCN: InboxLocale = {
4
+ 'inbox.title': '收件箱',
5
+ 'inbox.description': '发给你的通知和请求。',
6
+ 'inbox.views.all': '全部',
7
+ 'inbox.views.todo': '待处理',
8
+ 'inbox.kinds.label': '类型',
9
+ 'inbox.kinds.all': '全部类型',
10
+ 'inbox.kinds.decision': '决定',
11
+ 'inbox.kinds.info': '通知',
12
+ 'inbox.tabs.decision': '待我决定',
13
+ 'inbox.tabs.info': '通知',
14
+ 'inbox.clearFilter': '清除筛选',
15
+ 'inbox.noMatchDescription': '清除筛选,查看所有类型。',
16
+ 'inbox.readAll': '全部标为已读',
17
+ 'inbox.allRead': '已全部标为已读。',
18
+ 'inbox.loading': '加载中',
19
+ 'inbox.loadFailed': '无法加载收件箱',
20
+ 'inbox.loadFailedDescription': '请检查网络后重试。',
21
+ 'inbox.retry': '重试',
22
+ 'inbox.loadMore': '加载更多',
23
+ 'inbox.emptyAll': '这里空空如也',
24
+ 'inbox.empty.todo': '没有等你处理的事项',
25
+ 'inbox.empty.decision': '没有等你决定的事项',
26
+ 'inbox.empty.info': '没有通知',
27
+ 'inbox.emptyDescription': '发给你的通知和请求会出现在这里。',
28
+ 'inbox.unread': '未读',
29
+ 'inbox.unreadCount': '{{count}} 条未读',
30
+ 'inbox.pendingDecisions': '{{count}} 项待处理',
31
+ 'inbox.resolved': '已处理',
32
+ 'inbox.outcomes.approved': '已批准',
33
+ 'inbox.outcomes.rejected': '已驳回',
34
+ 'inbox.outcomes.withdrawn': '已撤回',
35
+ 'inbox.count': '×{{count}}',
36
+ 'inbox.actionsFor': '{{title}} 的操作',
37
+ 'inbox.actions.read': '标为已读',
38
+ 'inbox.actions.unread': '标为未读',
39
+ 'inbox.actions.delete': '删除',
40
+ 'inbox.deleted': '已删除。',
41
+ 'inbox.requestFailed': '请求失败,请重试。',
42
+ 'inbox.forbidden': '你没有执行此操作的权限。',
43
+ 'inbox.nothingSelected': '选择一条查看',
44
+ 'inbox.back': '返回列表',
45
+ 'inbox.open': '打开',
46
+ 'inbox.keyMove': '移动',
47
+ 'inbox.keyOpen': '打开',
48
+ 'inbox.cancel': '取消',
49
+ 'inbox.request.approve': '批准',
50
+ 'inbox.request.reject': '驳回',
51
+ 'inbox.request.commentPlaceholder': '写点什么…',
52
+ 'inbox.request.commentFor': '{{action}}:{{title}}',
53
+ 'inbox.request.quickSend': '发送',
54
+ 'inbox.request.newLine': '换行',
55
+ };
56
+
57
+ export default zhCN;
@@ -0,0 +1,428 @@
1
+ /**
2
+ * The inbox as the page shows it: each in-app item of `@nocobase/app-plugin-notification-in-app` with what the
3
+ * application's `InboxSource` knows about it (`source.ts`), sorted into categories, and the small rules the list
4
+ * follows. How each item reads is its contributor's (`registry.ts`).
5
+ */
6
+ import type { InboxItem } from '@nocobase/app-plugin-notification-in-app/client/inbox';
7
+ import type { ComponentType, ReactNode } from 'react';
8
+
9
+ /** What an item is about, when it is about one thing: an issue (`{ type: 'issue', id, label: 'PM-12' }`), an app… */
10
+ export interface InboxSubject {
11
+ readonly type: string;
12
+ readonly id: string;
13
+ /** How to name it in a list, such as an issue's identifier. */
14
+ readonly label: string | null;
15
+ }
16
+
17
+ /** A JSON value. */
18
+ export type InboxJson =
19
+ | string
20
+ | number
21
+ | boolean
22
+ | null
23
+ | readonly InboxJson[]
24
+ | { readonly [key: string]: InboxJson };
25
+
26
+ /** The sender's own values, as JSON: what its renderer words and shows the item from. */
27
+ export interface InboxData {
28
+ readonly [key: string]: InboxJson;
29
+ }
30
+
31
+ /** What the application knows about one in-app item, found by the item's `notificationId`. */
32
+ export interface InboxNotice {
33
+ readonly notificationId: string;
34
+ /** Who sent it: the contributor's id, such as `projects`; its renderer is registered under it. */
35
+ readonly source: string;
36
+ /** What kind of item it is, such as `decision` or `info`; the categories sort by it. */
37
+ readonly kind: string;
38
+ /** What happened, such as `approval_requested`; the contributor's renderer words it. */
39
+ readonly type: string;
40
+ readonly subject: InboxSubject | null;
41
+ /** The contributor's own key of the decision it asks for; null for information. */
42
+ readonly decisionKey: string | null;
43
+ readonly data: InboxData | null;
44
+ /** How many notices of the same group this item stands for (`×N` when more than one). */
45
+ readonly count: number;
46
+ /** Set once a decision no longer waits, or once information no longer holds. */
47
+ readonly resolvedAt: string | null;
48
+ /** How a decision ended, in its contributor's words (`approved`, `rejected`, `withdrawn`…). */
49
+ readonly outcome: string | null;
50
+ }
51
+
52
+ export interface InboxEntry {
53
+ readonly item: InboxItem;
54
+ /** Null for an item the source knows nothing about: shown as information. */
55
+ readonly notice: InboxNotice | null;
56
+ }
57
+
58
+ /** The kind of an item the source knows nothing about. */
59
+ export const INBOX_INFO_KIND = 'info';
60
+
61
+ export function kindOf(entry: InboxEntry): string {
62
+ return entry.notice?.kind ?? INBOX_INFO_KIND;
63
+ }
64
+
65
+ /**
66
+ * An item is settled once its decision no longer waits, or once its contributor said its information no longer holds;
67
+ * it stays listed, dimmed, until deleted.
68
+ */
69
+ export function isSettled(entry: InboxEntry): boolean {
70
+ return entry.notice?.resolvedAt != null;
71
+ }
72
+
73
+ /** Translates a key of the inbox's own wording, as `useTranslation()` does. */
74
+ export type InboxTranslate = (
75
+ key: string,
76
+ options?: Readonly<Record<string, unknown>>,
77
+ ) => string;
78
+
79
+ /**
80
+ * The inbox's two views: everything (`all`), or only what waits on the viewer (`todo`): the items of a waiting
81
+ * category not yet settled, and what each collection lists there.
82
+ */
83
+ export type InboxView = 'all' | 'todo';
84
+
85
+ export const INBOX_VIEWS: readonly InboxView[] = ['all', 'todo'];
86
+
87
+ /** A category of in-app items, such as the decisions or the notifications. */
88
+ export interface InboxEntryCategory {
89
+ readonly type: 'entries';
90
+ /** Its value of `?kind=`. */
91
+ readonly id: string;
92
+ /** Its name in the kind filter. */
93
+ readonly label: (t: InboxTranslate) => string;
94
+ /** The heading of its group in the list. */
95
+ readonly title: (t: InboxTranslate) => string;
96
+ /** What the list says when the kind filter selects it and nothing is there. */
97
+ readonly empty: (t: InboxTranslate) => string;
98
+ /** Whether an item belongs to it; an item no category takes belongs to the last one. */
99
+ readonly match: (entry: InboxEntry) => boolean;
100
+ /**
101
+ * Whether its items wait on the viewer until settled: listed unsettled first, offered by To do, and counted as what
102
+ * waits.
103
+ */
104
+ readonly waits?: boolean;
105
+ }
106
+
107
+ /** What the inbox gives a collection's hook. */
108
+ export interface InboxCollectionInput {
109
+ readonly filter: InboxFilter;
110
+ /** Every in-app item loaded, such as to leave out what an item already stands for. */
111
+ readonly entries: readonly InboxEntry[];
112
+ /** The id in its own parameter, or null. */
113
+ readonly selectedId: string | null;
114
+ readonly onSelect: (id: string) => void;
115
+ /** The page's search parameters, and how to replace them, for a filter of its own. */
116
+ readonly params: URLSearchParams;
117
+ readonly onParams: (next: URLSearchParams) => void;
118
+ }
119
+
120
+ /** What a collection shows, as its hook answers on every render. */
121
+ export interface InboxCollectionState {
122
+ /** How many records its group lists; the group shows only above zero. */
123
+ readonly count: number;
124
+ /** Whether its group has loaded; the list waits for it while every kind shows. */
125
+ readonly loaded: boolean;
126
+ /** Its group in the list while every kind shows, placed after the category `after` names. */
127
+ readonly group: ReactNode;
128
+ /** The list shown in place of the items while the kind filter selects it. */
129
+ readonly list: ReactNode;
130
+ }
131
+
132
+ /**
133
+ * A category whose records live in another API rather than in the in-app items, such as the plans an application
134
+ * asks the viewer to decide: listed as a group of its own, or alone through the kind filter, and shown in the detail
135
+ * pane by its own URL parameter.
136
+ */
137
+ export interface InboxCollectionCategory {
138
+ readonly type: 'collection';
139
+ readonly id: string;
140
+ readonly label: (t: InboxTranslate) => string;
141
+ /** The URL parameter holding the selected record's id, such as `plan`. */
142
+ readonly param: string;
143
+ /** Further parameters it owns, such as a status filter; dropped when the filter changes. */
144
+ readonly params?: readonly string[];
145
+ /** The views whose kind filter offers it; both when left out. */
146
+ readonly views?: readonly InboxView[];
147
+ /** The category its group follows in the list; after every group when left out. */
148
+ readonly after?: string;
149
+ /**
150
+ * Called on every render of the inbox page with what it shows; a hook, so it may query its API. The categories are
151
+ * fixed for the page, so the hooks always run in the same order.
152
+ */
153
+ readonly useCollection: (input: InboxCollectionInput) => InboxCollectionState;
154
+ /** The selected record, or what to say while none is, in the detail pane. */
155
+ readonly Detail: ComponentType<{
156
+ readonly id: string | null;
157
+ readonly onBack: () => void;
158
+ }>;
159
+ }
160
+
161
+ export type InboxCategory = InboxEntryCategory | InboxCollectionCategory;
162
+
163
+ /** The view and the kind shown: every kind (`all`) or a category's id. */
164
+ export interface InboxFilter {
165
+ readonly view: InboxView;
166
+ readonly kind: string;
167
+ }
168
+
169
+ export function entryCategories(
170
+ categories: readonly InboxCategory[],
171
+ ): InboxEntryCategory[] {
172
+ return categories.filter(
173
+ (category): category is InboxEntryCategory => category.type === 'entries',
174
+ );
175
+ }
176
+
177
+ export function collectionCategories(
178
+ categories: readonly InboxCategory[],
179
+ ): InboxCollectionCategory[] {
180
+ return categories.filter(
181
+ (category): category is InboxCollectionCategory =>
182
+ category.type === 'collection',
183
+ );
184
+ }
185
+
186
+ /** The categories a view offers: To do only those that wait. */
187
+ export function categoriesOf(
188
+ view: InboxView,
189
+ categories: readonly InboxCategory[],
190
+ ): InboxCategory[] {
191
+ return categories.filter((category) =>
192
+ category.type === 'entries'
193
+ ? view === 'all' || category.waits === true
194
+ : (category.views ?? INBOX_VIEWS).includes(view),
195
+ );
196
+ }
197
+
198
+ /** The kinds a view's filter offers, every kind first. */
199
+ export function kindsOf(
200
+ view: InboxView,
201
+ categories: readonly InboxCategory[],
202
+ ): string[] {
203
+ return ['all', ...categoriesOf(view, categories).map(({ id }) => id)];
204
+ }
205
+
206
+ /** The filter in the URL (`?view=`, `?kind=`); a kind the view does not offer reads as every kind. */
207
+ export function readInboxFilter(
208
+ params: URLSearchParams,
209
+ categories: readonly InboxCategory[],
210
+ ): InboxFilter {
211
+ const view: InboxView = params.get('view') === 'todo' ? 'todo' : 'all';
212
+ const kind =
213
+ kindsOf(view, categories).find((value) => value === params.get('kind')) ??
214
+ 'all';
215
+ return { view, kind };
216
+ }
217
+
218
+ /** The search of `filter` over `params`: the defaults left out, and what belonged to the previous filter dropped. */
219
+ export function inboxFilterParams(
220
+ params: URLSearchParams,
221
+ filter: InboxFilter,
222
+ categories: readonly InboxCategory[],
223
+ ): URLSearchParams {
224
+ const next = new URLSearchParams(params);
225
+ if (filter.view === 'all') next.delete('view');
226
+ else next.set('view', filter.view);
227
+ if (filter.kind === 'all') next.delete('kind');
228
+ else next.set('kind', filter.kind);
229
+ for (const collection of collectionCategories(categories))
230
+ for (const name of [...(collection.params ?? []), collection.param])
231
+ next.delete(name);
232
+ return next;
233
+ }
234
+
235
+ /** The collection the filter selects, or whose record is selected by its parameter. */
236
+ export function activeCollection(
237
+ filter: InboxFilter,
238
+ params: URLSearchParams,
239
+ categories: readonly InboxCategory[],
240
+ ): InboxCollectionCategory | null {
241
+ return (
242
+ collectionCategories(categories).find(
243
+ (category) =>
244
+ filter.kind === category.id || params.get(category.param) !== null,
245
+ ) ?? null
246
+ );
247
+ }
248
+
249
+ /** The in-app items of each entry category, by id. */
250
+ export type InboxGroups = Readonly<Record<string, readonly InboxEntry[]>>;
251
+
252
+ /**
253
+ * Sorts the items into the entry categories, in list order: each to the first category it matches, else the last.
254
+ * A waiting category lists what still waits first and settled items after.
255
+ */
256
+ export function groupEntries(
257
+ entries: readonly InboxEntry[],
258
+ categories: readonly InboxCategory[],
259
+ ): Record<string, InboxEntry[]> {
260
+ const sorted = entryCategories(categories);
261
+ const last = sorted.at(-1);
262
+ const members = new Map<string, InboxEntry[]>();
263
+ for (const entry of entries) {
264
+ const category = sorted.find((candidate) => candidate.match(entry)) ?? last;
265
+ if (category)
266
+ members.set(category.id, [...(members.get(category.id) ?? []), entry]);
267
+ }
268
+ return Object.fromEntries(
269
+ sorted.map((category) => {
270
+ const list = members.get(category.id) ?? [];
271
+ return [
272
+ category.id,
273
+ category.waits
274
+ ? [
275
+ ...list.filter((entry) => !isSettled(entry)),
276
+ ...list.filter((entry) => isSettled(entry)),
277
+ ]
278
+ : list,
279
+ ];
280
+ }),
281
+ );
282
+ }
283
+
284
+ /** The groups of in-app items `filter` shows, in order, each as the view lists it. */
285
+ export function shownGroups(
286
+ filter: InboxFilter,
287
+ groups: InboxGroups,
288
+ categories: readonly InboxCategory[],
289
+ ): {
290
+ readonly category: InboxEntryCategory;
291
+ readonly entries: readonly InboxEntry[];
292
+ }[] {
293
+ const offered = entryCategories(categoriesOf(filter.view, categories));
294
+ const shown =
295
+ filter.kind === 'all'
296
+ ? offered
297
+ : offered.filter((category) => category.id === filter.kind);
298
+ return shown.map((category) => {
299
+ const entries = groups[category.id] ?? [];
300
+ return {
301
+ category,
302
+ entries:
303
+ filter.view === 'todo'
304
+ ? entries.filter((entry) => !isSettled(entry))
305
+ : entries,
306
+ };
307
+ });
308
+ }
309
+
310
+ /** The in-app items `filter` shows, in list order (for j / k and the default selection). */
311
+ export function shownEntries(
312
+ filter: InboxFilter,
313
+ groups: InboxGroups,
314
+ categories: readonly InboxCategory[],
315
+ ): InboxEntry[] {
316
+ return shownGroups(filter, groups, categories).flatMap(
317
+ ({ entries }) => entries,
318
+ );
319
+ }
320
+
321
+ /** Unread items per entry category, among those loaded. */
322
+ export function unreadByCategory(
323
+ groups: InboxGroups,
324
+ ): Readonly<Record<string, number>> {
325
+ return Object.fromEntries(
326
+ Object.entries(groups).map(([id, entries]) => [
327
+ id,
328
+ entries.filter((entry) => !entry.item.readAt).length,
329
+ ]),
330
+ );
331
+ }
332
+
333
+ /**
334
+ * What waits on the viewer in each waiting category: the source's count where it gives one (it knows every decision,
335
+ * not only those loaded), else the loaded items not yet settled.
336
+ */
337
+ export function waitingByCategory(
338
+ groups: InboxGroups,
339
+ categories: readonly InboxCategory[],
340
+ pending: Readonly<Record<string, number>> | undefined,
341
+ ): Readonly<Record<string, number>> {
342
+ return Object.fromEntries(
343
+ entryCategories(categories)
344
+ .filter((category) => category.waits)
345
+ .map((category) => [
346
+ category.id,
347
+ pending?.[category.id] ??
348
+ (groups[category.id] ?? []).filter((entry) => !isSettled(entry))
349
+ .length,
350
+ ]),
351
+ );
352
+ }
353
+
354
+ export function entriesOf(
355
+ items: readonly InboxItem[],
356
+ notices: readonly InboxNotice[],
357
+ ): InboxEntry[] {
358
+ const byNotification = new Map(
359
+ notices.map((notice) => [notice.notificationId, notice]),
360
+ );
361
+ return items.map((item) => ({
362
+ item,
363
+ notice: byNotification.get(item.notificationId) ?? null,
364
+ }));
365
+ }
366
+
367
+ /**
368
+ * The list as the page shows it: what the source lists as waiting, in full, then the in-app items loaded page by page,
369
+ * each once. A waiting decision therefore never falls past the first page.
370
+ */
371
+ export function mergeWaiting(
372
+ waiting: readonly InboxEntry[],
373
+ paged: readonly InboxEntry[],
374
+ ): InboxEntry[] {
375
+ const shown = new Set(waiting.map((entry) => entry.item.id));
376
+ return [...waiting, ...paged.filter((entry) => !shown.has(entry.item.id))];
377
+ }
378
+
379
+ /** The id `step` places away from `currentId` in `ids` (j / k), clamped to the ends; the first id when none. */
380
+ export function stepSelection(
381
+ ids: readonly string[],
382
+ currentId: string | null,
383
+ step: number,
384
+ ): string | null {
385
+ if (ids.length === 0) return null;
386
+ const index = currentId ? ids.indexOf(currentId) : -1;
387
+ if (index === -1) return ids[0] ?? null;
388
+ return ids[Math.min(ids.length - 1, Math.max(0, index + step))] ?? null;
389
+ }
390
+
391
+ /** The ids among `entries` that were not there when the list first loaded: they arrived since, and slide in. */
392
+ export function freshIds(
393
+ entries: readonly InboxEntry[],
394
+ initial: ReadonlySet<string> | null,
395
+ ): ReadonlySet<string> {
396
+ if (!initial) return new Set();
397
+ return new Set(
398
+ entries.map((entry) => entry.item.id).filter((id) => !initial.has(id)),
399
+ );
400
+ }
401
+
402
+ /** Where opening an item goes: its route, or null without one. */
403
+ export function entryLink(entry: InboxEntry): string | null {
404
+ const { target } = entry.item;
405
+ return target?.type === 'route' ? target.path : null;
406
+ }
407
+
408
+ const UNITS: readonly (readonly [Intl.RelativeTimeFormatUnit, number])[] = [
409
+ ['year', 365 * 24 * 3600],
410
+ ['month', 30 * 24 * 3600],
411
+ ['day', 24 * 3600],
412
+ ['hour', 3600],
413
+ ['minute', 60],
414
+ ];
415
+
416
+ /** "3 minutes ago" in `locale`; "now" under a minute. */
417
+ export function relativeTime(
418
+ at: string,
419
+ locale: string,
420
+ now: number = Date.now(),
421
+ ): string {
422
+ const seconds = Math.round((new Date(at).getTime() - now) / 1000);
423
+ const format = new Intl.RelativeTimeFormat(locale, { numeric: 'auto' });
424
+ for (const [unit, size] of UNITS)
425
+ if (Math.abs(seconds) >= size)
426
+ return format.format(Math.round(seconds / size), unit);
427
+ return format.format(0, 'second');
428
+ }
@@ -0,0 +1,49 @@
1
+ /** The registry's React components: the provider that gives the inbox its contributors, and the scope a part renders in. */
2
+ import {
3
+ NamespaceScope,
4
+ useOptionalI18nRuntime,
5
+ type Namespace,
6
+ } from '@nocobase/i18n/client';
7
+ import type { ReactElement, ReactNode } from 'react';
8
+
9
+ import { InboxRegistryContext, type InboxRegistry } from './registry.js';
10
+
11
+ export interface InboxRegistryProviderProps {
12
+ readonly registry: InboxRegistry;
13
+ readonly children: ReactNode;
14
+ }
15
+
16
+ export function InboxRegistryProvider({
17
+ registry,
18
+ children,
19
+ }: InboxRegistryProviderProps): ReactElement {
20
+ return (
21
+ <InboxRegistryContext.Provider value={registry}>
22
+ {children}
23
+ </InboxRegistryContext.Provider>
24
+ );
25
+ }
26
+
27
+ /**
28
+ * Renders a contributor's part in its namespace, adding the resources it brings first. Adding is synchronous and
29
+ * idempotent, so the first render is already translated.
30
+ */
31
+ export interface ContributorScopeProps {
32
+ readonly namespace?: Namespace;
33
+ readonly resources?: Readonly<Record<string, object>>;
34
+ readonly children: ReactNode;
35
+ }
36
+
37
+ export function ContributorScope({
38
+ namespace,
39
+ resources,
40
+ children,
41
+ }: ContributorScopeProps): ReactElement {
42
+ const runtime = useOptionalI18nRuntime();
43
+ if (runtime && namespace && resources)
44
+ for (const [locale, resource] of Object.entries(resources))
45
+ if (!runtime.i18n.hasResourceBundle(locale, namespace))
46
+ runtime.i18n.addResourceBundle(locale, namespace, resource, true);
47
+ if (!namespace) return <>{children}</>;
48
+ return <NamespaceScope ns={namespace}>{children}</NamespaceScope>;
49
+ }