@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,274 @@
1
+ import {
2
+ CheckCircle2Icon,
3
+ ChevronRightIcon,
4
+ MoreHorizontalIcon,
5
+ } from 'lucide-react';
6
+ import { createElement, type KeyboardEvent, type ReactElement } from 'react';
7
+
8
+ import { Button } from '#components/ui/button';
9
+ import {
10
+ ContextMenu,
11
+ ContextMenuContent,
12
+ ContextMenuGroup,
13
+ ContextMenuItem,
14
+ ContextMenuTrigger,
15
+ } from '#components/ui/context-menu';
16
+ import {
17
+ DropdownMenu,
18
+ DropdownMenuContent,
19
+ DropdownMenuGroup,
20
+ DropdownMenuItem,
21
+ DropdownMenuTrigger,
22
+ } from '#components/ui/dropdown-menu';
23
+ import { cn } from 'cn';
24
+
25
+ import {
26
+ actionLabel,
27
+ useInboxFrameText,
28
+ type InboxFrameText,
29
+ } from './frame.js';
30
+ import {
31
+ INBOX_ACTION_ICON,
32
+ inboxActionsFor,
33
+ type InboxAction,
34
+ } from './inbox-actions.js';
35
+ import { isSettled, kindOf, relativeTime, type InboxEntry } from './model.js';
36
+ import { ContributorScope } from './registry-scope.js';
37
+ import {
38
+ rendererKey,
39
+ useRendererOf,
40
+ type InboxEntryRenderer,
41
+ } from './registry.js';
42
+
43
+ export interface InboxTypeIconProps {
44
+ readonly entry: InboxEntry;
45
+ readonly renderer: InboxEntryRenderer<never>;
46
+ }
47
+
48
+ /**
49
+ * The icon of an item: its contributor's, amber for a decision that still waits, a check once its decision is
50
+ * settled.
51
+ */
52
+ export function InboxTypeIcon({
53
+ entry,
54
+ renderer,
55
+ }: InboxTypeIconProps): ReactElement {
56
+ if (isSettled(entry))
57
+ return (
58
+ <CheckCircle2Icon
59
+ className='size-4 shrink-0 text-emerald-600 dark:text-emerald-400'
60
+ aria-hidden='true'
61
+ />
62
+ );
63
+ // The contributor's icon component, chosen per item.
64
+ return createElement(renderer.icon(entry), {
65
+ 'aria-hidden': true,
66
+ className: cn(
67
+ 'size-4 shrink-0',
68
+ kindOf(entry) === 'decision'
69
+ ? 'text-amber-600 dark:text-amber-400'
70
+ : 'text-muted-foreground',
71
+ ),
72
+ });
73
+ }
74
+
75
+ export interface InboxItemCardProps {
76
+ readonly entry: InboxEntry;
77
+ readonly selected: boolean;
78
+ readonly busy: boolean;
79
+ /** Arrived by a realtime push after the list first loaded: slides in. */
80
+ readonly fresh?: boolean;
81
+ readonly onSelect: (entry: InboxEntry) => void;
82
+ readonly onOpen: (entry: InboxEntry) => void;
83
+ readonly onAction: (entry: InboxEntry, action: InboxAction) => void;
84
+ }
85
+
86
+ /** A card, worded by the item's contributor in its own namespace (`registry.ts`). */
87
+ export function InboxItemCard(props: InboxItemCardProps): ReactElement {
88
+ const renderer = useRendererOf()(props.entry);
89
+ const frame = useInboxFrameText();
90
+ return (
91
+ <ContributorScope
92
+ key={rendererKey(renderer)}
93
+ namespace={renderer.namespace}
94
+ resources={renderer.resources}
95
+ >
96
+ <ItemCard {...props} renderer={renderer} frame={frame} />
97
+ </ContributorScope>
98
+ );
99
+ }
100
+
101
+ /**
102
+ * One compact card of the inbox list. Clicking anywhere on it shows it in the detail pane (and marks it read), as its
103
+ * chevron says; Enter on a focused card opens its route. The card reads: what it is about (an issue's identifier), type, ×N and time, then its title,
104
+ * then one sentence, as its contributor words them. A decision that still waits carries an amber bar, an unread card a
105
+ * dot and a bold title, a settled decision is dimmed with a check, and an item that arrived after the list loaded
106
+ * slides in. The menu button and a right-click offer read / unread and delete.
107
+ */
108
+ function ItemCard({
109
+ entry,
110
+ renderer,
111
+ frame,
112
+ selected,
113
+ busy,
114
+ fresh,
115
+ onSelect,
116
+ onOpen,
117
+ onAction,
118
+ }: InboxItemCardProps & {
119
+ readonly renderer: InboxEntryRenderer<never>;
120
+ readonly frame: InboxFrameText;
121
+ }): ReactElement {
122
+ const { t, language } = frame;
123
+ const wording = renderer.useWording();
124
+ const { item, notice } = entry;
125
+ const { title, sentence } = wording.text(entry);
126
+ const unread = !item.readAt;
127
+ const settled = isSettled(entry);
128
+ const waiting = kindOf(entry) === 'decision' && !settled;
129
+ const actions = inboxActionsFor(entry);
130
+
131
+ function onKeyDown(event: KeyboardEvent<HTMLButtonElement>): void {
132
+ if (event.key === 'Enter' && !event.metaKey && !event.ctrlKey) {
133
+ event.preventDefault();
134
+ onOpen(entry);
135
+ }
136
+ }
137
+
138
+ const menuItems = (
139
+ Item: typeof DropdownMenuItem | typeof ContextMenuItem,
140
+ ): ReactElement[] =>
141
+ actions.map((action) => {
142
+ const Icon = INBOX_ACTION_ICON[action];
143
+ return (
144
+ <Item
145
+ key={action}
146
+ disabled={busy}
147
+ onClick={() => onAction(entry, action)}
148
+ >
149
+ <Icon />
150
+ {actionLabel(t, action)}
151
+ </Item>
152
+ );
153
+ });
154
+
155
+ return (
156
+ <ContextMenu>
157
+ <ContextMenuTrigger
158
+ data-inbox-item={item.id}
159
+ data-selected={selected ? 'true' : undefined}
160
+ className={cn(
161
+ 'group relative flex items-start gap-3 rounded-lg border border-transparent p-3 pr-2 transition-[background-color,border-color,opacity] duration-200',
162
+ selected ? 'border-border bg-card shadow-xs' : 'hover:bg-accent/60',
163
+ settled && 'opacity-55',
164
+ fresh &&
165
+ 'animate-in duration-200 fade-in slide-in-from-top-1 motion-reduce:animate-none',
166
+ )}
167
+ >
168
+ <span
169
+ aria-hidden='true'
170
+ className={cn(
171
+ 'absolute top-3 bottom-3 left-0 w-0.5 rounded-full',
172
+ selected
173
+ ? 'bg-primary'
174
+ : waiting
175
+ ? 'bg-amber-500'
176
+ : 'bg-transparent',
177
+ )}
178
+ />
179
+ <span className='mt-0.5 flex shrink-0'>
180
+ <InboxTypeIcon entry={entry} renderer={renderer} />
181
+ </span>
182
+ <button
183
+ type='button'
184
+ aria-current={selected ? 'true' : undefined}
185
+ // Stretched over the whole card, so a click anywhere on it selects the item.
186
+ className="block min-w-0 flex-1 cursor-pointer space-y-1 text-left after:absolute after:inset-0 after:rounded-lg after:content-[''] focus-visible:outline-none"
187
+ onClick={() => onSelect(entry)}
188
+ onKeyDown={onKeyDown}
189
+ >
190
+ <span className='flex items-center gap-1.5 text-xs text-muted-foreground'>
191
+ {notice?.subject?.label ? (
192
+ <span className='shrink-0 font-mono'>{notice.subject.label}</span>
193
+ ) : null}
194
+ <span className='truncate'>{wording.label(entry)}</span>
195
+ {notice && notice.count > 1 ? (
196
+ <span className='shrink-0 tabular-nums'>
197
+ {t('inbox.count', {
198
+ count: notice.count,
199
+ defaultValue: '×{{count}}',
200
+ })}
201
+ </span>
202
+ ) : null}
203
+ {settled ? (
204
+ <span className='shrink-0'>
205
+ {t('inbox.resolved', { defaultValue: 'Handled' })}
206
+ </span>
207
+ ) : null}
208
+ {/* The latest notice's time: a merged item (×N) is sent anew, replacing the previous one. */}
209
+ <time
210
+ className='ml-auto shrink-0 tabular-nums'
211
+ dateTime={item.createdAt}
212
+ title={new Date(item.createdAt).toLocaleString(language)}
213
+ >
214
+ {relativeTime(item.createdAt, language)}
215
+ </time>
216
+ </span>
217
+ <span
218
+ className={cn(
219
+ 'flex items-center gap-1.5 text-sm group-focus-within:underline',
220
+ unread ? 'font-semibold' : 'font-normal',
221
+ )}
222
+ >
223
+ {unread ? (
224
+ <span className='size-1.5 shrink-0 rounded-full bg-primary'>
225
+ <span className='sr-only'>
226
+ {t('inbox.unread', { defaultValue: 'Unread' })}
227
+ </span>
228
+ </span>
229
+ ) : null}
230
+ <span className='truncate'>{title}</span>
231
+ </span>
232
+ {sentence ? (
233
+ <span className='line-clamp-1 text-sm text-muted-foreground wrap-anywhere'>
234
+ {sentence}
235
+ </span>
236
+ ) : null}
237
+ </button>
238
+ <DropdownMenu>
239
+ <DropdownMenuTrigger
240
+ render={
241
+ <Button
242
+ variant='ghost'
243
+ size='icon-xs'
244
+ disabled={busy}
245
+ className='relative opacity-0 group-hover:opacity-100 focus-visible:opacity-100 data-popup-open:opacity-100'
246
+ aria-label={t('inbox.actionsFor', {
247
+ title,
248
+ defaultValue: 'Actions for {{title}}',
249
+ })}
250
+ />
251
+ }
252
+ >
253
+ <MoreHorizontalIcon />
254
+ </DropdownMenuTrigger>
255
+ <DropdownMenuContent align='end' className='w-auto min-w-40'>
256
+ <DropdownMenuGroup>{menuItems(DropdownMenuItem)}</DropdownMenuGroup>
257
+ </DropdownMenuContent>
258
+ </DropdownMenu>
259
+ <ChevronRightIcon
260
+ aria-hidden='true'
261
+ className={cn(
262
+ 'size-4 shrink-0 self-center transition-colors',
263
+ selected
264
+ ? 'text-foreground'
265
+ : 'text-muted-foreground group-hover:text-foreground',
266
+ )}
267
+ />
268
+ </ContextMenuTrigger>
269
+ <ContextMenuContent>
270
+ <ContextMenuGroup>{menuItems(ContextMenuItem)}</ContextMenuGroup>
271
+ </ContextMenuContent>
272
+ </ContextMenu>
273
+ );
274
+ }
@@ -0,0 +1,433 @@
1
+ import { AlertCircleIcon, InboxIcon } from 'lucide-react';
2
+ import type { ReactElement, ReactNode } from 'react';
3
+
4
+ import {
5
+ Alert,
6
+ AlertAction,
7
+ AlertDescription,
8
+ AlertTitle,
9
+ } from '#components/ui/alert';
10
+ import { Badge } from '#components/ui/badge';
11
+ import { Button } from '#components/ui/button';
12
+ import {
13
+ Empty,
14
+ EmptyContent,
15
+ EmptyDescription,
16
+ EmptyHeader,
17
+ EmptyMedia,
18
+ EmptyTitle,
19
+ } from '#components/ui/empty';
20
+ import { Kbd } from '#components/ui/kbd';
21
+ import {
22
+ Select,
23
+ SelectContent,
24
+ SelectItem,
25
+ SelectTrigger,
26
+ SelectValue,
27
+ } from '#components/ui/select';
28
+ import { Skeleton } from '#components/ui/skeleton';
29
+ import { Spinner } from '#components/ui/spinner';
30
+ import { Tabs, TabsList, TabsTrigger } from '#components/ui/tabs';
31
+ import { cn } from 'cn';
32
+
33
+ import { useInboxFrameText } from './frame.js';
34
+ import type { InboxAction } from './inbox-actions.js';
35
+ import { InboxItemCard } from './inbox-item.js';
36
+ import {
37
+ categoriesOf,
38
+ INBOX_VIEWS,
39
+ kindsOf,
40
+ shownGroups,
41
+ type InboxEntry,
42
+ type InboxFilter,
43
+ type InboxGroups,
44
+ type InboxView,
45
+ } from './model.js';
46
+ import { categoriesOfRegistry, useInboxRegistry } from './registry.js';
47
+
48
+ export interface InboxListQuery {
49
+ readonly isError: boolean;
50
+ readonly error: unknown;
51
+ readonly hasNextPage: boolean;
52
+ readonly isFetchingNextPage: boolean;
53
+ refetch(): unknown;
54
+ fetchNextPage(): unknown;
55
+ }
56
+
57
+ /** What a collection category shows in the list: its group while every kind shows, its list while selected. */
58
+ export interface InboxListCollection {
59
+ /** Its group, after the category it follows. */
60
+ readonly group?: ReactNode;
61
+ /** How many records its group lists; the group shows only above zero. */
62
+ readonly count?: number;
63
+ /** Its list, in place of the items while the kind filter selects it. */
64
+ readonly list?: ReactNode;
65
+ }
66
+
67
+ export interface InboxListProps {
68
+ readonly filter: InboxFilter;
69
+ /** Unread items among those loaded, the count on All. */
70
+ readonly unread: number;
71
+ /** What waits on the viewer, the count on To do and the header badge's. */
72
+ readonly pending: number;
73
+ /** What waits in each waiting category, beside its group's heading; its unsettled items when left out. */
74
+ readonly waiting?: Readonly<Record<string, number>>;
75
+ readonly groups: InboxGroups;
76
+ readonly loaded: boolean;
77
+ readonly query: InboxListQuery;
78
+ readonly selectedId: string | null;
79
+ /** Items that arrived after the list first loaded. */
80
+ readonly fresh?: ReadonlySet<string>;
81
+ readonly busy: boolean;
82
+ readonly onFilter: (filter: InboxFilter) => void;
83
+ readonly onSelect: (entry: InboxEntry) => void;
84
+ readonly onOpen: (entry: InboxEntry) => void;
85
+ readonly onAction: (entry: InboxEntry, action: InboxAction) => void;
86
+ /** The feeds' sections (decisions kept in a contributor's own API), shown first with every kind or decisions. */
87
+ readonly feeds?: ReactNode;
88
+ readonly feedCount?: number;
89
+ /** What each collection category shows, by its id. */
90
+ readonly collections?: Readonly<Record<string, InboxListCollection>>;
91
+ /** Beside the kind filter, such as a sound reminder switch. */
92
+ readonly footer?: ReactNode;
93
+ /** The prefix of the element ids it renders. */
94
+ readonly idPrefix?: string;
95
+ }
96
+
97
+ /**
98
+ * The inbox's left column: two views — All, and To do with the count of what waits on the viewer, the same number
99
+ * as the header badge — then one filter by kind (every kind, then the registry's categories) and the `footer` slot.
100
+ * Below, the items in groups: the feeds' sections, each entry category's group with each collection's group after the
101
+ * category it follows, and "load more". To do lists only what still waits; its kind filter offers only the waiting
102
+ * categories and the collections offered there. A collection selected by the kind filter lists its own records in
103
+ * place of the items. The keyboard hint sits at the bottom.
104
+ */
105
+ export function InboxList({
106
+ filter,
107
+ unread,
108
+ pending,
109
+ waiting,
110
+ groups,
111
+ loaded,
112
+ query,
113
+ selectedId,
114
+ fresh,
115
+ busy,
116
+ onFilter,
117
+ onSelect,
118
+ onOpen,
119
+ onAction,
120
+ feeds,
121
+ feedCount = 0,
122
+ collections = {},
123
+ footer,
124
+ idPrefix = 'nocobase-inbox',
125
+ }: InboxListProps): ReactElement {
126
+ const { t } = useInboxFrameText();
127
+ const categories = categoriesOfRegistry(useInboxRegistry());
128
+ const { view, kind } = filter;
129
+ const shown = shownGroups(filter, groups, categories);
130
+ const selectedCollection = categories.find(
131
+ (category) => category.type === 'collection' && category.id === kind,
132
+ );
133
+ const shownCollections =
134
+ kind === 'all'
135
+ ? categoriesOf(view, categories).filter(
136
+ (category) =>
137
+ category.type === 'collection' &&
138
+ (collections[category.id]?.count ?? 0) > 0,
139
+ )
140
+ : [];
141
+ const firstEntries = shown[0]?.category.id;
142
+ const showFeeds = (kind === 'all' || kind === firstEntries) && feedCount > 0;
143
+ const empty =
144
+ loaded &&
145
+ !showFeeds &&
146
+ shownCollections.length === 0 &&
147
+ shown.every(({ entries }) => entries.length === 0);
148
+ const kinds = kindsOf(view, categories).map((value) => ({
149
+ value,
150
+ label:
151
+ value === 'all'
152
+ ? t('inbox.kinds.all', { defaultValue: 'All types' })
153
+ : (categories.find((category) => category.id === value)?.label(t) ??
154
+ value),
155
+ }));
156
+ // Each collection's group follows the category it names; one naming no category shown comes last.
157
+ const shownIds = new Set(shown.map(({ category }) => category.id));
158
+ const after = (id: string | null): ReactNode =>
159
+ shownCollections
160
+ .filter((category) => {
161
+ const target =
162
+ category.type === 'collection' ? category.after : undefined;
163
+ return id === null
164
+ ? target === undefined || !shownIds.has(target)
165
+ : target === id;
166
+ })
167
+ .map((category) => (
168
+ <CollectionSlot key={category.id}>
169
+ {collections[category.id]?.group}
170
+ </CollectionSlot>
171
+ ));
172
+
173
+ let content: ReactNode;
174
+ if (selectedCollection) content = collections[selectedCollection.id]?.list;
175
+ else if (query.isError && !loaded)
176
+ content = (
177
+ <div className='px-1'>
178
+ <Alert variant='destructive'>
179
+ <AlertCircleIcon />
180
+ <AlertTitle>
181
+ {t('inbox.loadFailed', {
182
+ defaultValue: 'Unable to load the inbox',
183
+ })}
184
+ </AlertTitle>
185
+ <AlertDescription>
186
+ {t('inbox.loadFailedDescription', {
187
+ defaultValue: 'Check your connection and try again.',
188
+ })}
189
+ </AlertDescription>
190
+ <AlertAction>
191
+ <Button
192
+ variant='outline'
193
+ size='sm'
194
+ onClick={() => void query.refetch()}
195
+ >
196
+ {t('inbox.retry', { defaultValue: 'Retry' })}
197
+ </Button>
198
+ </AlertAction>
199
+ </Alert>
200
+ </div>
201
+ );
202
+ else if (!loaded)
203
+ content = (
204
+ <div
205
+ role='status'
206
+ aria-label={t('inbox.loading', { defaultValue: 'Loading' })}
207
+ className='space-y-2 px-1'
208
+ >
209
+ {Array.from({ length: 5 }, (_, index) => (
210
+ <Skeleton key={index} className='h-16 w-full rounded-lg' />
211
+ ))}
212
+ </div>
213
+ );
214
+ else if (empty)
215
+ content =
216
+ kind === 'all' ? (
217
+ <ListEmpty
218
+ title={
219
+ view === 'todo'
220
+ ? t('inbox.empty.todo', {
221
+ defaultValue: 'Nothing waiting for you',
222
+ })
223
+ : t('inbox.emptyAll', { defaultValue: 'Nothing here' })
224
+ }
225
+ description={t('inbox.emptyDescription', {
226
+ defaultValue:
227
+ 'Notifications and requests sent to you show up here.',
228
+ })}
229
+ />
230
+ ) : (
231
+ <ListEmpty
232
+ title={shown[0]?.category.empty(t) ?? ''}
233
+ description={t('inbox.noMatchDescription', {
234
+ defaultValue: 'Clear the filter to see every type.',
235
+ })}
236
+ action={
237
+ <Button
238
+ variant='outline'
239
+ size='sm'
240
+ onClick={() => onFilter({ view, kind: 'all' })}
241
+ >
242
+ {t('inbox.clearFilter', { defaultValue: 'Clear filter' })}
243
+ </Button>
244
+ }
245
+ />
246
+ );
247
+ else
248
+ content = (
249
+ <div className='space-y-5'>
250
+ {showFeeds ? feeds : null}
251
+ {shown.map(({ category, entries }) => {
252
+ const heading = `${idPrefix}-group-${category.id}`;
253
+ const count = category.waits
254
+ ? (waiting?.[category.id] ??
255
+ entries.filter((entry) => entry.notice?.resolvedAt == null)
256
+ .length)
257
+ : entries.length;
258
+ return (
259
+ <CollectionSlot key={category.id}>
260
+ {entries.length === 0 && kind === 'all' ? null : (
261
+ <section aria-labelledby={heading} className='space-y-1'>
262
+ <h2
263
+ id={heading}
264
+ className='flex items-center gap-2 px-3 text-xs font-medium tracking-wider text-muted-foreground uppercase'
265
+ >
266
+ {category.title(t)}
267
+ {/* What still waits, as To do counts it; handled items stay listed in All but are not counted. */}
268
+ {count > 0 ? (
269
+ <span className='tabular-nums'>{count}</span>
270
+ ) : null}
271
+ </h2>
272
+ <ul aria-label={category.title(t)} className='space-y-0.5'>
273
+ {entries.map((entry) => (
274
+ <li key={entry.item.id}>
275
+ <InboxItemCard
276
+ entry={entry}
277
+ selected={entry.item.id === selectedId}
278
+ busy={busy}
279
+ fresh={fresh?.has(entry.item.id)}
280
+ onSelect={onSelect}
281
+ onOpen={onOpen}
282
+ onAction={onAction}
283
+ />
284
+ </li>
285
+ ))}
286
+ </ul>
287
+ </section>
288
+ )}
289
+ {after(category.id)}
290
+ </CollectionSlot>
291
+ );
292
+ })}
293
+ {after(null)}
294
+ {query.hasNextPage ? (
295
+ <div className='flex justify-center pt-1'>
296
+ <Button
297
+ variant='ghost'
298
+ size='sm'
299
+ disabled={query.isFetchingNextPage}
300
+ onClick={() => void query.fetchNextPage()}
301
+ >
302
+ {query.isFetchingNextPage ? (
303
+ <Spinner data-icon='inline-start' />
304
+ ) : null}
305
+ {t('inbox.loadMore', { defaultValue: 'Load more' })}
306
+ </Button>
307
+ </div>
308
+ ) : null}
309
+ </div>
310
+ );
311
+
312
+ return (
313
+ <>
314
+ <div className='shrink-0 space-y-2 border-b px-3 py-2.5'>
315
+ <Tabs
316
+ value={view}
317
+ onValueChange={(value) => {
318
+ const next = value as InboxView;
319
+ onFilter({
320
+ view: next,
321
+ kind: kindsOf(next, categories).includes(kind) ? kind : 'all',
322
+ });
323
+ }}
324
+ >
325
+ <TabsList variant='line' className='w-full'>
326
+ {INBOX_VIEWS.map((value) => {
327
+ const count = value === 'todo' ? pending : unread;
328
+ return (
329
+ <TabsTrigger key={value} value={value}>
330
+ {value === 'todo'
331
+ ? t('inbox.views.todo', { defaultValue: 'To do' })
332
+ : t('inbox.views.all', { defaultValue: 'All' })}
333
+ {count > 0 ? (
334
+ <Badge
335
+ variant={value === 'todo' ? 'outline' : 'secondary'}
336
+ className={cn(
337
+ 'h-4 px-1.5 tabular-nums',
338
+ value === 'todo' &&
339
+ 'border-transparent bg-amber-500/15 text-amber-700 dark:text-amber-300',
340
+ )}
341
+ aria-label={
342
+ value === 'todo'
343
+ ? t('inbox.pendingDecisions', {
344
+ count,
345
+ defaultValue: '{{count}} waiting',
346
+ })
347
+ : t('inbox.unreadCount', {
348
+ count,
349
+ defaultValue: '{{count}} unread',
350
+ })
351
+ }
352
+ >
353
+ {count}
354
+ </Badge>
355
+ ) : null}
356
+ </TabsTrigger>
357
+ );
358
+ })}
359
+ </TabsList>
360
+ </Tabs>
361
+ <div className='flex flex-wrap items-center justify-between gap-2'>
362
+ <Select
363
+ items={kinds}
364
+ value={kind}
365
+ onValueChange={(next: string | null) => {
366
+ if (next) onFilter({ view, kind: next });
367
+ }}
368
+ >
369
+ <SelectTrigger
370
+ size='sm'
371
+ className='w-40'
372
+ aria-label={t('inbox.kinds.label', { defaultValue: 'Type' })}
373
+ >
374
+ <SelectValue />
375
+ </SelectTrigger>
376
+ <SelectContent className='w-auto max-w-[min(var(--container-sm),var(--available-width))] min-w-(--anchor-width) [&_[data-slot=select-item]>:first-child]:whitespace-normal'>
377
+ {kinds.map((item) => (
378
+ <SelectItem key={item.value} value={item.value}>
379
+ {item.label}
380
+ </SelectItem>
381
+ ))}
382
+ </SelectContent>
383
+ </Select>
384
+ {footer}
385
+ </div>
386
+ </div>
387
+ <div className='min-h-0 flex-1 overflow-y-auto px-2 py-3'>{content}</div>
388
+ <p className='hidden shrink-0 items-center gap-3 border-t px-4 py-2 text-xs text-muted-foreground lg:flex'>
389
+ <span className='inline-flex items-center gap-1'>
390
+ <Kbd>J</Kbd>
391
+ <Kbd>K</Kbd>
392
+ {t('inbox.keyMove', { defaultValue: 'Move' })}
393
+ </span>
394
+ <span className='inline-flex items-center gap-1'>
395
+ <Kbd>Enter</Kbd>
396
+ {t('inbox.keyOpen', { defaultValue: 'Open' })}
397
+ </span>
398
+ </p>
399
+ </>
400
+ );
401
+ }
402
+
403
+ function ListEmpty({
404
+ title,
405
+ description,
406
+ action,
407
+ }: {
408
+ readonly title: string;
409
+ readonly description: string;
410
+ readonly action?: ReactNode;
411
+ }): ReactElement {
412
+ return (
413
+ <Empty className='mx-1 min-h-48'>
414
+ <EmptyHeader>
415
+ <EmptyMedia variant='icon'>
416
+ <InboxIcon />
417
+ </EmptyMedia>
418
+ <EmptyTitle>{title}</EmptyTitle>
419
+ <EmptyDescription>{description}</EmptyDescription>
420
+ </EmptyHeader>
421
+ {action ? <EmptyContent>{action}</EmptyContent> : null}
422
+ </Empty>
423
+ );
424
+ }
425
+
426
+ /** A group of the list, followed by what comes right after it. */
427
+ function CollectionSlot({
428
+ children,
429
+ }: {
430
+ readonly children: ReactNode;
431
+ }): ReactElement {
432
+ return <>{children}</>;
433
+ }