♻️ refactor: improve modal handling with createRawModal (#11071)

* feat: integrate TooltipGroup into SideBarLayout for enhanced UI interactions

Signed-off-by: Innei <tukon479@gmail.com>

* feat: refactor components to utilize createRawModal for improved modal handling and enhance UI interactions with TooltipGroup

Signed-off-by: Innei <tukon479@gmail.com>

* chore: update @lobehub/ui dependency to version 4.5.0 in package.json

Signed-off-by: Innei <tukon479@gmail.com>

---------

Signed-off-by: Innei <tukon479@gmail.com>
This commit is contained in:
Innei
2025-12-31 15:03:24 +08:00
committed by GitHub
parent f9d991b26c
commit f5314c5c32
13 changed files with 153 additions and 148 deletions
+1 -1
View File
@@ -208,7 +208,7 @@
"@lobehub/icons": "^4.0.2",
"@lobehub/market-sdk": "^0.25.0",
"@lobehub/tts": "^4.0.2",
"@lobehub/ui": "^4.4.5",
"@lobehub/ui": "^4.5.0",
"@modelcontextprotocol/sdk": "^1.25.1",
"@neondatabase/serverless": "^1.0.2",
"@next/third-parties": "^16.1.1",
@@ -1,8 +1,9 @@
import { Flexbox, Tag, Tooltip } from '@lobehub/ui';
import { Flexbox, Tag, Tooltip, createRawModal } from '@lobehub/ui';
import { Progress } from 'antd';
import { createStaticStyles, cssVar } from 'antd-style';
import { memo, useState } from 'react';
import { memo } from 'react';
import { useEventCallback } from '@/hooks/useEventCallback';
import { useFileStore } from '@/store/file';
import { UPLOAD_STATUS_SET, type UploadFileItem } from '@/types/files/upload';
@@ -65,39 +66,34 @@ type FileItemProps = UploadFileItem;
const ContextItem = memo<FileItemProps>((props) => {
const { file, id, status, uploadState } = props;
const [removeChatUploadFile] = useFileStore((s) => [s.removeChatUploadFile]);
const [previewOpen, setPreviewOpen] = useState(false);
const basename = getFileBasename(file.name);
const isUploading = UPLOAD_STATUS_SET.has(status);
const progress = uploadState?.progress ?? 0;
const handleClick = () => {
setPreviewOpen(true);
};
const handleClick = useEventCallback(() => {
createRawModal(FilePreviewModal, {
file: props,
});
});
const handleClose = useEventCallback(() => {
removeChatUploadFile(id);
});
return (
<>
<Tag closable onClick={handleClick} onClose={() => removeChatUploadFile(id)} size={'large'}>
<Flexbox className={styles.icon}>
<Content {...props} />
{isUploading && (
<div className={styles.progress}>
<Progress
percent={progress}
showInfo={false}
size={14}
strokeWidth={2}
type="circle"
/>
</div>
)}
</Flexbox>
<Tooltip title={file.name}>
<span className={styles.name}>{basename}</span>
</Tooltip>
</Tag>
<FilePreviewModal file={props} onClose={() => setPreviewOpen(false)} open={previewOpen} />
</>
<Tag closable onClick={handleClick} onClose={handleClose} size={'large'}>
<Flexbox className={styles.icon}>
<Content {...props} />
{isUploading && (
<div className={styles.progress}>
<Progress percent={progress} showInfo={false} size={14} strokeWidth={2} type="circle" />
</div>
)}
</Flexbox>
<Tooltip title={file.name}>
<span className={styles.name}>{basename}</span>
</Tooltip>
</Tag>
);
});
@@ -49,7 +49,7 @@ const ChatItem = memo<ChatItemProps>(
<ErrorContent customErrorRender={customErrorRender} error={error} id={id} />
);
const avtarContent = (
const avatarContent = (
<Avatar
alt={avatarProps?.alt || avatar.title || 'avatar'}
loading={loading}
@@ -84,7 +84,7 @@ const ChatItem = memo<ChatItemProps>(
gap={8}
>
{showAvatar &&
(customAvatarRender ? customAvatarRender(avatar, avtarContent) : avtarContent)}
(customAvatarRender ? customAvatarRender(avatar, avatarContent) : avatarContent)}
<Title avatar={avatar} showTitle={showTitle} time={time} titleAddon={titleAddon} />
</Flexbox>
<Flexbox
@@ -1,40 +1,39 @@
import { ActionIcon } from '@lobehub/ui';
import { ActionIcon, createRawModal } from '@lobehub/ui';
import { LucideSettings } from 'lucide-react';
import dynamic from 'next/dynamic';
import { memo, useState } from 'react';
import { memo } from 'react';
import { useTranslation } from 'react-i18next';
import type { PluginDetailModalProps } from '@/features/PluginDetailModal';
import { pluginHelpers, useToolStore } from '@/store/tool';
import { pluginSelectors } from '@/store/tool/selectors';
const PluginDetailModal = dynamic(() => import('@/features/PluginDetailModal'), { ssr: false });
const PluginDetailModal = dynamic<PluginDetailModalProps>(
() => import('@/features/PluginDetailModal'),
{
ssr: false,
},
);
const Settings = memo<{ id: string }>(({ id }) => {
const item = useToolStore(pluginSelectors.getToolManifestById(id));
const [open, setOpen] = useState(false);
const { t } = useTranslation('plugin');
const hasSettings = pluginHelpers.isSettingSchemaNonEmpty(item?.settings);
if (!hasSettings) return;
return (
<>
<ActionIcon
icon={LucideSettings}
onClick={() => {
setOpen(true);
}}
size={'small'}
title={t('setting')}
/>
<PluginDetailModal
id={id}
onClose={() => {
setOpen(false);
}}
open={open}
schema={item?.settings}
/>
</>
<ActionIcon
icon={LucideSettings}
onClick={() => {
createRawModal(PluginDetailModal, {
id,
schema: item?.settings,
});
}}
size={'small'}
title={t('setting')}
/>
);
});
+14 -12
View File
@@ -1,4 +1,4 @@
import { Flexbox, type FlexboxProps } from '@lobehub/ui';
import { Flexbox, type FlexboxProps, TooltipGroup } from '@lobehub/ui';
import { type CSSProperties, type ReactNode, memo } from 'react';
import ToggleLeftPanelButton from '@/features/NavPanel/ToggleLeftPanelButton';
@@ -35,18 +35,20 @@ const NavHeader = memo<NavHeaderProps>(
style={style}
{...rest}
>
<Flexbox align={'center'} gap={2} horizontal justify={'flex-start'} style={styles?.left}>
{showTogglePanelButton && !expand && <ToggleLeftPanelButton />}
{left}
</Flexbox>
{children && (
<Flexbox flex={1} style={styles?.center}>
{children}
<TooltipGroup>
<Flexbox align={'center'} gap={2} horizontal justify={'flex-start'} style={styles?.left}>
{showTogglePanelButton && !expand && <ToggleLeftPanelButton />}
{left}
</Flexbox>
)}
<Flexbox align={'center'} gap={2} horizontal justify={'flex-end'} style={styles?.right}>
{right}
</Flexbox>
{children && (
<Flexbox flex={1} style={styles?.center}>
{children}
</Flexbox>
)}
<Flexbox align={'center'} gap={2} horizontal justify={'flex-end'} style={styles?.right}>
{right}
</Flexbox>
</TooltipGroup>
</Flexbox>
);
},
+4 -2
View File
@@ -1,4 +1,4 @@
import { Flexbox, ScrollShadow } from '@lobehub/ui';
import { Flexbox, ScrollShadow, TooltipGroup } from '@lobehub/ui';
import { type ReactNode, Suspense, memo } from 'react';
import Footer from '@/app/[variants]/(main)/home/_layout/Footer';
@@ -15,7 +15,9 @@ const SideBarLayout = memo<SidebarLayoutProps>(({ header, body, footer }) => {
<Flexbox gap={4} style={{ height: '100%', overflow: 'hidden' }}>
<Suspense fallback={<SkeletonItem height={44} style={{ marginTop: 8 }} />}>{header}</Suspense>
<ScrollShadow size={2} style={{ height: '100%' }}>
<Suspense fallback={<SkeletonList paddingBlock={8} />}>{body}</Suspense>
<TooltipGroup>
<Suspense fallback={<SkeletonList paddingBlock={8} />}>{body}</Suspense>
</TooltipGroup>
</ScrollShadow>
<Suspense>{footer || <Footer />}</Suspense>
</Flexbox>
+2 -2
View File
@@ -9,11 +9,11 @@ import { pluginHelpers } from '@/store/tool';
import APIs from './APIs';
import Meta from './Meta';
interface PluginDetailModalProps {
export interface PluginDetailModalProps {
id: string;
onClose: () => void;
onTabChange?: (key: string) => void;
open?: boolean;
open: boolean;
schema: any;
tab?: string;
}
@@ -20,7 +20,7 @@ const DropdownMenu = memo<DropdownMenuProps>(
// Only compute dropdown items when dropdown is actually open
// This prevents expensive hook execution for all 20-25 visible items
const { menuItems, moveModal } = useFileItemDropdown({
const { menuItems } = useFileItemDropdown({
enabled: isOpen,
fileType,
filename,
@@ -32,12 +32,9 @@ const DropdownMenu = memo<DropdownMenuProps>(
});
return (
<>
<Dropdown menu={{ items: menuItems }} onOpenChange={setIsOpen} open={isOpen}>
<ActionIcon icon={MoreHorizontalIcon} size={'small'} />
</Dropdown>
{moveModal}
</>
<Dropdown menu={{ items: menuItems }} onOpenChange={setIsOpen} open={isOpen}>
<ActionIcon icon={MoreHorizontalIcon} size={'small'} />
</Dropdown>
);
},
);
@@ -1,4 +1,4 @@
import { Icon, copyToClipboard } from '@lobehub/ui';
import { Icon, copyToClipboard, createRawModal } from '@lobehub/ui';
import { App } from 'antd';
import { type ItemType } from 'antd/es/menu/interface';
import {
@@ -10,7 +10,7 @@ import {
PencilIcon,
Trash,
} from 'lucide-react';
import { type ReactNode, useMemo, useState } from 'react';
import { useMemo } from 'react';
import { useTranslation } from 'react-i18next';
import RepoIcon from '@/components/LibIcon';
@@ -34,7 +34,6 @@ interface UseFileItemDropdownParams {
interface UseFileItemDropdownReturn {
menuItems: ItemType[];
moveModal: ReactNode;
}
export const useFileItemDropdown = ({
@@ -50,8 +49,6 @@ export const useFileItemDropdown = ({
const { t } = useTranslation(['components', 'common', 'knowledgeBase']);
const { message, modal } = App.useApp();
const [isMoveModalOpen, setIsMoveModalOpen] = useState(false);
const [removeFile, refreshFileList] = useFileStore((s) => [s.removeFileItem, s.refreshFileList]);
const [removeFilesFromKnowledgeBase, addFilesToKnowledgeBase, useFetchKnowledgeBaseList] =
useKnowledgeBaseStore((s) => [
@@ -154,7 +151,11 @@ export const useFileItemDropdown = ({
label: t('FileManager.actions.moveToFolder'),
onClick: async ({ domEvent }) => {
domEvent.stopPropagation();
setIsMoveModalOpen(true);
createRawModal(MoveToFolderModal, {
fileId: id,
knowledgeBaseId,
});
},
},
isFolder && {
@@ -264,14 +265,5 @@ export const useFileItemDropdown = ({
).filter(Boolean);
}, [enabled, inKnowledgeBase, isFolder, knowledgeBases, knowledgeBaseId, id]);
const moveModal = (
<MoveToFolderModal
fileId={id}
knowledgeBaseId={knowledgeBaseId}
onClose={() => setIsMoveModalOpen(false)}
open={isMoveModalOpen}
/>
);
return { menuItems, moveModal };
return { menuItems };
};
@@ -250,7 +250,7 @@ const ListView = memo(() => {
/>
);
}}
overscan={600}
overscan={48 * 5}
ref={virtuosoRef}
style={{ height: '100%' }}
/>
@@ -8,6 +8,7 @@ import { FileText, FolderIcon, FolderOpenIcon } from 'lucide-react';
import * as motion from 'motion/react-m';
import React, { memo, useCallback, useMemo, useReducer, useRef, useState } from 'react';
import { useNavigate } from 'react-router-dom';
import { VList } from 'virtua';
import {
getTransparentDragImage,
@@ -130,13 +131,12 @@ interface TreeItem {
url: string;
}
// Recursive component to render folder and file tree
const FileTreeItem = memo<{
// Row component for folder / file tree (virtualized by flattening visible nodes)
const FileTreeRow = memo<{
expandedFolders: Set<string>;
folderChildrenCache: Map<string, TreeItem[]>;
item: TreeItem;
level?: number;
loadedFolders: Set<string>;
loadingFolders: Set<string>;
onLoadFolder: (_: string) => Promise<void>;
onToggleFolder: (_: string) => void;
@@ -147,12 +147,11 @@ const FileTreeItem = memo<{
item,
level = 0,
expandedFolders,
loadedFolders,
loadingFolders,
onToggleFolder,
onLoadFolder,
selectedKey,
updateKey,
folderChildrenCache,
}) => {
const navigate = useNavigate();
@@ -215,7 +214,7 @@ const FileTreeItem = memo<{
setRenamingValue(item.name);
}, [item.name]);
const { menuItems, moveModal } = useFileItemDropdown({
const { menuItems } = useFileItemDropdown({
fileType: item.fileType,
filename: item.name,
id: item.id,
@@ -225,9 +224,6 @@ const FileTreeItem = memo<{
url: item.url,
});
// Dynamically look up children from cache instead of using static item.children
const children = folderChildrenCache.get(itemKey);
const isDragActive = useDragActive();
const { setCurrentDrag } = useDragState();
const [isDragging, setIsDragging] = useState(false);
@@ -426,34 +422,6 @@ const FileTreeItem = memo<{
</Flexbox>
</Block>
</Dropdown>
{moveModal}
{isExpanded && children && children.length > 0 && (
<motion.div
animate={{ height: 'auto', opacity: 1 }}
initial={false}
style={{ overflow: 'hidden' }}
transition={{ duration: 0.2, ease: 'easeInOut' }}
>
<Flexbox gap={2}>
{children.map((child) => (
<FileTreeItem
expandedFolders={expandedFolders}
folderChildrenCache={folderChildrenCache}
item={child}
key={child.id}
level={level + 1}
loadedFolders={loadedFolders}
loadingFolders={loadingFolders}
onLoadFolder={onLoadFolder}
onToggleFolder={onToggleFolder}
selectedKey={selectedKey}
updateKey={updateKey}
/>
))}
</Flexbox>
</motion.div>
)}
</Flexbox>
);
}
@@ -508,13 +476,12 @@ const FileTreeItem = memo<{
</Flexbox>
</Block>
</Dropdown>
{moveModal}
</Flexbox>
);
},
);
FileTreeItem.displayName = 'FileTreeItem';
FileTreeRow.displayName = 'FileTreeRow';
/**
* As a sidebar along with the Explorer to work
@@ -536,7 +503,7 @@ const FileTree = memo(() => {
// Get the persisted state for this knowledge base
const state = React.useMemo(() => getTreeState(libraryId || ''), [libraryId]);
const { expandedFolders, loadedFolders, folderChildrenCache, loadingFolders } = state;
const { expandedFolders, folderChildrenCache, loadingFolders } = state;
// Fetch breadcrumb for current folder
const { data: folderBreadcrumb } = useFetchFolderBreadcrumb(currentFolderSlug);
@@ -582,6 +549,37 @@ const FileTree = memo(() => {
return sortItems(mappedItems);
}, [rootData, sortItems, updateKey]);
const visibleNodes = React.useMemo(() => {
interface VisibleNode {
item: TreeItem;
key: string;
level: number;
}
const result: VisibleNode[] = [];
const walk = (nodes: TreeItem[], level: number) => {
for (const node of nodes) {
const key = node.slug || node.id;
result.push({ item: node, key, level });
if (!node.isFolder) continue;
if (!expandedFolders.has(key)) continue;
const children = folderChildrenCache.get(key);
if (!children || children.length === 0) continue;
walk(children, level + 1);
}
};
walk(items, 0);
return result;
// NOTE: expandedFolders / folderChildrenCache are mutated in-place, so rely on updateKey for recompute
}, [items, expandedFolders, folderChildrenCache, updateKey]);
const handleLoadFolder = useCallback(
async (folderId: string) => {
// Set loading state
@@ -751,21 +749,27 @@ const FileTree = memo(() => {
: currentFolderSlug;
return (
<Flexbox gap={2} paddingInline={4}>
{items.map((item) => (
<FileTreeItem
expandedFolders={expandedFolders}
folderChildrenCache={folderChildrenCache}
item={item}
key={item.id}
loadedFolders={loadedFolders}
loadingFolders={loadingFolders}
onLoadFolder={handleLoadFolder}
onToggleFolder={handleToggleFolder}
selectedKey={selectedKey}
updateKey={updateKey}
/>
))}
<Flexbox paddingInline={4} style={{ height: '100%' }}>
<VList
bufferSize={typeof window !== 'undefined' ? window.innerHeight : 0}
style={{ height: '100%' }}
>
{visibleNodes.map(({ item, key, level }) => (
<div key={key} style={{ paddingBottom: 2 }}>
<FileTreeRow
expandedFolders={expandedFolders}
folderChildrenCache={folderChildrenCache}
item={item}
level={level}
loadingFolders={loadingFolders}
onLoadFolder={handleLoadFolder}
onToggleFolder={handleToggleFolder}
selectedKey={selectedKey}
updateKey={updateKey}
/>
</div>
))}
</VList>
</Flexbox>
);
});
+11
View File
@@ -0,0 +1,11 @@
import { useCallback, useLayoutEffect, useRef } from 'react';
export const useEventCallback = <T extends (...args: any[]) => any>(fn: T) => {
const ref = useRef<T>(fn);
useLayoutEffect(() => {
ref.current = fn;
}, [fn]);
return useCallback((...args: Parameters<T>) => {
return ref.current(...args);
}, []);
};
+2
View File
@@ -1,4 +1,5 @@
import { ENABLE_BUSINESS_FEATURES } from '@lobechat/business-const';
import { ModalHost } from '@lobehub/ui';
import { LazyMotion, domMax } from 'motion/react';
import { type ReactNode, Suspense } from 'react';
@@ -66,6 +67,7 @@ const GlobalLayout = async ({
<DragUploadProvider>
<LazyMotion features={domMax}>
<LobeAnalyticsProviderWrapper>{children}</LobeAnalyticsProviderWrapper>
<ModalHost />
</LazyMotion>
</DragUploadProvider>
</GroupWizardProvider>