first commit

This commit is contained in:
2025-10-19 13:31:11 +00:00
commit 8bfc183b66
1248 changed files with 195992 additions and 0 deletions

View File

@@ -0,0 +1,118 @@
import { Box, ListItemIcon, ListItemText, Menu, MenuItemProps, styled, useMediaQuery, useTheme } from "@mui/material";
import { bindFocus, bindHover } from "material-ui-popup-state";
import { bindMenu, bindTrigger, PopupState, usePopupState } from "material-ui-popup-state/hooks";
import { createContext, useCallback, useContext, useMemo } from "react";
import CaretRight from "../../Icons/CaretRight.tsx";
import { SquareMenuItem } from "./ContextMenu.tsx";
import HoverMenu from "./HoverMenu.tsx";
export const CascadingContext = createContext<{
parentPopupState?: PopupState;
rootPopupState?: PopupState;
}>({});
export const SquareHoverMenu = styled(HoverMenu)(() => ({
"& .MuiPaper-root": {
minWidth: "200px",
},
}));
export const SquareMenu = styled(Menu)(() => ({
"& .MuiPaper-root": {
minWidth: "200px",
},
}));
export interface CascadingMenuItem {
onClick?: (event: React.MouseEvent<HTMLElement>) => void;
[key: string]: any;
}
export function CascadingMenuItem({ onClick, ...props }: CascadingMenuItem) {
const { rootPopupState } = useContext(CascadingContext);
if (!rootPopupState) throw new Error("must be used inside a CascadingMenu");
const handleClick = useCallback(
(event: React.MouseEvent<HTMLElement>) => {
rootPopupState.close();
if (onClick) onClick(event);
},
[rootPopupState, onClick],
);
return <SquareMenuItem {...props} onClick={handleClick} />;
}
export interface CascadingMenuProps {
popupState: PopupState;
isMobile?: boolean;
[key: string]: any;
}
export function CascadingMenu({ popupState, isMobile, ...props }: CascadingMenuProps) {
const { rootPopupState } = useContext(CascadingContext);
const context = useMemo(
() => ({
rootPopupState: rootPopupState || popupState,
parentPopupState: popupState,
}),
[rootPopupState, popupState],
);
const MenuComponent = isMobile ? SquareMenu : SquareHoverMenu;
return (
<CascadingContext.Provider value={context}>
<MenuComponent {...props} {...bindMenu(popupState)} />
</CascadingContext.Provider>
);
}
export interface CascadingSubmenu {
title: string;
icon?: JSX.Element;
popupId: string;
menuItemProps?: MenuItemProps;
[key: string]: any;
}
export function CascadingSubmenu({ title, popupId, menuItemProps, icon, ...props }: CascadingSubmenu) {
const theme = useTheme();
const isMobile = useMediaQuery(theme.breakpoints.down("md"));
const { parentPopupState } = useContext(CascadingContext);
const popupState = usePopupState({
popupId,
variant: "popover",
parentPopupState,
});
return (
<>
<SquareMenuItem
dense
{...(isMobile ? bindTrigger(popupState) : bindHover(popupState))}
{...(isMobile ? {} : bindFocus(popupState))}
{...menuItemProps}
>
{icon && <ListItemIcon>{icon}</ListItemIcon>}
<ListItemText
primary={title}
slotProps={{
primary: { variant: "body2" },
}}
/>
<Box color="text.secondary" fontSize={"body2"} sx={{ display: "flex" }}>
<CaretRight fontSize={"inherit"} />
</Box>
</SquareMenuItem>
<CascadingMenu
{...props}
isMobile={isMobile}
anchorOrigin={{ vertical: isMobile ? "bottom" : "top", horizontal: "right" }}
transformOrigin={{ vertical: "top", horizontal: "left" }}
popupState={popupState}
MenuListProps={{
dense: true,
}}
/>
</>
);
}

View File

@@ -0,0 +1,409 @@
import { Box, Divider, ListItemIcon, ListItemText, Menu, MenuItem, styled, Typography, useTheme } from "@mui/material";
import { useCallback, useMemo } from "react";
import { useTranslation } from "react-i18next";
import { closeContextMenu } from "../../../redux/fileManagerSlice.ts";
import { CreateNewDialogType } from "../../../redux/globalStateSlice.ts";
import { useAppDispatch, useAppSelector } from "../../../redux/hooks.ts";
import { downloadFiles } from "../../../redux/thunks/download.ts";
import {
batchGetDirectLinks,
createNew,
deleteFile,
dialogBasedMoveCopy,
enterFolder,
extractArchive,
goToParent,
goToSharedLink,
newRemoteDownload,
openShareDialog,
openSidebar,
renameFile,
restoreFile,
} from "../../../redux/thunks/file.ts";
import { refreshFileList, uploadClicked, uploadFromClipboard } from "../../../redux/thunks/filemanager.ts";
import { openViewers } from "../../../redux/thunks/viewer.ts";
import AppFolder from "../../Icons/AppFolder.tsx";
import ArchiveArrow from "../../Icons/ArchiveArrow.tsx";
import ArrowSync from "../../Icons/ArrowSync.tsx";
import BinFullOutlined from "../../Icons/BinFullOutlined.tsx";
import Clipboard from "../../Icons/Clipboard.tsx";
import CloudDownloadOutlined from "../../Icons/CloudDownloadOutlined.tsx";
import CopyOutlined from "../../Icons/CopyOutlined.tsx";
import DeleteOutlined from "../../Icons/DeleteOutlined.tsx";
import Download from "../../Icons/Download.tsx";
import Enter from "../../Icons/Enter.tsx";
import FileAdd from "../../Icons/FileAdd.tsx";
import FolderAdd from "../../Icons/FolderAdd.tsx";
import FolderArrowUp from "../../Icons/FolderArrowUp.tsx";
import FolderLink from "../../Icons/FolderLink.tsx";
import FolderOutlined from "../../Icons/FolderOutlined.tsx";
import HistoryOutlined from "../../Icons/HistoryOutlined.tsx";
import Info from "../../Icons/Info.tsx";
import LinkOutlined from "../../Icons/LinkOutlined.tsx";
import Open from "../../Icons/Open.tsx";
import RenameOutlined from "../../Icons/RenameOutlined.tsx";
import ShareOutlined from "../../Icons/ShareOutlined.tsx";
import Tag from "../../Icons/Tag.tsx";
import Upload from "../../Icons/Upload.tsx";
import WrenchSettings from "../../Icons/WrenchSettings.tsx";
import { SelectType } from "../../Uploader/core";
import { CascadingSubmenu } from "./CascadingMenu.tsx";
import MoreMenuItems from "./MoreMenuItems.tsx";
import NewFileTemplateMenuItems from "./NewFileTemplateMenuItems.tsx";
import OpenWithMenuItems from "./OpenWithMenuItems.tsx";
import OrganizeMenuItems from "./OrganizeMenuItems.tsx";
import TagMenuItems from "./TagMenuItems.tsx";
import useActionDisplayOpt from "./useActionDisplayOpt.ts";
export const SquareMenu = styled(Menu)(() => ({
"& .MuiPaper-root": {
minWidth: "200px",
},
}));
export const SquareMenuItem = styled(MenuItem)<{ hoverColor?: string }>(({ theme, hoverColor }) => ({
"&:hover .MuiListItemIcon-root": {
color: hoverColor ?? theme.palette.primary.main,
},
}));
export const DenseDivider = styled(Divider)(() => ({
margin: "4px 0 !important",
}));
export const EmptyMenu = () => {
const { t } = useTranslation();
return (
<Box sx={{ py: 0.5, px: 1, display: "flex", alignItems: "center" }} color={"text.secondary"}>
<Info sx={{ mr: 1 }} />
<Typography variant="body2">{t("fileManager.noActionsCanBeDone")}</Typography>
</Box>
);
};
export interface ContextMenuProps {
fmIndex: number;
}
const ContextMenu = ({ fmIndex = 0 }: ContextMenuProps) => {
const { t } = useTranslation();
const dispatch = useAppDispatch();
const theme = useTheme();
const contextMenuOpen = useAppSelector((state) => state.fileManager[fmIndex].contextMenuOpen);
const contextMenuType = useAppSelector((state) => state.fileManager[fmIndex].contextMenuType);
const contextMenuPos = useAppSelector((state) => state.fileManager[fmIndex].contextMenuPos);
const selected = useAppSelector((state) => state.fileManager[fmIndex].selected);
const targetOverwrite = useAppSelector((state) => state.fileManager[fmIndex].contextMenuTargets);
const targets = useMemo(() => {
const targetsMap = targetOverwrite ?? selected;
return Object.keys(targetsMap).map((key) => targetsMap[key]);
}, [targetOverwrite, selected]);
const parent = useAppSelector((state) => state.fileManager[fmIndex].list?.parent);
const displayOpt = useActionDisplayOpt(targets, contextMenuType, parent, fmIndex);
const onClose = useCallback(() => {
dispatch(closeContextMenu({ index: fmIndex, value: undefined }));
}, [dispatch]);
const showOpenWithCascading = displayOpt.showOpenWithCascading && displayOpt.showOpenWithCascading();
const showOpenWith = displayOpt.showOpenWith && displayOpt.showOpenWith();
let part1 =
displayOpt.showOpen ||
showOpenWithCascading ||
showOpenWith ||
displayOpt.showEnter ||
displayOpt.showDownload ||
displayOpt.showRemoteDownload ||
displayOpt.showTorrentRemoteDownload ||
displayOpt.showExtractArchive ||
displayOpt.showUpload;
let part2 =
displayOpt.showCreateFolder ||
displayOpt.showCreateFile ||
displayOpt.showShare ||
displayOpt.showRename ||
displayOpt.showCopy ||
displayOpt.showDirectLink;
let part3 =
displayOpt.showTags || displayOpt.showOrganize || displayOpt.showMore || displayOpt.showNewFileFromTemplate;
let part4 = displayOpt.showInfo || displayOpt.showGoToParent || displayOpt.showGoToSharedLink;
let part5 = displayOpt.showRestore || displayOpt.showDelete || displayOpt.showRefresh;
const showDivider1 = part1 && part2;
const showDivider2 = part2 && part3;
const showDivider3 = part3 && part4;
const showDivider4 = part4 && part5;
const part1Elements = part1 ? (
<>
{displayOpt.showUpload && (
<SquareMenuItem onClick={() => dispatch(uploadClicked(0, SelectType.File))}>
<ListItemIcon>
<Upload fontSize="small" />
</ListItemIcon>
<ListItemText>{t("application:fileManager.uploadFiles")}</ListItemText>
</SquareMenuItem>
)}
{displayOpt.showEnter && (
<SquareMenuItem onClick={() => dispatch(enterFolder(0, targets[0]))}>
<ListItemIcon>
<Enter fontSize="small" />
</ListItemIcon>
<ListItemText>{t("application:fileManager.enter")}</ListItemText>
</SquareMenuItem>
)}
{displayOpt.showUpload && (
<SquareMenuItem onClick={() => dispatch(uploadClicked(0, SelectType.Directory))}>
<ListItemIcon>
<FolderArrowUp fontSize="small" />
</ListItemIcon>
<ListItemText>{t("application:fileManager.uploadFolder")}</ListItemText>
</SquareMenuItem>
)}
{displayOpt.showUpload && (
<SquareMenuItem onClick={() => dispatch(uploadFromClipboard(0))}>
<ListItemIcon>
<Clipboard fontSize="small" />
</ListItemIcon>
<ListItemText>{t("application:uploader.uploadFromClipboard")}</ListItemText>
</SquareMenuItem>
)}
{displayOpt.showRemoteDownload && (
<SquareMenuItem onClick={() => dispatch(newRemoteDownload(0))}>
<ListItemIcon>
<CloudDownloadOutlined fontSize="small" />
</ListItemIcon>
<ListItemText>{t("application:fileManager.newRemoteDownloads")}</ListItemText>
</SquareMenuItem>
)}
{displayOpt.showOpen && (
<SquareMenuItem onClick={() => dispatch(openViewers(fmIndex, targets[0]))}>
<ListItemIcon>
<Open fontSize="small" />
</ListItemIcon>
<ListItemText>{t("application:fileManager.open")}</ListItemText>
</SquareMenuItem>
)}
{showOpenWithCascading && (
<CascadingSubmenu
popupId={"openWith"}
title={t("application:fileManager.openWith")}
icon={<AppFolder fontSize="small" />}
>
<OpenWithMenuItems displayOpt={displayOpt} targets={targets} />
</CascadingSubmenu>
)}
{showOpenWith && (
<SquareMenuItem onClick={() => dispatch(openViewers(fmIndex, targets[0], targets[0].size, undefined, true))}>
<ListItemIcon>
<AppFolder fontSize="small" />
</ListItemIcon>
<ListItemText>{t("application:fileManager.openWith")}</ListItemText>
</SquareMenuItem>
)}
{displayOpt.showDownload && (
<SquareMenuItem onClick={() => dispatch(downloadFiles(fmIndex, targets))}>
<ListItemIcon>
<Download fontSize="small" />
</ListItemIcon>
<ListItemText>{t("application:fileManager.download")}</ListItemText>
</SquareMenuItem>
)}
{displayOpt.showExtractArchive && (
<SquareMenuItem onClick={() => dispatch(extractArchive(fmIndex, targets[0]))}>
<ListItemIcon>
<ArchiveArrow fontSize="small" />
</ListItemIcon>
<ListItemText>{t("application:fileManager.extractArchive")}</ListItemText>
</SquareMenuItem>
)}
{displayOpt.showTorrentRemoteDownload && (
<SquareMenuItem onClick={() => dispatch(newRemoteDownload(fmIndex, targets[0]))}>
<ListItemIcon>
<CloudDownloadOutlined fontSize="small" />
</ListItemIcon>
<ListItemText>{t("application:fileManager.createRemoteDownloadForTorrent")}</ListItemText>
</SquareMenuItem>
)}
</>
) : undefined;
const part2Elements = part2 ? (
<>
{displayOpt.showCreateFolder && (
<SquareMenuItem onClick={() => dispatch(createNew(fmIndex, CreateNewDialogType.folder))}>
<ListItemIcon>
<FolderAdd fontSize="small" />
</ListItemIcon>
<ListItemText>{t("application:fileManager.newFolder")}</ListItemText>
</SquareMenuItem>
)}
{displayOpt.showCreateFile && (
<SquareMenuItem onClick={() => dispatch(createNew(fmIndex, CreateNewDialogType.file))}>
<ListItemIcon>
<FileAdd fontSize="small" />
</ListItemIcon>
<ListItemText>{t("application:fileManager.newFile")}</ListItemText>
</SquareMenuItem>
)}
{displayOpt.showShare && (
<SquareMenuItem onClick={() => dispatch(openShareDialog(fmIndex, targets[0]))}>
<ListItemIcon>
<ShareOutlined fontSize="small" />
</ListItemIcon>
<ListItemText>{t("application:fileManager.share")}</ListItemText>
</SquareMenuItem>
)}
{displayOpt.showRename && (
<SquareMenuItem onClick={() => dispatch(renameFile(fmIndex, targets[0]))}>
<ListItemIcon>
<RenameOutlined fontSize="small" />
</ListItemIcon>
<ListItemText>{t("application:fileManager.rename")}</ListItemText>
</SquareMenuItem>
)}
{displayOpt.showCopy && (
<SquareMenuItem onClick={() => dispatch(dialogBasedMoveCopy(fmIndex, targets, true))}>
<ListItemIcon>
<CopyOutlined fontSize="small" />
</ListItemIcon>
<ListItemText>{t("application:fileManager.copy")}</ListItemText>
</SquareMenuItem>
)}
{displayOpt.showDirectLink && (
<SquareMenuItem onClick={() => dispatch(batchGetDirectLinks(fmIndex, targets))}>
<ListItemIcon>
<LinkOutlined fontSize="small" />
</ListItemIcon>
<ListItemText>{t("application:fileManager.getSourceLink")}</ListItemText>
</SquareMenuItem>
)}
</>
) : undefined;
const part3Elements = part3 ? (
<>
{displayOpt.showTags && (
<CascadingSubmenu popupId={"tags"} title={t("application:fileManager.tags")} icon={<Tag fontSize="small" />}>
<TagMenuItems displayOpt={displayOpt} targets={targets} />
</CascadingSubmenu>
)}
{displayOpt.showOrganize && (
<CascadingSubmenu
popupId={"organize"}
title={t("application:fileManager.organize")}
icon={<BinFullOutlined fontSize="small" />}
>
<OrganizeMenuItems displayOpt={displayOpt} targets={targets} />
</CascadingSubmenu>
)}
{displayOpt.showMore && (
<CascadingSubmenu
popupId={"more"}
title={t("application:fileManager.moreActions")}
icon={<WrenchSettings fontSize="small" />}
>
<MoreMenuItems displayOpt={displayOpt} targets={targets} />
</CascadingSubmenu>
)}
{displayOpt.showNewFileFromTemplate && <NewFileTemplateMenuItems displayOpt={displayOpt} targets={targets} />}
</>
) : undefined;
const part4Elements = part4 ? (
<>
{displayOpt.showGoToSharedLink && (
<SquareMenuItem onClick={() => dispatch(goToSharedLink(fmIndex, targets[0]))}>
<ListItemIcon>
<FolderLink fontSize="small" />
</ListItemIcon>
<ListItemText>{t("application:fileManager.goToSharedLink")}</ListItemText>
</SquareMenuItem>
)}
{displayOpt.showGoToParent && (
<SquareMenuItem onClick={() => dispatch(goToParent(0, targets[0]))}>
<ListItemIcon>
<FolderOutlined fontSize="small" />
</ListItemIcon>
<ListItemText>{t("application:fileManager.openParentFolder")}</ListItemText>
</SquareMenuItem>
)}
{displayOpt.showInfo && (
<SquareMenuItem onClick={() => dispatch(openSidebar(fmIndex, targets[0]))}>
<ListItemIcon>
<Info fontSize="small" />
</ListItemIcon>
<ListItemText>{t("application:fileManager.viewDetails")}</ListItemText>
</SquareMenuItem>
)}
</>
) : undefined;
const part5Elements = part5 ? (
<>
{displayOpt.showRestore && (
<SquareMenuItem onClick={() => dispatch(restoreFile(fmIndex, targets))}>
<ListItemIcon>
<HistoryOutlined fontSize="small" />
</ListItemIcon>
<ListItemText>{t("application:fileManager.restore")}</ListItemText>
</SquareMenuItem>
)}
{displayOpt.showDelete && (
<SquareMenuItem hoverColor={theme.palette.error.light} onClick={() => dispatch(deleteFile(fmIndex, targets))}>
<ListItemIcon>
<DeleteOutlined fontSize="small" />
</ListItemIcon>
<ListItemText>{t("application:fileManager.delete")}</ListItemText>
</SquareMenuItem>
)}
{displayOpt.showRefresh && (
<SquareMenuItem onClick={() => dispatch(refreshFileList(fmIndex))}>
<ListItemIcon>
<ArrowSync fontSize="small" />
</ListItemIcon>
<ListItemText>{t("application:fileManager.refresh")}</ListItemText>
</SquareMenuItem>
)}
</>
) : undefined;
const allParts = [part1Elements, part2Elements, part3Elements, part4Elements, part5Elements].filter(
(p) => p != undefined,
);
return (
<SquareMenu
keepMounted
onClose={onClose}
disableAutoFocusItem
open={Boolean(contextMenuOpen)}
anchorReference="anchorPosition"
anchorPosition={{
top: contextMenuPos?.y ?? 0,
left: contextMenuPos?.x ?? 0,
}}
MenuListProps={{
dense: true,
}}
componentsProps={{
root: {
onContextMenu: (e) => {
e.preventDefault();
},
},
}}
>
{allParts.map((part, index) => (
<>
{part}
{index < allParts.length - 1 && <DenseDivider />}
</>
))}
{allParts.length == 0 && <EmptyMenu />}
</SquareMenu>
);
};
export default ContextMenu;

View File

@@ -0,0 +1,23 @@
import * as React from "react";
import { Menu, type MenuProps } from "@mui/material";
const HoverMenu: React.ComponentType<MenuProps> = React.forwardRef(function HoverMenu(props: MenuProps, ref): any {
return (
<Menu
{...props}
ref={ref}
style={{ pointerEvents: "none", ...props.style }}
slotProps={{
...props.slotProps,
paper: {
...props.slotProps?.paper,
style: {
pointerEvents: "auto",
},
},
}}
/>
);
});
export default HoverMenu;

View File

@@ -0,0 +1,122 @@
import { ListItemIcon, ListItemText } from "@mui/material";
import { useCallback, useContext } from "react";
import { useTranslation } from "react-i18next";
import { closeContextMenu } from "../../../redux/fileManagerSlice.ts";
import {
setCreateArchiveDialog,
setDirectLinkManagementDialog,
setManageShareDialog,
setVersionControlDialog,
} from "../../../redux/globalStateSlice.ts";
import { useAppDispatch } from "../../../redux/hooks.ts";
import { resetThumbnails } from "../../../redux/thunks/file.ts";
import Archive from "../../Icons/Archive.tsx";
import BranchForkLink from "../../Icons/BranchForkLink.tsx";
import HistoryOutlined from "../../Icons/HistoryOutlined.tsx";
import ImageArrowCounterclockwise from "../../Icons/ImageAarowCounterclockwise.tsx";
import LinkSetting from "../../Icons/LinkSetting.tsx";
import { CascadingContext, CascadingMenuItem } from "./CascadingMenu.tsx";
import { SubMenuItemsProps } from "./OrganizeMenuItems.tsx";
const MoreMenuItems = ({ displayOpt, targets }: SubMenuItemsProps) => {
const { rootPopupState } = useContext(CascadingContext);
const { t } = useTranslation();
const dispatch = useAppDispatch();
const onClick = useCallback(
(f: () => any) => () => {
f();
if (rootPopupState) {
rootPopupState.close();
}
dispatch(
closeContextMenu({
index: 0,
value: undefined,
}),
);
},
[dispatch, targets],
);
return (
<>
{displayOpt.showVersionControl && (
<CascadingMenuItem
onClick={onClick(() =>
dispatch(
setVersionControlDialog({
open: true,
file: targets[0],
}),
),
)}
>
<ListItemIcon>
<HistoryOutlined fontSize="small" />
</ListItemIcon>
<ListItemText>{t("application:fileManager.manageVersions")}</ListItemText>
</CascadingMenuItem>
)}
{displayOpt.showManageShares && (
<CascadingMenuItem
onClick={onClick(() =>
dispatch(
setManageShareDialog({
open: true,
file: targets[0],
}),
),
)}
>
<ListItemIcon>
<BranchForkLink fontSize="small" />
</ListItemIcon>
<ListItemText>{t("application:fileManager.manageShares")}</ListItemText>
</CascadingMenuItem>
)}
{displayOpt.showDirectLinkManagement && (
<CascadingMenuItem
onClick={onClick(() =>
dispatch(
setDirectLinkManagementDialog({
open: true,
file: targets[0],
}),
),
)}
>
<ListItemIcon>
<LinkSetting fontSize="small" />
</ListItemIcon>
<ListItemText>{t("application:fileManager.manageDirectLinks")}</ListItemText>
</CascadingMenuItem>
)}
{displayOpt.showCreateArchive && (
<CascadingMenuItem
onClick={onClick(() =>
dispatch(
setCreateArchiveDialog({
open: true,
files: targets,
}),
),
)}
>
<ListItemIcon>
<Archive fontSize="small" />
</ListItemIcon>
<ListItemText>{t("application:fileManager.createArchive")}</ListItemText>
</CascadingMenuItem>
)}
{displayOpt.showResetThumb && (
<CascadingMenuItem onClick={onClick(() => dispatch(resetThumbnails(targets)))}>
<ListItemIcon>
<ImageArrowCounterclockwise fontSize="small" />
</ListItemIcon>
<ListItemText>{t("application:fileManager.resetThumbnail")}</ListItemText>
</CascadingMenuItem>
)}
</>
);
};
export default MoreMenuItems;

View File

@@ -0,0 +1,99 @@
import React, { useCallback, useContext } from "react";
import { useTranslation } from "react-i18next";
import { useAppDispatch } from "../../../redux/hooks.ts";
import { SubMenuItemsProps } from "./OrganizeMenuItems.tsx";
import { ViewersByID } from "../../../redux/siteConfigSlice.ts";
import { ListItemIcon, ListItemText } from "@mui/material";
import { ViewerIcon } from "../Dialogs/OpenWith.tsx";
import { SquareMenuItem } from "./ContextMenu.tsx";
import { CascadingContext, CascadingMenuItem, CascadingSubmenu } from "./CascadingMenu.tsx";
import { NewFileTemplate, Viewer } from "../../../api/explorer.ts";
import { createNew } from "../../../redux/thunks/file.ts";
import { CreateNewDialogType } from "../../../redux/globalStateSlice.ts";
interface MultiTemplatesMenuItemsProps {
viewer: Viewer;
}
const MultiTemplatesMenuItems = ({ viewer }: MultiTemplatesMenuItemsProps) => {
const { rootPopupState } = useContext(CascadingContext);
const { t } = useTranslation();
const dispatch = useAppDispatch();
const onClick = useCallback(
(f: NewFileTemplate) => () => {
if (rootPopupState) {
rootPopupState.close();
}
dispatch(createNew(0, CreateNewDialogType.file, viewer, f));
},
[dispatch],
);
return (
<>
{viewer.templates?.map((template) => (
<CascadingMenuItem key={template.ext} onClick={onClick(template)}>
<ListItemText>
{t("fileManager.newDocumentType", {
display_name: t(template.display_name),
ext: template.ext,
})}
</ListItemText>
</CascadingMenuItem>
))}
</>
);
};
const NewFileTemplateMenuItems = (props: SubMenuItemsProps) => {
const { t } = useTranslation();
const dispatch = useAppDispatch();
const onClick = useCallback(
(viewer: Viewer, template: NewFileTemplate) => () => {
dispatch(createNew(0, CreateNewDialogType.file, viewer, template));
},
[dispatch],
);
return (
<>
{Object.values(ViewersByID)
.filter((viewer) => viewer.templates)
.map((viewer) => {
if (!viewer.templates) {
return null;
}
if (viewer.templates.length == 1) {
return (
<SquareMenuItem key={viewer.id} onClick={onClick(viewer, viewer.templates[0])}>
<ListItemIcon>
<ViewerIcon size={20} viewer={viewer} py={0} />
</ListItemIcon>
<ListItemText>
{t("fileManager.newDocumentType", {
display_name: t(viewer.templates[0].display_name),
ext: viewer.templates[0].ext,
})}
</ListItemText>
</SquareMenuItem>
);
} else {
return (
<CascadingSubmenu
popupId={viewer.id}
title={t(viewer.display_name)}
icon={<ViewerIcon size={20} viewer={viewer} py={0} />}
>
<MultiTemplatesMenuItems viewer={viewer} />
</CascadingSubmenu>
);
}
})}
</>
);
};
export default NewFileTemplateMenuItems;

View File

@@ -0,0 +1,65 @@
import React, { useCallback, useContext, useMemo } from "react";
import { CascadingContext, CascadingMenuItem } from "./CascadingMenu.tsx";
import { useTranslation } from "react-i18next";
import { useAppDispatch } from "../../../redux/hooks.ts";
import { closeContextMenu } from "../../../redux/fileManagerSlice.ts";
import { ListItemIcon, ListItemText } from "@mui/material";
import { SubMenuItemsProps } from "./OrganizeMenuItems.tsx";
import { fileExtension } from "../../../util";
import { Viewers } from "../../../redux/siteConfigSlice.ts";
import { Viewer } from "../../../api/explorer.ts";
import { ViewerIcon } from "../Dialogs/OpenWith.tsx";
import { openViewer, openViewers } from "../../../redux/thunks/viewer.ts";
const OpenWithMenuItems = ({ targets }: SubMenuItemsProps) => {
const { rootPopupState } = useContext(CascadingContext);
const { t } = useTranslation();
const dispatch = useAppDispatch();
const onClick = useCallback(
(v: Viewer) => () => {
dispatch(openViewer(targets[0], v, targets[0].size));
if (rootPopupState) {
rootPopupState.close();
}
dispatch(
closeContextMenu({
index: 0,
value: undefined,
}),
);
},
[dispatch, targets],
);
const openSelector = useCallback(() => {
dispatch(openViewers(0, targets[0], targets[0].size, undefined, true));
}, [targets]);
const viewers = useMemo(() => {
if (targets.length == 0) {
return [];
}
const firstFileSuffix = fileExtension(targets[0].name);
return Viewers[firstFileSuffix ?? ""];
}, [targets]);
return (
<>
{viewers.map((viewer) => (
<CascadingMenuItem key={viewer.id} onClick={onClick(viewer)}>
<ListItemIcon>
<ViewerIcon size={20} viewer={viewer} py={0} />
</ListItemIcon>
<ListItemText>{t(viewer.display_name)}</ListItemText>
</CascadingMenuItem>
))}
<CascadingMenuItem onClick={openSelector}>
<ListItemIcon></ListItemIcon>
<ListItemText>{t("fileManager.selectApplications")}</ListItemText>
</CascadingMenuItem>
</>
);
};
export default OpenWithMenuItems;

View File

@@ -0,0 +1,103 @@
import { DisplayOption } from "./useActionDisplayOpt.ts";
import { FileResponse } from "../../../api/explorer.ts";
import { useCallback, useContext } from "react";
import { CascadingContext, CascadingMenuItem } from "./CascadingMenu.tsx";
import { useTranslation } from "react-i18next";
import { useAppDispatch } from "../../../redux/hooks.ts";
import { closeContextMenu } from "../../../redux/fileManagerSlice.ts";
import { applyIconColor, dialogBasedMoveCopy } from "../../../redux/thunks/file.ts";
import { ListItemIcon, ListItemText } from "@mui/material";
import FolderArrowRightOutlined from "../../Icons/FolderArrowRightOutlined.tsx";
import { setChangeIconDialog, setPinFileDialog } from "../../../redux/globalStateSlice.ts";
import { getFileLinkedUri } from "../../../util";
import PinOutlined from "../../Icons/PinOutlined.tsx";
import EmojiEdit from "../../Icons/EmojiEdit.tsx";
import FolderColorQuickAction from "../FileInfo/FolderColorQuickAction.tsx";
import { DenseDivider } from "./ContextMenu.tsx";
export interface SubMenuItemsProps {
displayOpt: DisplayOption;
targets: FileResponse[];
}
const OrganizeMenuItems = ({ displayOpt, targets }: SubMenuItemsProps) => {
const { rootPopupState } = useContext(CascadingContext);
const { t } = useTranslation();
const dispatch = useAppDispatch();
const onClick = useCallback(
(f: () => any) => () => {
f();
if (rootPopupState) {
rootPopupState.close();
}
dispatch(
closeContextMenu({
index: 0,
value: undefined,
}),
);
},
[dispatch, targets],
);
const showDivider =
(displayOpt.showMove || displayOpt.showPin || displayOpt.showChangeIcon) && displayOpt.showChangeFolderColor;
return (
<>
{displayOpt.showMove && (
<CascadingMenuItem onClick={onClick(() => dispatch(dialogBasedMoveCopy(0, targets, false)))}>
<ListItemIcon>
<FolderArrowRightOutlined fontSize="small" />
</ListItemIcon>
<ListItemText>{t("application:fileManager.move")}</ListItemText>
</CascadingMenuItem>
)}
{displayOpt.showPin && (
<CascadingMenuItem
onClick={onClick(() =>
dispatch(
setPinFileDialog({
open: true,
uri: getFileLinkedUri(targets[0]),
}),
),
)}
>
<ListItemIcon>
<PinOutlined fontSize="small" />
</ListItemIcon>
<ListItemText>{t("application:fileManager.pin")}</ListItemText>
</CascadingMenuItem>
)}
{displayOpt.showChangeIcon && (
<CascadingMenuItem
onClick={onClick(() =>
dispatch(
setChangeIconDialog({
open: true,
file: targets,
}),
),
)}
>
<ListItemIcon>
<EmojiEdit fontSize="small" />
</ListItemIcon>
<ListItemText>{t("application:fileManager.customizeIcon")}</ListItemText>
</CascadingMenuItem>
)}
{showDivider && <DenseDivider />}
{displayOpt.showChangeFolderColor && (
<FolderColorQuickAction
file={targets[0]}
onColorChange={(color) => onClick(() => dispatch(applyIconColor(0, targets, color, true)))()}
sx={{
maxWidth: "204px",
margin: (theme) => `0 ${theme.spacing(0.5)}`,
padding: (theme) => `${theme.spacing(0.5)} ${theme.spacing(1)}`,
}}
/>
)}
</>
);
};
export default OrganizeMenuItems;

View File

@@ -0,0 +1,179 @@
import { Box, IconButton, ListItemIcon, ListItemText } from "@mui/material";
import React, { useCallback, useContext, useState } from "react";
import { useTranslation } from "react-i18next";
import { FileResponse, Metadata } from "../../../api/explorer.ts";
import { closeContextMenu } from "../../../redux/fileManagerSlice.ts";
import { setTagsDialog } from "../../../redux/globalStateSlice.ts";
import { useAppDispatch } from "../../../redux/hooks.ts";
import { patchFileMetadata } from "../../../redux/thunks/file.ts";
import SessionManager, { UserSettings } from "../../../session";
import { UsedTags } from "../../../session/utils.ts";
import Checkmark from "../../Icons/Checkmark.tsx";
import DeleteOutlined from "../../Icons/DeleteOutlined.tsx";
import Tags from "../../Icons/Tags.tsx";
import { getUniqueTagsFromFiles, Tag as TagItem } from "../Dialogs/Tags.tsx";
import FileTag from "../Explorer/FileTag.tsx";
import { FileManagerIndex } from "../FileManager.tsx";
import { CascadingContext, CascadingMenuItem } from "./CascadingMenu.tsx";
import { DenseDivider, SquareMenuItem } from "./ContextMenu.tsx";
import { SubMenuItemsProps } from "./OrganizeMenuItems.tsx";
interface TagOption extends TagItem {
selected?: boolean;
}
const getTagOptions = (targets: FileResponse[]): TagOption[] => {
const tags: {
[key: string]: TagOption;
} = {};
getUniqueTagsFromFiles(targets).forEach((tag) => {
tags[tag.key] = { ...tag, selected: true };
});
const existing = SessionManager.get(UserSettings.UsedTags) as UsedTags;
if (existing) {
Object.keys(existing).forEach((key) => {
if (!tags[key]) {
tags[key] = { key, color: existing[key] ?? undefined, selected: false };
}
});
}
return Object.values(tags);
};
const TagMenuItems = ({ displayOpt, targets }: SubMenuItemsProps) => {
const { rootPopupState } = useContext(CascadingContext);
const [tags, setTags] = useState<TagOption[]>(getTagOptions(targets));
const { t } = useTranslation();
const dispatch = useAppDispatch();
const onClick = useCallback(
(f: () => any) => () => {
f();
if (rootPopupState) {
rootPopupState.close();
}
dispatch(
closeContextMenu({
index: 0,
value: undefined,
}),
);
},
[dispatch, targets],
);
const onTagChange = useCallback(
async (tag: TagOption, selected: boolean) => {
setTags((tags) =>
tags.map((t) => {
if (t.key == tag.key) {
return { ...t, selected };
}
return t;
}),
);
try {
await dispatch(
patchFileMetadata(FileManagerIndex.main, targets, [
{
key: Metadata.tag_prefix + tag.key,
value: tag.color,
remove: !selected,
},
]),
);
} catch (e) {
return;
}
},
[targets, setTags],
);
const onTagDelete = useCallback(
(tag: TagOption, event: React.MouseEvent) => {
event.stopPropagation();
// Remove tag from session cache
const existing = SessionManager.get(UserSettings.UsedTags) as UsedTags;
if (existing && existing[tag.key] !== undefined) {
delete existing[tag.key];
SessionManager.set(UserSettings.UsedTags, existing);
}
// Remove tag from local state
setTags((tags) => tags.filter((t) => t.key !== tag.key));
},
[setTags],
);
return (
<>
<CascadingMenuItem
onClick={onClick(() =>
dispatch(
setTagsDialog({
open: true,
file: targets,
}),
),
)}
>
<ListItemIcon>
<Tags fontSize="small" />
</ListItemIcon>
<ListItemText>{t("application:modals.manageTags")}</ListItemText>
</CascadingMenuItem>
{tags.length > 0 && <DenseDivider />}
{tags.map((tag) => (
<SquareMenuItem key={tag.key} onClick={() => onTagChange(tag, !tag.selected)}>
{tag.selected && (
<>
<ListItemIcon>
<Checkmark />
</ListItemIcon>
<ListItemText>
<FileTag disableClick spacing={1} label={tag.key} tagColor={tag.color} />
</ListItemText>
</>
)}
{!tag.selected && (
<Box
sx={{
display: "flex",
alignItems: "center",
width: "100%",
"&:hover .delete-button": {
opacity: 1,
},
}}
>
<ListItemText inset>
<FileTag disableClick spacing={1} label={tag.key} tagColor={tag.color} />
</ListItemText>
<IconButton
className="delete-button"
size="small"
onClick={(event) => onTagDelete(tag, event)}
sx={{
opacity: 0,
transition: "opacity 0.2s",
marginLeft: "auto",
marginRight: 1,
padding: "2px",
"&:hover": {
backgroundColor: "action.hover",
},
}}
>
<DeleteOutlined fontSize="small" />
</IconButton>
</Box>
)}
</SquareMenuItem>
))}
</>
);
};
export default TagMenuItems;

View File

@@ -0,0 +1,325 @@
import { useMemo } from "react";
import { FileResponse, FileType, Metadata, NavigatorCapability } from "../../../api/explorer.ts";
import { GroupPermission } from "../../../api/user.ts";
import { defaultPath } from "../../../hooks/useNavigation.tsx";
import { ContextMenuTypes } from "../../../redux/fileManagerSlice.ts";
import { Viewers, ViewersByID } from "../../../redux/siteConfigSlice.ts";
import { ExpandedViewerSetting } from "../../../redux/thunks/viewer.ts";
import SessionManager from "../../../session";
import { fileExtension } from "../../../util";
import Boolset from "../../../util/boolset.ts";
import CrUri, { Filesystem } from "../../../util/uri.ts";
import { FileManagerIndex } from "../FileManager.tsx";
const supportedArchiveTypes = ["zip", "gz", "xz", "tar", "rar", "7z", "bz2"];
export const canManageVersion = (file: FileResponse, bs: Boolset) => {
return (
file.type == FileType.file &&
(!file.metadata || !file.metadata[Metadata.share_redirect]) &&
bs.enabled(NavigatorCapability.version_control)
);
};
export const canShowInfo = (cap: Boolset) => {
return cap.enabled(NavigatorCapability.info);
};
export const canUpdate = (opt: DisplayOption) => {
return !!(
opt.allUpdatable &&
opt.hasFile &&
opt.orCapability?.enabled(NavigatorCapability.upload_file) &&
opt.allUpdatable
);
};
export interface DisplayOption {
allReadable: boolean;
allUpdatable: boolean;
hasReadable?: boolean;
hasUpdatable?: boolean;
hasTrashFile?: boolean;
hasFile?: boolean;
hasFolder?: boolean;
hasOwned?: boolean;
hasFailedThumb?: boolean;
showEnter?: boolean;
showOpen?: boolean;
showOpenWithCascading?: () => boolean;
showOpenWith?: () => boolean;
showDownload?: boolean;
showGoToSharedLink?: boolean;
showExtractArchive?: boolean;
showTorrentRemoteDownload?: boolean;
showGoToParent?: boolean;
showDelete?: boolean;
showRestore?: boolean;
showRename?: boolean;
showPin?: boolean;
showOrganize?: boolean;
showCopy?: boolean;
showShare?: boolean;
showInfo?: boolean;
showDirectLink?: boolean;
showMove?: boolean;
showTags?: boolean;
showChangeFolderColor?: boolean;
showChangeIcon?: boolean;
showCustomProps?: boolean;
showMore?: boolean;
showVersionControl?: boolean;
showDirectLinkManagement?: boolean;
showManageShares?: boolean;
showCreateArchive?: boolean;
showResetThumb?: boolean;
andCapability?: Boolset;
orCapability?: Boolset;
showCreateFolder?: boolean;
showCreateFile?: boolean;
showRefresh?: boolean;
showNewFileFromTemplate?: boolean;
showUpload?: boolean;
showRemoteDownload?: boolean;
}
const capabilityMap: { [key: string]: Boolset } = {};
export const getActionOpt = (
targets: FileResponse[],
viewerSetting?: ExpandedViewerSetting,
type?: string,
parent?: FileResponse,
fmIndex: number = 0,
): DisplayOption => {
const currentUser = SessionManager.currentLoginOrNull();
const currentUserAnonymous = SessionManager.currentUser();
const groupBs = SessionManager.currentUserGroupPermission();
const display: DisplayOption = {
allReadable: true,
allUpdatable: true,
};
if (type == ContextMenuTypes.empty || type == ContextMenuTypes.new) {
display.showRefresh = type == ContextMenuTypes.empty;
display.showRemoteDownload = groupBs.enabled(GroupPermission.remote_download) && !!currentUser;
if (!parent || parent.type != FileType.folder) {
display.showRemoteDownload = display.showRemoteDownload && type == ContextMenuTypes.new;
return display;
}
const parentCap = new Boolset(parent.capability);
display.showCreateFolder = parentCap.enabled(NavigatorCapability.create_file) && parent.owned;
display.showCreateFile = display.showCreateFolder && fmIndex == FileManagerIndex.main;
display.showUpload = display.showCreateFile;
if (display.showCreateFile) {
const allViewers = Object.entries(ViewersByID);
for (let i = 0; i < allViewers.length; i++) {
if (allViewers[i][1] && allViewers[i][1].templates) {
display.showNewFileFromTemplate = true;
break;
}
}
}
return display;
}
if (type == ContextMenuTypes.searchResult) {
display.showGoToParent = true;
}
const parentUrl = new CrUri(targets?.[0]?.path ?? defaultPath);
targets.forEach((target) => {
let readable = true;
let updatable = target.owned && parentUrl.fs() != Filesystem.share;
if (display.allReadable && !readable) {
display.allReadable = false;
}
if (display.allUpdatable && !updatable) {
display.allUpdatable = false;
}
if (!display.hasReadable && readable) {
display.hasReadable = true;
}
if (!display.hasUpdatable && updatable) {
display.hasUpdatable = true;
}
if (target.metadata) {
if (target.metadata[Metadata.restore_uri]) {
display.hasTrashFile = true;
}
if (target.metadata[Metadata.thumbDisabled] !== undefined) {
display.hasFailedThumb = true;
}
}
if (target.type == FileType.file) {
display.hasFile = true;
}
if (target.type == FileType.folder) {
display.hasFolder = true;
}
if (target.owned) {
display.hasOwned = true;
}
if (target.capability) {
let bs = capabilityMap[target.capability];
if (!bs) {
bs = new Boolset(target.capability);
capabilityMap[target.capability] = bs;
}
if (!display.andCapability) {
display.andCapability = bs;
}
display.andCapability = display.andCapability.and(bs);
if (!display.orCapability) {
display.orCapability = bs;
}
display.orCapability = display.orCapability.or(bs);
}
});
const firstFileSuffix = fileExtension(targets[0]?.name ?? "");
display.showPin = !display.hasTrashFile && targets.length == 1 && display.hasFolder;
display.showDelete =
display.hasUpdatable &&
display.orCapability &&
(display.orCapability.enabled(NavigatorCapability.soft_delete) ||
display.orCapability.enabled(NavigatorCapability.delete_file));
display.showRestore = display.andCapability?.enabled(NavigatorCapability.restore);
display.showRename =
targets.length == 1 &&
display.allUpdatable &&
display.orCapability &&
display.orCapability.enabled(NavigatorCapability.rename_file);
display.showCopy = display.hasUpdatable && !!display.orCapability;
display.showShare =
targets.length == 1 &&
!!currentUser &&
groupBs.enabled(GroupPermission.share) &&
display.allUpdatable &&
(targets[0].owned || groupBs.enabled(GroupPermission.is_admin)) &&
display.orCapability &&
display.orCapability.enabled(NavigatorCapability.share) &&
(!targets[0].metadata ||
(!targets[0].metadata[Metadata.share_redirect] && !targets[0].metadata[Metadata.restore_uri]));
display.showMove = display.hasUpdatable && !!display.orCapability;
display.showTags =
display.hasUpdatable && display.orCapability && display.orCapability.enabled(NavigatorCapability.update_metadata);
display.showChangeFolderColor =
display.hasUpdatable &&
!display.hasFile &&
display.orCapability &&
display.orCapability.enabled(NavigatorCapability.update_metadata);
display.showChangeIcon =
display.hasUpdatable && display.orCapability && display.orCapability.enabled(NavigatorCapability.update_metadata);
display.showCustomProps = display.showChangeIcon;
display.showDownload =
display.hasReadable && display.orCapability && display.orCapability.enabled(NavigatorCapability.download_file);
display.showDirectLink =
(display.hasOwned || groupBs.enabled(GroupPermission.is_admin)) &&
display.orCapability &&
(currentUserAnonymous?.group?.direct_link_batch_size ?? 0) >= targets.length &&
display.orCapability.enabled(NavigatorCapability.download_file);
display.showDirectLinkManagement = display.showDirectLink && targets.length == 1 && display.hasFile;
display.showOpen =
targets.length == 1 &&
display.hasFile &&
display.showDownload &&
!!viewerSetting &&
!!firstFileSuffix &&
!!viewerSetting?.[firstFileSuffix];
display.showEnter =
targets.length == 1 &&
display.hasFolder &&
display.orCapability?.enabled(NavigatorCapability.enter_folder) &&
display.allReadable;
display.showExtractArchive =
targets.length == 1 &&
display.hasFile &&
display.showDownload &&
!!currentUser &&
groupBs.enabled(GroupPermission.archive_task) &&
supportedArchiveTypes.includes(firstFileSuffix ?? "");
display.showTorrentRemoteDownload =
targets.length == 1 &&
display.hasFile &&
display.showDownload &&
!!currentUser &&
groupBs.enabled(GroupPermission.remote_download) &&
firstFileSuffix == "torrent";
display.showOpenWithCascading = () => false;
display.showOpenWith = () => targets.length == 1 && !!display.hasFile && !!display.showDownload;
if (display.showOpen) {
display.showOpenWithCascading = () =>
!!(display.showOpen && viewerSetting && viewerSetting[firstFileSuffix ?? ""]?.length >= 1);
display.showOpenWith = () =>
!!(display.showOpen && viewerSetting && viewerSetting[firstFileSuffix ?? ""]?.length < 1);
}
display.showOrganize = display.showPin || display.showMove || display.showChangeFolderColor || display.showChangeIcon;
display.showGoToSharedLink =
targets.length == 1 && display.hasFile && targets[0].metadata && !!targets[0].metadata[Metadata.share_redirect];
display.showInfo = targets.length == 1 && display.orCapability && canShowInfo(display.orCapability);
display.showVersionControl =
targets.length == 1 &&
display.orCapability &&
display.hasReadable &&
canManageVersion(targets[0], display.orCapability);
display.showManageShares =
targets.length == 1 &&
targets[0].shared &&
display.orCapability &&
!!currentUser &&
groupBs.enabled(GroupPermission.share) &&
display.orCapability.enabled(NavigatorCapability.share);
display.showCreateArchive =
display.hasReadable &&
!!currentUser &&
groupBs.enabled(GroupPermission.archive_task) &&
display.orCapability &&
display.orCapability.enabled(NavigatorCapability.download_file);
display.showResetThumb =
display.hasFile &&
!display.hasFolder &&
display.hasFailedThumb &&
display.allUpdatable &&
display.orCapability &&
display.orCapability.enabled(NavigatorCapability.update_metadata);
display.showMore =
display.showVersionControl ||
display.showManageShares ||
display.showCreateArchive ||
display.showDirectLinkManagement ||
display.showResetThumb;
return display;
};
const useActionDisplayOpt = (targets: FileResponse[], type?: string, parent?: FileResponse, fmIndex: number = 0) => {
const opt = useMemo(() => {
return getActionOpt(targets, Viewers, type, parent, fmIndex);
}, [targets, type, parent, fmIndex]);
return opt;
};
export default useActionDisplayOpt;