mirror of
https://github.com/arc53/DocsGPT.git
synced 2025-11-29 08:33:20 +00:00
(feat:menu) add reusable menu, ui
This commit is contained in:
122
frontend/src/components/ContextMenu.tsx
Normal file
122
frontend/src/components/ContextMenu.tsx
Normal file
@@ -0,0 +1,122 @@
|
||||
import { SyntheticEvent, useRef, useEffect } from 'react';
|
||||
|
||||
export interface MenuOption {
|
||||
icon?: string;
|
||||
label: string;
|
||||
onClick: (event: SyntheticEvent) => void;
|
||||
variant?: 'primary' | 'danger';
|
||||
iconClassName?: string;
|
||||
iconWidth?: number;
|
||||
iconHeight?: number;
|
||||
}
|
||||
|
||||
interface ContextMenuProps {
|
||||
isOpen: boolean;
|
||||
setIsOpen: (isOpen: boolean) => void;
|
||||
options: MenuOption[];
|
||||
anchorRef: React.RefObject<HTMLElement>;
|
||||
className?: string;
|
||||
position?: 'bottom-right' | 'bottom-left' | 'top-right' | 'top-left';
|
||||
offset?: { x: number; y: number };
|
||||
}
|
||||
|
||||
export default function ContextMenu({
|
||||
isOpen,
|
||||
setIsOpen,
|
||||
options,
|
||||
anchorRef,
|
||||
className = '',
|
||||
position = 'bottom-right',
|
||||
offset = { x: 1, y: 5 },
|
||||
}: ContextMenuProps) {
|
||||
const menuRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const handleClickOutside = (event: MouseEvent) => {
|
||||
if (menuRef.current && !menuRef.current.contains(event.target as Node)) {
|
||||
setIsOpen(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
document.addEventListener('mousedown', handleClickOutside);
|
||||
return () => {
|
||||
document.removeEventListener('mousedown', handleClickOutside);
|
||||
};
|
||||
}, []);
|
||||
|
||||
const getPositionClasses = () => {
|
||||
const positionMap = {
|
||||
'bottom-right': 'translate-x-1 translate-y-5',
|
||||
'bottom-left': '-translate-x-full translate-y-5',
|
||||
'top-right': 'translate-x-1 -translate-y-full',
|
||||
'top-left': '-translate-x-full -translate-y-full',
|
||||
};
|
||||
return positionMap[position];
|
||||
};
|
||||
|
||||
if (!isOpen) return null;
|
||||
|
||||
const getMenuPosition = () => {
|
||||
if (!anchorRef.current) return {};
|
||||
|
||||
const rect = anchorRef.current.getBoundingClientRect();
|
||||
return {
|
||||
top: `${rect.top + window.scrollY + offset.y}px`,
|
||||
};
|
||||
};
|
||||
|
||||
const getOptionStyles = (option: MenuOption, index: number) => {
|
||||
if (option.variant === 'danger') {
|
||||
return `
|
||||
dark:text-red-2000 dark:hover:bg-charcoal-grey
|
||||
text-rosso-corsa hover:bg-bright-gray
|
||||
}`;
|
||||
}
|
||||
|
||||
return `
|
||||
dark:text-bright-gray dark:hover:bg-charcoal-grey
|
||||
text-eerie-black hover:bg-bright-gray
|
||||
}`;
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={menuRef}
|
||||
className={`absolute z-30 ${getPositionClasses()} ${className}`}
|
||||
style={getMenuPosition()}
|
||||
>
|
||||
<div
|
||||
className={`flex w-32 flex-col rounded-xl text-sm shadow-xl md:w-36 dark:bg-charleston-green-2 bg-lotion`}
|
||||
style={{ minWidth: '144px' }}
|
||||
>
|
||||
{options.map((option, index) => (
|
||||
<button
|
||||
key={index}
|
||||
onClick={(event: SyntheticEvent) => {
|
||||
event.stopPropagation();
|
||||
option.onClick(event);
|
||||
setIsOpen(false);
|
||||
}}
|
||||
className={`${`
|
||||
flex justify-start items-center gap-4 p-3
|
||||
transition-colors duration-200 ease-in-out
|
||||
${index === 0 ? 'rounded-t-xl' : ''}
|
||||
${index === options.length - 1 ? 'rounded-b-xl' : ''}
|
||||
`}${getOptionStyles(option, index)}`}
|
||||
>
|
||||
{option.icon && (
|
||||
<img
|
||||
width={option.iconWidth || 16}
|
||||
height={option.iconHeight || 16}
|
||||
src={option.icon}
|
||||
alt={option.label}
|
||||
className={`cursor-pointer hover:opacity-75 ${option.iconClassName}`}
|
||||
/>
|
||||
)}
|
||||
<span>{option.label}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
selectPrompt,
|
||||
} from '../preferences/preferenceSlice';
|
||||
import Dropdown from '../components/Dropdown';
|
||||
import ToggleSwitch from '../components/ToggleSwitch';
|
||||
import { Doc } from '../models/misc';
|
||||
import Spinner from '../assets/spinner.svg';
|
||||
const apiHost = import.meta.env.VITE_API_HOST || 'https://docsapi.arc53.com';
|
||||
@@ -101,38 +102,21 @@ export const ShareConversationModal = ({
|
||||
return (
|
||||
<WrapperModal close={close}>
|
||||
<div className="flex flex-col gap-2">
|
||||
<h2 className="text-xl font-medium text-eerie-black dark:text-white">
|
||||
<h2 className="text-xl font-medium text-eerie-black dark:text-chinese-white">
|
||||
{t('modals.shareConv.label')}
|
||||
</h2>
|
||||
<p className="text-sm text-eerie-black dark:text-white">
|
||||
<p className="text-sm text-eerie-black dark:text-silver/60">
|
||||
{t('modals.shareConv.note')}
|
||||
</p>
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-lg text-eerie-black dark:text-white">
|
||||
{t('modals.shareConv.option')}
|
||||
</span>
|
||||
<label className="cursor-pointer select-none items-center">
|
||||
<div className="relative">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={allowPrompt}
|
||||
onChange={togglePromptPermission}
|
||||
className="sr-only"
|
||||
/>
|
||||
<div
|
||||
className={`box block h-8 w-14 rounded-full border border-purple-30 ${
|
||||
allowPrompt
|
||||
? 'bg-purple-30 dark:bg-purple-30'
|
||||
: 'dark:bg-transparent'
|
||||
}`}
|
||||
></div>
|
||||
<div
|
||||
className={`absolute left-1 top-1 flex h-6 w-6 items-center justify-center rounded-full transition ${
|
||||
allowPrompt ? 'translate-x-full bg-silver' : 'bg-purple-30'
|
||||
}`}
|
||||
></div>
|
||||
</div>
|
||||
</label>
|
||||
<ToggleSwitch
|
||||
checked={allowPrompt}
|
||||
onChange={togglePromptPermission}
|
||||
size="medium"
|
||||
/>
|
||||
</div>
|
||||
{allowPrompt && (
|
||||
<div className="my-4">
|
||||
@@ -149,19 +133,19 @@ export const ShareConversationModal = ({
|
||||
</div>
|
||||
)}
|
||||
<div className="flex items-baseline justify-between gap-2">
|
||||
<span className="no-scrollbar w-full overflow-x-auto whitespace-nowrap rounded-full border-2 py-3 px-4 text-eerie-black dark:text-white">
|
||||
<span className="no-scrollbar w-full overflow-x-auto whitespace-nowrap rounded-full border-2 border-silver dark:border-silver/40 py-3 px-4 text-eerie-black dark:text-white">
|
||||
{`${domain}/share/${identifier ?? '....'}`}
|
||||
</span>
|
||||
{status === 'fetched' ? (
|
||||
<button
|
||||
className="my-1 h-10 w-28 rounded-full border border-solid bg-purple-30 p-2 text-sm text-white hover:bg-[#6F3FD1]"
|
||||
className="my-1 h-10 w-28 rounded-full border border-solid bg-purple-30 p-2 text-sm text-white hover:bg-purple-hover"
|
||||
onClick={() => handleCopyKey(`${domain}/share/${identifier}`)}
|
||||
>
|
||||
{isCopied ? t('modals.saveKey.copied') : t('modals.saveKey.copy')}
|
||||
</button>
|
||||
) : (
|
||||
<button
|
||||
className="my-1 flex h-10 w-28 items-center justify-evenly rounded-full border border-solid bg-purple-30 p-2 text-center text-sm font-normal text-white hover:bg-[#6F3FD1]"
|
||||
className="my-1 flex h-10 w-28 items-center justify-evenly rounded-full bg-purple-30 p-2 text-center text-sm font-normal text-white hover:bg-purple-hover"
|
||||
onClick={() => {
|
||||
shareCoversationPublicly(allowPrompt);
|
||||
}}
|
||||
|
||||
Reference in New Issue
Block a user