@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.
- package/CHANGELOG.md +77 -0
- package/dist/index.cjs +7 -7
- package/dist/index.js +8 -8
- package/dist/templates/next-app/config/IOCIdentifier.ts +14 -1
- package/dist/templates/next-app/config/Identifier/index.ts +1 -0
- package/dist/templates/next-app/config/Identifier/page.admin.ts +48 -0
- package/dist/templates/next-app/config/i18n/admin18n.ts +33 -0
- package/dist/templates/next-app/config/i18n/index.ts +3 -1
- package/dist/templates/next-app/migrations/schema/UserSchema.ts +2 -2
- package/dist/templates/next-app/next.config.ts +1 -1
- package/dist/templates/next-app/package.json +3 -1
- package/dist/templates/next-app/public/locales/en.json +8 -1
- package/dist/templates/next-app/public/locales/zh.json +8 -1
- package/dist/templates/next-app/src/app/[locale]/admin/layout.tsx +1 -1
- package/dist/templates/next-app/src/app/[locale]/admin/page.tsx +14 -16
- package/dist/templates/next-app/src/app/[locale]/admin/users/page.tsx +10 -3
- package/dist/templates/next-app/src/app/[locale]/layout.tsx +1 -1
- package/dist/templates/next-app/src/app/[locale]/login/page.tsx +1 -1
- package/dist/templates/next-app/src/app/[locale]/page.tsx +2 -3
- package/dist/templates/next-app/src/app/[locale]/register/page.tsx +1 -1
- package/dist/templates/next-app/src/app/api/ai/completions/route.ts +32 -0
- package/dist/templates/next-app/src/base/cases/AppConfig.ts +3 -0
- package/dist/templates/next-app/src/base/cases/ChatAction.ts +21 -0
- package/dist/templates/next-app/src/base/cases/FocusBarAction.ts +36 -0
- package/dist/templates/next-app/src/base/port/AdminPageInterface.ts +1 -3
- package/dist/templates/next-app/src/base/services/AdminUserService.ts +1 -1
- package/dist/templates/next-app/src/base/services/adminApi/AdminUserApi.ts +1 -1
- package/dist/templates/next-app/src/base/services/appApi/AppApiPlugin.ts +23 -1
- package/dist/templates/next-app/src/base/services/appApi/AppUserApiBootstrap.ts +2 -2
- package/dist/templates/next-app/src/base/types/PageProps.ts +1 -1
- package/dist/templates/next-app/src/core/bootstraps/BootstrapClient.ts +1 -0
- package/dist/templates/next-app/src/core/bootstraps/BootstrapServer.ts +1 -0
- package/dist/templates/next-app/src/core/globals.ts +2 -0
- package/dist/templates/next-app/src/core/serverIoc/ServerIOCRegister.ts +4 -1
- package/dist/templates/next-app/src/{base/cases → server}/PageParams.ts +1 -1
- package/dist/templates/next-app/src/server/port/DBBridgeInterface.ts +31 -0
- package/dist/templates/next-app/src/server/port/DBTableInterface.ts +1 -1
- package/dist/templates/next-app/src/server/repositorys/UserRepository.ts +6 -4
- package/dist/templates/next-app/src/server/services/AIService.ts +43 -0
- package/dist/templates/next-app/src/server/services/ApiUserService.ts +1 -1
- package/dist/templates/next-app/src/server/{SupabaseBridge.ts → sqlBridges/SupabaseBridge.ts} +16 -11
- package/dist/templates/next-app/src/server/validators/LoginValidator.ts +4 -4
- package/dist/templates/next-app/src/server/validators/PaginationValidator.ts +1 -1
- package/dist/templates/next-app/src/uikit/components/AdminLayout.tsx +32 -25
- package/dist/templates/next-app/src/uikit/components/BaseHeader.tsx +12 -26
- package/dist/templates/next-app/src/uikit/components/BaseLayout.tsx +37 -5
- package/dist/templates/next-app/src/uikit/components/ChatRoot.tsx +17 -0
- package/dist/templates/next-app/src/uikit/components/ClientSeo.tsx +36 -0
- package/dist/templates/next-app/src/uikit/components/LanguageSwitcher.tsx +5 -6
- package/dist/templates/next-app/src/uikit/components/LogoutButton.tsx +2 -0
- package/dist/templates/next-app/src/uikit/components/With.tsx +17 -0
- package/dist/templates/next-app/src/uikit/components/chat/ChatActionInterface.ts +30 -0
- package/dist/templates/next-app/src/uikit/components/chat/ChatFocusBar.tsx +65 -0
- package/dist/templates/next-app/src/uikit/components/chat/ChatMessages.tsx +59 -0
- package/dist/templates/next-app/src/uikit/components/chat/ChatWrap.tsx +28 -0
- package/dist/templates/next-app/src/uikit/components/chat/FocusBarActionInterface.ts +19 -0
- package/package.json +1 -1
- package/dist/templates/next-app/src/base/port/DBBridgeInterface.ts +0 -21
- package/dist/templates/next-app/src/base/port/DBMigrationInterface.ts +0 -92
- package/dist/templates/next-app/src/base/port/MigrationApiInterface.ts +0 -3
- package/dist/templates/next-app/src/base/port/ServerApiResponseInterface.ts +0 -6
- package/dist/templates/next-app/src/base/services/migrations/MigrationsApi.ts +0 -43
- package/dist/templates/next-app/config/i18n/{HomeI18n .ts → HomeI18n.ts} +0 -0
- package/dist/templates/next-app/{build → make}/generateLocales.ts +2 -2
- /package/dist/templates/next-app/src/{base → server}/port/PaginationInterface.ts +0 -0
- /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 '@/
|
|
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';
|
package/dist/templates/next-app/src/server/{SupabaseBridge.ts → sqlBridges/SupabaseBridge.ts}
RENAMED
|
@@ -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 '@/
|
|
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<
|
|
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<
|
|
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
|
-
|
|
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<
|
|
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<
|
|
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<
|
|
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<
|
|
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<
|
|
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
|
|
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({
|
|
20
|
+
const emailSchema = z.string().email({ message: V_EMAIL_INVALID });
|
|
21
21
|
|
|
22
22
|
const passwordSchema = z
|
|
23
23
|
.string()
|
|
24
|
-
.min(6, {
|
|
25
|
-
.max(50, {
|
|
26
|
-
.regex(/^\S+$/, {
|
|
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 '@/
|
|
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
|
-
|
|
7
|
-
|
|
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, {
|
|
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
|
|
64
|
-
|
|
65
|
-
<
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
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
|
-
|
|
96
|
-
|
|
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 {
|
|
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 = '/',
|
|
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="
|
|
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
|
|
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
|
|
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(
|
|
17
|
-
const
|
|
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
|
|
@@ -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
|
+
}
|