@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,303 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The inbox registry: how the browser words and renders what each contributor puts in the inbox, and which categories
|
|
3
|
+
* the inbox sorts it into. Each contributor brings, keyed by its notices' `source` (and optionally `types`):
|
|
4
|
+
*
|
|
5
|
+
* - an **entry renderer** (`InboxEntryRenderer`) for its in-app items: the icon, the type label and the card's title
|
|
6
|
+
* and sentence; for the detail pane, what it loads once (`useModel`), whether the viewer may act (`useCanAct`), the
|
|
7
|
+
* actions (`Actions`) and the body (`Body`), and what it offers elsewhere in place of the actions (`Brief`, `Here`);
|
|
8
|
+
* - or a **feed** (`InboxFeed`) for decisions whose waiting state lives in its own API: a query listing them, a list
|
|
9
|
+
* section shown before the decisions, and a detail pane.
|
|
10
|
+
*
|
|
11
|
+
* A contributor's parts render in its own i18n namespace (`namespace`); one that brings its resources (`resources`)
|
|
12
|
+
* has them added to the namespace. An item whose source nobody renders gets a safe fallback: a bell, its title and
|
|
13
|
+
* body as sent, and no actions.
|
|
14
|
+
*
|
|
15
|
+
* The registry is a React context (`InboxRegistryProvider`, `registry-scope.tsx`); without a provider it is
|
|
16
|
+
* `defaultInboxRegistry`: no contributors, and the decisions and notifications as categories.
|
|
17
|
+
*/
|
|
18
|
+
import { useApiClient, type ApiClient } from '@nocobase/app-client';
|
|
19
|
+
import { useTranslation, type Namespace } from '@nocobase/i18n/client';
|
|
20
|
+
import { useQueries, type QueryKey } from '@tanstack/react-query';
|
|
21
|
+
import { BellIcon, type LucideIcon } from 'lucide-react';
|
|
22
|
+
import {
|
|
23
|
+
createContext,
|
|
24
|
+
useCallback,
|
|
25
|
+
useContext,
|
|
26
|
+
type ComponentType,
|
|
27
|
+
type Context,
|
|
28
|
+
} from 'react';
|
|
29
|
+
|
|
30
|
+
import {
|
|
31
|
+
entryCategories,
|
|
32
|
+
type InboxCategory,
|
|
33
|
+
type InboxEntry,
|
|
34
|
+
type InboxEntryCategory,
|
|
35
|
+
} from './model.js';
|
|
36
|
+
|
|
37
|
+
/** An item's title and the one sentence under it. */
|
|
38
|
+
export interface EntryText {
|
|
39
|
+
readonly title: string;
|
|
40
|
+
readonly sentence: string | null;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** Whether the viewer may act on a decision now: not at all, not yet known, yes, or no, with why. */
|
|
44
|
+
export type InboxCanAct =
|
|
45
|
+
| { readonly state: 'none' }
|
|
46
|
+
| { readonly state: 'loading' }
|
|
47
|
+
| { readonly state: 'yes' }
|
|
48
|
+
| { readonly state: 'no'; readonly reason: string };
|
|
49
|
+
|
|
50
|
+
/** What an entry's detail parts receive: the entry, what `useModel` loaded, and how to report a decision taken. */
|
|
51
|
+
export interface InboxPartProps<M> {
|
|
52
|
+
readonly entry: InboxEntry;
|
|
53
|
+
readonly model: M;
|
|
54
|
+
/** The title shown, for accessible names. */
|
|
55
|
+
readonly title: string;
|
|
56
|
+
/** Opens what the item is about, when it has a route. */
|
|
57
|
+
readonly onOpen?: () => void;
|
|
58
|
+
/** Called once the viewer decided: marks the item read. */
|
|
59
|
+
readonly onDecided: () => void;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/** What the item is about, for whatever the application places beside it (`detailToolbar`), such as an assistant. */
|
|
63
|
+
export interface InboxEntryContext {
|
|
64
|
+
/** Ids of the records the item is about. */
|
|
65
|
+
readonly ids?: readonly string[];
|
|
66
|
+
/** The record an assistant would be asked about. */
|
|
67
|
+
readonly ask?: {
|
|
68
|
+
readonly kind: string;
|
|
69
|
+
readonly id: string;
|
|
70
|
+
readonly label: string;
|
|
71
|
+
};
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
export interface InboxEntryWording<M> {
|
|
75
|
+
/** What kind of item it is, in words; `detail` for the detail pane's header. */
|
|
76
|
+
readonly label: (entry: InboxEntry, where?: 'card' | 'detail') => string;
|
|
77
|
+
/** The title and sentence; in the detail pane with what `useModel` loaded. */
|
|
78
|
+
readonly text: (entry: InboxEntry, model?: M) => EntryText;
|
|
79
|
+
/** How a decision ended, in words; `inbox.outcomes.<outcome>` when left out. */
|
|
80
|
+
readonly outcome?: (outcome: string) => string;
|
|
81
|
+
/** The name of the button opening the item's route. */
|
|
82
|
+
readonly open?: string;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
export interface InboxEntryRenderer<M = unknown> {
|
|
86
|
+
/** The source it renders, as its notices name it. */
|
|
87
|
+
readonly source: string;
|
|
88
|
+
/** The types it renders; every type of the source when left out. */
|
|
89
|
+
readonly types?: readonly string[];
|
|
90
|
+
/** The i18n namespace its parts translate in; the page's when left out. */
|
|
91
|
+
readonly namespace?: Namespace;
|
|
92
|
+
/** Resources of `namespace` by locale, added when the inbox first renders the entry. */
|
|
93
|
+
readonly resources?: Readonly<Record<string, object>>;
|
|
94
|
+
readonly icon: (entry: InboxEntry) => LucideIcon;
|
|
95
|
+
/** The wording, as a hook so it may translate and read other hooks. */
|
|
96
|
+
readonly useWording: () => InboxEntryWording<M>;
|
|
97
|
+
/** What the detail pane loads once for the item, shared by the parts below. */
|
|
98
|
+
readonly useModel?: (entry: InboxEntry) => M;
|
|
99
|
+
/** Whether the viewer may act; no actions when left out. */
|
|
100
|
+
readonly useCanAct?: (entry: InboxEntry, model: M) => InboxCanAct;
|
|
101
|
+
/** What the viewer may do, shown in the header when `useCanAct` says yes. */
|
|
102
|
+
readonly Actions?: ComponentType<InboxPartProps<M>>;
|
|
103
|
+
/** Everything below the header. */
|
|
104
|
+
readonly Body?: ComponentType<InboxPartProps<M>>;
|
|
105
|
+
/** The gist of the item, for a card shown outside the inbox without `Body`. */
|
|
106
|
+
readonly Brief?: ComponentType<InboxPartProps<M>>;
|
|
107
|
+
/** What a card shown on the page of what the item is about offers in place of `Actions`, such as a way there. */
|
|
108
|
+
readonly Here?: ComponentType<InboxPartProps<M>>;
|
|
109
|
+
readonly context?: (entry: InboxEntry, model: M) => InboxEntryContext;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/** A decision source whose items live in its own API, listed before the decisions and counted as waiting. */
|
|
113
|
+
export interface InboxFeed<T = unknown> {
|
|
114
|
+
readonly id: string;
|
|
115
|
+
/** The URL search parameter holding the selected item's id. */
|
|
116
|
+
readonly param: string;
|
|
117
|
+
readonly namespace?: Namespace;
|
|
118
|
+
readonly resources?: Readonly<Record<string, object>>;
|
|
119
|
+
/** The items waiting on the viewer: `queryFn` reads the contributor's API, `select` keeps what waits. */
|
|
120
|
+
readonly query: (api: ApiClient) => {
|
|
121
|
+
readonly queryKey: QueryKey;
|
|
122
|
+
readonly queryFn: () => Promise<unknown>;
|
|
123
|
+
readonly select: (data: unknown) => readonly T[];
|
|
124
|
+
readonly staleTime?: number;
|
|
125
|
+
};
|
|
126
|
+
/** The section listing them. */
|
|
127
|
+
readonly List: ComponentType<{
|
|
128
|
+
readonly items: readonly T[];
|
|
129
|
+
readonly selectedId: string | null;
|
|
130
|
+
readonly onSelect: (id: string) => void;
|
|
131
|
+
}>;
|
|
132
|
+
/** One of them in the detail pane. */
|
|
133
|
+
readonly Detail: ComponentType<{
|
|
134
|
+
readonly id: string;
|
|
135
|
+
readonly onBack: () => void;
|
|
136
|
+
}>;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
export interface InboxRegistry {
|
|
140
|
+
readonly renderers: readonly InboxEntryRenderer<never>[];
|
|
141
|
+
readonly feeds: readonly InboxFeed<never>[];
|
|
142
|
+
/** The kinds of the filter, in its order; `defaultInboxCategories` when left out. */
|
|
143
|
+
readonly categories?: readonly InboxCategory[];
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/** Types an entry renderer for the registry, whose entries are erased to `never` models. */
|
|
147
|
+
export function defineInboxRenderer<M>(
|
|
148
|
+
renderer: InboxEntryRenderer<M>,
|
|
149
|
+
): InboxEntryRenderer<never> {
|
|
150
|
+
return renderer as unknown as InboxEntryRenderer<never>;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
export function defineInboxFeed<T>(feed: InboxFeed<T>): InboxFeed<never> {
|
|
154
|
+
return feed as unknown as InboxFeed<never>;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
/** The decisions an item asks the viewer to take: its notice's kind is `decision`. They wait until settled. */
|
|
158
|
+
export const decisionCategory: InboxEntryCategory = {
|
|
159
|
+
type: 'entries',
|
|
160
|
+
id: 'decision',
|
|
161
|
+
label: (t) => t('inbox.kinds.decision', { defaultValue: 'Decisions' }),
|
|
162
|
+
title: (t) => t('inbox.tabs.decision', { defaultValue: 'Needs my decision' }),
|
|
163
|
+
empty: (t) =>
|
|
164
|
+
t('inbox.empty.decision', {
|
|
165
|
+
defaultValue: 'No decisions waiting for you',
|
|
166
|
+
}),
|
|
167
|
+
match: (entry) => entry.notice?.kind === 'decision',
|
|
168
|
+
waits: true,
|
|
169
|
+
};
|
|
170
|
+
|
|
171
|
+
/** Everything else: information, including every item the source knows nothing about. */
|
|
172
|
+
export const infoCategory: InboxEntryCategory = {
|
|
173
|
+
type: 'entries',
|
|
174
|
+
id: 'info',
|
|
175
|
+
label: (t) => t('inbox.kinds.info', { defaultValue: 'Notifications' }),
|
|
176
|
+
title: (t) => t('inbox.tabs.info', { defaultValue: 'Notifications' }),
|
|
177
|
+
empty: (t) => t('inbox.empty.info', { defaultValue: 'No notifications' }),
|
|
178
|
+
match: (entry) => entry.notice?.kind !== 'decision',
|
|
179
|
+
};
|
|
180
|
+
|
|
181
|
+
export const defaultInboxCategories: readonly InboxCategory[] = [
|
|
182
|
+
decisionCategory,
|
|
183
|
+
infoCategory,
|
|
184
|
+
];
|
|
185
|
+
|
|
186
|
+
export const defaultInboxRegistry: InboxRegistry = {
|
|
187
|
+
renderers: [],
|
|
188
|
+
feeds: [],
|
|
189
|
+
categories: defaultInboxCategories,
|
|
190
|
+
};
|
|
191
|
+
|
|
192
|
+
/** The registry in effect below `InboxRegistryProvider` (`registry-scope.tsx`). */
|
|
193
|
+
export const InboxRegistryContext: Context<InboxRegistry | null> =
|
|
194
|
+
createContext<InboxRegistry | null>(null);
|
|
195
|
+
|
|
196
|
+
/** The registry a provider gives, or null. */
|
|
197
|
+
export function useProvidedInboxRegistry(): InboxRegistry | null {
|
|
198
|
+
return useContext(InboxRegistryContext);
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
/** The registry in effect: the one a provider gives, or `defaultInboxRegistry`. */
|
|
202
|
+
export function useInboxRegistry(): InboxRegistry {
|
|
203
|
+
return useProvidedInboxRegistry() ?? defaultInboxRegistry;
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
/** The categories of `registry`. */
|
|
207
|
+
export function categoriesOfRegistry(
|
|
208
|
+
registry: InboxRegistry,
|
|
209
|
+
): readonly InboxCategory[] {
|
|
210
|
+
return registry.categories ?? defaultInboxCategories;
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
/** The entry category an item belongs to: the first that matches, else the last. */
|
|
214
|
+
export function categoryOf(
|
|
215
|
+
categories: readonly InboxCategory[],
|
|
216
|
+
entry: InboxEntry,
|
|
217
|
+
): InboxEntryCategory | undefined {
|
|
218
|
+
const sorted = entryCategories(categories);
|
|
219
|
+
return sorted.find((category) => category.match(entry)) ?? sorted.at(-1);
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
/**
|
|
223
|
+
* The renderer of an item nobody renders: what the in-app item says as sent, never the contributor's data, and
|
|
224
|
+
* nothing to act on.
|
|
225
|
+
*/
|
|
226
|
+
export const fallbackRenderer: InboxEntryRenderer<null> = {
|
|
227
|
+
source: '*',
|
|
228
|
+
icon: () => BellIcon,
|
|
229
|
+
useWording() {
|
|
230
|
+
const { t } = useTranslation();
|
|
231
|
+
const categories = categoriesOfRegistry(useInboxRegistry());
|
|
232
|
+
return {
|
|
233
|
+
label: (entry) =>
|
|
234
|
+
categoryOf(categories, entry)?.title(t) ??
|
|
235
|
+
t('inbox.tabs.info', { defaultValue: 'Notifications' }),
|
|
236
|
+
text: ({ item }) => ({
|
|
237
|
+
title: item.title,
|
|
238
|
+
sentence: item.body && item.body !== item.title ? item.body : null,
|
|
239
|
+
}),
|
|
240
|
+
};
|
|
241
|
+
},
|
|
242
|
+
};
|
|
243
|
+
|
|
244
|
+
/** The renderer of an item: the first registered for its source and type, else the fallback. */
|
|
245
|
+
export function rendererFor(
|
|
246
|
+
registry: InboxRegistry,
|
|
247
|
+
entry: InboxEntry,
|
|
248
|
+
): InboxEntryRenderer<never> {
|
|
249
|
+
const notice = entry.notice;
|
|
250
|
+
const found = notice
|
|
251
|
+
? registry.renderers.find(
|
|
252
|
+
(renderer) =>
|
|
253
|
+
renderer.source === notice.source &&
|
|
254
|
+
(!renderer.types || renderer.types.includes(notice.type)),
|
|
255
|
+
)
|
|
256
|
+
: undefined;
|
|
257
|
+
return found ?? (fallbackRenderer as unknown as InboxEntryRenderer<never>);
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
/** A renderer's identity, to key what renders an item: an item's renderer changes once its notice arrives. */
|
|
261
|
+
export function rendererKey(renderer: InboxEntryRenderer<never>): string {
|
|
262
|
+
return `${renderer.source}/${renderer.types?.join(',') ?? '*'}`;
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
/** The renderer of an item in the registry in effect. */
|
|
266
|
+
export function useRendererOf(): (
|
|
267
|
+
entry: InboxEntry,
|
|
268
|
+
) => InboxEntryRenderer<never> {
|
|
269
|
+
const registry = useInboxRegistry();
|
|
270
|
+
return (entry) => rendererFor(registry, entry);
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
export interface FeedItems {
|
|
274
|
+
readonly feed: InboxFeed<never>;
|
|
275
|
+
readonly items: readonly unknown[];
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
/** The feeds of `registry` with the items each has waiting on the viewer. */
|
|
279
|
+
export function useFeedItems(registry: InboxRegistry): readonly FeedItems[] {
|
|
280
|
+
const { feeds } = registry;
|
|
281
|
+
const api = useApiClient();
|
|
282
|
+
const combine = useCallback(
|
|
283
|
+
(results: readonly { readonly data?: unknown }[]): FeedItems[] =>
|
|
284
|
+
feeds.map((feed, index) => ({
|
|
285
|
+
feed,
|
|
286
|
+
items: (results[index]?.data as readonly unknown[] | undefined) ?? [],
|
|
287
|
+
})),
|
|
288
|
+
[feeds],
|
|
289
|
+
);
|
|
290
|
+
return useQueries({
|
|
291
|
+
queries: feeds.map((feed) => {
|
|
292
|
+
const query = feed.query(api);
|
|
293
|
+
return {
|
|
294
|
+
queryKey: query.queryKey,
|
|
295
|
+
queryFn: query.queryFn,
|
|
296
|
+
select: query.select,
|
|
297
|
+
retry: false,
|
|
298
|
+
staleTime: query.staleTime ?? 30_000,
|
|
299
|
+
};
|
|
300
|
+
}),
|
|
301
|
+
combine,
|
|
302
|
+
});
|
|
303
|
+
}
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The one contract between the inbox and what an application adds to the in-app items: an `InboxSource`. The items
|
|
3
|
+
* themselves (text, read state, deletion) are always `@nocobase/app-plugin-notification-in-app`'s; a source may say
|
|
4
|
+
* who sent each item and whether a decision it asks for still waits (`notices`), list every waiting item in full
|
|
5
|
+
* (`waiting`), and count what waits per category (`pending`). `defaultInboxSource` adds nothing, so every item reads
|
|
6
|
+
* as a notification.
|
|
7
|
+
*
|
|
8
|
+
* The inbox owns the queries, under the plugin's `inboxKeys.all`, so invalidating that key refreshes the items, the
|
|
9
|
+
* count and everything a source answered.
|
|
10
|
+
*/
|
|
11
|
+
import { useApiClient, type ApiClient } from '@nocobase/app-client';
|
|
12
|
+
import { inboxKeys } from '@nocobase/app-plugin-notification-in-app/client/inbox';
|
|
13
|
+
import {
|
|
14
|
+
keepPreviousData,
|
|
15
|
+
useQuery,
|
|
16
|
+
type QueryKey,
|
|
17
|
+
type UseQueryResult,
|
|
18
|
+
} from '@tanstack/react-query';
|
|
19
|
+
|
|
20
|
+
import type { InboxEntry, InboxNotice } from './model.js';
|
|
21
|
+
|
|
22
|
+
/** What a source's calls receive: the application's API client and the query's abort signal. */
|
|
23
|
+
export interface InboxSourceRequest {
|
|
24
|
+
readonly api: ApiClient;
|
|
25
|
+
readonly signal?: AbortSignal;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export interface InboxSource {
|
|
29
|
+
/** Keeps its queries apart from another source's. */
|
|
30
|
+
readonly id: string;
|
|
31
|
+
/** What it knows about these in-app items, by `notificationId`; items it knows nothing about are left out. */
|
|
32
|
+
readonly notices?: (
|
|
33
|
+
notificationIds: readonly string[],
|
|
34
|
+
request: InboxSourceRequest,
|
|
35
|
+
) => Promise<readonly InboxNotice[]>;
|
|
36
|
+
/**
|
|
37
|
+
* The items still waiting on the viewer, newest first and in full, with their in-app items; about one subject only
|
|
38
|
+
* (`issue:<id>`) when given. The inbox lists them before the pages.
|
|
39
|
+
*/
|
|
40
|
+
readonly waiting?: (
|
|
41
|
+
subject: string | undefined,
|
|
42
|
+
request: InboxSourceRequest,
|
|
43
|
+
) => Promise<readonly InboxEntry[]>;
|
|
44
|
+
/** How many items wait on the viewer, by category id, such as `{ decision: 3 }`. */
|
|
45
|
+
readonly pending?: (
|
|
46
|
+
request: InboxSourceRequest,
|
|
47
|
+
) => Promise<Readonly<Record<string, number>>>;
|
|
48
|
+
/** Realtime topics whose events refresh the inbox, besides the in-app plugin's own. */
|
|
49
|
+
readonly refreshTopics?: readonly string[];
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** The in-app items alone: nothing waits, and every item reads as a notification. */
|
|
53
|
+
export const defaultInboxSource: InboxSource = { id: 'notifications' };
|
|
54
|
+
|
|
55
|
+
export interface InboxSourceKeys {
|
|
56
|
+
readonly all: QueryKey;
|
|
57
|
+
notices(notificationIds: readonly string[]): QueryKey;
|
|
58
|
+
waiting(subject?: string): QueryKey;
|
|
59
|
+
readonly pending: QueryKey;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/** A source's query keys, under the plugin's `inboxKeys.all`. */
|
|
63
|
+
export function inboxSourceKeys(source: InboxSource): InboxSourceKeys {
|
|
64
|
+
const all = [...inboxKeys.all, 'source', source.id] as const;
|
|
65
|
+
return {
|
|
66
|
+
all,
|
|
67
|
+
notices: (ids) => [...all, 'notices', ids.join(',')],
|
|
68
|
+
waiting: (subject) => [...all, 'waiting', subject ?? null],
|
|
69
|
+
pending: [...all, 'pending'],
|
|
70
|
+
};
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/** What `source` knows about the items of these notifications. */
|
|
74
|
+
export function useInboxNotices(
|
|
75
|
+
source: InboxSource,
|
|
76
|
+
notificationIds: readonly string[],
|
|
77
|
+
): UseQueryResult<readonly InboxNotice[]> {
|
|
78
|
+
const api = useApiClient();
|
|
79
|
+
return useQuery({
|
|
80
|
+
queryKey: inboxSourceKeys(source).notices(notificationIds),
|
|
81
|
+
queryFn: ({ signal }) =>
|
|
82
|
+
source.notices?.(notificationIds, { api, signal }) ?? Promise.resolve([]),
|
|
83
|
+
enabled: notificationIds.length > 0,
|
|
84
|
+
placeholderData: keepPreviousData,
|
|
85
|
+
});
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/** The items `source` lists as waiting on the viewer, about `subject` when given. */
|
|
89
|
+
export function useInboxWaiting(
|
|
90
|
+
source: InboxSource,
|
|
91
|
+
subject?: string,
|
|
92
|
+
): UseQueryResult<readonly InboxEntry[]> {
|
|
93
|
+
const api = useApiClient();
|
|
94
|
+
return useQuery({
|
|
95
|
+
queryKey: inboxSourceKeys(source).waiting(subject),
|
|
96
|
+
queryFn: ({ signal }) =>
|
|
97
|
+
source.waiting?.(subject, { api, signal }) ?? Promise.resolve([]),
|
|
98
|
+
// The whole inbox keeps its list while refetching; one subject's list never shows another's.
|
|
99
|
+
...(subject === undefined
|
|
100
|
+
? { placeholderData: keepPreviousData }
|
|
101
|
+
: { retry: false }),
|
|
102
|
+
});
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/** How many items wait on the viewer per category, as `source` counts them. */
|
|
106
|
+
export function useInboxPending(
|
|
107
|
+
source: InboxSource,
|
|
108
|
+
): UseQueryResult<Readonly<Record<string, number>>> {
|
|
109
|
+
const api = useApiClient();
|
|
110
|
+
return useQuery({
|
|
111
|
+
queryKey: inboxSourceKeys(source).pending,
|
|
112
|
+
queryFn: ({ signal }) =>
|
|
113
|
+
source.pending?.({ api, signal }) ?? Promise.resolve({}),
|
|
114
|
+
retry: false,
|
|
115
|
+
staleTime: 30_000,
|
|
116
|
+
});
|
|
117
|
+
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import * as React from 'react';
|
|
2
|
+
|
|
3
|
+
const MOBILE_BREAKPOINT = 768;
|
|
4
|
+
|
|
5
|
+
export function useIsMobile() {
|
|
6
|
+
const [isMobile, setIsMobile] = React.useState<boolean | undefined>(
|
|
7
|
+
undefined,
|
|
8
|
+
);
|
|
9
|
+
|
|
10
|
+
React.useEffect(() => {
|
|
11
|
+
const mql = window.matchMedia(`(max-width: ${MOBILE_BREAKPOINT - 1}px)`);
|
|
12
|
+
const onChange = () => {
|
|
13
|
+
setIsMobile(window.innerWidth < MOBILE_BREAKPOINT);
|
|
14
|
+
};
|
|
15
|
+
mql.addEventListener('change', onChange);
|
|
16
|
+
setIsMobile(window.innerWidth < MOBILE_BREAKPOINT);
|
|
17
|
+
return () => mql.removeEventListener('change', onChange);
|
|
18
|
+
}, []);
|
|
19
|
+
|
|
20
|
+
return !!isMobile;
|
|
21
|
+
}
|
package/client/index.tsx
ADDED
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
import { createApp } from './app.js';
|
|
2
|
+
import {
|
|
3
|
+
AppClientRoot,
|
|
4
|
+
resolveAppBase,
|
|
5
|
+
type ClientApplication,
|
|
6
|
+
} from '@nocobase/app-client';
|
|
7
|
+
import { resolveAppRuntime } from '@nocobase/app-client/runtime';
|
|
8
|
+
import { createRoot } from 'react-dom/client';
|
|
9
|
+
|
|
10
|
+
import appRuntime from './runtime';
|
|
11
|
+
import { AppStartupError } from './startup';
|
|
12
|
+
import './styles.css';
|
|
13
|
+
import { initializeTheme } from './theme/theme-preferences';
|
|
14
|
+
import { themePresets } from './theme/theme-presets';
|
|
15
|
+
|
|
16
|
+
const container = document.getElementById('root');
|
|
17
|
+
|
|
18
|
+
if (!container) {
|
|
19
|
+
throw new Error('Missing application root element.');
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
const root = createRoot(container);
|
|
23
|
+
let app: ClientApplication | undefined;
|
|
24
|
+
let applicationStarted = false;
|
|
25
|
+
|
|
26
|
+
async function start(): Promise<void> {
|
|
27
|
+
try {
|
|
28
|
+
// Restore preferences at normal client startup; first-paint theme matching is deferred.
|
|
29
|
+
initializeTheme(
|
|
30
|
+
resolveAppBase(),
|
|
31
|
+
themePresets.map((preset) => preset.id),
|
|
32
|
+
);
|
|
33
|
+
|
|
34
|
+
const runtime = await resolveAppRuntime(appRuntime);
|
|
35
|
+
|
|
36
|
+
app = createApp(runtime);
|
|
37
|
+
await app.start();
|
|
38
|
+
applicationStarted = true;
|
|
39
|
+
|
|
40
|
+
root.render(<AppClientRoot app={app} />);
|
|
41
|
+
} catch (startupError) {
|
|
42
|
+
let error: unknown = startupError;
|
|
43
|
+
|
|
44
|
+
if (app && applicationStarted) {
|
|
45
|
+
try {
|
|
46
|
+
await app.shutdown();
|
|
47
|
+
} catch (shutdownError) {
|
|
48
|
+
error = new AggregateError(
|
|
49
|
+
[startupError, shutdownError],
|
|
50
|
+
'Client Application startup and shutdown both failed.',
|
|
51
|
+
{ cause: startupError },
|
|
52
|
+
);
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
root.render(<AppStartupError error={error} />);
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
void start();
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
import { useSyncServerLocale } from '@nocobase/app-plugin-i18n/client';
|
|
2
|
+
import { useMemo, type ReactElement } from 'react';
|
|
3
|
+
import type { AppClientRegisteredRoute } from '@nocobase/app-client/plugins';
|
|
4
|
+
import { Outlet, useLocation } from 'react-router';
|
|
5
|
+
|
|
6
|
+
import { Breadcrumbs } from '#components/breadcrumbs';
|
|
7
|
+
import { EMPTY_ARRAY } from '#lib/constants';
|
|
8
|
+
|
|
9
|
+
import { RouteTreeProvider } from '../routing/route-context.js';
|
|
10
|
+
|
|
11
|
+
import {
|
|
12
|
+
PageBreadcrumbProvider,
|
|
13
|
+
useClientApplication,
|
|
14
|
+
} from '@nocobase/app-client';
|
|
15
|
+
import { useTranslation } from '@nocobase/i18n/client';
|
|
16
|
+
import { LayoutHeader } from './components/layout-header.js';
|
|
17
|
+
import {
|
|
18
|
+
AppSidebar,
|
|
19
|
+
AppSidebarProvider,
|
|
20
|
+
AppSidebarToggle,
|
|
21
|
+
} from './components/app-sidebar.js';
|
|
22
|
+
import { NavigationMenu } from './components/navigation-menu.js';
|
|
23
|
+
import { AppBrand } from './components/app-brand.js';
|
|
24
|
+
import { HeaderActions } from './components/header-actions.js';
|
|
25
|
+
import { AppSidebarFooter } from './components/sidebar-footer.js';
|
|
26
|
+
import {
|
|
27
|
+
useRouteNavigation,
|
|
28
|
+
selectedNavigationId,
|
|
29
|
+
navigationPages,
|
|
30
|
+
} from '../routing/route-navigation.js';
|
|
31
|
+
|
|
32
|
+
export interface AppLayoutProps {
|
|
33
|
+
readonly routes: readonly AppClientRegisteredRoute[];
|
|
34
|
+
/** Pages plugins declare with `defineDevRoutes()`: rendered in this shell at `/dev/...`, never in its navigation. */
|
|
35
|
+
readonly devRoutes?: readonly AppClientRegisteredRoute[];
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export function AppLayout({
|
|
39
|
+
routes,
|
|
40
|
+
devRoutes = EMPTY_ARRAY,
|
|
41
|
+
}: AppLayoutProps): ReactElement {
|
|
42
|
+
// The browser decides what it renders; this tells the server the same language so its messages match.
|
|
43
|
+
useSyncServerLocale();
|
|
44
|
+
|
|
45
|
+
const { t } = useTranslation();
|
|
46
|
+
const { items: menuItems, denied } = useRouteNavigation(routes);
|
|
47
|
+
const selectedKey = selectedNavigationId(
|
|
48
|
+
routes,
|
|
49
|
+
useLocation().pathname,
|
|
50
|
+
denied,
|
|
51
|
+
);
|
|
52
|
+
const settingsNavigation = useRouteNavigation(
|
|
53
|
+
useClientApplication().runtime.settingsRouteTree,
|
|
54
|
+
);
|
|
55
|
+
// Pages read their trail from this tree, so it keeps its identity between renders.
|
|
56
|
+
const routeTree = useMemo(
|
|
57
|
+
() => (devRoutes.length ? [...routes, ...devRoutes] : routes),
|
|
58
|
+
[routes, devRoutes],
|
|
59
|
+
);
|
|
60
|
+
const navigationLabel = t('navigation.label', {
|
|
61
|
+
defaultValue: 'Application navigation',
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
return (
|
|
65
|
+
// The shell owns the business route tree used by its pages and navigation.
|
|
66
|
+
<RouteTreeProvider routes={routeTree}>
|
|
67
|
+
<PageBreadcrumbProvider>
|
|
68
|
+
<AppSidebarProvider>
|
|
69
|
+
<AppSidebar label={navigationLabel} footer={<AppSidebarFooter />}>
|
|
70
|
+
<NavigationMenu
|
|
71
|
+
items={menuItems}
|
|
72
|
+
label={navigationLabel}
|
|
73
|
+
selectedKey={selectedKey}
|
|
74
|
+
/>
|
|
75
|
+
</AppSidebar>
|
|
76
|
+
<div className='flex min-w-0 flex-1 flex-col'>
|
|
77
|
+
<LayoutHeader className='sticky top-0 z-40 justify-between'>
|
|
78
|
+
<div className='flex min-w-0 items-center gap-3'>
|
|
79
|
+
<AppSidebarToggle />
|
|
80
|
+
<div className='md:hidden'>
|
|
81
|
+
<AppBrand />
|
|
82
|
+
</div>
|
|
83
|
+
<div className='h-5 w-px shrink-0 bg-border' />
|
|
84
|
+
{/* The current page's trail: the route tree's, or the one the page declares (`usePageBreadcrumb`). */}
|
|
85
|
+
<Breadcrumbs denied={denied} />
|
|
86
|
+
</div>
|
|
87
|
+
<HeaderActions
|
|
88
|
+
showSettings={
|
|
89
|
+
navigationPages(settingsNavigation.items).length > 0
|
|
90
|
+
}
|
|
91
|
+
/>
|
|
92
|
+
</LayoutHeader>
|
|
93
|
+
<main className='relative min-w-0 flex-1 overflow-hidden'>
|
|
94
|
+
{/* main only positions; the page scrolls in here, so a child page layer laid over main is neither
|
|
95
|
+
moved by the page's scrolling nor stretched by its height. */}
|
|
96
|
+
<div className='h-full overflow-y-auto'>
|
|
97
|
+
<Outlet />
|
|
98
|
+
</div>
|
|
99
|
+
</main>
|
|
100
|
+
</div>
|
|
101
|
+
</AppSidebarProvider>
|
|
102
|
+
</PageBreadcrumbProvider>
|
|
103
|
+
</RouteTreeProvider>
|
|
104
|
+
);
|
|
105
|
+
}
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import { useTranslation } from '@nocobase/i18n/client';
|
|
2
|
+
import { resolveAssetUrl } from '@nocobase/app-client';
|
|
3
|
+
import type { ReactElement } from 'react';
|
|
4
|
+
import { Link } from 'react-router';
|
|
5
|
+
|
|
6
|
+
export interface AppBrandProps {
|
|
7
|
+
readonly compact?: boolean;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export function AppBrand(inputProps: AppBrandProps): ReactElement {
|
|
11
|
+
const { t } = useTranslation();
|
|
12
|
+
const { compact = false } = inputProps;
|
|
13
|
+
|
|
14
|
+
return (
|
|
15
|
+
<Link
|
|
16
|
+
aria-label={t('navigation.brandHome', { defaultValue: 'NocoBase home' })}
|
|
17
|
+
className='flex min-w-0 items-center text-foreground'
|
|
18
|
+
to='/'
|
|
19
|
+
>
|
|
20
|
+
{compact ? (
|
|
21
|
+
<span className='size-9 shrink-0 overflow-hidden'>
|
|
22
|
+
<img
|
|
23
|
+
src={resolveAssetUrl('/assets/logo-mark.png')}
|
|
24
|
+
alt=''
|
|
25
|
+
className='size-full object-contain dark:hidden'
|
|
26
|
+
/>
|
|
27
|
+
<img
|
|
28
|
+
src={resolveAssetUrl('/assets/logo-mark-dark.png')}
|
|
29
|
+
alt=''
|
|
30
|
+
className='hidden size-full object-contain dark:block'
|
|
31
|
+
/>
|
|
32
|
+
</span>
|
|
33
|
+
) : (
|
|
34
|
+
<span className='h-8 min-w-0 overflow-hidden'>
|
|
35
|
+
<img
|
|
36
|
+
src={resolveAssetUrl('/assets/logo.png')}
|
|
37
|
+
alt='NocoBase'
|
|
38
|
+
className='h-full w-auto object-contain dark:hidden'
|
|
39
|
+
/>
|
|
40
|
+
<img
|
|
41
|
+
src={resolveAssetUrl('/assets/logo-dark.png')}
|
|
42
|
+
alt='NocoBase'
|
|
43
|
+
className='hidden h-full w-auto object-contain dark:block'
|
|
44
|
+
/>
|
|
45
|
+
</span>
|
|
46
|
+
)}
|
|
47
|
+
</Link>
|
|
48
|
+
);
|
|
49
|
+
}
|