mirror of
https://github.com/arc53/DocsGPT.git
synced 2025-11-30 00:53:14 +00:00
(feat:docs):add contextMenu to actions
This commit is contained in:
@@ -1,4 +1,4 @@
|
||||
import { SyntheticEvent, useRef, useEffect } from 'react';
|
||||
import { SyntheticEvent, useRef, useEffect, CSSProperties } from 'react';
|
||||
|
||||
export interface MenuOption {
|
||||
icon?: string;
|
||||
@@ -27,82 +27,93 @@ export default function ContextMenu({
|
||||
anchorRef,
|
||||
className = '',
|
||||
position = 'bottom-right',
|
||||
offset = { x: 1, y: 5 },
|
||||
offset = { x: 0, y: 8 },
|
||||
}: 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 handleClickOutside = (event: MouseEvent) => {
|
||||
if (
|
||||
menuRef.current &&
|
||||
!menuRef.current.contains(event.target as Node) &&
|
||||
!anchorRef.current?.contains(event.target as Node)
|
||||
) {
|
||||
setIsOpen(false);
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
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) {
|
||||
document.addEventListener('mousedown', handleClickOutside);
|
||||
return () =>
|
||||
document.removeEventListener('mousedown', handleClickOutside);
|
||||
}
|
||||
}, [isOpen, setIsOpen]);
|
||||
|
||||
if (!isOpen) return null;
|
||||
|
||||
const getMenuPosition = () => {
|
||||
const getMenuPosition = (): CSSProperties => {
|
||||
if (!anchorRef.current) return {};
|
||||
|
||||
const rect = anchorRef.current.getBoundingClientRect();
|
||||
return {
|
||||
top: `${rect.top + window.scrollY + offset.y}px`,
|
||||
};
|
||||
};
|
||||
const scrollY = window.scrollY || document.documentElement.scrollTop;
|
||||
const scrollX = window.scrollX || document.documentElement.scrollLeft;
|
||||
|
||||
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
|
||||
}`;
|
||||
let top = rect.bottom + scrollY + offset.y;
|
||||
let left = rect.right + scrollX + offset.x;
|
||||
|
||||
// Adjust position based on position prop
|
||||
switch (position) {
|
||||
case 'bottom-left':
|
||||
left = rect.left + scrollX - offset.x;
|
||||
break;
|
||||
case 'top-right':
|
||||
top = rect.top + scrollY - offset.y;
|
||||
break;
|
||||
case 'top-left':
|
||||
top = rect.top + scrollY - offset.y;
|
||||
left = rect.left + scrollX - offset.x;
|
||||
break;
|
||||
// bottom-right is default
|
||||
}
|
||||
|
||||
return `
|
||||
dark:text-bright-gray dark:hover:bg-charcoal-grey
|
||||
text-eerie-black hover:bg-bright-gray
|
||||
}`;
|
||||
return {
|
||||
position: 'fixed',
|
||||
top: `${top}px`,
|
||||
left: `${left}px`,
|
||||
};
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={menuRef}
|
||||
className={`absolute z-30 ${getPositionClasses()} ${className}`}
|
||||
style={getMenuPosition()}
|
||||
className={`fixed z-50 ${className}`}
|
||||
style={{ ...getMenuPosition() }}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<div
|
||||
className={`flex w-32 flex-col rounded-xl text-sm shadow-xl md:w-36 dark:bg-charleston-green-2 bg-lotion`}
|
||||
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) => {
|
||||
onClick={(event) => {
|
||||
event.preventDefault();
|
||||
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)}`}
|
||||
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' : ''}
|
||||
${
|
||||
option.variant === 'danger'
|
||||
? 'dark:text-red-2000 dark:hover:bg-charcoal-grey text-rosso-corsa hover:bg-bright-gray'
|
||||
: 'dark:text-bright-gray dark:hover:bg-charcoal-grey text-eerie-black hover:bg-bright-gray'
|
||||
}
|
||||
`}
|
||||
>
|
||||
{option.icon && (
|
||||
<img
|
||||
@@ -110,7 +121,7 @@ export default function ContextMenu({
|
||||
height={option.iconHeight || 16}
|
||||
src={option.icon}
|
||||
alt={option.label}
|
||||
className={`cursor-pointer hover:opacity-75 ${option.iconClassName}`}
|
||||
className={`cursor-pointer hover:opacity-75 ${option.iconClassName || ''}`}
|
||||
/>
|
||||
)}
|
||||
<span>{option.label}</span>
|
||||
|
||||
@@ -6,6 +6,11 @@ type DropdownMenuProps = {
|
||||
onSelect: (value: string) => void;
|
||||
defaultValue?: string;
|
||||
icon?: string;
|
||||
isOpen?: boolean;
|
||||
onOpenChange?: (isOpen: boolean) => void;
|
||||
anchorRef?: React.RefObject<HTMLElement>;
|
||||
className?: string;
|
||||
position?: 'left' | 'right';
|
||||
};
|
||||
|
||||
export default function DropdownMenu({
|
||||
@@ -14,16 +19,22 @@ export default function DropdownMenu({
|
||||
onSelect,
|
||||
defaultValue = 'none',
|
||||
icon,
|
||||
isOpen: controlledIsOpen,
|
||||
onOpenChange,
|
||||
anchorRef,
|
||||
className,
|
||||
position = 'left',
|
||||
}: DropdownMenuProps) {
|
||||
const dropdownRef = React.useRef<HTMLDivElement>(null);
|
||||
const [isOpen, setIsOpen] = React.useState(false);
|
||||
const [internalIsOpen, setInternalIsOpen] = React.useState(false);
|
||||
const [selectedOption, setSelectedOption] = React.useState(
|
||||
options.find((option) => option.value === defaultValue) || options[0],
|
||||
);
|
||||
|
||||
const handleToggle = () => {
|
||||
setIsOpen(!isOpen);
|
||||
};
|
||||
const isOpen =
|
||||
controlledIsOpen !== undefined ? controlledIsOpen : internalIsOpen;
|
||||
const setIsOpen = onOpenChange || setInternalIsOpen;
|
||||
|
||||
const handleClickOutside = (event: MouseEvent) => {
|
||||
if (
|
||||
dropdownRef.current &&
|
||||
@@ -32,6 +43,7 @@ export default function DropdownMenu({
|
||||
setIsOpen(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleClickOption = (optionId: number) => {
|
||||
setIsOpen(false);
|
||||
setSelectedOption(options[optionId]);
|
||||
@@ -44,17 +56,11 @@ export default function DropdownMenu({
|
||||
document.removeEventListener('mousedown', handleClickOutside);
|
||||
};
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className="static inline-block text-left" ref={dropdownRef}>
|
||||
<button
|
||||
onClick={handleToggle}
|
||||
className="flex w-20 cursor-pointer flex-row gap-1 rounded-3xl border-purple-30/25 bg-purple-30 p-2 text-xs text-white hover:bg-[#6F3FD1] focus:outline-none"
|
||||
>
|
||||
{icon && <img src={icon} alt="OptionIcon" className="h-4 w-4" />}
|
||||
{selectedOption.value !== 'never' ? selectedOption.label : name}
|
||||
</button>
|
||||
<div className={`fixed ${className || ''}`} ref={dropdownRef}>
|
||||
<div
|
||||
className={`absolute z-50 right-0 mt-1 w-28 transform rounded-md bg-transparent shadow-lg ring-1 ring-black ring-opacity-5 transition-all duration-200 ease-in-out ${
|
||||
className={`w-28 transform rounded-md bg-white dark:bg-dark-charcoal shadow-lg ring-1 ring-black ring-opacity-5 transition-all duration-200 ease-in-out ${
|
||||
isOpen
|
||||
? 'scale-100 opacity-100'
|
||||
: 'pointer-events-none scale-95 opacity-0'
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import React, { useCallback, useEffect, useState } from 'react';
|
||||
import React, { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useDispatch } from 'react-redux';
|
||||
|
||||
@@ -9,7 +9,7 @@ import Edit from '../assets/edit.svg';
|
||||
import NoFilesDarkIcon from '../assets/no-files-dark.svg';
|
||||
import NoFilesIcon from '../assets/no-files.svg';
|
||||
import SyncIcon from '../assets/sync.svg';
|
||||
import Trash from '../assets/trash.svg';
|
||||
import Trash from '../assets/red-trash.svg';
|
||||
import Pagination from '../components/DocumentPagination';
|
||||
import DropdownMenu from '../components/DropdownMenu';
|
||||
import Input from '../components/Input';
|
||||
@@ -27,6 +27,8 @@ import {
|
||||
import Upload from '../upload/Upload';
|
||||
import { formatDate } from '../utils/dateTimeUtils';
|
||||
import { ChunkType } from './types';
|
||||
import ContextMenu, { MenuOption } from '../components/ContextMenu';
|
||||
import ThreeDots from '../assets/three-dots.svg';
|
||||
|
||||
const formatTokens = (tokens: number): string => {
|
||||
const roundToTwoDecimals = (num: number): string => {
|
||||
@@ -61,6 +63,51 @@ export default function Documents({
|
||||
const [currentPage, setCurrentPage] = useState<number>(1);
|
||||
const [rowsPerPage, setRowsPerPage] = useState<number>(10);
|
||||
const [totalPages, setTotalPages] = useState<number>(1);
|
||||
|
||||
const [activeMenuId, setActiveMenuId] = useState<string | null>(null);
|
||||
const menuRefs = useRef<{ [key: string]: React.RefObject<HTMLDivElement> }>(
|
||||
{},
|
||||
);
|
||||
|
||||
// Create or get a ref for each document wrapper div (not the td)
|
||||
const getMenuRef = (docId: string) => {
|
||||
if (!menuRefs.current[docId]) {
|
||||
menuRefs.current[docId] = React.createRef<HTMLDivElement>();
|
||||
}
|
||||
return menuRefs.current[docId];
|
||||
};
|
||||
|
||||
const handleMenuClick = (e: React.MouseEvent, docId: string) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
|
||||
// Close any open menu if clicking on a different button
|
||||
if (activeMenuId && activeMenuId !== docId) {
|
||||
setActiveMenuId(null);
|
||||
}
|
||||
|
||||
// Toggle the clicked menu
|
||||
setActiveMenuId((prev) => (prev === docId ? null : docId));
|
||||
};
|
||||
|
||||
// Close menu when clicking outside
|
||||
useEffect(() => {
|
||||
const handleClickOutside = (event: MouseEvent) => {
|
||||
if (activeMenuId) {
|
||||
const activeRef = menuRefs.current[activeMenuId];
|
||||
if (
|
||||
activeRef?.current &&
|
||||
!activeRef.current.contains(event.target as Node)
|
||||
) {
|
||||
setActiveMenuId(null);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
document.addEventListener('mousedown', handleClickOutside);
|
||||
return () => document.removeEventListener('mousedown', handleClickOutside);
|
||||
}, [activeMenuId]);
|
||||
|
||||
const currentDocuments = paginatedDocuments ?? [];
|
||||
const syncOptions = [
|
||||
{ label: t('settings.documents.syncFrequency.never'), value: 'never' },
|
||||
@@ -69,6 +116,16 @@ export default function Documents({
|
||||
{ label: t('settings.documents.syncFrequency.monthly'), value: 'monthly' },
|
||||
];
|
||||
const [showDocumentChunks, setShowDocumentChunks] = useState<Doc>();
|
||||
const [isMenuOpen, setIsMenuOpen] = useState(false);
|
||||
const [syncMenuState, setSyncMenuState] = useState<{
|
||||
isOpen: boolean;
|
||||
docId: string | null;
|
||||
document: Doc | null;
|
||||
}>({
|
||||
isOpen: false,
|
||||
docId: null,
|
||||
document: null,
|
||||
});
|
||||
|
||||
const refreshDocs = useCallback(
|
||||
(
|
||||
@@ -164,6 +221,39 @@ export default function Documents({
|
||||
}
|
||||
};
|
||||
|
||||
const getActionOptions = (index: number, document: Doc): MenuOption[] => {
|
||||
const actions: MenuOption[] = [
|
||||
{
|
||||
icon: Trash,
|
||||
label: t('convTile.delete'),
|
||||
onClick: () => {
|
||||
handleDeleteConfirmation(index, document);
|
||||
},
|
||||
iconWidth: 18,
|
||||
iconHeight: 18,
|
||||
variant: 'danger',
|
||||
},
|
||||
];
|
||||
|
||||
if (document.syncFrequency) {
|
||||
actions.push({
|
||||
icon: SyncIcon,
|
||||
label: t('settings.documents.sync'),
|
||||
onClick: () => {
|
||||
setSyncMenuState({
|
||||
isOpen: true,
|
||||
docId: document.id ?? null,
|
||||
document: document,
|
||||
});
|
||||
},
|
||||
iconWidth: 14,
|
||||
iconHeight: 14,
|
||||
variant: 'primary',
|
||||
});
|
||||
}
|
||||
|
||||
return actions;
|
||||
};
|
||||
useEffect(() => {
|
||||
refreshDocs(undefined, 1, rowsPerPage);
|
||||
}, [searchTerm]);
|
||||
@@ -269,61 +359,90 @@ export default function Documents({
|
||||
</td>
|
||||
</tr>
|
||||
) : (
|
||||
currentDocuments.map((document, index) => (
|
||||
<tr
|
||||
key={index}
|
||||
className="group transition-colors cursor-pointer"
|
||||
onClick={() => setShowDocumentChunks(document)}
|
||||
>
|
||||
<td
|
||||
className="py-4 px-4 text-sm text-gray-700 dark:text-[#E0E0E0] w-[45%] min-w-48 max-w-0 truncate group-hover:bg-gray-50 dark:group-hover:bg-gray-800/50"
|
||||
title={document.name}
|
||||
currentDocuments.map((document, index) => {
|
||||
const docId = document.id ? document.id.toString() : '';
|
||||
|
||||
return (
|
||||
<tr
|
||||
key={docId}
|
||||
className="group transition-colors cursor-pointer"
|
||||
onClick={() => setShowDocumentChunks(document)}
|
||||
>
|
||||
{document.name}
|
||||
</td>
|
||||
<td className="py-4 px-4 text-center text-sm text-gray-700 dark:text-[#E0E0E0] whitespace-nowrap w-[20%] group-hover:bg-gray-50 dark:group-hover:bg-gray-800/50">
|
||||
{document.date ? formatDate(document.date) : ''}
|
||||
</td>
|
||||
<td className="py-4 px-4 text-center text-sm text-gray-700 dark:text-[#E0E0E0] whitespace-nowrap w-[25%] group-hover:bg-gray-50 dark:group-hover:bg-gray-800/50">
|
||||
{document.tokens
|
||||
? formatTokens(+document.tokens)
|
||||
: ''}
|
||||
</td>
|
||||
<td
|
||||
className="py-4 px-4 text-right w-[10%] group-hover:bg-gray-50 dark:group-hover:bg-gray-800/50"
|
||||
onClick={(e) => e.stopPropagation()} // Stop event propagation for the entire actions cell
|
||||
>
|
||||
<div className="flex items-center justify-end gap-3">
|
||||
{!document.syncFrequency && (
|
||||
<div className="w-8"></div>
|
||||
)}
|
||||
{document.syncFrequency && (
|
||||
<DropdownMenu
|
||||
name={t('settings.documents.sync')}
|
||||
options={syncOptions}
|
||||
onSelect={(value: string) => {
|
||||
handleManageSync(document, value);
|
||||
}}
|
||||
defaultValue={document.syncFrequency}
|
||||
icon={SyncIcon}
|
||||
/>
|
||||
)}
|
||||
<button
|
||||
onClick={() => {
|
||||
handleDeleteConfirmation(index, document);
|
||||
}}
|
||||
className="inline-flex items-center justify-center w-8 h-8 rounded-full hover:bg-gray-100 dark:hover:bg-gray-700 transition-colors flex-shrink-0"
|
||||
<td
|
||||
className="py-4 px-4 text-sm text-gray-700 dark:text-[#E0E0E0] w-[45%] min-w-48 max-w-0 truncate group-hover:bg-gray-50 dark:group-hover:bg-gray-800/50"
|
||||
title={document.name}
|
||||
>
|
||||
{document.name}
|
||||
</td>
|
||||
<td className="py-4 px-4 text-center text-sm text-gray-700 dark:text-[#E0E0E0] whitespace-nowrap w-[20%] group-hover:bg-gray-50 dark:group-hover:bg-gray-800/50">
|
||||
{document.date ? formatDate(document.date) : ''}
|
||||
</td>
|
||||
<td className="py-4 px-4 text-center text-sm text-gray-700 dark:text-[#E0E0E0] whitespace-nowrap w-[25%] group-hover:bg-gray-50 dark:group-hover:bg-gray-800/50">
|
||||
{document.tokens
|
||||
? formatTokens(+document.tokens)
|
||||
: ''}
|
||||
</td>
|
||||
<td
|
||||
className="py-4 px-4 text-right w-[10%] group-hover:bg-gray-50 dark:group-hover:bg-gray-800/50"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<div
|
||||
ref={getMenuRef(docId)}
|
||||
className="flex items-center justify-end gap-3 relative"
|
||||
>
|
||||
<img
|
||||
src={Trash}
|
||||
alt={t('convTile.delete')}
|
||||
className="h-4 w-4 opacity-60 hover:opacity-100"
|
||||
{document.syncFrequency && (
|
||||
<DropdownMenu
|
||||
name={t('settings.documents.sync')}
|
||||
options={syncOptions}
|
||||
onSelect={(value: string) => {
|
||||
handleManageSync(document, value);
|
||||
}}
|
||||
defaultValue={document.syncFrequency}
|
||||
icon={SyncIcon}
|
||||
isOpen={
|
||||
syncMenuState.docId === docId &&
|
||||
syncMenuState.isOpen
|
||||
}
|
||||
onOpenChange={(isOpen) => {
|
||||
setSyncMenuState((prev) => ({
|
||||
...prev,
|
||||
isOpen,
|
||||
docId: isOpen ? docId : null,
|
||||
document: isOpen ? document : null,
|
||||
}));
|
||||
}}
|
||||
anchorRef={getMenuRef(docId)}
|
||||
className="absolute right-12 top-0"
|
||||
/>
|
||||
)}
|
||||
<button
|
||||
onClick={(e) => handleMenuClick(e, docId)}
|
||||
className="inline-flex items-center justify-center w-8 h-8 rounded-full hover:bg-gray-100 dark:hover:bg-gray-700 transition-colors flex-shrink-0"
|
||||
aria-label="Open menu"
|
||||
data-testid={`menu-button-${docId}`}
|
||||
>
|
||||
<img
|
||||
src={ThreeDots}
|
||||
alt={t('convTile.menu')}
|
||||
className="h-4 w-4 opacity-60 hover:opacity-100"
|
||||
/>
|
||||
</button>
|
||||
<ContextMenu
|
||||
isOpen={activeMenuId === docId}
|
||||
setIsOpen={(isOpen) => {
|
||||
setActiveMenuId(isOpen ? docId : null);
|
||||
}}
|
||||
options={getActionOptions(index, document)}
|
||||
anchorRef={getMenuRef(docId)}
|
||||
position="bottom-left"
|
||||
offset={{ x: 48, y: -24 }}
|
||||
className="z-50"
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
))
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
Reference in New Issue
Block a user