@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,411 @@
1
+ import { useTranslation } from '@nocobase/i18n/client';
2
+ import { ChevronRight } from 'lucide-react';
3
+ import { useRef, useState, type ReactElement } from 'react';
4
+ import { Link } from 'react-router';
5
+
6
+ import {
7
+ Collapsible,
8
+ CollapsibleContent,
9
+ CollapsibleTrigger,
10
+ } from '#components/ui/collapsible';
11
+ import {
12
+ Popover,
13
+ PopoverContent,
14
+ PopoverTitle,
15
+ PopoverTrigger,
16
+ } from '#components/ui/popover';
17
+ import {
18
+ SidebarGroup,
19
+ SidebarGroupContent,
20
+ SidebarMenu,
21
+ SidebarMenuAction,
22
+ SidebarMenuButton,
23
+ SidebarMenuItem,
24
+ SidebarMenuSub,
25
+ SidebarMenuSubButton,
26
+ SidebarMenuSubItem,
27
+ useSidebar,
28
+ } from '#components/ui/sidebar';
29
+
30
+ import {
31
+ routeKey,
32
+ type RouteNavigationItem,
33
+ } from '../../routing/route-navigation.js';
34
+
35
+ // The selected entry uses the theme's `--sidebar-primary` pair (`theme.md`), where shadcn's menu buttons use the
36
+ // hover color; the class is passed in, the primitive stays unchanged.
37
+ const SELECTED =
38
+ 'data-active:bg-sidebar-primary data-active:text-sidebar-primary-foreground data-active:hover:bg-sidebar-primary data-active:hover:text-sidebar-primary-foreground';
39
+
40
+ /**
41
+ * A permission-filtered route tree as the sidebar's menu, drawn with shadcn's Sidebar primitives. Groups and parents
42
+ * with a page are collapsible sub-menus. In the desktop icon mode the labels hide, every entry carries its label as a
43
+ * tooltip, and an entry with children opens them in a popover.
44
+ */
45
+ export function NavigationMenu({
46
+ items,
47
+ label,
48
+ selectedKey,
49
+ }: {
50
+ readonly items: readonly RouteNavigationItem[];
51
+ readonly label: string;
52
+ readonly selectedKey: string | undefined;
53
+ }): ReactElement {
54
+ return (
55
+ <nav aria-label={label}>
56
+ <SidebarGroup>
57
+ <SidebarGroupContent>
58
+ <SidebarMenu>
59
+ {items.map((item) => (
60
+ <NavigationItem
61
+ key={routeKey(item.route)}
62
+ item={item}
63
+ selectedKey={selectedKey}
64
+ />
65
+ ))}
66
+ </SidebarMenu>
67
+ </SidebarGroupContent>
68
+ </SidebarGroup>
69
+ </nav>
70
+ );
71
+ }
72
+
73
+ function useItemLabel(item: RouteNavigationItem): string {
74
+ const { t } = useTranslation(item.route.packageName);
75
+ const title = item.route.navigation?.title ?? '';
76
+ return t(title, { defaultValue: title });
77
+ }
78
+
79
+ /** Closes the phone's sheet once an entry is chosen; the desktop sidebar stays as it is. */
80
+ function useCloseOnNavigate(): () => void {
81
+ const { isMobile, setOpenMobile } = useSidebar();
82
+ return () => {
83
+ if (isMobile) setOpenMobile(false);
84
+ };
85
+ }
86
+
87
+ interface ItemProps {
88
+ readonly item: RouteNavigationItem;
89
+ readonly selectedKey: string | undefined;
90
+ /** Rendered inside the icon mode's popover, where tooltips and the icon mode's popovers do not apply. */
91
+ readonly inPopover?: boolean;
92
+ readonly onNavigate?: () => void;
93
+ }
94
+
95
+ /** One top-level entry of a menu: a link, a collapsible sub-menu, or in the icon mode a popover of its children. */
96
+ function NavigationItem({
97
+ item,
98
+ selectedKey,
99
+ inPopover = false,
100
+ onNavigate,
101
+ }: ItemProps): ReactElement | null {
102
+ const { state, isMobile } = useSidebar();
103
+ const closeSheet = useCloseOnNavigate();
104
+ const navigate = onNavigate ?? closeSheet;
105
+ const label = useItemLabel(item);
106
+ const Icon = item.route.navigation?.icon;
107
+ const isSelected = routeKey(item.route) === selectedKey;
108
+ const children = item.children;
109
+ const disclosure = useDisclosure(item, selectedKey);
110
+ const tooltip = inPopover
111
+ ? undefined
112
+ : { children: label, role: 'tooltip', sideOffset: 8 };
113
+
114
+ if (children.length > 0 && state === 'collapsed' && !isMobile && !inPopover)
115
+ return (
116
+ <SidebarMenuItem>
117
+ <NavigationPopover
118
+ item={item}
119
+ label={label}
120
+ selectedKey={selectedKey}
121
+ />
122
+ </SidebarMenuItem>
123
+ );
124
+
125
+ if (children.length === 0 && !item.route.componentLoader) return null;
126
+
127
+ const link = item.route.componentLoader ? (
128
+ <Link
129
+ to={item.route.path}
130
+ onClick={navigate}
131
+ aria-current={isSelected ? 'page' : undefined}
132
+ />
133
+ ) : undefined;
134
+
135
+ if (children.length === 0)
136
+ return (
137
+ <SidebarMenuItem>
138
+ <SidebarMenuButton
139
+ render={link}
140
+ isActive={isSelected}
141
+ tooltip={tooltip}
142
+ className={SELECTED}
143
+ >
144
+ {Icon ? <Icon /> : null}
145
+ <span>{label}</span>
146
+ </SidebarMenuButton>
147
+ </SidebarMenuItem>
148
+ );
149
+
150
+ return (
151
+ <Collapsible
152
+ open={disclosure.expanded}
153
+ onOpenChange={disclosure.setExpanded}
154
+ render={<SidebarMenuItem />}
155
+ >
156
+ {link ? (
157
+ <>
158
+ <SidebarMenuButton
159
+ render={link}
160
+ isActive={isSelected}
161
+ tooltip={tooltip}
162
+ className={SELECTED}
163
+ >
164
+ {Icon ? <Icon /> : null}
165
+ <span>{label}</span>
166
+ </SidebarMenuButton>
167
+ <SidebarMenuAction
168
+ render={<CollapsibleTrigger />}
169
+ aria-label={label}
170
+ className='data-panel-open:rotate-90'
171
+ >
172
+ <ChevronRight />
173
+ </SidebarMenuAction>
174
+ </>
175
+ ) : (
176
+ <SidebarMenuButton render={<CollapsibleTrigger />} tooltip={tooltip}>
177
+ {Icon ? <Icon /> : null}
178
+ <span>{label}</span>
179
+ <ChevronRight className='ml-auto transition-transform group-data-panel-open/menu-button:rotate-90' />
180
+ </SidebarMenuButton>
181
+ )}
182
+ <CollapsibleContent>
183
+ <SidebarMenuSub>
184
+ {children.map((child) => (
185
+ <NavigationSubItem
186
+ key={routeKey(child.route)}
187
+ item={child}
188
+ selectedKey={selectedKey}
189
+ onNavigate={navigate}
190
+ />
191
+ ))}
192
+ </SidebarMenuSub>
193
+ </CollapsibleContent>
194
+ </Collapsible>
195
+ );
196
+ }
197
+
198
+ /** An entry inside a sub-menu: a link, or a collapsible sub-menu of its own. */
199
+ function NavigationSubItem({
200
+ item,
201
+ selectedKey,
202
+ onNavigate,
203
+ }: ItemProps): ReactElement | null {
204
+ const label = useItemLabel(item);
205
+ const Icon = item.route.navigation?.icon;
206
+ const isSelected = routeKey(item.route) === selectedKey;
207
+ const disclosure = useDisclosure(item, selectedKey);
208
+ const children = item.children;
209
+ if (children.length === 0 && !item.route.componentLoader) return null;
210
+
211
+ const link = item.route.componentLoader ? (
212
+ <Link
213
+ to={item.route.path}
214
+ onClick={onNavigate}
215
+ aria-current={isSelected ? 'page' : undefined}
216
+ />
217
+ ) : undefined;
218
+ const content = (
219
+ <>
220
+ {Icon ? <Icon /> : null}
221
+ <span>{label}</span>
222
+ </>
223
+ );
224
+
225
+ if (children.length === 0)
226
+ return (
227
+ <SidebarMenuSubItem>
228
+ <SidebarMenuSubButton
229
+ render={link}
230
+ isActive={isSelected}
231
+ className={SELECTED}
232
+ >
233
+ {content}
234
+ </SidebarMenuSubButton>
235
+ </SidebarMenuSubItem>
236
+ );
237
+
238
+ return (
239
+ <Collapsible
240
+ open={disclosure.expanded}
241
+ onOpenChange={disclosure.setExpanded}
242
+ render={<SidebarMenuSubItem />}
243
+ >
244
+ {link ? (
245
+ <>
246
+ <SidebarMenuSubButton
247
+ render={link}
248
+ isActive={isSelected}
249
+ className={`${SELECTED} pr-8`}
250
+ >
251
+ {content}
252
+ </SidebarMenuSubButton>
253
+ <SidebarMenuAction
254
+ render={<CollapsibleTrigger />}
255
+ aria-label={label}
256
+ className='top-1 data-panel-open:rotate-90'
257
+ >
258
+ <ChevronRight />
259
+ </SidebarMenuAction>
260
+ </>
261
+ ) : (
262
+ <SidebarMenuSubButton render={<CollapsibleTrigger />}>
263
+ {Icon ? <Icon /> : null}
264
+ <span>{label}</span>
265
+ <ChevronRight className='ml-auto transition-transform in-data-panel-open:rotate-90' />
266
+ </SidebarMenuSubButton>
267
+ )}
268
+ <CollapsibleContent>
269
+ <SidebarMenuSub>
270
+ {children.map((child) => (
271
+ <NavigationSubItem
272
+ key={routeKey(child.route)}
273
+ item={child}
274
+ selectedKey={selectedKey}
275
+ onNavigate={onNavigate}
276
+ />
277
+ ))}
278
+ </SidebarMenuSub>
279
+ </CollapsibleContent>
280
+ </Collapsible>
281
+ );
282
+ }
283
+
284
+ /**
285
+ * A disclosure that opens when the selection moves into it and otherwise keeps what the person chose, so navigating
286
+ * elsewhere does not discard other groups' state.
287
+ */
288
+ function useDisclosure(
289
+ item: RouteNavigationItem,
290
+ selectedKey: string | undefined,
291
+ ): { expanded: boolean; setExpanded: (expanded: boolean) => void } {
292
+ const selected = containsSelection(item, selectedKey);
293
+ const [disclosure, setDisclosure] = useState({
294
+ key: selectedKey,
295
+ expanded: selected,
296
+ });
297
+ if (disclosure.key !== selectedKey)
298
+ setDisclosure({
299
+ key: selectedKey,
300
+ expanded: selected || disclosure.expanded,
301
+ });
302
+ return {
303
+ expanded: disclosure.expanded,
304
+ setExpanded: (expanded) => setDisclosure({ key: selectedKey, expanded }),
305
+ };
306
+ }
307
+
308
+ function containsSelection(
309
+ item: RouteNavigationItem,
310
+ id: string | undefined,
311
+ ): boolean {
312
+ return (
313
+ routeKey(item.route) === id ||
314
+ item.children.some((child) => containsSelection(child, id))
315
+ );
316
+ }
317
+
318
+ /**
319
+ * The icon mode's entry with children: the icon opens its children in a popover on hover or keyboard focus, as a
320
+ * tooltip cannot hold links. A parent with a page of its own stays a link, and a group holding the selected page is
321
+ * highlighted.
322
+ */
323
+ function NavigationPopover({
324
+ item,
325
+ label,
326
+ selectedKey,
327
+ }: {
328
+ readonly item: RouteNavigationItem;
329
+ readonly label: string;
330
+ readonly selectedKey: string | undefined;
331
+ }): ReactElement {
332
+ const [open, setOpen] = useState(false);
333
+ const restoringFocusRef = useRef(false);
334
+ const Icon = item.route.navigation?.icon;
335
+ const page = Boolean(item.route.componentLoader);
336
+ const close = () => setOpen(false);
337
+ return (
338
+ <Popover open={open} onOpenChange={setOpen}>
339
+ <SidebarMenuButton
340
+ isActive={containsSelection(item, selectedKey)}
341
+ className={`${SELECTED} data-popup-open:bg-sidebar-accent data-popup-open:text-sidebar-accent-foreground`}
342
+ render={
343
+ <PopoverTrigger
344
+ openOnHover
345
+ delay={0}
346
+ closeDelay={0}
347
+ onClick={
348
+ page
349
+ ? (event) => {
350
+ event.preventBaseUIHandler();
351
+ close();
352
+ }
353
+ : undefined
354
+ }
355
+ onFocus={(event) => {
356
+ if (restoringFocusRef.current) {
357
+ restoringFocusRef.current = false;
358
+ return;
359
+ }
360
+ if (event.currentTarget.matches(':focus-visible')) setOpen(true);
361
+ }}
362
+ nativeButton={!page}
363
+ role={page ? 'link' : undefined}
364
+ render={
365
+ page ? (
366
+ <Link
367
+ to={item.route.path}
368
+ aria-current={
369
+ routeKey(item.route) === selectedKey ? 'page' : undefined
370
+ }
371
+ />
372
+ ) : (
373
+ <button type='button' />
374
+ )
375
+ }
376
+ />
377
+ }
378
+ aria-label={label}
379
+ >
380
+ {Icon ? <Icon /> : <span>{label}</span>}
381
+ </SidebarMenuButton>
382
+ <PopoverContent
383
+ side='right'
384
+ align='start'
385
+ sideOffset={8}
386
+ initialFocus={false}
387
+ // Returning focus after Escape must not reopen the popup.
388
+ finalFocus={(interaction) => {
389
+ restoringFocusRef.current = interaction === 'keyboard';
390
+ return interaction === 'keyboard';
391
+ }}
392
+ className='max-h-(--available-height) w-max min-w-40 max-w-sm gap-0.5 overflow-y-auto border border-sidebar-border bg-sidebar p-1.5 text-sidebar-foreground shadow-lg'
393
+ >
394
+ <PopoverTitle className='px-2 pt-1 pb-0.5 text-xs font-medium whitespace-nowrap text-muted-foreground'>
395
+ {label}
396
+ </PopoverTitle>
397
+ <SidebarMenu>
398
+ {item.children.map((child) => (
399
+ <NavigationItem
400
+ key={routeKey(child.route)}
401
+ item={child}
402
+ selectedKey={selectedKey}
403
+ inPopover
404
+ onNavigate={close}
405
+ />
406
+ ))}
407
+ </SidebarMenu>
408
+ </PopoverContent>
409
+ </Popover>
410
+ );
411
+ }
@@ -0,0 +1,107 @@
1
+ import { Trans } from 'react-i18next';
2
+ import { useClientApplication } from '@nocobase/app-client';
3
+ import { useTranslation } from '@nocobase/i18n/client';
4
+ import { ShieldCheck } from 'lucide-react';
5
+ import type { ReactElement } from 'react';
6
+
7
+ import { SidebarFooter, useSidebar } from '#components/ui/sidebar';
8
+ import {
9
+ Tooltip,
10
+ TooltipContent,
11
+ TooltipTrigger,
12
+ } from '#components/ui/tooltip';
13
+
14
+ /**
15
+ * The application's sidebar footer: the NocoBase slogan's two lines, then the template's name and its version, each
16
+ * on its own line. In icon mode only the shield remains, and a tooltip carries the same text.
17
+ */
18
+ export function AppSidebarFooter(): ReactElement {
19
+ const { t } = useTranslation();
20
+ const { isMobile, state } = useSidebar();
21
+ const iconOnly = state === 'collapsed' && !isMobile;
22
+ // Published by the server from the application's package.json.
23
+ const publicConfig = useClientApplication().config.public;
24
+ const templateName = publicConfig.get('app.displayName', 'Default Template');
25
+ const templateVersion = publicConfig.get('app.version', '0.0.0');
26
+ const versionLabel = `v${templateVersion}`;
27
+ const buildFreely = t('shell.buildFreely', {
28
+ defaultValue: 'AI builds freely.',
29
+ });
30
+ // The tooltip and the icon's accessible name have no link, so the brand's markup is dropped there.
31
+ const reliability = t('shell.reliability', {
32
+ defaultValue: '<brand>NocoBase</brand> keeps it reliable.',
33
+ }).replace(/<\/?brand>/g, '');
34
+
35
+ const brandLink = (
36
+ <a
37
+ className='rounded-sm text-sidebar-foreground outline-none hover:underline focus-visible:ring-2 focus-visible:ring-sidebar-ring'
38
+ href='https://www.nocobase.com'
39
+ rel='noopener noreferrer'
40
+ target='_blank'
41
+ >
42
+ NocoBase
43
+ </a>
44
+ );
45
+
46
+ return (
47
+ <SidebarFooter className='shrink-0 border-t border-sidebar-border/70'>
48
+ {/* The menu button's inset (`p-2`, `gap-2`), so the shield lines up with the menu icons above. */}
49
+ <div className='flex min-w-0 items-start gap-2 p-2 text-xs leading-5'>
50
+ <Tooltip>
51
+ <TooltipTrigger
52
+ render={
53
+ <span
54
+ aria-label={
55
+ iconOnly
56
+ ? `${buildFreely} ${reliability} ${templateName} ${versionLabel}`
57
+ : undefined
58
+ }
59
+ className='flex h-5 shrink-0 items-center rounded-sm text-sidebar-foreground/80 outline-none focus-visible:ring-2 focus-visible:ring-sidebar-ring'
60
+ role={iconOnly ? 'img' : undefined}
61
+ tabIndex={iconOnly ? 0 : undefined}
62
+ />
63
+ }
64
+ >
65
+ <ShieldCheck aria-hidden className='size-4' />
66
+ </TooltipTrigger>
67
+ <TooltipContent
68
+ className='max-w-56 flex-col items-start gap-0.5'
69
+ hidden={!iconOnly}
70
+ side='right'
71
+ >
72
+ <span className='font-medium'>{buildFreely}</span>
73
+ <span>{reliability}</span>
74
+ <span className='mt-1 opacity-70'>
75
+ {templateName} {versionLabel}
76
+ </span>
77
+ </TooltipContent>
78
+ </Tooltip>
79
+ <div className='min-w-0 flex-1 group-data-[collapsible=icon]:hidden'>
80
+ <p className='font-medium text-balance text-sidebar-foreground'>
81
+ {buildFreely}
82
+ </p>
83
+ <p className='text-balance text-muted-foreground'>
84
+ <Trans
85
+ t={t}
86
+ i18nKey='shell.reliability'
87
+ defaults='<brand>NocoBase</brand> keeps it reliable.'
88
+ components={{ brand: brandLink }}
89
+ >
90
+ {brandLink} keeps it reliable.
91
+ </Trans>
92
+ </p>
93
+ {/* Name and version stay on one line each; a long name is cut off, with the whole name as its title. */}
94
+ <p
95
+ className='mt-1 truncate text-muted-foreground'
96
+ title={templateName}
97
+ >
98
+ {templateName}
99
+ </p>
100
+ <p className='truncate text-muted-foreground' title={versionLabel}>
101
+ {versionLabel}
102
+ </p>
103
+ </div>
104
+ </div>
105
+ </SidebarFooter>
106
+ );
107
+ }
@@ -0,0 +1,35 @@
1
+ import { useTranslation } from '@nocobase/i18n/client';
2
+ import { ArrowLeft } from 'lucide-react';
3
+ import { Link } from 'react-router';
4
+ import type { ReactElement } from 'react';
5
+ export interface SurfaceCopy {
6
+ /** Labels the navigation landmark and the loading state, such as `Settings` or `Dev tools`. */
7
+ readonly title: string;
8
+ /** The path this surface mounts at, used to strip the prefix from nested route paths. */
9
+ readonly pathPrefix: string;
10
+ readonly emptyTitle: string;
11
+ readonly emptyDescription: string;
12
+ }
13
+
14
+ export function SurfaceEmpty({
15
+ copy,
16
+ }: {
17
+ readonly copy: SurfaceCopy;
18
+ }): ReactElement {
19
+ const { t } = useTranslation();
20
+ return (
21
+ <main className='grid min-h-svh place-items-center px-6'>
22
+ <section className='w-full max-w-lg space-y-3 text-center'>
23
+ <h1 className='text-xl font-semibold'>{copy.emptyTitle}</h1>
24
+ <p className='text-sm text-muted-foreground'>{copy.emptyDescription}</p>
25
+ <Link
26
+ className='inline-flex items-center gap-2 text-sm font-medium text-primary hover:underline'
27
+ to='/'
28
+ >
29
+ <ArrowLeft className='size-4' />
30
+ {t('surface.backToApp', { defaultValue: 'Back to app' })}
31
+ </Link>
32
+ </section>
33
+ </main>
34
+ );
35
+ }
@@ -0,0 +1,127 @@
1
+ import { useToaster } from '@nocobase/app-client';
2
+ import { useTranslation } from '@nocobase/i18n/client';
3
+ import { useAuthentication } from '@nocobase/app-plugin-authentication/client';
4
+ import { LogOut, UserRound } from 'lucide-react';
5
+ import { useState, type ReactElement } from 'react';
6
+
7
+ import {
8
+ DropdownMenu,
9
+ DropdownMenuContent,
10
+ DropdownMenuItem,
11
+ DropdownMenuSeparator,
12
+ DropdownMenuTrigger,
13
+ } from '#components/ui/dropdown-menu';
14
+
15
+ import { LanguageSwitcher } from './language-switcher.js';
16
+
17
+ export function UserMenu(): ReactElement {
18
+ const {
19
+ client,
20
+ session,
21
+ isPending: isLoading,
22
+ refresh,
23
+ } = useAuthentication();
24
+ const identity = session?.user
25
+ ? {
26
+ id: session.user.id,
27
+ fullName: session.user.name,
28
+ email: session.user.email,
29
+ avatar: session.user.image ?? undefined,
30
+ }
31
+ : null;
32
+ const [isLoggingOut, setIsLoggingOut] = useState(false);
33
+ const { t } = useTranslation();
34
+ const toaster = useToaster();
35
+
36
+ const name =
37
+ identity?.fullName ||
38
+ identity?.email ||
39
+ t('account.fallback', { defaultValue: 'Account' });
40
+ const initials = getInitials(name);
41
+
42
+ return (
43
+ <DropdownMenu>
44
+ <DropdownMenuTrigger
45
+ openOnHover
46
+ delay={0}
47
+ aria-label={t('account.openMenu', {
48
+ defaultValue: 'Open account menu',
49
+ })}
50
+ className='flex size-10 cursor-pointer items-center justify-center rounded-full border border-border/70 bg-background/60 text-left text-sm outline-none transition-colors hover:bg-muted focus-visible:ring-3 focus-visible:ring-ring/50'
51
+ >
52
+ {identity?.avatar ? (
53
+ <img
54
+ src={identity.avatar}
55
+ alt=''
56
+ className='size-full rounded-full object-cover'
57
+ />
58
+ ) : (
59
+ <span className='grid size-full place-items-center rounded-full bg-muted text-xs font-medium text-muted-foreground'>
60
+ {isLoading ? <UserRound className='size-4' /> : initials}
61
+ </span>
62
+ )}
63
+ </DropdownMenuTrigger>
64
+ <DropdownMenuContent align='end' className='w-64'>
65
+ <div className='px-2 py-1.5'>
66
+ <p className='truncate text-sm font-medium'>{name}</p>
67
+ {identity?.email ? (
68
+ <p className='truncate text-xs text-muted-foreground'>
69
+ {identity.email}
70
+ </p>
71
+ ) : null}
72
+ </div>
73
+ <DropdownMenuSeparator />
74
+ <LanguageSwitcher />
75
+ <DropdownMenuSeparator />
76
+ <DropdownMenuItem
77
+ disabled={isLoggingOut}
78
+ onClick={() => {
79
+ void (async () => {
80
+ setIsLoggingOut(true);
81
+ try {
82
+ // Better Auth returns API failures as data rather than throwing.
83
+ const result = await client.signOut();
84
+ if (result.error) {
85
+ toaster.show({
86
+ type: 'error',
87
+ title: t('account.signOutFailed', {
88
+ defaultValue: 'Unable to sign out. Please try again.',
89
+ }),
90
+ });
91
+ return;
92
+ }
93
+ await refresh();
94
+ } catch {
95
+ toaster.show({
96
+ type: 'error',
97
+ title: t('account.signOutFailed', {
98
+ defaultValue: 'Unable to sign out. Please try again.',
99
+ }),
100
+ });
101
+ } finally {
102
+ setIsLoggingOut(false);
103
+ }
104
+ })();
105
+ }}
106
+ className='gap-2'
107
+ >
108
+ <LogOut className='size-4' />
109
+ {isLoggingOut
110
+ ? t('account.signingOut', { defaultValue: 'Signing out…' })
111
+ : t('account.signOut', { defaultValue: 'Sign out' })}
112
+ </DropdownMenuItem>
113
+ </DropdownMenuContent>
114
+ </DropdownMenu>
115
+ );
116
+ }
117
+
118
+ function getInitials(name: string): string {
119
+ const words = name.trim().split(/\s+/).filter(Boolean);
120
+ if (words.length === 0) {
121
+ return '?';
122
+ }
123
+ return words
124
+ .slice(0, 2)
125
+ .map((word) => word[0]?.toUpperCase() ?? '')
126
+ .join('');
127
+ }
@@ -0,0 +1,2 @@
1
+ export { AppLayout, type AppLayoutProps } from './app-layout.js';
2
+ export { SettingsLayout, type SettingsLayoutProps } from './settings-layout.js';