diff --git a/frontend/src/components/ContextMenu.tsx b/frontend/src/components/ContextMenu.tsx index 9a3046e4..8a721198 100644 --- a/frontend/src/components/ContextMenu.tsx +++ b/frontend/src/components/ContextMenu.tsx @@ -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(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 (
e.stopPropagation()} >
{options.map((option, index) => ( +
{ const roundToTwoDecimals = (num: number): string => { @@ -61,6 +63,51 @@ export default function Documents({ const [currentPage, setCurrentPage] = useState(1); const [rowsPerPage, setRowsPerPage] = useState(10); const [totalPages, setTotalPages] = useState(1); + + const [activeMenuId, setActiveMenuId] = useState(null); + const menuRefs = useRef<{ [key: string]: React.RefObject }>( + {}, + ); + + // 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(); + } + 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(); + 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({ ) : ( - currentDocuments.map((document, index) => ( - setShowDocumentChunks(document)} - > - { + const docId = document.id ? document.id.toString() : ''; + + return ( + setShowDocumentChunks(document)} > - {document.name} - - - {document.date ? formatDate(document.date) : ''} - - - {document.tokens - ? formatTokens(+document.tokens) - : ''} - - e.stopPropagation()} // Stop event propagation for the entire actions cell - > -
- {!document.syncFrequency && ( -
- )} - {document.syncFrequency && ( - { - handleManageSync(document, value); - }} - defaultValue={document.syncFrequency} - icon={SyncIcon} - /> - )} - + { + setActiveMenuId(isOpen ? docId : null); + }} + options={getActionOptions(index, document)} + anchorRef={getMenuRef(docId)} + position="bottom-left" + offset={{ x: 48, y: -24 }} + className="z-50" /> - -
- - - )) +
+ + + ); + }) )}