mirror of
https://github.com/infiniflow/ragflow.git
synced 2026-06-29 15:31:05 +08:00
refactor: enhance UI components and improve layout (#15984)
This commit is contained in:
@@ -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} />
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
72
web/src/layouts/components/mobile-menu-footer.tsx
Normal file
72
web/src/layouts/components/mobile-menu-footer.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
58
web/src/layouts/components/use-header-nav-layout.ts
Normal file
58
web/src/layouts/components/use-header-nav-layout.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -124,6 +124,9 @@ export default {
|
||||
welcome: 'Welcome to',
|
||||
dataset: 'Dataset',
|
||||
memories: 'Memory',
|
||||
discord: 'Discord',
|
||||
github: 'GitHub',
|
||||
help: 'Help',
|
||||
},
|
||||
skills: {
|
||||
title: 'Skills',
|
||||
|
||||
@@ -108,6 +108,9 @@ export default {
|
||||
welcome: '欢迎来到',
|
||||
dataset: '知识库',
|
||||
memories: '记忆',
|
||||
discord: 'Discord',
|
||||
github: 'GitHub',
|
||||
help: '帮助',
|
||||
},
|
||||
skills: {
|
||||
title: '技能',
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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()}
|
||||
/>
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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')}
|
||||
|
||||
Reference in New Issue
Block a user