@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,129 @@
|
|
|
1
|
+
import { useTranslation } from '@nocobase/i18n/client';
|
|
2
|
+
import type {
|
|
3
|
+
AppClientRegisteredRoute,
|
|
4
|
+
AppClientRouteComponentModule,
|
|
5
|
+
} from '@nocobase/app-client/plugins';
|
|
6
|
+
import { useCan } from '@nocobase/app-plugin-authorization/client';
|
|
7
|
+
import { NamespaceScope } from '@nocobase/i18n/client';
|
|
8
|
+
import { type ReactElement, useEffect, useState } from 'react';
|
|
9
|
+
import { ErrorBoundary } from 'react-error-boundary';
|
|
10
|
+
|
|
11
|
+
import { Loading } from '#components/loading';
|
|
12
|
+
import { Button } from '#components/ui/button';
|
|
13
|
+
|
|
14
|
+
export interface ClientRouteProps {
|
|
15
|
+
readonly route: AppClientRegisteredRoute;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export function ClientRoute({ route }: ClientRouteProps): ReactElement {
|
|
19
|
+
const { authz, componentLoader } = route;
|
|
20
|
+
const checkAccess = authz !== 'skip';
|
|
21
|
+
const { can: allowed, isPending: accessLoading } = useCan(
|
|
22
|
+
authz === 'skip' ? undefined : authz,
|
|
23
|
+
);
|
|
24
|
+
const [componentModule, setComponentModule] =
|
|
25
|
+
useState<AppClientRouteComponentModule>();
|
|
26
|
+
const [loadError, setLoadError] = useState<unknown>();
|
|
27
|
+
|
|
28
|
+
useEffect(() => {
|
|
29
|
+
if (checkAccess && !allowed) return;
|
|
30
|
+
let active = true;
|
|
31
|
+
|
|
32
|
+
componentLoader!().then(
|
|
33
|
+
(module) => {
|
|
34
|
+
if (active) {
|
|
35
|
+
setComponentModule(module);
|
|
36
|
+
setLoadError(undefined);
|
|
37
|
+
}
|
|
38
|
+
},
|
|
39
|
+
(error: unknown) => {
|
|
40
|
+
if (active) {
|
|
41
|
+
setLoadError(error);
|
|
42
|
+
}
|
|
43
|
+
},
|
|
44
|
+
);
|
|
45
|
+
|
|
46
|
+
return () => {
|
|
47
|
+
active = false;
|
|
48
|
+
};
|
|
49
|
+
}, [allowed, checkAccess, componentLoader]);
|
|
50
|
+
|
|
51
|
+
if (loadError) {
|
|
52
|
+
return <ClientPageError route={route} />;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
if (checkAccess && accessLoading) return <ClientPageLoading />;
|
|
56
|
+
if (checkAccess && !allowed) return <ClientPageDenied route={route} />;
|
|
57
|
+
|
|
58
|
+
if (!componentModule) {
|
|
59
|
+
return <ClientPageLoading />;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
const Component = componentModule.default;
|
|
63
|
+
|
|
64
|
+
return (
|
|
65
|
+
<ErrorBoundary fallback={<ClientPageError route={route} />}>
|
|
66
|
+
{/* The owning package is the page's namespace, so a plugin page translates with a bare useTranslation(). */}
|
|
67
|
+
<NamespaceScope ns={route.packageName}>
|
|
68
|
+
<Component />
|
|
69
|
+
</NamespaceScope>
|
|
70
|
+
</ErrorBoundary>
|
|
71
|
+
);
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function ClientPageDenied(inputProps: ClientRouteProps): ReactElement {
|
|
75
|
+
const { t } = useTranslation();
|
|
76
|
+
const { route } = inputProps;
|
|
77
|
+
|
|
78
|
+
return (
|
|
79
|
+
<section className='grid min-h-[calc(100svh-4rem)] place-items-center px-6'>
|
|
80
|
+
<section className='w-full max-w-lg space-y-3 text-center'>
|
|
81
|
+
<h1 className='text-xl font-semibold'>
|
|
82
|
+
{t('status.denied', { defaultValue: 'Access denied' })}
|
|
83
|
+
</h1>
|
|
84
|
+
<p className='text-sm text-muted-foreground'>
|
|
85
|
+
{t('status.deniedDescription', {
|
|
86
|
+
label: route.name,
|
|
87
|
+
defaultValue: `You do not have permission to access ${route.name}.`,
|
|
88
|
+
})}
|
|
89
|
+
</p>
|
|
90
|
+
</section>
|
|
91
|
+
</section>
|
|
92
|
+
);
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
function ClientPageLoading(): ReactElement {
|
|
96
|
+
const { t } = useTranslation();
|
|
97
|
+
|
|
98
|
+
return (
|
|
99
|
+
<Loading
|
|
100
|
+
className='min-h-[calc(100svh-4rem)]'
|
|
101
|
+
label={t('status.loadingPage', { defaultValue: 'Loading page' })}
|
|
102
|
+
/>
|
|
103
|
+
);
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
function ClientPageError(inputProps: ClientRouteProps): ReactElement {
|
|
107
|
+
const { t } = useTranslation();
|
|
108
|
+
const { route } = inputProps;
|
|
109
|
+
|
|
110
|
+
return (
|
|
111
|
+
<section className='grid min-h-[calc(100svh-4rem)] place-items-center px-6'>
|
|
112
|
+
<section className='w-full max-w-lg space-y-4 text-center'>
|
|
113
|
+
<h1 className='text-xl font-semibold'>
|
|
114
|
+
{t('status.pageFailed', { defaultValue: 'Unable to load page' })}
|
|
115
|
+
</h1>
|
|
116
|
+
<p className='text-sm text-muted-foreground'>
|
|
117
|
+
{t('status.routeFailedDescription', {
|
|
118
|
+
label: route.name,
|
|
119
|
+
packageName: route.packageName,
|
|
120
|
+
defaultValue: `Route ${route.name} from ${route.packageName} could not be loaded.`,
|
|
121
|
+
})}
|
|
122
|
+
</p>
|
|
123
|
+
<Button onClick={() => window.location.reload()}>
|
|
124
|
+
{t('status.retry', { defaultValue: 'Retry' })}
|
|
125
|
+
</Button>
|
|
126
|
+
</section>
|
|
127
|
+
</section>
|
|
128
|
+
);
|
|
129
|
+
}
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
import type { AppClientRegisteredRoute } from '@nocobase/app-client/plugins';
|
|
2
|
+
import { useLocation } from 'react-router';
|
|
3
|
+
import {
|
|
4
|
+
createContext,
|
|
5
|
+
useContext,
|
|
6
|
+
useMemo,
|
|
7
|
+
type PropsWithChildren,
|
|
8
|
+
type ReactElement,
|
|
9
|
+
} from 'react';
|
|
10
|
+
|
|
11
|
+
import { EMPTY_ARRAY } from '#lib/constants';
|
|
12
|
+
|
|
13
|
+
import { matchRouteTree } from './route-navigation.js';
|
|
14
|
+
|
|
15
|
+
/* eslint-disable react-refresh/only-export-components -- provider and hooks are intentionally colocated */
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* One level of the trail leading to the current page.
|
|
19
|
+
*
|
|
20
|
+
* `pathname` is the resolved URL rather than the registered pattern, so a level such as `/orders/:id` links to
|
|
21
|
+
* where the user actually is. The route names itself: a level with no `breadcrumb` is structure rather than a
|
|
22
|
+
* destination — a tab, an overlay, or a layer that exists only to share a layout — and consumers skip it.
|
|
23
|
+
*/
|
|
24
|
+
export interface RouteTrailEntry {
|
|
25
|
+
readonly route: AppClientRegisteredRoute;
|
|
26
|
+
readonly pathname: string;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
const RouteTreeContext = createContext<readonly AppClientRegisteredRoute[]>([]);
|
|
30
|
+
export interface RouteTreeProviderProps extends PropsWithChildren {
|
|
31
|
+
readonly routes: readonly AppClientRegisteredRoute[];
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Publishes the routes a page may be reached through, so anything below can work out where it is.
|
|
36
|
+
*
|
|
37
|
+
* It carries the tree rather than a trail derived from it: deriving is `useRouteTrail`'s job, which keeps the
|
|
38
|
+
* derivation and the memo that guards it in one place. `routes` still has to keep its identity between renders,
|
|
39
|
+
* since a new array re-renders every consumer; callers pass a memoised one.
|
|
40
|
+
*/
|
|
41
|
+
export function RouteTreeProvider({
|
|
42
|
+
children,
|
|
43
|
+
routes,
|
|
44
|
+
}: RouteTreeProviderProps): ReactElement {
|
|
45
|
+
return (
|
|
46
|
+
<RouteTreeContext.Provider value={routes}>
|
|
47
|
+
{children}
|
|
48
|
+
</RouteTreeContext.Provider>
|
|
49
|
+
);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* The trail of route levels leading to the current location.
|
|
54
|
+
*
|
|
55
|
+
* Every level the location matches is returned, structure included; whether a level is named, and what it is
|
|
56
|
+
* called, is its route's own `breadcrumb`, which a consumer reads. A breadcrumb title states what kind of page a
|
|
57
|
+
* level is rather than which record it is showing, so it is known before the page loads anything and the trail
|
|
58
|
+
* never changes while the user waits.
|
|
59
|
+
*/
|
|
60
|
+
export function useRouteTrail(): readonly RouteTrailEntry[] {
|
|
61
|
+
const routes = useContext(RouteTreeContext);
|
|
62
|
+
const { pathname } = useLocation();
|
|
63
|
+
|
|
64
|
+
return useMemo(
|
|
65
|
+
() =>
|
|
66
|
+
matchRouteTree(routes, pathname)?.map(
|
|
67
|
+
({ route, pathname: resolvedPathname }) => ({
|
|
68
|
+
route,
|
|
69
|
+
// matchRoutes decodes path segments (but keeps encoded slashes). Use only
|
|
70
|
+
// its matched depth, taking the actual segments from the original URL.
|
|
71
|
+
pathname:
|
|
72
|
+
pathname
|
|
73
|
+
.split('/')
|
|
74
|
+
.slice(
|
|
75
|
+
0,
|
|
76
|
+
resolvedPathname === '/'
|
|
77
|
+
? 1
|
|
78
|
+
: resolvedPathname.split('/').length,
|
|
79
|
+
)
|
|
80
|
+
.join('/') || '/',
|
|
81
|
+
}),
|
|
82
|
+
) ?? EMPTY_ARRAY,
|
|
83
|
+
[pathname, routes],
|
|
84
|
+
);
|
|
85
|
+
}
|
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
import type { AppClientRegisteredRoute } from '@nocobase/app-client/plugins';
|
|
2
|
+
import {
|
|
3
|
+
useAuthorizationRevision,
|
|
4
|
+
useAuthorizationClient,
|
|
5
|
+
type AuthorizationRequirement,
|
|
6
|
+
} from '@nocobase/app-plugin-authorization/client';
|
|
7
|
+
import { useEffect, useMemo, useState } from 'react';
|
|
8
|
+
import { matchPath, matchRoutes, type RouteObject } from 'react-router';
|
|
9
|
+
|
|
10
|
+
import { EMPTY_ARRAY } from '#lib/constants';
|
|
11
|
+
|
|
12
|
+
export interface RouteNavigationItem {
|
|
13
|
+
readonly route: AppClientRegisteredRoute;
|
|
14
|
+
readonly children: readonly RouteNavigationItem[];
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export function buildRouteNavigation(
|
|
18
|
+
routes: readonly AppClientRegisteredRoute[],
|
|
19
|
+
denied: ReadonlySet<string>,
|
|
20
|
+
): RouteNavigationItem[] {
|
|
21
|
+
return routes.flatMap((route) => {
|
|
22
|
+
if (denied.has(routeKey(route))) return [];
|
|
23
|
+
const children = buildRouteNavigation(
|
|
24
|
+
route.children ?? EMPTY_ARRAY,
|
|
25
|
+
denied,
|
|
26
|
+
);
|
|
27
|
+
return route.navigation && (route.componentLoader || children.length)
|
|
28
|
+
? [{ route, children }]
|
|
29
|
+
: children;
|
|
30
|
+
});
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export function selectedNavigationId(
|
|
34
|
+
routes: readonly AppClientRegisteredRoute[],
|
|
35
|
+
pathname: string,
|
|
36
|
+
denied: ReadonlySet<string> = new Set(),
|
|
37
|
+
): string | undefined {
|
|
38
|
+
const matches = matchRouteTree(routes, pathname);
|
|
39
|
+
const pages = navigationPages(buildRouteNavigation(routes, denied));
|
|
40
|
+
const visible = new Set(pages.map(routeKey));
|
|
41
|
+
const selected =
|
|
42
|
+
matches
|
|
43
|
+
?.map(({ route }) => route)
|
|
44
|
+
.reverse()
|
|
45
|
+
.find(
|
|
46
|
+
(route) =>
|
|
47
|
+
route.componentLoader &&
|
|
48
|
+
route.navigation &&
|
|
49
|
+
visible.has(routeKey(route)),
|
|
50
|
+
) ??
|
|
51
|
+
(matches
|
|
52
|
+
? undefined
|
|
53
|
+
: pages
|
|
54
|
+
.filter((route) =>
|
|
55
|
+
matchPath({ path: route.path, end: false }, pathname),
|
|
56
|
+
)
|
|
57
|
+
.sort((a, b) => b.path.length - a.path.length)[0]);
|
|
58
|
+
return selected ? routeKey(selected) : undefined;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export function navigationPages(
|
|
62
|
+
items: readonly RouteNavigationItem[],
|
|
63
|
+
): AppClientRegisteredRoute[] {
|
|
64
|
+
return items.flatMap(({ route, children }) => [
|
|
65
|
+
...(route.componentLoader ? [route] : []),
|
|
66
|
+
...navigationPages(children),
|
|
67
|
+
]);
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
export function useRouteNavigation(
|
|
71
|
+
routes: readonly AppClientRegisteredRoute[],
|
|
72
|
+
) {
|
|
73
|
+
const client = useAuthorizationClient();
|
|
74
|
+
// Recheck mounted menus after session or realtime permission invalidation.
|
|
75
|
+
const revision = useAuthorizationRevision();
|
|
76
|
+
const guards = useMemo(() => {
|
|
77
|
+
const collect = (
|
|
78
|
+
nodes: readonly AppClientRegisteredRoute[],
|
|
79
|
+
): { id: string; check: AuthorizationRequirement }[] =>
|
|
80
|
+
// `unrestricted` pages are checked like any other: hidden unless the snapshot is unrestricted.
|
|
81
|
+
nodes.flatMap((route) => [
|
|
82
|
+
...(route.componentLoader && route.authz !== 'skip'
|
|
83
|
+
? [{ id: routeKey(route), check: route.authz }]
|
|
84
|
+
: []),
|
|
85
|
+
...collect(route.children ?? EMPTY_ARRAY),
|
|
86
|
+
]);
|
|
87
|
+
return collect(routes);
|
|
88
|
+
}, [routes]);
|
|
89
|
+
const [result, setResult] = useState<{
|
|
90
|
+
client: typeof client;
|
|
91
|
+
guards: typeof guards;
|
|
92
|
+
revision: number;
|
|
93
|
+
denied: ReadonlySet<string>;
|
|
94
|
+
}>();
|
|
95
|
+
useEffect(() => {
|
|
96
|
+
if (!guards.length) return;
|
|
97
|
+
let active = true;
|
|
98
|
+
void Promise.all(
|
|
99
|
+
guards.map(async ({ id, check }) => {
|
|
100
|
+
try {
|
|
101
|
+
return (await client.can(check)) ? undefined : id;
|
|
102
|
+
} catch {
|
|
103
|
+
return id;
|
|
104
|
+
}
|
|
105
|
+
}),
|
|
106
|
+
).then((ids) => {
|
|
107
|
+
if (active)
|
|
108
|
+
setResult({
|
|
109
|
+
client,
|
|
110
|
+
guards,
|
|
111
|
+
revision,
|
|
112
|
+
denied: new Set(ids.filter((id) => id !== undefined)),
|
|
113
|
+
});
|
|
114
|
+
});
|
|
115
|
+
return () => {
|
|
116
|
+
active = false;
|
|
117
|
+
};
|
|
118
|
+
}, [client, guards, revision]);
|
|
119
|
+
const loading = Boolean(
|
|
120
|
+
guards.length &&
|
|
121
|
+
(result?.client !== client ||
|
|
122
|
+
result.guards !== guards ||
|
|
123
|
+
result.revision !== revision),
|
|
124
|
+
);
|
|
125
|
+
const denied = guards.length
|
|
126
|
+
? (result?.denied ?? new Set<string>())
|
|
127
|
+
: new Set<string>();
|
|
128
|
+
return {
|
|
129
|
+
loading,
|
|
130
|
+
items: loading ? [] : buildRouteNavigation(routes, denied),
|
|
131
|
+
denied,
|
|
132
|
+
};
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
export function matchRouteTree(
|
|
136
|
+
routes: readonly AppClientRegisteredRoute[],
|
|
137
|
+
pathname: string,
|
|
138
|
+
) {
|
|
139
|
+
const toMatch = (nodes: readonly AppClientRegisteredRoute[]): RouteObject[] =>
|
|
140
|
+
nodes.map((route) => ({
|
|
141
|
+
path: route.path,
|
|
142
|
+
handle: route,
|
|
143
|
+
children: toMatch(route.children ?? EMPTY_ARRAY),
|
|
144
|
+
}));
|
|
145
|
+
return matchRoutes(toMatch(routes), pathname)?.map((match) => ({
|
|
146
|
+
...match,
|
|
147
|
+
route: match.route.handle as AppClientRegisteredRoute,
|
|
148
|
+
}));
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
export function routeKey(route: AppClientRegisteredRoute): string {
|
|
152
|
+
return `${route.packageName}:${route.path}:${route.id}`;
|
|
153
|
+
}
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import type { AppClientRegisteredRoute } from '@nocobase/app-client/plugins';
|
|
2
|
+
import type { ReactElement } from 'react';
|
|
3
|
+
import { Outlet, Route } from 'react-router';
|
|
4
|
+
|
|
5
|
+
import { EMPTY_ARRAY } from '#lib/constants';
|
|
6
|
+
|
|
7
|
+
import { routeKey } from './route-navigation.js';
|
|
8
|
+
import { ClientRoute } from './client-route.js';
|
|
9
|
+
|
|
10
|
+
/** Groups pass children through; pages deliberately own their Outlet placement. */
|
|
11
|
+
export function renderRouteTree(
|
|
12
|
+
routes: readonly AppClientRegisteredRoute[],
|
|
13
|
+
parentPath = '',
|
|
14
|
+
): ReactElement[] {
|
|
15
|
+
return routes.map((route) => (
|
|
16
|
+
<Route
|
|
17
|
+
key={routeKey(route)}
|
|
18
|
+
path={
|
|
19
|
+
parentPath
|
|
20
|
+
? route.path === parentPath ||
|
|
21
|
+
parentPath.startsWith(`${route.path.replace(/\/$/, '')}/`)
|
|
22
|
+
? ''
|
|
23
|
+
: route.path.slice(parentPath === '/' ? 1 : parentPath.length + 1)
|
|
24
|
+
: route.path
|
|
25
|
+
}
|
|
26
|
+
element={
|
|
27
|
+
route.componentLoader ? (
|
|
28
|
+
<ClientRoute key={routeKey(route)} route={route} />
|
|
29
|
+
) : (
|
|
30
|
+
<Outlet />
|
|
31
|
+
)
|
|
32
|
+
}
|
|
33
|
+
>
|
|
34
|
+
{renderRouteTree(
|
|
35
|
+
route.children ?? EMPTY_ARRAY,
|
|
36
|
+
parentPath.startsWith(`${route.path.replace(/\/$/, '')}/`)
|
|
37
|
+
? parentPath
|
|
38
|
+
: route.path,
|
|
39
|
+
)}
|
|
40
|
+
</Route>
|
|
41
|
+
));
|
|
42
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import type { ReactElement } from 'react';
|
|
2
|
+
import { Outlet } from 'react-router';
|
|
3
|
+
|
|
4
|
+
import { ThemeSettings } from '../theme/index.js';
|
|
5
|
+
|
|
6
|
+
export function StandalonePageLayout(): ReactElement {
|
|
7
|
+
return (
|
|
8
|
+
<div className='relative min-h-svh'>
|
|
9
|
+
<div className='fixed top-4 right-4 z-50'>
|
|
10
|
+
<ThemeSettings />
|
|
11
|
+
</div>
|
|
12
|
+
<Outlet />
|
|
13
|
+
</div>
|
|
14
|
+
);
|
|
15
|
+
}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import defaultConfigs from './config/index.js';
|
|
2
|
+
import { createAppClientConfig } from '@nocobase/app-client';
|
|
3
|
+
import { defineAppRuntime } from '@nocobase/app-client/runtime';
|
|
4
|
+
|
|
5
|
+
import locales from './locales/index.js';
|
|
6
|
+
import plugins from './plugins.js';
|
|
7
|
+
import reactProviders from './react-providers.js';
|
|
8
|
+
import routeComponentOverrides from './route-overrides.js';
|
|
9
|
+
import routes from './routes.js';
|
|
10
|
+
import serviceProviders from './service-provider.js';
|
|
11
|
+
import sourceExtensions from './source-extensions.js';
|
|
12
|
+
|
|
13
|
+
const appRuntime = defineAppRuntime({
|
|
14
|
+
packageName: '@nocobase/app-template-default',
|
|
15
|
+
createAppConfig: createAppClientConfig,
|
|
16
|
+
defaultConfigs,
|
|
17
|
+
serviceProviders,
|
|
18
|
+
locales,
|
|
19
|
+
reactProviders,
|
|
20
|
+
routes,
|
|
21
|
+
plugins,
|
|
22
|
+
routeComponentOverrides,
|
|
23
|
+
sourceExtensions,
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
export default appRuntime;
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import { ClientApplication, toasterToken } from '@nocobase/app-client';
|
|
2
|
+
import type { ClientServiceProviderConstructor } from '@nocobase/app-client/plugins';
|
|
3
|
+
import { ServiceProvider } from '@nocobase/service-provider';
|
|
4
|
+
|
|
5
|
+
import { createToaster } from './lib/toaster.js';
|
|
6
|
+
|
|
7
|
+
export class DefaultClientServiceProvider extends ServiceProvider<ClientApplication> {
|
|
8
|
+
public readonly name: string = '@nocobase/app-template-default/client';
|
|
9
|
+
private previousDocumentTitle: string | undefined;
|
|
10
|
+
|
|
11
|
+
public override register(): void {
|
|
12
|
+
// What plugins and pages report through useToaster() reaches the Toaster in client/react-providers.ts from here.
|
|
13
|
+
this.app.container.instance(toasterToken, createToaster());
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
public override boot(): Promise<void> {
|
|
17
|
+
const configuredTitle = this.app.config.get<unknown>('app.title');
|
|
18
|
+
const title =
|
|
19
|
+
typeof configuredTitle === 'string' && configuredTitle.trim()
|
|
20
|
+
? configuredTitle.trim()
|
|
21
|
+
: 'NocoBase';
|
|
22
|
+
this.app.refine.setOptions({ title: { text: title } });
|
|
23
|
+
this.previousDocumentTitle = document.title;
|
|
24
|
+
document.title = title;
|
|
25
|
+
return Promise.resolve();
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
public override shutdown(): Promise<void> {
|
|
29
|
+
if (this.previousDocumentTitle !== undefined) {
|
|
30
|
+
document.title = this.previousDocumentTitle;
|
|
31
|
+
}
|
|
32
|
+
return Promise.resolve();
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
const serviceProviders: readonly ClientServiceProviderConstructor[] = [
|
|
37
|
+
DefaultClientServiceProvider,
|
|
38
|
+
];
|
|
39
|
+
|
|
40
|
+
export default serviceProviders;
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import type { AppClientSourceExtension } from '@nocobase/app-client/plugins';
|
|
2
|
+
|
|
3
|
+
interface AppClientSourceExtensionModule {
|
|
4
|
+
readonly default: AppClientSourceExtension;
|
|
5
|
+
}
|
|
6
|
+
|
|
7
|
+
const extensionModules = import.meta.glob<AppClientSourceExtensionModule>(
|
|
8
|
+
'./extensions/*/extension.ts',
|
|
9
|
+
{ eager: true },
|
|
10
|
+
);
|
|
11
|
+
|
|
12
|
+
export const sourceExtensions: readonly AppClientSourceExtension[] =
|
|
13
|
+
Object.freeze(
|
|
14
|
+
Object.entries(extensionModules)
|
|
15
|
+
.sort(([left], [right]) => left.localeCompare(right))
|
|
16
|
+
.map(([, module]) => module.default),
|
|
17
|
+
);
|
|
18
|
+
|
|
19
|
+
export default sourceExtensions;
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import type { ReactElement } from 'react';
|
|
2
|
+
|
|
3
|
+
import { Button } from '#components/ui/button';
|
|
4
|
+
|
|
5
|
+
export interface AppStartupErrorProps {
|
|
6
|
+
error: unknown;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export function AppStartupError({ error }: AppStartupErrorProps): ReactElement {
|
|
10
|
+
const message =
|
|
11
|
+
error instanceof Error ? error.message : 'Unknown startup error.';
|
|
12
|
+
|
|
13
|
+
return (
|
|
14
|
+
<main className='grid min-h-svh place-items-center px-6'>
|
|
15
|
+
<section className='w-full max-w-lg space-y-4 text-center'>
|
|
16
|
+
<h1 className='text-xl font-semibold'>Unable to start application</h1>
|
|
17
|
+
<p className='text-sm text-muted-foreground'>{message}</p>
|
|
18
|
+
<Button onClick={() => window.location.reload()}>Retry</Button>
|
|
19
|
+
</section>
|
|
20
|
+
</main>
|
|
21
|
+
);
|
|
22
|
+
}
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
@import 'tailwindcss';
|
|
2
|
+
@config "../tailwind.config.mjs";
|
|
3
|
+
@import 'tw-animate-css';
|
|
4
|
+
@import 'shadcn/tailwind.css';
|
|
5
|
+
@import './theme/themes/default.css';
|
|
6
|
+
@import './theme/themes/compact.css';
|
|
7
|
+
@source "./components/ui";
|
|
8
|
+
|
|
9
|
+
@custom-variant dark (&:is(.dark *));
|
|
10
|
+
|
|
11
|
+
@theme {
|
|
12
|
+
--font-heading: var(--font-sans);
|
|
13
|
+
/* These names are implemented below as runtime-variable utilities. */
|
|
14
|
+
--shadow-2xs: initial;
|
|
15
|
+
--shadow-xs: initial;
|
|
16
|
+
--shadow-sm: initial;
|
|
17
|
+
--shadow-md: initial;
|
|
18
|
+
--shadow-lg: initial;
|
|
19
|
+
--shadow-xl: initial;
|
|
20
|
+
--shadow-2xl: initial;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
@theme inline {
|
|
24
|
+
--color-background: var(--background);
|
|
25
|
+
--color-foreground: var(--foreground);
|
|
26
|
+
--color-card: var(--card);
|
|
27
|
+
--color-card-foreground: var(--card-foreground);
|
|
28
|
+
--color-popover: var(--popover);
|
|
29
|
+
--color-popover-foreground: var(--popover-foreground);
|
|
30
|
+
--color-primary: var(--primary);
|
|
31
|
+
--color-primary-foreground: var(--primary-foreground);
|
|
32
|
+
--color-secondary: var(--secondary);
|
|
33
|
+
--color-secondary-foreground: var(--secondary-foreground);
|
|
34
|
+
--color-muted: var(--muted);
|
|
35
|
+
--color-muted-foreground: var(--muted-foreground);
|
|
36
|
+
--color-accent: var(--accent);
|
|
37
|
+
--color-accent-foreground: var(--accent-foreground);
|
|
38
|
+
--color-destructive: var(--destructive);
|
|
39
|
+
--color-border: var(--border);
|
|
40
|
+
--color-input: var(--input);
|
|
41
|
+
--color-ring: var(--ring);
|
|
42
|
+
--color-sidebar: var(--sidebar);
|
|
43
|
+
--color-sidebar-foreground: var(--sidebar-foreground);
|
|
44
|
+
--color-sidebar-primary: var(--sidebar-primary);
|
|
45
|
+
--color-sidebar-primary-foreground: var(--sidebar-primary-foreground);
|
|
46
|
+
--color-sidebar-accent: var(--sidebar-accent);
|
|
47
|
+
--color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
|
|
48
|
+
--color-sidebar-border: var(--sidebar-border);
|
|
49
|
+
--color-sidebar-ring: var(--sidebar-ring);
|
|
50
|
+
--color-chart-1: var(--chart-1);
|
|
51
|
+
--color-chart-2: var(--chart-2);
|
|
52
|
+
--color-chart-3: var(--chart-3);
|
|
53
|
+
--color-chart-4: var(--chart-4);
|
|
54
|
+
--color-chart-5: var(--chart-5);
|
|
55
|
+
--radius-sm: calc(var(--radius) * 0.6);
|
|
56
|
+
--radius-md: calc(var(--radius) * 0.8);
|
|
57
|
+
--radius-lg: var(--radius);
|
|
58
|
+
--radius-xl: calc(var(--radius) * 1.4);
|
|
59
|
+
--radius-2xl: calc(var(--radius) * 1.8);
|
|
60
|
+
--radius-3xl: calc(var(--radius) * 2.2);
|
|
61
|
+
--radius-4xl: calc(var(--radius) * 2.6);
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
@layer base {
|
|
65
|
+
* {
|
|
66
|
+
@apply border-border outline-ring/50;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
body {
|
|
70
|
+
@apply min-h-svh bg-background font-sans text-base text-foreground antialiased;
|
|
71
|
+
margin: 0;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/* Keep semantic text in application and plugin content on the same typography. */
|
|
75
|
+
h1,
|
|
76
|
+
h2,
|
|
77
|
+
h3,
|
|
78
|
+
h4,
|
|
79
|
+
h5,
|
|
80
|
+
h6 {
|
|
81
|
+
@apply font-heading;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
code,
|
|
85
|
+
pre,
|
|
86
|
+
kbd,
|
|
87
|
+
samp {
|
|
88
|
+
@apply font-mono;
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/* Named shadows normally compile to literal values. Arbitrary-value utilities
|
|
93
|
+
preserve the runtime token and Tailwind's ring/shadow composition. */
|
|
94
|
+
@utility shadow-2xs {
|
|
95
|
+
@apply shadow-(--shadow-2xs);
|
|
96
|
+
}
|
|
97
|
+
@utility shadow-xs {
|
|
98
|
+
@apply shadow-(--shadow-xs);
|
|
99
|
+
}
|
|
100
|
+
@utility shadow-sm {
|
|
101
|
+
@apply shadow-(--shadow-sm);
|
|
102
|
+
}
|
|
103
|
+
@utility shadow-md {
|
|
104
|
+
@apply shadow-(--shadow-md);
|
|
105
|
+
}
|
|
106
|
+
@utility shadow-lg {
|
|
107
|
+
@apply shadow-(--shadow-lg);
|
|
108
|
+
}
|
|
109
|
+
@utility shadow-xl {
|
|
110
|
+
@apply shadow-(--shadow-xl);
|
|
111
|
+
}
|
|
112
|
+
@utility shadow-2xl {
|
|
113
|
+
@apply shadow-(--shadow-2xl);
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/* The application's one toaster mounts before any dialog opens, and every
|
|
117
|
+
overlay shares z-50, so a dialog portaled later would paint over its toasts.
|
|
118
|
+
Lift the viewport here rather than in the generated component; left outside
|
|
119
|
+
any layer so it outranks the component's z-50 utility. */
|
|
120
|
+
[data-slot='toast-viewport'] {
|
|
121
|
+
z-index: 100;
|
|
122
|
+
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import { createContext, useContext } from 'react';
|
|
2
|
+
import type { ThemePresetId } from './theme-presets';
|
|
3
|
+
|
|
4
|
+
export const PresetContext = createContext<{
|
|
5
|
+
preset: ThemePresetId;
|
|
6
|
+
setPreset: (value: ThemePresetId) => void;
|
|
7
|
+
} | null>(null);
|
|
8
|
+
export function useThemePreset() {
|
|
9
|
+
const value = useContext(PresetContext);
|
|
10
|
+
if (!value) throw new Error('ThemeSettings requires AppThemeProvider');
|
|
11
|
+
return value;
|
|
12
|
+
}
|