@nocobase/portal-template-default 1.0.1 → 1.0.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (78) hide show
  1. package/README.MD +22 -3
  2. package/package.json +4 -2
  3. package/registry/nocobase-acl/components/role-options.ts +7 -0
  4. package/registry/nocobase-acl/components/role-switcher.tsx +9 -4
  5. package/registry/nocobase-acl/demo/boundary-api.tsx +87 -16
  6. package/registry/nocobase-acl/demo/components.tsx +32 -11
  7. package/registry/nocobase-acl/demo/index.tsx +45 -14
  8. package/registry/nocobase-acl/demo/lazy-route.tsx +2 -7
  9. package/registry/nocobase-acl/demo/prompt-generator.tsx +52 -33
  10. package/registry/nocobase-acl/demo/role-switcher-prompt-generator.tsx +28 -9
  11. package/registry/nocobase-acl/extension.tsx +1 -1
  12. package/registry/nocobase-acl/tests/acl-regression.mjs +7 -6
  13. package/registry/nocobase-ai/components/chat/chat-messages.tsx +3 -4
  14. package/registry/nocobase-ai/components/chat/conversation-list.tsx +2 -4
  15. package/registry/nocobase-ai/components/tools/business-report-dialog.tsx +16 -19
  16. package/registry/nocobase-ai/components/tools/chart-renderer.tsx +3 -14
  17. package/registry/nocobase-ai/components/tools/echarts-preview.tsx +2 -5
  18. package/registry/nocobase-ai/demo/configuration-gate.tsx +9 -12
  19. package/registry/nocobase-ai/extension.tsx +2 -7
  20. package/registry/nocobase-i18n/README.md +53 -0
  21. package/registry/nocobase-i18n/components/index.ts +1 -0
  22. package/registry/nocobase-i18n/components/language-switcher.tsx +138 -0
  23. package/registry/nocobase-i18n/demo/index.tsx +209 -0
  24. package/registry/nocobase-i18n/demo/prompt-generator.tsx +155 -0
  25. package/registry/nocobase-i18n/extension.tsx +46 -0
  26. package/registry/nocobase-i18n/i18n-provider.ts +9 -0
  27. package/registry/nocobase-i18n/index.ts +6 -0
  28. package/registry/nocobase-i18n/locale-store.ts +134 -0
  29. package/registry/nocobase-i18n/locales/en-US.ts +89 -0
  30. package/registry/nocobase-i18n/locales/zh-CN.ts +85 -0
  31. package/registry/nocobase-i18n/provider.tsx +32 -0
  32. package/registry/nocobase-i18n/runtime.ts +118 -0
  33. package/registry/nocobase-i18n/server-resources.ts +184 -0
  34. package/registry/nocobase-i18n/tests/i18n-regression.mjs +42 -0
  35. package/registry.config.json +34 -14
  36. package/scripts/registry.mjs +3 -0
  37. package/src/App.tsx +27 -86
  38. package/src/app/extension.ts +3 -1
  39. package/src/app/extensions.tsx +21 -0
  40. package/src/components/access-control/acl-gate.tsx +4 -10
  41. package/src/components/app-shell/breadcrumb.tsx +39 -3
  42. package/src/components/app-shell/document-title-handler.tsx +11 -20
  43. package/src/components/app-shell/header.tsx +18 -3
  44. package/src/components/app-shell/loading-overlay.tsx +2 -4
  45. package/src/components/app-shell/loading-state.tsx +15 -0
  46. package/src/components/app-shell/sidebar.tsx +75 -47
  47. package/src/components/data-table/data-table-pagination.tsx +27 -7
  48. package/src/components/data-table/data-table-styles.ts +57 -0
  49. package/src/components/data-table/data-table.tsx +14 -59
  50. package/src/components/resources/resource-label.ts +101 -0
  51. package/src/components/resources/views/create-view.tsx +17 -14
  52. package/src/components/resources/views/edit-view.tsx +17 -15
  53. package/src/components/resources/views/list-view.tsx +20 -10
  54. package/src/components/resources/views/show-view.tsx +20 -18
  55. package/src/index.tsx +2 -3
  56. package/src/lib/i18n.ts +161 -0
  57. package/src/lib/nocobase/client.ts +19 -2
  58. package/src/locales/en-US.ts +82 -0
  59. package/src/locales/index.ts +13 -0
  60. package/src/locales/zh-CN.ts +79 -0
  61. package/src/pages/users/create.tsx +98 -0
  62. package/src/pages/users/edit.tsx +92 -0
  63. package/src/pages/users/form-context.ts +57 -0
  64. package/src/pages/users/form-fields.tsx +211 -0
  65. package/src/pages/users/list.tsx +205 -0
  66. package/src/pages/users/show.tsx +187 -0
  67. package/src/pages/users/types.ts +20 -0
  68. package/src/providers/constants.ts +1 -0
  69. package/src/pages/blog-posts/create.tsx +0 -177
  70. package/src/pages/blog-posts/edit.tsx +0 -197
  71. package/src/pages/blog-posts/list.tsx +0 -131
  72. package/src/pages/blog-posts/show.tsx +0 -81
  73. package/src/pages/categories/create.tsx +0 -73
  74. package/src/pages/categories/edit.tsx +0 -73
  75. package/src/pages/categories/index.ts +0 -4
  76. package/src/pages/categories/list.tsx +0 -86
  77. package/src/pages/categories/show.tsx +0 -37
  78. /package/src/pages/{blog-posts → users}/index.ts +0 -0
@@ -2,7 +2,13 @@ import { Check, Copy } from "lucide-react";
2
2
  import { useMemo, useState } from "react";
3
3
 
4
4
  import { Button } from "@/components/ui/button";
5
- import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
5
+ import {
6
+ Card,
7
+ CardContent,
8
+ CardDescription,
9
+ CardHeader,
10
+ CardTitle,
11
+ } from "@/components/ui/card";
6
12
  import { Input } from "@/components/ui/input";
7
13
  import { Label } from "@/components/ui/label";
8
14
  import {
@@ -52,7 +58,7 @@ export function RoleSwitcherPromptGenerator() {
52
58
  const [copyError, setCopyError] = useState(false);
53
59
 
54
60
  const prompt = useMemo(
55
- () => `Integrate the RoleSwitcher into the existing NocoBase Refine Starter.
61
+ () => `Integrate the RoleSwitcher into the existing NocoBase Admin Starter.
56
62
 
57
63
  Target placement
58
64
  - Placement: ${placementLabels[placement]}
@@ -71,7 +77,7 @@ Component presentation
71
77
  Integration requirements
72
78
  - Import and reuse RoleSwitcher from the installed local entry point at @/extensions/nocobase-acl.
73
79
  - Update the existing application surface instead of creating an isolated example page.
74
- - Do not fetch, hard-code, or pass a duplicate role list. The component reads roles from the current Refine identity.
80
+ - Do not fetch, hard-code, or pass a duplicate role list. The component reads roles from the signed-in identity.
75
81
  - Do not reimplement Full permissions, Anonymous, or role-mode logic. The component derives these options from the Starter ACL store.
76
82
  - Do not add a separate role-switch mutation. The component already calls users:setDefaultRole and reloads the application after a successful switch.
77
83
  - Preserve existing account, responsive navigation, and sign-out behavior.
@@ -83,14 +89,20 @@ Integration requirements
83
89
  <div className="grid items-start gap-5 xl:grid-cols-[360px_minmax(0,1fr)]">
84
90
  <Card className="gap-0 py-0">
85
91
  <CardHeader className="border-b py-4">
86
- <CardTitle>Placement settings</CardTitle>
92
+ <CardTitle className="text-base">Placement settings</CardTitle>
93
+ <CardDescription>
94
+ Choose where the shared role switcher belongs and how its compact
95
+ state should appear.
96
+ </CardDescription>
87
97
  </CardHeader>
88
98
  <CardContent className="space-y-5 py-5">
89
99
  <div className="space-y-2">
90
100
  <Label>Placement</Label>
91
101
  <Select
92
102
  value={placement}
93
- onValueChange={(value) => value && setPlacement(value as Placement)}
103
+ onValueChange={(value) =>
104
+ value && setPlacement(value as Placement)
105
+ }
94
106
  >
95
107
  <SelectTrigger className="w-full">
96
108
  <SelectValue>{placementLabels[placement]}</SelectValue>
@@ -144,8 +156,15 @@ Integration requirements
144
156
  </Card>
145
157
 
146
158
  <Card className="gap-0 overflow-hidden py-0">
147
- <CardHeader className="flex-row items-center justify-between gap-3 border-b py-3">
148
- <CardTitle className="text-sm">Generated integration prompt</CardTitle>
159
+ <div className="flex items-center justify-between gap-3 border-b px-4 py-3">
160
+ <div>
161
+ <div className="text-sm font-medium">
162
+ Generated integration prompt
163
+ </div>
164
+ <div className="text-xs text-muted-foreground">
165
+ Updates as you change placement and presentation settings.
166
+ </div>
167
+ </div>
149
168
  <Button
150
169
  size="sm"
151
170
  onClick={async () => {
@@ -162,14 +181,14 @@ Integration requirements
162
181
  {copied ? <Check /> : <Copy />}
163
182
  {copied ? "Copied" : "Copy prompt"}
164
183
  </Button>
165
- </CardHeader>
184
+ </div>
166
185
  {copyError ? (
167
186
  <p className="border-b px-4 py-2 text-xs text-destructive">
168
187
  Clipboard access failed. Select the prompt and copy it manually.
169
188
  </p>
170
189
  ) : null}
171
190
  <CardContent className="p-0">
172
- <pre className="max-h-[560px] overflow-auto whitespace-pre-wrap p-5 font-mono text-xs leading-5 text-muted-foreground">
191
+ <pre className="max-h-[560px] overflow-auto whitespace-pre-wrap bg-muted/25 p-5 font-mono text-xs leading-5 text-muted-foreground">
173
192
  {prompt}
174
193
  </pre>
175
194
  </CardContent>
@@ -21,7 +21,7 @@ const nocobaseAclExtension: AppExtension = {
21
21
  meta: {
22
22
  label: "Access control",
23
23
  icon: <ShieldCheck />,
24
- description: "NocoBase ACL integration for Refine applications.",
24
+ description: "NocoBase ACL integration for admin applications.",
25
25
  acl: { type: "authenticated" },
26
26
  },
27
27
  },
@@ -12,8 +12,7 @@ try {
12
12
  canAccessWithSnapshot,
13
13
  getAclDataForDataSource,
14
14
  resolveActionPermission,
15
- } =
16
- await server.ssrLoadModule("/src/lib/nocobase/acl/action.ts");
15
+ } = await server.ssrLoadModule("/src/lib/nocobase/acl/action.ts");
17
16
  const { resolveAclDataSourceKey } = await server.ssrLoadModule(
18
17
  "/src/lib/nocobase/acl/data-source.ts"
19
18
  );
@@ -23,13 +22,11 @@ try {
23
22
  clearRecordPermissions,
24
23
  getRecordActionPermission,
25
24
  updateRecordPermissions,
26
- } = await server.ssrLoadModule(
27
- "/src/lib/nocobase/acl/record-permissions.ts"
28
- );
25
+ } = await server.ssrLoadModule("/src/lib/nocobase/acl/record-permissions.ts");
29
26
  const { RoleSwitcher } = await server.ssrLoadModule(
30
27
  "/registry/nocobase-acl/components/role-switcher.tsx"
31
28
  );
32
- const { getRoleOptions } = await server.ssrLoadModule(
29
+ const { getRoleOptions, resolveRoleTitle } = await server.ssrLoadModule(
33
30
  "/registry/nocobase-acl/components/role-options.ts"
34
31
  );
35
32
 
@@ -305,6 +302,10 @@ try {
305
302
  { name: "anonymous", title: "Anonymous" },
306
303
  ]
307
304
  );
305
+ assert.equal(
306
+ resolveRoleTitle({ name: "admin", title: '{{t("Admin")}}' }),
307
+ "Admin"
308
+ );
308
309
 
309
310
  assert.equal(typeof RoleSwitcher, "function");
310
311
  console.log("NocoBase ACL regression tests passed");
@@ -1,4 +1,5 @@
1
1
  import { Button } from "@/components/ui/button";
2
+ import { LoadingState } from "@/components/app-shell/loading-state";
2
3
  import {
3
4
  useAIChatBase,
4
5
  useAIChatMessages,
@@ -7,7 +8,7 @@ import {
7
8
  type AIToolCallDecision,
8
9
  } from "../../providers";
9
10
  import { cn } from "@/lib/utils";
10
- import { ArrowDown, LoaderCircle } from "lucide-react";
11
+ import { ArrowDown } from "lucide-react";
11
12
  import { useEffect, useRef, useState, type ReactNode } from "react";
12
13
  import { ChatEmptyState } from "./chat-empty-state";
13
14
  import { ChatMessage } from "./chat-message";
@@ -107,9 +108,7 @@ export function AIChatMessageList({
107
108
  }}
108
109
  >
109
110
  {loading ? (
110
- <div className="flex h-full items-center justify-center gap-2 text-sm text-muted-foreground">
111
- <LoaderCircle className="size-4 animate-spin" /> Loading messages…
112
- </div>
111
+ <LoadingState className="h-full" />
113
112
  ) : (
114
113
  <>
115
114
  {messages.length ? (
@@ -1,4 +1,5 @@
1
1
  import { Button } from "@/components/ui/button";
2
+ import { LoadingState } from "@/components/app-shell/loading-state";
2
3
  import {
3
4
  Dialog,
4
5
  DialogContent,
@@ -170,10 +171,7 @@ export function ConversationList({
170
171
  </form>
171
172
  <div className="min-h-0 flex-1 overflow-y-auto overscroll-contain p-2">
172
173
  {conversationsLoading ? (
173
- <div className="flex items-center justify-center gap-2 px-3 py-8 text-sm text-muted-foreground">
174
- <LoaderCircle className="size-4 animate-spin" /> Loading
175
- conversations…
176
- </div>
174
+ <LoadingState className="py-8" />
177
175
  ) : conversations.length ? (
178
176
  <div className="space-y-1">
179
177
  {conversations.map((conversation) => {
@@ -1,4 +1,5 @@
1
1
  import { MarkdownMessage } from "../chat/markdown-message";
2
+ import { LoadingState } from "@/components/app-shell/loading-state";
2
3
  import { Button } from "@/components/ui/button";
3
4
  import {
4
5
  Dialog,
@@ -58,7 +59,11 @@ const sameReport = (
58
59
  sameCharts(left.charts, right.charts);
59
60
 
60
61
  type BusinessReportDialogController = {
61
- open: (toolCallId: string, report: BusinessReportData, ready: boolean) => void;
62
+ open: (
63
+ toolCallId: string,
64
+ report: BusinessReportData,
65
+ ready: boolean
66
+ ) => void;
62
67
  update: (
63
68
  toolCallId: string,
64
69
  report: BusinessReportData,
@@ -79,9 +84,7 @@ export function useBusinessReportDialog() {
79
84
  return value;
80
85
  }
81
86
 
82
- export function BusinessReportDialogProvider({
83
- children,
84
- }: PropsWithChildren) {
87
+ export function BusinessReportDialogProvider({ children }: PropsWithChildren) {
85
88
  const [state, setState] = useState(closedSnapshot);
86
89
  const open = useCallback(
87
90
  (toolCallId: string, report: BusinessReportData, ready: boolean) =>
@@ -107,9 +110,7 @@ export function BusinessReportDialogProvider({
107
110
  state={state}
108
111
  onOpenChange={(nextOpen) =>
109
112
  setState((current) =>
110
- current.open === nextOpen
111
- ? current
112
- : { ...current, open: nextOpen }
113
+ current.open === nextOpen ? current : { ...current, open: nextOpen }
113
114
  )
114
115
  }
115
116
  />
@@ -119,13 +120,7 @@ export function BusinessReportDialogProvider({
119
120
 
120
121
  function ChartPreview({ options }: { options: Record<string, unknown> }) {
121
122
  return (
122
- <Suspense
123
- fallback={
124
- <div className="flex h-[280px] items-center justify-center text-xs text-muted-foreground">
125
- <LoaderCircle className="mr-2 size-4 animate-spin" /> Loading chart…
126
- </div>
127
- }
128
- >
123
+ <Suspense fallback={<LoadingState className="h-[280px]" />}>
129
124
  <EChartsPreview options={options} />
130
125
  </Suspense>
131
126
  );
@@ -150,7 +145,10 @@ function BusinessReportDialogHost({
150
145
  [report, state.ready]
151
146
  );
152
147
  const reportMarkdown = useMemo(
153
- () => (state.open && state.ready && report ? buildBusinessReportMarkdown(report) : ""),
148
+ () =>
149
+ state.open && state.ready && report
150
+ ? buildBusinessReportMarkdown(report)
151
+ : "",
154
152
  [report, state.open, state.ready]
155
153
  );
156
154
  const previewParts = useMemo(
@@ -270,12 +268,11 @@ function BusinessReportDialogHost({
270
268
  srcDoc={htmlPreview}
271
269
  className="size-full min-h-[480px] border-0 bg-white"
272
270
  />
271
+ ) : htmlLoading ? (
272
+ <LoadingState className="h-full min-h-[480px]" />
273
273
  ) : (
274
274
  <div className="flex h-full min-h-[480px] items-center justify-center gap-2 text-sm text-muted-foreground">
275
- {htmlLoading ? (
276
- <LoaderCircle className="size-4 animate-spin" />
277
- ) : null}
278
- {htmlLoading ? "Preparing HTML preview…" : "HTML is unavailable"}
275
+ HTML is unavailable
279
276
  </div>
280
277
  )}
281
278
  </TabsContent>
@@ -1,12 +1,7 @@
1
1
  import { Button } from "@/components/ui/button";
2
+ import { LoadingState } from "@/components/app-shell/loading-state";
2
3
  import { CheckCircle2, Copy, LoaderCircle } from "lucide-react";
3
- import {
4
- Component,
5
- lazy,
6
- Suspense,
7
- useState,
8
- type ReactNode,
9
- } from "react";
4
+ import { Component, lazy, Suspense, useState, type ReactNode } from "react";
10
5
  import type { AIToolRendererProps } from "./tool-renderer-provider";
11
6
  import { asRecord } from "./tool-renderer-utils";
12
7
 
@@ -75,13 +70,7 @@ export function ChartPreview({
75
70
  options: Record<string, unknown>;
76
71
  }) {
77
72
  return (
78
- <Suspense
79
- fallback={
80
- <div className="flex h-[280px] items-center justify-center text-xs text-muted-foreground">
81
- <LoaderCircle className="mr-2 size-4 animate-spin" /> Loading chart…
82
- </div>
83
- }
84
- >
73
+ <Suspense fallback={<LoadingState className="h-[280px]" />}>
85
74
  <EChartsPreview options={options} />
86
75
  </Suspense>
87
76
  );
@@ -1,6 +1,7 @@
1
1
  import ReactEChartsCore from "echarts-for-react/lib/core";
2
2
  import { useTheme } from "next-themes";
3
3
  import { useEffect, useState } from "react";
4
+ import { LoadingState } from "@/components/app-shell/loading-state";
4
5
  import echarts, {
5
6
  getEChartsRuntimeSignature,
6
7
  prepareEChartsRuntime,
@@ -33,11 +34,7 @@ export default function EChartsPreview({
33
34
 
34
35
  if (error) throw error;
35
36
  if (preparedSignature !== signature) {
36
- return (
37
- <div className="flex h-[280px] items-center justify-center text-xs text-muted-foreground">
38
- Loading chart…
39
- </div>
40
- );
37
+ return <LoadingState className="h-[280px]" />;
41
38
  }
42
39
 
43
40
  return (
@@ -1,7 +1,8 @@
1
1
  import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
2
2
  import { Badge } from "@/components/ui/badge";
3
+ import { LoadingState } from "@/components/app-shell/loading-state";
3
4
  import { useAI } from "../providers";
4
- import { CircleAlert, LoaderCircle } from "lucide-react";
5
+ import { CircleAlert } from "lucide-react";
5
6
  import type { ReactNode } from "react";
6
7
 
7
8
  export function AIConfigurationGate({ children }: { children: ReactNode }) {
@@ -17,6 +18,8 @@ export function AIConfigurationGate({ children }: { children: ReactNode }) {
17
18
  const loading = configurationStatus === "loading";
18
19
  const error = configurationError ?? modelConfigurationError;
19
20
 
21
+ if (loading) return <LoadingState className="min-h-80" />;
22
+
20
23
  return (
21
24
  <div className="space-y-6 pb-12">
22
25
  <section className="border-b pb-8">
@@ -33,18 +36,12 @@ export function AIConfigurationGate({ children }: { children: ReactNode }) {
33
36
  </p>
34
37
  </section>
35
38
 
36
- <Alert variant={loading ? "default" : "destructive"}>
37
- {loading ? <LoaderCircle className="animate-spin" /> : <CircleAlert />}
38
- <AlertTitle>
39
- {loading
40
- ? "Loading NocoBase AI configuration"
41
- : "NocoBase AI is not available"}
42
- </AlertTitle>
39
+ <Alert variant="destructive">
40
+ <CircleAlert />
41
+ <AlertTitle>NocoBase AI is not available</AlertTitle>
43
42
  <AlertDescription>
44
- {loading
45
- ? "Fetching the AI employees and enabled models available to the current user."
46
- : error?.message ??
47
- "Check the NocoBase connection and the AI employees available to the current user."}
43
+ {error?.message ??
44
+ "Check the NocoBase connection and the AI employees available to the current user."}
48
45
  </AlertDescription>
49
46
  </Alert>
50
47
  </div>
@@ -1,5 +1,6 @@
1
1
  import { NocoBaseAIExtensionProvider } from "./global-ai-chat";
2
2
  import type { AppExtension } from "@/app/extension";
3
+ import { LoadingState } from "@/components/app-shell/loading-state";
3
4
  import {
4
5
  Bot,
5
6
  MessageSquare,
@@ -37,13 +38,7 @@ const ToolCardsPage = lazy(() =>
37
38
 
38
39
  function LazyDemoRoute({ children }: { children: ReactNode }) {
39
40
  return (
40
- <Suspense
41
- fallback={
42
- <div className="flex min-h-[320px] items-center justify-center text-sm text-muted-foreground">
43
- Loading AI components…
44
- </div>
45
- }
46
- >
41
+ <Suspense fallback={<LoadingState className="min-h-[320px]" />}>
47
42
  {children}
48
43
  </Suspense>
49
44
  );
@@ -0,0 +1,53 @@
1
+ # NocoBase i18n
2
+
3
+ Optional frontend-owned internationalization for the NocoBase Admin Starter.
4
+
5
+ The Registry connects i18next to the application translation provider, persists
6
+ the selected locale through the Starter's shared NocoBase client, sends it as
7
+ `X-Locale`, and adds a reusable language switcher to the signed-in user menu.
8
+ Its integrated Demo route shows the complete configuration model without
9
+ requiring a second Registry item.
10
+
11
+ Starter and Registry UI resources remain frontend-owned. At startup the runtime
12
+ reads `enabledLanguages` from `systemSettings:get` and merges only registered
13
+ dynamic NocoBase namespaces from `app:getLang`; `lm-collections` is registered
14
+ by default so collection and field translations remain compatible.
15
+
16
+ Application-owned React translations belong in `src/locales`, outside the
17
+ installed Registry directory. Register them through the Starter-level
18
+ `registerTranslationResources` helper so the application remains buildable when
19
+ the optional i18n Registry is not installed. When this Registry is present, it
20
+ consumes both resources registered before startup and resources added later by
21
+ lazy features.
22
+
23
+ On NocoBase versions that support filtered language resources, the request uses
24
+ `app:getLang?ns=lm-collections,...`. Older servers ignore the query parameter;
25
+ the client still merges only registered namespaces, so the integration remains
26
+ backward compatible.
27
+
28
+ Other installed components can register their own namespace without changing
29
+ the runtime:
30
+
31
+ ```ts
32
+ import { registerLocaleResources } from "@/extensions/nocobase-i18n";
33
+
34
+ registerLocaleResources("my-extension", {
35
+ "en-US": { title: "Orders" },
36
+ "zh-CN": { title: "订单" },
37
+ });
38
+ ```
39
+
40
+ If a component relies on another server-generated namespace, opt in explicitly:
41
+
42
+ ```ts
43
+ import { registerServerResourceNamespace } from "@/extensions/nocobase-i18n";
44
+
45
+ registerServerResourceNamespace("my-dynamic-namespace");
46
+ ```
47
+
48
+ Namespaces registered after application startup are loaded incrementally, so
49
+ lazy Registry components do not need to participate in the initial request.
50
+
51
+ Use the application's `useTranslate` hook in React components. Existing exact
52
+ NocoBase-style expressions such as `{{t("Orders")}}` and expressions with a
53
+ string namespace are resolved through the Starter compatibility helper.
@@ -0,0 +1 @@
1
+ export * from "./language-switcher";
@@ -0,0 +1,138 @@
1
+ import { useGetLocale, useSetLocale, useTranslate } from "@refinedev/core";
2
+ import { Languages, Loader2 } from "lucide-react";
3
+ import { useState, type ReactNode } from "react";
4
+
5
+ import {
6
+ DropdownMenuRadioGroup,
7
+ DropdownMenuRadioItem,
8
+ DropdownMenuSeparator,
9
+ DropdownMenuSub,
10
+ DropdownMenuSubContent,
11
+ DropdownMenuSubTrigger,
12
+ } from "@/components/ui/dropdown-menu";
13
+ import {
14
+ Select,
15
+ SelectContent,
16
+ SelectItem,
17
+ SelectTrigger,
18
+ SelectValue,
19
+ } from "@/components/ui/select";
20
+ import { cn } from "@/lib/utils";
21
+ import { useEnabledLocales } from "../locale-store";
22
+
23
+ export type LanguageSwitcherProps = {
24
+ className?: string;
25
+ label?: ReactNode | false;
26
+ triggerClassName?: string;
27
+ };
28
+
29
+ export function LanguageSwitcher({
30
+ className,
31
+ label,
32
+ triggerClassName,
33
+ }: LanguageSwitcherProps) {
34
+ const translate = useTranslate();
35
+ const getLocale = useGetLocale();
36
+ const setLocale = useSetLocale();
37
+ const locales = useEnabledLocales();
38
+ const currentLocale = getLocale();
39
+ const [switching, setSwitching] = useState(false);
40
+ const [error, setError] = useState<string>();
41
+ const resolvedLabel =
42
+ typeof label === "undefined"
43
+ ? translate("language.label", { ns: "nocobase-i18n" }, "Language")
44
+ : label;
45
+
46
+ if (locales.length < 2) return null;
47
+
48
+ const currentDefinition =
49
+ locales.find(({ locale }) => locale === currentLocale) ?? locales[0];
50
+
51
+ return (
52
+ <div className={cn("space-y-2", className)}>
53
+ {resolvedLabel === false ? null : (
54
+ <p className="text-xs font-medium text-muted-foreground">
55
+ {resolvedLabel}
56
+ </p>
57
+ )}
58
+ <Select
59
+ value={currentLocale}
60
+ disabled={switching}
61
+ onValueChange={(value) => {
62
+ if (!value || value === currentLocale) return;
63
+ setSwitching(true);
64
+ setError(undefined);
65
+ Promise.resolve(setLocale(value)).catch((reason) => {
66
+ setError(
67
+ reason instanceof Error
68
+ ? reason.message
69
+ : translate(
70
+ "language.switchError",
71
+ { ns: "nocobase-i18n" },
72
+ "Unable to switch language."
73
+ )
74
+ );
75
+ setSwitching(false);
76
+ });
77
+ }}
78
+ >
79
+ <SelectTrigger
80
+ className={cn("w-full min-w-52", triggerClassName)}
81
+ aria-label={String(resolvedLabel || "Language")}
82
+ >
83
+ {switching ? <Loader2 className="animate-spin" /> : <Languages />}
84
+ <SelectValue>{currentDefinition?.label ?? currentLocale}</SelectValue>
85
+ </SelectTrigger>
86
+ <SelectContent>
87
+ {locales.map((definition) => (
88
+ <SelectItem key={definition.locale} value={definition.locale}>
89
+ {definition.label}
90
+ </SelectItem>
91
+ ))}
92
+ </SelectContent>
93
+ </Select>
94
+ {error ? <p className="text-xs text-destructive">{error}</p> : null}
95
+ </div>
96
+ );
97
+ }
98
+
99
+ export function LanguageUserMenuItems() {
100
+ const translate = useTranslate();
101
+ const getLocale = useGetLocale();
102
+ const setLocale = useSetLocale();
103
+ const locales = useEnabledLocales();
104
+ const currentLocale = getLocale();
105
+
106
+ if (locales.length < 2) return null;
107
+
108
+ return (
109
+ <>
110
+ <DropdownMenuSeparator />
111
+ <DropdownMenuSub>
112
+ <DropdownMenuSubTrigger className="min-h-9 gap-2 px-2 text-muted-foreground focus:text-foreground">
113
+ <Languages />
114
+ <span>
115
+ {translate("language.label", { ns: "nocobase-i18n" }, "Language")}
116
+ </span>
117
+ </DropdownMenuSubTrigger>
118
+ <DropdownMenuSubContent className="min-w-44">
119
+ <DropdownMenuRadioGroup
120
+ value={currentLocale}
121
+ onValueChange={(value) => {
122
+ if (value && value !== currentLocale) void setLocale(value);
123
+ }}
124
+ >
125
+ {locales.map((definition) => (
126
+ <DropdownMenuRadioItem
127
+ key={definition.locale}
128
+ value={definition.locale}
129
+ >
130
+ {definition.label}
131
+ </DropdownMenuRadioItem>
132
+ ))}
133
+ </DropdownMenuRadioGroup>
134
+ </DropdownMenuSubContent>
135
+ </DropdownMenuSub>
136
+ </>
137
+ );
138
+ }