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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (250) hide show
  1. package/.cursor/mcp.json +8 -0
  2. package/.mcp.json +8 -0
  3. package/.prettierignore +20 -0
  4. package/.vscode/mcp.json +8 -0
  5. package/AGENTS.md +432 -0
  6. package/CLAUDE.md +9 -0
  7. package/Dockerfile +132 -0
  8. package/Dockerfile.dockerignore +45 -0
  9. package/README.MD +481 -0
  10. package/cli/plugins.ts +14 -0
  11. package/client/agent-annotations-host.ts +19 -0
  12. package/client/app.ts +38 -0
  13. package/client/components/back-button.tsx +49 -0
  14. package/client/components/breadcrumbs.tsx +167 -0
  15. package/client/components/inbox-badge.ts +49 -0
  16. package/client/components/inbox-button.tsx +132 -0
  17. package/client/components/inbox-header-button.tsx +25 -0
  18. package/client/components/loading.tsx +35 -0
  19. package/client/components/page-container.tsx +24 -0
  20. package/client/components/page-header.tsx +33 -0
  21. package/client/components/route-child-page.tsx +121 -0
  22. package/client/components/route-dialog.tsx +10 -0
  23. package/client/components/route-drawer.tsx +10 -0
  24. package/client/components/route-overlay.tsx +190 -0
  25. package/client/components/ui/alert.tsx +75 -0
  26. package/client/components/ui/badge.tsx +51 -0
  27. package/client/components/ui/breadcrumb.tsx +121 -0
  28. package/client/components/ui/button.tsx +58 -0
  29. package/client/components/ui/collapsible.tsx +19 -0
  30. package/client/components/ui/context-menu.tsx +271 -0
  31. package/client/components/ui/dialog.tsx +160 -0
  32. package/client/components/ui/dropdown-menu.tsx +270 -0
  33. package/client/components/ui/empty.tsx +103 -0
  34. package/client/components/ui/field.tsx +236 -0
  35. package/client/components/ui/input-group.tsx +158 -0
  36. package/client/components/ui/input.tsx +20 -0
  37. package/client/components/ui/kbd.tsx +26 -0
  38. package/client/components/ui/label.tsx +18 -0
  39. package/client/components/ui/popover.tsx +89 -0
  40. package/client/components/ui/select.tsx +200 -0
  41. package/client/components/ui/separator.tsx +22 -0
  42. package/client/components/ui/sheet.tsx +137 -0
  43. package/client/components/ui/sidebar.tsx +723 -0
  44. package/client/components/ui/skeleton.tsx +13 -0
  45. package/client/components/ui/spinner.tsx +20 -0
  46. package/client/components/ui/tabs.tsx +79 -0
  47. package/client/components/ui/textarea.tsx +17 -0
  48. package/client/components/ui/toast.tsx +231 -0
  49. package/client/components/ui/tooltip.tsx +63 -0
  50. package/client/components/use-route-overlay.ts +26 -0
  51. package/client/config/api.ts +13 -0
  52. package/client/config/app.ts +8 -0
  53. package/client/config/auth.ts +18 -0
  54. package/client/config/index.ts +17 -0
  55. package/client/extensions/nocobase-auth-forms/field-props.ts +13 -0
  56. package/client/extensions/nocobase-auth-forms/form-parts.tsx +101 -0
  57. package/client/extensions/nocobase-auth-forms/password-input.tsx +41 -0
  58. package/client/extensions/nocobase-auth-forms/password-login-form.tsx +123 -0
  59. package/client/extensions/nocobase-auth-forms/password-registration-form.tsx +193 -0
  60. package/client/extensions/nocobase-auth-forms/password-reset-form.tsx +132 -0
  61. package/client/extensions/nocobase-auth-forms/password-reset-request-form.tsx +99 -0
  62. package/client/extensions/nocobase-auth-methods/auth-methods.tsx +170 -0
  63. package/client/extensions/nocobase-auth-split-layout/auth-split-layout.tsx +126 -0
  64. package/client/extensions/nocobase-device-approval/device-approval.tsx +277 -0
  65. package/client/extensions/nocobase-device-approval/locales/en-US.ts +55 -0
  66. package/client/extensions/nocobase-device-approval/locales/zh-CN.ts +27 -0
  67. package/client/extensions/nocobase-device-approval/use-device-approval.ts +176 -0
  68. package/client/extensions/nocobase-file-component-ui/README.md +13 -0
  69. package/client/extensions/nocobase-file-component-ui/components/file-list.tsx +147 -0
  70. package/client/extensions/nocobase-file-component-ui/components/file-preview-dialog.tsx +263 -0
  71. package/client/extensions/nocobase-file-component-ui/components/file-preview-field.tsx +68 -0
  72. package/client/extensions/nocobase-file-component-ui/components/file-thumbnail.tsx +73 -0
  73. package/client/extensions/nocobase-file-component-ui/components/file-upload-field.tsx +386 -0
  74. package/client/extensions/nocobase-file-component-ui/components/previewers/file-preview-content.tsx +255 -0
  75. package/client/extensions/nocobase-file-component-ui/components/previewers/office-open-xml-preview.tsx +226 -0
  76. package/client/extensions/nocobase-file-component-ui/index.ts +8 -0
  77. package/client/extensions/nocobase-file-component-ui/lib/file-preview.ts +194 -0
  78. package/client/extensions/nocobase-file-component-ui/lib/file-url.ts +22 -0
  79. package/client/extensions/nocobase-file-component-ui/lib/message-key.ts +18 -0
  80. package/client/extensions/nocobase-file-component-ui/types.ts +69 -0
  81. package/client/extensions/nocobase-inbox/decision-actions-bar.tsx +140 -0
  82. package/client/extensions/nocobase-inbox/frame.ts +31 -0
  83. package/client/extensions/nocobase-inbox/inbox-actions.ts +21 -0
  84. package/client/extensions/nocobase-inbox/inbox-detail.tsx +386 -0
  85. package/client/extensions/nocobase-inbox/inbox-item.tsx +274 -0
  86. package/client/extensions/nocobase-inbox/inbox-list.tsx +433 -0
  87. package/client/extensions/nocobase-inbox/inbox-page.tsx +545 -0
  88. package/client/extensions/nocobase-inbox/locales/en-US.ts +111 -0
  89. package/client/extensions/nocobase-inbox/locales/zh-CN.ts +57 -0
  90. package/client/extensions/nocobase-inbox/model.ts +428 -0
  91. package/client/extensions/nocobase-inbox/registry-scope.tsx +49 -0
  92. package/client/extensions/nocobase-inbox/registry.ts +303 -0
  93. package/client/extensions/nocobase-inbox/source.ts +117 -0
  94. package/client/hooks/use-mobile.ts +21 -0
  95. package/client/index.tsx +60 -0
  96. package/client/layouts/app-layout.tsx +105 -0
  97. package/client/layouts/components/app-brand.tsx +49 -0
  98. package/client/layouts/components/app-sidebar.tsx +196 -0
  99. package/client/layouts/components/header-actions.tsx +50 -0
  100. package/client/layouts/components/language-switcher.tsx +95 -0
  101. package/client/layouts/components/layout-header.tsx +17 -0
  102. package/client/layouts/components/navigation-menu.tsx +411 -0
  103. package/client/layouts/components/sidebar-footer.tsx +107 -0
  104. package/client/layouts/components/surface-empty.tsx +35 -0
  105. package/client/layouts/components/user-menu.tsx +127 -0
  106. package/client/layouts/index.ts +2 -0
  107. package/client/layouts/settings-layout.tsx +172 -0
  108. package/client/layouts/use-sidebar-preference.ts +57 -0
  109. package/client/lib/constants.ts +5 -0
  110. package/client/lib/toaster.ts +56 -0
  111. package/client/lib/utils.ts +1 -0
  112. package/client/locales/en-US.ts +178 -0
  113. package/client/locales/index.ts +9 -0
  114. package/client/locales/zh-CN.ts +161 -0
  115. package/client/pages/auth/device.tsx +41 -0
  116. package/client/pages/auth/forgot-password.tsx +39 -0
  117. package/client/pages/auth/login.tsx +66 -0
  118. package/client/pages/auth/register.tsx +46 -0
  119. package/client/pages/auth/reset-password.tsx +38 -0
  120. package/client/pages/auth/shared.tsx +111 -0
  121. package/client/pages/home.tsx +16 -0
  122. package/client/pages/inbox.tsx +12 -0
  123. package/client/plugins.ts +41 -0
  124. package/client/react-providers.ts +27 -0
  125. package/client/route-overrides.ts +9 -0
  126. package/client/routes.ts +75 -0
  127. package/client/routing/app-router.tsx +127 -0
  128. package/client/routing/client-route.tsx +129 -0
  129. package/client/routing/route-context.tsx +85 -0
  130. package/client/routing/route-navigation.ts +153 -0
  131. package/client/routing/route-tree.tsx +42 -0
  132. package/client/routing/standalone-page-layout.tsx +15 -0
  133. package/client/runtime.ts +26 -0
  134. package/client/service-provider.ts +40 -0
  135. package/client/source-extensions.ts +19 -0
  136. package/client/startup.tsx +22 -0
  137. package/client/styles.css +122 -0
  138. package/client/theme/index.ts +4 -0
  139. package/client/theme/theme-context.ts +12 -0
  140. package/client/theme/theme-preferences.ts +84 -0
  141. package/client/theme/theme-presets.ts +7 -0
  142. package/client/theme/theme-provider.tsx +106 -0
  143. package/client/theme/theme-settings.tsx +176 -0
  144. package/client/theme/themes/compact.css +140 -0
  145. package/client/theme/themes/default.css +117 -0
  146. package/client/vite-env.d.ts +5 -0
  147. package/components.json +27 -0
  148. package/config.example.yml +172 -0
  149. package/database/main/migrations/.gitkeep +0 -0
  150. package/database/main/migrations/202610060001_create_device_code.ts +37 -0
  151. package/database/main/seeds/.gitkeep +0 -0
  152. package/database/tsconfig.json +4 -0
  153. package/ecosystem.config.js +35 -0
  154. package/eslint.config.js +24 -0
  155. package/index.html +100 -0
  156. package/package.json +200 -3
  157. package/playwright.config.ts +10 -0
  158. package/public/assets/favicon.ico +0 -0
  159. package/public/assets/logo-dark.png +0 -0
  160. package/public/assets/logo-mark-dark.png +0 -0
  161. package/public/assets/logo-mark.png +0 -0
  162. package/public/assets/logo.png +0 -0
  163. package/server/app.ts +50 -0
  164. package/server/config/ai.ts +27 -0
  165. package/server/config/api.ts +8 -0
  166. package/server/config/app.ts +40 -0
  167. package/server/config/auth.ts +36 -0
  168. package/server/config/authorization.ts +18 -0
  169. package/server/config/caching.ts +21 -0
  170. package/server/config/database.ts +22 -0
  171. package/server/config/drive.ts +30 -0
  172. package/server/config/i18n.ts +17 -0
  173. package/server/config/index.ts +73 -0
  174. package/server/config/jobs.ts +27 -0
  175. package/server/config/logging.ts +23 -0
  176. package/server/config/notification.ts +11 -0
  177. package/server/config/queue.ts +28 -0
  178. package/server/config/scheduler.ts +23 -0
  179. package/server/config/secrets.ts +10 -0
  180. package/server/config/server.ts +26 -0
  181. package/server/config/session.ts +60 -0
  182. package/server/config/snowflake.ts +21 -0
  183. package/server/config/spa.ts +24 -0
  184. package/server/config/users.ts +11 -0
  185. package/server/config/workflow.ts +24 -0
  186. package/server/config.ts +24 -0
  187. package/server/embedded.ts +23 -0
  188. package/server/locales/en-US.ts +10 -0
  189. package/server/locales/index.ts +10 -0
  190. package/server/locales/zh-CN.ts +5 -0
  191. package/server/plugins.ts +41 -0
  192. package/server/providers/index.ts +5 -0
  193. package/server/routes/index.ts +6 -0
  194. package/server/runtime.ts +24 -0
  195. package/server/standalone.ts +29 -0
  196. package/server/tsconfig.json +4 -0
  197. package/tailwind.config.mjs +59 -0
  198. package/tests/components/app-layout-routes.test.tsx +132 -0
  199. package/tests/components/app-title.test.tsx +57 -0
  200. package/tests/components/auth-i18n.test.tsx +87 -0
  201. package/tests/components/back-button.test.tsx +80 -0
  202. package/tests/components/file-preview.test.tsx +101 -0
  203. package/tests/components/header-hover.test.tsx +87 -0
  204. package/tests/components/inbox.test.tsx +139 -0
  205. package/tests/components/language-switcher.test.tsx +298 -0
  206. package/tests/components/layout-containers.test.tsx +15 -0
  207. package/tests/components/layout-i18n.test.tsx +187 -0
  208. package/tests/components/loading.test.tsx +25 -0
  209. package/tests/components/navigation-menu.test.tsx +355 -0
  210. package/tests/components/page-harness.test.tsx +318 -0
  211. package/tests/components/primitive-labels.test.tsx +80 -0
  212. package/tests/components/sidebar-permissions.test.tsx +110 -0
  213. package/tests/components/sidebar-preference.test.tsx +42 -0
  214. package/tests/fixtures/theme-tokens.html +12 -0
  215. package/tests/fixtures/theme-tokens.tsx +111 -0
  216. package/tests/logic/account-permissions.test.tsx +204 -0
  217. package/tests/logic/app-locale-coverage.test.ts +136 -0
  218. package/tests/logic/app-server.test.ts +1589 -0
  219. package/tests/logic/asset-url-build.test.ts +106 -0
  220. package/tests/logic/client-auth.test.tsx +122 -0
  221. package/tests/logic/client-plugin-registry.test.ts +63 -0
  222. package/tests/logic/client-route-recovery.test.tsx +80 -0
  223. package/tests/logic/client-routes.test.ts +126 -0
  224. package/tests/logic/client-runtime.test.ts +112 -0
  225. package/tests/logic/client-settings.test.tsx +841 -0
  226. package/tests/logic/client-shell.test.tsx +466 -0
  227. package/tests/logic/client-theme.test.tsx +441 -0
  228. package/tests/logic/config.test.ts +197 -0
  229. package/tests/logic/dev-client-watch.test.ts +108 -0
  230. package/tests/logic/dev-routes-production-build.test.ts +168 -0
  231. package/tests/logic/device-code-migration.test.ts +70 -0
  232. package/tests/logic/lifecycle.test.ts +221 -0
  233. package/tests/logic/plugin-commands.test.ts +69 -0
  234. package/tests/logic/plugin-page-authz.test.ts +76 -0
  235. package/tests/logic/route-navigation.test.ts +66 -0
  236. package/tests/logic/route-overlay.test.tsx +353 -0
  237. package/tests/logic/server-imports.test.ts +88 -0
  238. package/tests/logic/tailwind-sources.test.ts +151 -0
  239. package/tests/logic/template-package-name.test.ts +88 -0
  240. package/tests/logic/theme-preferences.test.ts +86 -0
  241. package/tests/logic/theme-tokens.test.ts +248 -0
  242. package/tests/logic/toaster.test.tsx +108 -0
  243. package/tests/logic/user-menu-sign-out.test.tsx +98 -0
  244. package/tests/setup/client-config.ts +27 -0
  245. package/tsconfig.json +11 -0
  246. package/tsconfig.node.json +12 -0
  247. package/tsconfig.server.json +19 -0
  248. package/vite.config.ts +92 -0
  249. package/vitest.config.ts +37 -0
  250. package/README.md +0 -3
@@ -0,0 +1,167 @@
1
+ import { usePageBreadcrumbLevels } from '@nocobase/app-client';
2
+ import type { PageBreadcrumbLevel } from '@nocobase/app-client';
3
+ import { useTranslation } from '@nocobase/i18n/client';
4
+ import { Fragment, type ReactElement } from 'react';
5
+ import { Link } from 'react-router';
6
+
7
+ import {
8
+ Breadcrumb,
9
+ BreadcrumbEllipsis,
10
+ BreadcrumbItem,
11
+ BreadcrumbLink,
12
+ BreadcrumbList,
13
+ BreadcrumbPage,
14
+ BreadcrumbSeparator,
15
+ } from '#components/ui/breadcrumb';
16
+ import {
17
+ DropdownMenu,
18
+ DropdownMenuContent,
19
+ DropdownMenuItem,
20
+ DropdownMenuTrigger,
21
+ } from '#components/ui/dropdown-menu';
22
+ import { cn } from 'cn';
23
+
24
+ import {
25
+ useRouteTrail,
26
+ type RouteTrailEntry,
27
+ } from '../routing/route-context.js';
28
+ import { routeKey } from '../routing/route-navigation.js';
29
+
30
+ export interface BreadcrumbsProps {
31
+ readonly className?: string;
32
+ /** Route keys the viewer may not open (`useRouteNavigation`): the trail names none of them. */
33
+ readonly denied?: ReadonlySet<string>;
34
+ }
35
+
36
+ /**
37
+ * The trail of destinations leading to the current page, which the layout renders in its header after the sidebar
38
+ * toggle. Pages do not place it.
39
+ *
40
+ * By default it is the route trail: a route earns a level by declaring `breadcrumb`, or by being a menu page
41
+ * (`navigation` with a component), and structure that only owns a path segment — a tab, an overlay, a group with
42
+ * neither — is skipped. A page whose trail names records, or does not follow its URL, declares the whole trail with
43
+ * `usePageBreadcrumb` from `@nocobase/app-client`. Earlier levels link where a page sits behind them; the last is the
44
+ * current page.
45
+ *
46
+ * On a phone only the last level shows, beside the brand; on a medium screen a trail of three or more levels keeps its
47
+ * first and last and folds the middle into a menu.
48
+ */
49
+ export function Breadcrumbs({
50
+ className,
51
+ denied,
52
+ }: BreadcrumbsProps = {}): ReactElement | null {
53
+ const trail = useRouteTrail();
54
+ const declared = usePageBreadcrumbLevels();
55
+ const { t } = useTranslation();
56
+ const trailLevels =
57
+ declared ??
58
+ trail.flatMap((entry): PageBreadcrumbLevel[] => {
59
+ const title = routeLevelTitle(entry);
60
+ if (title === undefined || denied?.has(routeKey(entry.route))) return [];
61
+ return [
62
+ {
63
+ label: t(title, {
64
+ ns: entry.route.packageName,
65
+ defaultValue: title,
66
+ }),
67
+ ...(entry.route.componentLoader ? { to: entry.pathname } : {}),
68
+ },
69
+ ];
70
+ });
71
+
72
+ if (trailLevels.length === 0) return null;
73
+ // A level is told apart by its depth and label; two levels may share either alone.
74
+ const levels = trailLevels.map((level, depth) => ({
75
+ ...level,
76
+ key: `${depth}:${level.label}`,
77
+ }));
78
+ const last = levels.length - 1;
79
+ const folded = levels.slice(1, last);
80
+
81
+ return (
82
+ <Breadcrumb
83
+ aria-label={t('navigation.breadcrumb', { defaultValue: 'Breadcrumb' })}
84
+ className={cn('min-w-0', className)}
85
+ >
86
+ <BreadcrumbList className='flex-nowrap'>
87
+ {levels.map((level, index) => (
88
+ <Fragment key={level.key}>
89
+ {index === 1 && folded.length > 0 ? (
90
+ <>
91
+ <BreadcrumbItem className='hidden md:inline-flex lg:hidden'>
92
+ <DropdownMenu>
93
+ <DropdownMenuTrigger
94
+ aria-label={t('navigation.breadcrumbMore', {
95
+ defaultValue: 'Show the levels in between',
96
+ })}
97
+ className='rounded-sm transition-colors hover:text-foreground'
98
+ >
99
+ <BreadcrumbEllipsis />
100
+ </DropdownMenuTrigger>
101
+ <DropdownMenuContent align='start'>
102
+ {folded.map((middle) =>
103
+ middle.to ? (
104
+ <DropdownMenuItem
105
+ key={middle.key}
106
+ render={<Link to={middle.to} />}
107
+ >
108
+ {middle.label}
109
+ </DropdownMenuItem>
110
+ ) : (
111
+ <DropdownMenuItem key={middle.key} disabled>
112
+ {middle.label}
113
+ </DropdownMenuItem>
114
+ ),
115
+ )}
116
+ </DropdownMenuContent>
117
+ </DropdownMenu>
118
+ </BreadcrumbItem>
119
+ <BreadcrumbSeparator className='hidden md:block lg:hidden' />
120
+ </>
121
+ ) : null}
122
+ <BreadcrumbItem
123
+ className={cn(
124
+ 'min-w-0',
125
+ index === last
126
+ ? undefined
127
+ : index === 0
128
+ ? 'hidden shrink-0 md:inline-flex'
129
+ : 'hidden shrink-0 lg:inline-flex',
130
+ )}
131
+ >
132
+ {index === last ? (
133
+ <BreadcrumbPage className='truncate font-medium'>
134
+ {level.label}
135
+ </BreadcrumbPage>
136
+ ) : level.to ? (
137
+ <BreadcrumbLink
138
+ className='max-w-48 truncate'
139
+ render={<Link to={level.to} />}
140
+ >
141
+ {level.label}
142
+ </BreadcrumbLink>
143
+ ) : (
144
+ <span className='max-w-48 truncate'>{level.label}</span>
145
+ )}
146
+ </BreadcrumbItem>
147
+ {index < last ? (
148
+ <BreadcrumbSeparator
149
+ className={index === 0 ? 'hidden md:block' : 'hidden lg:block'}
150
+ />
151
+ ) : null}
152
+ </Fragment>
153
+ ))}
154
+ </BreadcrumbList>
155
+ </Breadcrumb>
156
+ );
157
+ }
158
+
159
+ /** A route's level title: its `breadcrumb`, else the menu title of a page; `undefined` for structure. */
160
+ function routeLevelTitle(entry: RouteTrailEntry): string | undefined {
161
+ const { route } = entry;
162
+ if (route.breadcrumb) return route.breadcrumb.title;
163
+ if (route.navigation && route.componentLoader) return route.navigation.title;
164
+ return undefined;
165
+ }
166
+
167
+ Breadcrumbs.displayName = 'Breadcrumbs';
@@ -0,0 +1,49 @@
1
+ import { useEffect } from 'react';
2
+
3
+ /**
4
+ * What the inbox button shows: the count of what waits on the viewer (`decisions`), or, with none waiting, the count
5
+ * of unread items; nothing otherwise.
6
+ *
7
+ * What waits wins outright rather than adding up with the unread items. The two overlap: a waiting decision is also an
8
+ * unread item until opened, so a sum would count it twice. And a sum would blur what the badge means: amber says
9
+ * "needs you", which an unread notification does not.
10
+ */
11
+ export type InboxBadge = {
12
+ readonly kind: 'decisions' | 'unread';
13
+ readonly count: number;
14
+ readonly text: string;
15
+ } | null;
16
+
17
+ /** The badge's text for `count`: "99+" above 99 so the badge keeps its size, none at zero. */
18
+ export function inboxBadgeText(count: number): string | null {
19
+ if (!Number.isFinite(count) || count <= 0) return null;
20
+ return count > 99 ? '99+' : String(count);
21
+ }
22
+
23
+ export function inboxBadge(decisions: number, unread: number): InboxBadge {
24
+ const pending = inboxBadgeText(decisions);
25
+ if (pending) return { kind: 'decisions', count: decisions, text: pending };
26
+ const text = inboxBadgeText(unread);
27
+ return text ? { kind: 'unread', count: unread, text } : null;
28
+ }
29
+
30
+ const TITLE_COUNT = /^\(\d+\+?\) /;
31
+
32
+ /**
33
+ * The browser tab title with the badge text in front (`(3) Inbox`), or without it when `text` is null. Any earlier
34
+ * count prefix is replaced, so applying it again never stacks prefixes.
35
+ */
36
+ export function inboxTitle(title: string, text: string | null): string {
37
+ const base = title.replace(TITLE_COUNT, '');
38
+ return text ? `(${text}) ${base}` : base;
39
+ }
40
+
41
+ /** Puts the badge text in front of the browser tab title while mounted, and takes it off again. */
42
+ export function useDocumentTitleBadge(text: string | null): void {
43
+ useEffect(() => {
44
+ document.title = inboxTitle(document.title, text);
45
+ return () => {
46
+ document.title = inboxTitle(document.title, null);
47
+ };
48
+ }, [text]);
49
+ }
@@ -0,0 +1,132 @@
1
+ import { useTranslation } from '@nocobase/i18n/client';
2
+ import { InboxIcon } from 'lucide-react';
3
+ import type { ReactElement } from 'react';
4
+ import { Link, useLocation } from 'react-router';
5
+
6
+ import {
7
+ Tooltip,
8
+ TooltipContent,
9
+ TooltipTrigger,
10
+ } from '#components/ui/tooltip';
11
+ import { cn } from 'cn';
12
+
13
+ import type { InboxBadge } from './inbox-badge.js';
14
+
15
+ /** Other words for the button, in place of its `inboxButton.*` translations. */
16
+ export interface InboxButtonLabels {
17
+ readonly title?: string;
18
+ readonly pending?: (count: number) => string;
19
+ readonly unread?: (count: number) => string;
20
+ readonly pendingHint?: (count: number) => string;
21
+ readonly unreadHint?: (count: number) => string;
22
+ }
23
+
24
+ export interface InboxButtonProps {
25
+ /** Where the inbox is. */
26
+ readonly to?: string;
27
+ /** What the badge shows (`inboxBadge`); none when null. */
28
+ readonly badge: InboxBadge;
29
+ /** Marks the button current; on `to` and below it when left out. */
30
+ readonly active?: boolean;
31
+ readonly labels?: InboxButtonLabels;
32
+ /** The prefix of its test ids: `<prefix>-button` and `<prefix>-badge`. */
33
+ readonly idPrefix?: string;
34
+ readonly className?: string;
35
+ }
36
+
37
+ /**
38
+ * A header button linking to the inbox, with a badge: amber with the count of what waits on the viewer, or in the
39
+ * primary color with the unread count when nothing waits. The label and the tooltip say which count it is.
40
+ */
41
+ export function InboxButton({
42
+ to = '/inbox',
43
+ badge,
44
+ active,
45
+ labels,
46
+ idPrefix = 'nocobase-inbox',
47
+ className,
48
+ }: InboxButtonProps): ReactElement {
49
+ const { t } = useTranslation();
50
+ const { pathname } = useLocation();
51
+ const current = active ?? (pathname === to || pathname.startsWith(`${to}/`));
52
+ const title =
53
+ labels?.title ?? t('inboxButton.title', { defaultValue: 'Inbox' });
54
+ const label = !badge
55
+ ? title
56
+ : badge.kind === 'decisions'
57
+ ? (labels?.pending?.(badge.count) ??
58
+ t('inboxButton.pending', {
59
+ count: badge.count,
60
+ defaultValue: 'Inbox, {{count}} waiting',
61
+ }))
62
+ : (labels?.unread?.(badge.count) ??
63
+ t('inboxButton.unread', {
64
+ count: badge.count,
65
+ defaultValue: 'Inbox, {{count}} unread',
66
+ }));
67
+
68
+ return (
69
+ <Tooltip>
70
+ <TooltipTrigger
71
+ render={
72
+ <Link
73
+ to={to}
74
+ aria-current={current ? 'page' : undefined}
75
+ className={cn(
76
+ 'relative inline-flex size-9 shrink-0 items-center justify-center rounded-lg border transition-colors focus-visible:outline-none focus-visible:ring-3 focus-visible:ring-ring/50',
77
+ // On the inbox itself: filled and outlined in the text color, apart from the plain buttons beside it.
78
+ current
79
+ ? 'border-foreground/40 bg-accent text-foreground hover:bg-accent'
80
+ : 'border-border/70 bg-background/60 text-foreground hover:bg-muted',
81
+ className,
82
+ )}
83
+ data-testid={`${idPrefix}-button`}
84
+ />
85
+ }
86
+ aria-label={label}
87
+ >
88
+ <InboxIcon className='size-4.5' aria-hidden='true' />
89
+ {/* Amber means "needs you"; the primary color (the brand's, in a themed application) only that something is
90
+ unread. Ringed in the header's color so it stays apart from the border. */}
91
+ {badge ? (
92
+ <span
93
+ className={cn(
94
+ // Anchored to the corner and pushed a third of its size out, whatever its width.
95
+ 'absolute top-0 right-0 flex h-4.5 min-w-4.5 translate-x-1/3 -translate-y-1/3 items-center justify-center rounded-full px-1 leading-none font-semibold tabular-nums ring-2 ring-background',
96
+ // "99+" in smaller type, so the badge stays a pill rather than covering the icon.
97
+ badge.text.length > 2 ? 'text-[9px]' : 'text-[11px]',
98
+ badge.kind === 'decisions'
99
+ ? 'bg-amber-500 text-white'
100
+ : 'bg-primary text-primary-foreground',
101
+ )}
102
+ data-testid={`${idPrefix}-badge`}
103
+ data-kind={badge.kind}
104
+ aria-hidden='true'
105
+ >
106
+ {badge.text}
107
+ </span>
108
+ ) : null}
109
+ </TooltipTrigger>
110
+ <TooltipContent side='bottom' className='max-w-64 flex-col items-start'>
111
+ <span>{title}</span>
112
+ {badge ? (
113
+ <span className='opacity-80'>
114
+ {badge.kind === 'decisions'
115
+ ? (labels?.pendingHint?.(badge.count) ??
116
+ t('inboxButton.pendingHint', {
117
+ count: badge.count,
118
+ defaultValue:
119
+ '{{count}} waiting for you; it goes down once they are handled.',
120
+ }))
121
+ : (labels?.unreadHint?.(badge.count) ??
122
+ t('inboxButton.unreadHint', {
123
+ count: badge.count,
124
+ defaultValue:
125
+ '{{count}} unread; it goes down as you read them.',
126
+ }))}
127
+ </span>
128
+ ) : null}
129
+ </TooltipContent>
130
+ </Tooltip>
131
+ );
132
+ }
@@ -0,0 +1,25 @@
1
+ import { useAuthentication } from '@nocobase/app-plugin-authentication/client';
2
+ import {
3
+ useInboxRefresh,
4
+ useInboxUnreadCount,
5
+ } from '@nocobase/app-plugin-notification-in-app/client/inbox';
6
+ import type { ReactElement } from 'react';
7
+
8
+ import { inboxBadge, useDocumentTitleBadge } from '#components/inbox-badge';
9
+ import { InboxButton } from '#components/inbox-button';
10
+
11
+ /** The header's link to `/inbox` with the unread count, for a signed-in person only. */
12
+ export function InboxHeaderButton(): ReactElement | null {
13
+ const { session, isPending } = useAuthentication();
14
+ if (isPending || !session?.user) return null;
15
+ return <SignedInInboxButton key={session.user.id} />;
16
+ }
17
+
18
+ function SignedInInboxButton(): ReactElement {
19
+ useInboxRefresh();
20
+ const unread = useInboxUnreadCount();
21
+ // Nothing in this application asks the viewer to decide, so the badge counts what is unread.
22
+ const badge = inboxBadge(0, unread.data ?? 0);
23
+ useDocumentTitleBadge(badge?.text ?? null);
24
+ return <InboxButton badge={badge} />;
25
+ }
@@ -0,0 +1,35 @@
1
+ import { useTranslation } from '@nocobase/i18n/client';
2
+ import type { ComponentPropsWithoutRef, ReactElement } from 'react';
3
+
4
+ import { Spinner } from '#components/ui/spinner';
5
+ import { cn } from 'cn';
6
+
7
+ export interface LoadingProps extends ComponentPropsWithoutRef<'div'> {
8
+ readonly fullscreen?: boolean;
9
+ readonly label?: string;
10
+ }
11
+
12
+ export function Loading(inputProps: LoadingProps): ReactElement {
13
+ const { t } = useTranslation();
14
+ const {
15
+ className,
16
+ fullscreen = false,
17
+ label = t('status.loading', { defaultValue: 'Loading' }),
18
+ ...props
19
+ } = inputProps;
20
+
21
+ return (
22
+ <div
23
+ aria-label={label}
24
+ className={cn(
25
+ 'flex items-center justify-center',
26
+ fullscreen && 'min-h-svh w-full bg-background',
27
+ className,
28
+ )}
29
+ role='status'
30
+ {...props}
31
+ >
32
+ <Spinner aria-hidden='true' role={undefined} />
33
+ </div>
34
+ );
35
+ }
@@ -0,0 +1,24 @@
1
+ import type { ComponentProps, ReactElement } from 'react';
2
+
3
+ import { cn } from 'cn';
4
+
5
+ export type PageContainerProps = ComponentProps<'section'>;
6
+
7
+ /**
8
+ * The frame of a page: the full width, the responsive padding, and the spacing between its sections.
9
+ *
10
+ * A page renders one. Content that renders inside another page — an inline child route, a tab panel — already sits in
11
+ * that page's container and adds none of its own, while a covering `RouteChildPage` is a surface of its own and
12
+ * places one inside it.
13
+ */
14
+ export function PageContainer({
15
+ className,
16
+ ...props
17
+ }: PageContainerProps): ReactElement {
18
+ return (
19
+ <section
20
+ className={cn('w-full space-y-6 p-6 md:p-8', className)}
21
+ {...props}
22
+ />
23
+ );
24
+ }
@@ -0,0 +1,33 @@
1
+ import type { ReactElement, ReactNode } from 'react';
2
+
3
+ export interface PageHeaderProps {
4
+ readonly title: ReactNode;
5
+ readonly description?: ReactNode;
6
+ /** Page-level actions, aligned to the right of the title from the `sm` breakpoint up. */
7
+ readonly actions?: ReactNode;
8
+ }
9
+
10
+ /** The heading of a page: its only `h1`, an optional description, and the actions that apply to the whole page. */
11
+ export function PageHeader({
12
+ actions,
13
+ description,
14
+ title,
15
+ }: PageHeaderProps): ReactElement {
16
+ return (
17
+ <header className='flex flex-col gap-4 sm:flex-row sm:items-end sm:justify-between'>
18
+ <div className='min-w-0'>
19
+ <h1 className='font-heading text-3xl font-semibold tracking-[-0.035em]'>
20
+ {title}
21
+ </h1>
22
+ {description ? (
23
+ <p className='mt-2 max-w-2xl text-sm leading-6 text-muted-foreground'>
24
+ {description}
25
+ </p>
26
+ ) : null}
27
+ </div>
28
+ {actions ? (
29
+ <div className='flex shrink-0 items-center gap-2'>{actions}</div>
30
+ ) : null}
31
+ </header>
32
+ );
33
+ }
@@ -0,0 +1,121 @@
1
+ import {
2
+ createContext,
3
+ useContext,
4
+ useEffect,
5
+ useRef,
6
+ type ReactElement,
7
+ type ReactNode,
8
+ type RefObject,
9
+ } from 'react';
10
+
11
+ import { cn } from 'cn';
12
+
13
+ export interface RouteChildPageProps {
14
+ readonly children?: ReactNode;
15
+ readonly className?: string;
16
+ }
17
+
18
+ // The scrolling element of the layer a child page is rendered inside, when it is rendered inside one.
19
+ const EnclosingLayerContext =
20
+ createContext<RefObject<HTMLDivElement | null> | null>(null);
21
+
22
+ /**
23
+ * A child page, laid over the page that opened it.
24
+ *
25
+ * It is the third way a child route can present itself, beside `RouteDialog` and `RouteDrawer`: those float in the
26
+ * middle or at the side, this one covers the content area. Because it covers rather than replaces, the page beneath
27
+ * keeps its DOM — a draft being typed, a scroll position — and gets it back when this layer closes.
28
+ *
29
+ * Unlike the other two it is deliberately **not** modal. It does not portal out of the content area or trap focus,
30
+ * because the user is still on a page of the application and must be able to reach the sidebar. What closes it is
31
+ * the header's breadcrumb or the `BackButton` above its heading, or the browser's back button — not an X or Escape.
32
+ *
33
+ * It is two elements, the way the layout's content area is: the outer one positions and never scrolls, the inner one
34
+ * scrolls. A deeper layer rendered beside this component anchors to the content area, as this one does, which is
35
+ * where a page's own outlet belongs. One rendered inside it — a child page of a tab, which can only render through
36
+ * the tab's outlet — anchors to the outer element: it covers this layer whole instead of scrolling out of sight with
37
+ * the content once the user has scrolled this page.
38
+ */
39
+ export function RouteChildPage({
40
+ children,
41
+ className,
42
+ }: RouteChildPageProps): ReactElement {
43
+ const enclosingScrollerRef = useContext(EnclosingLayerContext);
44
+ const layerRef = useRef<HTMLDivElement>(null);
45
+ const scrollerRef = useRef<HTMLDivElement>(null);
46
+
47
+ // Covering a page is not the same as closing it: the siblings underneath keep their DOM, and with it their place
48
+ // in the tab order and the accessibility tree. Left alone they are reachable behind opaque paint — a keyboard
49
+ // user tabs through controls nothing on screen shows, and focusing one scrolls the covered page into view while
50
+ // the layer, anchored to the content area, does not move. `inert` switches off exactly what this layer covers.
51
+ // The sidebar and the header are outside the content area and so outside this list, which is what keeps the
52
+ // component non-modal. A sibling already marked belongs to the layer below and is left for it to restore.
53
+ //
54
+ // Inside another layer, what this one covers also surrounds it: the enclosing page's header and tab bar, up to that
55
+ // layer's scrolling element. That element stays live, since it holds this layer; everything else in it is covered.
56
+ // At this layer's own level only the siblings before it are covered: the ones after it are deeper layers.
57
+ useEffect(() => {
58
+ const layer = layerRef.current;
59
+ const boundary = enclosingScrollerRef?.current;
60
+ const covered: Element[] = [];
61
+ const cover = (element: Element): void => {
62
+ if (element.hasAttribute('inert')) return;
63
+ element.setAttribute('inert', '');
64
+ covered.push(element);
65
+ };
66
+ for (
67
+ let sibling = layer?.previousElementSibling ?? null;
68
+ sibling;
69
+ sibling = sibling.previousElementSibling
70
+ ) {
71
+ cover(sibling);
72
+ }
73
+ if (layer && boundary?.contains(layer)) {
74
+ for (
75
+ let ancestor = layer.parentElement;
76
+ ancestor && ancestor !== boundary;
77
+ ancestor = ancestor.parentElement
78
+ ) {
79
+ for (
80
+ let sibling = ancestor.previousElementSibling;
81
+ sibling;
82
+ sibling = sibling.previousElementSibling
83
+ ) {
84
+ cover(sibling);
85
+ }
86
+ for (
87
+ let sibling = ancestor.nextElementSibling;
88
+ sibling;
89
+ sibling = sibling.nextElementSibling
90
+ ) {
91
+ cover(sibling);
92
+ }
93
+ }
94
+ }
95
+ return () => {
96
+ for (const element of covered) element.removeAttribute('inert');
97
+ };
98
+ });
99
+
100
+ return (
101
+ <div
102
+ className={cn(
103
+ 'absolute inset-0 z-10 overflow-hidden bg-background',
104
+ className,
105
+ )}
106
+ data-slot='route-child-page'
107
+ ref={layerRef}
108
+ >
109
+ {/* Scrolling stops at this layer rather than carrying on into the page it covers, which keeps that page's
110
+ scroll position for when this layer closes. */}
111
+ <div
112
+ className='h-full overflow-y-auto overscroll-contain'
113
+ ref={scrollerRef}
114
+ >
115
+ <EnclosingLayerContext.Provider value={scrollerRef}>
116
+ {children}
117
+ </EnclosingLayerContext.Provider>
118
+ </div>
119
+ </div>
120
+ );
121
+ }
@@ -0,0 +1,10 @@
1
+ import type { ReactElement } from 'react';
2
+
3
+ import { RouteOverlay, type RouteOverlayProps } from './route-overlay.js';
4
+
5
+ export type RouteDialogProps = RouteOverlayProps;
6
+
7
+ /** A child route presented as a centered modal dialog. */
8
+ export function RouteDialog(props: RouteDialogProps): ReactElement {
9
+ return <RouteOverlay {...props} />;
10
+ }
@@ -0,0 +1,10 @@
1
+ import type { ReactElement } from 'react';
2
+
3
+ import { RouteOverlay, type RouteOverlayProps } from './route-overlay.js';
4
+
5
+ export type RouteDrawerProps = RouteOverlayProps;
6
+
7
+ /** A child route presented as a modal panel docked to the right edge of the viewport. */
8
+ export function RouteDrawer(props: RouteDrawerProps): ReactElement {
9
+ return <RouteOverlay {...props} drawer />;
10
+ }