@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,545 @@
1
+ import { ApiClientError, useToaster } from '@nocobase/app-client';
2
+ import {
3
+ useInboxActions,
4
+ useInboxItems,
5
+ useInboxRefresh,
6
+ useInboxUnreadCount,
7
+ } from '@nocobase/app-plugin-notification-in-app/client/inbox';
8
+ import { useTranslation } from '@nocobase/i18n/client';
9
+ import { useMutation, useQueryClient } from '@tanstack/react-query';
10
+ import { CheckCheckIcon } from 'lucide-react';
11
+ import {
12
+ type ReactElement,
13
+ type ReactNode,
14
+ useEffect,
15
+ useMemo,
16
+ useRef,
17
+ useState,
18
+ } from 'react';
19
+ import { useNavigate, useSearchParams } from 'react-router';
20
+
21
+ import { PageHeader } from '#components/page-header';
22
+ import { Button } from '#components/ui/button';
23
+ import { Spinner } from '#components/ui/spinner';
24
+ import { cn } from 'cn';
25
+
26
+ import type { InboxAction } from './inbox-actions.js';
27
+ import { InboxDetail, type InboxDetailToolbarInput } from './inbox-detail.js';
28
+ import { InboxList, type InboxListCollection } from './inbox-list.js';
29
+ import {
30
+ activeCollection,
31
+ collectionCategories,
32
+ entriesOf,
33
+ entryLink,
34
+ freshIds,
35
+ groupEntries,
36
+ inboxFilterParams,
37
+ mergeWaiting,
38
+ readInboxFilter,
39
+ shownEntries,
40
+ stepSelection,
41
+ unreadByCategory,
42
+ waitingByCategory,
43
+ type InboxCollectionState,
44
+ type InboxEntry,
45
+ } from './model.js';
46
+ import { ContributorScope, InboxRegistryProvider } from './registry-scope.js';
47
+ import {
48
+ categoriesOfRegistry,
49
+ useFeedItems,
50
+ useInboxRegistry,
51
+ type InboxRegistry,
52
+ } from './registry.js';
53
+ import {
54
+ defaultInboxSource,
55
+ inboxSourceKeys,
56
+ useInboxNotices,
57
+ useInboxPending,
58
+ useInboxWaiting,
59
+ type InboxSource,
60
+ } from './source.js';
61
+
62
+ /** How the inbox reports what it did: a deletion and "mark all read", and a request that failed. */
63
+ export interface InboxNotify {
64
+ success(title: string): void;
65
+ error(error: unknown): void;
66
+ }
67
+
68
+ export interface InboxPageProps {
69
+ /** What the application adds to the in-app items; `defaultInboxSource` adds nothing. */
70
+ readonly source?: InboxSource;
71
+ /** The contributors and categories; the provided registry, or `defaultInboxRegistry`, when left out. */
72
+ readonly registry?: InboxRegistry;
73
+ /** The page title; `inbox.title` when left out. */
74
+ readonly title?: ReactNode;
75
+ /** The sentence under the title; `inbox.description` when left out. */
76
+ readonly description?: ReactNode;
77
+ /** What the detail pane shows before its read, delete and open buttons, such as an "Ask agent" button. */
78
+ readonly detailToolbar?: (input: InboxDetailToolbarInput) => ReactNode;
79
+ /** Beside the kind filter, such as a sound reminder switch. */
80
+ readonly listFooter?: ReactNode;
81
+ /** Called with what waits on the viewer, the count on To do, whenever it changes once loaded. */
82
+ readonly onPendingChange?: (count: number) => void;
83
+ /** The application's toasts; the application toaster when left out. */
84
+ readonly notify?: InboxNotify;
85
+ /** In-app items loaded per page. */
86
+ readonly pageSize?: number;
87
+ /** The prefix of the element ids it renders, such as `nocobase-inbox-detail`. */
88
+ readonly idPrefix?: string;
89
+ }
90
+
91
+ function isEditableTarget(target: EventTarget | null): boolean {
92
+ return (
93
+ target instanceof HTMLElement &&
94
+ (target.isContentEditable ||
95
+ ['INPUT', 'TEXTAREA', 'SELECT'].includes(target.tagName))
96
+ );
97
+ }
98
+
99
+ function useDefaultNotify(): InboxNotify {
100
+ const toaster = useToaster();
101
+ const { t } = useTranslation();
102
+ return useMemo(
103
+ () => ({
104
+ success: (title: string) => void toaster.show({ type: 'success', title }),
105
+ error: (error: unknown) =>
106
+ void toaster.show({
107
+ type: 'error',
108
+ title:
109
+ error instanceof ApiClientError && error.status === 403
110
+ ? t('inbox.forbidden', {
111
+ defaultValue: 'You are not allowed to do this.',
112
+ })
113
+ : t('inbox.requestFailed', {
114
+ defaultValue: 'The request failed. Try again.',
115
+ }),
116
+ }),
117
+ }),
118
+ [toaster, t],
119
+ );
120
+ }
121
+
122
+ /**
123
+ * The inbox as a route's page: a master–detail page. The left column lists the viewer's items; the right pane shows
124
+ * the selected one with what is needed to decide. There are two views and one filter by kind (`inbox-list.tsx`), all
125
+ * in the URL so a view can be shared:
126
+ *
127
+ * - `?view=todo` lists what waits on the viewer; without it, everything, settled decisions and notifications included.
128
+ * - `?kind=` narrows either view to one of the registry's categories; a collection category lists its own records in
129
+ * place of the items.
130
+ * - The selection is `?item=` for an item, or a feed's or collection's own parameter, so a narrow screen shows the
131
+ * list, then the detail with a back button.
132
+ *
133
+ * The items are `@nocobase/app-plugin-notification-in-app`'s; what each is about and whether its decision still
134
+ * waits is the `source`'s, and how each reads is its contributor's (`registry.ts`). What the source lists as waiting
135
+ * comes first and in full, then the rest page by page. Keyboard: `j` / `k` move the selection among the items, Enter
136
+ * opens its route. "Mark all read" marks the items shown.
137
+ */
138
+ export function InboxPage({
139
+ registry,
140
+ ...props
141
+ }: InboxPageProps): ReactElement {
142
+ if (!registry) return <InboxPageContent {...props} />;
143
+ return (
144
+ <InboxRegistryProvider registry={registry}>
145
+ <InboxPageContent {...props} />
146
+ </InboxRegistryProvider>
147
+ );
148
+ }
149
+
150
+ function InboxPageContent({
151
+ source = defaultInboxSource,
152
+ title,
153
+ description,
154
+ detailToolbar,
155
+ listFooter,
156
+ onPendingChange,
157
+ notify: givenNotify,
158
+ pageSize,
159
+ idPrefix = 'nocobase-inbox',
160
+ }: Omit<InboxPageProps, 'registry'>): ReactElement {
161
+ const { t } = useTranslation();
162
+ const defaultNotify = useDefaultNotify();
163
+ const notify = givenNotify ?? defaultNotify;
164
+ const navigate = useNavigate();
165
+ const queryClient = useQueryClient();
166
+ const registry = useInboxRegistry();
167
+ const categories = categoriesOfRegistry(registry);
168
+ const collections = collectionCategories(categories);
169
+ const [params, setParams] = useSearchParams();
170
+ const filter = readInboxFilter(params, categories);
171
+ const { view, kind } = filter;
172
+ const collection = activeCollection(filter, params, categories);
173
+ const collectionId = collection ? params.get(collection.param) : null;
174
+ const selectedParam = collection ? null : params.get('item');
175
+ // A feed's item (a contributor's decision kept in its own API, by its own parameter), shown instead of an inbox item.
176
+ const feeds = useFeedItems(registry);
177
+ const shownFeed = collection
178
+ ? undefined
179
+ : feeds.find(({ feed }) => params.get(feed.param));
180
+ const feedSelection = shownFeed
181
+ ? { feed: shownFeed.feed, id: params.get(shownFeed.feed.param) ?? '' }
182
+ : null;
183
+ const feedCount = feeds.reduce((sum, { items }) => sum + items.length, 0);
184
+ useInboxRefresh(source.refreshTopics);
185
+
186
+ const pages = useInboxItems(pageSize);
187
+ const items = useMemo(
188
+ () => pages.data?.pages.flatMap((page) => page.data) ?? [],
189
+ [pages.data],
190
+ );
191
+ const ids = useMemo(
192
+ () => [...new Set(items.map((item) => item.notificationId))],
193
+ [items],
194
+ );
195
+ const notices = useInboxNotices(source, ids);
196
+ const waiting = useInboxWaiting(source);
197
+ const unreadTotal = useInboxUnreadCount();
198
+ const pending = useInboxPending(source);
199
+ const actions = useInboxActions();
200
+
201
+ const entries = useMemo(
202
+ () =>
203
+ mergeWaiting(waiting.data ?? [], entriesOf(items, notices.data ?? [])),
204
+ [waiting.data, items, notices.data],
205
+ );
206
+ const groups = groupEntries(entries, categories);
207
+ const unread = Object.values(unreadByCategory(groups)).reduce(
208
+ (sum, count) => sum + count,
209
+ 0,
210
+ );
211
+ const waitingCounts = waitingByCategory(groups, categories, pending.data);
212
+ const pendingTotal =
213
+ Object.values(waitingCounts).reduce((sum, count) => sum + count, 0) +
214
+ feedCount;
215
+ const visible = shownEntries(filter, groups, categories);
216
+
217
+ function replaceParams(next: URLSearchParams): void {
218
+ setParams(next, { replace: true });
219
+ }
220
+
221
+ function setParam(name: string, value: string | null): void {
222
+ const next = new URLSearchParams(params);
223
+ if (value) next.set(name, value);
224
+ else next.delete(name);
225
+ replaceParams(next);
226
+ }
227
+
228
+ /** The params with every selection removed: the item, a feed's and a collection's. */
229
+ function withoutSelection(): URLSearchParams {
230
+ const next = new URLSearchParams(params);
231
+ next.delete('item');
232
+ for (const { feed } of feeds) next.delete(feed.param);
233
+ for (const category of collections) next.delete(category.param);
234
+ return next;
235
+ }
236
+
237
+ // Each collection's hook runs on every render, in the registry's order, which is fixed for the page.
238
+ const collectionStates: Record<string, InboxCollectionState> = {};
239
+ for (const category of collections)
240
+ collectionStates[category.id] = category.useCollection({
241
+ filter,
242
+ entries,
243
+ selectedId: params.get(category.param),
244
+ onSelect: (id) => {
245
+ const next = withoutSelection();
246
+ next.set(category.param, id);
247
+ replaceParams(next);
248
+ },
249
+ params,
250
+ onParams: replaceParams,
251
+ });
252
+ const listCollections: Record<string, InboxListCollection> = {};
253
+ for (const [id, state] of Object.entries(collectionStates))
254
+ listCollections[id] = {
255
+ group: state.group,
256
+ count: state.count,
257
+ list: state.list,
258
+ };
259
+
260
+ const loaded =
261
+ pages.data !== undefined &&
262
+ waiting.data !== undefined &&
263
+ (ids.length === 0 || notices.data !== undefined) &&
264
+ (kind !== 'all' ||
265
+ Object.values(collectionStates).every((state) => state.loaded));
266
+ const selected =
267
+ visible.find((entry) => entry.item.id === selectedParam) ??
268
+ visible[0] ??
269
+ null;
270
+
271
+ const onPendingChangeRef = useRef(onPendingChange);
272
+ useEffect(() => {
273
+ onPendingChangeRef.current = onPendingChange;
274
+ });
275
+ useEffect(() => {
276
+ if (loaded) onPendingChangeRef.current?.(pendingTotal);
277
+ }, [loaded, pendingTotal]);
278
+
279
+ // Items present when the list first loaded do not animate; later arrivals (realtime pushes) slide in. The first
280
+ // loaded list is remembered while rendering (derived state, no effect).
281
+ const [initialIds, setInitialIds] = useState<ReadonlySet<string> | null>(
282
+ null,
283
+ );
284
+ if (initialIds === null && loaded)
285
+ setInitialIds(new Set(entries.map((entry) => entry.item.id)));
286
+ const fresh = useMemo(
287
+ () => freshIds(entries, initialIds),
288
+ [entries, initialIds],
289
+ );
290
+
291
+ const waitingKey = inboxSourceKeys(source).waiting();
292
+ function act(entry: InboxEntry, action: InboxAction): void {
293
+ // The plugin's hook updates the cached pages and count; what the source listed as waiting is updated here.
294
+ queryClient.setQueryData<readonly InboxEntry[]>(waitingKey, (data) =>
295
+ action === 'delete'
296
+ ? data?.filter((candidate) => candidate.item.id !== entry.item.id)
297
+ : data?.map((candidate) =>
298
+ candidate.item.id === entry.item.id
299
+ ? {
300
+ ...candidate,
301
+ item: {
302
+ ...candidate.item,
303
+ readAt:
304
+ action === 'read' ? new Date().toISOString() : undefined,
305
+ },
306
+ }
307
+ : candidate,
308
+ ),
309
+ );
310
+ actions.mark(entry.item.id, action).then(
311
+ () => {
312
+ if (action === 'delete')
313
+ notify.success(t('inbox.deleted', { defaultValue: 'Deleted.' }));
314
+ },
315
+ (error: unknown) => notify.error(error),
316
+ );
317
+ }
318
+ // "Mark all read" acts on what is shown: everything in All with every kind, otherwise the loaded unread items listed.
319
+ const everything = view === 'all' && kind === 'all';
320
+ const unreadShown = everything
321
+ ? (unreadTotal.data ?? 0)
322
+ : visible.filter((entry) => !entry.item.readAt).length;
323
+ const readAll = useMutation({
324
+ mutationFn: async (): Promise<void> => {
325
+ if (everything) {
326
+ await actions.readAll();
327
+ return;
328
+ }
329
+ const targets = visible.filter((entry) => !entry.item.readAt);
330
+ await Promise.all(
331
+ targets.map((entry) => actions.mark(entry.item.id, 'read')),
332
+ );
333
+ },
334
+ onSuccess: () =>
335
+ notify.success(
336
+ t('inbox.allRead', { defaultValue: 'All marked as read.' }),
337
+ ),
338
+ onError: (error) => notify.error(error),
339
+ });
340
+
341
+ function select(entry: InboxEntry): void {
342
+ const next = withoutSelection();
343
+ next.set('item', entry.item.id);
344
+ replaceParams(next);
345
+ if (!entry.item.readAt) act(entry, 'read');
346
+ }
347
+
348
+ function open(entry: InboxEntry): void {
349
+ if (!entry.item.readAt) act(entry, 'read');
350
+ const link = entryLink(entry);
351
+ if (link) void navigate(link);
352
+ }
353
+
354
+ function run(entry: InboxEntry, action: InboxAction): void {
355
+ if (action === 'delete') {
356
+ const next = stepSelection(
357
+ visible.map((candidate) => candidate.item.id),
358
+ entry.item.id,
359
+ 1,
360
+ );
361
+ setParam('item', next && next !== entry.item.id ? next : null);
362
+ }
363
+ act(entry, action);
364
+ }
365
+
366
+ // j / k / Enter, ignored while typing, with a modifier, or when a dialog is open.
367
+ const keysRef = useRef({ visible, selected, select, open });
368
+ useEffect(() => {
369
+ keysRef.current = { visible, selected, select, open };
370
+ });
371
+ useEffect(() => {
372
+ function onKeyDown(event: KeyboardEvent): void {
373
+ if (event.metaKey || event.ctrlKey || event.altKey) return;
374
+ if (isEditableTarget(event.target)) return;
375
+ if (document.querySelector('[role="dialog"], [role="alertdialog"]'))
376
+ return;
377
+ const current = keysRef.current;
378
+ const key = event.key.toLowerCase();
379
+ if (key === 'j' || key === 'k') {
380
+ const id = stepSelection(
381
+ current.visible.map((entry) => entry.item.id),
382
+ current.selected?.item.id ?? null,
383
+ key === 'j' ? 1 : -1,
384
+ );
385
+ const entry = current.visible.find(
386
+ (candidate) => candidate.item.id === id,
387
+ );
388
+ if (!entry) return;
389
+ event.preventDefault();
390
+ current.select(entry);
391
+ document
392
+ .querySelector(
393
+ `[data-inbox-item="${CSS.escape(entry.item.id)}"] button`,
394
+ )
395
+ ?.scrollIntoView?.({ block: 'nearest' });
396
+ } else if (
397
+ event.key === 'Enter' &&
398
+ current.selected &&
399
+ (event.target === document.body ||
400
+ event.target === document.documentElement)
401
+ ) {
402
+ event.preventDefault();
403
+ current.open(current.selected);
404
+ }
405
+ }
406
+ window.addEventListener('keydown', onKeyDown);
407
+ return () => window.removeEventListener('keydown', onKeyDown);
408
+ }, []);
409
+
410
+ const showsDetail = Boolean(selectedParam || collectionId || feedSelection);
411
+
412
+ return (
413
+ <div className='flex h-full min-h-0 flex-col'>
414
+ <div className='shrink-0 border-b px-6 py-5 md:px-8'>
415
+ <PageHeader
416
+ title={title ?? t('inbox.title', { defaultValue: 'Inbox' })}
417
+ description={
418
+ description ??
419
+ t('inbox.description', {
420
+ defaultValue: 'Notifications and requests sent to you.',
421
+ })
422
+ }
423
+ actions={
424
+ <>
425
+ {(pages.isFetching || notices.isFetching || waiting.isFetching) &&
426
+ pages.data ? (
427
+ <Spinner
428
+ className='size-4 text-muted-foreground'
429
+ aria-label={t('inbox.loading', { defaultValue: 'Loading' })}
430
+ />
431
+ ) : null}
432
+ <Button
433
+ variant='outline'
434
+ disabled={readAll.isPending || unreadShown === 0}
435
+ onClick={() => readAll.mutate()}
436
+ >
437
+ {readAll.isPending ? (
438
+ <Spinner data-icon='inline-start' />
439
+ ) : (
440
+ <CheckCheckIcon data-icon='inline-start' />
441
+ )}
442
+ {t('inbox.readAll', { defaultValue: 'Mark all as read' })}
443
+ </Button>
444
+ </>
445
+ }
446
+ />
447
+ </div>
448
+ <div className='flex min-h-0 flex-1'>
449
+ <div
450
+ className={cn(
451
+ 'flex min-h-0 w-full flex-col border-r bg-sidebar/60 lg:w-[26rem] lg:shrink-0',
452
+ showsDetail ? 'max-lg:hidden' : undefined,
453
+ )}
454
+ >
455
+ <InboxList
456
+ filter={filter}
457
+ unread={unread}
458
+ pending={pendingTotal}
459
+ waiting={waitingCounts}
460
+ feedCount={feedCount}
461
+ feeds={feeds.map(({ feed, items: feedItems }) =>
462
+ feedItems.length === 0 ? null : (
463
+ <ContributorScope
464
+ key={feed.id}
465
+ namespace={feed.namespace}
466
+ resources={feed.resources}
467
+ >
468
+ <feed.List
469
+ items={feedItems as never[]}
470
+ selectedId={
471
+ feedSelection?.feed === feed ? feedSelection.id : null
472
+ }
473
+ onSelect={(id) => {
474
+ const next = withoutSelection();
475
+ next.set(feed.param, id);
476
+ replaceParams(next);
477
+ }}
478
+ />
479
+ </ContributorScope>
480
+ ),
481
+ )}
482
+ groups={groups}
483
+ loaded={loaded}
484
+ query={{
485
+ isError: pages.isError,
486
+ error: pages.error,
487
+ isFetchingNextPage: pages.isFetchingNextPage,
488
+ refetch: pages.refetch,
489
+ fetchNextPage: pages.fetchNextPage,
490
+ hasNextPage: pages.hasNextPage,
491
+ }}
492
+ selectedId={
493
+ feedSelection || collectionId ? null : (selected?.item.id ?? null)
494
+ }
495
+ fresh={fresh}
496
+ busy={actions.pending}
497
+ onFilter={(value) =>
498
+ replaceParams(inboxFilterParams(params, value, categories))
499
+ }
500
+ collections={listCollections}
501
+ footer={listFooter}
502
+ idPrefix={idPrefix}
503
+ onSelect={select}
504
+ onOpen={open}
505
+ onAction={run}
506
+ />
507
+ </div>
508
+ <div
509
+ className={cn(
510
+ 'min-h-0 min-w-0 flex-1 overflow-y-auto',
511
+ showsDetail ? undefined : 'max-lg:hidden',
512
+ )}
513
+ >
514
+ {collection ? (
515
+ <collection.Detail
516
+ id={collectionId}
517
+ onBack={() => setParam(collection.param, null)}
518
+ />
519
+ ) : feedSelection ? (
520
+ <ContributorScope
521
+ namespace={feedSelection.feed.namespace}
522
+ resources={feedSelection.feed.resources}
523
+ >
524
+ <feedSelection.feed.Detail
525
+ key={feedSelection.id}
526
+ id={feedSelection.id}
527
+ onBack={() => setParam(feedSelection.feed.param, null)}
528
+ />
529
+ </ContributorScope>
530
+ ) : (
531
+ <InboxDetail
532
+ entry={selected}
533
+ busy={actions.pending}
534
+ onBack={() => setParam('item', null)}
535
+ onAction={run}
536
+ onOpen={open}
537
+ toolbar={detailToolbar}
538
+ idPrefix={idPrefix}
539
+ />
540
+ )}
541
+ </div>
542
+ </div>
543
+ </div>
544
+ );
545
+ }
@@ -0,0 +1,111 @@
1
+ /** Every key the inbox looks up, so each locale is checked against the same list. */
2
+ export interface InboxLocale {
3
+ readonly 'inbox.title': string;
4
+ readonly 'inbox.description': string;
5
+ readonly 'inbox.views.all': string;
6
+ readonly 'inbox.views.todo': string;
7
+ readonly 'inbox.kinds.label': string;
8
+ readonly 'inbox.kinds.all': string;
9
+ readonly 'inbox.kinds.decision': string;
10
+ readonly 'inbox.kinds.info': string;
11
+ readonly 'inbox.tabs.decision': string;
12
+ readonly 'inbox.tabs.info': string;
13
+ readonly 'inbox.clearFilter': string;
14
+ readonly 'inbox.noMatchDescription': string;
15
+ readonly 'inbox.readAll': string;
16
+ readonly 'inbox.allRead': string;
17
+ readonly 'inbox.loading': string;
18
+ readonly 'inbox.loadFailed': string;
19
+ readonly 'inbox.loadFailedDescription': string;
20
+ readonly 'inbox.retry': string;
21
+ readonly 'inbox.loadMore': string;
22
+ readonly 'inbox.emptyAll': string;
23
+ readonly 'inbox.empty.todo': string;
24
+ readonly 'inbox.empty.decision': string;
25
+ readonly 'inbox.empty.info': string;
26
+ readonly 'inbox.emptyDescription': string;
27
+ readonly 'inbox.unread': string;
28
+ readonly 'inbox.unreadCount': string;
29
+ readonly 'inbox.pendingDecisions': string;
30
+ readonly 'inbox.resolved': string;
31
+ readonly 'inbox.outcomes.approved': string;
32
+ readonly 'inbox.outcomes.rejected': string;
33
+ readonly 'inbox.outcomes.withdrawn': string;
34
+ readonly 'inbox.count': string;
35
+ readonly 'inbox.actionsFor': string;
36
+ readonly 'inbox.actions.read': string;
37
+ readonly 'inbox.actions.unread': string;
38
+ readonly 'inbox.actions.delete': string;
39
+ readonly 'inbox.deleted': string;
40
+ readonly 'inbox.requestFailed': string;
41
+ readonly 'inbox.forbidden': string;
42
+ readonly 'inbox.nothingSelected': string;
43
+ readonly 'inbox.back': string;
44
+ readonly 'inbox.open': string;
45
+ readonly 'inbox.keyMove': string;
46
+ readonly 'inbox.keyOpen': string;
47
+ readonly 'inbox.cancel': string;
48
+ readonly 'inbox.request.approve': string;
49
+ readonly 'inbox.request.reject': string;
50
+ readonly 'inbox.request.commentPlaceholder': string;
51
+ readonly 'inbox.request.commentFor': string;
52
+ readonly 'inbox.request.quickSend': string;
53
+ readonly 'inbox.request.newLine': string;
54
+ }
55
+
56
+ const enUS: InboxLocale = {
57
+ 'inbox.title': 'Inbox',
58
+ 'inbox.description': 'Notifications and requests sent to you.',
59
+ 'inbox.views.all': 'All',
60
+ 'inbox.views.todo': 'To do',
61
+ 'inbox.kinds.label': 'Type',
62
+ 'inbox.kinds.all': 'All types',
63
+ 'inbox.kinds.decision': 'Decisions',
64
+ 'inbox.kinds.info': 'Notifications',
65
+ 'inbox.tabs.decision': 'Needs my decision',
66
+ 'inbox.tabs.info': 'Notifications',
67
+ 'inbox.clearFilter': 'Clear filter',
68
+ 'inbox.noMatchDescription': 'Clear the filter to see every type.',
69
+ 'inbox.readAll': 'Mark all as read',
70
+ 'inbox.allRead': 'All marked as read.',
71
+ 'inbox.loading': 'Loading',
72
+ 'inbox.loadFailed': 'Unable to load the inbox',
73
+ 'inbox.loadFailedDescription': 'Check your connection and try again.',
74
+ 'inbox.retry': 'Retry',
75
+ 'inbox.loadMore': 'Load more',
76
+ 'inbox.emptyAll': 'Nothing here',
77
+ 'inbox.empty.todo': 'Nothing waiting for you',
78
+ 'inbox.empty.decision': 'No decisions waiting for you',
79
+ 'inbox.empty.info': 'No notifications',
80
+ 'inbox.emptyDescription':
81
+ 'Notifications and requests sent to you show up here.',
82
+ 'inbox.unread': 'Unread',
83
+ 'inbox.unreadCount': '{{count}} unread',
84
+ 'inbox.pendingDecisions': '{{count}} waiting',
85
+ 'inbox.resolved': 'Handled',
86
+ 'inbox.outcomes.approved': 'Approved',
87
+ 'inbox.outcomes.rejected': 'Rejected',
88
+ 'inbox.outcomes.withdrawn': 'Withdrawn',
89
+ 'inbox.count': '×{{count}}',
90
+ 'inbox.actionsFor': 'Actions for {{title}}',
91
+ 'inbox.actions.read': 'Mark as read',
92
+ 'inbox.actions.unread': 'Mark as unread',
93
+ 'inbox.actions.delete': 'Delete',
94
+ 'inbox.deleted': 'Deleted.',
95
+ 'inbox.requestFailed': 'The request failed. Try again.',
96
+ 'inbox.forbidden': 'You are not allowed to do this.',
97
+ 'inbox.nothingSelected': 'Select an item to view it',
98
+ 'inbox.back': 'Back to the list',
99
+ 'inbox.open': 'Open',
100
+ 'inbox.keyMove': 'Move',
101
+ 'inbox.keyOpen': 'Open',
102
+ 'inbox.cancel': 'Cancel',
103
+ 'inbox.request.approve': 'Approve',
104
+ 'inbox.request.reject': 'Reject',
105
+ 'inbox.request.commentPlaceholder': 'Write something…',
106
+ 'inbox.request.commentFor': '{{action}}: {{title}}',
107
+ 'inbox.request.quickSend': 'to send',
108
+ 'inbox.request.newLine': 'for a new line',
109
+ };
110
+
111
+ export default enUS;