diff --git a/resources/scripts/components/elements/DropdownMenu.tsx b/resources/scripts/components/elements/DropdownMenu.tsx index 05b3d3c7c..b32976e1c 100644 --- a/resources/scripts/components/elements/DropdownMenu.tsx +++ b/resources/scripts/components/elements/DropdownMenu.tsx @@ -1,4 +1,4 @@ -import React, { useEffect, useRef, useState } from 'react'; +import React, { createRef } from 'react'; import styled from 'styled-components/macro'; import tw from 'twin.macro'; import Fade from '@/components/elements/Fade'; @@ -17,64 +17,93 @@ export const DropdownButtonRow = styled.button<{ danger?: boolean }>` } `; -const DropdownMenu = ({ renderToggle, children }: Props) => { - const menu = useRef<HTMLDivElement>(null); - const [ posX, setPosX ] = useState(0); - const [ visible, setVisible ] = useState(false); +interface State { + posX: number; + visible: boolean; +} - const onClickHandler = (e: React.MouseEvent<any, MouseEvent>) => { +class DropdownMenu extends React.PureComponent<Props, State> { + menu = createRef<HTMLDivElement>(); + + state: State = { + posX: 0, + visible: false, + }; + + componentWillUnmount () { + this.removeListeners(); + } + + componentDidUpdate (prevProps: Readonly<Props>, prevState: Readonly<State>) { + const menu = this.menu.current; + + if (this.state.visible && !prevState.visible && menu) { + document.addEventListener('click', this.windowListener); + document.addEventListener('contextmenu', this.contextMenuListener); + menu.setAttribute( + 'style', `left: ${Math.round(this.state.posX - menu.clientWidth)}px`, + ); + } + + if (!this.state.visible && prevState.visible) { + this.removeListeners(); + } + } + + removeListeners = () => { + document.removeEventListener('click', this.windowListener); + document.removeEventListener('contextmenu', this.contextMenuListener); + }; + + onClickHandler = (e: React.MouseEvent<any, MouseEvent>) => { e.preventDefault(); - - !visible && setPosX(e.clientX); - setVisible(s => !s); + this.triggerMenu(e.clientX); }; - const windowListener = (e: MouseEvent) => { - if (e.button === 2 || !visible || !menu.current) { + contextMenuListener = () => this.setState({ visible: false }); + + windowListener = (e: MouseEvent) => { + const menu = this.menu.current; + + console.log('windowListener:', e.button); + + if (e.button === 2 || !this.state.visible || !menu) { return; } - if (e.target === menu.current || menu.current.contains(e.target as Node)) { + if (e.target === menu || menu.contains(e.target as Node)) { return; } - if (e.target !== menu.current && !menu.current.contains(e.target as Node)) { - setVisible(false); + if (e.target !== menu && !menu.contains(e.target as Node)) { + this.setState({ visible: false }); } }; - useEffect(() => { - if (!visible || !menu.current) { - return; - } + triggerMenu = (posX: number) => this.setState(s => ({ + posX: !s.visible ? posX : s.posX, + visible: !s.visible, + })); - document.addEventListener('click', windowListener); - menu.current.setAttribute( - 'style', `left: ${Math.round(posX - menu.current.clientWidth)}px`, + render () { + return ( + <div> + {this.props.renderToggle(this.onClickHandler)} + <Fade timeout={150} in={this.state.visible} unmountOnExit> + <div + ref={this.menu} + onClick={e => { + e.stopPropagation(); + this.setState({ visible: false }); + }} + css={tw`absolute bg-white p-2 rounded border border-neutral-700 shadow-lg text-neutral-500 min-w-48`} + > + {this.props.children} + </div> + </Fade> + </div> ); - - return () => { - document.removeEventListener('click', windowListener); - }; - }, [ visible ]); - - return ( - <div> - {renderToggle(onClickHandler)} - <Fade timeout={150} in={visible} unmountOnExit> - <div - ref={menu} - onClick={e => { - e.stopPropagation(); - setVisible(false); - }} - css={tw`absolute bg-white p-2 rounded border border-neutral-700 shadow-lg text-neutral-500 min-w-48`} - > - {children} - </div> - </Fade> - </div> - ); -}; + } +} export default DropdownMenu; diff --git a/resources/scripts/components/server/files/FileDropdownMenu.tsx b/resources/scripts/components/server/files/FileDropdownMenu.tsx index 443d50a82..c03e5396e 100644 --- a/resources/scripts/components/server/files/FileDropdownMenu.tsx +++ b/resources/scripts/components/server/files/FileDropdownMenu.tsx @@ -1,4 +1,4 @@ -import React, { useState } from 'react'; +import React, { useRef, useState } from 'react'; import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; import { faCopy, @@ -24,6 +24,7 @@ import { FileObject } from '@/api/server/files/loadDirectory'; import useFileManagerSwr from '@/plugins/useFileManagerSwr'; import DropdownMenu from '@/components/elements/DropdownMenu'; import styled from 'styled-components/macro'; +import useEventListener from '@/plugins/useEventListener'; type ModalType = 'rename' | 'move'; @@ -46,15 +47,21 @@ const Row = ({ icon, title, ...props }: RowProps) => ( ); export default ({ file }: { file: FileObject }) => { + const onClickRef = useRef<DropdownMenu>(null); const [ showSpinner, setShowSpinner ] = useState(false); const [ modal, setModal ] = useState<ModalType | null>(null); const { uuid } = useServer(); const { mutate } = useFileManagerSwr(); const { clearAndAddHttpError, clearFlashes } = useFlash(); - const directory = ServerContext.useStoreState(state => state.files.directory); + useEventListener(`pterodactyl:files:ctx:${file.uuid}`, (e: CustomEvent) => { + if (onClickRef.current) { + onClickRef.current.triggerMenu(e.detail); + } + }); + const doDeletion = () => { clearFlashes('files'); @@ -95,6 +102,7 @@ export default ({ file }: { file: FileObject }) => { return ( <DropdownMenu + ref={onClickRef} renderToggle={onClick => ( <div css={tw`p-3 hover:text-white`} onClick={onClick}> <FontAwesomeIcon icon={faEllipsisH}/> @@ -112,9 +120,11 @@ export default ({ file }: { file: FileObject }) => { <Row onClick={() => setModal('rename')} icon={faPencilAlt} title={'Rename'}/> <Row onClick={() => setModal('move')} icon={faLevelUpAlt} title={'Move'}/> </Can> + {file.isFile && <Can action={'file.create'}> <Row onClick={doCopy} icon={faCopy} title={'Copy'}/> </Can> + } <Row onClick={doDownload} icon={faFileDownload} title={'Download'}/> <Can action={'file.delete'}> <Row onClick={doDeletion} icon={faTrashAlt} title={'Delete'} $danger/> diff --git a/resources/scripts/components/server/files/FileObjectRow.tsx b/resources/scripts/components/server/files/FileObjectRow.tsx index 66ed8ca09..2f097e1b9 100644 --- a/resources/scripts/components/server/files/FileObjectRow.tsx +++ b/resources/scripts/components/server/files/FileObjectRow.tsx @@ -37,7 +37,13 @@ const FileObjectRow = ({ file }: { file: FileObject }) => { }; return ( - <Row key={file.name}> + <Row + key={file.name} + onContextMenu={e => { + e.preventDefault(); + window.dispatchEvent(new CustomEvent(`pterodactyl:files:ctx:${file.uuid}`, { detail: e.clientX })); + }} + > <NavLink to={`${match.url}/${file.isFile ? 'edit/' : ''}#${cleanDirectoryPath(`${directory}/${file.name}`)}`} css={tw`flex flex-1 text-neutral-300 no-underline p-3`} diff --git a/resources/scripts/plugins/useEventListener.ts b/resources/scripts/plugins/useEventListener.ts index feb281ea4..1328fffff 100644 --- a/resources/scripts/plugins/useEventListener.ts +++ b/resources/scripts/plugins/useEventListener.ts @@ -1,6 +1,6 @@ import { useEffect, useRef } from 'react'; -export default (eventName: string, handler: any, element: any = window) => { +export default (eventName: string, handler: (e: Event | CustomEvent | UIEvent | any) => void, element: any = window) => { const savedHandler = useRef<any>(null); useEffect(() => {