@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.
- package/.agents/rules/components.md +2 -2
- package/.agents/rules/shell.md +5 -1
- package/AGENTS.md +5 -3
- package/Dockerfile.dockerignore +0 -1
- package/README.MD +3 -1
- package/client/app.ts +15 -0
- package/client/components/data-table/column-header.tsx +91 -0
- package/client/components/data-table/data-table.tsx +216 -0
- package/client/components/data-table/index.ts +13 -0
- package/client/components/data-table/pagination.tsx +165 -0
- package/client/components/data-table/view-options.tsx +77 -0
- package/client/components/page-header.tsx +1 -1
- package/client/components/permission-editor.tsx +589 -0
- package/client/components/permission-filter.tsx +455 -0
- package/client/components/route-overlay.tsx +26 -13
- package/client/components/status-badge.tsx +7 -1
- package/client/components/ui/checkbox.tsx +25 -0
- package/client/components/ui/sidebar.tsx +1 -4
- package/client/components/ui/switch.tsx +30 -0
- package/client/components/ui/table.tsx +113 -0
- package/client/extensions/nocobase-inbox/inbox-item.tsx +44 -58
- package/client/extensions/nocobase-inbox/inbox-list.tsx +73 -75
- package/client/index.tsx +4 -0
- package/client/layouts/components/app-sidebar.tsx +6 -6
- package/client/layouts/components/layout-header.tsx +1 -1
- package/client/layouts/components/navigation-menu.tsx +223 -12
- package/client/layouts/components/sidebar-footer.tsx +1 -1
- package/client/layouts/components/user-menu.tsx +1 -1
- package/client/layouts/use-sidebar-preference.ts +62 -42
- package/client/lib/navigation-transform.ts +8 -0
- package/client/lib/scrollbar.ts +88 -0
- package/client/locales/system/en-US.ts +11 -0
- package/client/locales/system/settings-en-US.ts +141 -0
- package/client/locales/system/settings-zh-CN.ts +135 -0
- package/client/locales/system/zh-CN.ts +22 -12
- package/client/pages/auth/auth-page.css +52 -0
- package/client/pages/auth/login.tsx +1 -1
- package/client/pages/auth/reset-password.tsx +57 -14
- package/client/pages/auth/shared.tsx +26 -24
- package/client/pages/home/index.tsx +2 -2
- package/client/pages/settings/hooks.tsx +137 -0
- package/client/pages/settings/permission-filters.tsx +119 -0
- package/client/pages/settings/permissions-model.ts +260 -0
- package/client/pages/settings/role-form.tsx +426 -0
- package/client/pages/settings/roles.tsx +163 -0
- package/client/pages/settings/routes.ts +79 -0
- package/client/pages/settings/shared.tsx +119 -0
- package/client/pages/settings/user-detail.tsx +268 -0
- package/client/pages/settings/user-form.tsx +427 -0
- package/client/pages/settings/users.tsx +451 -0
- package/client/routing/app-router.tsx +4 -1
- package/client/routing/route-navigation.ts +10 -1
- package/client/routing/session-preparation.tsx +65 -0
- package/client/styles.css +257 -24
- package/client/theme/theme-settings.tsx +12 -7
- package/client/theme/themes/compact.css +9 -14
- package/client/theme/themes/default.css +7 -12
- package/package.json +26 -25
- package/server/app.ts +2 -0
- package/server/config/notification.ts +4 -7
- package/tests/components/app-layout-routes.test.tsx +14 -7
- package/tests/components/auth-i18n.test.tsx +57 -5
- package/tests/components/data-table.test.tsx +127 -0
- package/tests/components/layout-i18n.test.tsx +10 -3
- package/tests/components/navigation-menu.test.tsx +155 -16
- package/tests/components/permission-editor.test.tsx +477 -0
- package/tests/components/permission-filter-binding.test.tsx +150 -0
- package/tests/components/permission-filter.test.tsx +202 -0
- package/tests/components/settings.test.tsx +294 -0
- package/tests/components/sidebar-permissions.test.tsx +17 -19
- package/tests/components/sidebar-preference.test.tsx +209 -14
- package/tests/components/status-badge.test.tsx +22 -0
- package/tests/components/user-permissions.test.tsx +142 -0
- package/tests/fixtures/theme-tokens.tsx +9 -1
- package/tests/logic/asset-url-build.test.ts +0 -1
- package/tests/logic/client-routes.test.ts +40 -0
- package/tests/logic/client-shell.test.tsx +10 -2
- package/tests/logic/config-example.test.ts +30 -0
- package/tests/logic/dev-client-watch.test.ts +8 -1
- package/tests/logic/route-overlay.test.tsx +42 -0
- package/tests/logic/scrollbar.test.ts +88 -0
- package/tests/logic/session-preparation.test.tsx +223 -0
- package/tests/logic/settings-permissions.test.ts +168 -0
- package/tests/logic/theme-tokens.test.ts +1 -0
- package/vite.config.ts +0 -22
- package/vitest.config.ts +1 -0
- 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 {
|
|
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
|
|
42
|
-
*
|
|
43
|
-
*
|
|
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
|
-
<
|
|
56
|
-
<
|
|
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
|
-
{
|
|
60
|
-
<
|
|
61
|
-
key={routeKey(
|
|
62
|
-
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
|
-
</
|
|
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
|
|
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
|
|
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
|
|
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 {
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
function
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
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
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
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
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
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
|
-
|
|
48
|
+
|
|
49
|
+
return { getSnapshot, subscribe, setCollapsed };
|
|
41
50
|
}
|
|
42
51
|
|
|
43
|
-
|
|
44
|
-
|
|
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
|
-
|
|
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.
|