@nocobase/portal-template-default 1.0.12 → 1.0.14
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/package.json +1 -1
- package/scripts/build-html.mjs +14 -0
- package/src/extensions/nocobase-auth-cas/README.md +6 -0
- package/src/extensions/nocobase-auth-cas/cas-sign-in-button.tsx +41 -0
- package/src/extensions/nocobase-auth-cas/demo.tsx +20 -0
- package/src/extensions/nocobase-auth-cas/extension.tsx +48 -0
- package/src/extensions/nocobase-auth-cas/index.ts +1 -0
- package/src/extensions/nocobase-auth-cas/use-cas-sign-in.ts +31 -0
- package/src/extensions/nocobase-auth-dingtalk/README.md +7 -0
- package/src/extensions/nocobase-auth-dingtalk/auto-login-provider.tsx +185 -0
- package/src/extensions/nocobase-auth-dingtalk/demo.tsx +23 -0
- package/src/extensions/nocobase-auth-dingtalk/dingtalk-sign-in-button.tsx +45 -0
- package/src/extensions/nocobase-auth-dingtalk/extension.tsx +53 -0
- package/src/extensions/nocobase-auth-dingtalk/index.ts +4 -0
- package/src/extensions/nocobase-auth-dingtalk/use-dingtalk-sign-in.ts +66 -0
- package/src/extensions/nocobase-auth-ldap/README.md +6 -0
- package/src/extensions/nocobase-auth-ldap/demo.tsx +21 -0
- package/src/extensions/nocobase-auth-ldap/extension.tsx +48 -0
- package/src/extensions/nocobase-auth-ldap/index.ts +1 -0
- package/src/extensions/nocobase-auth-ldap/ldap-sign-in-form.tsx +69 -0
- package/src/extensions/nocobase-auth-ldap/use-ldap-sign-in.ts +5 -0
- package/src/extensions/nocobase-auth-oidc/README.md +11 -0
- package/src/extensions/nocobase-auth-oidc/auto-redirect-provider.tsx +13 -0
- package/src/extensions/nocobase-auth-oidc/demo.tsx +20 -0
- package/src/extensions/nocobase-auth-oidc/extension.tsx +51 -0
- package/src/extensions/nocobase-auth-oidc/index.ts +1 -0
- package/src/extensions/nocobase-auth-oidc/oidc-sign-in-button.tsx +44 -0
- package/src/extensions/nocobase-auth-oidc/use-oidc-sign-in.ts +60 -0
- package/src/extensions/nocobase-auth-saml/README.md +6 -0
- package/src/extensions/nocobase-auth-saml/auto-redirect-provider.tsx +13 -0
- package/src/extensions/nocobase-auth-saml/demo.tsx +20 -0
- package/src/extensions/nocobase-auth-saml/extension.tsx +51 -0
- package/src/extensions/nocobase-auth-saml/index.ts +1 -0
- package/src/extensions/nocobase-auth-saml/saml-sign-in-button.tsx +43 -0
- package/src/extensions/nocobase-auth-saml/use-saml-sign-in.ts +53 -0
- package/src/extensions/nocobase-auth-sms/README.md +7 -0
- package/src/extensions/nocobase-auth-sms/demo.tsx +25 -0
- package/src/extensions/nocobase-auth-sms/extension.tsx +48 -0
- package/src/extensions/nocobase-auth-sms/index.ts +1 -0
- package/src/extensions/nocobase-auth-sms/sms-sign-in-form.tsx +107 -0
- package/src/extensions/nocobase-auth-sms/use-sms-sign-in.ts +88 -0
- package/src/extensions/nocobase-auth-wecom/README.md +6 -0
- package/src/extensions/nocobase-auth-wecom/auto-login-provider.tsx +21 -0
- package/src/extensions/nocobase-auth-wecom/demo.tsx +23 -0
- package/src/extensions/nocobase-auth-wecom/extension.tsx +51 -0
- package/src/extensions/nocobase-auth-wecom/index.ts +1 -0
- package/src/extensions/nocobase-auth-wecom/use-wecom-sign-in.ts +64 -0
- package/src/extensions/nocobase-auth-wecom/wecom-sign-in-button.tsx +70 -0
- package/src/extensions/nocobase-i18n/README.md +44 -0
- package/src/extensions/nocobase-i18n/components/index.ts +1 -0
- package/src/extensions/nocobase-i18n/components/language-switcher.tsx +138 -0
- package/src/extensions/nocobase-i18n/demo/index.tsx +209 -0
- package/src/extensions/nocobase-i18n/demo/prompt-generator.tsx +132 -0
- package/src/extensions/nocobase-i18n/extension.tsx +48 -0
- package/src/extensions/nocobase-i18n/index.ts +7 -0
- package/src/extensions/nocobase-i18n/locales/en-US.ts +39 -0
- package/src/extensions/nocobase-i18n/locales/index.ts +8 -0
- package/src/extensions/nocobase-i18n/locales/zh-CN.ts +35 -0
- package/src/extensions/nocobase-i18n/provider.tsx +44 -0
- package/src/extensions/nocobase-i18n/server-resources.ts +166 -0
- package/src/extensions/nocobase-i18n/tests/i18n-regression.mjs +51 -0
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
import { useCallback, useEffect, useMemo, useState } from "react";
|
|
2
|
+
|
|
3
|
+
import { useAuthenticatorSignIn } from "@/components/auth";
|
|
4
|
+
import type { Authenticator } from "@/components/auth/types";
|
|
5
|
+
import { nocobaseClient } from "@/lib/nocobase/client";
|
|
6
|
+
|
|
7
|
+
type SmsCodeResponse = {
|
|
8
|
+
expiresAt?: string;
|
|
9
|
+
};
|
|
10
|
+
|
|
11
|
+
export function useSmsSignIn(authenticator: Authenticator) {
|
|
12
|
+
const auth = useAuthenticatorSignIn(authenticator.name);
|
|
13
|
+
const [isSendingCode, setIsSendingCode] = useState(false);
|
|
14
|
+
const [sendError, setSendError] = useState<Error>();
|
|
15
|
+
const [expiresAt, setExpiresAt] = useState<number>();
|
|
16
|
+
const [now, setNow] = useState(Date.now());
|
|
17
|
+
const verifier =
|
|
18
|
+
typeof authenticator.options?.verifier === "string"
|
|
19
|
+
? authenticator.options.verifier
|
|
20
|
+
: "";
|
|
21
|
+
|
|
22
|
+
useEffect(() => {
|
|
23
|
+
if (!expiresAt || expiresAt <= Date.now()) return;
|
|
24
|
+
const timer = window.setInterval(() => setNow(Date.now()), 1_000);
|
|
25
|
+
return () => window.clearInterval(timer);
|
|
26
|
+
}, [expiresAt]);
|
|
27
|
+
|
|
28
|
+
const retryAfter = useMemo(
|
|
29
|
+
() =>
|
|
30
|
+
expiresAt ? Math.max(0, Math.ceil((expiresAt - now) / 1_000)) : 0,
|
|
31
|
+
[expiresAt, now]
|
|
32
|
+
);
|
|
33
|
+
|
|
34
|
+
const sendCode = useCallback(
|
|
35
|
+
async (phone: string) => {
|
|
36
|
+
if (!phone) throw new Error("Enter a phone number first.");
|
|
37
|
+
if (!verifier) {
|
|
38
|
+
throw new Error("The SMS verifier is not configured.");
|
|
39
|
+
}
|
|
40
|
+
if (retryAfter > 0) return;
|
|
41
|
+
|
|
42
|
+
setSendError(undefined);
|
|
43
|
+
setIsSendingCode(true);
|
|
44
|
+
try {
|
|
45
|
+
const result = await nocobaseClient.action<SmsCodeResponse>(
|
|
46
|
+
"smsOTP",
|
|
47
|
+
"publicCreate",
|
|
48
|
+
{
|
|
49
|
+
method: "POST",
|
|
50
|
+
authenticator: null,
|
|
51
|
+
includeRole: false,
|
|
52
|
+
withAclMeta: false,
|
|
53
|
+
body: {
|
|
54
|
+
action: "auth:signIn",
|
|
55
|
+
verifier,
|
|
56
|
+
uuid: phone,
|
|
57
|
+
},
|
|
58
|
+
}
|
|
59
|
+
);
|
|
60
|
+
const nextExpiry = result?.expiresAt
|
|
61
|
+
? Date.parse(result.expiresAt)
|
|
62
|
+
: Date.now() + 60_000;
|
|
63
|
+
setNow(Date.now());
|
|
64
|
+
setExpiresAt(nextExpiry);
|
|
65
|
+
} catch (cause) {
|
|
66
|
+
const error =
|
|
67
|
+
cause instanceof Error
|
|
68
|
+
? cause
|
|
69
|
+
: new Error("Unable to send the verification code.");
|
|
70
|
+
setSendError(error);
|
|
71
|
+
throw error;
|
|
72
|
+
} finally {
|
|
73
|
+
setIsSendingCode(false);
|
|
74
|
+
}
|
|
75
|
+
},
|
|
76
|
+
[retryAfter, verifier]
|
|
77
|
+
);
|
|
78
|
+
|
|
79
|
+
return {
|
|
80
|
+
sendCode,
|
|
81
|
+
signIn: (phone: string, code: string) =>
|
|
82
|
+
auth.signIn({ uuid: phone, code }),
|
|
83
|
+
isSendingCode,
|
|
84
|
+
isSigningIn: auth.isPending,
|
|
85
|
+
retryAfter,
|
|
86
|
+
error: sendError ?? auth.error,
|
|
87
|
+
};
|
|
88
|
+
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import type { PropsWithChildren } from "react";
|
|
2
|
+
|
|
3
|
+
import { AuthAutoRedirectProvider } from "@/components/auth";
|
|
4
|
+
|
|
5
|
+
import { isWecomBrowser } from "./use-wecom-sign-in";
|
|
6
|
+
|
|
7
|
+
export default function WecomAutoLoginProvider({
|
|
8
|
+
children,
|
|
9
|
+
}: PropsWithChildren) {
|
|
10
|
+
const insideWecom = isWecomBrowser();
|
|
11
|
+
return (
|
|
12
|
+
<AuthAutoRedirectProvider
|
|
13
|
+
resource="wecom"
|
|
14
|
+
action="checkLogin"
|
|
15
|
+
enabled={insideWecom}
|
|
16
|
+
query={{ isWeComBrowser: insideWecom }}
|
|
17
|
+
>
|
|
18
|
+
{children}
|
|
19
|
+
</AuthAutoRedirectProvider>
|
|
20
|
+
);
|
|
21
|
+
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import { AuthMethodDemo } from "@/components/auth/demo";
|
|
2
|
+
import WecomSignInButton from "./wecom-sign-in-button";
|
|
3
|
+
|
|
4
|
+
const authenticator = {
|
|
5
|
+
name: "company-wecom",
|
|
6
|
+
authType: "wecom",
|
|
7
|
+
title: "Sign in via WeCom",
|
|
8
|
+
options: {
|
|
9
|
+
btnTooltip: "Use the account associated with your company directory.",
|
|
10
|
+
},
|
|
11
|
+
};
|
|
12
|
+
|
|
13
|
+
export function WecomAuthDemoPage() {
|
|
14
|
+
return (
|
|
15
|
+
<AuthMethodDemo
|
|
16
|
+
authType="wecom"
|
|
17
|
+
methodName="WeCom"
|
|
18
|
+
description="Provide a WeCom sign-in button and support automatic login when the application is opened inside WeCom."
|
|
19
|
+
>
|
|
20
|
+
<WecomSignInButton authenticator={authenticator} onSignIn={() => {}} />
|
|
21
|
+
</AuthMethodDemo>
|
|
22
|
+
);
|
|
23
|
+
}
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
import { lazy } from "react";
|
|
2
|
+
import { Building2 } from "lucide-react";
|
|
3
|
+
import { Route } from "react-router";
|
|
4
|
+
|
|
5
|
+
import type { AppExtension } from "@/app/extension";
|
|
6
|
+
import { AuthDemoRoute } from "@/components/auth/demo";
|
|
7
|
+
|
|
8
|
+
const WecomSignInButton = lazy(() => import("./wecom-sign-in-button"));
|
|
9
|
+
const WecomAutoLoginProvider = lazy(() => import("./auto-login-provider"));
|
|
10
|
+
const WecomAuthDemoPage = lazy(() =>
|
|
11
|
+
import("./demo").then((module) => ({ default: module.WecomAuthDemoPage }))
|
|
12
|
+
);
|
|
13
|
+
|
|
14
|
+
const wecomAuthExtension: AppExtension = {
|
|
15
|
+
id: "nocobase-auth-wecom",
|
|
16
|
+
AuthRuntimeProvider: WecomAutoLoginProvider,
|
|
17
|
+
authRuntimePriority: 10,
|
|
18
|
+
dev: {
|
|
19
|
+
resources: [
|
|
20
|
+
{
|
|
21
|
+
name: "auth-wecom-demo",
|
|
22
|
+
list: "auth/wecom",
|
|
23
|
+
meta: {
|
|
24
|
+
parent: "auth-components",
|
|
25
|
+
label: "WeCom",
|
|
26
|
+
icon: <Building2 />,
|
|
27
|
+
acl: { type: "authenticated" },
|
|
28
|
+
},
|
|
29
|
+
},
|
|
30
|
+
],
|
|
31
|
+
routes: (
|
|
32
|
+
<Route
|
|
33
|
+
path="auth/wecom"
|
|
34
|
+
element={
|
|
35
|
+
<AuthDemoRoute>
|
|
36
|
+
<WecomAuthDemoPage />
|
|
37
|
+
</AuthDemoRoute>
|
|
38
|
+
}
|
|
39
|
+
/>
|
|
40
|
+
),
|
|
41
|
+
},
|
|
42
|
+
authAdapters: [
|
|
43
|
+
{
|
|
44
|
+
authType: "wecom",
|
|
45
|
+
placement: "button",
|
|
46
|
+
Component: WecomSignInButton,
|
|
47
|
+
},
|
|
48
|
+
],
|
|
49
|
+
};
|
|
50
|
+
|
|
51
|
+
export default wecomAuthExtension;
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export { isWecomBrowser, useWecomSignIn } from "./use-wecom-sign-in";
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
import { useCallback, useMemo, useState } from "react";
|
|
2
|
+
import { useSearchParams } from "react-router";
|
|
3
|
+
|
|
4
|
+
import type { Authenticator } from "@/components/auth/types";
|
|
5
|
+
import { nocobaseClient } from "@/lib/nocobase/client";
|
|
6
|
+
import { resolvePortalUrl } from "@/providers/runtime-config";
|
|
7
|
+
|
|
8
|
+
type WecomAuthUrlResponse = {
|
|
9
|
+
url?: string;
|
|
10
|
+
};
|
|
11
|
+
|
|
12
|
+
export function isWecomBrowser() {
|
|
13
|
+
return (
|
|
14
|
+
typeof navigator !== "undefined" &&
|
|
15
|
+
navigator.userAgent.toLowerCase().includes("wxwork")
|
|
16
|
+
);
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export function useWecomSignIn(authenticator: Authenticator) {
|
|
20
|
+
const [searchParams] = useSearchParams();
|
|
21
|
+
const [isPending, setIsPending] = useState(false);
|
|
22
|
+
const [error, setError] = useState<Error>();
|
|
23
|
+
const callbackError = useMemo(() => {
|
|
24
|
+
if (searchParams.get("authenticator") !== authenticator.name) {
|
|
25
|
+
return undefined;
|
|
26
|
+
}
|
|
27
|
+
const message = searchParams.get("errorMsg");
|
|
28
|
+
return message ? new Error(message) : undefined;
|
|
29
|
+
}, [authenticator.name, searchParams]);
|
|
30
|
+
|
|
31
|
+
const signIn = useCallback(async () => {
|
|
32
|
+
setError(undefined);
|
|
33
|
+
setIsPending(true);
|
|
34
|
+
try {
|
|
35
|
+
const redirect = resolvePortalUrl(
|
|
36
|
+
searchParams.get("to") || searchParams.get("redirect") || "/"
|
|
37
|
+
);
|
|
38
|
+
const result = await nocobaseClient.action<WecomAuthUrlResponse>(
|
|
39
|
+
"wecom",
|
|
40
|
+
"getAuthUrl",
|
|
41
|
+
{
|
|
42
|
+
method: "POST",
|
|
43
|
+
authenticator: authenticator.name,
|
|
44
|
+
includeRole: false,
|
|
45
|
+
withAclMeta: false,
|
|
46
|
+
body: { redirect, isWeComBrowser: isWecomBrowser() },
|
|
47
|
+
}
|
|
48
|
+
);
|
|
49
|
+
if (!result?.url) {
|
|
50
|
+
throw new Error("NocoBase did not return a WeCom authorization URL.");
|
|
51
|
+
}
|
|
52
|
+
window.location.replace(nocobaseClient.resolveUrl(result.url));
|
|
53
|
+
} catch (cause) {
|
|
54
|
+
setError(
|
|
55
|
+
cause instanceof Error
|
|
56
|
+
? cause
|
|
57
|
+
: new Error("Unable to start WeCom sign-in.")
|
|
58
|
+
);
|
|
59
|
+
setIsPending(false);
|
|
60
|
+
}
|
|
61
|
+
}, [authenticator.name, searchParams]);
|
|
62
|
+
|
|
63
|
+
return { signIn, isPending, error: error ?? callbackError };
|
|
64
|
+
}
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
import { Building2 } from "lucide-react";
|
|
2
|
+
|
|
3
|
+
import type { AuthenticatorComponentProps } from "@/components/auth/types";
|
|
4
|
+
import {
|
|
5
|
+
Alert,
|
|
6
|
+
AlertDescription,
|
|
7
|
+
AlertTitle,
|
|
8
|
+
} from "@/components/ui/alert";
|
|
9
|
+
import { Button } from "@/components/ui/button";
|
|
10
|
+
import {
|
|
11
|
+
Tooltip,
|
|
12
|
+
TooltipContent,
|
|
13
|
+
TooltipTrigger,
|
|
14
|
+
} from "@/components/ui/tooltip";
|
|
15
|
+
import { resolveTranslatableText } from "@/lib/i18n";
|
|
16
|
+
|
|
17
|
+
import { useWecomSignIn } from "./use-wecom-sign-in";
|
|
18
|
+
|
|
19
|
+
function plainText(value: unknown) {
|
|
20
|
+
return typeof value === "string"
|
|
21
|
+
? value.replace(/<[^>]+>/g, " ").replace(/\s+/g, " ").trim()
|
|
22
|
+
: "";
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export default function WecomSignInButton({
|
|
26
|
+
authenticator,
|
|
27
|
+
onSignIn,
|
|
28
|
+
}: AuthenticatorComponentProps & { onSignIn?: () => void }) {
|
|
29
|
+
const { signIn, isPending, error } = useWecomSignIn(authenticator);
|
|
30
|
+
const tooltip = plainText(authenticator.options?.btnTooltip);
|
|
31
|
+
const button = (
|
|
32
|
+
<Button
|
|
33
|
+
type="button"
|
|
34
|
+
variant="outline"
|
|
35
|
+
className="w-full"
|
|
36
|
+
disabled={!onSignIn && isPending}
|
|
37
|
+
onClick={onSignIn ?? signIn}
|
|
38
|
+
>
|
|
39
|
+
<Building2 />
|
|
40
|
+
{!onSignIn && isPending
|
|
41
|
+
? "Redirecting…"
|
|
42
|
+
: resolveTranslatableText(
|
|
43
|
+
authenticator.title ||
|
|
44
|
+
authenticator.authTypeTitle ||
|
|
45
|
+
"Sign in via WeCom"
|
|
46
|
+
)}
|
|
47
|
+
</Button>
|
|
48
|
+
);
|
|
49
|
+
|
|
50
|
+
return (
|
|
51
|
+
<div className="space-y-3">
|
|
52
|
+
{error && (
|
|
53
|
+
<Alert variant="destructive">
|
|
54
|
+
<AlertTitle>WeCom sign-in failed</AlertTitle>
|
|
55
|
+
<AlertDescription>{error.message}</AlertDescription>
|
|
56
|
+
</Alert>
|
|
57
|
+
)}
|
|
58
|
+
{tooltip ? (
|
|
59
|
+
<Tooltip>
|
|
60
|
+
<TooltipTrigger render={button} />
|
|
61
|
+
<TooltipContent side="right" className="max-w-xs">
|
|
62
|
+
{tooltip}
|
|
63
|
+
</TooltipContent>
|
|
64
|
+
</Tooltip>
|
|
65
|
+
) : (
|
|
66
|
+
button
|
|
67
|
+
)}
|
|
68
|
+
</div>
|
|
69
|
+
);
|
|
70
|
+
}
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
# NocoBase i18n
|
|
2
|
+
|
|
3
|
+
Optional NocoBase server-language integration and language controls for the
|
|
4
|
+
NocoBase Admin Starter.
|
|
5
|
+
|
|
6
|
+
The Starter owns the Refine i18n provider, local i18next runtime, application
|
|
7
|
+
resources, locale selection, and the shared `systemSettings:get` bootstrap.
|
|
8
|
+
This Registry reuses those foundations and adds only the NocoBase-specific
|
|
9
|
+
remote capabilities:
|
|
10
|
+
|
|
11
|
+
- load registered dynamic namespaces from `app:getLang`;
|
|
12
|
+
- persist a signed-in user's selected language through `users:updateLang`;
|
|
13
|
+
- expose reusable page and user-menu language switchers;
|
|
14
|
+
- provide an integrated Demo and Prompt generator.
|
|
15
|
+
|
|
16
|
+
When this Registry is not installed, the Starter still uses the system default
|
|
17
|
+
language and all local translations normally. It does not request
|
|
18
|
+
`app:getLang`. When installed, the Registry reads the already-cached system
|
|
19
|
+
settings and requests only registered server namespaces. `lm-collections` is
|
|
20
|
+
registered by default for collection and field metadata.
|
|
21
|
+
|
|
22
|
+
Application-owned React translations belong in `src/locales`, outside the
|
|
23
|
+
installed Registry directory:
|
|
24
|
+
|
|
25
|
+
```ts
|
|
26
|
+
import { registerLocaleResources } from "@/providers/i18n";
|
|
27
|
+
|
|
28
|
+
registerLocaleResources("my-feature", {
|
|
29
|
+
"en-US": { title: "Orders" },
|
|
30
|
+
"zh-CN": { title: "订单" },
|
|
31
|
+
});
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
Other installed components can opt into another server-generated namespace:
|
|
35
|
+
|
|
36
|
+
```ts
|
|
37
|
+
import { registerServerResourceNamespace } from "@/extensions/nocobase-i18n";
|
|
38
|
+
|
|
39
|
+
registerServerResourceNamespace("my-dynamic-namespace");
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
Namespaces registered after startup are loaded incrementally. Existing exact
|
|
43
|
+
NocoBase expressions such as `{{t("Orders")}}` remain supported by the
|
|
44
|
+
Starter's translation 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 "@/providers/i18n";
|
|
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
|
+
}
|