diff --git a/app/components/ContextMenu.tsx b/app/components/ContextMenu.tsx new file mode 100644 index 0000000..b66348b --- /dev/null +++ b/app/components/ContextMenu.tsx @@ -0,0 +1,101 @@ +"use client"; + +import { useEffect, useRef, useState } from "react"; +import type { ElementType } from "react"; + +export interface ContextMenuAction { + label: string; + icon: ElementType; + onClick: () => void; + danger?: boolean; + disabled?: boolean; +} + +interface ContextMenuProps { + position: { x: number; y: number }; + actions: ContextMenuAction[]; + onClose: () => void; +} + +export default function ContextMenu({ position, actions, onClose }: ContextMenuProps) { + const menuRef = useRef(null); + const [menuPosition, setMenuPosition] = useState(position); + + useEffect(() => { + const adjustPosition = () => { + const menuWidth = menuRef.current?.offsetWidth ?? 200; + const menuHeight = menuRef.current?.offsetHeight ?? actions.length * 40; + + let top = position.y; + let left = position.x; + + if (top + menuHeight > window.innerHeight) { + top = Math.max(0, window.innerHeight - menuHeight - 8); + } + if (left + menuWidth > window.innerWidth) { + left = Math.max(0, window.innerWidth - menuWidth - 8); + } + + setMenuPosition({ x: left, y: top }); + }; + + adjustPosition(); + }, [position, actions.length]); + + useEffect(() => { + const handleScroll = () => onClose(); + const handleResize = () => onClose(); + + window.addEventListener("scroll", handleScroll, true); + window.addEventListener("resize", handleResize); + + return () => { + window.removeEventListener("scroll", handleScroll, true); + window.removeEventListener("resize", handleResize); + }; + }, [onClose]); + + return ( +
e.stopPropagation()} + onContextMenu={(e) => e.preventDefault()} + > + {actions.map((action) => { + const Icon = action.icon; + return ( + + ); + })} +
+ ); +} + + diff --git a/app/components/FileItem.tsx b/app/components/FileItem.tsx index de2023e..834749e 100644 --- a/app/components/FileItem.tsx +++ b/app/components/FileItem.tsx @@ -28,6 +28,7 @@ interface FileItemProps { onDownload?: (file: FileItemData) => void; onDelete?: (file: FileItemData) => void; isSelected?: boolean; + onContextMenu?: (file: FileItemData, event: React.MouseEvent) => void; } export default function FileItem({ @@ -42,6 +43,7 @@ export default function FileItem({ onDownload, onDelete, isSelected = false, + onContextMenu, }: FileItemProps) { const [isHovered, setIsHovered] = useState(false); const clickCountRef = useRef(0); @@ -128,6 +130,11 @@ export default function FileItem({ role="button" tabIndex={0} aria-label={`${file.type === "folder" ? "Folder" : "File"}: ${file.name}`} + onContextMenu={(event) => { + event.preventDefault(); + event.stopPropagation(); + onContextMenu?.(file, event); + }} > {isHovered && ( { + event.preventDefault(); + event.stopPropagation(); + onContextMenu?.(file, event); + }} >
{getFileIcon(file.type, file.mimeType)} diff --git a/app/components/FileList.tsx b/app/components/FileList.tsx index fcb943f..e1ece44 100644 --- a/app/components/FileList.tsx +++ b/app/components/FileList.tsx @@ -17,6 +17,7 @@ interface FileListProps { onFileDownload?: (file: FileItemData) => void; onFileDelete?: (file: FileItemData) => void; selectedFileIds: string[]; + onFileContextMenu?: (file: FileItemData, event: React.MouseEvent) => void; } const VIEW_STORAGE_KEY = "solid-file-manager-view"; @@ -33,6 +34,7 @@ export default function FileList({ onFileDownload, onFileDelete, selectedFileIds, + onFileContextMenu, }: FileListProps) { const [view, setView] = useState<"grid" | "list">(() => { if (typeof window === "undefined") return "list"; @@ -72,6 +74,7 @@ export default function FileList({ onDownload={onFileDownload} onDelete={onFileDelete} isSelected={selectedFileIds.includes(file.id)} + onContextMenu={onFileContextMenu} /> ))}
@@ -91,6 +94,7 @@ export default function FileList({ onDownload={onFileDownload} onDelete={onFileDelete} isSelected={selectedFileIds.includes(file.id)} + onContextMenu={onFileContextMenu} /> ))} diff --git a/app/components/FileManager.tsx b/app/components/FileManager.tsx index f39cdd1..9a37710 100644 --- a/app/components/FileManager.tsx +++ b/app/components/FileManager.tsx @@ -3,6 +3,16 @@ import { useState, useEffect, useRef, useCallback } from "react"; import toast from "react-hot-toast"; import { useSearchParams, useRouter } from "next/navigation"; +import { + FolderPlusIcon, + ArrowUpTrayIcon, + PencilIcon, + ArrowDownTrayIcon, + DocumentDuplicateIcon, + ArrowRightCircleIcon, + TrashIcon, + EyeIcon, +} from "@heroicons/react/24/outline"; import AuthWrapper from "./AuthWrapper"; import Header from "./Header"; import Sidebar from "./Sidebar"; @@ -15,6 +25,7 @@ import PreviewModal from "./PreviewModal"; import MoveDialog from "./MoveDialog"; import DeleteConfirmDialog from "./DeleteConfirmDialog"; import FileUploadHandler from "./FileUploadHandler"; +import ContextMenu, { ContextMenuAction } from "./ContextMenu"; import { FileItemData } from "./FileItem"; import LoadingSpinner from "./shared/LoadingSpinner"; import ErrorDisplay from "./shared/ErrorDisplay"; @@ -43,6 +54,17 @@ import { safeEncodeUrl, } from "../lib/helpers/urlStateUtils"; +type ContextMenuState = + | { + type: "new"; + position: { x: number; y: number }; + } + | { + type: "file"; + position: { x: number; y: number }; + file: FileItemData; + }; + export default function FileManager() { const searchParams = useSearchParams(); const router = useRouter(); @@ -72,6 +94,26 @@ export default function FileManager() { const [showDeleteDialog, setShowDeleteDialog] = useState(false); const [fileToDelete, setFileToDelete] = useState(null); const [isDeleting, setIsDeleting] = useState(false); + const [contextMenuState, setContextMenuState] = useState(null); + + const closeContextMenu = () => setContextMenuState(null); + + const handleBlankContextMenu = (event: React.MouseEvent) => { + event.preventDefault(); + setContextMenuState({ + type: "new", + position: { x: event.clientX, y: event.clientY }, + }); + }; + + const handleFileContextMenu = (file: FileItemData, event: React.MouseEvent) => { + event.preventDefault(); + setContextMenuState({ + type: "file", + file, + position: { x: event.clientX, y: event.clientY }, + }); + }; useEffect(() => { if (isLoadingStorages || storages.length === 0 || isInitialized) { @@ -120,6 +162,27 @@ export default function FileManager() { }; }, []); + useEffect(() => { + if (!contextMenuState) { + return; + } + + const handleClick = () => setContextMenuState(null); + const handleKeyDown = (event: KeyboardEvent) => { + if (event.key === "Escape") { + setContextMenuState(null); + } + }; + + document.addEventListener("click", handleClick); + document.addEventListener("keydown", handleKeyDown); + + return () => { + document.removeEventListener("click", handleClick); + document.removeEventListener("keydown", handleKeyDown); + }; + }, [contextMenuState]); + const updateUrl = (url: string | null) => { if (!url || url === "/") { removeUrlFromStorage(); @@ -174,6 +237,29 @@ export default function FileManager() { triggerContainerRefresh(); }; + const ensureStorageSelected = () => { + if (!containerUrlToBrowse) { + toast.error("Please select a storage first"); + return false; + } + return true; + }; + + const triggerFileUploadDialog = () => { + if (!ensureStorageSelected()) return; + setFileUploadTrigger((prev) => prev + 1); + }; + + const triggerFolderUploadDialog = () => { + if (!ensureStorageSelected()) return; + setFolderUploadTrigger((prev) => prev + 1); + }; + + const openNewFolderDialog = () => { + if (!ensureStorageSelected()) return; + setShowNewFolderDialog(true); + }; + const handleRename = (file: FileItemData) => { setFileToRename(file); setShowRenameDialog(true); @@ -252,13 +338,13 @@ export default function FileManager() { } toast.success(`Deleted "${fileToDelete.name}"`, { id: toastId }); - + // Clear selected files if the deleted file was selected setSelectedFileIds((prev) => prev.filter((id) => id !== fileToDelete.id)); - + setShowDeleteDialog(false); setFileToDelete(null); - + // Wait a bit for server to process deletion, then trigger single refresh setTimeout(() => { setRefreshKey((prev) => prev + 1); @@ -482,6 +568,98 @@ export default function FileManager() { } }; + const newContextMenuActions: ContextMenuAction[] = [ + { + label: "New Folder", + icon: FolderPlusIcon, + onClick: () => { + closeContextMenu(); + openNewFolderDialog(); + }, + }, + { + label: "File Upload", + icon: ArrowUpTrayIcon, + onClick: () => { + closeContextMenu(); + triggerFileUploadDialog(); + }, + }, + { + label: "Folder Upload", + icon: FolderPlusIcon, + onClick: () => { + closeContextMenu(); + triggerFolderUploadDialog(); + }, + }, + ]; + + const getFileContextMenuActions = (file: FileItemData): ContextMenuAction[] => { + const actions: ContextMenuAction[] = []; + + if (file.type === "file") { + actions.push({ + label: "Preview", + icon: EyeIcon, + onClick: () => { + closeContextMenu(); + handlePreview(file); + }, + }); + } + + actions.push( + { + label: "Rename", + icon: PencilIcon, + onClick: () => { + closeContextMenu(); + handleRename(file); + }, + }, + { + label: "Download", + icon: ArrowDownTrayIcon, + onClick: () => { + closeContextMenu(); + handleDownload(file); + }, + }, + { + label: "Copy", + icon: DocumentDuplicateIcon, + onClick: () => { + closeContextMenu(); + handleCopy(file); + }, + } + ); + + if (file.type === "file") { + actions.push({ + label: "Move", + icon: ArrowRightCircleIcon, + onClick: () => { + closeContextMenu(); + handleMove(file); + }, + }); + } + + actions.push({ + label: "Delete", + icon: TrashIcon, + danger: true, + onClick: () => { + closeContextMenu(); + handleDelete(file); + }, + }); + + return actions; + }; + const handleFileSelect = (file: FileItemData) => { setSelectedFileIds((prev) => { if (prev.includes(file.id)) { @@ -621,7 +799,10 @@ export default function FileManager() { onFileUploadClick={() => setFileUploadTrigger((prev) => prev + 1)} onFolderUploadClick={() => setFolderUploadTrigger((prev) => prev + 1)} /> -
+
@@ -643,6 +824,7 @@ export default function FileManager() { onFileDownload={handleDownload} onFileDelete={handleDelete} selectedFileIds={selectedFileIds} + onFileContextMenu={handleFileContextMenu} /> )} @@ -722,6 +904,18 @@ export default function FileManager() { triggerUpload={fileUploadTrigger} triggerFolderUpload={folderUploadTrigger} /> + + {contextMenuState && ( + + )} );