mirror of
https://github.com/lobehub/lobehub
synced 2026-04-21 17:47:27 +00:00
fix: Move email contact (#12323)
* fix: Move email contact * style: profile * fix: urk * fix: urk * feat: loading indicator * fix: build error * fix: sort * fix: sort * fix: sort * fix: sort * fix: sort
This commit is contained in:
parent
3f1473d65f
commit
5d19dbf430
20 changed files with 397 additions and 444 deletions
8
.github/ISSUE_TEMPLATE/1_bug_report.yml
vendored
8
.github/ISSUE_TEMPLATE/1_bug_report.yml
vendored
|
|
@ -68,19 +68,19 @@ body:
|
|||
|
||||
- type: textarea
|
||||
attributes:
|
||||
label: '🐛 Bug Description'
|
||||
label: '🐛 What happened?'
|
||||
description: A clear and concise description of the bug, if the above option is `Other`, please also explain in detail.
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: textarea
|
||||
attributes:
|
||||
label: '📷 Recurrence Steps'
|
||||
description: A clear and concise description of how to recurrence.
|
||||
label: '📷 How to reproduce it?'
|
||||
description: A clear and concise description of how to reproduce.
|
||||
|
||||
- type: textarea
|
||||
attributes:
|
||||
label: '🚦 Expected Behavior'
|
||||
label: '🚦 What it should be?'
|
||||
description: A clear and concise description of what you expected to happen.
|
||||
|
||||
- type: textarea
|
||||
|
|
|
|||
|
|
@ -166,6 +166,8 @@
|
|||
"cmdk.search.files": "Files",
|
||||
"cmdk.search.folder": "Folder",
|
||||
"cmdk.search.folders": "Folders",
|
||||
"cmdk.search.knowledgeBase": "Library",
|
||||
"cmdk.search.knowledgeBases": "Libraries",
|
||||
"cmdk.search.loading": "Searching...",
|
||||
"cmdk.search.market": "Community",
|
||||
"cmdk.search.mcp": "MCP Server",
|
||||
|
|
@ -221,6 +223,7 @@
|
|||
"exportType.allAgentWithMessage": "Export All Agents and Messages",
|
||||
"exportType.globalSetting": "Export Global Settings",
|
||||
"feedback": "Feedback",
|
||||
"feedback.emailContact": "You can also email us at {{email}}",
|
||||
"feedback.errors.fileTooLarge": "File exceeds 5MB",
|
||||
"feedback.errors.submitFailed": "Submit failed. Try again.",
|
||||
"feedback.errors.teamNotFound": "Configuration error",
|
||||
|
|
@ -423,7 +426,7 @@
|
|||
"userPanel.community": "Community",
|
||||
"userPanel.data": "Data Storage",
|
||||
"userPanel.defaultNickname": "Community User",
|
||||
"userPanel.discord": "Community Support",
|
||||
"userPanel.discord": "Discord",
|
||||
"userPanel.docs": "Documentation",
|
||||
"userPanel.email": "Email Support",
|
||||
"userPanel.feedback": "Contact Us",
|
||||
|
|
|
|||
|
|
@ -166,6 +166,8 @@
|
|||
"cmdk.search.files": "文件",
|
||||
"cmdk.search.folder": "文件夹",
|
||||
"cmdk.search.folders": "文件夹",
|
||||
"cmdk.search.knowledgeBase": "知识库",
|
||||
"cmdk.search.knowledgeBases": "知识库",
|
||||
"cmdk.search.loading": "搜索中…",
|
||||
"cmdk.search.market": "社区",
|
||||
"cmdk.search.mcp": "MCP 服务器",
|
||||
|
|
@ -221,6 +223,7 @@
|
|||
"exportType.allAgentWithMessage": "导出所有助理和消息",
|
||||
"exportType.globalSetting": "导出全局设置",
|
||||
"feedback": "反馈与建议",
|
||||
"feedback.emailContact": "您也可以发送邮件至 {{email}}",
|
||||
"feedback.errors.fileTooLarge": "文件大小超过 5MB 限制",
|
||||
"feedback.errors.submitFailed": "提交失败,请重试",
|
||||
"feedback.errors.teamNotFound": "配置错误",
|
||||
|
|
@ -423,7 +426,7 @@
|
|||
"userPanel.community": "社区版",
|
||||
"userPanel.data": "数据存储",
|
||||
"userPanel.defaultNickname": "社区版用户",
|
||||
"userPanel.discord": "社区支持",
|
||||
"userPanel.discord": "Discord",
|
||||
"userPanel.docs": "使用文档",
|
||||
"userPanel.email": "邮件支持",
|
||||
"userPanel.feedback": "联系我们",
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@ import {
|
|||
documents,
|
||||
files,
|
||||
knowledgeBaseFiles,
|
||||
knowledgeBases,
|
||||
messages,
|
||||
topics,
|
||||
userMemories,
|
||||
|
|
@ -22,7 +23,8 @@ export type SearchResultType =
|
|||
| 'message'
|
||||
| 'mcp'
|
||||
| 'plugin'
|
||||
| 'communityAgent';
|
||||
| 'communityAgent'
|
||||
| 'knowledgeBase';
|
||||
|
||||
export interface BaseSearchResult {
|
||||
// 1=exact, 2=prefix, 3=contains
|
||||
|
|
@ -111,6 +113,11 @@ export interface PluginSearchResult extends BaseSearchResult {
|
|||
type: 'plugin';
|
||||
}
|
||||
|
||||
export interface KnowledgeBaseSearchResult extends BaseSearchResult {
|
||||
avatar: string | null;
|
||||
type: 'knowledgeBase';
|
||||
}
|
||||
|
||||
export interface AssistantSearchResult extends BaseSearchResult {
|
||||
author: string;
|
||||
avatar?: string | null;
|
||||
|
|
@ -131,7 +138,8 @@ export type SearchResult =
|
|||
| MemorySearchResult
|
||||
| MCPSearchResult
|
||||
| PluginSearchResult
|
||||
| AssistantSearchResult;
|
||||
| AssistantSearchResult
|
||||
| KnowledgeBaseSearchResult;
|
||||
|
||||
export interface SearchOptions {
|
||||
agentId?: string;
|
||||
|
|
@ -192,6 +200,9 @@ export class SearchRepo {
|
|||
if ((!type || type === 'memory') && limits.memory > 0) {
|
||||
searchPromises.push(this.searchMemories(trimmedQuery, limits.memory));
|
||||
}
|
||||
if ((!type || type === 'knowledgeBase') && limits.knowledgeBase > 0) {
|
||||
searchPromises.push(this.searchKnowledgeBases(trimmedQuery, limits.knowledgeBase));
|
||||
}
|
||||
|
||||
const results = await Promise.all(searchPromises);
|
||||
|
||||
|
|
@ -213,6 +224,7 @@ export class SearchRepo {
|
|||
agent: number;
|
||||
file: number;
|
||||
folder: number;
|
||||
knowledgeBase: number;
|
||||
memory: number;
|
||||
message: number;
|
||||
page: number;
|
||||
|
|
@ -225,6 +237,7 @@ export class SearchRepo {
|
|||
agent: type === 'agent' ? baseLimit : 0,
|
||||
file: type === 'file' ? baseLimit : 0,
|
||||
folder: type === 'folder' ? baseLimit : 0,
|
||||
knowledgeBase: type === 'knowledgeBase' ? baseLimit : 0,
|
||||
memory: type === 'memory' ? baseLimit : 0,
|
||||
message: type === 'message' ? baseLimit : 0,
|
||||
page: type === 'page' ? baseLimit : 0,
|
||||
|
|
@ -239,6 +252,7 @@ export class SearchRepo {
|
|||
agent: 3,
|
||||
file: 3,
|
||||
folder: 3,
|
||||
knowledgeBase: 3,
|
||||
memory: 3,
|
||||
message: 3,
|
||||
page: 6,
|
||||
|
|
@ -253,6 +267,7 @@ export class SearchRepo {
|
|||
agent: 3,
|
||||
file: 6,
|
||||
folder: 6,
|
||||
knowledgeBase: 6,
|
||||
memory: 3,
|
||||
message: 3,
|
||||
page: 3,
|
||||
|
|
@ -267,6 +282,7 @@ export class SearchRepo {
|
|||
agent: 3,
|
||||
file: 3,
|
||||
folder: 3,
|
||||
knowledgeBase: 3,
|
||||
memory: 3,
|
||||
message: 6,
|
||||
page: 3,
|
||||
|
|
@ -280,6 +296,7 @@ export class SearchRepo {
|
|||
agent: 3,
|
||||
file: 3,
|
||||
folder: 3,
|
||||
knowledgeBase: 3,
|
||||
memory: 3,
|
||||
message: 3,
|
||||
page: 3,
|
||||
|
|
@ -614,4 +631,40 @@ export class SearchRepo {
|
|||
updatedAt: row.updatedAt,
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
* Search knowledge bases by name and description
|
||||
*/
|
||||
private async searchKnowledgeBases(
|
||||
query: string,
|
||||
limit: number,
|
||||
): Promise<KnowledgeBaseSearchResult[]> {
|
||||
const searchTerm = `%${query}%`;
|
||||
|
||||
const rows = await this.db
|
||||
.select()
|
||||
.from(knowledgeBases)
|
||||
.where(
|
||||
and(
|
||||
eq(knowledgeBases.userId, this.userId),
|
||||
or(
|
||||
ilike(knowledgeBases.name, searchTerm),
|
||||
ilike(sql`COALESCE(${knowledgeBases.description}, '')`, searchTerm),
|
||||
),
|
||||
),
|
||||
)
|
||||
.orderBy(desc(knowledgeBases.updatedAt))
|
||||
.limit(limit);
|
||||
|
||||
return rows.map((row) => ({
|
||||
avatar: row.avatar,
|
||||
createdAt: row.createdAt,
|
||||
description: row.description,
|
||||
id: row.id,
|
||||
relevance: this.calculateRelevance(row.name, query),
|
||||
title: row.name,
|
||||
type: 'knowledgeBase' as const,
|
||||
updatedAt: row.updatedAt,
|
||||
}));
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
'use client';
|
||||
|
||||
import { BRANDING_EMAIL } from '@lobechat/business-const';
|
||||
import { Button, Flexbox, Icon, Modal } from '@lobehub/ui';
|
||||
import { App, Form, Input, Upload } from 'antd';
|
||||
import { ImagePlus, Send } from 'lucide-react';
|
||||
|
|
@ -126,6 +127,10 @@ const FeedbackModal = memo<FeedbackModalProps>(({ initialValues, onClose, open }
|
|||
}
|
||||
onCancel={handleCancel}
|
||||
>
|
||||
<p style={{ color: 'var(--colorTextSecondary)', fontSize: 14, marginBottom: 16 }}>
|
||||
{t('feedback.emailContact', { email: BRANDING_EMAIL.business })}
|
||||
</p>
|
||||
|
||||
<Form form={form} initialValues={initialValues} layout="vertical">
|
||||
<Form.Item
|
||||
label={t('feedback.fields.title.label')}
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@ import {
|
|||
ChevronRight,
|
||||
FileText,
|
||||
Folder,
|
||||
Library,
|
||||
MessageCircle,
|
||||
MessageSquare,
|
||||
Plug,
|
||||
|
|
@ -111,6 +112,10 @@ const SearchResults = memo<SearchResultsProps>(
|
|||
navigate(`/memory/preferences?preferenceId=${result.id}`);
|
||||
break;
|
||||
}
|
||||
case 'knowledgeBase': {
|
||||
navigate(`/resource/library/${result.id}`);
|
||||
break;
|
||||
}
|
||||
}
|
||||
onClose();
|
||||
};
|
||||
|
|
@ -147,6 +152,9 @@ const SearchResults = memo<SearchResultsProps>(
|
|||
case 'memory': {
|
||||
return <Brain size={16} />;
|
||||
}
|
||||
case 'knowledgeBase': {
|
||||
return <Library size={16} />;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
|
|
@ -182,6 +190,9 @@ const SearchResults = memo<SearchResultsProps>(
|
|||
case 'memory': {
|
||||
return t('cmdk.search.memory');
|
||||
}
|
||||
case 'knowledgeBase': {
|
||||
return t('cmdk.search.knowledgeBase');
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
|
|
@ -232,6 +243,7 @@ const SearchResults = memo<SearchResultsProps>(
|
|||
const memoryResults = results.filter((r) => r.type === 'memory');
|
||||
const mcpResults = results.filter((r) => r.type === 'mcp');
|
||||
const pluginResults = results.filter((r) => r.type === 'plugin');
|
||||
const knowledgeBaseResults = results.filter((r) => r.type === 'knowledgeBase');
|
||||
const assistantResults = results.filter((r) => r.type === 'communityAgent');
|
||||
|
||||
// Don't render anything if no results and not loading
|
||||
|
|
@ -361,6 +373,13 @@ const SearchResults = memo<SearchResultsProps>(
|
|||
</Command.Group>
|
||||
)}
|
||||
|
||||
{knowledgeBaseResults.length > 0 && (
|
||||
<Command.Group forceMount>
|
||||
{knowledgeBaseResults.map((result) => renderResultItem(result))}
|
||||
{renderSearchMore('knowledgeBase', knowledgeBaseResults.length)}
|
||||
</Command.Group>
|
||||
)}
|
||||
|
||||
{mcpResults.length > 0 && (
|
||||
<Command.Group forceMount>
|
||||
{mcpResults.map((result) => renderResultItem(result))}
|
||||
|
|
|
|||
|
|
@ -114,7 +114,9 @@ const CommandMenuContent = memo<CommandMenuContentProps>(({ isClosing, onClose }
|
|||
<CommandInput />
|
||||
|
||||
<Command.List ref={listRef}>
|
||||
{!(hasSearch && (isSearching || searchResults.length > 0)) && (
|
||||
{/* Hide cmdk's Empty when we have search results or are loading them,
|
||||
since force-mounted items aren't counted by cmdk's internal filter */}
|
||||
{!(hasSearch && (searchResults.length > 0 || isSearching)) && (
|
||||
<Command.Empty>{t('cmdk.noResults')}</Command.Empty>
|
||||
)}
|
||||
|
||||
|
|
|
|||
|
|
@ -22,6 +22,7 @@ const VALID_TYPES = [
|
|||
'mcp',
|
||||
'plugin',
|
||||
'communityAgent',
|
||||
'knowledgeBase',
|
||||
] as const;
|
||||
|
||||
export type ValidSearchType = (typeof VALID_TYPES)[number];
|
||||
|
|
|
|||
|
|
@ -177,10 +177,7 @@ export default {
|
|||
'profile.emailInvalid': 'Please enter a valid email address',
|
||||
'profile.emailPlaceholder': 'new-email@example.com',
|
||||
'profile.fullName': 'Fullname',
|
||||
'profile.fullNameInputHint': 'Please enter your new fullname',
|
||||
'profile.interests': 'Interests',
|
||||
'profile.interestsAdd': 'Add',
|
||||
'profile.interestsPlaceholder': 'Enter an interest',
|
||||
'profile.password': 'Password',
|
||||
'profile.resetPasswordError': 'Failed to send password reset link',
|
||||
'profile.resetPasswordSent': 'Password reset link sent, please check your email',
|
||||
|
|
@ -202,7 +199,6 @@ export default {
|
|||
'profile.updateUsername': 'Update username',
|
||||
'profile.username': 'Username',
|
||||
'profile.usernameDuplicate': 'Username is already taken',
|
||||
'profile.usernameInputHint': 'Please enter your new username',
|
||||
'profile.usernamePlaceholder': 'Enter a username with letters, numbers, or underscores',
|
||||
'profile.usernameRequired': 'Username cannot be empty',
|
||||
'profile.usernameRule': 'Username can only contain letters, numbers, or underscores',
|
||||
|
|
|
|||
|
|
@ -229,6 +229,10 @@ export default {
|
|||
|
||||
'cmdk.search.folders': 'Folders',
|
||||
|
||||
'cmdk.search.knowledgeBase': 'Library',
|
||||
|
||||
'cmdk.search.knowledgeBases': 'Libraries',
|
||||
|
||||
'cmdk.search.loading': 'Searching...',
|
||||
|
||||
'cmdk.search.market': 'Community',
|
||||
|
|
@ -290,6 +294,7 @@ export default {
|
|||
'exportType.allAgentWithMessage': 'Export All Agents and Messages',
|
||||
'exportType.globalSetting': 'Export Global Settings',
|
||||
'feedback': 'Feedback',
|
||||
'feedback.emailContact': 'You can also email us at {{email}}',
|
||||
'feedback.errors.fileTooLarge': 'File exceeds 5MB',
|
||||
'feedback.errors.submitFailed': 'Submit failed. Try again.',
|
||||
'feedback.errors.teamNotFound': 'Configuration error',
|
||||
|
|
@ -499,7 +504,7 @@ export default {
|
|||
'userPanel.community': 'Community',
|
||||
'userPanel.data': 'Data Storage',
|
||||
'userPanel.defaultNickname': 'Community User',
|
||||
'userPanel.discord': 'Community Support',
|
||||
'userPanel.discord': 'Discord',
|
||||
'userPanel.docs': 'Documentation',
|
||||
'userPanel.email': 'Email Support',
|
||||
'userPanel.feedback': 'Contact Us',
|
||||
|
|
|
|||
|
|
@ -96,7 +96,7 @@ const CreatorRewardBanner = memo(() => {
|
|||
{t('home.creatorReward.subtitle')}
|
||||
</p>
|
||||
<div style={{ marginBlockStart: 4 }}>
|
||||
<a href={'#'} rel={'noopener noreferrer'} target={'_blank'}>
|
||||
<a href={'https://lobehub.com/creator?utm_source=lobehub'} rel={'noopener noreferrer'} target={'_blank'}>
|
||||
<Button type={'primary'}>{t('home.creatorReward.action')}</Button>
|
||||
</a>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
'use client';
|
||||
|
||||
import { BRANDING_EMAIL, SOCIAL_URL } from '@lobechat/business-const';
|
||||
import { SOCIAL_URL } from '@lobechat/business-const';
|
||||
import { useAnalytics } from '@lobehub/analytics/react';
|
||||
import { type MenuProps } from '@lobehub/ui';
|
||||
import { ActionIcon, DropdownMenu, Flexbox, Icon } from '@lobehub/ui';
|
||||
|
|
@ -12,7 +12,6 @@ import {
|
|||
FileClockIcon,
|
||||
FlaskConical,
|
||||
Github,
|
||||
Mail,
|
||||
Rocket,
|
||||
} from 'lucide-react';
|
||||
import { memo, useCallback, useEffect, useMemo, useState } from 'react';
|
||||
|
|
@ -22,7 +21,7 @@ import { Link } from 'react-router-dom';
|
|||
import ChangelogModal from '@/components/ChangelogModal';
|
||||
import HighlightNotification from '@/components/HighlightNotification';
|
||||
import LabsModal from '@/components/LabsModal';
|
||||
import { DOCUMENTS_REFER_URL, GITHUB, mailTo } from '@/const/url';
|
||||
import { DOCUMENTS_REFER_URL, GITHUB } from '@/const/url';
|
||||
import ThemeButton from '@/features/User/UserPanel/ThemeButton';
|
||||
import { useFeedbackModal } from '@/hooks/useFeedbackModal';
|
||||
import { useGlobalStore } from '@/store/global';
|
||||
|
|
@ -156,15 +155,6 @@ const Footer = memo(() => {
|
|||
</a>
|
||||
),
|
||||
},
|
||||
{
|
||||
icon: <Icon icon={Mail} />,
|
||||
key: 'email',
|
||||
label: (
|
||||
<a href={mailTo(BRANDING_EMAIL.support)} rel="noopener noreferrer" target="_blank">
|
||||
{t('userPanel.email')}
|
||||
</a>
|
||||
),
|
||||
},
|
||||
{
|
||||
type: 'divider',
|
||||
},
|
||||
|
|
|
|||
|
|
@ -1,8 +1,10 @@
|
|||
'use client';
|
||||
|
||||
import { LoadingOutlined } from '@ant-design/icons';
|
||||
import { Flexbox, Text } from '@lobehub/ui';
|
||||
import { Flexbox, Icon, Text } from '@lobehub/ui';
|
||||
import { Spin, Upload } from 'antd';
|
||||
import { createStaticStyles, cssVar } from 'antd-style';
|
||||
import { PencilIcon } from 'lucide-react';
|
||||
import { useCallback, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
|
|
@ -15,6 +17,36 @@ import { createUploadImageHandler } from '@/utils/uploadFIle';
|
|||
|
||||
import { labelStyle, rowStyle } from './ProfileRow';
|
||||
|
||||
const styles = createStaticStyles(({ css }) => ({
|
||||
overlay: css`
|
||||
cursor: pointer;
|
||||
|
||||
position: absolute;
|
||||
z-index: 1;
|
||||
inset: 0;
|
||||
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
|
||||
opacity: 0;
|
||||
background: ${cssVar.colorBgMask};
|
||||
border-radius: 8px;
|
||||
|
||||
transition: opacity ${cssVar.motionDurationMid} ease;
|
||||
`,
|
||||
wrapper: css`
|
||||
cursor: pointer;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
border-radius: 8px;
|
||||
|
||||
&:hover .avatar-edit-overlay {
|
||||
opacity: 1;
|
||||
}
|
||||
`,
|
||||
}));
|
||||
|
||||
interface AvatarRowProps {
|
||||
mobile?: boolean;
|
||||
}
|
||||
|
|
@ -56,42 +88,33 @@ const AvatarRow = ({ mobile }: AvatarRowProps) => {
|
|||
const canUpload = isLogin;
|
||||
|
||||
const avatarContent = canUpload ? (
|
||||
<Spin indicator={<LoadingOutlined spin />} spinning={uploading}>
|
||||
<Upload beforeUpload={handleUploadAvatar} itemRender={() => void 0} maxCount={1}>
|
||||
<UserAvatar clickable size={40} />
|
||||
</Upload>
|
||||
</Spin>
|
||||
<Upload beforeUpload={handleUploadAvatar} itemRender={() => void 0} maxCount={1}>
|
||||
<Spin indicator={<LoadingOutlined spin />} spinning={uploading}>
|
||||
<div className={styles.wrapper}>
|
||||
<UserAvatar size={40} />
|
||||
<div className={`${styles.overlay} avatar-edit-overlay`}>
|
||||
<Icon color={cssVar.colorTextLightSolid} icon={PencilIcon} size={16} />
|
||||
</div>
|
||||
</div>
|
||||
</Spin>
|
||||
</Upload>
|
||||
) : (
|
||||
<UserAvatar size={40} />
|
||||
);
|
||||
|
||||
const updateAction = canUpload ? (
|
||||
<Upload beforeUpload={handleUploadAvatar} itemRender={() => void 0} maxCount={1}>
|
||||
<Text fontSize={13} style={{ cursor: 'pointer' }}>
|
||||
{t('profile.updateAvatar')}
|
||||
</Text>
|
||||
</Upload>
|
||||
) : null;
|
||||
|
||||
if (mobile) {
|
||||
return (
|
||||
<Flexbox gap={12} style={rowStyle}>
|
||||
<Flexbox horizontal align="center" justify="space-between">
|
||||
<Text strong>{t('profile.avatar')}</Text>
|
||||
{updateAction}
|
||||
</Flexbox>
|
||||
<Flexbox>{avatarContent}</Flexbox>
|
||||
<Flexbox horizontal align="center" gap={12} justify="space-between" style={rowStyle}>
|
||||
<Text strong>{t('profile.avatar')}</Text>
|
||||
{avatarContent}
|
||||
</Flexbox>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Flexbox horizontal align="center" gap={24} justify="space-between" style={rowStyle}>
|
||||
<Flexbox horizontal align="center" gap={24} style={{ flex: 1 }}>
|
||||
<Text style={labelStyle}>{t('profile.avatar')}</Text>
|
||||
<Flexbox style={{ flex: 1 }}>{avatarContent}</Flexbox>
|
||||
</Flexbox>
|
||||
{updateAction}
|
||||
<Flexbox horizontal align="center" gap={24} style={rowStyle}>
|
||||
<Text style={labelStyle}>{t('profile.avatar')}</Text>
|
||||
<Flexbox align="flex-end" style={{ flex: 1 }}>{avatarContent}</Flexbox>
|
||||
</Flexbox>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -1,15 +1,16 @@
|
|||
'use client';
|
||||
|
||||
import { Button, Flexbox, Input, Text } from '@lobehub/ui';
|
||||
import { AnimatePresence, m as motion } from 'motion/react';
|
||||
import { useCallback, useState } from 'react';
|
||||
import { LoadingOutlined } from '@ant-design/icons';
|
||||
import { Flexbox, Input, Text } from '@lobehub/ui';
|
||||
import { type InputRef, Spin } from 'antd';
|
||||
import { useCallback, useRef, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import { fetchErrorNotification } from '@/components/Error/fetchErrorNotification';
|
||||
import { useUserStore } from '@/store/user';
|
||||
import { userProfileSelectors } from '@/store/user/selectors';
|
||||
|
||||
import { labelStyle, rowStyle } from './ProfileRow';
|
||||
import { INPUT_WIDTH, labelStyle, rowStyle } from './ProfileRow';
|
||||
|
||||
interface FullNameRowProps {
|
||||
mobile?: boolean;
|
||||
|
|
@ -19,27 +20,16 @@ const FullNameRow = ({ mobile }: FullNameRowProps) => {
|
|||
const { t } = useTranslation('auth');
|
||||
const fullName = useUserStore(userProfileSelectors.fullName);
|
||||
const updateFullName = useUserStore((s) => s.updateFullName);
|
||||
const [isEditing, setIsEditing] = useState(false);
|
||||
const [editValue, setEditValue] = useState('');
|
||||
const [saving, setSaving] = useState(false);
|
||||
|
||||
const handleStartEdit = () => {
|
||||
setEditValue(fullName || '');
|
||||
setIsEditing(true);
|
||||
};
|
||||
|
||||
const handleCancel = () => {
|
||||
setIsEditing(false);
|
||||
setEditValue('');
|
||||
};
|
||||
const inputRef = useRef<InputRef>(null);
|
||||
|
||||
const handleSave = useCallback(async () => {
|
||||
if (!editValue.trim()) return;
|
||||
const value = inputRef.current?.input?.value?.trim();
|
||||
if (!value || value === fullName) return;
|
||||
|
||||
try {
|
||||
setSaving(true);
|
||||
await updateFullName(editValue.trim());
|
||||
setIsEditing(false);
|
||||
await updateFullName(value);
|
||||
} catch (error) {
|
||||
console.error('Failed to update fullName:', error);
|
||||
fetchErrorNotification.error({
|
||||
|
|
@ -49,81 +39,39 @@ const FullNameRow = ({ mobile }: FullNameRowProps) => {
|
|||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
}, [editValue, updateFullName]);
|
||||
}, [fullName, updateFullName]);
|
||||
|
||||
const editingContent = (
|
||||
<motion.div
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
exit={{ opacity: 0, y: -10 }}
|
||||
initial={{ opacity: 0, y: -10 }}
|
||||
key="editing"
|
||||
transition={{ duration: 0.2 }}
|
||||
>
|
||||
<Flexbox gap={12}>
|
||||
{!mobile && <Text strong>{t('profile.fullNameInputHint')}</Text>}
|
||||
<Input
|
||||
autoFocus
|
||||
showCount
|
||||
maxLength={64}
|
||||
placeholder={t('profile.fullName')}
|
||||
value={editValue}
|
||||
onChange={(e) => setEditValue(e.target.value)}
|
||||
onPressEnter={handleSave}
|
||||
/>
|
||||
<Flexbox horizontal gap={8} justify="flex-end">
|
||||
<Button disabled={saving} size="small" onClick={handleCancel}>
|
||||
{t('profile.cancel')}
|
||||
</Button>
|
||||
<Button loading={saving} size="small" type="primary" onClick={handleSave}>
|
||||
{t('profile.save')}
|
||||
</Button>
|
||||
</Flexbox>
|
||||
</Flexbox>
|
||||
</motion.div>
|
||||
);
|
||||
|
||||
const displayContent = (
|
||||
<motion.div
|
||||
animate={{ opacity: 1 }}
|
||||
exit={{ opacity: 0 }}
|
||||
initial={{ opacity: 0 }}
|
||||
key="display"
|
||||
transition={{ duration: 0.2 }}
|
||||
>
|
||||
{mobile ? (
|
||||
<Text>{fullName || '--'}</Text>
|
||||
) : (
|
||||
<Flexbox horizontal align="center" justify="space-between">
|
||||
<Text>{fullName || '--'}</Text>
|
||||
<Text style={{ cursor: 'pointer', fontSize: 13 }} onClick={handleStartEdit}>
|
||||
{t('profile.updateFullName')}
|
||||
</Text>
|
||||
</Flexbox>
|
||||
)}
|
||||
</motion.div>
|
||||
const input = (
|
||||
<Flexbox horizontal align="center" gap={8}>
|
||||
{saving && <Spin indicator={<LoadingOutlined spin />} size="small" />}
|
||||
<Input
|
||||
defaultValue={fullName || ''}
|
||||
disabled={saving}
|
||||
key={fullName}
|
||||
placeholder={t('profile.fullName')}
|
||||
ref={inputRef}
|
||||
style={mobile ? undefined : { textAlign: 'right', width: INPUT_WIDTH }}
|
||||
variant="filled"
|
||||
onBlur={handleSave}
|
||||
onPressEnter={handleSave}
|
||||
/>
|
||||
</Flexbox>
|
||||
);
|
||||
|
||||
if (mobile) {
|
||||
return (
|
||||
<Flexbox gap={12} style={rowStyle}>
|
||||
<Flexbox horizontal align="center" justify="space-between">
|
||||
<Text strong>{t('profile.fullName')}</Text>
|
||||
{!isEditing && (
|
||||
<Text style={{ cursor: 'pointer', fontSize: 13 }} onClick={handleStartEdit}>
|
||||
{t('profile.updateFullName')}
|
||||
</Text>
|
||||
)}
|
||||
</Flexbox>
|
||||
<AnimatePresence mode="wait">{isEditing ? editingContent : displayContent}</AnimatePresence>
|
||||
<Text strong>{t('profile.fullName')}</Text>
|
||||
{input}
|
||||
</Flexbox>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Flexbox horizontal gap={24} style={rowStyle}>
|
||||
<Flexbox horizontal align="center" gap={24} style={rowStyle}>
|
||||
<Text style={labelStyle}>{t('profile.fullName')}</Text>
|
||||
<Flexbox style={{ flex: 1 }}>
|
||||
<AnimatePresence mode="wait">{isEditing ? editingContent : displayContent}</AnimatePresence>
|
||||
<Flexbox align="flex-end" style={{ flex: 1 }}>
|
||||
{input}
|
||||
</Flexbox>
|
||||
</Flexbox>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -1,9 +1,8 @@
|
|||
'use client';
|
||||
|
||||
import { Block, Button, Flexbox, Icon, Input, Tag, Text } from '@lobehub/ui';
|
||||
import { Block, Flexbox, Icon, Input, Text } from '@lobehub/ui';
|
||||
import { cssVar } from 'antd-style';
|
||||
import { BriefcaseIcon } from 'lucide-react';
|
||||
import { AnimatePresence, m as motion } from 'motion/react';
|
||||
import { useCallback, useMemo, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
|
|
@ -23,8 +22,6 @@ const InterestsRow = ({ mobile }: InterestsRowProps) => {
|
|||
const { t: tOnboarding } = useTranslation('onboarding');
|
||||
const interests = useUserStore(userProfileSelectors.interests);
|
||||
const updateInterests = useUserStore((s) => s.updateInterests);
|
||||
const [isEditing, setIsEditing] = useState(false);
|
||||
const [selectedInterests, setSelectedInterests] = useState<string[]>([]);
|
||||
const [customInput, setCustomInput] = useState('');
|
||||
const [showCustomInput, setShowCustomInput] = useState(false);
|
||||
const [saving, setSaving] = useState(false);
|
||||
|
|
@ -38,50 +35,38 @@ const InterestsRow = ({ mobile }: InterestsRowProps) => {
|
|||
[tOnboarding],
|
||||
);
|
||||
|
||||
const handleStartEdit = () => {
|
||||
setSelectedInterests([...interests]);
|
||||
setIsEditing(true);
|
||||
};
|
||||
const toggleInterest = useCallback(
|
||||
async (label: string) => {
|
||||
const updated = interests.includes(label)
|
||||
? interests.filter((i) => i !== label)
|
||||
: [...interests, label];
|
||||
|
||||
const handleCancel = () => {
|
||||
setIsEditing(false);
|
||||
setSelectedInterests([]);
|
||||
setCustomInput('');
|
||||
setShowCustomInput(false);
|
||||
};
|
||||
try {
|
||||
setSaving(true);
|
||||
await updateInterests(updated);
|
||||
} catch (error) {
|
||||
console.error('Failed to update interests:', error);
|
||||
fetchErrorNotification.error({
|
||||
errorMessage: error instanceof Error ? error.message : String(error),
|
||||
status: 500,
|
||||
});
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
},
|
||||
[interests, updateInterests],
|
||||
);
|
||||
|
||||
const toggleInterest = useCallback((label: string) => {
|
||||
setSelectedInterests((prev) =>
|
||||
prev.includes(label) ? prev.filter((i) => i !== label) : [...prev, label],
|
||||
);
|
||||
}, []);
|
||||
|
||||
const handleAddCustom = useCallback(() => {
|
||||
const handleAddCustom = useCallback(async () => {
|
||||
const trimmed = customInput.trim();
|
||||
if (trimmed && !selectedInterests.includes(trimmed)) {
|
||||
setSelectedInterests((prev) => [...prev, trimmed]);
|
||||
setCustomInput('');
|
||||
}
|
||||
}, [customInput, selectedInterests]);
|
||||
if (!trimmed || interests.includes(trimmed)) return;
|
||||
|
||||
const handleSave = useCallback(async () => {
|
||||
// Include custom input if has content
|
||||
const finalInterests = [...selectedInterests];
|
||||
const trimmedCustom = customInput.trim();
|
||||
if (showCustomInput && trimmedCustom && !finalInterests.includes(trimmedCustom)) {
|
||||
finalInterests.push(trimmedCustom);
|
||||
}
|
||||
|
||||
// Deduplicate
|
||||
const uniqueInterests = [...new Set(finalInterests)];
|
||||
const updated = [...interests, trimmed];
|
||||
setCustomInput('');
|
||||
|
||||
try {
|
||||
setSaving(true);
|
||||
await updateInterests(uniqueInterests);
|
||||
setIsEditing(false);
|
||||
setSelectedInterests([]);
|
||||
setCustomInput('');
|
||||
setShowCustomInput(false);
|
||||
await updateInterests(updated);
|
||||
} catch (error) {
|
||||
console.error('Failed to update interests:', error);
|
||||
fetchErrorNotification.error({
|
||||
|
|
@ -91,155 +76,97 @@ const InterestsRow = ({ mobile }: InterestsRowProps) => {
|
|||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
}, [selectedInterests, customInput, showCustomInput, updateInterests]);
|
||||
}, [customInput, interests, updateInterests]);
|
||||
|
||||
const editingContent = (
|
||||
<motion.div
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
exit={{ opacity: 0, y: -10 }}
|
||||
initial={{ opacity: 0, y: -10 }}
|
||||
key="editing"
|
||||
transition={{ duration: 0.2 }}
|
||||
>
|
||||
<Flexbox gap={12}>
|
||||
<Flexbox horizontal align="center" gap={8} wrap="wrap">
|
||||
{areas.map((item) => {
|
||||
const isSelected = selectedInterests.includes(item.label);
|
||||
return (
|
||||
<Block
|
||||
clickable
|
||||
horizontal
|
||||
gap={8}
|
||||
key={item.key}
|
||||
padding={8}
|
||||
variant="outlined"
|
||||
style={
|
||||
isSelected
|
||||
? {
|
||||
background: cssVar.colorFillSecondary,
|
||||
borderColor: cssVar.colorFillSecondary,
|
||||
}
|
||||
: {}
|
||||
}
|
||||
onClick={() => toggleInterest(item.label)}
|
||||
>
|
||||
<Icon color={cssVar.colorTextSecondary} icon={item.icon} size={14} />
|
||||
<Text fontSize={13} weight={500}>
|
||||
{item.label}
|
||||
</Text>
|
||||
</Block>
|
||||
);
|
||||
})}
|
||||
{/* Render custom interests with same Block style but no icon */}
|
||||
{selectedInterests
|
||||
.filter((i) => !areas.some((a) => a.label === i))
|
||||
.map((interest) => (
|
||||
<Block
|
||||
clickable
|
||||
key={interest}
|
||||
padding={8}
|
||||
variant="outlined"
|
||||
style={{
|
||||
background: cssVar.colorFillSecondary,
|
||||
borderColor: cssVar.colorFillSecondary,
|
||||
}}
|
||||
onClick={() => toggleInterest(interest)}
|
||||
>
|
||||
<Text fontSize={13} weight={500}>
|
||||
{interest}
|
||||
</Text>
|
||||
</Block>
|
||||
))}
|
||||
<Block
|
||||
clickable
|
||||
horizontal
|
||||
gap={8}
|
||||
padding={8}
|
||||
variant="outlined"
|
||||
style={
|
||||
showCustomInput
|
||||
? { background: cssVar.colorFillSecondary, borderColor: cssVar.colorFillSecondary }
|
||||
: {}
|
||||
}
|
||||
onClick={() => setShowCustomInput(!showCustomInput)}
|
||||
>
|
||||
<Icon color={cssVar.colorTextSecondary} icon={BriefcaseIcon} size={14} />
|
||||
<Text fontSize={13} weight={500}>
|
||||
{tOnboarding('interests.area.other')}
|
||||
</Text>
|
||||
</Block>
|
||||
</Flexbox>
|
||||
{showCustomInput && (
|
||||
<Input
|
||||
placeholder={tOnboarding('interests.placeholder')}
|
||||
size="small"
|
||||
style={{ width: 200 }}
|
||||
value={customInput}
|
||||
onChange={(e) => setCustomInput(e.target.value)}
|
||||
onPressEnter={handleAddCustom}
|
||||
/>
|
||||
)}
|
||||
<Flexbox horizontal gap={8} justify="flex-end">
|
||||
<Button disabled={saving} size="small" onClick={handleCancel}>
|
||||
{t('profile.cancel')}
|
||||
</Button>
|
||||
<Button loading={saving} size="small" type="primary" onClick={handleSave}>
|
||||
{t('profile.save')}
|
||||
</Button>
|
||||
</Flexbox>
|
||||
</Flexbox>
|
||||
</motion.div>
|
||||
);
|
||||
|
||||
const displayContent = (
|
||||
<motion.div
|
||||
animate={{ opacity: 1 }}
|
||||
exit={{ opacity: 0 }}
|
||||
initial={{ opacity: 0 }}
|
||||
key="display"
|
||||
transition={{ duration: 0.2 }}
|
||||
>
|
||||
{mobile ? (
|
||||
interests.length > 0 ? (
|
||||
<Flexbox horizontal gap={8} style={{ flexWrap: 'wrap' }}>
|
||||
{interests.map((interest) => (
|
||||
<Tag key={interest}>{interest}</Tag>
|
||||
))}
|
||||
</Flexbox>
|
||||
) : (
|
||||
<Text>--</Text>
|
||||
)
|
||||
) : (
|
||||
<Flexbox horizontal align="center" justify="space-between">
|
||||
{interests.length > 0 ? (
|
||||
<Flexbox horizontal gap={8} style={{ flexWrap: 'wrap' }}>
|
||||
{interests.map((interest) => (
|
||||
<Tag key={interest}>{interest}</Tag>
|
||||
))}
|
||||
</Flexbox>
|
||||
) : (
|
||||
<Text>--</Text>
|
||||
)}
|
||||
<Text style={{ cursor: 'pointer', fontSize: 13 }} onClick={handleStartEdit}>
|
||||
{t('profile.updateInterests')}
|
||||
const content = (
|
||||
<Flexbox gap={12}>
|
||||
<Flexbox horizontal align="center" gap={8} justify="flex-end" wrap="wrap">
|
||||
{areas.map((item) => {
|
||||
const isSelected = interests.includes(item.label);
|
||||
return (
|
||||
<Block
|
||||
clickable
|
||||
horizontal
|
||||
gap={8}
|
||||
key={item.key}
|
||||
padding={8}
|
||||
variant="outlined"
|
||||
style={
|
||||
isSelected
|
||||
? {
|
||||
background: cssVar.colorFillSecondary,
|
||||
borderColor: cssVar.colorFillSecondary,
|
||||
opacity: saving ? 0.6 : 1,
|
||||
}
|
||||
: { opacity: saving ? 0.6 : 1 }
|
||||
}
|
||||
onClick={() => !saving && toggleInterest(item.label)}
|
||||
>
|
||||
<Icon color={cssVar.colorTextSecondary} icon={item.icon} size={14} />
|
||||
<Text fontSize={13} weight={500}>
|
||||
{item.label}
|
||||
</Text>
|
||||
</Block>
|
||||
);
|
||||
})}
|
||||
{/* Render custom interests */}
|
||||
{interests
|
||||
.filter((i) => !areas.some((a) => a.label === i))
|
||||
.map((interest) => (
|
||||
<Block
|
||||
clickable
|
||||
key={interest}
|
||||
padding={8}
|
||||
variant="outlined"
|
||||
style={{
|
||||
background: cssVar.colorFillSecondary,
|
||||
borderColor: cssVar.colorFillSecondary,
|
||||
opacity: saving ? 0.6 : 1,
|
||||
}}
|
||||
onClick={() => !saving && toggleInterest(interest)}
|
||||
>
|
||||
<Text fontSize={13} weight={500}>
|
||||
{interest}
|
||||
</Text>
|
||||
</Block>
|
||||
))}
|
||||
<Block
|
||||
clickable
|
||||
horizontal
|
||||
gap={8}
|
||||
padding={8}
|
||||
variant="outlined"
|
||||
style={
|
||||
showCustomInput
|
||||
? { background: cssVar.colorFillSecondary, borderColor: cssVar.colorFillSecondary }
|
||||
: {}
|
||||
}
|
||||
onClick={() => setShowCustomInput(!showCustomInput)}
|
||||
>
|
||||
<Icon color={cssVar.colorTextSecondary} icon={BriefcaseIcon} size={14} />
|
||||
<Text fontSize={13} weight={500}>
|
||||
{tOnboarding('interests.area.other')}
|
||||
</Text>
|
||||
</Flexbox>
|
||||
</Block>
|
||||
</Flexbox>
|
||||
{showCustomInput && (
|
||||
<Input
|
||||
placeholder={tOnboarding('interests.placeholder')}
|
||||
size="small"
|
||||
style={{ width: 200 }}
|
||||
value={customInput}
|
||||
onChange={(e) => setCustomInput(e.target.value)}
|
||||
onPressEnter={handleAddCustom}
|
||||
/>
|
||||
)}
|
||||
</motion.div>
|
||||
</Flexbox>
|
||||
);
|
||||
|
||||
if (mobile) {
|
||||
return (
|
||||
<Flexbox gap={12} style={rowStyle}>
|
||||
<Flexbox horizontal align="center" justify="space-between">
|
||||
<Text strong>{t('profile.interests')}</Text>
|
||||
{!isEditing && (
|
||||
<Text style={{ cursor: 'pointer', fontSize: 13 }} onClick={handleStartEdit}>
|
||||
{t('profile.updateInterests')}
|
||||
</Text>
|
||||
)}
|
||||
</Flexbox>
|
||||
<AnimatePresence mode="wait">{isEditing ? editingContent : displayContent}</AnimatePresence>
|
||||
<Text strong>{t('profile.interests')}</Text>
|
||||
{content}
|
||||
</Flexbox>
|
||||
);
|
||||
}
|
||||
|
|
@ -247,9 +174,7 @@ const InterestsRow = ({ mobile }: InterestsRowProps) => {
|
|||
return (
|
||||
<Flexbox horizontal gap={24} style={rowStyle}>
|
||||
<Text style={labelStyle}>{t('profile.interests')}</Text>
|
||||
<Flexbox style={{ flex: 1 }}>
|
||||
<AnimatePresence mode="wait">{isEditing ? editingContent : displayContent}</AnimatePresence>
|
||||
</Flexbox>
|
||||
<Flexbox align="flex-end" style={{ flex: 1 }}>{content}</Flexbox>
|
||||
</Flexbox>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
'use client';
|
||||
|
||||
import { Text } from '@lobehub/ui';
|
||||
import { Button, Flexbox, Text } from '@lobehub/ui';
|
||||
import { useCallback, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
|
|
@ -8,7 +8,7 @@ import { notification } from '@/components/AntdStaticMethods';
|
|||
import { useUserStore } from '@/store/user';
|
||||
import { authSelectors, userProfileSelectors } from '@/store/user/selectors';
|
||||
|
||||
import ProfileRow from './ProfileRow';
|
||||
import { labelStyle, rowStyle } from './ProfileRow';
|
||||
|
||||
interface PasswordRowProps {
|
||||
mobile?: boolean;
|
||||
|
|
@ -43,25 +43,26 @@ const PasswordRow = ({ mobile }: PasswordRowProps) => {
|
|||
}
|
||||
}, [userProfile?.email, t]);
|
||||
|
||||
return (
|
||||
<ProfileRow
|
||||
label={t('profile.password')}
|
||||
mobile={mobile}
|
||||
action={
|
||||
<Text
|
||||
style={{
|
||||
cursor: sending ? 'default' : 'pointer',
|
||||
fontSize: 13,
|
||||
opacity: sending ? 0.5 : 1,
|
||||
}}
|
||||
onClick={sending ? undefined : handleChangePassword}
|
||||
>
|
||||
if (mobile) {
|
||||
return (
|
||||
<Flexbox horizontal align="center" gap={12} justify="space-between" style={rowStyle}>
|
||||
<Text strong>{t('profile.password')}</Text>
|
||||
<Button loading={sending} size="small" onClick={handleChangePassword}>
|
||||
{hasPasswordAccount ? t('profile.changePassword') : t('profile.setPassword')}
|
||||
</Text>
|
||||
}
|
||||
>
|
||||
<Text>{hasPasswordAccount ? '••••••' : '--'}</Text>
|
||||
</ProfileRow>
|
||||
</Button>
|
||||
</Flexbox>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Flexbox horizontal align="center" gap={24} style={rowStyle}>
|
||||
<Text style={labelStyle}>{t('profile.password')}</Text>
|
||||
<Flexbox align="flex-end" style={{ flex: 1 }}>
|
||||
<Button loading={sending} size="small" onClick={handleChangePassword}>
|
||||
{hasPasswordAccount ? t('profile.changePassword') : t('profile.setPassword')}
|
||||
</Button>
|
||||
</Flexbox>
|
||||
</Flexbox>
|
||||
);
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -20,6 +20,8 @@ export const labelStyle: CSSProperties = {
|
|||
width: 160,
|
||||
};
|
||||
|
||||
export const INPUT_WIDTH = 240;
|
||||
|
||||
const ProfileRow = ({ label, children, action, mobile }: ProfileRowProps) => {
|
||||
if (mobile) {
|
||||
return (
|
||||
|
|
@ -34,11 +36,9 @@ const ProfileRow = ({ label, children, action, mobile }: ProfileRowProps) => {
|
|||
}
|
||||
|
||||
return (
|
||||
<Flexbox horizontal align="center" gap={24} justify="space-between" style={rowStyle}>
|
||||
<Flexbox horizontal align="center" gap={24} style={{ flex: 1 }}>
|
||||
<Text style={labelStyle}>{label}</Text>
|
||||
<Flexbox style={{ flex: 1 }}>{children}</Flexbox>
|
||||
</Flexbox>
|
||||
<Flexbox horizontal align="center" gap={24} style={rowStyle}>
|
||||
<Text style={labelStyle}>{label}</Text>
|
||||
<Flexbox align="flex-end" style={{ flex: 1 }}>{children}</Flexbox>
|
||||
{action && <Flexbox>{action}</Flexbox>}
|
||||
</Flexbox>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -1,15 +1,16 @@
|
|||
'use client';
|
||||
|
||||
import { LoadingOutlined } from '@ant-design/icons';
|
||||
import { Button, Flexbox, Input, Text } from '@lobehub/ui';
|
||||
import { AnimatePresence, m as motion } from 'motion/react';
|
||||
import { type InputRef, Spin } from 'antd';
|
||||
import { type ChangeEvent } from 'react';
|
||||
import { useCallback, useState } from 'react';
|
||||
import { useCallback, useRef, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import { useUserStore } from '@/store/user';
|
||||
import { userProfileSelectors } from '@/store/user/selectors';
|
||||
|
||||
import { labelStyle, rowStyle } from './ProfileRow';
|
||||
import { INPUT_WIDTH, labelStyle, rowStyle } from './ProfileRow';
|
||||
|
||||
interface UsernameRowProps {
|
||||
mobile?: boolean;
|
||||
|
|
@ -19,25 +20,13 @@ const UsernameRow = ({ mobile }: UsernameRowProps) => {
|
|||
const { t } = useTranslation('auth');
|
||||
const username = useUserStore(userProfileSelectors.username);
|
||||
const updateUsername = useUserStore((s) => s.updateUsername);
|
||||
const [isEditing, setIsEditing] = useState(false);
|
||||
const [editValue, setEditValue] = useState('');
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [error, setError] = useState('');
|
||||
const [dirty, setDirty] = useState(false);
|
||||
const inputRef = useRef<InputRef>(null);
|
||||
|
||||
const usernameRegex = /^\w+$/;
|
||||
|
||||
const handleStartEdit = () => {
|
||||
setEditValue(username || '');
|
||||
setError('');
|
||||
setIsEditing(true);
|
||||
};
|
||||
|
||||
const handleCancel = () => {
|
||||
setIsEditing(false);
|
||||
setEditValue('');
|
||||
setError('');
|
||||
};
|
||||
|
||||
const validateUsername = (value: string): string => {
|
||||
const trimmed = value.trim();
|
||||
if (!trimmed) return t('profile.usernameRequired');
|
||||
|
|
@ -47,7 +36,13 @@ const UsernameRow = ({ mobile }: UsernameRowProps) => {
|
|||
};
|
||||
|
||||
const handleSave = useCallback(async () => {
|
||||
const validationError = validateUsername(editValue);
|
||||
const value = inputRef.current?.input?.value?.trim();
|
||||
if (!value || value === username) {
|
||||
setError('');
|
||||
return;
|
||||
}
|
||||
|
||||
const validationError = validateUsername(value);
|
||||
if (validationError) {
|
||||
setError(validationError);
|
||||
return;
|
||||
|
|
@ -56,11 +51,10 @@ const UsernameRow = ({ mobile }: UsernameRowProps) => {
|
|||
try {
|
||||
setSaving(true);
|
||||
setError('');
|
||||
await updateUsername(editValue.trim());
|
||||
setIsEditing(false);
|
||||
await updateUsername(value);
|
||||
setDirty(false);
|
||||
} catch (err: any) {
|
||||
console.error('Failed to update username:', err);
|
||||
// Handle duplicate username error
|
||||
if (err?.data?.code === 'CONFLICT' || err?.message === 'USERNAME_TAKEN') {
|
||||
setError(t('profile.usernameDuplicate'));
|
||||
} else {
|
||||
|
|
@ -69,104 +63,94 @@ const UsernameRow = ({ mobile }: UsernameRowProps) => {
|
|||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
}, [editValue, updateUsername, t]);
|
||||
}, [username, updateUsername, t]);
|
||||
|
||||
const handleInputChange = (e: ChangeEvent<HTMLInputElement>) => {
|
||||
const handleChange = (e: ChangeEvent<HTMLInputElement>) => {
|
||||
const value = e.target.value;
|
||||
setEditValue(value);
|
||||
|
||||
setDirty(value.trim() !== (username || ''));
|
||||
if (!value.trim()) {
|
||||
setError('');
|
||||
return;
|
||||
}
|
||||
|
||||
if (!usernameRegex.test(value)) {
|
||||
setError(t('profile.usernameRule'));
|
||||
return;
|
||||
}
|
||||
|
||||
setError('');
|
||||
};
|
||||
|
||||
const editingContent = (
|
||||
<motion.div
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
exit={{ opacity: 0, y: -10 }}
|
||||
initial={{ opacity: 0, y: -10 }}
|
||||
key="editing"
|
||||
transition={{ duration: 0.2 }}
|
||||
>
|
||||
<Flexbox gap={12}>
|
||||
{!mobile && <Text strong>{t('profile.usernameInputHint')}</Text>}
|
||||
<Input
|
||||
autoFocus
|
||||
showCount
|
||||
maxLength={64}
|
||||
placeholder={t('profile.usernamePlaceholder')}
|
||||
status={error ? 'error' : undefined}
|
||||
value={editValue}
|
||||
onChange={handleInputChange}
|
||||
onPressEnter={handleSave}
|
||||
/>
|
||||
const handleCancel = useCallback(() => {
|
||||
if (inputRef.current?.input) {
|
||||
const nativeInputValueSetter = Object.getOwnPropertyDescriptor(
|
||||
HTMLInputElement.prototype,
|
||||
'value',
|
||||
)?.set;
|
||||
nativeInputValueSetter?.call(inputRef.current.input, username || '');
|
||||
inputRef.current.input.dispatchEvent(new Event('input', { bubbles: true }));
|
||||
}
|
||||
setError('');
|
||||
setDirty(false);
|
||||
inputRef.current?.blur();
|
||||
}, [username]);
|
||||
|
||||
const input = (
|
||||
<Flexbox gap={4}>
|
||||
<Flexbox horizontal align="center" gap={8}>
|
||||
{saving && <Spin indicator={<LoadingOutlined spin />} size="small" />}
|
||||
{error && (
|
||||
<Text style={{ fontSize: 12 }} type="danger">
|
||||
<Text style={{ fontSize: 12, whiteSpace: 'nowrap' }} type="danger">
|
||||
{error}
|
||||
</Text>
|
||||
)}
|
||||
<Flexbox horizontal gap={8} justify="flex-end">
|
||||
<Button disabled={saving} size="small" onClick={handleCancel}>
|
||||
{dirty && !saving && (
|
||||
<Button
|
||||
onMouseDown={(e) => {
|
||||
e.preventDefault();
|
||||
handleCancel();
|
||||
}}
|
||||
size="small"
|
||||
variant="outlined"
|
||||
>
|
||||
{t('profile.cancel')}
|
||||
</Button>
|
||||
<Button loading={saving} size="small" type="primary" onClick={handleSave}>
|
||||
{t('profile.save')}
|
||||
</Button>
|
||||
</Flexbox>
|
||||
)}
|
||||
<Input
|
||||
defaultValue={username || ''}
|
||||
disabled={saving}
|
||||
key={username}
|
||||
placeholder={t('profile.usernamePlaceholder')}
|
||||
ref={inputRef}
|
||||
status={error ? 'error' : undefined}
|
||||
style={mobile ? undefined : { textAlign: 'right', width: INPUT_WIDTH }}
|
||||
variant="filled"
|
||||
onBlur={handleSave}
|
||||
onChange={handleChange}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Escape') {
|
||||
e.preventDefault();
|
||||
handleCancel();
|
||||
}
|
||||
}}
|
||||
onPressEnter={handleSave}
|
||||
/>
|
||||
</Flexbox>
|
||||
</motion.div>
|
||||
);
|
||||
|
||||
const displayContent = (
|
||||
<motion.div
|
||||
animate={{ opacity: 1 }}
|
||||
exit={{ opacity: 0 }}
|
||||
initial={{ opacity: 0 }}
|
||||
key="display"
|
||||
transition={{ duration: 0.2 }}
|
||||
>
|
||||
{mobile ? (
|
||||
<Text>{username || '--'}</Text>
|
||||
) : (
|
||||
<Flexbox horizontal align="center" justify="space-between">
|
||||
<Text>{username || '--'}</Text>
|
||||
<Text style={{ cursor: 'pointer', fontSize: 13 }} onClick={handleStartEdit}>
|
||||
{t('profile.updateUsername')}
|
||||
</Text>
|
||||
</Flexbox>
|
||||
)}
|
||||
</motion.div>
|
||||
</Flexbox>
|
||||
);
|
||||
|
||||
if (mobile) {
|
||||
return (
|
||||
<Flexbox gap={12} style={rowStyle}>
|
||||
<Flexbox horizontal align="center" justify="space-between">
|
||||
<Text strong>{t('profile.username')}</Text>
|
||||
{!isEditing && (
|
||||
<Text style={{ cursor: 'pointer', fontSize: 13 }} onClick={handleStartEdit}>
|
||||
{t('profile.updateUsername')}
|
||||
</Text>
|
||||
)}
|
||||
</Flexbox>
|
||||
<AnimatePresence mode="wait">{isEditing ? editingContent : displayContent}</AnimatePresence>
|
||||
<Text strong>{t('profile.username')}</Text>
|
||||
{input}
|
||||
</Flexbox>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Flexbox horizontal gap={24} style={rowStyle}>
|
||||
<Flexbox horizontal align="center" gap={24} style={rowStyle}>
|
||||
<Text style={labelStyle}>{t('profile.username')}</Text>
|
||||
<Flexbox style={{ flex: 1 }}>
|
||||
<AnimatePresence mode="wait">{isEditing ? editingContent : displayContent}</AnimatePresence>
|
||||
<Flexbox align="flex-end" style={{ flex: 1 }}>
|
||||
{input}
|
||||
</Flexbox>
|
||||
</Flexbox>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -28,21 +28,15 @@ const SkeletonRow = ({ mobile }: { mobile?: boolean }) => {
|
|||
if (mobile) {
|
||||
return (
|
||||
<Flexbox gap={12} style={rowStyle}>
|
||||
<Flexbox horizontal align="center" justify="space-between">
|
||||
<Skeleton.Button active size="small" style={{ height: 22, width: 60 }} />
|
||||
<Skeleton.Button active size="small" style={{ height: 22, width: 80 }} />
|
||||
</Flexbox>
|
||||
<Skeleton.Button active size="small" style={{ height: 22, width: 60 }} />
|
||||
<Skeleton.Button active size="small" style={{ height: 22, width: 120 }} />
|
||||
</Flexbox>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<Flexbox horizontal align="center" gap={24} justify="space-between" style={rowStyle}>
|
||||
<Flexbox horizontal align="center" gap={24} style={{ flex: 1 }}>
|
||||
<Skeleton.Button active size="small" style={{ ...labelStyle, height: 22 }} />
|
||||
<Skeleton.Button active size="small" style={{ height: 22, minWidth: 120, width: 160 }} />
|
||||
</Flexbox>
|
||||
<Skeleton.Button active size="small" style={{ height: 22, width: 100 }} />
|
||||
<Flexbox horizontal align="center" gap={24} style={rowStyle}>
|
||||
<Skeleton.Button active size="small" style={{ ...labelStyle, height: 22 }} />
|
||||
<Skeleton.Button active size="small" style={{ height: 22, marginInlineStart: 'auto', width: 120 }} />
|
||||
</Flexbox>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -56,6 +56,7 @@ export const searchRouter = router({
|
|||
'mcp',
|
||||
'plugin',
|
||||
'communityAgent',
|
||||
'knowledgeBase',
|
||||
])
|
||||
.optional(),
|
||||
}),
|
||||
|
|
@ -70,7 +71,7 @@ export const searchRouter = router({
|
|||
const searchPromises: Promise<any>[] = [];
|
||||
|
||||
// Database searches (agent, topic, file, folder, message, page, memory)
|
||||
if (!type || ['agent', 'topic', 'file', 'folder', 'message', 'page', 'memory'].includes(type)) {
|
||||
if (!type || ['agent', 'topic', 'file', 'folder', 'message', 'page', 'memory', 'knowledgeBase'].includes(type)) {
|
||||
searchPromises.push(ctx.searchRepo.search(input));
|
||||
}
|
||||
|
||||
|
|
|
|||
Loading…
Reference in a new issue