@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.
- package/.cursor/mcp.json +8 -0
- package/.mcp.json +8 -0
- package/.prettierignore +20 -0
- package/.vscode/mcp.json +8 -0
- package/AGENTS.md +432 -0
- package/CLAUDE.md +9 -0
- package/Dockerfile +132 -0
- package/Dockerfile.dockerignore +45 -0
- package/README.MD +481 -0
- package/cli/plugins.ts +14 -0
- package/client/agent-annotations-host.ts +19 -0
- package/client/app.ts +38 -0
- package/client/components/back-button.tsx +49 -0
- package/client/components/breadcrumbs.tsx +167 -0
- package/client/components/inbox-badge.ts +49 -0
- package/client/components/inbox-button.tsx +132 -0
- package/client/components/inbox-header-button.tsx +25 -0
- package/client/components/loading.tsx +35 -0
- package/client/components/page-container.tsx +24 -0
- package/client/components/page-header.tsx +33 -0
- package/client/components/route-child-page.tsx +121 -0
- package/client/components/route-dialog.tsx +10 -0
- package/client/components/route-drawer.tsx +10 -0
- package/client/components/route-overlay.tsx +190 -0
- package/client/components/ui/alert.tsx +75 -0
- package/client/components/ui/badge.tsx +51 -0
- package/client/components/ui/breadcrumb.tsx +121 -0
- package/client/components/ui/button.tsx +58 -0
- package/client/components/ui/collapsible.tsx +19 -0
- package/client/components/ui/context-menu.tsx +271 -0
- package/client/components/ui/dialog.tsx +160 -0
- package/client/components/ui/dropdown-menu.tsx +270 -0
- package/client/components/ui/empty.tsx +103 -0
- package/client/components/ui/field.tsx +236 -0
- package/client/components/ui/input-group.tsx +158 -0
- package/client/components/ui/input.tsx +20 -0
- package/client/components/ui/kbd.tsx +26 -0
- package/client/components/ui/label.tsx +18 -0
- package/client/components/ui/popover.tsx +89 -0
- package/client/components/ui/select.tsx +200 -0
- package/client/components/ui/separator.tsx +22 -0
- package/client/components/ui/sheet.tsx +137 -0
- package/client/components/ui/sidebar.tsx +723 -0
- package/client/components/ui/skeleton.tsx +13 -0
- package/client/components/ui/spinner.tsx +20 -0
- package/client/components/ui/tabs.tsx +79 -0
- package/client/components/ui/textarea.tsx +17 -0
- package/client/components/ui/toast.tsx +231 -0
- package/client/components/ui/tooltip.tsx +63 -0
- package/client/components/use-route-overlay.ts +26 -0
- package/client/config/api.ts +13 -0
- package/client/config/app.ts +8 -0
- package/client/config/auth.ts +18 -0
- package/client/config/index.ts +17 -0
- package/client/extensions/nocobase-auth-forms/field-props.ts +13 -0
- package/client/extensions/nocobase-auth-forms/form-parts.tsx +101 -0
- package/client/extensions/nocobase-auth-forms/password-input.tsx +41 -0
- package/client/extensions/nocobase-auth-forms/password-login-form.tsx +123 -0
- package/client/extensions/nocobase-auth-forms/password-registration-form.tsx +193 -0
- package/client/extensions/nocobase-auth-forms/password-reset-form.tsx +132 -0
- package/client/extensions/nocobase-auth-forms/password-reset-request-form.tsx +99 -0
- package/client/extensions/nocobase-auth-methods/auth-methods.tsx +170 -0
- package/client/extensions/nocobase-auth-split-layout/auth-split-layout.tsx +126 -0
- package/client/extensions/nocobase-device-approval/device-approval.tsx +277 -0
- package/client/extensions/nocobase-device-approval/locales/en-US.ts +55 -0
- package/client/extensions/nocobase-device-approval/locales/zh-CN.ts +27 -0
- package/client/extensions/nocobase-device-approval/use-device-approval.ts +176 -0
- package/client/extensions/nocobase-file-component-ui/README.md +13 -0
- package/client/extensions/nocobase-file-component-ui/components/file-list.tsx +147 -0
- package/client/extensions/nocobase-file-component-ui/components/file-preview-dialog.tsx +263 -0
- package/client/extensions/nocobase-file-component-ui/components/file-preview-field.tsx +68 -0
- package/client/extensions/nocobase-file-component-ui/components/file-thumbnail.tsx +73 -0
- package/client/extensions/nocobase-file-component-ui/components/file-upload-field.tsx +386 -0
- package/client/extensions/nocobase-file-component-ui/components/previewers/file-preview-content.tsx +255 -0
- package/client/extensions/nocobase-file-component-ui/components/previewers/office-open-xml-preview.tsx +226 -0
- package/client/extensions/nocobase-file-component-ui/index.ts +8 -0
- package/client/extensions/nocobase-file-component-ui/lib/file-preview.ts +194 -0
- package/client/extensions/nocobase-file-component-ui/lib/file-url.ts +22 -0
- package/client/extensions/nocobase-file-component-ui/lib/message-key.ts +18 -0
- package/client/extensions/nocobase-file-component-ui/types.ts +69 -0
- package/client/extensions/nocobase-inbox/decision-actions-bar.tsx +140 -0
- package/client/extensions/nocobase-inbox/frame.ts +31 -0
- package/client/extensions/nocobase-inbox/inbox-actions.ts +21 -0
- package/client/extensions/nocobase-inbox/inbox-detail.tsx +386 -0
- package/client/extensions/nocobase-inbox/inbox-item.tsx +274 -0
- package/client/extensions/nocobase-inbox/inbox-list.tsx +433 -0
- package/client/extensions/nocobase-inbox/inbox-page.tsx +545 -0
- package/client/extensions/nocobase-inbox/locales/en-US.ts +111 -0
- package/client/extensions/nocobase-inbox/locales/zh-CN.ts +57 -0
- package/client/extensions/nocobase-inbox/model.ts +428 -0
- package/client/extensions/nocobase-inbox/registry-scope.tsx +49 -0
- package/client/extensions/nocobase-inbox/registry.ts +303 -0
- package/client/extensions/nocobase-inbox/source.ts +117 -0
- package/client/hooks/use-mobile.ts +21 -0
- package/client/index.tsx +60 -0
- package/client/layouts/app-layout.tsx +105 -0
- package/client/layouts/components/app-brand.tsx +49 -0
- package/client/layouts/components/app-sidebar.tsx +196 -0
- package/client/layouts/components/header-actions.tsx +50 -0
- package/client/layouts/components/language-switcher.tsx +95 -0
- package/client/layouts/components/layout-header.tsx +17 -0
- package/client/layouts/components/navigation-menu.tsx +411 -0
- package/client/layouts/components/sidebar-footer.tsx +107 -0
- package/client/layouts/components/surface-empty.tsx +35 -0
- package/client/layouts/components/user-menu.tsx +127 -0
- package/client/layouts/index.ts +2 -0
- package/client/layouts/settings-layout.tsx +172 -0
- package/client/layouts/use-sidebar-preference.ts +57 -0
- package/client/lib/constants.ts +5 -0
- package/client/lib/toaster.ts +56 -0
- package/client/lib/utils.ts +1 -0
- package/client/locales/en-US.ts +178 -0
- package/client/locales/index.ts +9 -0
- package/client/locales/zh-CN.ts +161 -0
- package/client/pages/auth/device.tsx +41 -0
- package/client/pages/auth/forgot-password.tsx +39 -0
- package/client/pages/auth/login.tsx +66 -0
- package/client/pages/auth/register.tsx +46 -0
- package/client/pages/auth/reset-password.tsx +38 -0
- package/client/pages/auth/shared.tsx +111 -0
- package/client/pages/home.tsx +16 -0
- package/client/pages/inbox.tsx +12 -0
- package/client/plugins.ts +41 -0
- package/client/react-providers.ts +27 -0
- package/client/route-overrides.ts +9 -0
- package/client/routes.ts +75 -0
- package/client/routing/app-router.tsx +127 -0
- package/client/routing/client-route.tsx +129 -0
- package/client/routing/route-context.tsx +85 -0
- package/client/routing/route-navigation.ts +153 -0
- package/client/routing/route-tree.tsx +42 -0
- package/client/routing/standalone-page-layout.tsx +15 -0
- package/client/runtime.ts +26 -0
- package/client/service-provider.ts +40 -0
- package/client/source-extensions.ts +19 -0
- package/client/startup.tsx +22 -0
- package/client/styles.css +122 -0
- package/client/theme/index.ts +4 -0
- package/client/theme/theme-context.ts +12 -0
- package/client/theme/theme-preferences.ts +84 -0
- package/client/theme/theme-presets.ts +7 -0
- package/client/theme/theme-provider.tsx +106 -0
- package/client/theme/theme-settings.tsx +176 -0
- package/client/theme/themes/compact.css +140 -0
- package/client/theme/themes/default.css +117 -0
- package/client/vite-env.d.ts +5 -0
- package/components.json +27 -0
- package/config.example.yml +172 -0
- package/database/main/migrations/.gitkeep +0 -0
- package/database/main/migrations/202610060001_create_device_code.ts +37 -0
- package/database/main/seeds/.gitkeep +0 -0
- package/database/tsconfig.json +4 -0
- package/ecosystem.config.js +35 -0
- package/eslint.config.js +24 -0
- package/index.html +100 -0
- package/package.json +200 -3
- package/playwright.config.ts +10 -0
- package/public/assets/favicon.ico +0 -0
- package/public/assets/logo-dark.png +0 -0
- package/public/assets/logo-mark-dark.png +0 -0
- package/public/assets/logo-mark.png +0 -0
- package/public/assets/logo.png +0 -0
- package/server/app.ts +50 -0
- package/server/config/ai.ts +27 -0
- package/server/config/api.ts +8 -0
- package/server/config/app.ts +40 -0
- package/server/config/auth.ts +36 -0
- package/server/config/authorization.ts +18 -0
- package/server/config/caching.ts +21 -0
- package/server/config/database.ts +22 -0
- package/server/config/drive.ts +30 -0
- package/server/config/i18n.ts +17 -0
- package/server/config/index.ts +73 -0
- package/server/config/jobs.ts +27 -0
- package/server/config/logging.ts +23 -0
- package/server/config/notification.ts +11 -0
- package/server/config/queue.ts +28 -0
- package/server/config/scheduler.ts +23 -0
- package/server/config/secrets.ts +10 -0
- package/server/config/server.ts +26 -0
- package/server/config/session.ts +60 -0
- package/server/config/snowflake.ts +21 -0
- package/server/config/spa.ts +24 -0
- package/server/config/users.ts +11 -0
- package/server/config/workflow.ts +24 -0
- package/server/config.ts +24 -0
- package/server/embedded.ts +23 -0
- package/server/locales/en-US.ts +10 -0
- package/server/locales/index.ts +10 -0
- package/server/locales/zh-CN.ts +5 -0
- package/server/plugins.ts +41 -0
- package/server/providers/index.ts +5 -0
- package/server/routes/index.ts +6 -0
- package/server/runtime.ts +24 -0
- package/server/standalone.ts +29 -0
- package/server/tsconfig.json +4 -0
- package/tailwind.config.mjs +59 -0
- package/tests/components/app-layout-routes.test.tsx +132 -0
- package/tests/components/app-title.test.tsx +57 -0
- package/tests/components/auth-i18n.test.tsx +87 -0
- package/tests/components/back-button.test.tsx +80 -0
- package/tests/components/file-preview.test.tsx +101 -0
- package/tests/components/header-hover.test.tsx +87 -0
- package/tests/components/inbox.test.tsx +139 -0
- package/tests/components/language-switcher.test.tsx +298 -0
- package/tests/components/layout-containers.test.tsx +15 -0
- package/tests/components/layout-i18n.test.tsx +187 -0
- package/tests/components/loading.test.tsx +25 -0
- package/tests/components/navigation-menu.test.tsx +355 -0
- package/tests/components/page-harness.test.tsx +318 -0
- package/tests/components/primitive-labels.test.tsx +80 -0
- package/tests/components/sidebar-permissions.test.tsx +110 -0
- package/tests/components/sidebar-preference.test.tsx +42 -0
- package/tests/fixtures/theme-tokens.html +12 -0
- package/tests/fixtures/theme-tokens.tsx +111 -0
- package/tests/logic/account-permissions.test.tsx +204 -0
- package/tests/logic/app-locale-coverage.test.ts +136 -0
- package/tests/logic/app-server.test.ts +1589 -0
- package/tests/logic/asset-url-build.test.ts +106 -0
- package/tests/logic/client-auth.test.tsx +122 -0
- package/tests/logic/client-plugin-registry.test.ts +63 -0
- package/tests/logic/client-route-recovery.test.tsx +80 -0
- package/tests/logic/client-routes.test.ts +126 -0
- package/tests/logic/client-runtime.test.ts +112 -0
- package/tests/logic/client-settings.test.tsx +841 -0
- package/tests/logic/client-shell.test.tsx +466 -0
- package/tests/logic/client-theme.test.tsx +441 -0
- package/tests/logic/config.test.ts +197 -0
- package/tests/logic/dev-client-watch.test.ts +108 -0
- package/tests/logic/dev-routes-production-build.test.ts +168 -0
- package/tests/logic/device-code-migration.test.ts +70 -0
- package/tests/logic/lifecycle.test.ts +221 -0
- package/tests/logic/plugin-commands.test.ts +69 -0
- package/tests/logic/plugin-page-authz.test.ts +76 -0
- package/tests/logic/route-navigation.test.ts +66 -0
- package/tests/logic/route-overlay.test.tsx +353 -0
- package/tests/logic/server-imports.test.ts +88 -0
- package/tests/logic/tailwind-sources.test.ts +151 -0
- package/tests/logic/template-package-name.test.ts +88 -0
- package/tests/logic/theme-preferences.test.ts +86 -0
- package/tests/logic/theme-tokens.test.ts +248 -0
- package/tests/logic/toaster.test.tsx +108 -0
- package/tests/logic/user-menu-sign-out.test.tsx +98 -0
- package/tests/setup/client-config.ts +27 -0
- package/tsconfig.json +11 -0
- package/tsconfig.node.json +12 -0
- package/tsconfig.server.json +19 -0
- package/vite.config.ts +92 -0
- package/vitest.config.ts +37 -0
- 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
|
+
}
|