mirror of
https://github.com/danny-avila/LibreChat.git
synced 2025-12-19 09:50:15 +01:00
🎨 style: Refine SidePanel and Textarea Styling (#2209)
* experimental: use TextareaAutosize wrapper with useLayoutEffect to hopefully fix random textarea jankiness * fix(Textarea): force a resize when placeholder text changes * style(ScrollToBottom): update styling for scroll button * style: memoize values and improve side panel toggle states * refactor(SidePanel): more control for toggle states, new hide panel button, and improve toggle state logic * chore: hide resizable panel handle on smaller screens
This commit is contained in:
parent
cb62847838
commit
718572b7c8
9 changed files with 87 additions and 33 deletions
|
|
@ -23,6 +23,7 @@ export type NavLink = {
|
||||||
label?: string;
|
label?: string;
|
||||||
icon: LucideIcon;
|
icon: LucideIcon;
|
||||||
Component?: React.ComponentType;
|
Component?: React.ComponentType;
|
||||||
|
onClick?: () => void;
|
||||||
variant?: 'default' | 'ghost';
|
variant?: 'default' | 'ghost';
|
||||||
id: string;
|
id: string;
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,5 @@
|
||||||
import { useRecoilState } from 'recoil';
|
import { useRecoilState } from 'recoil';
|
||||||
import { useForm } from 'react-hook-form';
|
import { useForm } from 'react-hook-form';
|
||||||
import TextareaAutosize from 'react-textarea-autosize';
|
|
||||||
import { memo, useCallback, useRef, useMemo } from 'react';
|
import { memo, useCallback, useRef, useMemo } from 'react';
|
||||||
import {
|
import {
|
||||||
supportsFiles,
|
supportsFiles,
|
||||||
|
|
@ -10,6 +9,7 @@ import {
|
||||||
} from 'librechat-data-provider';
|
} from 'librechat-data-provider';
|
||||||
import { useChatContext, useAssistantsMapContext } from '~/Providers';
|
import { useChatContext, useAssistantsMapContext } from '~/Providers';
|
||||||
import { useRequiresKey, useTextarea } from '~/hooks';
|
import { useRequiresKey, useTextarea } from '~/hooks';
|
||||||
|
import { TextareaAutosize } from '~/components/ui';
|
||||||
import { useGetFileConfig } from '~/data-provider';
|
import { useGetFileConfig } from '~/data-provider';
|
||||||
import { cn, removeFocusOutlines } from '~/utils';
|
import { cn, removeFocusOutlines } from '~/utils';
|
||||||
import AttachFile from './Files/AttachFile';
|
import AttachFile from './Files/AttachFile';
|
||||||
|
|
|
||||||
|
|
@ -8,7 +8,7 @@ export default function ScrollToBottom({ scrollHandler }: Props) {
|
||||||
return (
|
return (
|
||||||
<button
|
<button
|
||||||
onClick={scrollHandler}
|
onClick={scrollHandler}
|
||||||
className="absolute bottom-5 right-1/2 cursor-pointer rounded-full border border-white bg-gray-50 text-gray-600 dark:border-white/10 dark:bg-white/10 dark:text-gray-200"
|
className="absolute bottom-5 right-1/2 cursor-pointer rounded-full border border-gray-200 bg-white bg-clip-padding text-gray-600 dark:border-white/10 dark:bg-gray-750/90 dark:text-gray-200"
|
||||||
>
|
>
|
||||||
<svg
|
<svg
|
||||||
width="24"
|
width="24"
|
||||||
|
|
|
||||||
|
|
@ -42,6 +42,11 @@ export default function Nav({ links, isCollapsed, resize, defaultActive }: NavPr
|
||||||
: '',
|
: '',
|
||||||
)}
|
)}
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
|
if (link.onClick) {
|
||||||
|
link.onClick();
|
||||||
|
setActive('');
|
||||||
|
return;
|
||||||
|
}
|
||||||
setActive(link.id);
|
setActive(link.id);
|
||||||
resize && resize(25);
|
resize && resize(25);
|
||||||
}}
|
}}
|
||||||
|
|
@ -50,7 +55,11 @@ export default function Nav({ links, isCollapsed, resize, defaultActive }: NavPr
|
||||||
<span className="sr-only">{link.title}</span>
|
<span className="sr-only">{link.title}</span>
|
||||||
</button>
|
</button>
|
||||||
</TooltipTrigger>
|
</TooltipTrigger>
|
||||||
<TooltipContent side="right" className="flex items-center gap-4">
|
<TooltipContent
|
||||||
|
side="left"
|
||||||
|
sideOffset={10}
|
||||||
|
className="flex items-center gap-4"
|
||||||
|
>
|
||||||
{localize(link.title)}
|
{localize(link.title)}
|
||||||
{link.label && (
|
{link.label && (
|
||||||
<span className="text-muted-foreground ml-auto">{link.label}</span>
|
<span className="text-muted-foreground ml-auto">{link.label}</span>
|
||||||
|
|
@ -78,6 +87,12 @@ export default function Nav({ links, isCollapsed, resize, defaultActive }: NavPr
|
||||||
'hover:bg-gray-50 data-[state=open]:bg-gray-50 data-[state=open]:text-black dark:hover:bg-gray-700 dark:data-[state=open]:bg-gray-700 dark:data-[state=open]:text-white',
|
'hover:bg-gray-50 data-[state=open]:bg-gray-50 data-[state=open]:text-black dark:hover:bg-gray-700 dark:data-[state=open]:bg-gray-700 dark:data-[state=open]:text-white',
|
||||||
'w-full justify-start rounded-md border dark:border-gray-700',
|
'w-full justify-start rounded-md border dark:border-gray-700',
|
||||||
)}
|
)}
|
||||||
|
onClick={() => {
|
||||||
|
if (link.onClick) {
|
||||||
|
link.onClick();
|
||||||
|
setActive('');
|
||||||
|
}
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
<link.icon className="mr-2 h-4 w-4" />
|
<link.icon className="mr-2 h-4 w-4" />
|
||||||
{localize(link.title)}
|
{localize(link.title)}
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
import throttle from 'lodash/throttle';
|
import throttle from 'lodash/throttle';
|
||||||
import { useState, useRef, useCallback, useEffect, useMemo } from 'react';
|
import { ArrowRightToLine } from 'lucide-react';
|
||||||
|
import { useState, useRef, useCallback, useEffect, useMemo, memo } from 'react';
|
||||||
import { useGetEndpointsQuery, useUserKeyQuery } from 'librechat-data-provider/react-query';
|
import { useGetEndpointsQuery, useUserKeyQuery } from 'librechat-data-provider/react-query';
|
||||||
import type { ImperativePanelHandle } from 'react-resizable-panels';
|
import type { ImperativePanelHandle } from 'react-resizable-panels';
|
||||||
import { EModelEndpoint, type TEndpointsConfig } from 'librechat-data-provider';
|
import { EModelEndpoint, type TEndpointsConfig } from 'librechat-data-provider';
|
||||||
|
|
@ -25,12 +26,12 @@ interface SidePanelProps {
|
||||||
|
|
||||||
const defaultMinSize = 20;
|
const defaultMinSize = 20;
|
||||||
|
|
||||||
export default function SidePanel({
|
const SidePanel = ({
|
||||||
defaultLayout = [97, 3],
|
defaultLayout = [97, 3],
|
||||||
defaultCollapsed = false,
|
defaultCollapsed = false,
|
||||||
navCollapsedSize = 3,
|
navCollapsedSize = 3,
|
||||||
children,
|
children,
|
||||||
}: SidePanelProps) {
|
}: SidePanelProps) => {
|
||||||
const [minSize, setMinSize] = useState(defaultMinSize);
|
const [minSize, setMinSize] = useState(defaultMinSize);
|
||||||
const [isHovering, setIsHovering] = useState(false);
|
const [isHovering, setIsHovering] = useState(false);
|
||||||
const [newUser, setNewUser] = useLocalStorage('newUser', true);
|
const [newUser, setNewUser] = useLocalStorage('newUser', true);
|
||||||
|
|
@ -42,14 +43,20 @@ export default function SidePanel({
|
||||||
|
|
||||||
const panelRef = useRef<ImperativePanelHandle>(null);
|
const panelRef = useRef<ImperativePanelHandle>(null);
|
||||||
|
|
||||||
const activePanel = localStorage.getItem('side:active-panel');
|
const defaultActive = useMemo(() => {
|
||||||
const defaultActive = activePanel ? activePanel : undefined;
|
const activePanel = localStorage.getItem('side:active-panel');
|
||||||
|
return activePanel ? activePanel : undefined;
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const assistants = useMemo(() => endpointsConfig?.[EModelEndpoint.assistants], [endpointsConfig]);
|
||||||
|
const userProvidesKey = useMemo(() => !!assistants?.userProvide, [assistants]);
|
||||||
|
const keyProvided = useMemo(
|
||||||
|
() => (userProvidesKey ? !!keyExpiry?.expiresAt : true),
|
||||||
|
[keyExpiry?.expiresAt, userProvidesKey],
|
||||||
|
);
|
||||||
|
|
||||||
const Links = useMemo(() => {
|
const Links = useMemo(() => {
|
||||||
const links: NavLink[] = [];
|
const links: NavLink[] = [];
|
||||||
const assistants = endpointsConfig?.[EModelEndpoint.assistants];
|
|
||||||
const userProvidesKey = !!assistants?.userProvide;
|
|
||||||
const keyProvided = userProvidesKey ? !!keyExpiry?.expiresAt : true;
|
|
||||||
if (assistants && assistants.disableBuilder !== true && keyProvided) {
|
if (assistants && assistants.disableBuilder !== true && keyProvided) {
|
||||||
links.push({
|
links.push({
|
||||||
title: 'com_sidepanel_assistant_builder',
|
title: 'com_sidepanel_assistant_builder',
|
||||||
|
|
@ -68,8 +75,22 @@ export default function SidePanel({
|
||||||
Component: FilesPanel,
|
Component: FilesPanel,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
links.push({
|
||||||
|
title: 'com_sidepanel_hide_panel',
|
||||||
|
label: '',
|
||||||
|
icon: ArrowRightToLine,
|
||||||
|
onClick: () => {
|
||||||
|
console.log('hide-panel');
|
||||||
|
setIsCollapsed(true);
|
||||||
|
setCollapsedSize(0);
|
||||||
|
setMinSize(defaultMinSize - 1);
|
||||||
|
panelRef.current?.collapse();
|
||||||
|
},
|
||||||
|
id: 'hide-panel',
|
||||||
|
});
|
||||||
|
|
||||||
return links;
|
return links;
|
||||||
}, [endpointsConfig, keyExpiry?.expiresAt]);
|
}, [assistants, keyProvided]);
|
||||||
|
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
const throttledSaveLayout = useCallback(
|
const throttledSaveLayout = useCallback(
|
||||||
|
|
@ -82,24 +103,26 @@ export default function SidePanel({
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (isSmallScreen) {
|
if (isSmallScreen) {
|
||||||
setIsCollapsed(true);
|
setIsCollapsed(true);
|
||||||
setMinSize(0);
|
|
||||||
setCollapsedSize(0);
|
setCollapsedSize(0);
|
||||||
|
setMinSize(defaultMinSize);
|
||||||
panelRef.current?.collapse();
|
panelRef.current?.collapse();
|
||||||
return;
|
return;
|
||||||
|
} else {
|
||||||
|
setIsCollapsed(defaultCollapsed);
|
||||||
|
setCollapsedSize(navCollapsedSize);
|
||||||
|
setMinSize(defaultMinSize);
|
||||||
|
panelRef.current?.collapse();
|
||||||
}
|
}
|
||||||
}, [isSmallScreen]);
|
}, [isSmallScreen, defaultCollapsed, navCollapsedSize]);
|
||||||
|
|
||||||
const toggleNavVisible = () => {
|
const toggleNavVisible = useCallback(() => {
|
||||||
if (newUser) {
|
if (newUser) {
|
||||||
setNewUser(false);
|
setNewUser(false);
|
||||||
}
|
}
|
||||||
setIsCollapsed((prev: boolean) => {
|
setIsCollapsed((prev: boolean) => {
|
||||||
if (!prev) {
|
if (prev) {
|
||||||
setMinSize(0);
|
|
||||||
setCollapsedSize(0);
|
|
||||||
} else {
|
|
||||||
setMinSize(defaultMinSize);
|
setMinSize(defaultMinSize);
|
||||||
setCollapsedSize(3);
|
setCollapsedSize(navCollapsedSize);
|
||||||
}
|
}
|
||||||
return !prev;
|
return !prev;
|
||||||
});
|
});
|
||||||
|
|
@ -108,11 +131,7 @@ export default function SidePanel({
|
||||||
} else {
|
} else {
|
||||||
panelRef.current?.expand();
|
panelRef.current?.expand();
|
||||||
}
|
}
|
||||||
};
|
}, [isCollapsed, newUser, setNewUser, navCollapsedSize]);
|
||||||
|
|
||||||
const assistants = endpointsConfig?.[EModelEndpoint.assistants];
|
|
||||||
const userProvidesKey = !!assistants?.userProvide;
|
|
||||||
const keyProvided = userProvidesKey ? !!keyExpiry?.expiresAt : true;
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
|
|
@ -139,7 +158,10 @@ export default function SidePanel({
|
||||||
setIsHovering={setIsHovering}
|
setIsHovering={setIsHovering}
|
||||||
className={cn(
|
className={cn(
|
||||||
'fixed top-1/2',
|
'fixed top-1/2',
|
||||||
isCollapsed && (minSize === 0 || collapsedSize === 0) ? 'mr-9' : 'mr-16',
|
(isCollapsed && (minSize === 0 || collapsedSize === 0)) ||
|
||||||
|
minSize === defaultMinSize - 1
|
||||||
|
? 'mr-9'
|
||||||
|
: 'mr-16',
|
||||||
)}
|
)}
|
||||||
translateX={false}
|
translateX={false}
|
||||||
side="right"
|
side="right"
|
||||||
|
|
@ -147,7 +169,7 @@ export default function SidePanel({
|
||||||
</div>
|
</div>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
</TooltipProvider>
|
</TooltipProvider>
|
||||||
{(!isCollapsed || minSize > 0) && (
|
{(!isCollapsed || (minSize > 0 && minSize !== defaultMinSize - 1)) && (
|
||||||
<ResizableHandleAlt withHandle className="bg-transparent dark:text-white" />
|
<ResizableHandleAlt withHandle className="bg-transparent dark:text-white" />
|
||||||
)}
|
)}
|
||||||
<ResizablePanel
|
<ResizablePanel
|
||||||
|
|
@ -159,9 +181,7 @@ export default function SidePanel({
|
||||||
ref={panelRef}
|
ref={panelRef}
|
||||||
style={{
|
style={{
|
||||||
overflowY: 'auto',
|
overflowY: 'auto',
|
||||||
visibility:
|
transition: 'width 0.2s ease, visibility 0s linear 0.2s',
|
||||||
isCollapsed && (minSize === 0 || collapsedSize === 0) ? 'hidden' : 'visible',
|
|
||||||
transition: 'width 0.2s ease',
|
|
||||||
}}
|
}}
|
||||||
onExpand={() => {
|
onExpand={() => {
|
||||||
setIsCollapsed(false);
|
setIsCollapsed(false);
|
||||||
|
|
@ -172,9 +192,12 @@ export default function SidePanel({
|
||||||
localStorage.setItem('react-resizable-panels:collapsed', 'true');
|
localStorage.setItem('react-resizable-panels:collapsed', 'true');
|
||||||
}}
|
}}
|
||||||
className={cn(
|
className={cn(
|
||||||
'sidenav hide-scrollbar border-l border-gray-200 bg-white dark:border-gray-800/50 dark:bg-gray-850',
|
'sidenav hide-scrollbar border-l border-gray-200 bg-white transition-opacity dark:border-gray-800/50 dark:bg-gray-850',
|
||||||
isCollapsed ? 'min-w-[50px]' : 'min-w-[340px] sm:min-w-[352px]',
|
isCollapsed ? 'min-w-[50px]' : 'min-w-[340px] sm:min-w-[352px]',
|
||||||
minSize === 0 ? 'min-w-0' : '',
|
(isSmallScreen && isCollapsed && (minSize === 0 || collapsedSize === 0)) ||
|
||||||
|
minSize === defaultMinSize - 1
|
||||||
|
? 'hidden min-w-0'
|
||||||
|
: 'opacity-100',
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{keyProvided && (
|
{keyProvided && (
|
||||||
|
|
@ -211,4 +234,6 @@ export default function SidePanel({
|
||||||
/>
|
/>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
};
|
||||||
|
|
||||||
|
export default memo(SidePanel);
|
||||||
|
|
|
||||||
10
client/src/components/ui/TextareaAutosize.tsx
Normal file
10
client/src/components/ui/TextareaAutosize.tsx
Normal file
|
|
@ -0,0 +1,10 @@
|
||||||
|
import { forwardRef, useLayoutEffect, useState } from 'react';
|
||||||
|
import ReactTextareaAutosize from 'react-textarea-autosize';
|
||||||
|
import type { TextareaAutosizeProps } from 'react-textarea-autosize';
|
||||||
|
export const TextareaAutosize = forwardRef<HTMLTextAreaElement, TextareaAutosizeProps>(
|
||||||
|
(props, ref) => {
|
||||||
|
const [, setIsRerendered] = useState(false);
|
||||||
|
useLayoutEffect(() => setIsRerendered(true), []);
|
||||||
|
return <ReactTextareaAutosize {...props} ref={ref} />;
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
@ -18,6 +18,7 @@ export * from './Table';
|
||||||
export * from './Tabs';
|
export * from './Tabs';
|
||||||
export * from './Templates';
|
export * from './Templates';
|
||||||
export * from './Textarea';
|
export * from './Textarea';
|
||||||
|
export * from './TextareaAutosize';
|
||||||
export * from './Tooltip';
|
export * from './Tooltip';
|
||||||
export { default as Dropdown } from './Dropdown';
|
export { default as Dropdown } from './Dropdown';
|
||||||
export { default as FileUpload } from './FileUpload';
|
export { default as FileUpload } from './FileUpload';
|
||||||
|
|
|
||||||
|
|
@ -165,6 +165,7 @@ export default function useTextarea({
|
||||||
|
|
||||||
if (textAreaRef.current?.getAttribute('placeholder') !== placeholder) {
|
if (textAreaRef.current?.getAttribute('placeholder') !== placeholder) {
|
||||||
textAreaRef.current?.setAttribute('placeholder', placeholder);
|
textAreaRef.current?.setAttribute('placeholder', placeholder);
|
||||||
|
forceResize(textAreaRef);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -8,6 +8,7 @@ export default {
|
||||||
com_files_number_selected: '{0} of {1} file(s) selected',
|
com_files_number_selected: '{0} of {1} file(s) selected',
|
||||||
com_sidepanel_select_assistant: 'Select an Assistant',
|
com_sidepanel_select_assistant: 'Select an Assistant',
|
||||||
com_sidepanel_assistant_builder: 'Assistant Builder',
|
com_sidepanel_assistant_builder: 'Assistant Builder',
|
||||||
|
com_sidepanel_hide_panel: 'Hide Panel',
|
||||||
com_sidepanel_attach_files: 'Attach Files',
|
com_sidepanel_attach_files: 'Attach Files',
|
||||||
com_sidepanel_manage_files: 'Manage Files',
|
com_sidepanel_manage_files: 'Manage Files',
|
||||||
com_assistants_capabilities: 'Capabilities',
|
com_assistants_capabilities: 'Capabilities',
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue