@nocobase/app-template-default 1.0.0-beta.67 → 1.0.0-beta.69

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 (87) hide show
  1. package/.agents/rules/components.md +2 -2
  2. package/.agents/rules/shell.md +5 -1
  3. package/AGENTS.md +5 -3
  4. package/Dockerfile.dockerignore +0 -1
  5. package/README.MD +3 -1
  6. package/client/app.ts +15 -0
  7. package/client/components/data-table/column-header.tsx +91 -0
  8. package/client/components/data-table/data-table.tsx +216 -0
  9. package/client/components/data-table/index.ts +13 -0
  10. package/client/components/data-table/pagination.tsx +165 -0
  11. package/client/components/data-table/view-options.tsx +77 -0
  12. package/client/components/page-header.tsx +1 -1
  13. package/client/components/permission-editor.tsx +589 -0
  14. package/client/components/permission-filter.tsx +455 -0
  15. package/client/components/route-overlay.tsx +26 -13
  16. package/client/components/status-badge.tsx +7 -1
  17. package/client/components/ui/checkbox.tsx +25 -0
  18. package/client/components/ui/sidebar.tsx +1 -4
  19. package/client/components/ui/switch.tsx +30 -0
  20. package/client/components/ui/table.tsx +113 -0
  21. package/client/extensions/nocobase-inbox/inbox-item.tsx +44 -58
  22. package/client/extensions/nocobase-inbox/inbox-list.tsx +73 -75
  23. package/client/index.tsx +4 -0
  24. package/client/layouts/components/app-sidebar.tsx +6 -6
  25. package/client/layouts/components/layout-header.tsx +1 -1
  26. package/client/layouts/components/navigation-menu.tsx +223 -12
  27. package/client/layouts/components/sidebar-footer.tsx +1 -1
  28. package/client/layouts/components/user-menu.tsx +1 -1
  29. package/client/layouts/use-sidebar-preference.ts +62 -42
  30. package/client/lib/navigation-transform.ts +8 -0
  31. package/client/lib/scrollbar.ts +88 -0
  32. package/client/locales/system/en-US.ts +11 -0
  33. package/client/locales/system/settings-en-US.ts +141 -0
  34. package/client/locales/system/settings-zh-CN.ts +135 -0
  35. package/client/locales/system/zh-CN.ts +22 -12
  36. package/client/pages/auth/auth-page.css +52 -0
  37. package/client/pages/auth/login.tsx +1 -1
  38. package/client/pages/auth/reset-password.tsx +57 -14
  39. package/client/pages/auth/shared.tsx +26 -24
  40. package/client/pages/home/index.tsx +2 -2
  41. package/client/pages/settings/hooks.tsx +137 -0
  42. package/client/pages/settings/permission-filters.tsx +119 -0
  43. package/client/pages/settings/permissions-model.ts +260 -0
  44. package/client/pages/settings/role-form.tsx +426 -0
  45. package/client/pages/settings/roles.tsx +163 -0
  46. package/client/pages/settings/routes.ts +79 -0
  47. package/client/pages/settings/shared.tsx +119 -0
  48. package/client/pages/settings/user-detail.tsx +268 -0
  49. package/client/pages/settings/user-form.tsx +427 -0
  50. package/client/pages/settings/users.tsx +451 -0
  51. package/client/routing/app-router.tsx +4 -1
  52. package/client/routing/route-navigation.ts +10 -1
  53. package/client/routing/session-preparation.tsx +65 -0
  54. package/client/styles.css +257 -24
  55. package/client/theme/theme-settings.tsx +12 -7
  56. package/client/theme/themes/compact.css +9 -14
  57. package/client/theme/themes/default.css +7 -12
  58. package/package.json +26 -25
  59. package/server/app.ts +2 -0
  60. package/server/config/notification.ts +4 -7
  61. package/tests/components/app-layout-routes.test.tsx +14 -7
  62. package/tests/components/auth-i18n.test.tsx +57 -5
  63. package/tests/components/data-table.test.tsx +127 -0
  64. package/tests/components/layout-i18n.test.tsx +10 -3
  65. package/tests/components/navigation-menu.test.tsx +155 -16
  66. package/tests/components/permission-editor.test.tsx +477 -0
  67. package/tests/components/permission-filter-binding.test.tsx +150 -0
  68. package/tests/components/permission-filter.test.tsx +202 -0
  69. package/tests/components/settings.test.tsx +294 -0
  70. package/tests/components/sidebar-permissions.test.tsx +17 -19
  71. package/tests/components/sidebar-preference.test.tsx +209 -14
  72. package/tests/components/status-badge.test.tsx +22 -0
  73. package/tests/components/user-permissions.test.tsx +142 -0
  74. package/tests/fixtures/theme-tokens.tsx +9 -1
  75. package/tests/logic/asset-url-build.test.ts +0 -1
  76. package/tests/logic/client-routes.test.ts +40 -0
  77. package/tests/logic/client-shell.test.tsx +10 -2
  78. package/tests/logic/config-example.test.ts +30 -0
  79. package/tests/logic/dev-client-watch.test.ts +8 -1
  80. package/tests/logic/route-overlay.test.tsx +42 -0
  81. package/tests/logic/scrollbar.test.ts +88 -0
  82. package/tests/logic/session-preparation.test.tsx +223 -0
  83. package/tests/logic/settings-permissions.test.ts +168 -0
  84. package/tests/logic/theme-tokens.test.ts +1 -0
  85. package/vite.config.ts +0 -22
  86. package/vitest.config.ts +1 -0
  87. package/client/agent-annotations-host.ts +0 -25
@@ -1,6 +1,12 @@
1
1
  import { useTranslation } from '@nocobase/i18n/client';
2
2
  import { ChevronRight } from 'lucide-react';
3
- import { useRef, useState, type ReactElement } from 'react';
3
+ import {
4
+ createContext,
5
+ useContext,
6
+ useRef,
7
+ useState,
8
+ type ReactElement,
9
+ } from 'react';
4
10
  import { Link } from 'react-router';
5
11
 
6
12
  import {
@@ -17,8 +23,10 @@ import {
17
23
  import {
18
24
  SidebarGroup,
19
25
  SidebarGroupContent,
26
+ SidebarGroupLabel,
20
27
  SidebarMenu,
21
28
  SidebarMenuAction,
29
+ SidebarMenuBadge,
22
30
  SidebarMenuButton,
23
31
  SidebarMenuItem,
24
32
  SidebarMenuSub,
@@ -38,35 +46,164 @@ const SELECTED =
38
46
  '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
47
 
40
48
  /**
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.
49
+ * A count beside a menu entry. A number is what waits on the viewer, such as unread messages or requests to decide;
50
+ * `live` is work under way, such as runs in progress, with `hint` as its words for the tooltip and screen readers. Zero
51
+ * shows nothing.
52
+ */
53
+ export type NavigationBadge =
54
+ | number
55
+ | { readonly count: number; readonly live: true; readonly hint: string };
56
+
57
+ /** Counts keyed by the route name of the entry they stand beside. */
58
+ export type NavigationBadges = Readonly<Record<string, NavigationBadge>>;
59
+
60
+ const NavigationBadgesContext = createContext<NavigationBadges>({});
61
+
62
+ function useNavigationBadge(item: RouteNavigationItem): {
63
+ readonly count: number;
64
+ readonly live: boolean;
65
+ readonly text: string;
66
+ } {
67
+ const badge = useContext(NavigationBadgesContext)[item.route.name];
68
+ if (typeof badge === 'object')
69
+ return { count: badge.count, live: true, text: badge.hint };
70
+ const count = badge ?? 0;
71
+ return { count, live: false, text: String(count) };
72
+ }
73
+
74
+ /**
75
+ * A permission-filtered route tree as the sidebar's menu, drawn with shadcn's Sidebar primitives. A top-level group
76
+ * with no page of its own is a section: a sidebar group under a small heading, its entries always shown. Nested groups
77
+ * and parents with a page are collapsible sub-menus. In the desktop icon mode the labels and headings hide, every entry
78
+ * carries its label as a tooltip, and an entry with children opens them in a popover.
44
79
  */
45
80
  export function NavigationMenu({
46
81
  items,
47
82
  label,
48
83
  selectedKey,
84
+ sections = true,
85
+ badges = {},
49
86
  }: {
50
87
  readonly items: readonly RouteNavigationItem[];
51
88
  readonly label: string;
52
89
  readonly selectedKey: string | undefined;
90
+ /** `false` draws top-level groups as collapsible sub-menus too. */
91
+ readonly sections?: boolean;
92
+ /**
93
+ * Counts beside entries, keyed by route name, for a page entry that is not a sub-menu's: a top-level entry or a
94
+ * section's. They change while the page is open, so the layout passes them rather than the route declaring them.
95
+ */
96
+ readonly badges?: NavigationBadges;
97
+ }): ReactElement {
98
+ // A group already divided into `section`s by the navigation stays one collapsible entry over them.
99
+ const isSection = (item: RouteNavigationItem): boolean =>
100
+ sections &&
101
+ item.children.length > 0 &&
102
+ !item.route.componentLoader &&
103
+ !item.children.some((child) => child.section);
104
+ // Consecutive plain entries share one sidebar group; each section is a group of its own.
105
+ const runs: (RouteNavigationItem | RouteNavigationItem[])[] = [];
106
+ for (const item of items) {
107
+ const last = runs.at(-1);
108
+ if (isSection(item)) runs.push(item);
109
+ else if (Array.isArray(last)) last.push(item);
110
+ else runs.push([item]);
111
+ }
112
+ return (
113
+ <NavigationBadgesContext value={badges}>
114
+ <nav aria-label={label}>
115
+ {runs.map((run) =>
116
+ Array.isArray(run) ? (
117
+ <SidebarGroup key={routeKey(run[0].route)}>
118
+ <SidebarGroupContent>
119
+ <SidebarMenu>
120
+ {run.map((item) => (
121
+ <NavigationItem
122
+ key={routeKey(item.route)}
123
+ item={item}
124
+ selectedKey={selectedKey}
125
+ />
126
+ ))}
127
+ </SidebarMenu>
128
+ </SidebarGroupContent>
129
+ </SidebarGroup>
130
+ ) : (
131
+ <NavigationGroup
132
+ key={routeKey(run.route)}
133
+ item={run}
134
+ selectedKey={selectedKey}
135
+ />
136
+ ),
137
+ )}
138
+ </nav>
139
+ </NavigationBadgesContext>
140
+ );
141
+ }
142
+
143
+ /**
144
+ * A top-level group with no page of its own, as a sidebar group under a heading, its entries always shown. A section
145
+ * the navigation marks deeper in the tree (`section`) is a `NavigationSection` inside its menu instead.
146
+ */
147
+ function NavigationGroup({
148
+ item,
149
+ selectedKey,
150
+ }: {
151
+ readonly item: RouteNavigationItem;
152
+ readonly selectedKey: string | undefined;
53
153
  }): ReactElement {
154
+ const label = useItemLabel(item);
54
155
  return (
55
- <nav aria-label={label}>
56
- <SidebarGroup>
156
+ <SidebarGroup aria-label={label}>
157
+ <SidebarGroupLabel>{label}</SidebarGroupLabel>
158
+ <SidebarGroupContent>
159
+ <SidebarMenu>
160
+ {item.children.map((child) => (
161
+ <NavigationItem
162
+ key={routeKey(child.route)}
163
+ item={child}
164
+ selectedKey={selectedKey}
165
+ />
166
+ ))}
167
+ </SidebarMenu>
168
+ </SidebarGroupContent>
169
+ </SidebarGroup>
170
+ );
171
+ }
172
+
173
+ /** Navigation sections are labels with direct links, rather than another collapsible menu level. */
174
+ function NavigationSection({
175
+ item,
176
+ selectedKey,
177
+ onNavigate,
178
+ inPopover = false,
179
+ }: ItemProps): ReactElement {
180
+ const label = useItemLabel(item);
181
+ return (
182
+ <SidebarMenuItem>
183
+ <SidebarGroup role='group' aria-label={label} className='p-0'>
184
+ <SidebarGroupLabel
185
+ className={
186
+ inPopover
187
+ ? 'text-sidebar-foreground/50 group-data-[collapsible=icon]:mt-0 group-data-[collapsible=icon]:opacity-100'
188
+ : 'text-sidebar-foreground/50'
189
+ }
190
+ >
191
+ {label}
192
+ </SidebarGroupLabel>
57
193
  <SidebarGroupContent>
58
194
  <SidebarMenu>
59
- {items.map((item) => (
60
- <NavigationItem
61
- key={routeKey(item.route)}
62
- item={item}
195
+ {item.children.map((child) => (
196
+ <NavigationSubItem
197
+ key={routeKey(child.route)}
198
+ item={child}
63
199
  selectedKey={selectedKey}
200
+ onNavigate={onNavigate}
64
201
  />
65
202
  ))}
66
203
  </SidebarMenu>
67
204
  </SidebarGroupContent>
68
205
  </SidebarGroup>
69
- </nav>
206
+ </SidebarMenuItem>
70
207
  );
71
208
  }
72
209
 
@@ -107,10 +244,22 @@ function NavigationItem({
107
244
  const isSelected = routeKey(item.route) === selectedKey;
108
245
  const children = item.children;
109
246
  const disclosure = useDisclosure(item, selectedKey);
247
+ const badge = useNavigationBadge(item);
248
+ const counted = badge.count > 0 ? `${label} · ${badge.text}` : null;
110
249
  const tooltip = inPopover
111
250
  ? undefined
112
251
  : { children: label, role: 'tooltip', sideOffset: 8 };
113
252
 
253
+ if (item.section)
254
+ return (
255
+ <NavigationSection
256
+ item={item}
257
+ selectedKey={selectedKey}
258
+ onNavigate={navigate}
259
+ inPopover={inPopover}
260
+ />
261
+ );
262
+
114
263
  if (children.length > 0 && state === 'collapsed' && !isMobile && !inPopover)
115
264
  return (
116
265
  <SidebarMenuItem>
@@ -138,12 +287,22 @@ function NavigationItem({
138
287
  <SidebarMenuButton
139
288
  render={link}
140
289
  isActive={isSelected}
141
- tooltip={tooltip}
290
+ // A counted entry is named with its count, as its tooltip shows it.
291
+ aria-label={counted ?? undefined}
292
+ tooltip={
293
+ tooltip && counted ? { ...tooltip, children: counted } : tooltip
294
+ }
142
295
  className={SELECTED}
143
296
  >
144
297
  {Icon ? <Icon /> : null}
145
298
  <span>{label}</span>
146
299
  </SidebarMenuButton>
300
+ {badge.count > 0 && (
301
+ <EntryBadge
302
+ {...badge}
303
+ dot={state === 'collapsed' && !isMobile && !inPopover}
304
+ />
305
+ )}
147
306
  </SidebarMenuItem>
148
307
  );
149
308
 
@@ -195,6 +354,50 @@ function NavigationItem({
195
354
  );
196
355
  }
197
356
 
357
+ /**
358
+ * An entry's count at the row's end, on a round ground of its own; work under way in the primary color behind a
359
+ * pulsing dot. The primitive hides it in the icon mode, where a dot on the icon's corner keeps it noticed and the
360
+ * tooltip carries the count. The words are the entry's, so this is hidden from screen readers.
361
+ */
362
+ function EntryBadge({
363
+ count,
364
+ live,
365
+ text,
366
+ dot,
367
+ }: {
368
+ readonly count: number;
369
+ readonly live: boolean;
370
+ readonly text: string;
371
+ readonly dot: boolean;
372
+ }): ReactElement {
373
+ const pulse = 'animate-pulse motion-reduce:animate-none';
374
+ return (
375
+ <>
376
+ <SidebarMenuBadge
377
+ aria-hidden
378
+ title={live ? text : undefined}
379
+ className={
380
+ live
381
+ ? 'gap-1 rounded-full bg-primary/10 px-1.5 text-primary dark:bg-primary/20'
382
+ : 'rounded-full bg-foreground/8 px-1.5 dark:bg-foreground/15'
383
+ }
384
+ >
385
+ {live && (
386
+ <span className={`size-1.5 rounded-full bg-primary ${pulse}`} />
387
+ )}
388
+ {count > 99 ? '99+' : count}
389
+ </SidebarMenuBadge>
390
+ {dot && (
391
+ <span
392
+ aria-hidden
393
+ data-slot='navigation-badge-dot'
394
+ className={`pointer-events-none absolute top-1 right-1 size-2 rounded-full bg-primary ${live ? pulse : ''}`}
395
+ />
396
+ )}
397
+ </>
398
+ );
399
+ }
400
+
198
401
  /** An entry inside a sub-menu: a link, or a collapsible sub-menu of its own. */
199
402
  function NavigationSubItem({
200
403
  item,
@@ -206,6 +409,14 @@ function NavigationSubItem({
206
409
  const isSelected = routeKey(item.route) === selectedKey;
207
410
  const disclosure = useDisclosure(item, selectedKey);
208
411
  const children = item.children;
412
+ if (item.section)
413
+ return (
414
+ <NavigationSection
415
+ item={item}
416
+ selectedKey={selectedKey}
417
+ onNavigate={onNavigate}
418
+ />
419
+ );
209
420
  if (children.length === 0 && !item.route.componentLoader) return null;
210
421
 
211
422
  const link = item.route.componentLoader ? (
@@ -44,7 +44,7 @@ export function AppSidebarFooter(): ReactElement {
44
44
  );
45
45
 
46
46
  return (
47
- <SidebarFooter className='shrink-0 border-t border-sidebar-border/70'>
47
+ <SidebarFooter className='shrink-0 border-t border-sidebar-border'>
48
48
  {/* The menu button's inset (`p-2`, `gap-2`), so the shield lines up with the menu icons above. */}
49
49
  <div className='flex min-w-0 items-start gap-2 p-2 text-xs leading-5'>
50
50
  <Tooltip>
@@ -49,7 +49,7 @@ export function UserMenu(): ReactElement {
49
49
  aria-label={t('account.openMenu', {
50
50
  defaultValue: 'Open account menu',
51
51
  })}
52
- 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'
52
+ className='flex size-10 cursor-pointer items-center justify-center rounded-full border border-border bg-background/60 text-left text-sm outline-none transition-colors hover:bg-muted focus-visible:ring-3 focus-visible:ring-ring/50'
53
53
  >
54
54
  {identity?.avatar ? (
55
55
  <img
@@ -1,57 +1,77 @@
1
- import { useSyncExternalStore, type SetStateAction } from 'react';
2
-
3
- // All applications and layout surfaces on this origin share this preference.
4
- const storageKey = 'nocobase:sidebar:collapsed';
5
- const changeEvent = 'nocobase:sidebar-preference-change';
6
- let memoryValue = false;
7
- let memoryOnly = false;
8
-
9
- function getSnapshot(): boolean {
10
- if (memoryOnly) return memoryValue;
11
- try {
12
- memoryValue = localStorage.getItem(storageKey) === 'true';
13
- } catch {
14
- // Storage may be unavailable; keep the current session's preference.
1
+ import {
2
+ createContext,
3
+ useContext,
4
+ useSyncExternalStore,
5
+ type SetStateAction,
6
+ } from 'react';
7
+
8
+ // Each application shares its preference across layouts and tabs, never across scopes.
9
+ export function createSidebarPreferenceStore(scope: string) {
10
+ const storageKey = `nocobase:sidebar:collapsed:${scope}`;
11
+ const changeEvent = `nocobase:sidebar-preference-change:${scope}`;
12
+ let memoryValue = false;
13
+ let memoryOnly = false;
14
+
15
+ function getSnapshot(): boolean {
16
+ if (memoryOnly) return memoryValue;
17
+ try {
18
+ memoryValue = localStorage.getItem(storageKey) === 'true';
19
+ } catch {
20
+ // Storage may be unavailable; keep this application's session preference.
21
+ }
22
+ return memoryValue;
15
23
  }
16
- return memoryValue;
17
- }
18
24
 
19
- function subscribe(notify: () => void): () => void {
20
- const onStorage = (event: StorageEvent) => {
21
- if (event.key === storageKey || event.key === null) notify();
22
- };
23
- window.addEventListener('storage', onStorage);
24
- window.addEventListener(changeEvent, notify);
25
- return () => {
26
- window.removeEventListener('storage', onStorage);
27
- window.removeEventListener(changeEvent, notify);
28
- };
29
- }
25
+ function subscribe(notify: () => void): () => void {
26
+ const onStorage = (event: StorageEvent) => {
27
+ if (event.key === storageKey || event.key === null) notify();
28
+ };
29
+ window.addEventListener('storage', onStorage);
30
+ window.addEventListener(changeEvent, notify);
31
+ return () => {
32
+ window.removeEventListener('storage', onStorage);
33
+ window.removeEventListener(changeEvent, notify);
34
+ };
35
+ }
30
36
 
31
- function setCollapsed(update: SetStateAction<boolean>): void {
32
- const current = getSnapshot();
33
- memoryValue = typeof update === 'function' ? update(current) : update;
34
- try {
35
- localStorage.setItem(storageKey, String(memoryValue));
36
- } catch {
37
- memoryOnly = true;
38
- // The toggle remains usable when storage is blocked.
37
+ function setCollapsed(update: SetStateAction<boolean>): void {
38
+ const current = getSnapshot();
39
+ memoryValue = typeof update === 'function' ? update(current) : update;
40
+ try {
41
+ localStorage.setItem(storageKey, String(memoryValue));
42
+ } catch {
43
+ memoryOnly = true;
44
+ // The toggle remains usable when storage is blocked.
45
+ }
46
+ window.dispatchEvent(new Event(changeEvent));
39
47
  }
40
- window.dispatchEvent(new Event(changeEvent));
48
+
49
+ return { getSnapshot, subscribe, setCollapsed };
41
50
  }
42
51
 
43
- function getServerSnapshot(): boolean {
44
- return false;
52
+ type SidebarPreferenceStore = ReturnType<typeof createSidebarPreferenceStore>;
53
+
54
+ export function createAppSidebarPreferenceStore(basename: string = '') {
55
+ const normalizedBasename = `/${basename.replace(/^\/+|\/+$/g, '')}`;
56
+ return createSidebarPreferenceStore(`app:${normalizedBasename}`);
45
57
  }
46
58
 
59
+ const SidebarPreferenceContext = createContext<SidebarPreferenceStore | null>(
60
+ null,
61
+ );
62
+
63
+ export const SidebarPreferenceProvider = SidebarPreferenceContext.Provider;
64
+
47
65
  export function useSidebarPreference(): readonly [
48
66
  boolean,
49
67
  (update: SetStateAction<boolean>) => void,
50
68
  ] {
69
+ const store = useContext(SidebarPreferenceContext);
70
+ if (!store) throw new Error('SidebarPreferenceProvider is required.');
51
71
  const collapsed = useSyncExternalStore(
52
- subscribe,
53
- getSnapshot,
54
- getServerSnapshot,
72
+ store.subscribe,
73
+ store.getSnapshot,
74
+ () => false,
55
75
  );
56
- return [collapsed, setCollapsed];
76
+ return [collapsed, store.setCollapsed];
57
77
  }
@@ -0,0 +1,8 @@
1
+ import type { RouteNavigationItem } from '../routing/route-navigation.js';
2
+
3
+ /** Application-owned navigation customization, applied after permission filtering. */
4
+ export function transformNavigation(
5
+ items: RouteNavigationItem[],
6
+ ): RouteNavigationItem[] {
7
+ return items;
8
+ }
@@ -0,0 +1,88 @@
1
+ // Every scrollbar in the application shows only while what it belongs to scrolls, or while the mouse is near the
2
+ // edge it runs along, so it can be found and grabbed (client/styles.css draws a bar under `[data-scrolling]` alone).
3
+ // One listener on the document, in the capture phase since a scroll does not bubble, marks the element that scrolls,
4
+ // or the page's root for the page itself, until it has been still for a moment; a second marks the scroller the mouse
5
+ // is within EDGE_PX of the right or bottom edge of, at most once a frame. Installed once, as the client starts
6
+ // (client/index.tsx).
7
+ const STILL_MS = 800;
8
+ const EDGE_PX = 16;
9
+ const timers = new WeakMap<Element, number>();
10
+
11
+ function reveal(element: HTMLElement): void {
12
+ element.dataset.scrolling = '';
13
+ window.clearTimeout(timers.get(element));
14
+ timers.set(
15
+ element,
16
+ window.setTimeout(() => {
17
+ delete element.dataset.scrolling;
18
+ }, STILL_MS),
19
+ );
20
+ }
21
+
22
+ function scrolls(element: Element, axis: 'x' | 'y'): boolean {
23
+ const overflow =
24
+ getComputedStyle(element)[axis === 'y' ? 'overflowY' : 'overflowX'];
25
+ if (overflow !== 'auto' && overflow !== 'scroll') return false;
26
+ return axis === 'y'
27
+ ? element.scrollHeight > element.clientHeight
28
+ : element.scrollWidth > element.clientWidth;
29
+ }
30
+
31
+ /** The scroller whose right or bottom edge the point is near: the nearest such ancestor of the target, or the page. */
32
+ function scrollerAtEdge(
33
+ target: Element | null,
34
+ x: number,
35
+ y: number,
36
+ ): HTMLElement | undefined {
37
+ for (
38
+ let element = target;
39
+ element && element !== document.body;
40
+ element = element.parentElement
41
+ ) {
42
+ if (element === document.documentElement) break;
43
+ const box = element.getBoundingClientRect();
44
+ if (scrolls(element, 'y') && x >= box.right - EDGE_PX && x <= box.right)
45
+ return element as HTMLElement;
46
+ if (scrolls(element, 'x') && y >= box.bottom - EDGE_PX && y <= box.bottom)
47
+ return element as HTMLElement;
48
+ }
49
+ const page = (document.scrollingElement ??
50
+ document.documentElement) as HTMLElement;
51
+ if (page.scrollHeight > page.clientHeight && x >= window.innerWidth - EDGE_PX)
52
+ return page;
53
+ if (page.scrollWidth > page.clientWidth && y >= window.innerHeight - EDGE_PX)
54
+ return page;
55
+ return undefined;
56
+ }
57
+
58
+ export function revealScrollbarsWhileScrolling(): void {
59
+ document.addEventListener(
60
+ 'scroll',
61
+ (event) => {
62
+ const element =
63
+ event.target instanceof Element
64
+ ? event.target
65
+ : (document.scrollingElement ?? document.documentElement);
66
+ if (element instanceof HTMLElement) reveal(element);
67
+ },
68
+ { capture: true, passive: true },
69
+ );
70
+ let frame = 0;
71
+ document.addEventListener(
72
+ 'pointermove',
73
+ (event) => {
74
+ if (event.pointerType !== 'mouse' || frame) return;
75
+ const { clientX, clientY, target } = event;
76
+ frame = window.requestAnimationFrame(() => {
77
+ frame = 0;
78
+ const element = scrollerAtEdge(
79
+ target instanceof Element ? target : null,
80
+ clientX,
81
+ clientY,
82
+ );
83
+ if (element) reveal(element);
84
+ });
85
+ },
86
+ { passive: true },
87
+ );
88
+ }
@@ -1,3 +1,4 @@
1
+ import settings from './settings-en-US.js';
1
2
  import type { LocaleResource } from '@nocobase/i18n';
2
3
  import deviceApprovalEnUS from '#extensions/nocobase-device-approval/locales/en-US';
3
4
  import inboxEnUS from '#extensions/nocobase-inbox/locales/en-US';
@@ -7,6 +8,16 @@ import inboxEnUS from '#extensions/nocobase-inbox/locales/en-US';
7
8
  * upgrade replaces this file, so put the application's own copy in `../en-US.ts` rather than here.
8
9
  */
9
10
  const systemEnUS = {
11
+ settings,
12
+ 'auth.preparingSession': 'Preparing your session…',
13
+ 'auth.connectionFailed': 'Unable to connect',
14
+ 'auth.connectionFailedDescription':
15
+ 'The realtime connection could not be prepared. Retry to enter the application, or sign out.',
16
+
17
+ 'auth.passwordResetSuccess':
18
+ 'Your password has been reset. Sign in with your new password.',
19
+ 'auth.requestNewResetLink': 'Request a new reset link',
20
+
10
21
  // The UI Library block of the `/device` page; the keys below may reword it.
11
22
  ...deviceApprovalEnUS,
12
23
  // The UI Library block of the `/inbox` page; the keys below may reword it.