🖼️ style: Conversation Menu and Dialogs update (#3601)

* feat: new dropdown

* fix: maintain popover active when open

* fix: update DeleteButton and ShareButton component to use useState for managing dialog state

* BREAKING: style improvement of base Button component

* style: update export button

* a11y: ExportAndShareButton

* add border

* quick style fix

* fix: flick issue on convo

* fix: DropDown opens when renaming

* chore: update radix-ui/react-dropdown-menu to latest

* small fix

* style: bookmarks update

* reorder export modal

* feat: imporved dropdowns

* style: a lot of changes; header, bookmarks, export, nav, convo, convoOptions

* fix: small style issues

* fix: button

* fix: bookmarks header menu

* fix: dropdown close glitch

* feat: Improve accessibility and keyboard navigation in ModelSpec component

* fix: Nav related type issues

* style: ConvoOptions theming and focus ring

---------

Co-authored-by: Danny Avila <danny@librechat.ai>
This commit is contained in:
Marco Beretta 2024-08-16 10:30:14 +02:00 committed by GitHub
parent 7f50d2f7c0
commit 96581d56df
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
62 changed files with 2627 additions and 1821 deletions

View file

@ -3,6 +3,7 @@ import { isAssistantsEndpoint } from 'librechat-data-provider';
import type { TConversation } from 'librechat-data-provider';
import { useChatContext, useAddedChatContext } from '~/Providers';
import { mainTextareaId } from '~/common';
import { Button } from '~/components/ui';
import { cn } from '~/utils';
function AddMultiConvo({ className = '' }: { className?: string }) {
@ -32,16 +33,15 @@ function AddMultiConvo({ className = '' }: { className?: string }) {
}
return (
<button
<Button
id="add-multi-conversation-button"
aria-label="Add multi-conversation"
onClick={clickHandler}
className={cn(
'group m-1.5 flex w-fit cursor-pointer items-center rounded text-sm hover:bg-border-medium focus-visible:bg-border-medium focus-visible:outline-offset-2',
className,
)}
variant="outline"
className={cn('h-10 w-10 p-0 transition-all duration-300 ease-in-out', className)}
>
<PlusCircle size={16} />
</button>
</Button>
);
}

View file

@ -1,64 +1,95 @@
import { useState } from 'react';
import { Upload } from 'lucide-react';
import { Upload, Share2 } from 'lucide-react';
import { useRecoilValue } from 'recoil';
import DropDownMenu from '~/components/Conversations/DropDownMenu';
import ShareButton from '~/components/Conversations/ShareButton';
import HoverToggle from '~/components/Conversations/HoverToggle';
import { ShareButton } from '~/components/Conversations/ConvoOptions';
import { Button, DropdownPopup } from '~/components/ui';
import useLocalize from '~/hooks/useLocalize';
import ExportButton from './ExportButton';
import { ExportModal } from '../Nav';
import store from '~/store';
export default function ExportAndShareMenu({
isSharedButtonEnabled,
className = '',
}: {
isSharedButtonEnabled: boolean;
className?: string;
}) {
const localize = useLocalize();
const conversation = useRecoilValue(store.conversationByIndex(0));
const [isPopoverActive, setIsPopoverActive] = useState(false);
const [showExports, setShowExports] = useState(false);
const [showShareDialog, setShowShareDialog] = useState(false);
const exportable =
conversation &&
conversation.conversationId &&
conversation.conversationId != null &&
conversation.conversationId !== 'new' &&
conversation.conversationId !== 'search';
if (!exportable) {
if (exportable === false) {
return null;
}
const isActiveConvo = exportable;
const onOpenChange = (value: boolean) => {
setShowExports(value);
};
const shareHandler = () => {
setIsPopoverActive(false);
setShowShareDialog(true);
};
const exportHandler = () => {
setIsPopoverActive(false);
setShowExports(true);
};
const dropdownItems = [
{
label: localize('com_endpoint_export'),
onClick: exportHandler,
icon: <Upload className="icon-md mr-2 dark:text-gray-300" />,
},
{
label: localize('com_ui_share'),
onClick: shareHandler,
icon: <Share2 className="icon-md mr-2 dark:text-gray-300" />,
show: isSharedButtonEnabled,
},
];
return (
<HoverToggle
isActiveConvo={!!isActiveConvo}
isPopoverActive={isPopoverActive}
setIsPopoverActive={setIsPopoverActive}
className={className}
>
<DropDownMenu
icon={<Upload />}
tooltip={localize('com_endpoint_export_share')}
className="pointer-cursor relative z-50 flex h-[40px] min-w-4 flex-none flex-col items-center justify-center rounded-md border border-gray-100 bg-white px-3 text-left hover:bg-gray-50 focus:outline-none focus:ring-0 focus:ring-offset-0 radix-state-open:bg-gray-50 dark:border-gray-700 dark:bg-gray-800 dark:hover:bg-gray-700 dark:radix-state-open:bg-gray-700 sm:text-sm"
>
{conversation && conversation.conversationId && (
<>
<ExportButton conversation={conversation} setPopoverActive={setIsPopoverActive} />
{isSharedButtonEnabled && (
<ShareButton
conversationId={conversation.conversationId}
title={conversation.title ?? ''}
appendLabel={true}
className="mb-[3.5px]"
setPopoverActive={setIsPopoverActive}
/>
)}
</>
)}
</DropDownMenu>
</HoverToggle>
<>
<DropdownPopup
isOpen={isPopoverActive}
setIsOpen={setIsPopoverActive}
trigger={
<Button
id="export-menu-button"
aria-label="Export options"
variant="outline"
className="mr-4 h-10 w-10 p-0 transition-all duration-300 ease-in-out"
>
<Upload className="icon-md dark:text-gray-300" aria-hidden="true" focusable="false" />
</Button>
}
items={dropdownItems}
anchor="bottom end"
/>
{showShareDialog && conversation.conversationId != null && (
<ShareButton
conversationId={conversation.conversationId}
title={conversation.title ?? ''}
showShareDialog={showShareDialog}
setShowShareDialog={setShowShareDialog}
/>
)}
{showExports && (
<ExportModal
open={showExports}
onOpenChange={onOpenChange}
conversation={conversation}
aria-label="Export conversation modal"
/>
)}
</>
);
}

View file

@ -1,44 +0,0 @@
import React from 'react';
import { useState } from 'react';
import type { TConversation } from 'librechat-data-provider';
import { Upload } from 'lucide-react';
import { useLocalize } from '~/hooks';
import { ExportModal } from '../Nav';
function ExportButton({
conversation,
setPopoverActive,
}: {
conversation: TConversation;
setPopoverActive: (value: boolean) => void;
}) {
const localize = useLocalize();
const [showExports, setShowExports] = useState(false);
const clickHandler = () => {
setShowExports(true);
};
const onOpenChange = (value: boolean) => {
setShowExports(value);
setPopoverActive(value);
};
return (
<>
<button
onClick={clickHandler}
className="group m-1.5 flex w-full cursor-pointer items-center gap-2 rounded p-2.5 text-sm hover:bg-gray-200 focus-visible:bg-gray-200 focus-visible:outline-0 radix-disabled:pointer-events-none radix-disabled:opacity-50 dark:hover:bg-gray-600 dark:focus-visible:bg-gray-600"
>
<Upload size={16} /> {localize('com_nav_export')}
</button>
{showExports && (
<ExportModal open={showExports} onOpenChange={onOpenChange} conversation={conversation} />
)}
</>
);
}
export default ExportButton;

View file

@ -12,31 +12,31 @@ export default function Footer({ className }: { className?: string }) {
const privacyPolicy = config?.interface?.privacyPolicy;
const termsOfService = config?.interface?.termsOfService;
const privacyPolicyRender = privacyPolicy?.externalUrl && (
const privacyPolicyRender = privacyPolicy?.externalUrl != null && (
<a
className=" text-gray-600 underline dark:text-gray-300"
className="text-text-secondary underline"
href={privacyPolicy.externalUrl}
target={privacyPolicy.openNewTab ? '_blank' : undefined}
target={privacyPolicy.openNewTab === true ? '_blank' : undefined}
rel="noreferrer"
>
{localize('com_ui_privacy_policy')}
</a>
);
const termsOfServiceRender = termsOfService?.externalUrl && (
const termsOfServiceRender = termsOfService?.externalUrl != null && (
<a
className=" text-gray-600 underline dark:text-gray-300"
className="text-text-secondary underline"
href={termsOfService.externalUrl}
target={termsOfService.openNewTab ? '_blank' : undefined}
target={termsOfService.openNewTab === true ? '_blank' : undefined}
rel="noreferrer"
>
{localize('com_ui_terms_of_service')}
</a>
);
if (config?.analyticsGtmId) {
if (config?.analyticsGtmId != null) {
const tagManagerArgs = {
gtmId: config?.analyticsGtmId,
gtmId: config.analyticsGtmId,
};
TagManager.initialize(tagManagerArgs);
}
@ -54,19 +54,22 @@ export default function Footer({ className }: { className?: string }) {
<React.Fragment key={`main-content-part-${index}`}>
<ReactMarkdown
components={{
a: (props) => {
const { ['node']: _, href, ...otherProps } = props;
// eslint-disable-next-line @typescript-eslint/no-unused-vars
a: ({ node: _n, href, children, ...otherProps }) => {
return (
<a
className=" text-gray-600 underline dark:text-gray-300"
className="text-text-secondary underline"
href={href}
target="_blank"
rel="noreferrer"
{...otherProps}
/>
>
{children}
</a>
);
},
p: ({ node, ...props }) => <span {...props} />,
// eslint-disable-next-line @typescript-eslint/no-unused-vars
p: ({ node: _n, ...props }) => <span {...props} />,
}}
>
{text.trim()}
@ -81,8 +84,8 @@ export default function Footer({ className }: { className?: string }) {
return (
<div
className={
className ||
'relative flex items-center justify-center gap-2 px-2 py-2 text-center text-xs text-gray-600 dark:text-gray-300 md:px-[60px]'
className ??
'relative flex items-center justify-center gap-2 px-2 py-2 text-center text-xs text-text-primary md:px-[60px]'
}
role="contentinfo"
>
@ -92,7 +95,7 @@ export default function Footer({ className }: { className?: string }) {
<React.Fragment key={`footer-element-${index}`}>
{contentRender}
{!isLastElement && (
<div key={`separator-${index}`} className="h-2 border-r-[1px] border-gray-300" />
<div key={`separator-${index}`} className="h-2 border-r-[1px] border-border-medium" />
)}
</React.Fragment>
);

View file

@ -29,17 +29,16 @@ export default function Header() {
<div className="flex items-center gap-2">
{!navVisible && <HeaderNewChat />}
{interfaceConfig.endpointsMenu && <EndpointsMenu />}
{modelSpecs?.length > 0 && <ModelSpecsMenu modelSpecs={modelSpecs} />}
{modelSpecs.length > 0 && <ModelSpecsMenu modelSpecs={modelSpecs} />}
{<HeaderOptions interfaceConfig={interfaceConfig} />}
{interfaceConfig.presets && <PresetsMenu />}
<BookmarkMenu />
<AddMultiConvo />
{isSmallScreen && (
<ExportAndShareMenu
isSharedButtonEnabled={startupConfig?.sharedLinksEnabled ?? false}
className="pl-0"
/>
)}
<BookmarkMenu />
<AddMultiConvo />
</div>
{!isSmallScreen && (
<ExportAndShareMenu isSharedButtonEnabled={startupConfig?.sharedLinksEnabled ?? false} />

View file

@ -33,12 +33,12 @@ export default function OptionsPopover({
(_target) => {
const target = _target as Element;
if (
target?.id === 'presets-button' ||
(target?.parentNode instanceof Element && target.parentNode.id === 'presets-button')
target.id === 'presets-button' ||
(target.parentNode instanceof Element && target.parentNode.id === 'presets-button')
) {
return false;
}
const tagName = target?.tagName;
const tagName = target.tagName;
return tagName === 'path' || tagName === 'svg' || tagName === 'circle';
},
);

View file

@ -1,106 +1,128 @@
import { useState, type FC } from 'react';
import { useState, type FC, useCallback } from 'react';
import { useRecoilValue } from 'recoil';
import { Constants } from 'librechat-data-provider';
import { Content, Portal, Root, Trigger } from '@radix-ui/react-popover';
import { Menu, MenuButton, MenuItems } from '@headlessui/react';
import { BookmarkFilledIcon, BookmarkIcon } from '@radix-ui/react-icons';
import { useConversationTagsQuery, useTagConversationMutation } from '~/data-provider';
import { BookmarkMenuItems } from './Bookmarks/BookmarkMenuItems';
import { BookmarkContext } from '~/Providers/BookmarkContext';
import { useLocalize, useBookmarkSuccess } from '~/hooks';
import { BookmarkEditDialog } from '~/components/Bookmarks';
import { NotificationSeverity } from '~/common';
import { useToastContext } from '~/Providers';
import { useBookmarkSuccess } from '~/hooks';
import { Spinner } from '~/components';
import { cn } from '~/utils';
import store from '~/store';
const BookmarkMenu: FC = () => {
const localize = useLocalize();
const { showToast } = useToastContext();
const conversation = useRecoilValue(store.conversationByIndex(0));
const conversation = useRecoilValue(store.conversationByIndex(0)) || undefined;
const conversationId = conversation?.conversationId ?? '';
const onSuccess = useBookmarkSuccess(conversationId);
const [tags, setTags] = useState<string[]>(conversation?.tags || []);
const [open, setIsOpen] = useState(false);
const [open, setOpen] = useState(false);
const { mutateAsync, isLoading } = useTagConversationMutation(conversationId);
const { data } = useConversationTagsQuery();
const isActiveConvo =
const isActiveConvo = Boolean(
conversation &&
conversationId &&
conversationId !== Constants.NEW_CONVO &&
conversationId !== 'search';
conversationId &&
conversationId !== Constants.NEW_CONVO &&
conversationId !== 'search',
);
const handleSubmit = useCallback(
async (tag?: string): Promise<void> => {
if (tag === undefined || tag === '' || !conversationId) {
showToast({
message: 'Invalid tag or conversationId',
severity: NotificationSeverity.ERROR,
});
return;
}
const newTags = tags.includes(tag) ? tags.filter((t) => t !== tag) : [...tags, tag];
await mutateAsync(
{
tags: newTags,
},
{
onSuccess: (newTags: string[]) => {
setTags(newTags);
onSuccess(newTags);
},
onError: () => {
showToast({
message: 'Error adding bookmark',
severity: NotificationSeverity.ERROR,
});
},
},
);
},
[tags, conversationId, mutateAsync, setTags, onSuccess, showToast],
);
if (!isActiveConvo) {
return <></>;
}
const onOpenChange = async (open: boolean) => {
if (!open) {
setIsOpen(open);
return;
}
if (open && tags && tags.length > 0) {
setIsOpen(open);
} else {
if (conversation && conversationId) {
await mutateAsync(
{
tags: [Constants.SAVED_TAG as 'Saved'],
},
{
onSuccess: (newTags: string[]) => {
setTags(newTags);
onSuccess(newTags);
},
onError: () => {
console.error('Error adding bookmark');
},
},
);
}
}
};
const renderButtonContent = () => {
if (isLoading) {
return <Spinner />;
return <Spinner aria-label="Spinner" />;
}
if (tags && tags.length > 0) {
return <BookmarkFilledIcon className="icon-sm" />;
if (tags.length > 0) {
return <BookmarkFilledIcon className="icon-sm" aria-label="Filled Bookmark" />;
}
return <BookmarkIcon className="icon-sm" />;
return <BookmarkIcon className="icon-sm" aria-label="Bookmark" />;
};
const handleToggleOpen = () => {
setOpen(!open);
return Promise.resolve();
};
return (
<Root open={open} onOpenChange={onOpenChange}>
<Trigger asChild>
<button
id="header-bookmarks-menu"
className={cn(
'pointer-cursor relative flex flex-col rounded-md border border-border-light bg-transparent text-left focus:outline-none focus:ring-0 sm:text-sm',
'hover:bg-header-button-hover radix-state-open:bg-header-button-hover',
'z-50 flex h-[40px] min-w-4 flex-none items-center justify-center px-3 focus:outline-offset-2 focus:ring-0 focus-visible:ring-2 focus-visible:ring-ring-primary ',
)}
title={localize('com_ui_bookmarks')}
>
{renderButtonContent()}
</button>
</Trigger>
<Portal>
<Content
className="mt-2 grid max-h-[500px] w-full min-w-[240px] overflow-y-auto rounded-lg border border-border-medium bg-header-primary text-text-primary shadow-lg"
side="bottom"
align="start"
>
{data && conversation && (
<BookmarkContext.Provider value={{ bookmarks: data }}>
<BookmarkMenuItems conversation={conversation} tags={tags ?? []} setTags={setTags} />
</BookmarkContext.Provider>
)}
</Content>
</Portal>
</Root>
<>
<Menu as="div" className="group relative">
{({ open }) => (
<>
<MenuButton
aria-label="Add bookmarks"
className={cn(
'mt-text-sm flex size-10 items-center justify-center gap-2 rounded-lg border border-border-light text-sm transition-colors duration-200 hover:bg-surface-hover',
open ? 'bg-surface-hover' : '',
)}
data-testid="bookmark-menu"
>
{renderButtonContent()}
</MenuButton>
<MenuItems
anchor="bottom start"
className="overflow-hidden rounded-lg bg-header-primary p-1.5 shadow-lg outline-none"
>
<BookmarkContext.Provider value={{ bookmarks: data || [] }}>
<BookmarkMenuItems
handleToggleOpen={handleToggleOpen}
tags={tags}
handleSubmit={handleSubmit}
/>
</BookmarkContext.Provider>
</MenuItems>
</>
)}
</Menu>
<BookmarkEditDialog
conversation={conversation}
tags={tags}
setTags={setTags}
open={open}
setOpen={setOpen}
/>
</>
);
};

View file

@ -1,78 +1,34 @@
import React, { useCallback } from 'react';
import React from 'react';
import { BookmarkPlusIcon } from 'lucide-react';
import type { FC } from 'react';
import type { TConversation } from 'librechat-data-provider';
import { BookmarkItems, BookmarkEditDialog } from '~/components/Bookmarks';
import { useTagConversationMutation } from '~/data-provider';
import { useLocalize, useBookmarkSuccess } from '~/hooks';
import { NotificationSeverity } from '~/common';
import { useToastContext } from '~/Providers';
import { BookmarkItems, BookmarkItem } from '~/components/Bookmarks';
import { useLocalize } from '~/hooks';
export const BookmarkMenuItems: FC<{
conversation: TConversation;
tags: string[];
setTags: React.Dispatch<React.SetStateAction<string[]>>;
}> = ({ conversation, tags, setTags }) => {
const { showToast } = useToastContext();
handleToggleOpen?: () => Promise<void>;
handleSubmit: (tag?: string) => Promise<void>;
}> = ({
tags,
handleSubmit,
handleToggleOpen = async () => {
('');
},
}) => {
const localize = useLocalize();
const conversationId = conversation?.conversationId ?? '';
const onSuccess = useBookmarkSuccess(conversationId);
const { mutateAsync } = useTagConversationMutation(conversationId);
const handleSubmit = useCallback(
async (tag: string): Promise<void> => {
if (tags !== undefined && conversationId) {
const newTags = tags.includes(tag) ? tags.filter((t) => t !== tag) : [...tags, tag];
await mutateAsync(
{
tags: newTags,
},
{
onSuccess: (newTags: string[]) => {
setTags(newTags);
onSuccess(newTags);
},
onError: () => {
showToast({
message: 'Error adding bookmark',
severity: NotificationSeverity.ERROR,
});
},
},
);
}
},
[tags, conversationId, mutateAsync, setTags, onSuccess, showToast],
);
return (
<BookmarkItems
ctx="header"
tags={tags}
handleSubmit={handleSubmit}
header={
<div>
<BookmarkEditDialog
conversation={conversation}
tags={tags}
setTags={setTags}
trigger={
<div
role="menuitem"
className="group m-1.5 flex cursor-pointer gap-2 rounded px-2 !pr-3.5 pb-2.5 pt-3 text-sm !opacity-100 hover:bg-header-hover focus:ring-0 radix-disabled:pointer-events-none radix-disabled:opacity-50"
tabIndex={-1}
>
<div className="flex grow items-center justify-between gap-2">
<div className="flex items-center gap-2">
<BookmarkPlusIcon className="size-4" />
<div className="break-all">{localize('com_ui_bookmarks_new')}</div>
</div>
</div>
</div>
}
/>
</div>
<BookmarkItem
tag={localize('com_ui_bookmarks_new')}
data-testid="bookmark-item-new"
handleSubmit={handleToggleOpen}
selected={false}
icon={<BookmarkPlusIcon className="size-4" aria-label="Add Bookmark" />}
/>
}
/>
);

View file

@ -36,7 +36,7 @@ const MenuItem: FC<MenuItemProps> = ({
const expiryTime = getExpiry();
const clickHandler = () => {
if (!expiryTime) {
if (expiryTime == null) {
setDialogOpen(true);
}
if (onClick) {
@ -60,6 +60,12 @@ const MenuItem: FC<MenuItemProps> = ({
{...rest}
onClick={clickHandler}
aria-label={title}
onKeyDown={(e) => {
if (e.key === 'Enter') {
e.preventDefault();
clickHandler();
}
}}
>
<div className="flex grow items-center justify-between gap-2">
<div>
@ -78,7 +84,7 @@ const MenuItem: FC<MenuItemProps> = ({
className={cn(
'invisible flex gap-x-1 group-hover:visible',
selected ? 'visible' : '',
expiryTime
expiryTime != null
? 'w-full rounded-lg p-2 hover:bg-gray-200 dark:hover:bg-gray-900'
: '',
)}
@ -88,10 +94,15 @@ const MenuItem: FC<MenuItemProps> = ({
setDialogOpen(true);
}}
>
<div className={cn('invisible group-hover:visible', expiryTime ? 'text-xs' : '')}>
<div
className={cn(
'invisible group-hover:visible',
expiryTime != null ? 'text-xs' : '',
)}
>
{localize('com_endpoint_config_key')}
</div>
<Settings className={cn(expiryTime ? 'icon-sm' : 'icon-md stroke-1')} />
<Settings className={cn(expiryTime != null ? 'icon-sm' : 'icon-md stroke-1')} />
</button>
</div>
) : null}

View file

@ -45,7 +45,7 @@ const PresetItems: FC<{
<div className="flex h-full grow items-center justify-end gap-2">
<label
htmlFor="default-preset"
className="w-40 truncate rounded bg-transparent py-1 text-xs font-medium font-normal text-gray-600 transition-colors dark:bg-transparent dark:text-gray-300 sm:w-72"
className="w-40 truncate rounded bg-transparent py-1 text-xs font-medium text-gray-600 transition-colors dark:bg-transparent dark:text-gray-300 sm:w-72"
>
{defaultPreset
? `${localize('com_endpoint_preset_default_item')} ${defaultPreset.title}`
@ -55,7 +55,7 @@ const PresetItems: FC<{
<DialogTrigger asChild>
<label
htmlFor="file-upload"
className="mr-1 flex h-[32px] cursor-pointer items-center rounded bg-transparent px-2 py-1 text-xs font-medium font-normal text-gray-600 transition-colors hover:bg-gray-100 hover:text-red-700 dark:bg-transparent dark:text-gray-300 dark:hover:bg-gray-700 dark:hover:text-green-500 dark:hover:text-red-700"
className="mr-1 flex h-[32px] cursor-pointer items-center rounded bg-transparent px-2 py-1 text-xs font-medium text-gray-600 transition-colors hover:bg-gray-100 hover:text-red-700 dark:bg-transparent dark:text-gray-300 dark:hover:bg-gray-700 dark:hover:text-red-700"
>
<svg
width="24"
@ -159,7 +159,7 @@ const PresetItems: FC<{
<PinIcon unpin={defaultPreset?.presetId === preset.presetId} />
</button>
<button
className="m-0 h-full rounded-md p-2 text-gray-400 hover:text-gray-700 dark:bg-gray-600 dark:text-gray-400 dark:hover:text-gray-200 sm:invisible sm:group-hover:visible"
className="m-0 h-full rounded-md p-2 text-gray-400 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200 sm:invisible sm:group-hover:visible"
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
@ -169,7 +169,7 @@ const PresetItems: FC<{
<EditIcon />
</button>
<button
className="m-0 h-full rounded-md p-2 text-gray-400 hover:text-gray-600 dark:bg-gray-600 dark:text-gray-400 dark:hover:text-gray-200 sm:invisible sm:group-hover:visible"
className="m-0 h-full rounded-md p-2 text-gray-400 hover:text-gray-600 dark:text-gray-400 dark:hover:text-gray-200 sm:invisible sm:group-hover:visible"
onClick={(e) => {
e.preventDefault();
e.stopPropagation();