@qlover/create-app 0.7.8 → 0.7.10

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 (89) hide show
  1. package/CHANGELOG.md +140 -0
  2. package/dist/index.cjs +3 -3
  3. package/dist/index.js +3 -3
  4. package/dist/templates/next-app/.env.template +8 -4
  5. package/dist/templates/next-app/config/IOCIdentifier.ts +4 -1
  6. package/dist/templates/next-app/config/Identifier/api.ts +20 -0
  7. package/dist/templates/next-app/config/Identifier/index.ts +2 -0
  8. package/dist/templates/next-app/config/Identifier/page.home.ts +7 -0
  9. package/dist/templates/next-app/config/Identifier/page.login.ts +2 -2
  10. package/dist/templates/next-app/config/Identifier/page.register.ts +43 -22
  11. package/dist/templates/next-app/config/Identifier/validator.ts +34 -0
  12. package/dist/templates/next-app/config/i18n/HomeI18n .ts +22 -0
  13. package/dist/templates/next-app/config/i18n/index.ts +1 -0
  14. package/dist/templates/next-app/config/i18n/register18n.ts +44 -0
  15. package/dist/templates/next-app/config/theme.ts +1 -0
  16. package/dist/templates/next-app/migrations/schema/UserSchema.ts +13 -0
  17. package/dist/templates/next-app/migrations/sql/1694244000000.sql +10 -0
  18. package/dist/templates/next-app/package.json +15 -6
  19. package/dist/templates/next-app/public/locales/en.json +17 -2
  20. package/dist/templates/next-app/public/locales/zh.json +17 -2
  21. package/dist/templates/next-app/src/app/[locale]/admin/layout.tsx +21 -0
  22. package/dist/templates/next-app/src/app/[locale]/admin/page.tsx +10 -0
  23. package/dist/templates/next-app/src/app/[locale]/layout.tsx +1 -7
  24. package/dist/templates/next-app/src/app/[locale]/login/LoginForm.tsx +28 -16
  25. package/dist/templates/next-app/src/app/[locale]/login/page.tsx +10 -17
  26. package/dist/templates/next-app/src/app/[locale]/page.tsx +94 -102
  27. package/dist/templates/next-app/src/app/[locale]/register/RegisterForm.tsx +176 -0
  28. package/dist/templates/next-app/src/app/[locale]/register/page.tsx +79 -0
  29. package/dist/templates/next-app/src/app/api/user/login/route.ts +50 -0
  30. package/dist/templates/next-app/src/app/api/user/logout/route.ts +27 -0
  31. package/dist/templates/next-app/src/app/api/user/register/route.ts +50 -0
  32. package/dist/templates/next-app/src/base/cases/AppConfig.ts +19 -0
  33. package/dist/templates/next-app/src/base/cases/DialogErrorPlugin.ts +35 -0
  34. package/dist/templates/next-app/src/base/cases/RequestEncryptPlugin.ts +70 -0
  35. package/dist/templates/next-app/src/base/cases/RouterService.ts +4 -0
  36. package/dist/templates/next-app/src/base/cases/StringEncryptor.ts +67 -0
  37. package/dist/templates/next-app/src/base/cases/UserServiceApi.ts +48 -0
  38. package/dist/templates/next-app/src/base/port/AppApiInterface.ts +14 -0
  39. package/dist/templates/next-app/src/base/port/AppUserApiInterface.ts +15 -0
  40. package/dist/templates/next-app/src/base/port/DBBridgeInterface.ts +18 -0
  41. package/dist/templates/next-app/src/base/port/DBMigrationInterface.ts +92 -0
  42. package/dist/templates/next-app/src/base/port/DBTableInterface.ts +3 -0
  43. package/dist/templates/next-app/src/base/port/I18nServiceInterface.ts +3 -2
  44. package/dist/templates/next-app/src/base/port/MigrationApiInterface.ts +3 -0
  45. package/dist/templates/next-app/src/base/port/ServerApiResponseInterface.ts +6 -0
  46. package/dist/templates/next-app/src/base/port/UserServiceInterface.ts +3 -2
  47. package/dist/templates/next-app/src/base/services/I18nService.ts +9 -45
  48. package/dist/templates/next-app/src/base/services/UserService.ts +9 -8
  49. package/dist/templates/next-app/src/base/services/appApi/AppApiPlugin.ts +63 -0
  50. package/dist/templates/next-app/src/base/services/appApi/AppUserApi.ts +72 -0
  51. package/dist/templates/next-app/src/base/services/appApi/AppUserApiBootstrap.ts +48 -0
  52. package/dist/templates/next-app/src/base/services/appApi/AppUserType.ts +51 -0
  53. package/dist/templates/next-app/src/base/services/migrations/MigrationsApi.ts +43 -0
  54. package/dist/templates/next-app/src/core/bootstraps/BootstrapServer.ts +30 -5
  55. package/dist/templates/next-app/src/core/bootstraps/BootstrapsRegistry.ts +4 -3
  56. package/dist/templates/next-app/src/server/AppErrorApi.ts +10 -0
  57. package/dist/templates/next-app/src/server/AppSuccessApi.ts +7 -0
  58. package/dist/templates/next-app/src/server/PasswordEncrypt.ts +12 -0
  59. package/dist/templates/next-app/src/server/ServerAuth.ts +50 -0
  60. package/dist/templates/next-app/src/server/SupabaseBridge.ts +124 -0
  61. package/dist/templates/next-app/src/server/UserCredentialToken.ts +49 -0
  62. package/dist/templates/next-app/src/server/port/CrentialTokenInterface.ts +5 -0
  63. package/dist/templates/next-app/src/server/port/ServerInterface.ts +22 -0
  64. package/dist/templates/next-app/src/server/port/UserAuthInterface.ts +9 -0
  65. package/dist/templates/next-app/src/server/port/UserRepositoryInterface.ts +15 -0
  66. package/dist/templates/next-app/src/server/port/UserServiceInterface.ts +8 -0
  67. package/dist/templates/next-app/src/server/port/ValidatorInterface.ts +23 -0
  68. package/dist/templates/next-app/src/server/repositorys/UserRepository.ts +63 -0
  69. package/dist/templates/next-app/src/server/services/UserService.ts +105 -0
  70. package/dist/templates/next-app/src/server/validators/LoginValidator.ts +79 -0
  71. package/dist/templates/next-app/src/styles/css/antd-themes/_default.css +12 -0
  72. package/dist/templates/next-app/src/styles/css/antd-themes/dark.css +26 -0
  73. package/dist/templates/next-app/src/styles/css/antd-themes/pink.css +16 -0
  74. package/dist/templates/next-app/src/styles/css/page.css +4 -3
  75. package/dist/templates/next-app/src/styles/css/themes/_default.css +1 -0
  76. package/dist/templates/next-app/src/styles/css/themes/dark.css +1 -0
  77. package/dist/templates/next-app/src/styles/css/themes/pink.css +1 -0
  78. package/dist/templates/next-app/src/uikit/components/BaseHeader.tsx +6 -11
  79. package/dist/templates/next-app/src/uikit/components/BaseLayout.tsx +27 -0
  80. package/dist/templates/next-app/src/uikit/components/BootstrapsProvider.tsx +8 -1
  81. package/dist/templates/next-app/src/uikit/components/LanguageSwitcher.tsx +49 -21
  82. package/dist/templates/next-app/src/uikit/components/LogoutButton.tsx +20 -10
  83. package/dist/templates/next-app/src/uikit/components/ThemeSwitcher.tsx +92 -48
  84. package/dist/templates/next-app/tsconfig.json +3 -1
  85. package/package.json +2 -2
  86. package/dist/templates/next-app/src/base/cases/ServerAuth.ts +0 -17
  87. package/dist/templates/next-app/src/base/port/ServerAuthInterface.ts +0 -3
  88. package/dist/templates/next-app/src/base/port/ServerInterface.ts +0 -12
  89. /package/dist/templates/next-app/src/{app/[locale]/login → uikit/components}/FeatureItem.tsx +0 -0
@@ -0,0 +1,105 @@
1
+ import { inject, injectable } from 'inversify';
2
+ import { isEmpty, last, omit } from 'lodash';
3
+ import {
4
+ API_USER_NOT_FOUND,
5
+ API_USER_ALREADY_EXISTS
6
+ } from '@config/Identifier/api';
7
+ import { PasswordEncrypt } from '../PasswordEncrypt';
8
+ import { UserRepository } from '../repositorys/UserRepository';
9
+ import { ServerAuth } from '../ServerAuth';
10
+ import {
11
+ UserCredentialToken,
12
+ type UserCredentialTokenValue
13
+ } from '../UserCredentialToken';
14
+ import type { CrentialTokenInterface } from '../port/CrentialTokenInterface';
15
+ import type { UserAuthInterface } from '../port/UserAuthInterface';
16
+ import type { UserRepositoryInterface } from '../port/UserRepositoryInterface';
17
+ import type { UserServiceInterface } from '../port/UserServiceInterface';
18
+ import type { UserSchema } from '@migrations/schema/UserSchema';
19
+ import type { Encryptor } from '@qlover/fe-corekit';
20
+
21
+ @injectable()
22
+ export class UserService implements UserServiceInterface {
23
+ constructor(
24
+ @inject(UserRepository)
25
+ protected userRepository: UserRepositoryInterface,
26
+ @inject(ServerAuth)
27
+ protected userAuth: UserAuthInterface,
28
+ @inject(PasswordEncrypt)
29
+ protected encryptor: Encryptor<string, string>,
30
+ @inject(UserCredentialToken)
31
+ protected credentialToken: CrentialTokenInterface<UserCredentialTokenValue>
32
+ ) {}
33
+
34
+ async register(params: {
35
+ email: string;
36
+ password: string;
37
+ }): Promise<UserSchema> {
38
+ const user = await this.userRepository.getUserByEmail(params.email);
39
+
40
+ if (!isEmpty(user)) {
41
+ throw new Error(API_USER_ALREADY_EXISTS);
42
+ }
43
+
44
+ const result = await this.userRepository.add({
45
+ email: params.email,
46
+ password: this.encryptor.encrypt(params.password)
47
+ });
48
+
49
+ const target = last(result);
50
+ if (!target) {
51
+ throw new Error(API_USER_NOT_FOUND);
52
+ }
53
+
54
+ return omit(target, 'password') as UserSchema;
55
+ }
56
+
57
+ async login(params: {
58
+ email: string;
59
+ password: string;
60
+ }): Promise<UserSchema> {
61
+ const user = await this.userRepository.getUserByEmail(params.email);
62
+
63
+ if (!user) {
64
+ throw new Error(API_USER_NOT_FOUND);
65
+ }
66
+
67
+ const encryptedPassword = this.encryptor.encrypt(params.password);
68
+
69
+ if (encryptedPassword !== user.password) {
70
+ throw new Error(API_USER_NOT_FOUND);
71
+ }
72
+
73
+ const credentialToken = await this.credentialToken.generateToken(user);
74
+
75
+ await this.userRepository.updateById(user.id, {
76
+ credential_token: credentialToken
77
+ });
78
+
79
+ return Object.assign(omit(user, 'password') as UserSchema, {
80
+ credential_token: credentialToken
81
+ });
82
+ }
83
+
84
+ async logout(): Promise<void> {
85
+ const auth = await this.userAuth.getAuth();
86
+
87
+ if (!auth) {
88
+ return;
89
+ }
90
+
91
+ try {
92
+ const user = await this.credentialToken.parseToken(auth);
93
+
94
+ console.log('user', user);
95
+
96
+ await this.userRepository.updateById(user.id, {
97
+ credential_token: ''
98
+ });
99
+ } catch {
100
+ return;
101
+ }
102
+
103
+ await this.userAuth.clear();
104
+ }
105
+ }
@@ -0,0 +1,79 @@
1
+ import { ExecutorError } from '@qlover/fe-corekit';
2
+ import { z } from 'zod';
3
+ import {
4
+ V_LOGIN_PARAMS_REQUIRED,
5
+ V_EMAIL_INVALID,
6
+ V_PASSWORD_MIN_LENGTH,
7
+ V_PASSWORD_MAX_LENGTH,
8
+ V_PASSWORD_SPECIAL_CHARS
9
+ } from '@config/Identifier/validator';
10
+ import type {
11
+ ValidatorInterface,
12
+ ValidationFaildResult
13
+ } from '../port/ValidatorInterface';
14
+
15
+ export interface LoginValidatorData {
16
+ email: string;
17
+ password: string;
18
+ }
19
+
20
+ const emailSchema = z.email({ error: V_EMAIL_INVALID });
21
+
22
+ const passwordSchema = z
23
+ .string()
24
+ .min(6, { error: V_PASSWORD_MIN_LENGTH })
25
+ .max(50, { error: V_PASSWORD_MAX_LENGTH })
26
+ .regex(/^\S+$/, { error: V_PASSWORD_SPECIAL_CHARS });
27
+
28
+ interface ExtendedExecutorError extends ExecutorError {
29
+ issues?: ValidationFaildResult[];
30
+ }
31
+
32
+ export class LoginValidator implements ValidatorInterface {
33
+ validateEmail(data: unknown): void | ValidationFaildResult {
34
+ const emailResult = emailSchema.safeParse(data);
35
+ if (!emailResult.success) {
36
+ return emailResult.error.issues[0];
37
+ }
38
+ }
39
+
40
+ validatePassword(data: unknown): void | ValidationFaildResult {
41
+ const passwordResult = passwordSchema.safeParse(data);
42
+ if (!passwordResult.success) {
43
+ return passwordResult.error.issues[0];
44
+ }
45
+ }
46
+
47
+ validate(data: unknown): void | ValidationFaildResult {
48
+ if (typeof data !== 'object' || data === null) {
49
+ return {
50
+ path: ['form'],
51
+ message: V_LOGIN_PARAMS_REQUIRED
52
+ };
53
+ }
54
+
55
+ const { email, password } = data as Record<string, unknown>;
56
+
57
+ let validateResult = this.validateEmail(email);
58
+ if (validateResult != null) {
59
+ return validateResult;
60
+ }
61
+
62
+ validateResult = this.validatePassword(password);
63
+ if (validateResult != null) {
64
+ return validateResult;
65
+ }
66
+ }
67
+
68
+ getThrow(data: unknown): LoginValidatorData {
69
+ const result = this.validate(data);
70
+
71
+ if (result == null) {
72
+ return data as LoginValidatorData;
73
+ }
74
+
75
+ const error: ExtendedExecutorError = new ExecutorError(result.message);
76
+ error.issues = [result];
77
+ throw error;
78
+ }
79
+ }
@@ -236,4 +236,16 @@ html,
236
236
  --fe-message-info-color: var(--fe-color-primary);
237
237
  --fe-message-loading-color: var(--fe-color-primary);
238
238
  }
239
+
240
+ .ant-dropdown-css-var {
241
+ /* Control Variables - Default Theme */
242
+ --fe-control-outline-width: 2px;
243
+ --fe-control-interactive-size: 16px;
244
+ --fe-control-item-bg-hover: rgba(0, 0, 0, 0.04);
245
+ --fe-control-item-bg-active: #e6f4ff;
246
+ --fe-control-item-bg-active-hover: #bae0ff;
247
+ --fe-control-item-bg-active-disabled: rgba(0, 0, 0, 0.15);
248
+ --fe-control-tmp-outline: rgba(0, 0, 0, 0.02);
249
+ --fe-control-outline: rgba(96, 165, 250, 0.1); /* blue-400 with 0.1 opacity */
250
+ }
239
251
  }
@@ -175,4 +175,30 @@
175
175
  0.85
176
176
  ); /* 确保文字在深色背景上清晰可见 */
177
177
  }
178
+ .ant-dropdown-css-var {
179
+ /* Control Variables - Dark Theme */
180
+ --fe-control-outline-width: 2px;
181
+ --fe-control-interactive-size: 16px;
182
+ --fe-control-item-bg-hover: rgba(255, 255, 255, 0.08);
183
+ --fe-control-item-bg-active: rgba(
184
+ 96,
185
+ 165,
186
+ 250,
187
+ 0.2
188
+ ); /* blue-400 with 0.2 opacity */
189
+ --fe-control-item-bg-active-hover: rgba(
190
+ 96,
191
+ 165,
192
+ 250,
193
+ 0.3
194
+ ); /* blue-400 with 0.3 opacity */
195
+ --fe-control-item-bg-active-disabled: rgba(255, 255, 255, 0.15);
196
+ --fe-control-tmp-outline: rgba(255, 255, 255, 0.02);
197
+ --fe-control-outline: rgba(
198
+ 96,
199
+ 165,
200
+ 250,
201
+ 0.1
202
+ ); /* blue-400 with 0.1 opacity */
203
+ }
178
204
  }
@@ -201,4 +201,20 @@
201
201
  0 9px 28px 8px rgba(244, 114, 182, 0.05);
202
202
  --fe-modal-mask-bg: rgba(244, 114, 182, 0.45);
203
203
  }
204
+ .ant-dropdown-css-var {
205
+ /* Control Variables - Pink Theme */
206
+ --fe-control-outline-width: 2px;
207
+ --fe-control-interactive-size: 16px;
208
+ --fe-control-item-bg-hover: rgba(0, 0, 0, 0.04);
209
+ --fe-control-item-bg-active: #fce7f3; /* pink-100 */
210
+ --fe-control-item-bg-active-hover: #fbcfe8; /* pink-200 */
211
+ --fe-control-item-bg-active-disabled: rgba(0, 0, 0, 0.15);
212
+ --fe-control-tmp-outline: rgba(0, 0, 0, 0.02);
213
+ --fe-control-outline: rgba(
214
+ 244,
215
+ 114,
216
+ 182,
217
+ 0.1
218
+ ); /* pink-400 with 0.1 opacity */
219
+ }
204
220
  }
@@ -11,9 +11,10 @@
11
11
  --color-secondary: rgba(var(--color-bg-secondary));
12
12
  --color-elevated: rgba(var(--color-bg-elevated));
13
13
  --color-text: rgba(var(--text-primary));
14
+ --color-text-hover: rgba(var(--text-primary-hover));
14
15
  --color-text-secondary: rgba(var(--text-secondary));
15
16
  --color-text-tertiary: rgba(var(--text-tertiary));
16
- --color-border: rgba(var(--color-border));
17
- --color-brand: rgba(var(--color-brand));
18
- --color-brand-hover: rgba(var(--color-brand-hover));
17
+ --color-c-border: rgba(var(--color-border));
18
+ --color-c-brand: rgba(var(--color-brand));
19
+ --color-c-brand-hover: rgba(var(--color-brand-hover));
19
20
  }
@@ -8,6 +8,7 @@
8
8
 
9
9
  /* 文字颜色 */
10
10
  --text-primary: 15 23 42; /* slate-900 */
11
+ --text-primary-hover: 100 116 139; /* slate-500 */
11
12
  --text-secondary: 71 85 105; /* slate-600 */
12
13
  --text-tertiary: 100 116 139; /* slate-500 */
13
14
 
@@ -8,6 +8,7 @@
8
8
 
9
9
  /* 文字颜色 */
10
10
  --text-primary: 255 255 255;
11
+ --text-primary-hover: 148 163 184; /* slate-400 */
11
12
  --text-secondary: 148 163 184; /* slate-400 */
12
13
  --text-tertiary: 100 116 139; /* slate-500 */
13
14
 
@@ -8,6 +8,7 @@
8
8
 
9
9
  /* 文字颜色 */
10
10
  --text-primary: 190 18 60; /* rose-700 */
11
+ --text-primary-hover: 244 63 94; /* rose-500 */
11
12
  --text-secondary: 225 29 72; /* rose-600 */
12
13
  --text-tertiary: 244 63 94; /* rose-500 */
13
14
 
@@ -10,10 +10,12 @@ import { ThemeSwitcher } from './ThemeSwitcher';
10
10
  export function BaseHeader(props: { showLogoutButton?: boolean }) {
11
11
  const { showLogoutButton } = props;
12
12
  const appConfig = useIOC(IOCIdentifier.AppConfig);
13
+ const i18nService = useIOC(IOCIdentifier.I18nServiceInterface);
14
+
13
15
  return (
14
16
  <header
15
- data-testid="base-header"
16
- className="h-14 bg-secondary border-b border-border sticky top-0 z-50"
17
+ data-testid="BaseHeader"
18
+ className="h-14 bg-secondary border-b border-c-border sticky top-0 z-50"
17
19
  >
18
20
  <div className="flex items-center justify-between h-full px-4 mx-auto max-w-7xl">
19
21
  <div className="flex items-center">
@@ -21,12 +23,6 @@ export function BaseHeader(props: { showLogoutButton?: boolean }) {
21
23
  href="/"
22
24
  className="flex items-center hover:opacity-80 transition-opacity"
23
25
  >
24
- {/* <img
25
- data-testid="base-header-logo"
26
- src={IOC(PublicAssetsPath).getPath('/logo.svg')}
27
- alt="logo"
28
- className="h-8 w-auto"
29
- /> */}
30
26
  <span
31
27
  data-testid="base-header-app-name"
32
28
  className="ml-2 text-lg font-semibold text-text"
@@ -35,10 +31,9 @@ export function BaseHeader(props: { showLogoutButton?: boolean }) {
35
31
  </span>
36
32
  </Link>
37
33
  </div>
38
- <div className="flex items-center gap-4">
39
- <LanguageSwitcher />
34
+ <div className="flex items-center gap-2 md:gap-4">
35
+ <LanguageSwitcher i18nService={i18nService} />
40
36
  <ThemeSwitcher />
41
-
42
37
  {showLogoutButton && <LogoutButton />}
43
38
  </div>
44
39
  </div>
@@ -0,0 +1,27 @@
1
+ import { BaseHeader } from './BaseHeader';
2
+ import type { HTMLAttributes } from 'react';
3
+
4
+ export interface BaseLayoutProps extends HTMLAttributes<HTMLDivElement> {
5
+ showLogoutButton?: boolean;
6
+ mainProps?: HTMLAttributes<HTMLElement>;
7
+ }
8
+
9
+ export function BaseLayout({
10
+ children,
11
+ showLogoutButton,
12
+ mainProps,
13
+ ...props
14
+ }: BaseLayoutProps) {
15
+ return (
16
+ <div
17
+ data-testid="BaseLayout"
18
+ className="flex flex-col min-h-screen"
19
+ {...props}
20
+ >
21
+ <BaseHeader showLogoutButton={showLogoutButton} />
22
+ <main className="flex flex-1 flex-col bg-primary" {...mainProps}>
23
+ {children}
24
+ </main>
25
+ </div>
26
+ );
27
+ }
@@ -1,10 +1,11 @@
1
1
  'use client';
2
2
  import '@ant-design/v5-patch-for-react-19';
3
3
  import { useRouter } from 'next/navigation';
4
- import { useLocale } from 'next-intl';
4
+ import { useLocale, useTranslations } from 'next-intl';
5
5
  import { useEffect } from 'react';
6
6
  import { I } from '@config/IOCIdentifier';
7
7
  import { NavigateBridge } from '@/base/cases/NavigateBridge';
8
+ import type { I18nServiceLocale } from '@/base/port/I18nServiceInterface';
8
9
  import { BootstrapClient } from '@/core/bootstraps/BootstrapClient';
9
10
  import { clientIOC } from '@/core/clientIoc/ClientIOC';
10
11
  import { IOCContext } from '../context/IOCContext';
@@ -13,12 +14,18 @@ export function BootstrapsProvider(props: { children: React.ReactNode }) {
13
14
  const IOC = clientIOC.create();
14
15
  const locale = useLocale();
15
16
  const router = useRouter();
17
+ const t = useTranslations();
16
18
 
17
19
  useEffect(() => {
18
20
  IOC(I.RouterServiceInterface).setLocale(locale);
19
21
  IOC(NavigateBridge).setUIBridge(router);
20
22
  }, [locale, router, IOC]);
21
23
 
24
+ useEffect(() => {
25
+ IOC(I.I18nServiceInterface).changeLanguage(locale as I18nServiceLocale);
26
+ IOC(I.I18nServiceInterface).setTranslator(t);
27
+ }, [t, IOC, locale]);
28
+
22
29
  useEffect(() => {
23
30
  if (typeof window !== 'undefined') {
24
31
  BootstrapClient.main({
@@ -1,24 +1,38 @@
1
1
  'use client';
2
2
 
3
- import { Select } from 'antd';
3
+ import { TranslationOutlined } from '@ant-design/icons';
4
+ import { Dropdown } from 'antd';
4
5
  import { useLocale } from 'next-intl';
5
- import { useCallback } from 'react';
6
+ import { useCallback, useMemo } from 'react';
6
7
  import { i18nConfig } from '@config/i18n';
7
- import { IOCIdentifier } from '@config/IOCIdentifier';
8
- import type { I18nServiceLocale } from '@/base/port/I18nServiceInterface';
8
+ import type {
9
+ I18nServiceInterface,
10
+ I18nServiceLocale
11
+ } from '@/base/port/I18nServiceInterface';
9
12
  import { usePathname, useRouter } from '@/i18n/routing';
10
- import { useIOC } from '../hook/useIOC';
11
- import { useStore } from '../hook/useStore';
12
13
  import type { LocaleType } from '@config/i18n';
14
+ import type { ItemType } from 'antd/es/menu/interface';
13
15
 
14
- export function LanguageSwitcher() {
15
- const i18nService = useIOC(IOCIdentifier.I18nServiceInterface);
16
- const { loading } = useStore(i18nService);
16
+ export function LanguageSwitcher(props: { i18nService: I18nServiceInterface }) {
17
+ const { i18nService } = props;
17
18
  const pathname = usePathname(); // current pathname, aware of i18n
18
19
 
19
20
  const router = useRouter(); // i18n-aware router instance
20
21
  const currentLocale = useLocale() as LocaleType; // currently active locale
21
22
 
23
+ const options: ItemType[] = useMemo(() => {
24
+ return i18nConfig.supportedLngs.map(
25
+ (lang) =>
26
+ ({
27
+ type: 'item',
28
+ key: lang,
29
+ value: lang,
30
+ label:
31
+ i18nConfig.localeNames[lang as keyof typeof i18nConfig.localeNames]
32
+ }) as ItemType
33
+ );
34
+ }, []);
35
+
22
36
  const handleLanguageChange = useCallback(
23
37
  async (value: string) => {
24
38
  // Set a persistent cookie with the user's preferred locale (valid for 1 year)
@@ -35,18 +49,32 @@ export function LanguageSwitcher() {
35
49
  [i18nService, pathname, router]
36
50
  );
37
51
 
52
+ const nextLocale = useMemo(() => {
53
+ const targetIndex = i18nConfig.supportedLngs.indexOf(currentLocale) + 1;
54
+ return i18nConfig.supportedLngs[
55
+ targetIndex % i18nConfig.supportedLngs.length
56
+ ];
57
+ }, [currentLocale]);
58
+
38
59
  return (
39
- <Select
40
- data-testid="LanguageSwitcher"
41
- loading={loading}
42
- value={currentLocale}
43
- onChange={handleLanguageChange}
44
- options={i18nConfig.supportedLngs.map((lang) => ({
45
- value: lang,
46
- label:
47
- i18nConfig.localeNames[lang as keyof typeof i18nConfig.localeNames]
48
- }))}
49
- className="w-24"
50
- />
60
+ <Dropdown
61
+ data-testid="LanguageSwitcherDropdown"
62
+ trigger={['hover']}
63
+ menu={{
64
+ selectedKeys: [currentLocale],
65
+ items: options,
66
+ onClick: ({ key }) => {
67
+ handleLanguageChange(key);
68
+ }
69
+ }}
70
+ >
71
+ <span
72
+ data-testid="LanguageSwitcher"
73
+ className="text-text hover:text-text-hover cursor-pointer text-lg transition-colors"
74
+ onClick={() => handleLanguageChange(nextLocale)}
75
+ >
76
+ <TranslationOutlined />
77
+ </span>
78
+ </Dropdown>
51
79
  );
52
80
  }
@@ -1,34 +1,44 @@
1
- import { Button } from 'antd';
1
+ import { LogoutOutlined } from '@ant-design/icons';
2
+ import { Tooltip } from 'antd';
2
3
  import { useCallback } from 'react';
3
4
  import {
4
5
  AUTH_LOGOUT_DIALOG_CONTENT,
5
6
  AUTH_LOGOUT_DIALOG_TITLE
6
7
  } from '@config/Identifier';
7
- import { IOCIdentifier } from '@config/IOCIdentifier';
8
+ import { I } from '@config/IOCIdentifier';
8
9
  import { useI18nInterface } from '../hook/useI18nInterface';
9
10
  import { useIOC } from '../hook/useIOC';
10
11
  import type { PageI18nInterface } from '@config/i18n';
11
12
 
12
13
  export function LogoutButton() {
13
- const IOC = useIOC();
14
+ const dialogHandler = useIOC(I.DialogHandler);
15
+ const userService = useIOC(I.UserServiceInterface);
16
+ const routerService = useIOC(I.RouterServiceInterface);
17
+
14
18
  const tt = useI18nInterface({
15
19
  title: AUTH_LOGOUT_DIALOG_TITLE,
16
20
  content: AUTH_LOGOUT_DIALOG_CONTENT
17
21
  } as PageI18nInterface);
18
22
 
19
23
  const onClick = useCallback(() => {
20
- IOC(IOCIdentifier.DialogHandler).confirm({
24
+ dialogHandler.confirm({
21
25
  title: tt.title,
22
26
  content: tt.content,
23
- onOk: () => {
24
- IOC(IOCIdentifier.UserServiceInterface).logout();
27
+ onOk: async () => {
28
+ await userService.logout();
29
+ routerService.gotoLogin();
25
30
  }
26
31
  });
27
- }, [tt, IOC]);
32
+ }, [tt, dialogHandler, userService, routerService]);
28
33
 
29
34
  return (
30
- <Button data-testid="LogoutButton" danger onClick={onClick}>
31
- {tt.title}
32
- </Button>
35
+ <Tooltip data-testid="LogoutIcon" title={tt.title} placement="right">
36
+ <span
37
+ className="text-text hover:text-red-500 cursor-pointer text-lg transition-colors"
38
+ onClick={onClick}
39
+ >
40
+ <LogoutOutlined />
41
+ </span>
42
+ </Tooltip>
33
43
  );
34
44
  }