@qlover/create-app 0.7.12 → 0.7.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.
Files changed (66) hide show
  1. package/CHANGELOG.md +77 -0
  2. package/dist/index.cjs +7 -7
  3. package/dist/index.js +8 -8
  4. package/dist/templates/next-app/config/IOCIdentifier.ts +14 -1
  5. package/dist/templates/next-app/config/Identifier/index.ts +1 -0
  6. package/dist/templates/next-app/config/Identifier/page.admin.ts +48 -0
  7. package/dist/templates/next-app/config/i18n/admin18n.ts +33 -0
  8. package/dist/templates/next-app/config/i18n/index.ts +3 -1
  9. package/dist/templates/next-app/migrations/schema/UserSchema.ts +2 -2
  10. package/dist/templates/next-app/next.config.ts +1 -1
  11. package/dist/templates/next-app/package.json +3 -1
  12. package/dist/templates/next-app/public/locales/en.json +8 -1
  13. package/dist/templates/next-app/public/locales/zh.json +8 -1
  14. package/dist/templates/next-app/src/app/[locale]/admin/layout.tsx +1 -1
  15. package/dist/templates/next-app/src/app/[locale]/admin/page.tsx +14 -16
  16. package/dist/templates/next-app/src/app/[locale]/admin/users/page.tsx +10 -3
  17. package/dist/templates/next-app/src/app/[locale]/layout.tsx +1 -1
  18. package/dist/templates/next-app/src/app/[locale]/login/page.tsx +1 -1
  19. package/dist/templates/next-app/src/app/[locale]/page.tsx +2 -3
  20. package/dist/templates/next-app/src/app/[locale]/register/page.tsx +1 -1
  21. package/dist/templates/next-app/src/app/api/ai/completions/route.ts +32 -0
  22. package/dist/templates/next-app/src/base/cases/AppConfig.ts +3 -0
  23. package/dist/templates/next-app/src/base/cases/ChatAction.ts +21 -0
  24. package/dist/templates/next-app/src/base/cases/FocusBarAction.ts +36 -0
  25. package/dist/templates/next-app/src/base/port/AdminPageInterface.ts +1 -3
  26. package/dist/templates/next-app/src/base/services/AdminUserService.ts +1 -1
  27. package/dist/templates/next-app/src/base/services/adminApi/AdminUserApi.ts +1 -1
  28. package/dist/templates/next-app/src/base/services/appApi/AppApiPlugin.ts +23 -1
  29. package/dist/templates/next-app/src/base/services/appApi/AppUserApiBootstrap.ts +2 -2
  30. package/dist/templates/next-app/src/base/types/PageProps.ts +1 -1
  31. package/dist/templates/next-app/src/core/bootstraps/BootstrapClient.ts +1 -0
  32. package/dist/templates/next-app/src/core/bootstraps/BootstrapServer.ts +1 -0
  33. package/dist/templates/next-app/src/core/globals.ts +2 -0
  34. package/dist/templates/next-app/src/core/serverIoc/ServerIOCRegister.ts +4 -1
  35. package/dist/templates/next-app/src/{base/cases → server}/PageParams.ts +1 -1
  36. package/dist/templates/next-app/src/server/port/DBBridgeInterface.ts +31 -0
  37. package/dist/templates/next-app/src/server/port/DBTableInterface.ts +1 -1
  38. package/dist/templates/next-app/src/server/repositorys/UserRepository.ts +6 -4
  39. package/dist/templates/next-app/src/server/services/AIService.ts +43 -0
  40. package/dist/templates/next-app/src/server/services/ApiUserService.ts +1 -1
  41. package/dist/templates/next-app/src/server/{SupabaseBridge.ts → sqlBridges/SupabaseBridge.ts} +16 -11
  42. package/dist/templates/next-app/src/server/validators/LoginValidator.ts +4 -4
  43. package/dist/templates/next-app/src/server/validators/PaginationValidator.ts +1 -1
  44. package/dist/templates/next-app/src/uikit/components/AdminLayout.tsx +32 -25
  45. package/dist/templates/next-app/src/uikit/components/BaseHeader.tsx +12 -26
  46. package/dist/templates/next-app/src/uikit/components/BaseLayout.tsx +37 -5
  47. package/dist/templates/next-app/src/uikit/components/ChatRoot.tsx +17 -0
  48. package/dist/templates/next-app/src/uikit/components/ClientSeo.tsx +36 -0
  49. package/dist/templates/next-app/src/uikit/components/LanguageSwitcher.tsx +5 -6
  50. package/dist/templates/next-app/src/uikit/components/LogoutButton.tsx +2 -0
  51. package/dist/templates/next-app/src/uikit/components/With.tsx +17 -0
  52. package/dist/templates/next-app/src/uikit/components/chat/ChatActionInterface.ts +30 -0
  53. package/dist/templates/next-app/src/uikit/components/chat/ChatFocusBar.tsx +65 -0
  54. package/dist/templates/next-app/src/uikit/components/chat/ChatMessages.tsx +59 -0
  55. package/dist/templates/next-app/src/uikit/components/chat/ChatWrap.tsx +28 -0
  56. package/dist/templates/next-app/src/uikit/components/chat/FocusBarActionInterface.ts +19 -0
  57. package/package.json +1 -1
  58. package/dist/templates/next-app/src/base/port/DBBridgeInterface.ts +0 -21
  59. package/dist/templates/next-app/src/base/port/DBMigrationInterface.ts +0 -92
  60. package/dist/templates/next-app/src/base/port/MigrationApiInterface.ts +0 -3
  61. package/dist/templates/next-app/src/base/port/ServerApiResponseInterface.ts +0 -6
  62. package/dist/templates/next-app/src/base/services/migrations/MigrationsApi.ts +0 -43
  63. package/dist/templates/next-app/config/i18n/{HomeI18n .ts → HomeI18n.ts} +0 -0
  64. package/dist/templates/next-app/{build → make}/generateLocales.ts +2 -2
  65. /package/dist/templates/next-app/src/{base → server}/port/PaginationInterface.ts +0 -0
  66. /package/dist/templates/next-app/src/{base → server}/port/ParamsHandlerInterface.ts +0 -0
@@ -0,0 +1,43 @@
1
+ import { inject, injectable } from 'inversify';
2
+ import OpenAI from 'openai';
3
+ import type { AppConfig } from '@/base/cases/AppConfig';
4
+ import { I } from '@config/IOCIdentifier';
5
+ import type { ChatCompletionMessageParam } from 'openai/resources/chat/completions';
6
+
7
+ @injectable()
8
+ export class AIService {
9
+ protected apiKey: string;
10
+ protected baseUrl: string;
11
+ protected client: OpenAI;
12
+
13
+ constructor(@inject(I.AppConfig) appConfig: AppConfig) {
14
+ this.apiKey = appConfig.openaiApiKey;
15
+ this.baseUrl = appConfig.openaiBaseUrl;
16
+
17
+ console.log(this.apiKey, this.baseUrl);
18
+ this.client = new OpenAI({
19
+ apiKey: this.apiKey,
20
+ baseURL: this.baseUrl
21
+ });
22
+ }
23
+
24
+ async completions(messages: ChatCompletionMessageParam[]): Promise<unknown> {
25
+ const url = `${this.baseUrl}/chat/completions`;
26
+
27
+ const response = await fetch(url, {
28
+ method: 'POST',
29
+ body: JSON.stringify({
30
+ messages: messages,
31
+ model: 'claude-sonnet-4-20250514'
32
+ }),
33
+ headers: {
34
+ Authorization: `token ${this.apiKey}`,
35
+ 'Content-Type': 'application/json',
36
+ Accept: 'application/json'
37
+ },
38
+ mode: 'cors'
39
+ });
40
+
41
+ return await response.json();
42
+ }
43
+ }
@@ -1,5 +1,5 @@
1
1
  import { inject, injectable } from 'inversify';
2
- import type { PaginationInterface } from '@/base/port/PaginationInterface';
2
+ import type { PaginationInterface } from '@/server/port/PaginationInterface';
3
3
  import type { UserSchema } from '@migrations/schema/UserSchema';
4
4
  import { UserRepository } from '../repositorys/UserRepository';
5
5
  import { PaginationValidator } from '../validators/PaginationValidator';
@@ -9,8 +9,9 @@ import type { AppConfig } from '@/base/cases/AppConfig';
9
9
  import type {
10
10
  BridgeEvent,
11
11
  DBBridgeInterface,
12
+ DBBridgeResponse,
12
13
  Where
13
- } from '@/base/port/DBBridgeInterface';
14
+ } from '@/server/port/DBBridgeInterface';
14
15
  import { I } from '@config/IOCIdentifier';
15
16
  import type { LoggerInterface } from '@qlover/logger';
16
17
  import type { PostgrestFilterBuilder } from '@supabase/postgrest-js';
@@ -24,6 +25,9 @@ const whereHandlerMaps = {
24
25
  '<=': 'lte'
25
26
  };
26
27
 
28
+ export type SupabaseBridgeResponse<T> = DBBridgeResponse<T> &
29
+ PostgrestResponse<T>;
30
+
27
31
  @injectable()
28
32
  export class SupabaseBridge implements DBBridgeInterface {
29
33
  protected supabase: SupabaseClient;
@@ -42,19 +46,20 @@ export class SupabaseBridge implements DBBridgeInterface {
42
46
  return this.supabase;
43
47
  }
44
48
 
45
- async execSql(sql: string): Promise<PostgrestSingleResponse<unknown>> {
49
+ async execSql(sql: string): Promise<SupabaseBridgeResponse<unknown>> {
46
50
  const res = await this.supabase.rpc('exec_sql', { sql });
47
51
  return this.catch(res);
48
52
  }
49
53
 
50
54
  protected async catch(
51
55
  result: PostgrestSingleResponse<unknown>
52
- ): Promise<PostgrestSingleResponse<unknown>> {
56
+ ): Promise<SupabaseBridgeResponse<unknown>> {
53
57
  if (result.error) {
54
58
  this.logger.info(result);
55
59
  throw new Error(result.error.message);
56
60
  }
57
- return result;
61
+
62
+ return result as SupabaseBridgeResponse<unknown>;
58
63
  }
59
64
 
60
65
  protected handleWhere(
@@ -79,7 +84,7 @@ export class SupabaseBridge implements DBBridgeInterface {
79
84
  }
80
85
  }
81
86
 
82
- async add(event: BridgeEvent): Promise<PostgrestSingleResponse<unknown>> {
87
+ async add(event: BridgeEvent): Promise<DBBridgeResponse<unknown>> {
83
88
  const { table, data } = event;
84
89
  if (!data) {
85
90
  throw new Error('Data is required for add operation');
@@ -91,7 +96,7 @@ export class SupabaseBridge implements DBBridgeInterface {
91
96
  return this.catch(res);
92
97
  }
93
98
 
94
- async update(event: BridgeEvent): Promise<PostgrestSingleResponse<unknown>> {
99
+ async update(event: BridgeEvent): Promise<DBBridgeResponse<unknown>> {
95
100
  const { table, data, where } = event;
96
101
  if (!data) {
97
102
  throw new Error('Data is required for update operation');
@@ -104,7 +109,7 @@ export class SupabaseBridge implements DBBridgeInterface {
104
109
  return this.catch(await handler);
105
110
  }
106
111
 
107
- async delete(event: BridgeEvent): Promise<PostgrestSingleResponse<unknown>> {
112
+ async delete(event: BridgeEvent): Promise<DBBridgeResponse<unknown>> {
108
113
  const { table, where } = event;
109
114
  const handler = this.supabase.from(table).delete();
110
115
 
@@ -113,7 +118,7 @@ export class SupabaseBridge implements DBBridgeInterface {
113
118
  return this.catch(await handler);
114
119
  }
115
120
 
116
- async get(event: BridgeEvent): Promise<PostgrestSingleResponse<unknown>> {
121
+ async get(event: BridgeEvent): Promise<SupabaseBridgeResponse<unknown>> {
117
122
  const { table, fields = '*', where } = event;
118
123
  const selectFields = Array.isArray(fields) ? fields.join(',') : fields;
119
124
  const handler = this.supabase.from(table).select(selectFields);
@@ -123,7 +128,7 @@ export class SupabaseBridge implements DBBridgeInterface {
123
128
  return this.catch(await handler);
124
129
  }
125
130
 
126
- async pagination(event: BridgeEvent): Promise<PostgrestResponse<unknown>> {
131
+ async pagination(event: BridgeEvent): Promise<DBBridgeResponse<unknown[]>> {
127
132
  const { table, fields = '*', where, page = 1, pageSize = 10 } = event;
128
133
  const selectFields = Array.isArray(fields) ? fields.join(',') : fields;
129
134
 
@@ -145,7 +150,7 @@ export class SupabaseBridge implements DBBridgeInterface {
145
150
  const result = await this.catch(await handler);
146
151
 
147
152
  if (result.error) {
148
- return result as PostgrestResponse<unknown>;
153
+ return result as DBBridgeResponse<unknown[]>;
149
154
  }
150
155
 
151
156
  return {
@@ -154,6 +159,6 @@ export class SupabaseBridge implements DBBridgeInterface {
154
159
  count: countResult.count,
155
160
  status: result.status,
156
161
  statusText: result.statusText
157
- };
162
+ } as DBBridgeResponse<unknown[]>;
158
163
  }
159
164
  }
@@ -17,13 +17,13 @@ export interface LoginValidatorData {
17
17
  password: string;
18
18
  }
19
19
 
20
- const emailSchema = z.email({ error: V_EMAIL_INVALID });
20
+ const emailSchema = z.string().email({ message: V_EMAIL_INVALID });
21
21
 
22
22
  const passwordSchema = z
23
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 });
24
+ .min(6, { message: V_PASSWORD_MIN_LENGTH })
25
+ .max(50, { message: V_PASSWORD_MAX_LENGTH })
26
+ .regex(/^\S+$/, { message: V_PASSWORD_SPECIAL_CHARS });
27
27
 
28
28
  interface ExtendedExecutorError extends ExecutorError {
29
29
  issues?: ValidationFaildResult[];
@@ -1,5 +1,5 @@
1
1
  import { z } from 'zod';
2
- import type { PaginationInterface } from '@/base/port/PaginationInterface';
2
+ import type { PaginationInterface } from '@/server/port/PaginationInterface';
3
3
  import { API_PAGE_INVALID } from '@config/Identifier';
4
4
  import {
5
5
  type ValidationFaildResult,
@@ -3,18 +3,20 @@
3
3
  import {
4
4
  DashboardOutlined,
5
5
  UserOutlined,
6
- MenuFoldOutlined,
7
- MenuUnfoldOutlined
6
+ VerticalAlignBottomOutlined,
7
+ VerticalAlignTopOutlined
8
8
  } from '@ant-design/icons';
9
9
  import { Layout, Menu } from 'antd';
10
10
  import { clsx } from 'clsx';
11
- import React, { useCallback, useMemo, type HTMLAttributes } from 'react';
11
+ import React, { useMemo, type HTMLAttributes } from 'react';
12
12
  import { AdminPageManager } from '@/base/cases/AdminPageManager';
13
13
  import { BaseHeader } from './BaseHeader';
14
+ import { LanguageSwitcher } from './LanguageSwitcher';
14
15
  import { LocaleLink } from './LocaleLink';
16
+ import { LogoutButton } from './LogoutButton';
17
+ import { ThemeSwitcher } from './ThemeSwitcher';
15
18
  import { useIOC } from '../hook/useIOC';
16
19
  import { useStore } from '../hook/useStore';
17
- import type { RenderLeftFunction } from './BaseHeader';
18
20
  import type { ItemType } from 'antd/es/menu/interface';
19
21
 
20
22
  const { Sider } = Layout;
@@ -33,7 +35,6 @@ export function AdminLayout(props: AdminLayoutProps) {
33
35
  const { children, className, mainClassName, ...rest } = props;
34
36
 
35
37
  const page = useIOC(AdminPageManager);
36
-
37
38
  const collapsedSidebar = useStore(page, page.selectors.collapsedSidebar);
38
39
  const navItems = useStore(page, page.selectors.navItems);
39
40
 
@@ -60,40 +61,46 @@ export function AdminLayout(props: AdminLayoutProps) {
60
61
  });
61
62
  }, [navItems]);
62
63
 
63
- const renderHeaderLeft: RenderLeftFunction = useCallback(
64
- ({ defaultElement }) => (
65
- <div data-testid="AdminLayoutHeader" className="flex items-center">
66
- <span
67
- className="text-text hover:text-text-hover cursor-pointer text-md transition-colors"
68
- onClick={() => page.toggleSidebar()}
69
- >
70
- {collapsedSidebar ? <MenuUnfoldOutlined /> : <MenuFoldOutlined />}
71
- </span>
72
-
73
- {defaultElement}
74
- </div>
75
- ),
76
- [collapsedSidebar, page]
77
- );
64
+ const rightActions = useMemo(() => {
65
+ return [
66
+ <LanguageSwitcher key="language-switcher" />,
67
+ <ThemeSwitcher key="theme-switcher" />,
68
+ <LogoutButton key="logout-button" />
69
+ ];
70
+ }, []);
78
71
 
79
72
  return (
80
73
  <Layout data-testid="AdminLayout" className={className} {...rest}>
81
- <div className="overflow-auto h-screen sticky top-0 bottom-0 scrollbar-thin scrollbar-gutter-stable">
74
+ <div className="overflow-y-auto overflow-x-hidden h-screen sticky top-0 bottom-0 scrollbar-thin scrollbar-gutter-stable">
82
75
  <Sider
83
- className="h-full"
76
+ className="h-full relative"
84
77
  onCollapse={() => page.toggleSidebar()}
85
78
  collapsed={collapsedSidebar}
79
+ collapsedWidth={46}
86
80
  >
87
- <div className="demo-logo-vertical" />
88
81
  <Menu mode="inline" items={sidebarItems} />
82
+
83
+ <div
84
+ data-testid="ToggleSidebarButton"
85
+ className="absolute w-2 right-0 top-0 bottom-0 bg-secondary cursor-pointer hover:bg-elevated flex items-center justify-center"
86
+ onClick={() => page.toggleSidebar()}
87
+ >
88
+ <span className="text-text scale-75 rotate-x-90">
89
+ {collapsedSidebar ? (
90
+ <VerticalAlignTopOutlined />
91
+ ) : (
92
+ <VerticalAlignBottomOutlined />
93
+ )}
94
+ </span>
95
+ </div>
89
96
  </Sider>
90
97
  </div>
91
98
 
92
99
  <Layout>
93
100
  <BaseHeader
94
101
  href="/admin"
95
- showLogoutButton
96
- renderLeft={renderHeaderLeft}
102
+ className="max-w-full pl-0"
103
+ rightActions={rightActions}
97
104
  />
98
105
  <main
99
106
  className={clsx('p-2 bg-primary text-text flex-1', mainClassName)}
@@ -1,15 +1,12 @@
1
1
  'use client';
2
2
 
3
- import { TeamOutlined } from '@ant-design/icons';
3
+ import { clsx } from 'clsx';
4
4
  import { useLocale, useTranslations } from 'next-intl';
5
5
  import { useMemo } from 'react';
6
6
  import { useIOC } from '@/uikit/hook/useIOC';
7
7
  import { PAGE_HEAD_ADMIN_TITLE } from '@config/Identifier';
8
8
  import { IOCIdentifier } from '@config/IOCIdentifier';
9
- import { LanguageSwitcher } from './LanguageSwitcher';
10
9
  import { LocaleLink } from './LocaleLink';
11
- import { LogoutButton } from './LogoutButton';
12
- import { ThemeSwitcher } from './ThemeSwitcher';
13
10
 
14
11
  export type RenderLeftFunction = (props: {
15
12
  locale: string;
@@ -18,13 +15,12 @@ export type RenderLeftFunction = (props: {
18
15
 
19
16
  export function BaseHeader(props: {
20
17
  href?: string;
21
- showLogoutButton?: boolean;
22
- showAdminButton?: boolean;
23
18
  renderLeft?: React.ReactNode | RenderLeftFunction;
19
+ rightActions?: React.ReactNode;
20
+ className?: string;
24
21
  }) {
25
- const { href = '/', showLogoutButton, showAdminButton, renderLeft } = props;
22
+ const { href = '/', className, renderLeft, rightActions } = props;
26
23
  const appConfig = useIOC(IOCIdentifier.AppConfig);
27
- const i18nService = useIOC(IOCIdentifier.I18nServiceInterface);
28
24
  const locale = useLocale();
29
25
  const t = useTranslations();
30
26
 
@@ -44,7 +40,7 @@ export function BaseHeader(props: {
44
40
  >
45
41
  <span
46
42
  data-testid="base-header-app-name"
47
- className="ml-2 text-lg font-semibold text-text"
43
+ className="text-lg font-semibold text-text"
48
44
  >
49
45
  {tt.title}
50
46
  </span>
@@ -69,24 +65,14 @@ export function BaseHeader(props: {
69
65
  data-testid="BaseHeader"
70
66
  className="h-14 bg-secondary border-b border-c-border sticky top-0 z-50"
71
67
  >
72
- <div className="flex items-center justify-between h-full px-4 mx-auto max-w-7xl">
68
+ <div
69
+ className={clsx(
70
+ 'flex items-center justify-between h-full px-4 mx-auto max-w-7xl',
71
+ className
72
+ )}
73
+ >
73
74
  <div className="flex items-center">{RenderLeft}</div>
74
- <div className="flex items-center gap-2 md:gap-4">
75
- {showAdminButton && (
76
- <LocaleLink
77
- href="/admin"
78
- title={tt.admin}
79
- locale={locale}
80
- className="text-text hover:text-text-hover cursor-pointer text-lg transition-colors"
81
- >
82
- <TeamOutlined className="text-lg text-text" />
83
- </LocaleLink>
84
- )}
85
-
86
- <LanguageSwitcher i18nService={i18nService} />
87
- <ThemeSwitcher />
88
- {showLogoutButton && <LogoutButton />}
89
- </div>
75
+ <div className="flex items-center gap-2 md:gap-4">{rightActions}</div>
90
76
  </div>
91
77
  </header>
92
78
  );
@@ -1,5 +1,12 @@
1
+ import { TeamOutlined } from '@ant-design/icons';
2
+ import { useLocale, useTranslations } from 'next-intl';
3
+ import { useMemo, type HTMLAttributes } from 'react';
4
+ import { PAGE_HEAD_ADMIN_TITLE } from '@config/Identifier';
1
5
  import { BaseHeader } from './BaseHeader';
2
- import type { HTMLAttributes } from 'react';
6
+ import { LanguageSwitcher } from './LanguageSwitcher';
7
+ import { LocaleLink } from './LocaleLink';
8
+ import { LogoutButton } from './LogoutButton';
9
+ import { ThemeSwitcher } from './ThemeSwitcher';
3
10
 
4
11
  export interface BaseLayoutProps extends HTMLAttributes<HTMLDivElement> {
5
12
  showLogoutButton?: boolean;
@@ -14,16 +21,41 @@ export function BaseLayout({
14
21
  mainProps,
15
22
  ...props
16
23
  }: BaseLayoutProps) {
24
+ const locale = useLocale();
25
+ const t = useTranslations();
26
+
27
+ const tt = {
28
+ admin: t(PAGE_HEAD_ADMIN_TITLE)
29
+ };
30
+
31
+ const actions = useMemo(
32
+ () =>
33
+ [
34
+ showAdminButton && (
35
+ <LocaleLink
36
+ key="admin-button"
37
+ href="/admin"
38
+ title={tt.admin}
39
+ locale={locale}
40
+ className="text-text hover:text-text-hover cursor-pointer text-lg transition-colors"
41
+ >
42
+ <TeamOutlined className="text-lg text-text" />
43
+ </LocaleLink>
44
+ ),
45
+ <LanguageSwitcher key="language-switcher" />,
46
+ <ThemeSwitcher key="theme-switcher" />,
47
+ showLogoutButton && <LogoutButton key="logout-button" />
48
+ ].filter(Boolean),
49
+ [showAdminButton, tt.admin, locale, showLogoutButton]
50
+ );
51
+
17
52
  return (
18
53
  <div
19
54
  data-testid="BaseLayout"
20
55
  className="flex flex-col min-h-screen"
21
56
  {...props}
22
57
  >
23
- <BaseHeader
24
- showLogoutButton={showLogoutButton}
25
- showAdminButton={showAdminButton}
26
- />
58
+ <BaseHeader rightActions={actions} />
27
59
  <main className="flex flex-1 flex-col bg-primary" {...mainProps}>
28
60
  {children}
29
61
  </main>
@@ -0,0 +1,17 @@
1
+ 'use client';
2
+
3
+ import { ChatAction } from '@/base/cases/ChatAction';
4
+ import { FocusBarAction } from '@/base/cases/FocusBarAction';
5
+ import { useIOC } from '../hook/useIOC';
6
+ import { ChatWrap } from './chat/ChatWrap';
7
+
8
+ export function ChatRoot() {
9
+ const chatAction = useIOC(ChatAction);
10
+ const focusBarAction = useIOC(FocusBarAction);
11
+
12
+ return (
13
+ <div data-testid="ChatRoot" className="fixed bottom-0 right-0 ">
14
+ <ChatWrap chatAction={chatAction} focusBarAction={focusBarAction} />
15
+ </div>
16
+ );
17
+ }
@@ -0,0 +1,36 @@
1
+ import type { PageI18nInterface } from '@config/i18n';
2
+ import { With } from './With';
3
+
4
+ export function ClientSeo(props: {
5
+ i18nInterface: PageI18nInterface;
6
+ children?: React.ReactNode;
7
+ }) {
8
+ const { i18nInterface, children } = props;
9
+
10
+ return (
11
+ <>
12
+ <title>{i18nInterface.title}</title>
13
+ <meta name="description" content={i18nInterface.description} />
14
+ <meta name="keywords" content={i18nInterface.keywords} />
15
+ <meta name="author" content={i18nInterface.author} />
16
+ <meta name="publishedTime" content={i18nInterface.publishedTime} />
17
+ <meta name="modifiedTime" content={i18nInterface.modifiedTime} />
18
+
19
+ <With it={i18nInterface.canonical}>
20
+ <meta name="canonical" content={i18nInterface.canonical} />
21
+ </With>
22
+
23
+ <With it={i18nInterface.og}>
24
+ {({ title, description, image }) => (
25
+ <>
26
+ <meta name="og:title" content={title} />
27
+ <meta name="og:description" content={description} />
28
+ <meta name="og:image" content={image} />
29
+ </>
30
+ )}
31
+ </With>
32
+
33
+ {children}
34
+ </>
35
+ );
36
+ }
@@ -4,17 +4,16 @@ import { TranslationOutlined } from '@ant-design/icons';
4
4
  import { Dropdown } from 'antd';
5
5
  import { useLocale } from 'next-intl';
6
6
  import { useCallback, useMemo } from 'react';
7
- import type {
8
- I18nServiceInterface,
9
- I18nServiceLocale
10
- } from '@/base/port/I18nServiceInterface';
7
+ import type { I18nServiceLocale } from '@/base/port/I18nServiceInterface';
11
8
  import { usePathname, useRouter } from '@/i18n/routing';
12
9
  import { i18nConfig } from '@config/i18n';
13
10
  import type { LocaleType } from '@config/i18n';
11
+ import { I } from '@config/IOCIdentifier';
12
+ import { useIOC } from '../hook/useIOC';
14
13
  import type { ItemType } from 'antd/es/menu/interface';
15
14
 
16
- export function LanguageSwitcher(props: { i18nService: I18nServiceInterface }) {
17
- const { i18nService } = props;
15
+ export function LanguageSwitcher() {
16
+ const i18nService = useIOC(I.I18nServiceInterface);
18
17
  const pathname = usePathname(); // current pathname, aware of i18n
19
18
 
20
19
  const router = useRouter(); // i18n-aware router instance
@@ -1,3 +1,5 @@
1
+ 'use client';
2
+
1
3
  import { LogoutOutlined } from '@ant-design/icons';
2
4
  import { Tooltip } from 'antd';
3
5
  import { useCallback } from 'react';
@@ -0,0 +1,17 @@
1
+ export function With<T>(props: {
2
+ fallback?: React.ReactNode;
3
+ it: T;
4
+ children: React.ReactNode | ((it: NonNullable<T>) => React.ReactNode);
5
+ }) {
6
+ const { fallback, it, children } = props;
7
+
8
+ if (it) {
9
+ if (typeof children === 'function') {
10
+ return children(it);
11
+ }
12
+
13
+ return children;
14
+ }
15
+
16
+ return fallback ?? null;
17
+ }
@@ -0,0 +1,30 @@
1
+ import {
2
+ StoreInterface,
3
+ type StoreStateInterface
4
+ } from '@qlover/corekit-bridge';
5
+
6
+ export const MessageType = Object.freeze({
7
+ USER: 'user',
8
+ ASSISTANT: 'assistant'
9
+ });
10
+
11
+ export type MessageTypeValue = (typeof MessageType)[keyof typeof MessageType];
12
+
13
+ export interface MessageInterface {
14
+ id: string;
15
+ content: unknown;
16
+ role: MessageTypeValue;
17
+ createdAt: string;
18
+
19
+ loading?: boolean;
20
+ }
21
+
22
+ export interface ChatStateInterface extends StoreStateInterface {
23
+ messages: MessageInterface[];
24
+ }
25
+
26
+ export abstract class ChatActionInterface<
27
+ S extends ChatStateInterface
28
+ > extends StoreInterface<S> {
29
+ abstract focus(): void;
30
+ }
@@ -0,0 +1,65 @@
1
+ import { SendOutlined } from '@ant-design/icons';
2
+ import { Button, Input } from 'antd';
3
+ import { useCallback, useRef } from 'react';
4
+ import { useStore } from '../../hook/useStore';
5
+ import type {
6
+ FocusBarActionInterface,
7
+ FocusBarStateInterface
8
+ } from './FocusBarActionInterface';
9
+
10
+ export function ChatFocusBar({
11
+ focusBarAction
12
+ }: {
13
+ focusBarAction: FocusBarActionInterface<FocusBarStateInterface>;
14
+ }) {
15
+ const inputRef = useRef<HTMLTextAreaElement>(null);
16
+ const { inputValue } = useStore(focusBarAction);
17
+ const sendState = useStore(focusBarAction, (state) => state.sendState);
18
+
19
+ const handleInputChange = useCallback(
20
+ (e: React.ChangeEvent<HTMLTextAreaElement>) => {
21
+ focusBarAction.setInputValue(e.target.value);
22
+ },
23
+ [focusBarAction]
24
+ );
25
+
26
+ const handleKeyDown = useCallback(
27
+ (e: React.KeyboardEvent<HTMLTextAreaElement>) => {
28
+ if (e.key === 'Enter' && !e.shiftKey) {
29
+ e.preventDefault();
30
+ focusBarAction.sendMessage(inputValue);
31
+ }
32
+ },
33
+ [focusBarAction, inputValue]
34
+ );
35
+
36
+ const sending = sendState.loading;
37
+
38
+ return (
39
+ <div
40
+ data-testid="ChatFocusBarInput"
41
+ className="flex items-end gap-2 p-4 bg-elevated border-t border-c-border"
42
+ >
43
+ <Input.TextArea
44
+ ref={inputRef}
45
+ disabled={sending}
46
+ value={inputValue}
47
+ onChange={handleInputChange}
48
+ onKeyDown={handleKeyDown}
49
+ placeholder="Type your message..."
50
+ rows={1}
51
+ />
52
+ <Button
53
+ data-testid="ChatFocusBarSendButton"
54
+ onClick={() => {
55
+ focusBarAction.sendMessage(inputValue);
56
+ }}
57
+ type="primary"
58
+ className="flex items-center justify-center !h-10 !w-10 !rounded-full !bg-c-brand !text-white hover:!bg-c-brand-hover transition-colors"
59
+ icon={<SendOutlined />}
60
+ loading={sending}
61
+ disabled={sending}
62
+ />
63
+ </div>
64
+ );
65
+ }
@@ -0,0 +1,59 @@
1
+ import { useEffect, useRef } from 'react';
2
+ import { MessageType } from './ChatActionInterface';
3
+ import { useStore } from '../../hook/useStore';
4
+ import type {
5
+ ChatActionInterface,
6
+ ChatStateInterface,
7
+ MessageInterface
8
+ } from './ChatActionInterface';
9
+
10
+ function MessageItem({ message }: { message: MessageInterface }) {
11
+ return (
12
+ <div
13
+ data-testid="MessageItem"
14
+ className={`flex ${
15
+ message.role === MessageType.USER ? 'justify-end' : 'justify-start'
16
+ } mb-4`}
17
+ >
18
+ <div
19
+ data-testid="MessageItemContent"
20
+ className={`max-w-[70%] rounded-lg p-3 ${
21
+ message.role === MessageType.USER
22
+ ? 'bg-blue-500 text-white'
23
+ : 'bg-gray-100 dark:bg-gray-700'
24
+ }`}
25
+ >
26
+ <p className="whitespace-pre-wrap break-words">{message.content}</p>
27
+ <div className="mt-1 text-xs opacity-70">
28
+ {new Date(message.createdAt).toLocaleTimeString()}
29
+ </div>
30
+ </div>
31
+ </div>
32
+ );
33
+ }
34
+
35
+ export function ChatMessages({
36
+ chatAction
37
+ }: {
38
+ chatAction: ChatActionInterface<ChatStateInterface>;
39
+ }) {
40
+ const messagesEndRef = useRef<HTMLDivElement>(null);
41
+ const { messages } = useStore(chatAction);
42
+
43
+ useEffect(() => {
44
+ messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' });
45
+ }, [messages]);
46
+
47
+ return (
48
+ <div data-testid="ChatMessages" className="flex-1 overflow-y-auto p-4">
49
+ {messages.map((message: MessageInterface) => (
50
+ <MessageItem
51
+ data-testid="MessageItem"
52
+ key={message.id}
53
+ message={message}
54
+ />
55
+ ))}
56
+ <div ref={messagesEndRef} />
57
+ </div>
58
+ );
59
+ }