refactor: enhance UI components and improve layout (#15984)

This commit is contained in:
Carl Harris
2026-06-28 19:40:28 -07:00
committed by GitHub
parent 78db4e949b
commit 61ac1c1dff
17 changed files with 641 additions and 267 deletions

View File

@@ -10,14 +10,7 @@ import { EmptyCardData, EmptyCardType, EmptyType } from './constant';
import { EmptyCardProps, EmptyProps } from './interface';
const EmptyIcon = ({ name, width }: { name: string; width?: number }) => {
return (
// <img
// className="h-20"
// src={isDarkTheme ? noDataIconDark : noDataIcon}
// alt={t('common.noData')}
// />
<SvgIcon name={name || 'empty/no-data-dark'} width={width || 42} />
);
return <SvgIcon name={name || 'empty/no-data-dark'} width={width || 42} />;
};
const Empty = (props: EmptyProps) => {
@@ -58,16 +51,18 @@ export const EmptyCard = (props: EmptyCardProps) => {
return (
<article
className={cn(
'flex flex-col gap-3 items-start justify-start border border-dashed border-border-button rounded-md p-5 w-fit',
'flex flex-col gap-3 rounded-md border border-dashed border-border-button p-5',
'w-full items-center justify-center text-center',
'md:w-fit md:items-start md:justify-start md:text-left',
className,
)}
style={style}
{...restProps}
>
{icon}
{title && <div className="text-text-primary text-sm">{title}</div>}
{title && <div className="text-sm text-text-primary">{title}</div>}
{description && (
<p className="text-text-secondary text-sm">{description}</p>
<p className="text-sm text-text-secondary">{description}</p>
)}
{children}
</article>
@@ -89,7 +84,7 @@ export const EmptyAppCard = (props: {
props;
const { t } = useTranslation();
let defaultClass = '';
let style = {};
let style: React.CSSProperties | undefined;
const cardData = EmptyCardData[type];
const title = t(cardData.titleKey);
const notFound = t(cardData.notFoundKey);
@@ -100,30 +95,36 @@ export const EmptyAppCard = (props: {
defaultClass = 'mt-1';
break;
case 'large':
style = { width: '480px' };
defaultClass = 'mt-5';
break;
default:
defaultClass = '';
break;
}
return (
<div>
<div className="flex w-full justify-center px-5 md:px-0">
<EmptyCard
onClick={isSearch ? undefined : props.onClick}
data-testid={testId}
tabIndex={tabIndex ?? (isSearch ? undefined : 0)}
icon={showIcon ? cardData.icon : undefined}
title={isSearch ? notFound : title}
className={cn(!isSearch && 'cursor-pointer', className)}
className={cn(
!isSearch && 'cursor-pointer',
props.size === 'large' && 'p-14',
className,
'w-full max-w-[480px] md:max-w-none',
props.size === 'large' && 'md:w-[480px]',
props.size === 'small' && 'max-w-64',
)}
style={style}
// description={EmptyCardData[type].description}
>
{!isSearch && !children && (
<div
className={cn(
defaultClass,
'flex items-center justify-start w-full',
'flex w-full items-center justify-center md:justify-start',
)}
>
<Plus size={24} />

View File

@@ -32,14 +32,6 @@ export const FilterButton = React.forwardRef<
{...props}
ref={ref}
>
{/* <span
className={cn({
'text-text-primary': count > 0,
'text-text-sub-title-invert': count === 0,
})}
>
Filter
</span> */}
<Funnel />
{count > 0 && (
@@ -52,6 +44,7 @@ export const FilterButton = React.forwardRef<
});
FilterButton.displayName = 'FilterButton';
export default function ListFilterBar({
title,
children,
@@ -94,11 +87,17 @@ export default function ListFilterBar({
: 0;
}, [value]);
const hasFilter = Boolean(filters?.length && showFilter);
return (
<div className={cn('flex justify-between items-center', className)}>
<h1 className="text-2xl font-semibold flex items-center gap-2.5">
<div
className={cn(
'flex min-w-0 flex-col gap-3 md:flex-row md:items-center md:justify-between',
className,
)}
>
<h1 className="flex min-w-0 shrink-0 items-center gap-2.5 text-2xl font-semibold">
{typeof icon === 'string' ? (
// <IconFont name={icon} className="size-6"></IconFont>
<HomeIcon
name={`${icon}`}
imgClass={cn('size-[1em]', iconClassName)}
@@ -109,9 +108,23 @@ export default function ListFilterBar({
{leftPanel || title}
</h1>
<div className="flex gap-4 items-center" role="toolbar">
<div
className={cn(
'min-w-0 w-full items-center gap-2',
preChildren
? 'flex flex-wrap md:flex-nowrap md:w-auto md:shrink-0 md:gap-4'
: cn(
'grid',
hasFilter
? 'grid-cols-[auto_minmax(0,1fr)_auto]'
: 'grid-cols-[minmax(0,1fr)_auto]',
'md:flex md:w-auto md:shrink-0 md:gap-4',
),
)}
role="toolbar"
>
{preChildren}
{filters?.length && showFilter && (
{hasFilter && (
<FilterPopover
value={value}
onChange={onChange}
@@ -119,18 +132,25 @@ export default function ListFilterBar({
filterGroup={filterGroup}
onOpenChange={onOpenChange}
>
<FilterButton count={filterCount}></FilterButton>
<FilterButton count={filterCount} />
</FilterPopover>
)}
{showSearch && (
<SearchInput
value={searchString}
onChange={onSearchChange}
className="w-32"
className={cn(
'min-w-0 w-full',
preChildren ? 'flex-1 basis-32' : '',
'md:w-32',
)}
role="searchbox"
></SearchInput>
/>
)}
{children && (
<div className="shrink-0 justify-self-end">{children}</div>
)}
{children}
</div>
</div>
);

View File

@@ -1,18 +1,19 @@
import { Button } from '@/components/ui/button';
import { cn } from '@/lib/utils';
import { Routes } from '@/routes';
import { BellRing } from 'lucide-react';
export function BellButton() {
export function BellButton({ className }: { className?: string }) {
return (
<Button
asLink
to={`${Routes.UserSetting}${Routes.Team}`}
variant="ghost"
size="icon"
className="group"
className={cn('group size-10 shrink-0 lg:size-8', className)}
dot
>
<BellRing className="size-4 animate-bell-shake group-hover:animate-none" />
<BellRing className="size-5 animate-bell-shake group-hover:animate-none lg:size-4" />
</Button>
);
}

View File

@@ -1,9 +1,20 @@
import { useId, useMemo } from 'react';
import { useId, useMemo, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { Link, useLocation } from 'react-router';
import { LucideHouse } from 'lucide-react';
import {
LucideBrain,
LucideCpu,
LucideDatabase,
LucideFolderOpen,
LucideHouse,
LucideMenu,
LucideMessageSquareText,
LucideSearch,
} from 'lucide-react';
import { Button } from '@/components/ui/button';
import { Sheet, SheetContent, SheetTrigger } from '@/components/ui/sheet';
import { cn } from '@/lib/utils';
import { Routes } from '@/routes';
import { supportsCssAnchor } from '@/utils/css-support';
@@ -23,141 +34,243 @@ const matchesPath = (pathname: string, candidate: string) =>
pathname === candidate || pathname.startsWith(`${candidate}/`);
const menuItems = [
{ path: Routes.Root, name: 'header.Root', icon: LucideHouse },
{ path: Routes.Datasets, name: 'header.dataset' /* icon: Library, */ },
{ path: Routes.Root, name: 'header.home', icon: LucideHouse },
{ path: Routes.Datasets, name: 'header.dataset', icon: LucideDatabase },
{
path: Routes.Chats,
name: 'header.chat',
/* icon: MessageSquareText, */ 'data-testid': 'nav-chat',
icon: LucideMessageSquareText,
'data-testid': 'nav-chat',
},
{
path: Routes.Searches,
name: 'header.search',
/* icon: Search, */ 'data-testid': 'nav-search',
icon: LucideSearch,
'data-testid': 'nav-search',
},
{
path: Routes.Agents,
name: 'header.flow',
/* icon: Cpu, */ 'data-testid': 'nav-agent',
icon: LucideCpu,
'data-testid': 'nav-agent',
},
{ path: Routes.Memories, name: 'header.memories' /* icon: Cpu, */ },
{ path: Routes.Files, name: 'header.fileManager' /* icon: File, */ },
{ path: Routes.Memories, name: 'header.memories', icon: LucideBrain },
{ path: Routes.Files, name: 'header.fileManager', icon: LucideFolderOpen },
];
const GlobalNavbar = supportsCssAnchor
? () => {
const { t } = useTranslation();
const { pathname } = useLocation();
const navbarAnchorNamePrefix = useId().replace(/:/g, '');
function useActivePath() {
const { pathname } = useLocation();
const activePath = useMemo(() => {
return (
Object.keys(PathMap).find((x: string) =>
PathMap[x as keyof typeof PathMap].some((y: string) =>
matchesPath(pathname, y),
),
) || pathname
);
}, [pathname]);
return useMemo(() => {
return (
Object.keys(PathMap).find((x: string) =>
PathMap[x as keyof typeof PathMap].some((y: string) =>
matchesPath(pathname, y),
),
) || pathname
);
}, [pathname]);
}
const activePathAnchorName = `--${navbarAnchorNamePrefix}${activePath === Routes.Root ? '-root' : activePath.replace('/', '-')}`;
const DesktopNavbarWithAnchor = () => {
const { t } = useTranslation();
const activePath = useActivePath();
const navbarAnchorNamePrefix = useId().replace(/:/g, '');
const hasAnyActive = useMemo(
() => menuItems.some(({ path }) => path === activePath),
[activePath],
);
const activePathAnchorName = `--${navbarAnchorNamePrefix}${activePath === Routes.Root ? '-root' : activePath.replace('/', '-')}`;
return (
<nav>
<ul className="relative flex items-center p-1 bg-bg-card rounded-full border border-border-button">
{menuItems.map(({ path, name, icon: Icon, ...props }) => {
const isActive = path === activePath;
const anchorName = `--${navbarAnchorNamePrefix}${path === Routes.Root ? '-root' : path.replace('/', '-')}`;
const hasAnyActive = useMemo(
() => menuItems.some(({ path }) => path === activePath),
[activePath],
);
return (
<li key={path} className="relative" style={{ anchorName }}>
<Link
{...props}
to={path}
className={cn(
'h-10 px-6 text-base inline-flex items-center justify-center',
'hover:text-current focus-visible:text-current rounded-full transition-all',
isActive && '!text-bg-base',
)}
aria-current={isActive ? 'page' : undefined}
>
{Icon && <Icon className="size-6 stroke-[1.5]" />}
<span className={cn(Icon && 'sr-only')}>{t(name)}</span>
</Link>
</li>
);
})}
return (
<nav>
<ul className="relative flex items-center p-1 bg-bg-card rounded-full border border-border-button">
{menuItems.map(({ path, name, icon: Icon, ...props }) => {
const isActive = path === activePath;
const anchorName = `--${navbarAnchorNamePrefix}${path === Routes.Root ? '-root' : path.replace('/', '-')}`;
<li
className={cn(
'absolute -z-[1] bg-text-primary border-b-2 border-b-accent-primary rounded-full opacity-0',
'transition-all',
hasAnyActive && 'opacity-100',
)}
role="presentation"
style={{
top: 'anchor(top)',
left: 'anchor(left)',
width: 'anchor-size(width)',
height: 'anchor-size(height)',
positionAnchor: activePathAnchorName,
}}
/>
return (
<li key={path} className="relative" style={{ anchorName }}>
<Link
{...props}
to={path}
className={cn(
'h-10 px-4 xl:px-6 text-sm xl:text-base inline-flex items-center justify-center whitespace-nowrap',
'hover:text-current focus-visible:text-current rounded-full transition-all',
isActive && '!text-bg-base',
)}
aria-current={isActive ? 'page' : undefined}
>
{path === Routes.Root ? (
<>
<Icon className="size-6 stroke-[1.5]" />
<span className="sr-only">{t(name)}</span>
</>
) : (
<span>{t(name)}</span>
)}
</Link>
</li>
);
})}
<li
className={cn(
'absolute -z-[1] bg-text-primary border-b-2 border-b-accent-primary rounded-full opacity-0',
'transition-all',
hasAnyActive && 'opacity-100',
)}
role="presentation"
style={{
top: 'anchor(top)',
left: 'anchor(left)',
width: 'anchor-size(width)',
height: 'anchor-size(height)',
positionAnchor: activePathAnchorName,
}}
/>
</ul>
</nav>
);
};
const DesktopNavbarFallback = () => {
const { t } = useTranslation();
const activePath = useActivePath();
return (
<nav>
<ul className="flex items-center p-1 bg-bg-card rounded-full border border-border-button">
{menuItems.map(({ path, name, icon: Icon, ...props }) => {
const isActive = path === activePath;
return (
<li key={path}>
<Link
{...props}
to={path}
className={cn(
'h-10 px-4 xl:px-6 text-sm xl:text-base inline-flex items-center justify-center whitespace-nowrap',
'hover:text-current focus-visible:text-current rounded-full transition-all',
isActive &&
'!text-bg-base bg-text-primary border-b-2 border-b-accent-primary',
)}
aria-label={t(name)}
aria-current={isActive ? 'page' : undefined}
>
{path === Routes.Root ? (
<Icon className="size-6 stroke-[1.5]" />
) : (
<span>{t(name)}</span>
)}
</Link>
</li>
);
})}
</ul>
</nav>
);
};
export function DesktopNavbar() {
return supportsCssAnchor ? (
<DesktopNavbarWithAnchor />
) : (
<DesktopNavbarFallback />
);
}
function MobileNavItem({
label,
icon: Icon,
isActive,
onClick,
...linkProps
}: {
label: string;
icon: React.ComponentType<{ className?: string }>;
isActive?: boolean;
onClick?: () => void;
to: string;
'data-testid'?: string;
}) {
return (
<Link
{...linkProps}
onClick={onClick}
className={cn(
'flex w-full items-center gap-3.5 px-4 py-3.5 text-base',
'text-text-secondary transition-colors hover:bg-bg-card hover:text-text-primary',
'focus-visible:bg-bg-card focus-visible:text-text-primary',
isActive &&
'border-l-2 border-text-primary bg-bg-card font-medium text-text-primary',
)}
aria-current={isActive ? 'page' : undefined}
>
<Icon className="size-5 shrink-0 stroke-[1.5]" />
<span className="truncate">{label}</span>
</Link>
);
}
type MobileNavbarProps = {
renderFooter?: (close: () => void) => React.ReactNode;
};
export function MobileNavbar({ renderFooter }: MobileNavbarProps) {
const { t } = useTranslation();
const activePath = useActivePath();
const [open, setOpen] = useState(false);
const close = () => setOpen(false);
return (
<Sheet open={open} onOpenChange={setOpen}>
<SheetTrigger asChild>
<Button
variant="ghost"
size="icon"
className="size-10 shrink-0"
aria-label="Menu"
>
<LucideMenu className="size-6 stroke-[1.75]" />
</Button>
</SheetTrigger>
<SheetContent
side="left"
closeIcon={false}
className="flex w-[min(85vw,18rem)] flex-col gap-0 p-0 sm:w-72"
>
<div className="flex shrink-0 justify-center py-5">
<img src="/logo.svg" alt="RAGFlow logo" className="size-9" />
</div>
<nav className="min-h-0 flex-1 overflow-y-auto py-3">
<ul className="space-y-1">
{menuItems.map(({ path, name, icon, ...props }) => (
<li key={path}>
<MobileNavItem
{...props}
to={path}
label={t(name)}
icon={icon}
isActive={path === activePath}
onClick={close}
/>
</li>
))}
</ul>
</nav>
);
}
: () => {
const { t } = useTranslation();
const { pathname } = useLocation();
const activePath = useMemo(() => {
return (
Object.keys(PathMap).find((x: string) =>
PathMap[x as keyof typeof PathMap].some((y: string) =>
matchesPath(pathname, y),
),
) || pathname
);
}, [pathname]);
{renderFooter?.(close)}
</SheetContent>
</Sheet>
);
}
return (
<nav>
<ul className="flex items-center p-1 bg-bg-card rounded-full border border-border-button">
{menuItems.map(({ path, name, icon: Icon, ...props }) => {
const isActive = path === activePath;
return (
<li key={path}>
<Link
{...props}
to={path}
className={cn(
'h-10 px-6 text-base inline-flex items-center justify-center',
'hover:text-current focus-visible:text-current rounded-full transition-all',
isActive &&
'!text-bg-base bg-text-primary border-b-2 border-b-accent-primary',
)}
aria-label={t(name)}
aria-current={isActive ? 'page' : undefined}
>
{Icon ? (
<Icon className="size-6 stroke-[1.5]" />
) : (
<span>{t(name)}</span>
)}
</Link>
</li>
);
})}
</ul>
</nav>
);
};
const GlobalNavbar = DesktopNavbar;
export default GlobalNavbar;

View File

@@ -15,12 +15,18 @@ import {
import { cn } from '@/lib/utils';
import { TenantRole } from '@/pages/user-setting/constants';
import { Routes } from '@/routes';
import { LucideChevronDown, LucideCircleHelp } from 'lucide-react';
import {
LucideChevronDown,
LucideCircleHelp,
LucideLanguages,
} from 'lucide-react';
import React, { useMemo } from 'react';
import { Link, useLocation } from 'react-router';
import { BellButton } from './bell-button';
import GlobalNavbar from './global-navbar';
import { DesktopNavbar, MobileNavbar } from './global-navbar';
import { MobileMenuFooter } from './mobile-menu-footer';
import ThemeButton from './theme-button';
import { useHeaderNavLayout } from './use-header-nav-layout';
import { supportedLanguages } from '@/locales/config';
@@ -29,7 +35,6 @@ export function Header({
...props
}: React.HTMLAttributes<HTMLElement>) {
const { pathname } = useLocation();
const changeLanguage = useChangeLanguage();
const {
@@ -44,105 +49,185 @@ export function Header({
const currentLanguage = supportedLanguages.find((x) => x.code === language);
// const langItems = LanguageList.map((x) => ({
// key: x,
// label: <span>{LanguageMap[x as keyof typeof LanguageMap]}</span>,
// }));
const {
headerRef,
logoRef,
expandedRightMeasureRef,
navMeasureRef,
isCompact,
} = useHeaderNavLayout(`${hasNotification}-${language}`);
return (
<header
key="app-navbar"
className={cn(
'w-full grid grid-cols-[1fr_auto_1fr] grid-rows-1 items-center gap-8',
className,
)}
{...props}
>
<div className="inline-flex items-center">
<Link
to={Routes.Root}
aria-current={pathname === Routes.Root ? 'page' : undefined}
>
<img src={'/logo.svg'} alt="RAGFlow logo" className="size-10" />
</Link>
</div>
<>
<header
ref={headerRef}
key="app-navbar"
className={cn(
'w-full min-w-0 flex items-center gap-2 sm:gap-4',
className,
)}
{...props}
>
<div className="inline-flex shrink-0 items-center gap-2">
{isCompact && (
<MobileNavbar
renderFooter={(close) => <MobileMenuFooter onClose={close} />}
/>
)}
<div ref={logoRef} className="inline-flex shrink-0 items-center">
<Link
to={Routes.Root}
aria-current={pathname === Routes.Root ? 'page' : undefined}
className="flex size-10 shrink-0 items-center justify-center"
>
<img src={'/logo.svg'} alt="RAGFlow logo" className="size-10" />
</Link>
</div>
</div>
<GlobalNavbar />
{!isCompact && (
<div className="flex min-w-0 flex-1 justify-center overflow-hidden">
<DesktopNavbar />
</div>
)}
{isCompact && <div className="flex-1" aria-hidden />}
<div
className={cn(
'flex shrink-0 items-center justify-end text-text-badge',
isCompact ? 'gap-0.5' : 'gap-4',
)}
data-testid="auth-status"
>
{!isCompact && (
<>
<a
className="inline-flex p-2 text-text-secondary hover:text-text-primary focus-visible:text-text-primary"
target="_blank"
href="https://discord.com/invite/NjYzJD3GM3"
rel="noreferrer noopener"
>
<IconFontFill name="a-DiscordIconSVGVectorIcon" />
</a>
<a
className="inline-flex p-2 text-text-secondary hover:text-text-primary focus-visible:text-text-primary"
target="_blank"
href="https://github.com/infiniflow/ragflow"
rel="noreferrer noopener"
>
<IconFontFill name="GitHub" />
</a>
</>
)}
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant="ghost"
className={cn(
'size-10 shrink-0 px-0',
!isCompact && 'size-auto gap-1 px-4',
)}
aria-label={currentLanguage?.displayName}
>
{isCompact && <LucideLanguages className="size-5" />}
{!isCompact && (
<>
{currentLanguage?.displayName}
<LucideChevronDown className="size-[1em]" />
</>
)}
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
{supportedLanguages.map((x) => (
<DropdownMenuItem
key={x.code}
onClick={() => changeLanguage(x.code)}
>
{x.displayName}
</DropdownMenuItem>
))}
</DropdownMenuContent>
</DropdownMenu>
{!isCompact && (
<>
<Button
asLink
variant="ghost"
size="icon"
className="size-8"
to="https://ragflow.io/docs/dev/category/user-guides"
target="_blank"
rel="noreferrer noopener"
>
<LucideCircleHelp className="size-[1em]" />
</Button>
{hasNotification && <BellButton className="!size-8" />}
</>
)}
<ThemeButton className={cn(!isCompact && '!size-8')} />
<Link
to={Routes.UserSetting}
className={cn(
'relative flex size-10 shrink-0 items-center justify-center',
!isCompact && 'ms-3',
)}
data-testid="settings-entrypoint"
>
<RAGFlowAvatar
name={nickname}
avatar={avatar}
isPerson
className="size-8"
/>
</Link>
</div>
</header>
<div
className="flex items-center justify-end gap-4 text-text-badge"
data-testid="auth-status"
className="pointer-events-none invisible fixed -left-[9999px] top-0"
aria-hidden
>
<a
className="p-2 text-text-secondary hover:text-text-primary focus-visible:text-text-primary"
target="_blank"
href="https://discord.com/invite/NjYzJD3GM3"
rel="noreferrer noopener"
<div ref={navMeasureRef}>
<DesktopNavbar />
</div>
<div
ref={expandedRightMeasureRef}
className="inline-flex shrink-0 items-center justify-end gap-4 text-text-badge"
>
<IconFontFill name="a-DiscordIconSVGVectorIcon" />
</a>
<a
className="p-2 text-text-secondary hover:text-text-primary focus-visible:text-text-primary"
target="_blank"
href="https://github.com/infiniflow/ragflow"
rel="noreferrer noopener"
>
<IconFontFill name="GitHub" />
</a>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button className="flex items-center gap-1" variant="ghost">
{currentLanguage?.displayName}
<LucideChevronDown className="size-[1em]" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent>
{supportedLanguages.map((x) => (
<DropdownMenuItem
key={x.code}
onClick={() => changeLanguage(x.code)}
>
{x.displayName}
</DropdownMenuItem>
))}
</DropdownMenuContent>
</DropdownMenu>
<Button
asLink
variant="ghost"
size="icon"
to="https://ragflow.io/docs/dev/category/user-guides"
target="_blank"
rel="noreferrer noopener"
>
<LucideCircleHelp className="size-[1em]" />
</Button>
<ThemeButton />
{hasNotification && <BellButton />}
<Link
to={Routes.UserSetting}
className="relative ms-3"
data-testid="settings-entrypoint"
>
<RAGFlowAvatar
name={nickname}
avatar={avatar}
isPerson
className="size-8"
/>
{/* Temporarily hidden */}
{/* <Badge className="h-5 w-8 absolute font-normal p-0 justify-center -right-8 -top-2 text-bg-base bg-gradient-to-l from-[#42D7E7] to-[#478AF5]">
Pro
</Badge> */}
</Link>
<a className="inline-flex p-2">
<IconFontFill name="a-DiscordIconSVGVectorIcon" />
</a>
<a className="inline-flex p-2">
<IconFontFill name="GitHub" />
</a>
<Button variant="ghost" className="size-auto gap-1 px-4">
{currentLanguage?.displayName}
<LucideChevronDown className="size-[1em]" />
</Button>
<Button variant="ghost" size="icon" className="size-8">
<LucideCircleHelp className="size-[1em]" />
</Button>
<ThemeButton className="!size-8" />
{hasNotification && <BellButton className="!size-8" />}
<div className="relative ms-3 flex size-10 shrink-0 items-center justify-center">
<RAGFlowAvatar
name={nickname}
avatar={avatar}
isPerson
className="size-8"
/>
</div>
</div>
</div>
</header>
</>
);
}

View File

@@ -0,0 +1,72 @@
import { useTranslation } from 'react-i18next';
function FooterDivider() {
return <span className="text-border-button select-none">|</span>;
}
function FooterLink({
children,
onClick,
href,
target,
rel,
}: {
children: React.ReactNode;
onClick?: () => void;
href: string;
target?: string;
rel?: string;
}) {
return (
<a
href={href}
target={target}
rel={rel}
onClick={onClick}
className="text-text-secondary transition-colors hover:text-text-primary"
>
{children}
</a>
);
}
type MobileMenuFooterProps = {
onClose: () => void;
};
export function MobileMenuFooter({ onClose }: MobileMenuFooterProps) {
const { t } = useTranslation();
return (
<div className="shrink-0 border-t border-border-button px-4 py-4">
<div className="flex flex-wrap items-center justify-center gap-x-2 gap-y-1 text-sm">
<FooterLink
href="https://discord.com/invite/NjYzJD3GM3"
target="_blank"
rel="noreferrer noopener"
onClick={onClose}
>
{t('header.discord')}
</FooterLink>
<FooterDivider />
<FooterLink
href="https://github.com/infiniflow/ragflow"
target="_blank"
rel="noreferrer noopener"
onClick={onClose}
>
{t('header.github')}
</FooterLink>
<FooterDivider />
<FooterLink
href="https://ragflow.io/docs/dev/category/user-guides"
target="_blank"
rel="noreferrer noopener"
onClick={onClose}
>
{t('header.help')}
</FooterLink>
</div>
</div>
);
}

View File

@@ -3,20 +3,25 @@ import { LucideMoon, LucideSun } from 'lucide-react';
import { useTheme } from '@/components/theme-provider';
import { Button } from '@/components/ui/button';
import { ThemeEnum } from '@/constants/common';
import { cn } from '@/lib/utils';
export default function ThemeButton() {
export default function ThemeButton({ className }: { className?: string }) {
const { setTheme, theme } = useTheme();
return (
<Button
variant="ghost"
size="icon"
className="relative"
className={cn('relative size-10 shrink-0 lg:size-8', className)}
onClick={() =>
setTheme(theme === ThemeEnum.Dark ? ThemeEnum.Light : ThemeEnum.Dark)
}
>
{theme === ThemeEnum.Light ? <LucideSun /> : <LucideMoon />}
{theme === ThemeEnum.Light ? (
<LucideSun className="size-5 lg:size-4" />
) : (
<LucideMoon className="size-5 lg:size-4" />
)}
</Button>
);
}

View File

@@ -0,0 +1,58 @@
import { useLayoutEffect, useRef, useState } from 'react';
const LAYOUT_GAP = 48;
const FIT_BUFFER = 16;
export function useHeaderNavLayout(measureKey = '') {
const headerRef = useRef<HTMLElement>(null);
const logoRef = useRef<HTMLDivElement>(null);
const expandedRightMeasureRef = useRef<HTMLDivElement>(null);
const navMeasureRef = useRef<HTMLDivElement>(null);
const [isCompact, setIsCompact] = useState(true);
useLayoutEffect(() => {
const measure = () => {
const header = headerRef.current;
const logo = logoRef.current;
const expandedRight = expandedRightMeasureRef.current;
const nav = navMeasureRef.current;
if (!header || !logo || !expandedRight || !nav) {
return;
}
const navWidth = nav.scrollWidth;
const availableForDesktop =
header.clientWidth -
logo.offsetWidth -
expandedRight.offsetWidth -
LAYOUT_GAP;
setIsCompact(navWidth + FIT_BUFFER > availableForDesktop);
};
measure();
const observer = new ResizeObserver(measure);
[
headerRef.current,
logoRef.current,
expandedRightMeasureRef.current,
navMeasureRef.current,
].forEach((node) => {
if (node) {
observer.observe(node);
}
});
return () => observer.disconnect();
}, [measureKey]);
return {
headerRef,
logoRef,
expandedRightMeasureRef,
navMeasureRef,
isCompact,
};
}

View File

@@ -3,10 +3,10 @@ import { Header } from './components/header';
export function RootLayoutContainer({ children }: React.PropsWithChildren) {
return (
<div className="size-full grid grid-rows-[auto_1fr] grid-cols-1 grid-flow-col">
<div className="size-full min-w-0 grid grid-flow-col grid-cols-1 grid-rows-[auto_1fr]">
<Header className="px-5 py-4" />
<main className="size-full overflow-hidden">{children}</main>
<main className="size-full min-w-0 overflow-hidden">{children}</main>
</div>
);
}

View File

@@ -124,6 +124,9 @@ export default {
welcome: 'Welcome to',
dataset: 'Dataset',
memories: 'Memory',
discord: 'Discord',
github: 'GitHub',
help: 'Help',
},
skills: {
title: 'Skills',

View File

@@ -108,6 +108,9 @@ export default {
welcome: '欢迎来到',
dataset: '知识库',
memories: '记忆',
discord: 'Discord',
github: 'GitHub',
help: '帮助',
},
skills: {
title: '技能',

View File

@@ -86,8 +86,11 @@ export default function Agents() {
return (
<>
{data?.length || searchString ? (
<article className="size-full flex flex-col" data-testid="agents-list">
<header className="px-5 pt-8 mb-4">
<article
className="size-full min-w-0 flex flex-col"
data-testid="agents-list"
>
<header className="mb-4 min-w-0 px-5 pt-8">
<ListFilterBar
title={t('flow.agents')}
searchString={searchString}

View File

@@ -72,10 +72,10 @@ export default function Datasets() {
<>
{kbs?.length || searchString ? (
<article
className="size-full flex flex-col"
className="size-full min-w-0 flex flex-col"
data-testid="datasets-list"
>
<header className="px-5 pt-8 mb-4">
<header className="mb-4 min-w-0 px-5 pt-8">
<ListFilterBar
title={t('header.dataset')}
searchString={searchString}

View File

@@ -96,8 +96,11 @@ export default function Files() {
);
return (
<article className="size-full flex flex-col" data-testid="files-list">
<header className="px-5 pt-8 mb-4">
<article
className="size-full min-w-0 flex flex-col"
data-testid="files-list"
>
<header className="mb-4 min-w-0 px-5 pt-8">
<ListFilterBar
leftPanel={leftPanel}
searchString={searchString}

View File

@@ -71,8 +71,11 @@ export default function MemoryList() {
return (
<>
{list?.data?.memory_list?.length || searchString ? (
<article className="size-full flex flex-col" data-testid="memory-list">
<header className="px-5 pt-8 mb-4">
<article
className="size-full min-w-0 flex flex-col"
data-testid="memory-list"
>
<header className="mb-4 min-w-0 px-5 pt-8">
<ListFilterBar
icon="memory"
title={t('memory')}
@@ -113,11 +116,10 @@ export default function MemoryList() {
</footer>
</>
) : (
<div className="flex-1 flex items-center justify-center">
<div className="flex flex-1 items-center justify-center px-5">
<EmptyAppCard
showIcon
size="large"
className="w-[480px] p-14"
isSearch
type={EmptyCardType.Memory}
onClick={() => openCreateModalFun()}
@@ -127,13 +129,12 @@ export default function MemoryList() {
</article>
) : (
<article
className="size-full flex items-center justify-center"
className="size-full min-w-0 flex items-center justify-center px-5"
data-testid="memory-list"
>
<EmptyAppCard
showIcon
size="large"
className="w-[480px] p-14"
type={EmptyCardType.Memory}
onClick={() => openCreateModalFun()}
/>

View File

@@ -92,8 +92,11 @@ export default function ChatList() {
return (
<>
{data.chats?.length || searchString ? (
<article className="size-full flex flex-col" data-testid="chats-list">
<header className="px-5 pt-8 mb-4">
<article
className="size-full min-w-0 flex flex-col"
data-testid="chats-list"
>
<header className="mb-4 min-w-0 px-5 pt-8">
<ListFilterBar
title={t('chat.chatApps')}
icon="chats"

View File

@@ -67,8 +67,11 @@ export default function SearchList() {
return (
<>
{list?.data?.search_apps?.length || searchString ? (
<article className="size-full flex flex-col" data-testid="search-list">
<header className="px-5 pt-8 mb-4">
<article
className="size-full min-w-0 flex flex-col"
data-testid="search-list"
>
<header className="mb-4 min-w-0 px-5 pt-8">
<ListFilterBar
icon="searches"
title={t('searchApps')}