first commit
This commit is contained in:
118
src/component/FileManager/ContextMenu/CascadingMenu.tsx
Executable file
118
src/component/FileManager/ContextMenu/CascadingMenu.tsx
Executable 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,
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
409
src/component/FileManager/ContextMenu/ContextMenu.tsx
Executable file
409
src/component/FileManager/ContextMenu/ContextMenu.tsx
Executable 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;
|
||||
23
src/component/FileManager/ContextMenu/HoverMenu.tsx
Executable file
23
src/component/FileManager/ContextMenu/HoverMenu.tsx
Executable 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;
|
||||
122
src/component/FileManager/ContextMenu/MoreMenuItems.tsx
Executable file
122
src/component/FileManager/ContextMenu/MoreMenuItems.tsx
Executable 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;
|
||||
99
src/component/FileManager/ContextMenu/NewFileTemplateMenuItems.tsx
Executable file
99
src/component/FileManager/ContextMenu/NewFileTemplateMenuItems.tsx
Executable 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;
|
||||
65
src/component/FileManager/ContextMenu/OpenWithMenuItems.tsx
Executable file
65
src/component/FileManager/ContextMenu/OpenWithMenuItems.tsx
Executable 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;
|
||||
103
src/component/FileManager/ContextMenu/OrganizeMenuItems.tsx
Executable file
103
src/component/FileManager/ContextMenu/OrganizeMenuItems.tsx
Executable 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;
|
||||
179
src/component/FileManager/ContextMenu/TagMenuItems.tsx
Executable file
179
src/component/FileManager/ContextMenu/TagMenuItems.tsx
Executable 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;
|
||||
325
src/component/FileManager/ContextMenu/useActionDisplayOpt.ts
Executable file
325
src/component/FileManager/ContextMenu/useActionDisplayOpt.ts
Executable 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;
|
||||
184
src/component/FileManager/Dialogs/ChangeIcon.tsx
Executable file
184
src/component/FileManager/Dialogs/ChangeIcon.tsx
Executable file
@@ -0,0 +1,184 @@
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Box, Button, DialogContent, Skeleton, styled, Tab, Tabs, useTheme } from "@mui/material";
|
||||
import { useAppDispatch, useAppSelector } from "../../../redux/hooks.ts";
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import DraggableDialog from "../../Dialogs/DraggableDialog.tsx";
|
||||
import { closeChangeIconDialog } from "../../../redux/globalStateSlice.ts";
|
||||
import { LoadingButton } from "@mui/lab";
|
||||
import { loadSiteConfig } from "../../../redux/thunks/site.ts";
|
||||
import AutoHeight from "../../Common/AutoHeight.tsx";
|
||||
import { ConfigLoadState } from "../../../redux/siteConfigSlice.ts";
|
||||
import { applyIcon } from "../../../redux/thunks/file.ts";
|
||||
import { FileManagerIndex } from "../FileManager.tsx";
|
||||
|
||||
interface EmojiSetting {
|
||||
[key: string]: string[];
|
||||
}
|
||||
|
||||
interface TabPanelProps {
|
||||
children?: React.ReactNode;
|
||||
index: number;
|
||||
value: number;
|
||||
loading?: boolean;
|
||||
}
|
||||
|
||||
function CustomTabPanel(props: TabPanelProps) {
|
||||
const { children, value, index, ...other } = props;
|
||||
|
||||
return (
|
||||
<div
|
||||
role="tabpanel"
|
||||
hidden={value !== index}
|
||||
id={`simple-tabpanel-${index}`}
|
||||
aria-labelledby={`simple-tab-${index}`}
|
||||
{...other}
|
||||
>
|
||||
{value === index && children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const StyledTab = styled(Tab)(({ theme }) => ({
|
||||
minWidth: 0,
|
||||
minHeight: 0,
|
||||
fontSize: theme.typography.h6.fontSize,
|
||||
padding: "8px 10px",
|
||||
}));
|
||||
|
||||
const EmojiButton = styled(Button)(({ theme }) => ({
|
||||
minWidth: 0,
|
||||
padding: "0px 4px",
|
||||
fontSize: theme.typography.h6.fontSize,
|
||||
}));
|
||||
|
||||
const SelectorBox = styled(Box)({
|
||||
display: "flex",
|
||||
flexWrap: "wrap",
|
||||
});
|
||||
|
||||
const ChangeIcon = () => {
|
||||
const { t } = useTranslation();
|
||||
const dispatch = useAppDispatch();
|
||||
const theme = useTheme();
|
||||
|
||||
const [tabValue, setTabValue] = useState(0);
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
const open = useAppSelector((state) => state.globalState.changeIconDialogOpen);
|
||||
const targets = useAppSelector((state) => state.globalState.changeIconDialogFile);
|
||||
const emojiStr = useAppSelector((state) => state.siteConfig.emojis.config.emoji_preset);
|
||||
const emojiStrLoaded = useAppSelector((state) => state.siteConfig.emojis.loaded);
|
||||
|
||||
const emojiSetting = useMemo((): EmojiSetting => {
|
||||
if (!emojiStr) return {};
|
||||
try {
|
||||
return JSON.parse(emojiStr) as EmojiSetting;
|
||||
} catch (e) {
|
||||
console.warn("failed to parse emoji setting", e);
|
||||
}
|
||||
return {};
|
||||
}, [emojiStr]);
|
||||
|
||||
const handleTabChange = (_event: React.SyntheticEvent, newValue: number) => {
|
||||
setTabValue(newValue);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (open && emojiStrLoaded != ConfigLoadState.Loaded) {
|
||||
dispatch(loadSiteConfig("emojis"));
|
||||
}
|
||||
}, [open]);
|
||||
|
||||
const onClose = useCallback(() => {
|
||||
if (!loading) {
|
||||
dispatch(closeChangeIconDialog());
|
||||
}
|
||||
}, [dispatch, loading]);
|
||||
|
||||
const onAccept = useCallback(
|
||||
(icon?: string) => async (e?: React.MouseEvent<HTMLElement>) => {
|
||||
if (e) {
|
||||
e.preventDefault();
|
||||
}
|
||||
|
||||
if (!targets) return;
|
||||
|
||||
setLoading(true);
|
||||
try {
|
||||
await dispatch(applyIcon(FileManagerIndex.main, targets, icon));
|
||||
} catch (e) {
|
||||
} finally {
|
||||
setLoading(false);
|
||||
dispatch(closeChangeIconDialog());
|
||||
}
|
||||
},
|
||||
[dispatch, targets, setLoading],
|
||||
);
|
||||
|
||||
return (
|
||||
<DraggableDialog
|
||||
title={t("application:fileManager.customizeIcon")}
|
||||
showActions
|
||||
loading={loading}
|
||||
showCancel
|
||||
hideOk
|
||||
dialogProps={{
|
||||
open: open ?? false,
|
||||
onClose: onClose,
|
||||
fullWidth: true,
|
||||
maxWidth: "sm",
|
||||
}}
|
||||
secondaryAction={
|
||||
<LoadingButton onClick={onAccept()} loading={loading} color="primary">
|
||||
<span>{t("application:modals.resetToDefault")}</span>
|
||||
</LoadingButton>
|
||||
}
|
||||
>
|
||||
<DialogContent>
|
||||
<AutoHeight>
|
||||
<Box sx={{ width: "100%" }}>
|
||||
<Box sx={{ borderBottom: 1, borderColor: "divider" }}>
|
||||
<Tabs
|
||||
sx={{ minHeight: 0 }}
|
||||
value={tabValue}
|
||||
variant="scrollable"
|
||||
scrollButtons="auto"
|
||||
onChange={handleTabChange}
|
||||
>
|
||||
{emojiStrLoaded ? (
|
||||
Object.keys(emojiSetting).map((key) => <StyledTab label={key} key={key} />)
|
||||
) : (
|
||||
<StyledTab label={<Skeleton sx={{ minWidth: "20px" }} />} />
|
||||
)}
|
||||
</Tabs>
|
||||
</Box>
|
||||
<Box sx={{ maxHeight: "200px", overflowY: "auto" }}>
|
||||
{emojiStrLoaded ? (
|
||||
Object.keys(emojiSetting).map((key, index) => (
|
||||
<CustomTabPanel value={tabValue} index={index}>
|
||||
<SelectorBox>
|
||||
{emojiSetting[key].map((emoji) => (
|
||||
<EmojiButton onClick={onAccept(emoji)}>{emoji}</EmojiButton>
|
||||
))}
|
||||
</SelectorBox>
|
||||
</CustomTabPanel>
|
||||
))
|
||||
) : (
|
||||
<CustomTabPanel value={tabValue} index={0}>
|
||||
<SelectorBox>
|
||||
{[...Array(50).keys()].map(() => (
|
||||
<EmojiButton disabled>
|
||||
<Skeleton sx={{ minWidth: "20px" }} />
|
||||
</EmojiButton>
|
||||
))}
|
||||
</SelectorBox>
|
||||
</CustomTabPanel>
|
||||
)}
|
||||
</Box>
|
||||
</Box>
|
||||
</AutoHeight>
|
||||
</DialogContent>
|
||||
</DraggableDialog>
|
||||
);
|
||||
};
|
||||
export default ChangeIcon;
|
||||
102
src/component/FileManager/Dialogs/CreateArchive.tsx
Executable file
102
src/component/FileManager/Dialogs/CreateArchive.tsx
Executable file
@@ -0,0 +1,102 @@
|
||||
import { DialogContent, Stack, useMediaQuery, useTheme } from "@mui/material";
|
||||
import { useSnackbar } from "notistack";
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { sendCreateArchive } from "../../../api/api.ts";
|
||||
import { closeCreateArchiveDialog } from "../../../redux/globalStateSlice.ts";
|
||||
import { useAppDispatch, useAppSelector } from "../../../redux/hooks.ts";
|
||||
import { getFileLinkedUri } from "../../../util";
|
||||
import CrUri from "../../../util/uri.ts";
|
||||
import { OutlineIconTextField } from "../../Common/Form/OutlineIconTextField.tsx";
|
||||
import { PathSelectorForm } from "../../Common/Form/PathSelectorForm.tsx";
|
||||
import { ViewTaskAction } from "../../Common/Snackbar/snackbar.tsx";
|
||||
import DraggableDialog from "../../Dialogs/DraggableDialog.tsx";
|
||||
import Archive from "../../Icons/Archive.tsx";
|
||||
import { FileManagerIndex } from "../FileManager.tsx";
|
||||
|
||||
const CreateArchive = () => {
|
||||
const { t } = useTranslation();
|
||||
const dispatch = useAppDispatch();
|
||||
const { enqueueSnackbar } = useSnackbar();
|
||||
|
||||
const theme = useTheme();
|
||||
const isMobile = useMediaQuery(theme.breakpoints.down("sm"));
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [fileName, setFileName] = useState("archive.zip");
|
||||
const [path, setPath] = useState("");
|
||||
|
||||
const open = useAppSelector((state) => state.globalState.createArchiveDialogOpen);
|
||||
const targets = useAppSelector((state) => state.globalState.createArchiveDialogFiles);
|
||||
const current = useAppSelector((state) => state.fileManager[FileManagerIndex.main].pure_path);
|
||||
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
setPath(current ?? "");
|
||||
}
|
||||
}, [open]);
|
||||
|
||||
const onClose = useCallback(() => {
|
||||
dispatch(closeCreateArchiveDialog());
|
||||
}, [dispatch]);
|
||||
|
||||
const onAccept = useCallback(() => {
|
||||
if (!targets) {
|
||||
return;
|
||||
}
|
||||
|
||||
setLoading(true);
|
||||
const dst = new CrUri(path);
|
||||
dispatch(
|
||||
sendCreateArchive({
|
||||
src: targets?.map((t) => getFileLinkedUri(t)),
|
||||
dst: dst.join(fileName).toString(),
|
||||
}),
|
||||
)
|
||||
.then(() => {
|
||||
dispatch(closeCreateArchiveDialog());
|
||||
enqueueSnackbar({
|
||||
message: t("modals.taskCreated"),
|
||||
variant: "success",
|
||||
action: ViewTaskAction(),
|
||||
});
|
||||
})
|
||||
.finally(() => {
|
||||
setLoading(false);
|
||||
});
|
||||
}, [targets, fileName, path]);
|
||||
|
||||
return (
|
||||
<DraggableDialog
|
||||
title={t("application:fileManager.createArchive")}
|
||||
showActions
|
||||
loading={loading}
|
||||
showCancel
|
||||
disabled={!fileName}
|
||||
onAccept={onAccept}
|
||||
dialogProps={{
|
||||
open: open ?? false,
|
||||
onClose: onClose,
|
||||
fullWidth: true,
|
||||
maxWidth: "sm",
|
||||
disableRestoreFocus: true,
|
||||
}}
|
||||
>
|
||||
<DialogContent sx={{ pt: 1 }}>
|
||||
<Stack spacing={3}>
|
||||
<OutlineIconTextField
|
||||
icon={<Archive />}
|
||||
variant="outlined"
|
||||
value={fileName}
|
||||
onChange={(e) => setFileName(e.target.value)}
|
||||
label={t("application:modals.zipFileName")}
|
||||
fullWidth
|
||||
/>
|
||||
<Stack spacing={3} direction={isMobile ? "column" : "row"}>
|
||||
<PathSelectorForm onChange={setPath} path={path} label={t("modals.saveToTitle")} />
|
||||
</Stack>
|
||||
</Stack>
|
||||
</DialogContent>
|
||||
</DraggableDialog>
|
||||
);
|
||||
};
|
||||
export default CreateArchive;
|
||||
133
src/component/FileManager/Dialogs/CreateNew.tsx
Executable file
133
src/component/FileManager/Dialogs/CreateNew.tsx
Executable file
@@ -0,0 +1,133 @@
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { DialogContent, Stack } from "@mui/material";
|
||||
import { useAppDispatch, useAppSelector } from "../../../redux/hooks.ts";
|
||||
import { ChangeEvent, useCallback, useEffect, useRef, useState } from "react";
|
||||
import { setRenameFileModalError } from "../../../redux/fileManagerSlice.ts";
|
||||
import DraggableDialog from "../../Dialogs/DraggableDialog.tsx";
|
||||
import { createNewDialogPromisePool } from "../../../redux/thunks/dialog.ts";
|
||||
import { FilledTextField } from "../../Common/StyledComponents.tsx";
|
||||
import { closeCreateNewDialog, CreateNewDialogType } from "../../../redux/globalStateSlice.ts";
|
||||
import { submitCreateNew } from "../../../redux/thunks/file.ts";
|
||||
import { FileType } from "../../../api/explorer.ts";
|
||||
|
||||
const CreateNew = () => {
|
||||
const { t } = useTranslation();
|
||||
const dispatch = useAppDispatch();
|
||||
|
||||
const [name, setName] = useState("");
|
||||
const [error, setError] = useState("");
|
||||
const [loading, setLoading] = useState(false);
|
||||
const formRef = useRef<HTMLFormElement>(null);
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
const open = useAppSelector((state) => state.globalState.createNewDialogOpen);
|
||||
const promiseId = useAppSelector((state) => state.globalState.createNewPromiseId);
|
||||
const type = useAppSelector((state) => state.globalState.createNewDialogType);
|
||||
const defaultName = useAppSelector((state) => state.globalState.createNewDialogDefault);
|
||||
const fmIndex = useAppSelector((state) => state.globalState.createNewDialogFmIndex) ?? 0;
|
||||
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
setName(defaultName ?? "");
|
||||
}
|
||||
}, [open]);
|
||||
|
||||
const onClose = useCallback(() => {
|
||||
dispatch(closeCreateNewDialog());
|
||||
if (promiseId) {
|
||||
createNewDialogPromisePool[promiseId]?.reject("cancel");
|
||||
}
|
||||
}, [dispatch, promiseId]);
|
||||
|
||||
const onAccept = useCallback(
|
||||
(e?: React.FormEvent<HTMLFormElement>) => {
|
||||
if (e) {
|
||||
e.preventDefault();
|
||||
}
|
||||
|
||||
setLoading(true);
|
||||
dispatch(submitCreateNew(fmIndex, name, type == CreateNewDialogType.folder ? FileType.folder : FileType.file))
|
||||
.then((f) => {
|
||||
if (promiseId) {
|
||||
createNewDialogPromisePool[promiseId]?.resolve(f);
|
||||
}
|
||||
dispatch(closeCreateNewDialog());
|
||||
})
|
||||
.finally(() => {
|
||||
setLoading(false);
|
||||
});
|
||||
},
|
||||
[promiseId, name],
|
||||
);
|
||||
|
||||
const onOkClicked = useCallback(() => {
|
||||
if (formRef.current) {
|
||||
if (formRef.current.reportValidity()) {
|
||||
onAccept();
|
||||
}
|
||||
}
|
||||
}, [formRef, onAccept]);
|
||||
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
const lastDot = name.lastIndexOf(".");
|
||||
setTimeout(
|
||||
() => inputRef.current && inputRef.current.setSelectionRange(0, lastDot > 0 ? lastDot : name.length),
|
||||
200,
|
||||
);
|
||||
}
|
||||
}, [open, inputRef.current]);
|
||||
|
||||
const onNameChange = useCallback(
|
||||
(e: ChangeEvent<HTMLInputElement | HTMLTextAreaElement>) => {
|
||||
setName(e.target.value);
|
||||
if (error) {
|
||||
dispatch(setRenameFileModalError({ index: 0, value: undefined }));
|
||||
}
|
||||
},
|
||||
[dispatch, setName, error],
|
||||
);
|
||||
|
||||
return (
|
||||
<DraggableDialog
|
||||
title={t(
|
||||
type == CreateNewDialogType.folder ? "application:fileManager.newFolder" : "application:fileManager.newFile",
|
||||
)}
|
||||
showActions
|
||||
loading={loading}
|
||||
showCancel
|
||||
onAccept={onOkClicked}
|
||||
dialogProps={{
|
||||
open: open ?? false,
|
||||
onClose: onClose,
|
||||
fullWidth: true,
|
||||
maxWidth: "sm",
|
||||
disableRestoreFocus: true,
|
||||
}}
|
||||
>
|
||||
<DialogContent>
|
||||
<Stack spacing={2}>
|
||||
<form ref={formRef} onSubmit={onAccept}>
|
||||
<FilledTextField
|
||||
inputRef={inputRef}
|
||||
variant="filled"
|
||||
autoFocus
|
||||
error={!!error}
|
||||
helperText={error}
|
||||
margin="dense"
|
||||
label={t(
|
||||
type == CreateNewDialogType.folder ? "application:modals.folderName" : "application:modals.fileName",
|
||||
)}
|
||||
type="text"
|
||||
value={name}
|
||||
onChange={onNameChange}
|
||||
fullWidth
|
||||
required
|
||||
/>
|
||||
</form>
|
||||
</Stack>
|
||||
</DialogContent>
|
||||
</DraggableDialog>
|
||||
);
|
||||
};
|
||||
export default CreateNew;
|
||||
119
src/component/FileManager/Dialogs/CreateRemoteDownload.tsx
Executable file
119
src/component/FileManager/Dialogs/CreateRemoteDownload.tsx
Executable file
@@ -0,0 +1,119 @@
|
||||
import { DialogContent, Stack, useMediaQuery, useTheme } from "@mui/material";
|
||||
import { useSnackbar } from "notistack";
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { sendCreateRemoteDownload } from "../../../api/api.ts";
|
||||
import { defaultPath } from "../../../hooks/useNavigation.tsx";
|
||||
import { closeRemoteDownloadDialog } from "../../../redux/globalStateSlice.ts";
|
||||
import { useAppDispatch, useAppSelector } from "../../../redux/hooks.ts";
|
||||
import { getFileLinkedUri } from "../../../util";
|
||||
import CrUri, { Filesystem } from "../../../util/uri.ts";
|
||||
import { FileDisplayForm } from "../../Common/Form/FileDisplayForm.tsx";
|
||||
import { OutlineIconTextField } from "../../Common/Form/OutlineIconTextField.tsx";
|
||||
import { PathSelectorForm } from "../../Common/Form/PathSelectorForm.tsx";
|
||||
import { ViewTaskAction } from "../../Common/Snackbar/snackbar.tsx";
|
||||
import DraggableDialog from "../../Dialogs/DraggableDialog.tsx";
|
||||
import Link from "../../Icons/Link.tsx";
|
||||
import { FileManagerIndex } from "../FileManager.tsx";
|
||||
|
||||
const CreateRemoteDownload = () => {
|
||||
const { t } = useTranslation();
|
||||
const dispatch = useAppDispatch();
|
||||
const { enqueueSnackbar } = useSnackbar();
|
||||
|
||||
const theme = useTheme();
|
||||
const isMobile = useMediaQuery(theme.breakpoints.down("sm"));
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [path, setPath] = useState("");
|
||||
const [url, setUrl] = useState("");
|
||||
|
||||
const open = useAppSelector((state) => state.globalState.remoteDownloadDialogOpen);
|
||||
const target = useAppSelector((state) => state.globalState.remoteDownloadDialogFile);
|
||||
const current = useAppSelector((state) => state.fileManager[FileManagerIndex.main].pure_path);
|
||||
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
const initialPath = new CrUri(current ?? defaultPath);
|
||||
const fs = initialPath.fs();
|
||||
setPath(fs == Filesystem.shared_with_me || fs == Filesystem.trash ? defaultPath : initialPath.toString());
|
||||
setUrl("");
|
||||
}
|
||||
}, [open]);
|
||||
|
||||
const onClose = useCallback(() => {
|
||||
dispatch(closeRemoteDownloadDialog());
|
||||
}, [dispatch]);
|
||||
|
||||
const onAccept = useCallback(() => {
|
||||
if (!target && !url) {
|
||||
return;
|
||||
}
|
||||
|
||||
setLoading(true);
|
||||
dispatch(
|
||||
sendCreateRemoteDownload({
|
||||
src_file: target ? getFileLinkedUri(target) : undefined,
|
||||
dst: path,
|
||||
src: url ? url.split("\n") : undefined,
|
||||
}),
|
||||
)
|
||||
.then(() => {
|
||||
dispatch(closeRemoteDownloadDialog());
|
||||
enqueueSnackbar({
|
||||
message: t("modals.taskCreated"),
|
||||
variant: "success",
|
||||
action: ViewTaskAction("/downloads"),
|
||||
});
|
||||
})
|
||||
.finally(() => {
|
||||
setLoading(false);
|
||||
});
|
||||
}, [target, url, path]);
|
||||
|
||||
return (
|
||||
<DraggableDialog
|
||||
title={t("application:modals.newRemoteDownloadTitle")}
|
||||
showActions
|
||||
loading={loading}
|
||||
disabled={!target && !url}
|
||||
showCancel
|
||||
onAccept={onAccept}
|
||||
dialogProps={{
|
||||
open: open ?? false,
|
||||
onClose: onClose,
|
||||
fullWidth: true,
|
||||
maxWidth: "sm",
|
||||
disableRestoreFocus: true,
|
||||
}}
|
||||
>
|
||||
<DialogContent sx={{ pt: 1 }}>
|
||||
<Stack spacing={3}>
|
||||
<Stack spacing={3} direction={isMobile ? "column" : "row"}>
|
||||
{target && <FileDisplayForm file={target} label={t("modals.remoteDownloadURL")} />}
|
||||
{!target && (
|
||||
<OutlineIconTextField
|
||||
icon={<Link />}
|
||||
variant="outlined"
|
||||
value={url}
|
||||
multiline
|
||||
onChange={(e) => setUrl(e.target.value)}
|
||||
placeholder={t("modals.remoteDownloadURLDescription")}
|
||||
label={t("application:modals.remoteDownloadURL")}
|
||||
fullWidth
|
||||
/>
|
||||
)}
|
||||
</Stack>
|
||||
<Stack spacing={3} direction={isMobile ? "column" : "row"}>
|
||||
<PathSelectorForm
|
||||
onChange={setPath}
|
||||
path={path}
|
||||
variant={"downloadTo"}
|
||||
label={t("modals.remoteDownloadDst")}
|
||||
/>
|
||||
</Stack>
|
||||
</Stack>
|
||||
</DialogContent>
|
||||
</DraggableDialog>
|
||||
);
|
||||
};
|
||||
export default CreateRemoteDownload;
|
||||
156
src/component/FileManager/Dialogs/DeleteConfirmation.tsx
Executable file
156
src/component/FileManager/Dialogs/DeleteConfirmation.tsx
Executable file
@@ -0,0 +1,156 @@
|
||||
import { Alert, Checkbox, Collapse, DialogContent, FormGroup, Stack, Tooltip } from "@mui/material";
|
||||
import dayjs from "dayjs";
|
||||
import duration from "dayjs/plugin/duration";
|
||||
import { useCallback, useMemo, useState } from "react";
|
||||
import { Trans, useTranslation } from "react-i18next";
|
||||
import { Metadata } from "../../../api/explorer.ts";
|
||||
import { GroupPermission } from "../../../api/user.ts";
|
||||
import { setFileDeleteModal } from "../../../redux/fileManagerSlice.ts";
|
||||
import { useAppDispatch, useAppSelector } from "../../../redux/hooks.ts";
|
||||
import { deleteDialogPromisePool } from "../../../redux/thunks/dialog.ts";
|
||||
import SessionManager from "../../../session";
|
||||
import { formatDuration } from "../../../util/datetime.ts";
|
||||
import { SmallFormControlLabel } from "../../Common/StyledComponents.tsx";
|
||||
import DialogAccordion from "../../Dialogs/DialogAccordion.tsx";
|
||||
import DraggableDialog, { StyledDialogContentText } from "../../Dialogs/DraggableDialog.tsx";
|
||||
|
||||
dayjs.extend(duration);
|
||||
|
||||
export interface DeleteOption {
|
||||
unlink?: boolean;
|
||||
skip_soft_delete?: boolean;
|
||||
}
|
||||
const DeleteConfirmation = () => {
|
||||
const { t } = useTranslation();
|
||||
const dispatch = useAppDispatch();
|
||||
|
||||
const [unlink, setUnlink] = useState(false);
|
||||
const [skipSoftDelete, setSkipSoftDelete] = useState(false);
|
||||
|
||||
const open = useAppSelector((state) => state.fileManager[0].deleteFileModalOpen);
|
||||
const targets = useAppSelector((state) => state.fileManager[0].deleteFileModalSelected);
|
||||
const promiseId = useAppSelector((state) => state.fileManager[0].deleteFileModalPromiseId);
|
||||
const loading = useAppSelector((state) => state.fileManager[0].deleteFileModalLoading);
|
||||
|
||||
const hasTrashFiles = useMemo(() => {
|
||||
if (targets) {
|
||||
return targets.some((target) => target.metadata && target.metadata[Metadata.restore_uri]);
|
||||
}
|
||||
|
||||
return false;
|
||||
}, [targets]);
|
||||
|
||||
const onClose = useCallback(() => {
|
||||
dispatch(
|
||||
setFileDeleteModal({
|
||||
index: 0,
|
||||
value: [false, targets, undefined, false],
|
||||
}),
|
||||
);
|
||||
if (promiseId) {
|
||||
deleteDialogPromisePool[promiseId]?.reject("cancel");
|
||||
}
|
||||
}, [dispatch, targets, promiseId]);
|
||||
|
||||
const singleFileToTrash = targets && targets.length == 1 && !hasTrashFiles && !skipSoftDelete;
|
||||
const multipleFilesToTrash = targets && targets.length > 1 && !hasTrashFiles && !skipSoftDelete;
|
||||
const singleFilePermanently = targets && targets.length == 1 && (hasTrashFiles || skipSoftDelete);
|
||||
const multipleFilesPermanently = targets && targets.length > 1 && (hasTrashFiles || skipSoftDelete);
|
||||
|
||||
const onAccept = useCallback(() => {
|
||||
if (promiseId) {
|
||||
deleteDialogPromisePool[promiseId]?.resolve({
|
||||
unlink,
|
||||
skip_soft_delete: singleFilePermanently || multipleFilesPermanently ? true : skipSoftDelete,
|
||||
});
|
||||
}
|
||||
}, [promiseId, unlink, skipSoftDelete, singleFilePermanently, multipleFilesPermanently]);
|
||||
|
||||
const permission = SessionManager.currentUserGroupPermission();
|
||||
const showSkipSoftDeleteOption = !hasTrashFiles;
|
||||
const showUnlinkOption = (skipSoftDelete || hasTrashFiles) && permission.enabled(GroupPermission.advance_delete);
|
||||
const showAdvanceOptions = showUnlinkOption || showSkipSoftDeleteOption;
|
||||
|
||||
const group = useMemo(() => SessionManager.currentUserGroup(), [open]);
|
||||
|
||||
return (
|
||||
<DraggableDialog
|
||||
title={t("application:modals.deleteTitle")}
|
||||
showActions
|
||||
loading={loading}
|
||||
showCancel
|
||||
onAccept={onAccept}
|
||||
dialogProps={{
|
||||
open: open ?? false,
|
||||
onClose: onClose,
|
||||
fullWidth: true,
|
||||
maxWidth: "xs",
|
||||
}}
|
||||
>
|
||||
<DialogContent>
|
||||
<Stack spacing={2}>
|
||||
<StyledDialogContentText>
|
||||
{(singleFileToTrash || singleFilePermanently) && (
|
||||
<Trans
|
||||
i18nKey={singleFileToTrash ? "modals.deleteOneDescription" : "modals.deleteOneDescriptionHard"}
|
||||
ns={"application"}
|
||||
values={{
|
||||
name: targets[0].name,
|
||||
}}
|
||||
components={[<strong key={0} />]}
|
||||
/>
|
||||
)}
|
||||
{(multipleFilesToTrash || multipleFilesPermanently) &&
|
||||
t(
|
||||
multipleFilesToTrash
|
||||
? "application:modals.deleteMultipleDescription"
|
||||
: "application:modals.deleteMultipleDescriptionHard",
|
||||
{
|
||||
num: targets.length,
|
||||
},
|
||||
)}
|
||||
<Collapse in={singleFileToTrash || multipleFilesToTrash}>
|
||||
<Alert sx={{ mt: 1 }} severity="info">
|
||||
<Trans
|
||||
i18nKey="application:modals.trashRetention"
|
||||
ns={"application"}
|
||||
values={{ num: formatDuration(dayjs.duration((group?.trash_retention ?? 0) * 1000)) }}
|
||||
components={[<strong key={0} />]}
|
||||
/>
|
||||
</Alert>
|
||||
</Collapse>
|
||||
</StyledDialogContentText>
|
||||
{showAdvanceOptions && (
|
||||
<DialogAccordion defaultExpanded={unlink || skipSoftDelete} title={t("application:modals.advanceOptions")}>
|
||||
<FormGroup>
|
||||
<Collapse in={showSkipSoftDeleteOption}>
|
||||
<Tooltip title={t("application:modals.skipSoftDeleteDes")}>
|
||||
<SmallFormControlLabel
|
||||
control={
|
||||
<Checkbox
|
||||
size="small"
|
||||
onChange={(e) => setSkipSoftDelete(e.target.checked)}
|
||||
checked={skipSoftDelete}
|
||||
/>
|
||||
}
|
||||
label={t("application:modals.skipSoftDelete")}
|
||||
/>
|
||||
</Tooltip>
|
||||
</Collapse>
|
||||
<Collapse in={showUnlinkOption}>
|
||||
<Tooltip title={t("application:modals.unlinkOnlyDes")}>
|
||||
<SmallFormControlLabel
|
||||
control={<Checkbox size="small" onChange={(e) => setUnlink(e.target.checked)} checked={unlink} />}
|
||||
label={t("application:modals.unlinkOnly")}
|
||||
/>
|
||||
</Tooltip>
|
||||
</Collapse>
|
||||
</FormGroup>
|
||||
</DialogAccordion>
|
||||
)}
|
||||
</Stack>
|
||||
</DialogContent>
|
||||
</DraggableDialog>
|
||||
);
|
||||
};
|
||||
export default DeleteConfirmation;
|
||||
85
src/component/FileManager/Dialogs/Dialogs.tsx
Executable file
85
src/component/FileManager/Dialogs/Dialogs.tsx
Executable file
@@ -0,0 +1,85 @@
|
||||
import DeleteConfirmation from "./DeleteConfirmation.tsx";
|
||||
import AggregatedErrorDetail from "../../Dialogs/AggregatedErrorDetail.tsx";
|
||||
import LockConflictDetails from "./LockConflictDetails.tsx";
|
||||
import Rename from "./Rename.tsx";
|
||||
import PathSelection from "./PathSelection.tsx";
|
||||
import Tags from "./Tags.tsx";
|
||||
import ChangeIcon from "./ChangeIcon.tsx";
|
||||
import ShareDialog from "./Share/ShareDialog.tsx";
|
||||
import VersionControl from "./VersionControl.tsx";
|
||||
import ManageShares from "./Share/ManageShares.tsx";
|
||||
import StaleVersionConfirm from "./StaleVersionConfirm.tsx";
|
||||
import SaveAs from "./SaveAs.tsx";
|
||||
import Photopea from "../../Viewers/Photopea/Photopea.tsx";
|
||||
import OpenWith from "./OpenWith.tsx";
|
||||
import Wopi from "../../Viewers/Wopi.tsx";
|
||||
import ArchivePreview from "../../Viewers/ArchivePreview/ArchivePreview.tsx";
|
||||
import CodeViewer from "../../Viewers/CodeViewer/CodeViewer.tsx";
|
||||
import DrawIOViewer from "../../Viewers/DrawIO/DrawIOViewer.tsx";
|
||||
import MarkdownViewer from "../../Viewers/MarkdownEditor/MarkdownViewer.tsx";
|
||||
import VideoViewer from "../../Viewers/Video/VideoViewer.tsx";
|
||||
import PdfViewer from "../../Viewers/PdfViewer.tsx";
|
||||
import CustomViewer from "../../Viewers/CustomViewer.tsx";
|
||||
import EpubViewer from "../../Viewers/EpubViewer/EpubViewer.tsx";
|
||||
import ExcalidrawViewer from "../../Viewers/Excalidraw/ExcalidrawViewer.tsx";
|
||||
import CreateNew from "./CreateNew.tsx";
|
||||
import { useAppSelector } from "../../../redux/hooks.ts";
|
||||
import CreateArchive from "./CreateArchive.tsx";
|
||||
import ExtractArchive from "./ExtractArchive.tsx";
|
||||
import CreateRemoteDownload from "./CreateRemoteDownload.tsx";
|
||||
import AdvanceSearch from "../Search/AdvanceSearch/AdvanceSearch.tsx";
|
||||
import React from "react";
|
||||
import ColumnSetting from "../Explorer/ListView/ColumnSetting.tsx";
|
||||
import DirectLinks from "./DirectLinks.tsx";
|
||||
import DirectLinksControl from "./DirectLinksControl.tsx";
|
||||
|
||||
const Dialogs = () => {
|
||||
const showCreateArchive = useAppSelector((state) => state.globalState.createArchiveDialogOpen);
|
||||
const showExtractArchive = useAppSelector((state) => state.globalState.extractArchiveDialogOpen);
|
||||
const showRemoteDownload = useAppSelector((state) => state.globalState.remoteDownloadDialogOpen);
|
||||
const showAdvancedSearch = useAppSelector((state) => state.globalState.advanceSearchOpen);
|
||||
const showListViewColumnSetting = useAppSelector((state) => state.globalState.listViewColumnSettingDialogOpen);
|
||||
const directLink = useAppSelector((state) => state.globalState.directLinkDialogOpen);
|
||||
const excalidrawViewer = useAppSelector((state) => state.globalState.excalidrawViewer);
|
||||
const directLinkManagement = useAppSelector((state) => state.globalState.directLinkManagementDialogOpen);
|
||||
const archivePreview = useAppSelector((state) => state.globalState.archiveViewer);
|
||||
|
||||
return (
|
||||
<>
|
||||
<CreateNew />
|
||||
<DeleteConfirmation />
|
||||
<AggregatedErrorDetail />
|
||||
<LockConflictDetails />
|
||||
<Rename />
|
||||
<PathSelection />
|
||||
<Tags />
|
||||
<ChangeIcon />
|
||||
<ShareDialog />
|
||||
<VersionControl />
|
||||
<ManageShares />
|
||||
<StaleVersionConfirm />
|
||||
<SaveAs />
|
||||
<Photopea />
|
||||
<OpenWith />
|
||||
<Wopi />
|
||||
<CodeViewer />
|
||||
<DrawIOViewer />
|
||||
<MarkdownViewer />
|
||||
<VideoViewer />
|
||||
<PdfViewer />
|
||||
<CustomViewer />
|
||||
<EpubViewer />
|
||||
{showCreateArchive != undefined && <CreateArchive />}
|
||||
{showExtractArchive != undefined && <ExtractArchive />}
|
||||
{showRemoteDownload != undefined && <CreateRemoteDownload />}
|
||||
{showAdvancedSearch != undefined && <AdvanceSearch />}
|
||||
{showListViewColumnSetting != undefined && <ColumnSetting />}
|
||||
{directLink != undefined && <DirectLinks />}
|
||||
{excalidrawViewer != undefined && <ExcalidrawViewer />}
|
||||
{directLinkManagement != undefined && <DirectLinksControl />}
|
||||
{archivePreview != undefined && <ArchivePreview />}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default Dialogs;
|
||||
128
src/component/FileManager/Dialogs/DirectLinks.tsx
Executable file
128
src/component/FileManager/Dialogs/DirectLinks.tsx
Executable file
@@ -0,0 +1,128 @@
|
||||
import { DialogContent, FormControlLabel, Stack, TextField, useMediaQuery, useTheme } from "@mui/material";
|
||||
import { useCallback, useMemo, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { closeDirectLinkDialog } from "../../../redux/globalStateSlice.ts";
|
||||
import { useAppDispatch, useAppSelector } from "../../../redux/hooks.ts";
|
||||
import CrUri from "../../../util/uri.ts";
|
||||
import { StyledCheckbox } from "../../Common/StyledComponents.tsx";
|
||||
import DraggableDialog from "../../Dialogs/DraggableDialog.tsx";
|
||||
|
||||
const DirectLinks = () => {
|
||||
const { t } = useTranslation();
|
||||
const dispatch = useAppDispatch();
|
||||
const theme = useTheme();
|
||||
const isMobile = useMediaQuery(theme.breakpoints.down("sm"));
|
||||
|
||||
const [showFileName, setShowFileName] = useState(false);
|
||||
const [forceDownload, setForceDownload] = useState(false);
|
||||
|
||||
const open = useAppSelector((state) => state.globalState.directLinkDialogOpen);
|
||||
const targets = useAppSelector((state) => state.globalState.directLinkRes);
|
||||
|
||||
const contents = useMemo(() => {
|
||||
if (!targets) {
|
||||
return "";
|
||||
}
|
||||
|
||||
return targets
|
||||
.map((link) => {
|
||||
let finalLink = link.link;
|
||||
|
||||
if (forceDownload) {
|
||||
finalLink = finalLink.replace("/f/", "/f/d/");
|
||||
}
|
||||
|
||||
if (!showFileName) {
|
||||
return finalLink;
|
||||
}
|
||||
|
||||
const crUri = new CrUri(link.file_url);
|
||||
const elements = crUri.elements();
|
||||
return `[${elements.pop()}] ${finalLink}`;
|
||||
})
|
||||
.join("\n");
|
||||
}, [targets, showFileName, forceDownload]);
|
||||
|
||||
const onClose = useCallback(() => {
|
||||
dispatch(closeDirectLinkDialog());
|
||||
}, [dispatch]);
|
||||
|
||||
return (
|
||||
<DraggableDialog
|
||||
title={t("application:modals.getSourceLinkTitle")}
|
||||
showActions
|
||||
hideOk
|
||||
secondaryAction={
|
||||
<Stack direction={isMobile ? "column" : "row"} spacing={1}>
|
||||
<FormControlLabel
|
||||
sx={{
|
||||
ml: 0,
|
||||
}}
|
||||
slotProps={{
|
||||
typography: {
|
||||
variant: "body2",
|
||||
pl: 1,
|
||||
color: "text.secondary",
|
||||
},
|
||||
}}
|
||||
control={
|
||||
<StyledCheckbox
|
||||
onChange={() => {
|
||||
setShowFileName(!showFileName);
|
||||
}}
|
||||
disableRipple
|
||||
checked={showFileName}
|
||||
size="small"
|
||||
/>
|
||||
}
|
||||
label={t("application:modals.showFileName")}
|
||||
/>
|
||||
<FormControlLabel
|
||||
sx={{
|
||||
ml: 0,
|
||||
}}
|
||||
slotProps={{
|
||||
typography: {
|
||||
variant: "body2",
|
||||
pl: 1,
|
||||
color: "text.secondary",
|
||||
},
|
||||
}}
|
||||
control={
|
||||
<StyledCheckbox
|
||||
onChange={() => {
|
||||
setForceDownload(!forceDownload);
|
||||
}}
|
||||
disableRipple
|
||||
checked={forceDownload}
|
||||
size="small"
|
||||
/>
|
||||
}
|
||||
label={t("application:modals.forceDownload")}
|
||||
/>
|
||||
</Stack>
|
||||
}
|
||||
dialogProps={{
|
||||
open: open ?? false,
|
||||
onClose: onClose,
|
||||
fullWidth: true,
|
||||
maxWidth: "sm",
|
||||
}}
|
||||
>
|
||||
<DialogContent sx={{ pt: 2, pb: 0 }}>
|
||||
<TextField
|
||||
autoFocus
|
||||
label={t("modals.sourceLink")}
|
||||
multiline
|
||||
value={contents}
|
||||
variant="outlined"
|
||||
fullWidth
|
||||
slotProps={{
|
||||
htmlInput: { readonly: true },
|
||||
}}
|
||||
/>
|
||||
</DialogContent>
|
||||
</DraggableDialog>
|
||||
);
|
||||
};
|
||||
export default DirectLinks;
|
||||
254
src/component/FileManager/Dialogs/DirectLinksControl.tsx
Executable file
254
src/component/FileManager/Dialogs/DirectLinksControl.tsx
Executable file
@@ -0,0 +1,254 @@
|
||||
import {
|
||||
Alert,
|
||||
Box,
|
||||
DialogContent,
|
||||
FormControlLabel,
|
||||
IconButton,
|
||||
Link,
|
||||
Skeleton,
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableContainer,
|
||||
TableHead,
|
||||
TableRow,
|
||||
Typography,
|
||||
} from "@mui/material";
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { getFileInfo, sendDeleteDirectLink } from "../../../api/api.ts";
|
||||
import { DirectLink, FileResponse } from "../../../api/explorer.ts";
|
||||
import { closeDirectLinkManagementDialog } from "../../../redux/globalStateSlice.ts";
|
||||
import { useAppDispatch, useAppSelector } from "../../../redux/hooks.ts";
|
||||
import { confirmOperation } from "../../../redux/thunks/dialog.ts";
|
||||
import { copyToClipboard } from "../../../util/index.ts";
|
||||
import AutoHeight from "../../Common/AutoHeight.tsx";
|
||||
import { NoWrapTableCell, StyledCheckbox, StyledTableContainerPaper } from "../../Common/StyledComponents.tsx";
|
||||
import TimeBadge from "../../Common/TimeBadge.tsx";
|
||||
import DraggableDialog from "../../Dialogs/DraggableDialog.tsx";
|
||||
import CopyOutlined from "../../Icons/CopyOutlined.tsx";
|
||||
import DeleteOutlined from "../../Icons/DeleteOutlined.tsx";
|
||||
|
||||
const DirectLinksControl = () => {
|
||||
const { t } = useTranslation();
|
||||
const dispatch = useAppDispatch();
|
||||
|
||||
const [fileExtended, setFileExtended] = useState<FileResponse | undefined>(undefined);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [forceDownload, setForceDownload] = useState(false);
|
||||
|
||||
const open = useAppSelector((state) => state.globalState.directLinkManagementDialogOpen);
|
||||
const target = useAppSelector((state) => state.globalState.directLinkManagementDialogFile);
|
||||
const highlight = useAppSelector((state) => state.globalState.directLinkHighlight);
|
||||
|
||||
const hilightButNotFound = useMemo(() => {
|
||||
return (
|
||||
highlight &&
|
||||
fileExtended?.extended_info &&
|
||||
!fileExtended?.extended_info?.direct_links?.some((link) => link.id == highlight)
|
||||
);
|
||||
}, [highlight, fileExtended?.extended_info?.direct_links]);
|
||||
|
||||
const onClose = useCallback(() => {
|
||||
if (!loading) {
|
||||
dispatch(closeDirectLinkManagementDialog());
|
||||
}
|
||||
}, [dispatch, loading]);
|
||||
|
||||
useEffect(() => {
|
||||
if (target && open) {
|
||||
setFileExtended(undefined);
|
||||
dispatch(
|
||||
getFileInfo({
|
||||
uri: target.path,
|
||||
extended: true,
|
||||
}),
|
||||
).then((res) => setFileExtended(res));
|
||||
}
|
||||
}, [target, open]);
|
||||
|
||||
const directLinks = useMemo(() => {
|
||||
return fileExtended?.extended_info?.direct_links?.map((link) => {
|
||||
return {
|
||||
...link,
|
||||
url: forceDownload ? link.url.replace("/f/", "/f/d/") : link.url,
|
||||
};
|
||||
});
|
||||
}, [fileExtended?.extended_info?.direct_links, forceDownload]);
|
||||
|
||||
const handleRowClick = useCallback((directLink: DirectLink) => {
|
||||
window.open(directLink.url, "_blank");
|
||||
}, []);
|
||||
|
||||
const copyURL = useCallback((actionTarget: DirectLink) => {
|
||||
if (!actionTarget) {
|
||||
return;
|
||||
}
|
||||
|
||||
copyToClipboard(actionTarget.url);
|
||||
}, []);
|
||||
|
||||
const deleteDirectLink = useCallback(
|
||||
(actionTarget: DirectLink) => {
|
||||
if (!target || !actionTarget) {
|
||||
return;
|
||||
}
|
||||
|
||||
dispatch(confirmOperation(t("fileManager.deleteLinkConfirm"))).then(() => {
|
||||
setLoading(true);
|
||||
dispatch(sendDeleteDirectLink(actionTarget.id))
|
||||
.then(() => {
|
||||
setFileExtended((prev) =>
|
||||
prev
|
||||
? {
|
||||
...prev,
|
||||
extended_info: prev.extended_info
|
||||
? {
|
||||
...prev.extended_info,
|
||||
direct_links: prev.extended_info.direct_links?.filter((link) => link.id !== actionTarget.id),
|
||||
}
|
||||
: undefined,
|
||||
}
|
||||
: undefined,
|
||||
);
|
||||
})
|
||||
.finally(() => {
|
||||
setLoading(false);
|
||||
});
|
||||
});
|
||||
},
|
||||
[t, target, dispatch],
|
||||
);
|
||||
|
||||
return (
|
||||
<DraggableDialog
|
||||
title={t("application:fileManager.manageDirectLinks")}
|
||||
loading={loading}
|
||||
dialogProps={{
|
||||
open: open ?? false,
|
||||
onClose: onClose,
|
||||
fullWidth: true,
|
||||
maxWidth: "md",
|
||||
}}
|
||||
>
|
||||
<DialogContent>
|
||||
<AutoHeight>
|
||||
{hilightButNotFound && (
|
||||
<Alert severity="warning" sx={{ mb: 2 }}>
|
||||
{t("application:fileManager.directLinkNotFound")}
|
||||
</Alert>
|
||||
)}
|
||||
<TableContainer component={StyledTableContainerPaper}>
|
||||
<Table sx={{ width: "100%" }} size="small">
|
||||
<TableHead>
|
||||
<TableRow>
|
||||
<NoWrapTableCell>{t("fileManager.actions")}</NoWrapTableCell>
|
||||
<TableCell>{t("modals.sourceLink")}</TableCell>
|
||||
<NoWrapTableCell>{t("setting.viewNumber")}</NoWrapTableCell>
|
||||
<NoWrapTableCell>{t("fileManager.createdAt")}</NoWrapTableCell>
|
||||
</TableRow>
|
||||
</TableHead>
|
||||
<TableBody>
|
||||
{!fileExtended && (
|
||||
<TableRow
|
||||
hover
|
||||
sx={{
|
||||
"&:last-child td, &:last-child th": { border: 0 },
|
||||
}}
|
||||
>
|
||||
<NoWrapTableCell component="th" scope="row">
|
||||
<Skeleton variant={"text"} width={100} />
|
||||
</NoWrapTableCell>
|
||||
<TableCell>
|
||||
<Skeleton variant={"text"} width={200} />
|
||||
</TableCell>
|
||||
<NoWrapTableCell>
|
||||
<Skeleton variant={"text"} width={60} />
|
||||
</NoWrapTableCell>
|
||||
<NoWrapTableCell>
|
||||
<Skeleton variant={"text"} width={100} />
|
||||
</NoWrapTableCell>
|
||||
</TableRow>
|
||||
)}
|
||||
{directLinks &&
|
||||
directLinks.map((link) => (
|
||||
<TableRow
|
||||
key={link.id}
|
||||
hover
|
||||
selected={highlight == link.id}
|
||||
sx={{
|
||||
boxShadow: (theme) =>
|
||||
highlight == link.id ? `inset 0 0 0 2px ${theme.palette.primary.light}` : "none",
|
||||
"&:last-child td, &:last-child th": { border: 0 },
|
||||
}}
|
||||
>
|
||||
<NoWrapTableCell component="th" scope="row">
|
||||
<IconButton onClick={() => copyURL(link)} size={"small"}>
|
||||
<CopyOutlined fontSize={"small"} />
|
||||
</IconButton>
|
||||
<IconButton disabled={loading} onClick={() => deleteDirectLink(link)} size={"small"}>
|
||||
<DeleteOutlined fontSize={"small"} />
|
||||
</IconButton>
|
||||
</NoWrapTableCell>
|
||||
<TableCell
|
||||
sx={{
|
||||
maxWidth: 300,
|
||||
overflow: "hidden",
|
||||
textOverflow: "ellipsis",
|
||||
whiteSpace: "nowrap",
|
||||
}}
|
||||
onClick={(event) => event.stopPropagation()}
|
||||
>
|
||||
<Typography variant="body2" sx={{ cursor: "text" }}>
|
||||
<Link href={link.url} target="_blank" underline="hover">
|
||||
{link.url}
|
||||
</Link>
|
||||
</Typography>
|
||||
</TableCell>
|
||||
<NoWrapTableCell>{link.downloaded}</NoWrapTableCell>
|
||||
<NoWrapTableCell>
|
||||
<TimeBadge variant={"body2"} datetime={link.created_at} />
|
||||
</NoWrapTableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
{!directLinks && fileExtended && (
|
||||
<Box sx={{ p: 1, width: "100%", textAlign: "center" }}>
|
||||
<Typography variant={"caption"} color={"text.secondary"}>
|
||||
{t("application:setting.listEmpty")}
|
||||
</Typography>
|
||||
</Box>
|
||||
)}
|
||||
</TableContainer>
|
||||
</AutoHeight>
|
||||
<FormControlLabel
|
||||
sx={{
|
||||
ml: 0,
|
||||
mt: 2,
|
||||
}}
|
||||
slotProps={{
|
||||
typography: {
|
||||
variant: "body2",
|
||||
pl: 1,
|
||||
color: "text.secondary",
|
||||
},
|
||||
}}
|
||||
control={
|
||||
<StyledCheckbox
|
||||
onChange={() => {
|
||||
setForceDownload(!forceDownload);
|
||||
}}
|
||||
disableRipple
|
||||
checked={forceDownload}
|
||||
size="small"
|
||||
/>
|
||||
}
|
||||
label={t("application:modals.forceDownload")}
|
||||
/>
|
||||
</DialogContent>
|
||||
</DraggableDialog>
|
||||
);
|
||||
};
|
||||
|
||||
export default DirectLinksControl;
|
||||
167
src/component/FileManager/Dialogs/ExtractArchive.tsx
Executable file
167
src/component/FileManager/Dialogs/ExtractArchive.tsx
Executable file
@@ -0,0 +1,167 @@
|
||||
import { DialogContent, Grid2, InputAdornment, TextField, useMediaQuery, useTheme } from "@mui/material";
|
||||
import { useSnackbar } from "notistack";
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { sendExtractArchive } from "../../../api/api.ts";
|
||||
import { closeExtractArchiveDialog } from "../../../redux/globalStateSlice.ts";
|
||||
import { useAppDispatch, useAppSelector } from "../../../redux/hooks.ts";
|
||||
import { fileExtension, getFileLinkedUri } from "../../../util";
|
||||
import EncodingSelector, { defaultEncodingValue } from "../../Common/Form/EncodingSelector.tsx";
|
||||
import { FileDisplayForm } from "../../Common/Form/FileDisplayForm.tsx";
|
||||
import { PathSelectorForm } from "../../Common/Form/PathSelectorForm.tsx";
|
||||
import { ViewTaskAction } from "../../Common/Snackbar/snackbar.tsx";
|
||||
import DraggableDialog from "../../Dialogs/DraggableDialog.tsx";
|
||||
import Password from "../../Icons/Password.tsx";
|
||||
import { FileManagerIndex } from "../FileManager.tsx";
|
||||
|
||||
const ExtractArchive = () => {
|
||||
const { t } = useTranslation();
|
||||
const dispatch = useAppDispatch();
|
||||
const { enqueueSnackbar } = useSnackbar();
|
||||
|
||||
const theme = useTheme();
|
||||
const isMobile = useMediaQuery(theme.breakpoints.down("sm"));
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [path, setPath] = useState("");
|
||||
const [encoding, setEncoding] = useState(defaultEncodingValue);
|
||||
const [password, setPassword] = useState("");
|
||||
const [showPassword, setShowPassword] = useState(false);
|
||||
|
||||
const open = useAppSelector((state) => state.globalState.extractArchiveDialogOpen);
|
||||
const target = useAppSelector((state) => state.globalState.extractArchiveDialogFile);
|
||||
const current = useAppSelector((state) => state.fileManager[FileManagerIndex.main].pure_path);
|
||||
const mask = useAppSelector((state) => state.globalState.extractArchiveDialogMask);
|
||||
const predefinedEncoding = useAppSelector((state) => state.globalState.extractArchiveDialogEncoding);
|
||||
|
||||
useEffect(() => {
|
||||
setEncoding(predefinedEncoding ?? defaultEncodingValue);
|
||||
}, [predefinedEncoding]);
|
||||
|
||||
const showEncodingOption = useMemo(() => {
|
||||
const ext = fileExtension(target?.name ?? "");
|
||||
return ext === "zip";
|
||||
}, [target?.name]);
|
||||
|
||||
const showPasswordOption = useMemo(() => {
|
||||
const ext = fileExtension(target?.name ?? "");
|
||||
return ext === "zip" || ext === "7z";
|
||||
}, [target?.name]);
|
||||
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
setPath(current ?? "");
|
||||
}
|
||||
}, [open]);
|
||||
|
||||
const onClose = useCallback(() => {
|
||||
dispatch(closeExtractArchiveDialog());
|
||||
}, [dispatch]);
|
||||
|
||||
const onAccept = useCallback(() => {
|
||||
if (!target) {
|
||||
return;
|
||||
}
|
||||
|
||||
setLoading(true);
|
||||
dispatch(
|
||||
sendExtractArchive({
|
||||
src: [getFileLinkedUri(target)],
|
||||
dst: path,
|
||||
encoding: showEncodingOption && encoding != defaultEncodingValue ? encoding : undefined,
|
||||
password: showPasswordOption && password ? password : undefined,
|
||||
file_mask: mask ?? undefined,
|
||||
}),
|
||||
)
|
||||
.then(() => {
|
||||
dispatch(closeExtractArchiveDialog());
|
||||
enqueueSnackbar({
|
||||
message: t("modals.taskCreated"),
|
||||
variant: "success",
|
||||
action: ViewTaskAction(),
|
||||
});
|
||||
})
|
||||
.finally(() => {
|
||||
setLoading(false);
|
||||
});
|
||||
}, [target, encoding, path, showPasswordOption, showEncodingOption, password, mask]);
|
||||
|
||||
return (
|
||||
<DraggableDialog
|
||||
title={t("application:fileManager.extractArchive")}
|
||||
showActions
|
||||
loading={loading}
|
||||
showCancel
|
||||
onAccept={onAccept}
|
||||
dialogProps={{
|
||||
open: open ?? false,
|
||||
onClose: onClose,
|
||||
fullWidth: true,
|
||||
maxWidth: "sm",
|
||||
disableRestoreFocus: true,
|
||||
}}
|
||||
>
|
||||
<DialogContent sx={{ pt: 1 }}>
|
||||
<Grid2 container spacing={3}>
|
||||
{target && (
|
||||
<Grid2
|
||||
size={{
|
||||
xs: 12,
|
||||
md: showEncodingOption ? 6 : 12,
|
||||
}}
|
||||
>
|
||||
<FileDisplayForm file={target} label={t("modals.archiveFile")} />
|
||||
</Grid2>
|
||||
)}
|
||||
{showEncodingOption && (
|
||||
<Grid2
|
||||
size={{
|
||||
xs: 12,
|
||||
md: 6,
|
||||
}}
|
||||
>
|
||||
<EncodingSelector
|
||||
value={encoding}
|
||||
onChange={setEncoding}
|
||||
variant="outlined"
|
||||
fullWidth
|
||||
showIcon={!isMobile}
|
||||
/>
|
||||
</Grid2>
|
||||
)}
|
||||
<Grid2
|
||||
size={{
|
||||
xs: 12,
|
||||
}}
|
||||
>
|
||||
<PathSelectorForm onChange={setPath} path={path} variant={"extractTo"} label={t("modals.decompressTo")} />
|
||||
</Grid2>
|
||||
{showPasswordOption && (
|
||||
<Grid2
|
||||
size={{
|
||||
xs: 12,
|
||||
}}
|
||||
>
|
||||
<TextField
|
||||
slotProps={{
|
||||
input: {
|
||||
startAdornment: !isMobile && (
|
||||
<InputAdornment position="start">
|
||||
<Password />
|
||||
</InputAdornment>
|
||||
),
|
||||
},
|
||||
}}
|
||||
fullWidth
|
||||
placeholder={t("application:modals.passwordDescription")}
|
||||
label={t("modals.password")}
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
/>
|
||||
</Grid2>
|
||||
)}
|
||||
</Grid2>
|
||||
</DialogContent>
|
||||
</DraggableDialog>
|
||||
);
|
||||
};
|
||||
export default ExtractArchive;
|
||||
234
src/component/FileManager/Dialogs/LockConflictDetails.tsx
Executable file
234
src/component/FileManager/Dialogs/LockConflictDetails.tsx
Executable file
@@ -0,0 +1,234 @@
|
||||
import {
|
||||
Box,
|
||||
Button,
|
||||
DialogContent,
|
||||
Stack,
|
||||
styled,
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableContainer,
|
||||
TableHead,
|
||||
TableRow,
|
||||
Tooltip,
|
||||
Typography,
|
||||
} from "@mui/material";
|
||||
import { useCallback, useMemo, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { ConflictDetail, FileResponse, LockApplication } from "../../../api/explorer.ts";
|
||||
import { closeLockConflictDialog } from "../../../redux/globalStateSlice.ts";
|
||||
import { useAppDispatch, useAppSelector } from "../../../redux/hooks.ts";
|
||||
import { ViewersByID } from "../../../redux/siteConfigSlice.ts";
|
||||
import { generalDialogPromisePool } from "../../../redux/thunks/dialog.ts";
|
||||
import { forceUnlockFiles } from "../../../redux/thunks/file.ts";
|
||||
import { NoWrapTableCell, StyledTableContainerPaper } from "../../Common/StyledComponents.tsx";
|
||||
import DraggableDialog, { StyledDialogActions, StyledDialogContentText } from "../../Dialogs/DraggableDialog.tsx";
|
||||
import FileBadge from "../FileBadge.tsx";
|
||||
import { ViewerIcon } from "./OpenWith.tsx";
|
||||
|
||||
interface ErrorTableProps {
|
||||
data: ConflictDetail[];
|
||||
loading?: boolean;
|
||||
files: {
|
||||
[key: string]: FileResponse;
|
||||
};
|
||||
unlock: (tokens: string[]) => Promise<void>;
|
||||
}
|
||||
|
||||
export const CellHeaderWithPadding = styled(Box)({
|
||||
paddingLeft: "8px",
|
||||
});
|
||||
|
||||
const ErrorTable = (props: ErrorTableProps) => {
|
||||
const { t } = useTranslation();
|
||||
const dispatch = useAppDispatch();
|
||||
|
||||
return (
|
||||
<TableContainer component={StyledTableContainerPaper}>
|
||||
<Table sx={{ width: "100%" }} size="small">
|
||||
<TableHead>
|
||||
<TableRow>
|
||||
<TableCell>
|
||||
<CellHeaderWithPadding>{t("common:object")}</CellHeaderWithPadding>
|
||||
</TableCell>
|
||||
<TableCell>{t("application:modals.application")}</TableCell>
|
||||
<TableCell>
|
||||
<CellHeaderWithPadding>{t("application:setting.action")}</CellHeaderWithPadding>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
</TableHead>
|
||||
<TableBody>
|
||||
{props.data.map((conflict, i) => (
|
||||
<TableRow hover key={i}>
|
||||
<TableCell component="th" scope="row">
|
||||
{conflict.path && (
|
||||
<FileBadge
|
||||
sx={{ maxWidth: "250px" }}
|
||||
simplifiedFile={{
|
||||
path: conflict.path ?? "",
|
||||
type: conflict.type,
|
||||
}}
|
||||
file={props.files[conflict.path ?? ""]}
|
||||
/>
|
||||
)}
|
||||
{!conflict.path && <FileBadge sx={{ maxWidth: "250px" }} unknown />}
|
||||
</TableCell>
|
||||
<NoWrapTableCell>
|
||||
{conflict.owner?.application && <Application app={conflict.owner?.application} />}
|
||||
</NoWrapTableCell>
|
||||
<NoWrapTableCell>
|
||||
<Tooltip title={!conflict.token ? t("application:modals:onlyOwner") : ""}>
|
||||
<span>
|
||||
<Button
|
||||
disabled={!conflict.token || props.loading}
|
||||
onClick={() => props.unlock([conflict.token ?? ""])}
|
||||
>
|
||||
<Typography variant={"body2"}>{t("application:modals.forceUnlock")}</Typography>
|
||||
</Button>
|
||||
</span>
|
||||
</Tooltip>
|
||||
</NoWrapTableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
{(!props.data || props.data.length === 0) && (
|
||||
<Box sx={{ p: 1, width: "100%", textAlign: "center" }}>
|
||||
<Typography variant={"caption"} color={"text.secondary"}>
|
||||
{t("application:setting.listEmpty")}
|
||||
</Typography>
|
||||
</Box>
|
||||
)}
|
||||
</TableContainer>
|
||||
);
|
||||
};
|
||||
|
||||
interface ApplicationProps {
|
||||
app: LockApplication;
|
||||
}
|
||||
|
||||
const ApplicationNameMap: {
|
||||
[key: string]: string;
|
||||
} = {
|
||||
rename: "application:fileManager.rename",
|
||||
moveCopy: "application:modals.moveCopy",
|
||||
upload: "application:modals.upload",
|
||||
updateMetadata: "application:modals.updateMetadata",
|
||||
delete: "application:fileManager.delete",
|
||||
softDelete: "application:fileManager.delete",
|
||||
dav: "application:modals.webdav",
|
||||
versionControl: "fileManager.manageVersions",
|
||||
};
|
||||
|
||||
const viewerType = "viewer";
|
||||
|
||||
const Application = ({ app }: ApplicationProps) => {
|
||||
const { t } = useTranslation();
|
||||
const title = ApplicationNameMap[app.type] ?? app.type;
|
||||
if (app.type == "viewer" && ViewersByID[app.viewer_id ?? ""]) {
|
||||
const viewer = ViewersByID[app.viewer_id ?? ""];
|
||||
if (viewer) {
|
||||
return (
|
||||
<Box sx={{ display: "flex", alignItems: "center" }}>
|
||||
<Box sx={{ mr: 1 }}>
|
||||
<ViewerIcon size={20} viewer={viewer} />
|
||||
</Box>
|
||||
{viewer?.display_name}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
}
|
||||
return <Box sx={{ display: "flex", alignItems: "center" }}>{t(title)}</Box>;
|
||||
};
|
||||
|
||||
const LockConflictDetails = () => {
|
||||
const { t } = useTranslation();
|
||||
const dispatch = useAppDispatch();
|
||||
|
||||
const open = useAppSelector((state) => state.globalState.lockConflictDialogOpen);
|
||||
const files = useAppSelector((state) => state.globalState.lockConflictFile);
|
||||
const error = useAppSelector((state) => state.globalState.lockConflictError);
|
||||
const promiseId = useAppSelector((state) => state.globalState.lockConflictPromiseId);
|
||||
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
const onClose = useCallback(() => {
|
||||
dispatch(closeLockConflictDialog());
|
||||
if (promiseId) {
|
||||
generalDialogPromisePool[promiseId]?.reject("cancel");
|
||||
}
|
||||
}, [dispatch, promiseId]);
|
||||
|
||||
const onRetry = useCallback(() => {
|
||||
if (promiseId) {
|
||||
dispatch(closeLockConflictDialog());
|
||||
generalDialogPromisePool[promiseId]?.resolve();
|
||||
}
|
||||
}, [promiseId]);
|
||||
|
||||
const showUnlockAll = useMemo(() => {
|
||||
if (error && error.data) {
|
||||
for (const conflict of error.data) {
|
||||
if (conflict.token) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}, [error]);
|
||||
|
||||
const forceUnlockByToken = useCallback(
|
||||
async (tokens: string[]) => {
|
||||
setLoading(true);
|
||||
try {
|
||||
await dispatch(forceUnlockFiles(tokens));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
},
|
||||
[dispatch, setLoading],
|
||||
);
|
||||
|
||||
const unlockAll = useCallback(async () => {
|
||||
const tokens = error?.data?.filter((c) => c.token).map((c) => c.token ?? "");
|
||||
if (tokens) {
|
||||
await forceUnlockByToken(tokens);
|
||||
}
|
||||
}, [forceUnlockByToken, error]);
|
||||
|
||||
return (
|
||||
<DraggableDialog
|
||||
title={t("application:modals.lockConflictTitle")}
|
||||
dialogProps={{
|
||||
open: open ?? false,
|
||||
onClose: onClose,
|
||||
fullWidth: true,
|
||||
maxWidth: "sm",
|
||||
}}
|
||||
>
|
||||
<DialogContent>
|
||||
<Stack spacing={2}>
|
||||
<StyledDialogContentText>{t("application:modals.lockConflictDescription")}</StyledDialogContentText>
|
||||
{files && error && error.data && (
|
||||
<ErrorTable unlock={forceUnlockByToken} loading={loading} data={error.data} files={files} />
|
||||
)}
|
||||
{showUnlockAll && (
|
||||
<Box>
|
||||
<Button onClick={unlockAll} disabled={loading} variant={"contained"}>
|
||||
{t("application:modals.forceUnlockAll")}
|
||||
</Button>
|
||||
</Box>
|
||||
)}
|
||||
</Stack>
|
||||
</DialogContent>
|
||||
<StyledDialogActions>
|
||||
<Button onClick={onClose}>{t("common:cancel")}</Button>
|
||||
<Button variant={"contained"} disabled={loading} onClick={onRetry}>
|
||||
{t("application:uploader.retry")}
|
||||
</Button>
|
||||
</StyledDialogActions>
|
||||
</DraggableDialog>
|
||||
);
|
||||
};
|
||||
|
||||
export default LockConflictDetails;
|
||||
231
src/component/FileManager/Dialogs/OpenWith.tsx
Executable file
231
src/component/FileManager/Dialogs/OpenWith.tsx
Executable file
@@ -0,0 +1,231 @@
|
||||
import {
|
||||
Avatar,
|
||||
Box,
|
||||
DialogContent,
|
||||
Divider,
|
||||
Grid,
|
||||
List,
|
||||
ListItem,
|
||||
ListItemAvatar,
|
||||
ListItemButton,
|
||||
ListItemText,
|
||||
Stack,
|
||||
} from "@mui/material";
|
||||
import React, { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Viewer, ViewerType } from "../../../api/explorer.ts";
|
||||
import { closeViewerSelector } from "../../../redux/globalStateSlice.ts";
|
||||
import { useAppDispatch, useAppSelector } from "../../../redux/hooks.ts";
|
||||
import { ViewersByID } from "../../../redux/siteConfigSlice.ts";
|
||||
import { builtInViewers, openViewer } from "../../../redux/thunks/viewer.ts";
|
||||
import SessionManager, { UserSettings } from "../../../session";
|
||||
import { fileExtension } from "../../../util";
|
||||
import AutoHeight from "../../Common/AutoHeight.tsx";
|
||||
import { SecondaryButton } from "../../Common/StyledComponents.tsx";
|
||||
import DraggableDialog, { StyledDialogContentText } from "../../Dialogs/DraggableDialog.tsx";
|
||||
import Book from "../../Icons/Book.tsx";
|
||||
import DocumentPDF from "../../Icons/DocumentPDF.tsx";
|
||||
import FolderZip from "../../Icons/FolderZip.tsx";
|
||||
import Image from "../../Icons/Image.tsx";
|
||||
import Markdown from "../../Icons/Markdown.tsx";
|
||||
import MoreHorizontal from "../../Icons/MoreHorizontal.tsx";
|
||||
import MusicNote1 from "../../Icons/MusicNote1.tsx";
|
||||
|
||||
export interface ViewerIconProps {
|
||||
viewer: Viewer;
|
||||
size?: number;
|
||||
py?: number;
|
||||
}
|
||||
|
||||
const emptyViewer: Viewer[] = [];
|
||||
|
||||
export const ViewerIDWithDefaultIcons = [
|
||||
builtInViewers.image,
|
||||
builtInViewers.pdf,
|
||||
builtInViewers.epub,
|
||||
builtInViewers.music,
|
||||
builtInViewers.markdown,
|
||||
builtInViewers.archive,
|
||||
];
|
||||
|
||||
export const ViewerIcon = ({ viewer, size = 32, py = 0.5 }: ViewerIconProps) => {
|
||||
const BuiltinIcons = useMemo(() => {
|
||||
if (viewer.icon) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
if (viewer.type == ViewerType.builtin) {
|
||||
switch (viewer.id) {
|
||||
case builtInViewers.image:
|
||||
return <Image sx={{ width: size, height: size, color: "#d32f2f" }} />;
|
||||
case builtInViewers.pdf:
|
||||
return <DocumentPDF sx={{ width: size, height: size, color: "#f44336" }} />;
|
||||
case builtInViewers.epub:
|
||||
return <Book sx={{ width: size, height: size, color: "#81b315" }} />;
|
||||
case builtInViewers.music:
|
||||
return <MusicNote1 sx={{ width: size, height: size, color: "#651fff" }} />;
|
||||
case builtInViewers.archive:
|
||||
return <FolderZip sx={{ width: size, height: size, color: "#f9a825" }} />;
|
||||
case builtInViewers.markdown:
|
||||
return (
|
||||
<Markdown
|
||||
sx={{
|
||||
width: size,
|
||||
height: size,
|
||||
color: (theme) => (theme.palette.mode == "dark" ? "#cbcbcb" : "#383838"),
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
}
|
||||
}, [viewer]);
|
||||
return (
|
||||
<Box sx={{ display: "flex", py }}>
|
||||
{BuiltinIcons && BuiltinIcons}
|
||||
{viewer.icon && (
|
||||
<Box
|
||||
component={"img"}
|
||||
src={viewer.icon}
|
||||
sx={{
|
||||
width: size,
|
||||
height: size,
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
const OpenWith = () => {
|
||||
const { t } = useTranslation();
|
||||
const dispatch = useAppDispatch();
|
||||
const [selectedViewer, setSelectedViewer] = React.useState<Viewer | null>(null);
|
||||
const [expanded, setExpanded] = useState(false);
|
||||
|
||||
const selectorState = useAppSelector((state) => state.globalState.viewerSelector);
|
||||
|
||||
useEffect(() => {
|
||||
if (selectorState?.open) {
|
||||
setExpanded(!selectorState.viewers);
|
||||
setSelectedViewer(null);
|
||||
}
|
||||
}, [selectorState]);
|
||||
|
||||
const ext = useMemo(() => {
|
||||
if (!selectorState?.file) {
|
||||
return "";
|
||||
}
|
||||
|
||||
return fileExtension(selectorState.file.name) ?? "";
|
||||
}, [selectorState?.file]);
|
||||
|
||||
const onClose = useCallback(() => {
|
||||
dispatch(closeViewerSelector());
|
||||
}, [dispatch]);
|
||||
|
||||
const openWith = (always: boolean, viewer?: Viewer) => {
|
||||
if (!selectorState || (!selectedViewer && !viewer)) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (always) {
|
||||
SessionManager.set(UserSettings.OpenWithPrefix + ext, viewer?.id ?? selectedViewer?.id);
|
||||
}
|
||||
|
||||
dispatch(
|
||||
openViewer(
|
||||
selectorState.file,
|
||||
viewer ?? (selectedViewer as Viewer),
|
||||
selectorState.entitySize,
|
||||
selectorState.version,
|
||||
),
|
||||
);
|
||||
dispatch(closeViewerSelector());
|
||||
};
|
||||
|
||||
const onViewerClick = (viewer: Viewer) => {
|
||||
if (selectorState?.viewers) {
|
||||
setSelectedViewer(viewer);
|
||||
} else {
|
||||
// For files without matching viewers, open the selected viewer without asking for preference
|
||||
openWith(false, viewer);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<DraggableDialog
|
||||
title={t("application:fileManager.openWith")}
|
||||
dialogProps={{
|
||||
open: !!(selectorState && selectorState.open),
|
||||
onClose: onClose,
|
||||
fullWidth: true,
|
||||
maxWidth: "xs",
|
||||
}}
|
||||
>
|
||||
<AutoHeight>
|
||||
<DialogContent sx={{ pb: selectedViewer ? 0 : 2 }}>
|
||||
<Stack spacing={2}>
|
||||
<StyledDialogContentText>
|
||||
{t("fileManager.openWithDescription", {
|
||||
ext,
|
||||
})}
|
||||
</StyledDialogContentText>
|
||||
</Stack>
|
||||
<List
|
||||
sx={{
|
||||
width: "100%",
|
||||
maxHeight: "calc(100vh - 400px)",
|
||||
overflow: "auto",
|
||||
}}
|
||||
>
|
||||
{((expanded ? Object.values(ViewersByID) : selectorState?.viewers) ?? emptyViewer).map((viewer) => (
|
||||
<ListItem
|
||||
disablePadding
|
||||
key={viewer.id}
|
||||
onDoubleClick={() => openWith(false, viewer)}
|
||||
onClick={() => onViewerClick(viewer)}
|
||||
>
|
||||
<ListItemButton selected={viewer.id == selectedViewer?.id}>
|
||||
<ListItemAvatar sx={{ minWidth: "48px" }}>
|
||||
<ViewerIcon viewer={viewer} />
|
||||
</ListItemAvatar>
|
||||
<ListItemText primary={t(viewer.display_name)} />
|
||||
</ListItemButton>
|
||||
</ListItem>
|
||||
))}
|
||||
{!expanded && (
|
||||
<ListItem onClick={() => setExpanded(true)} disablePadding>
|
||||
<ListItemButton>
|
||||
<ListItemAvatar sx={{ minWidth: "48px" }}>
|
||||
<Avatar sx={{ width: 32, height: 32 }}>
|
||||
<MoreHorizontal />
|
||||
</Avatar>
|
||||
</ListItemAvatar>
|
||||
<ListItemText primary={t("fileManager.expandAllApp")} />
|
||||
</ListItemButton>
|
||||
</ListItem>
|
||||
)}
|
||||
</List>
|
||||
</DialogContent>
|
||||
{!!selectedViewer && (
|
||||
<>
|
||||
<Divider />
|
||||
<Grid container spacing={2} sx={{ p: 2 }}>
|
||||
<Grid md={6} xs={12} item>
|
||||
<SecondaryButton fullWidth variant={"contained"} onClick={() => openWith(true)}>
|
||||
{t("modals.always")}
|
||||
</SecondaryButton>
|
||||
</Grid>
|
||||
<Grid md={6} xs={12} item>
|
||||
<SecondaryButton fullWidth variant={"contained"} onClick={() => openWith(false)}>
|
||||
{t("modals.justOnce")}
|
||||
</SecondaryButton>
|
||||
</Grid>
|
||||
</Grid>
|
||||
</>
|
||||
)}
|
||||
</AutoHeight>
|
||||
</DraggableDialog>
|
||||
);
|
||||
};
|
||||
export default OpenWith;
|
||||
198
src/component/FileManager/Dialogs/PathSelection.tsx
Executable file
198
src/component/FileManager/Dialogs/PathSelection.tsx
Executable file
@@ -0,0 +1,198 @@
|
||||
import { DialogContent, Typography, useMediaQuery, useTheme } from "@mui/material";
|
||||
import { useCallback, useMemo, useState } from "react";
|
||||
import { Trans, useTranslation } from "react-i18next";
|
||||
import { FileResponse, FileType } from "../../../api/explorer.ts";
|
||||
import { closePathSelectionDialog } from "../../../redux/globalStateSlice.ts";
|
||||
import { useAppDispatch, useAppSelector } from "../../../redux/hooks.ts";
|
||||
import { pathSelectionDialogPromisePool } from "../../../redux/thunks/dialog.ts";
|
||||
import CrUri, { Filesystem } from "../../../util/uri.ts";
|
||||
import DraggableDialog from "../../Dialogs/DraggableDialog.tsx";
|
||||
import FileBadge from "../FileBadge.tsx";
|
||||
import FolderPicker, { useFolderSelector } from "../FolderPicker.tsx";
|
||||
|
||||
export const PathSelectionVariantOptions = {
|
||||
copy: "copy",
|
||||
move: "move",
|
||||
shortcut: "shortcut",
|
||||
};
|
||||
|
||||
interface SelectedFolderIndicatorProps {
|
||||
selectedFile?: FileResponse;
|
||||
selectedPath?: string;
|
||||
variant: PathSelectionVariant;
|
||||
}
|
||||
|
||||
interface PathSelectionVariant {
|
||||
indicator: string;
|
||||
title: string;
|
||||
disableSharedWithMe?: boolean;
|
||||
disableTrash?: boolean;
|
||||
}
|
||||
|
||||
export const PathSelectionVariants: Record<string, PathSelectionVariant> = {
|
||||
copy: {
|
||||
indicator: "fileManager.copyToDst",
|
||||
title: "application:fileManager.copyTo",
|
||||
disableSharedWithMe: true,
|
||||
disableTrash: true,
|
||||
},
|
||||
move: {
|
||||
indicator: "fileManager.moveToDst",
|
||||
title: "application:fileManager.moveTo",
|
||||
disableSharedWithMe: true,
|
||||
disableTrash: true,
|
||||
},
|
||||
shortcut: {
|
||||
indicator: "application:modals.createShortcutTo",
|
||||
title: "application:modals.createShortcut",
|
||||
disableSharedWithMe: true,
|
||||
disableTrash: true,
|
||||
},
|
||||
saveAs: {
|
||||
indicator: "application:modals.saveToTitleDescription",
|
||||
title: "application:modals.saveAs",
|
||||
disableSharedWithMe: true,
|
||||
disableTrash: true,
|
||||
},
|
||||
saveTo: {
|
||||
indicator: "application:modals.saveToTitleDescription",
|
||||
title: "application:modals.saveToTitle",
|
||||
disableSharedWithMe: true,
|
||||
disableTrash: true,
|
||||
},
|
||||
extractTo: {
|
||||
indicator: "application:modals.decompressToDst",
|
||||
title: "application:modals.decompressTo",
|
||||
disableSharedWithMe: true,
|
||||
disableTrash: true,
|
||||
},
|
||||
downloadTo: {
|
||||
indicator: "application:modals.downloadToDst",
|
||||
title: "application:modals.downloadTo",
|
||||
disableSharedWithMe: true,
|
||||
disableTrash: true,
|
||||
},
|
||||
searchIn: {
|
||||
indicator: "application:navbar.searchInBase",
|
||||
title: "application:navbar.searchBase",
|
||||
},
|
||||
davAccountRoot: {
|
||||
indicator: "application:setting.rootFolderIn",
|
||||
title: "application:setting.rootFolder",
|
||||
disableSharedWithMe: true,
|
||||
disableTrash: true,
|
||||
},
|
||||
};
|
||||
|
||||
export const SelectedFolderIndicator = ({ selectedFile, selectedPath, variant }: SelectedFolderIndicatorProps) => {
|
||||
const theme = useTheme();
|
||||
const isMobile = useMediaQuery(theme.breakpoints.down("sm"));
|
||||
if (!selectedFile && !selectedPath) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const badge = (
|
||||
<FileBadge
|
||||
file={selectedFile}
|
||||
variant={"outlined"}
|
||||
sx={{ mx: 1, maxWidth: isMobile ? "150px" : "initial" }}
|
||||
simplifiedFile={
|
||||
selectedPath
|
||||
? {
|
||||
path: selectedPath,
|
||||
type: FileType.folder,
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
);
|
||||
return (
|
||||
<Typography variant={"body2"} color={"text.secondary"}>
|
||||
{isMobile ? badge : <Trans i18nKey={variant.indicator} ns={"application"} components={[badge]} />}
|
||||
</Typography>
|
||||
);
|
||||
};
|
||||
|
||||
const PathSelection = () => {
|
||||
const { t } = useTranslation();
|
||||
const dispatch = useAppDispatch();
|
||||
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
const open = useAppSelector((state) => state.globalState.pathSelectDialogOpen);
|
||||
const variant = useAppSelector((state) => state.globalState.pathSelectDialogVariant);
|
||||
const promiseId = useAppSelector((state) => state.globalState.pathSelectPromiseId);
|
||||
const initialPath = useAppSelector((state) => state.globalState.pathSelectInitialPath);
|
||||
|
||||
const variantProps = useMemo(
|
||||
() => (variant ? PathSelectionVariants[variant] : PathSelectionVariants["copy"]),
|
||||
[variant],
|
||||
);
|
||||
|
||||
const [selectedFile, selectedPath] = useFolderSelector();
|
||||
|
||||
const onClose = useCallback(() => {
|
||||
dispatch(closePathSelectionDialog());
|
||||
if (promiseId) {
|
||||
pathSelectionDialogPromisePool[promiseId]?.reject("cancel");
|
||||
}
|
||||
}, [dispatch, promiseId]);
|
||||
|
||||
const onAccept = useCallback(async () => {
|
||||
const dst = selectedPath;
|
||||
|
||||
dispatch(closePathSelectionDialog());
|
||||
if (promiseId && dst) {
|
||||
pathSelectionDialogPromisePool[promiseId]?.resolve(dst);
|
||||
}
|
||||
}, [dispatch, selectedPath, promiseId]);
|
||||
|
||||
const disabled = useMemo(() => {
|
||||
const dst = selectedPath;
|
||||
if (dst) {
|
||||
const crUri = new CrUri(dst);
|
||||
if (variantProps.disableSharedWithMe && crUri.fs() == Filesystem.shared_with_me) {
|
||||
return true;
|
||||
}
|
||||
if (variantProps.disableTrash && crUri.fs() == Filesystem.trash) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return !selectedPath;
|
||||
}, [selectedPath, variantProps]);
|
||||
|
||||
return (
|
||||
<DraggableDialog
|
||||
title={t(variantProps.title)}
|
||||
showActions
|
||||
loading={loading}
|
||||
disabled={disabled}
|
||||
secondaryAction={
|
||||
<SelectedFolderIndicator variant={variantProps} selectedFile={selectedFile} selectedPath={selectedPath} />
|
||||
}
|
||||
onAccept={onAccept}
|
||||
dialogProps={{
|
||||
open: open ?? false,
|
||||
onClose: onClose,
|
||||
fullWidth: true,
|
||||
maxWidth: "lg",
|
||||
disableRestoreFocus: true,
|
||||
PaperProps: {
|
||||
sx: {
|
||||
height: "100%",
|
||||
},
|
||||
},
|
||||
}}
|
||||
>
|
||||
<DialogContent sx={{ display: "flex", pb: 0 }}>
|
||||
<FolderPicker
|
||||
disableSharedWithMe={variantProps.disableSharedWithMe}
|
||||
disableTrash={variantProps.disableTrash}
|
||||
initialPath={initialPath}
|
||||
/>
|
||||
</DialogContent>
|
||||
</DraggableDialog>
|
||||
);
|
||||
};
|
||||
export default PathSelection;
|
||||
92
src/component/FileManager/Dialogs/PinToSidebar.tsx
Executable file
92
src/component/FileManager/Dialogs/PinToSidebar.tsx
Executable file
@@ -0,0 +1,92 @@
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { DialogContent } from "@mui/material";
|
||||
import { useAppDispatch, useAppSelector } from "../../../redux/hooks.ts";
|
||||
import { ChangeEvent, useCallback, useEffect, useState } from "react";
|
||||
import DraggableDialog from "../../Dialogs/DraggableDialog.tsx";
|
||||
import { closePinFileDialog } from "../../../redux/globalStateSlice.ts";
|
||||
import { pinToSidebar } from "../../../redux/thunks/settings.ts";
|
||||
import { FilledTextField } from "../../Common/StyledComponents.tsx";
|
||||
|
||||
const PinToSidebar = () => {
|
||||
const { t } = useTranslation();
|
||||
const dispatch = useAppDispatch();
|
||||
|
||||
const [name, setName] = useState("");
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
const open = useAppSelector((state) => state.globalState.pinFileDialogOpen);
|
||||
const uri = useAppSelector((state) => state.globalState.pinFileUri);
|
||||
|
||||
const onClose = useCallback(() => {
|
||||
if (!loading) {
|
||||
dispatch(closePinFileDialog());
|
||||
}
|
||||
}, [dispatch, loading]);
|
||||
|
||||
const onAccept = useCallback(
|
||||
async (e?: React.FormEvent<HTMLFormElement>) => {
|
||||
if (e) {
|
||||
e.preventDefault();
|
||||
}
|
||||
|
||||
if (!uri) {
|
||||
return;
|
||||
}
|
||||
|
||||
setLoading(true);
|
||||
try {
|
||||
await dispatch(pinToSidebar(uri, name));
|
||||
} catch (e) {
|
||||
} finally {
|
||||
setLoading(false);
|
||||
dispatch(closePinFileDialog());
|
||||
}
|
||||
},
|
||||
[name, dispatch, uri, setLoading],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (uri && open) {
|
||||
setName("");
|
||||
}
|
||||
}, [uri]);
|
||||
|
||||
const onNameChange = useCallback(
|
||||
(e: ChangeEvent<HTMLInputElement | HTMLTextAreaElement>) => {
|
||||
setName(e.target.value);
|
||||
},
|
||||
[dispatch, setName],
|
||||
);
|
||||
|
||||
return (
|
||||
<DraggableDialog
|
||||
title={t("application:fileManager.pin")}
|
||||
showActions
|
||||
loading={loading}
|
||||
showCancel
|
||||
onAccept={onAccept}
|
||||
dialogProps={{
|
||||
open: open ?? false,
|
||||
onClose: onClose,
|
||||
fullWidth: true,
|
||||
maxWidth: "xs",
|
||||
}}
|
||||
>
|
||||
<DialogContent>
|
||||
<FilledTextField
|
||||
sx={{ mt: 2 }}
|
||||
variant="filled"
|
||||
autoFocus
|
||||
helperText={t("application:fileManager.optional")}
|
||||
margin="dense"
|
||||
label={t("application:fileManager.pinAlias")}
|
||||
type="text"
|
||||
value={name}
|
||||
onChange={onNameChange}
|
||||
fullWidth
|
||||
/>
|
||||
</DialogContent>
|
||||
</DraggableDialog>
|
||||
);
|
||||
};
|
||||
export default PinToSidebar;
|
||||
133
src/component/FileManager/Dialogs/Rename.tsx
Executable file
133
src/component/FileManager/Dialogs/Rename.tsx
Executable file
@@ -0,0 +1,133 @@
|
||||
import { Trans, useTranslation } from "react-i18next";
|
||||
import { DialogContent, Stack } from "@mui/material";
|
||||
import { useAppDispatch, useAppSelector } from "../../../redux/hooks.ts";
|
||||
import { ChangeEvent, useCallback, useContext, useEffect, useRef, useState } from "react";
|
||||
import { closeRenameFileModal, setRenameFileModalError } from "../../../redux/fileManagerSlice.ts";
|
||||
import DraggableDialog, { StyledDialogContentText } from "../../Dialogs/DraggableDialog.tsx";
|
||||
import { renameDialogPromisePool } from "../../../redux/thunks/dialog.ts";
|
||||
import { validateFileName } from "../../../redux/thunks/file.ts";
|
||||
import { FileType } from "../../../api/explorer.ts";
|
||||
|
||||
import { FmIndexContext } from "../FmIndexContext.tsx";
|
||||
import { FilledTextField } from "../../Common/StyledComponents.tsx";
|
||||
|
||||
const Rename = () => {
|
||||
const { t } = useTranslation();
|
||||
const dispatch = useAppDispatch();
|
||||
|
||||
const [name, setName] = useState("");
|
||||
const formRef = useRef<HTMLFormElement>(null);
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
const fmIndex = useContext(FmIndexContext);
|
||||
|
||||
const open = useAppSelector((state) => state.fileManager[0].renameFileModalOpen);
|
||||
const targets = useAppSelector((state) => state.fileManager[0].renameFileModalSelected);
|
||||
const promiseId = useAppSelector((state) => state.fileManager[0].renameFileModalPromiseId);
|
||||
const loading = useAppSelector((state) => state.fileManager[0].renameFileModalLoading);
|
||||
const error = useAppSelector((state) => state.fileManager[0].renameFileModalError);
|
||||
|
||||
const onClose = useCallback(() => {
|
||||
dispatch(
|
||||
closeRenameFileModal({
|
||||
index: 0,
|
||||
value: undefined,
|
||||
}),
|
||||
);
|
||||
if (promiseId) {
|
||||
renameDialogPromisePool[promiseId]?.reject("cancel");
|
||||
}
|
||||
}, [dispatch, targets, promiseId]);
|
||||
|
||||
const onAccept = useCallback(
|
||||
(e?: React.FormEvent<HTMLFormElement>) => {
|
||||
if (e) {
|
||||
e.preventDefault();
|
||||
}
|
||||
if (promiseId) {
|
||||
dispatch(validateFileName(0, renameDialogPromisePool[promiseId]?.resolve, name));
|
||||
}
|
||||
},
|
||||
[promiseId, name],
|
||||
);
|
||||
|
||||
const onOkClicked = useCallback(() => {
|
||||
if (formRef.current) {
|
||||
if (formRef.current.reportValidity()) {
|
||||
onAccept();
|
||||
}
|
||||
}
|
||||
}, [formRef, onAccept]);
|
||||
|
||||
useEffect(() => {
|
||||
if (targets && open) {
|
||||
setName(targets.name);
|
||||
}
|
||||
}, [targets, open]);
|
||||
|
||||
useEffect(() => {
|
||||
if (targets && open && inputRef.current) {
|
||||
const lastDot = targets.type == FileType.folder ? 0 : targets.name.lastIndexOf(".");
|
||||
inputRef.current.setSelectionRange(0, lastDot > 0 ? lastDot : targets.name.length);
|
||||
}
|
||||
}, [inputRef.current, open]);
|
||||
|
||||
const onNameChange = useCallback(
|
||||
(e: ChangeEvent<HTMLInputElement | HTMLTextAreaElement>) => {
|
||||
setName(e.target.value);
|
||||
if (error) {
|
||||
dispatch(setRenameFileModalError({ index: 0, value: undefined }));
|
||||
}
|
||||
},
|
||||
[dispatch, setName, error],
|
||||
);
|
||||
|
||||
return (
|
||||
<DraggableDialog
|
||||
title={t("application:fileManager.rename")}
|
||||
showActions
|
||||
loading={loading}
|
||||
showCancel
|
||||
onAccept={onOkClicked}
|
||||
dialogProps={{
|
||||
open: open ?? false,
|
||||
onClose: onClose,
|
||||
fullWidth: true,
|
||||
maxWidth: "sm",
|
||||
disableRestoreFocus: true,
|
||||
}}
|
||||
>
|
||||
<DialogContent>
|
||||
<Stack spacing={2}>
|
||||
<StyledDialogContentText>
|
||||
<Trans
|
||||
i18nKey="modals.renameDescription"
|
||||
ns={"application"}
|
||||
values={{
|
||||
name: targets?.name,
|
||||
}}
|
||||
components={[<strong key={0} />]}
|
||||
/>
|
||||
</StyledDialogContentText>
|
||||
<form ref={formRef} onSubmit={onAccept}>
|
||||
<FilledTextField
|
||||
inputRef={inputRef}
|
||||
variant="filled"
|
||||
autoFocus
|
||||
error={!!error}
|
||||
helperText={error}
|
||||
margin="dense"
|
||||
label={t("application:modals.newName")}
|
||||
type="text"
|
||||
value={name}
|
||||
onChange={onNameChange}
|
||||
fullWidth
|
||||
required
|
||||
/>
|
||||
</form>
|
||||
</Stack>
|
||||
</DialogContent>
|
||||
</DraggableDialog>
|
||||
);
|
||||
};
|
||||
export default Rename;
|
||||
94
src/component/FileManager/Dialogs/SaveAs.tsx
Executable file
94
src/component/FileManager/Dialogs/SaveAs.tsx
Executable file
@@ -0,0 +1,94 @@
|
||||
import { Box, DialogContent, Divider } from "@mui/material";
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { closeSaveAsDialog } from "../../../redux/globalStateSlice.ts";
|
||||
import { useAppDispatch, useAppSelector } from "../../../redux/hooks.ts";
|
||||
import { saveAsDialogPromisePool } from "../../../redux/thunks/dialog.ts";
|
||||
import { FilledTextField } from "../../Common/StyledComponents.tsx";
|
||||
import DraggableDialog from "../../Dialogs/DraggableDialog.tsx";
|
||||
import FolderPicker, { useFolderSelector } from "../FolderPicker.tsx";
|
||||
|
||||
const SaveAs = () => {
|
||||
const { t } = useTranslation();
|
||||
const dispatch = useAppDispatch();
|
||||
|
||||
const [selectedFile, selectedPath] = useFolderSelector();
|
||||
const open = useAppSelector((state) => state.globalState.saveAsDialogOpen);
|
||||
const initialName = useAppSelector((state) => state.globalState.saveAsInitialName);
|
||||
const promiseId = useAppSelector((state) => state.globalState.saveAsPromiseId);
|
||||
const [name, setName] = useState("");
|
||||
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
setName(initialName ?? "");
|
||||
}
|
||||
}, [open]);
|
||||
|
||||
const onClose = useCallback(() => {
|
||||
dispatch(closeSaveAsDialog());
|
||||
if (promiseId) {
|
||||
saveAsDialogPromisePool[promiseId]?.reject("cancel");
|
||||
}
|
||||
}, [dispatch, promiseId]);
|
||||
|
||||
const onAccept = useCallback(
|
||||
(e?: React.FormEvent<HTMLFormElement>) => {
|
||||
if (e) {
|
||||
e.preventDefault();
|
||||
}
|
||||
|
||||
const dst = selectedFile && selectedFile.path ? selectedFile.path : selectedPath;
|
||||
dispatch(closeSaveAsDialog());
|
||||
if (promiseId && dst) {
|
||||
saveAsDialogPromisePool[promiseId]?.resolve({
|
||||
uri: dst,
|
||||
name: name,
|
||||
});
|
||||
}
|
||||
},
|
||||
[promiseId, selectedFile, name, selectedPath],
|
||||
);
|
||||
|
||||
return (
|
||||
<DraggableDialog
|
||||
title={t("application:modals.saveAs")}
|
||||
showActions
|
||||
secondaryFullWidth
|
||||
onAccept={onAccept}
|
||||
secondaryAction={
|
||||
<Box sx={{ display: "flex", alignItems: "flex-end" }}>
|
||||
<FilledTextField
|
||||
variant="filled"
|
||||
autoFocus
|
||||
margin="dense"
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
label={t("modals.fileName")}
|
||||
type="text"
|
||||
value={name}
|
||||
fullWidth
|
||||
required
|
||||
/>
|
||||
</Box>
|
||||
}
|
||||
denseAction
|
||||
dialogProps={{
|
||||
open: open ?? false,
|
||||
onClose: onClose,
|
||||
fullWidth: true,
|
||||
maxWidth: "lg",
|
||||
disableRestoreFocus: true,
|
||||
PaperProps: {
|
||||
sx: {
|
||||
height: "100%",
|
||||
},
|
||||
},
|
||||
}}
|
||||
>
|
||||
<DialogContent sx={{ display: "flex" }}>
|
||||
<FolderPicker disableSharedWithMe={true} disableTrash={true} />
|
||||
</DialogContent>
|
||||
<Divider />
|
||||
</DraggableDialog>
|
||||
);
|
||||
};
|
||||
export default SaveAs;
|
||||
238
src/component/FileManager/Dialogs/Share/ManageShares.tsx
Executable file
238
src/component/FileManager/Dialogs/Share/ManageShares.tsx
Executable file
@@ -0,0 +1,238 @@
|
||||
import {
|
||||
Box,
|
||||
DialogContent,
|
||||
IconButton,
|
||||
ListItemText,
|
||||
Menu,
|
||||
Skeleton,
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableContainer,
|
||||
TableHead,
|
||||
TableRow,
|
||||
Typography,
|
||||
} from "@mui/material";
|
||||
import React, { useCallback, useEffect, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { getFileInfo, sendDeleteShare } from "../../../../api/api.ts";
|
||||
import { FileResponse, Share } from "../../../../api/explorer.ts";
|
||||
import { closeManageShareDialog, setShareLinkDialog } from "../../../../redux/globalStateSlice.ts";
|
||||
import { useAppDispatch, useAppSelector } from "../../../../redux/hooks.ts";
|
||||
import { confirmOperation } from "../../../../redux/thunks/dialog.ts";
|
||||
import AutoHeight from "../../../Common/AutoHeight.tsx";
|
||||
import { NoWrapTableCell, StyledTableContainerPaper } from "../../../Common/StyledComponents.tsx";
|
||||
import TimeBadge from "../../../Common/TimeBadge.tsx";
|
||||
import DraggableDialog from "../../../Dialogs/DraggableDialog.tsx";
|
||||
import MoreVertical from "../../../Icons/MoreVertical.tsx";
|
||||
import { SquareMenuItem } from "../../ContextMenu/ContextMenu.tsx";
|
||||
import { ShareExpires, ShareStatistics } from "../../TopBar/ShareInfoPopover.tsx";
|
||||
|
||||
const ManageShares = () => {
|
||||
const { t } = useTranslation();
|
||||
const dispatch = useAppDispatch();
|
||||
|
||||
const [anchorEl, setAnchorEl] = useState<null | HTMLElement>(null);
|
||||
const [actionTarget, setActionTarget] = useState<Share | null>(null);
|
||||
const [fileExtended, setFileExtended] = useState<FileResponse | undefined>(undefined);
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
const open = useAppSelector((state) => state.globalState.manageShareDialogOpen);
|
||||
const target = useAppSelector((state) => state.globalState.manageShareDialogFile);
|
||||
|
||||
const onClose = useCallback(() => {
|
||||
if (!loading) {
|
||||
dispatch(closeManageShareDialog());
|
||||
}
|
||||
}, [dispatch, loading]);
|
||||
|
||||
useEffect(() => {
|
||||
if (target && open) {
|
||||
if (target.extended_info) {
|
||||
setFileExtended(target);
|
||||
} else {
|
||||
setFileExtended(undefined);
|
||||
dispatch(
|
||||
getFileInfo({
|
||||
uri: target.path,
|
||||
extended: true,
|
||||
}),
|
||||
).then((res) => setFileExtended(res));
|
||||
}
|
||||
}
|
||||
}, [target, open]);
|
||||
|
||||
const handleActionClose = () => {
|
||||
setAnchorEl(null);
|
||||
};
|
||||
|
||||
const handleOpenAction = (event: React.MouseEvent<HTMLElement>, element: Share) => {
|
||||
event.stopPropagation();
|
||||
setAnchorEl(event.currentTarget);
|
||||
setActionTarget(element);
|
||||
};
|
||||
|
||||
const openEditDialog = () => {
|
||||
dispatch(
|
||||
setShareLinkDialog({
|
||||
open: true,
|
||||
file: target,
|
||||
share: actionTarget ?? undefined,
|
||||
}),
|
||||
);
|
||||
setAnchorEl(null);
|
||||
};
|
||||
|
||||
const openLink = useCallback((s: Share) => {
|
||||
window.open(s.url, "_blank");
|
||||
}, []);
|
||||
|
||||
const deleteShare = useCallback(() => {
|
||||
if (!target || !actionTarget) {
|
||||
return;
|
||||
}
|
||||
|
||||
dispatch(confirmOperation(t("fileManager.deleteShareWarning"))).then(() => {
|
||||
setLoading(true);
|
||||
dispatch(sendDeleteShare(actionTarget.id))
|
||||
.then(() => {
|
||||
setFileExtended((prev) =>
|
||||
prev
|
||||
? {
|
||||
...prev,
|
||||
extended_info: prev.extended_info
|
||||
? {
|
||||
...prev.extended_info,
|
||||
shares: prev.extended_info.shares?.filter((e) => e.id !== actionTarget.id),
|
||||
}
|
||||
: undefined,
|
||||
}
|
||||
: undefined,
|
||||
);
|
||||
})
|
||||
.finally(() => {
|
||||
setLoading(false);
|
||||
});
|
||||
});
|
||||
setAnchorEl(null);
|
||||
}, [t, target, actionTarget, setLoading, dispatch]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<Menu
|
||||
anchorEl={anchorEl}
|
||||
open={Boolean(anchorEl)}
|
||||
onClose={handleActionClose}
|
||||
slotProps={{
|
||||
paper: {
|
||||
sx: {
|
||||
minWidth: 150,
|
||||
},
|
||||
},
|
||||
}}
|
||||
>
|
||||
<SquareMenuItem dense>
|
||||
<ListItemText onClick={openEditDialog}>
|
||||
{t(`fileManager.${actionTarget?.expired ? "editAndReactivate" : "edit"}`)}
|
||||
</ListItemText>
|
||||
</SquareMenuItem>
|
||||
<SquareMenuItem dense>
|
||||
<ListItemText onClick={deleteShare}>{t(`fileManager.delete`)}</ListItemText>
|
||||
</SquareMenuItem>
|
||||
</Menu>
|
||||
<DraggableDialog
|
||||
title={t("application:fileManager.manageShares")}
|
||||
loading={loading}
|
||||
dialogProps={{
|
||||
open: open ?? false,
|
||||
onClose: onClose,
|
||||
fullWidth: true,
|
||||
maxWidth: "md",
|
||||
}}
|
||||
>
|
||||
<DialogContent>
|
||||
<AutoHeight>
|
||||
<TableContainer component={StyledTableContainerPaper}>
|
||||
<Table sx={{ width: "100%" }} size="small">
|
||||
<TableHead>
|
||||
<TableRow>
|
||||
<NoWrapTableCell>{t("fileManager.actions")}</NoWrapTableCell>
|
||||
<NoWrapTableCell>{t("fileManager.createdAt")}</NoWrapTableCell>
|
||||
<NoWrapTableCell>{t("fileManager.expires")}</NoWrapTableCell>
|
||||
<NoWrapTableCell>{t("application:share.statistics")}</NoWrapTableCell>
|
||||
<NoWrapTableCell>{t("modals.privateShare")}</NoWrapTableCell>
|
||||
</TableRow>
|
||||
</TableHead>
|
||||
<TableBody>
|
||||
{!fileExtended && (
|
||||
<TableRow
|
||||
hover
|
||||
sx={{
|
||||
"&:last-child td, &:last-child th": { border: 0 },
|
||||
}}
|
||||
>
|
||||
<NoWrapTableCell component="th" scope="row">
|
||||
<Skeleton variant={"text"} width={100} />
|
||||
</NoWrapTableCell>
|
||||
<NoWrapTableCell>
|
||||
<Skeleton variant={"text"} width={30} />
|
||||
</NoWrapTableCell>
|
||||
</TableRow>
|
||||
)}
|
||||
{fileExtended?.extended_info?.shares &&
|
||||
fileExtended?.extended_info?.shares.map((e) => (
|
||||
<TableRow
|
||||
sx={{
|
||||
"&:last-child td, &:last-child th": { border: 0 },
|
||||
cursor: "pointer",
|
||||
td: {
|
||||
color: (theme) => (e.expired ? theme.palette.text.disabled : undefined),
|
||||
},
|
||||
}}
|
||||
onClick={() => openLink(e)}
|
||||
hover
|
||||
>
|
||||
<NoWrapTableCell component="th" scope="row">
|
||||
<IconButton disabled={loading} onClick={(event) => handleOpenAction(event, e)} size={"small"}>
|
||||
<MoreVertical fontSize={"small"} />
|
||||
</IconButton>
|
||||
</NoWrapTableCell>
|
||||
<NoWrapTableCell>
|
||||
<TimeBadge variant={"body2"} datetime={e.created_at ?? ""} />
|
||||
</NoWrapTableCell>
|
||||
<TableCell>
|
||||
{e.expired ? (
|
||||
t("application:share.expired")
|
||||
) : (
|
||||
<>
|
||||
{e.remain_downloads != undefined || e.expires ? (
|
||||
<ShareExpires expires={e.expires} remain_downloads={e.remain_downloads} />
|
||||
) : (
|
||||
t("application:fileManager.permanentValid")
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<ShareStatistics shareInfo={e} />
|
||||
</TableCell>
|
||||
<NoWrapTableCell>{t(`fileManager.${e.is_private ? "yes" : "no"}`)}</NoWrapTableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
{fileExtended && !fileExtended?.extended_info?.shares && (
|
||||
<Box sx={{ p: 1, width: "100%", textAlign: "center" }}>
|
||||
<Typography variant={"caption"} color={"text.secondary"}>
|
||||
{t("application:setting.listEmpty")}
|
||||
</Typography>
|
||||
</Box>
|
||||
)}
|
||||
</TableContainer>
|
||||
</AutoHeight>
|
||||
</DialogContent>
|
||||
</DraggableDialog>
|
||||
</>
|
||||
);
|
||||
};
|
||||
export default ManageShares;
|
||||
278
src/component/FileManager/Dialogs/Share/ShareDialog.tsx
Executable file
278
src/component/FileManager/Dialogs/Share/ShareDialog.tsx
Executable file
@@ -0,0 +1,278 @@
|
||||
import { Box, Checkbox, Collapse, DialogContent, IconButton, Stack, Tooltip, useTheme } from "@mui/material";
|
||||
import dayjs from "dayjs";
|
||||
import { TFunction } from "i18next";
|
||||
import React, { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { CSSTransition, SwitchTransition } from "react-transition-group";
|
||||
import { Share as ShareModel } from "../../../../api/explorer.ts";
|
||||
import { closeShareLinkDialog } from "../../../../redux/globalStateSlice.ts";
|
||||
import { useAppDispatch, useAppSelector } from "../../../../redux/hooks.ts";
|
||||
import { createOrUpdateShareLink } from "../../../../redux/thunks/share.ts";
|
||||
import { copyToClipboard, sendLink } from "../../../../util";
|
||||
import AutoHeight from "../../../Common/AutoHeight.tsx";
|
||||
import { FilledTextField, SmallFormControlLabel } from "../../../Common/StyledComponents.tsx";
|
||||
import DraggableDialog from "../../../Dialogs/DraggableDialog.tsx";
|
||||
import CopyOutlined from "../../../Icons/CopyOutlined.tsx";
|
||||
import Share from "../../../Icons/Share.tsx";
|
||||
import { FileManagerIndex } from "../../FileManager.tsx";
|
||||
import ShareSettingContent, { downloadOptions, expireOptions, ShareSetting } from "./ShareSetting.tsx";
|
||||
|
||||
const initialSetting: ShareSetting = {
|
||||
expires_val: expireOptions[2],
|
||||
downloads_val: downloadOptions[0],
|
||||
};
|
||||
|
||||
interface ShareLinkPassword {
|
||||
shareLink: string;
|
||||
password?: string;
|
||||
}
|
||||
|
||||
const shareToSetting = (share: ShareModel, t: TFunction): ShareSetting => {
|
||||
const res: ShareSetting = {
|
||||
is_private: share.is_private,
|
||||
password: share.password,
|
||||
use_custom_password: true,
|
||||
share_view: share.share_view,
|
||||
show_readme: share.show_readme,
|
||||
downloads: share.remain_downloads != undefined && share.remain_downloads > 0,
|
||||
|
||||
expires_val: expireOptions[2],
|
||||
downloads_val: downloadOptions[0],
|
||||
};
|
||||
|
||||
if (res.downloads) {
|
||||
res.downloads_val = {
|
||||
value: share.remain_downloads ?? 0,
|
||||
label: (share.remain_downloads ?? 0).toString(),
|
||||
};
|
||||
}
|
||||
|
||||
if (share.expires != undefined) {
|
||||
const expires = dayjs(share.expires);
|
||||
const isExpired = expires.isBefore(dayjs());
|
||||
if (!isExpired) {
|
||||
res.expires = true;
|
||||
const secondsTtl = dayjs(share.expires).diff(dayjs(), "second");
|
||||
res.expires_val = {
|
||||
value: secondsTtl,
|
||||
label: Math.round(secondsTtl / 60) + " " + t("application:modals.minutes"),
|
||||
};
|
||||
} else {
|
||||
res.expires = false;
|
||||
}
|
||||
}
|
||||
|
||||
return res;
|
||||
};
|
||||
|
||||
const ShareDialog = () => {
|
||||
const { t } = useTranslation();
|
||||
const dispatch = useAppDispatch();
|
||||
const theme = useTheme();
|
||||
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [setting, setSetting] = useState<ShareSetting>(initialSetting);
|
||||
const [shareLink, setShareLink] = useState<string>("");
|
||||
const [includePassword, setIncludePassword] = useState(true);
|
||||
const shareLinkPassword = useMemo(() => {
|
||||
const start = shareLink.lastIndexOf("/s/");
|
||||
const shareLinkParts = shareLink.substring(start + 3).split("/");
|
||||
const password = shareLinkParts.length == 2 ? shareLinkParts[1] : undefined;
|
||||
return {
|
||||
shareLink: password ? shareLink.substring(0, shareLink.lastIndexOf("/")) : shareLink,
|
||||
password: password,
|
||||
} as ShareLinkPassword;
|
||||
}, [shareLink]);
|
||||
|
||||
const open = useAppSelector((state) => state.globalState.shareLinkDialogOpen);
|
||||
const target = useAppSelector((state) => state.globalState.shareLinkDialogFile);
|
||||
const editTarget = useAppSelector((state) => state.globalState.shareLinkDialogShare);
|
||||
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
if (editTarget) {
|
||||
setSetting(shareToSetting(editTarget, t));
|
||||
} else {
|
||||
setSetting(initialSetting);
|
||||
}
|
||||
setShareLink("");
|
||||
setIncludePassword(true);
|
||||
}
|
||||
}, [open]);
|
||||
|
||||
const onClose = useCallback(() => {
|
||||
if (!loading) {
|
||||
dispatch(closeShareLinkDialog());
|
||||
}
|
||||
}, [dispatch, loading]);
|
||||
|
||||
const onAccept = useCallback(
|
||||
async (e?: React.MouseEvent<HTMLElement>) => {
|
||||
if (e) {
|
||||
e.preventDefault();
|
||||
}
|
||||
|
||||
if (!target) return;
|
||||
|
||||
if (shareLink) {
|
||||
copyToClipboard(shareLink);
|
||||
return;
|
||||
}
|
||||
|
||||
setLoading(true);
|
||||
try {
|
||||
const shareLink = await dispatch(
|
||||
createOrUpdateShareLink(FileManagerIndex.main, target, setting, editTarget?.id),
|
||||
);
|
||||
setShareLink(shareLink);
|
||||
} catch (e) {
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
},
|
||||
[dispatch, target, shareLink, editTarget, setLoading, setting, setShareLink],
|
||||
);
|
||||
|
||||
const finalShareLink = useMemo(() => {
|
||||
if (includePassword) {
|
||||
return shareLink;
|
||||
}
|
||||
return shareLink.substring(0, shareLink.lastIndexOf("/"));
|
||||
}, [includePassword, shareLink]);
|
||||
|
||||
const finalShareLinkPassword = useMemo(() => {
|
||||
if (!includePassword) {
|
||||
return shareLink.substring(shareLink.lastIndexOf("/") + 1);
|
||||
}
|
||||
return undefined;
|
||||
}, [includePassword, shareLink]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<DraggableDialog
|
||||
title={t(`application:modals.${editTarget ? "edit" : "create"}ShareLink`)}
|
||||
showActions
|
||||
loading={loading}
|
||||
showCancel
|
||||
hideOk={!!shareLink}
|
||||
onAccept={onAccept}
|
||||
dialogProps={{
|
||||
open: open ?? false,
|
||||
onClose: onClose,
|
||||
fullWidth: true,
|
||||
maxWidth: "xs",
|
||||
}}
|
||||
cancelText={shareLink ? t("common:close") : undefined}
|
||||
secondaryAction={
|
||||
shareLink
|
||||
? // @ts-ignore
|
||||
navigator.share && (
|
||||
<Tooltip title={t("application:modals.sendLink")}>
|
||||
<IconButton onClick={() => sendLink(target?.name ?? "", finalShareLink)}>
|
||||
<Share />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
)
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
<DialogContent sx={{ pb: 0 }}>
|
||||
<AutoHeight>
|
||||
<SwitchTransition>
|
||||
<CSSTransition
|
||||
addEndListener={(node, done) => node.addEventListener("transitionend", done, false)}
|
||||
classNames="fade"
|
||||
key={`${shareLink}`}
|
||||
>
|
||||
<Box>
|
||||
{!shareLink && (
|
||||
<ShareSettingContent
|
||||
editing={!!editTarget}
|
||||
onSettingChange={setSetting}
|
||||
setting={setting}
|
||||
file={target}
|
||||
/>
|
||||
)}
|
||||
{shareLink && (
|
||||
<Stack spacing={1}>
|
||||
<FilledTextField
|
||||
variant={"filled"}
|
||||
inputProps={{ readonly: true }}
|
||||
label={t("modals.shareLink")}
|
||||
fullWidth
|
||||
value={finalShareLink ?? ""}
|
||||
onFocus={(e) => e.target.select()}
|
||||
slotProps={{
|
||||
input: {
|
||||
endAdornment: (
|
||||
<IconButton
|
||||
onClick={() => copyToClipboard(finalShareLink)}
|
||||
size="small"
|
||||
sx={{ marginRight: -1 }}
|
||||
>
|
||||
<CopyOutlined />
|
||||
</IconButton>
|
||||
),
|
||||
},
|
||||
}}
|
||||
/>
|
||||
{shareLinkPassword.password && (
|
||||
<>
|
||||
<Collapse in={!includePassword}>
|
||||
<FilledTextField
|
||||
variant={"filled"}
|
||||
inputProps={{ readonly: true }}
|
||||
label={t("modals.sharePassword")}
|
||||
fullWidth
|
||||
value={finalShareLinkPassword ?? ""}
|
||||
onFocus={(e) => e.target.select()}
|
||||
slotProps={{
|
||||
input: {
|
||||
endAdornment: (
|
||||
<IconButton
|
||||
onClick={() => copyToClipboard(finalShareLinkPassword ?? "")}
|
||||
size="small"
|
||||
sx={{ marginRight: -1 }}
|
||||
>
|
||||
<CopyOutlined />
|
||||
</IconButton>
|
||||
),
|
||||
},
|
||||
}}
|
||||
/>
|
||||
</Collapse>
|
||||
<Tooltip enterDelay={100} title={t("application:modals.includePasswordInShareLinkDes")}>
|
||||
<SmallFormControlLabel
|
||||
sx={{
|
||||
mt: "0!important",
|
||||
}}
|
||||
control={
|
||||
<Checkbox
|
||||
disableRipple
|
||||
sx={{
|
||||
pl: 0,
|
||||
}}
|
||||
size="small"
|
||||
checked={includePassword}
|
||||
onChange={() => {
|
||||
setIncludePassword(!includePassword);
|
||||
}}
|
||||
/>
|
||||
}
|
||||
label={t("application:modals.includePasswordInShareLink")}
|
||||
/>
|
||||
</Tooltip>
|
||||
</>
|
||||
)}
|
||||
</Stack>
|
||||
)}
|
||||
</Box>
|
||||
</CSSTransition>
|
||||
</SwitchTransition>
|
||||
</AutoHeight>
|
||||
</DialogContent>
|
||||
</DraggableDialog>
|
||||
</>
|
||||
);
|
||||
};
|
||||
export default ShareDialog;
|
||||
382
src/component/FileManager/Dialogs/Share/ShareSetting.tsx
Executable file
382
src/component/FileManager/Dialogs/Share/ShareSetting.tsx
Executable file
@@ -0,0 +1,382 @@
|
||||
import {
|
||||
Autocomplete,
|
||||
Checkbox,
|
||||
Collapse,
|
||||
createFilterOptions,
|
||||
FormControl,
|
||||
List,
|
||||
ListItemButton,
|
||||
ListItemIcon,
|
||||
ListItemSecondaryAction,
|
||||
ListItemText,
|
||||
Stack,
|
||||
styled,
|
||||
TextField,
|
||||
Typography,
|
||||
} from "@mui/material";
|
||||
import MuiAccordion from "@mui/material/Accordion";
|
||||
import MuiAccordionDetails from "@mui/material/AccordionDetails";
|
||||
import MuiAccordionSummary from "@mui/material/AccordionSummary";
|
||||
import { useState } from "react";
|
||||
import { Trans, useTranslation } from "react-i18next";
|
||||
import { FileResponse, FileType } from "../../../../api/explorer.ts";
|
||||
import { Code } from "../../../Common/Code.tsx";
|
||||
import { FilledTextField, SmallFormControlLabel } from "../../../Common/StyledComponents.tsx";
|
||||
import BookInformation from "../../../Icons/BookInformation.tsx";
|
||||
import ClockArrowDownload from "../../../Icons/ClockArrowDownload.tsx";
|
||||
import Eye from "../../../Icons/Eye.tsx";
|
||||
import TableSettingsOutlined from "../../../Icons/TableSettings.tsx";
|
||||
import Timer from "../../../Icons/Timer.tsx";
|
||||
|
||||
const Accordion = styled(MuiAccordion)(() => ({
|
||||
border: "0px solid rgba(0, 0, 0, .125)",
|
||||
boxShadow: "none",
|
||||
"&:not(:last-child)": {
|
||||
borderBottom: 0,
|
||||
},
|
||||
"&:before": {
|
||||
display: "none",
|
||||
},
|
||||
".Mui-expanded": {
|
||||
margin: "0 0",
|
||||
minHeight: 0,
|
||||
},
|
||||
"&.Mui-expanded": {
|
||||
margin: "0 0",
|
||||
minHeight: 0,
|
||||
},
|
||||
}));
|
||||
|
||||
const AccordionSummary = styled(MuiAccordionSummary)(({ theme }) => ({
|
||||
padding: 0,
|
||||
"& .MuiAccordionSummary-content": {
|
||||
margin: 0,
|
||||
display: "initial",
|
||||
"&.Mui-expanded": {
|
||||
margin: "0 0",
|
||||
},
|
||||
},
|
||||
"&.Mui-expanded": {
|
||||
borderRadius: "12px 12px 0 0",
|
||||
backgroundColor: theme.palette.mode == "light" ? "rgba(0, 0, 0, 0.06)" : "rgba(255, 255, 255, 0.09)",
|
||||
minHeight: "0px!important",
|
||||
},
|
||||
}));
|
||||
|
||||
const AccordionDetails = styled(MuiAccordionDetails)(({ theme }) => ({
|
||||
padding: 24,
|
||||
backgroundColor: theme.palette.mode == "light" ? "rgba(0, 0, 0, 0.06)" : "rgba(255, 255, 255, 0.09)",
|
||||
borderRadius: "0 0 12px 12px",
|
||||
fontSize: theme.typography.body2.fontSize,
|
||||
color: theme.palette.text.secondary,
|
||||
}));
|
||||
|
||||
const StyledListItemButton = styled(ListItemButton)(() => ({}));
|
||||
|
||||
export interface ShareSetting {
|
||||
is_private?: boolean;
|
||||
use_custom_password?: boolean;
|
||||
password?: string;
|
||||
share_view?: boolean;
|
||||
show_readme?: boolean;
|
||||
downloads?: boolean;
|
||||
expires?: boolean;
|
||||
|
||||
downloads_val: valueOption;
|
||||
expires_val: valueOption;
|
||||
}
|
||||
|
||||
export interface ShareSettingProps {
|
||||
setting: ShareSetting;
|
||||
file?: FileResponse;
|
||||
onSettingChange: (value: ShareSetting) => void;
|
||||
editing?: boolean;
|
||||
}
|
||||
|
||||
interface valueOption {
|
||||
value: number;
|
||||
label: string;
|
||||
inputValue?: string;
|
||||
}
|
||||
|
||||
export const expireOptions: valueOption[] = [
|
||||
{ value: 300, label: "modals.5minutes" },
|
||||
{ value: 3600, label: "modals.1hour" },
|
||||
{ value: 24 * 3600, label: "modals.1day" },
|
||||
{ value: 7 * 24 * 3600, label: "modals.7days" },
|
||||
{ value: 30 * 24 * 3600, label: "modals.30days" },
|
||||
];
|
||||
|
||||
export const downloadOptions: valueOption[] = [
|
||||
{ value: 1, label: "1" },
|
||||
{ value: 2, label: "2" },
|
||||
{ value: 3, label: "3" },
|
||||
{ value: 4, label: "4" },
|
||||
{ value: 5, label: "5" },
|
||||
{ value: 20, label: "20" },
|
||||
{ value: 50, label: "50" },
|
||||
{ value: 100, label: "100" },
|
||||
];
|
||||
|
||||
const isNumeric = (num: any) =>
|
||||
(typeof num === "number" || (typeof num === "string" && num.trim() !== "")) && !isNaN(num as number);
|
||||
|
||||
const filter = createFilterOptions<valueOption>();
|
||||
|
||||
const ShareSettingContent = ({ setting, file, editing, onSettingChange }: ShareSettingProps) => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const [expanded, setExpanded] = useState<string | undefined>(undefined);
|
||||
|
||||
const handleExpand = (panel: string) => (_event: any, isExpanded: boolean) => {
|
||||
setExpanded(isExpanded ? panel : undefined);
|
||||
};
|
||||
|
||||
const handleCheck = (prop: "is_private" | "share_view" | "show_readme" | "expires" | "downloads") => () => {
|
||||
if (!setting[prop]) {
|
||||
handleExpand(prop)(null, true);
|
||||
}
|
||||
|
||||
onSettingChange({ ...setting, [prop]: !setting[prop] });
|
||||
};
|
||||
|
||||
return (
|
||||
<List
|
||||
sx={{
|
||||
padding: 0,
|
||||
}}
|
||||
>
|
||||
<Accordion expanded={expanded === "is_private"} onChange={handleExpand("is_private")}>
|
||||
<AccordionSummary aria-controls="panel1a-content" id="panel1a-header">
|
||||
<StyledListItemButton>
|
||||
<ListItemIcon>
|
||||
<Eye />
|
||||
</ListItemIcon>
|
||||
<ListItemText primary={t("application:modals.privateShare")} />
|
||||
<ListItemSecondaryAction>
|
||||
<Checkbox disabled={editing} checked={!!setting.is_private} onChange={handleCheck("is_private")} />
|
||||
</ListItemSecondaryAction>
|
||||
</StyledListItemButton>
|
||||
</AccordionSummary>
|
||||
<AccordionDetails sx={{ display: "flex", flexDirection: "column", alignItems: "center" }}>
|
||||
<Typography variant="body2">{t("application:modals.privateShareDes")}</Typography>
|
||||
{setting.is_private && (
|
||||
<Stack sx={{ mt: 1, width: "100%" }}>
|
||||
{!editing && (
|
||||
<SmallFormControlLabel
|
||||
control={
|
||||
<Checkbox
|
||||
size="small"
|
||||
checked={setting.use_custom_password}
|
||||
onChange={() => {
|
||||
onSettingChange({ ...setting, use_custom_password: !setting.use_custom_password });
|
||||
}}
|
||||
/>
|
||||
}
|
||||
label={t("application:modals.useCustomPassword")}
|
||||
/>
|
||||
)}
|
||||
<Collapse in={setting.use_custom_password}>
|
||||
<FormControl variant="standard" fullWidth sx={{ mt: 1 }}>
|
||||
<FilledTextField
|
||||
label={t("application:modals.sharePassword")}
|
||||
disabled={editing}
|
||||
slotProps={{
|
||||
htmlInput: {
|
||||
maxLength: 32,
|
||||
},
|
||||
}}
|
||||
value={setting.password ?? ""}
|
||||
onChange={(e) => {
|
||||
const value = e.target.value.trim();
|
||||
if (!/^[a-zA-Z0-9]*$/.test(value) || value.length > 32) return;
|
||||
onSettingChange({ ...setting, password: value });
|
||||
}}
|
||||
required
|
||||
/>
|
||||
</FormControl>
|
||||
</Collapse>
|
||||
</Stack>
|
||||
)}
|
||||
</AccordionDetails>
|
||||
</Accordion>
|
||||
{file?.type == FileType.folder && (
|
||||
<>
|
||||
<Accordion expanded={expanded === "share_view"} onChange={handleExpand("share_view")}>
|
||||
<AccordionSummary aria-controls="panel1a-content" id="panel1a-header">
|
||||
<StyledListItemButton>
|
||||
<ListItemIcon>
|
||||
<TableSettingsOutlined />
|
||||
</ListItemIcon>
|
||||
<ListItemText primary={t("application:modals.shareView")} />
|
||||
<ListItemSecondaryAction>
|
||||
<Checkbox checked={setting.share_view} onChange={handleCheck("share_view")} />
|
||||
</ListItemSecondaryAction>
|
||||
</StyledListItemButton>
|
||||
</AccordionSummary>
|
||||
<AccordionDetails>{t("application:modals.shareViewDes")}</AccordionDetails>
|
||||
</Accordion>
|
||||
<Accordion expanded={expanded === "show_readme"} onChange={handleExpand("show_readme")}>
|
||||
<AccordionSummary aria-controls="panel1a-content" id="panel1a-header">
|
||||
<StyledListItemButton>
|
||||
<ListItemIcon>
|
||||
<BookInformation />
|
||||
</ListItemIcon>
|
||||
<ListItemText primary={t("application:modals.showReadme")} />
|
||||
<ListItemSecondaryAction>
|
||||
<Checkbox checked={setting.show_readme} onChange={handleCheck("show_readme")} />
|
||||
</ListItemSecondaryAction>
|
||||
</StyledListItemButton>
|
||||
</AccordionSummary>
|
||||
<AccordionDetails>
|
||||
<Trans i18nKey="application:modals.showReadmeDes" components={[<Code />]} />
|
||||
</AccordionDetails>
|
||||
</Accordion>
|
||||
</>
|
||||
)}
|
||||
<Accordion expanded={expanded === "expires"} onChange={handleExpand("expires")}>
|
||||
<AccordionSummary aria-controls="panel1a-content" id="panel1a-header">
|
||||
<StyledListItemButton>
|
||||
<ListItemIcon>
|
||||
<Timer />
|
||||
</ListItemIcon>
|
||||
<ListItemText primary={t("modals.expireAutomatically")} />
|
||||
<ListItemSecondaryAction>
|
||||
<Checkbox checked={setting.expires} onChange={handleCheck("expires")} />
|
||||
</ListItemSecondaryAction>
|
||||
</StyledListItemButton>
|
||||
</AccordionSummary>
|
||||
<AccordionDetails sx={{ display: "flex", alignItems: "center" }}>
|
||||
<Typography>{t("application:modals.expirePrefix")}</Typography>
|
||||
<FormControl
|
||||
variant="standard"
|
||||
style={{
|
||||
marginRight: 10,
|
||||
marginLeft: 10,
|
||||
}}
|
||||
>
|
||||
<Autocomplete
|
||||
value={setting.expires_val}
|
||||
filterOptions={(options, params) => {
|
||||
const filtered = filter(options, params);
|
||||
|
||||
const { inputValue } = params;
|
||||
const value = parseInt(inputValue) * 60;
|
||||
if (inputValue !== "" && isNumeric(inputValue) && parseInt(inputValue) > 0 && value != 300) {
|
||||
filtered.push({
|
||||
inputValue,
|
||||
value,
|
||||
label: inputValue + " " + t("application:modals.minutes"),
|
||||
});
|
||||
}
|
||||
|
||||
return filtered;
|
||||
}}
|
||||
onChange={(_event, newValue) => {
|
||||
let expiry = 0;
|
||||
let label = "";
|
||||
if (typeof newValue === "string") {
|
||||
expiry = parseInt(newValue);
|
||||
label = newValue + " " + t("application:modals.minutes");
|
||||
} else {
|
||||
expiry = newValue?.value ?? 0;
|
||||
label = newValue?.label ?? "";
|
||||
}
|
||||
|
||||
onSettingChange({
|
||||
...setting,
|
||||
expires_val: { value: expiry, label },
|
||||
});
|
||||
}}
|
||||
freeSolo
|
||||
getOptionLabel={(option: string | valueOption) => (typeof option === "string" ? option : t(option.label))}
|
||||
disableClearable
|
||||
options={expireOptions}
|
||||
renderInput={(params) => <TextField sx={{ width: 150 }} {...params} variant={"standard"} />}
|
||||
/>
|
||||
</FormControl>
|
||||
<Typography>{t("application:modals.expireSuffix")}</Typography>
|
||||
</AccordionDetails>
|
||||
</Accordion>
|
||||
{file?.type == FileType.file && (
|
||||
<Accordion expanded={expanded === "downloads"} onChange={handleExpand("downloads")}>
|
||||
<AccordionSummary aria-controls="panel1a-content" id="panel1a-header">
|
||||
<StyledListItemButton>
|
||||
<ListItemIcon>
|
||||
<ClockArrowDownload />
|
||||
</ListItemIcon>
|
||||
<ListItemText primary={t("application:modals.expireAfterDownload")} />
|
||||
<ListItemSecondaryAction>
|
||||
<Checkbox checked={setting.downloads} onChange={handleCheck("downloads")} />
|
||||
</ListItemSecondaryAction>
|
||||
</StyledListItemButton>
|
||||
</AccordionSummary>
|
||||
<AccordionDetails sx={{ display: "flex", alignItems: "center" }}>
|
||||
<Typography>{t("application:modals.expirePrefix")}</Typography>
|
||||
<FormControl
|
||||
variant="standard"
|
||||
style={{
|
||||
marginRight: 10,
|
||||
marginLeft: 10,
|
||||
}}
|
||||
>
|
||||
<Autocomplete
|
||||
value={setting.downloads_val}
|
||||
filterOptions={(options, params) => {
|
||||
const filtered = filter(options, params);
|
||||
|
||||
const { inputValue } = params;
|
||||
const value = parseInt(inputValue);
|
||||
if (
|
||||
inputValue !== "" &&
|
||||
isNumeric(inputValue) &&
|
||||
parseInt(inputValue) > 0 &&
|
||||
!filtered.find((v) => v.value == value)
|
||||
) {
|
||||
filtered.push({
|
||||
inputValue,
|
||||
value,
|
||||
label: inputValue,
|
||||
});
|
||||
}
|
||||
|
||||
return filtered;
|
||||
}}
|
||||
onChange={(_event, newValue) => {
|
||||
let downloads = 0;
|
||||
let label = "";
|
||||
if (typeof newValue === "string") {
|
||||
downloads = parseInt(newValue);
|
||||
label = newValue;
|
||||
} else {
|
||||
downloads = newValue?.value ?? 0;
|
||||
label = newValue?.label ?? "";
|
||||
}
|
||||
|
||||
onSettingChange({
|
||||
...setting,
|
||||
downloads_val: { value: downloads, label },
|
||||
});
|
||||
}}
|
||||
freeSolo
|
||||
getOptionLabel={(option: string | valueOption) =>
|
||||
typeof option === "string"
|
||||
? option
|
||||
: t("application:modals.downloadLimitOptions", {
|
||||
num: option.label,
|
||||
})
|
||||
}
|
||||
disableClearable
|
||||
options={downloadOptions}
|
||||
renderInput={(params) => <TextField sx={{ width: 200 }} {...params} variant={"standard"} />}
|
||||
/>
|
||||
</FormControl>
|
||||
<Typography>{t("application:modals.expireSuffix")}</Typography>
|
||||
</AccordionDetails>
|
||||
</Accordion>
|
||||
)}
|
||||
</List>
|
||||
);
|
||||
};
|
||||
|
||||
export default ShareSettingContent;
|
||||
90
src/component/FileManager/Dialogs/StaleVersionConfirm.tsx
Executable file
90
src/component/FileManager/Dialogs/StaleVersionConfirm.tsx
Executable file
@@ -0,0 +1,90 @@
|
||||
import { Trans, useTranslation } from "react-i18next";
|
||||
import { Button, DialogContent, Stack } from "@mui/material";
|
||||
import { useAppDispatch, useAppSelector } from "../../../redux/hooks.ts";
|
||||
import { useCallback } from "react";
|
||||
import DraggableDialog, { StyledDialogContentText } from "../../Dialogs/DraggableDialog.tsx";
|
||||
import { askSaveAs, staleVersionDialogPromisePool } from "../../../redux/thunks/dialog.ts";
|
||||
import { closeStaleVersionDialog } from "../../../redux/globalStateSlice.ts";
|
||||
import CrUri from "../../../util/uri.ts";
|
||||
|
||||
const StaleVersionConfirm = () => {
|
||||
const { t } = useTranslation();
|
||||
const dispatch = useAppDispatch();
|
||||
|
||||
const open = useAppSelector((state) => state.globalState.staleVersionDialogOpen);
|
||||
const uri = useAppSelector((state) => state.globalState.staleVersionUri);
|
||||
const promiseId = useAppSelector((state) => state.globalState.staleVersionPromiseId);
|
||||
|
||||
const onClose = useCallback(() => {
|
||||
dispatch(closeStaleVersionDialog());
|
||||
if (promiseId) {
|
||||
staleVersionDialogPromisePool[promiseId]?.reject("cancel");
|
||||
}
|
||||
}, [dispatch, promiseId]);
|
||||
|
||||
const onAccept = useCallback(
|
||||
(e?: React.FormEvent<HTMLFormElement>) => {
|
||||
if (e) {
|
||||
e.preventDefault();
|
||||
}
|
||||
if (promiseId) {
|
||||
staleVersionDialogPromisePool[promiseId]?.resolve({ overwrite: true });
|
||||
dispatch(closeStaleVersionDialog());
|
||||
}
|
||||
},
|
||||
[promiseId, name],
|
||||
);
|
||||
|
||||
const onSaveAs = useCallback(async () => {
|
||||
if (!uri) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const fileName = new CrUri(uri).elements().pop();
|
||||
if (fileName && promiseId) {
|
||||
const saveAsDst = await dispatch(askSaveAs(fileName));
|
||||
const dst = new CrUri(saveAsDst.uri).join(saveAsDst.name);
|
||||
staleVersionDialogPromisePool[promiseId]?.resolve({
|
||||
overwrite: false,
|
||||
saveAs: dst.toString(),
|
||||
});
|
||||
dispatch(closeStaleVersionDialog());
|
||||
}
|
||||
} catch (e) {
|
||||
return;
|
||||
}
|
||||
}, [dispatch, promiseId, uri]);
|
||||
|
||||
return (
|
||||
<DraggableDialog
|
||||
title={t("application:modals.versionConflict")}
|
||||
showActions
|
||||
okText={t("application:modals.overwrite")}
|
||||
showCancel
|
||||
onAccept={onAccept}
|
||||
secondaryAction={
|
||||
<Button variant={"contained"} onClick={onSaveAs} color="primary">
|
||||
{t("modals.saveAs")}
|
||||
</Button>
|
||||
}
|
||||
dialogProps={{
|
||||
open: open ?? false,
|
||||
onClose: onClose,
|
||||
fullWidth: true,
|
||||
maxWidth: "sm",
|
||||
}}
|
||||
>
|
||||
<DialogContent sx={{ pb: 0 }}>
|
||||
<Stack spacing={2}>
|
||||
<StyledDialogContentText>
|
||||
{t("modals.conflictDes1")}
|
||||
<ul>
|
||||
<Trans i18nKey="modals.conflictDes2" ns={"application"} components={[<li key={0} />, <li key={1} />]} />
|
||||
</ul>
|
||||
</StyledDialogContentText>
|
||||
</Stack>
|
||||
</DialogContent>
|
||||
</DraggableDialog>
|
||||
);
|
||||
};
|
||||
export default StaleVersionConfirm;
|
||||
211
src/component/FileManager/Dialogs/Tags.tsx
Executable file
211
src/component/FileManager/Dialogs/Tags.tsx
Executable file
@@ -0,0 +1,211 @@
|
||||
import { Autocomplete, DialogContent, Stack, useTheme } from "@mui/material";
|
||||
import { enqueueSnackbar } from "notistack";
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { FileResponse, Metadata } from "../../../api/explorer.ts";
|
||||
import { defaultColors } from "../../../constants";
|
||||
import { closeTagsDialog } from "../../../redux/globalStateSlice.ts";
|
||||
import { useAppDispatch, useAppSelector } from "../../../redux/hooks.ts";
|
||||
import { patchTags } from "../../../redux/thunks/file.ts";
|
||||
import SessionManager, { UserSettings } from "../../../session";
|
||||
import { addRecentUsedColor } from "../../../session/utils.ts";
|
||||
import { FilledTextField } from "../../Common/StyledComponents.tsx";
|
||||
import DialogAccordion from "../../Dialogs/DialogAccordion.tsx";
|
||||
import DraggableDialog from "../../Dialogs/DraggableDialog.tsx";
|
||||
import FileTag from "../Explorer/FileTag.tsx";
|
||||
import CircleColorSelector, { customizeMagicColor } from "../FileInfo/ColorCircle/CircleColorSelector.tsx";
|
||||
import { FileManagerIndex } from "../FileManager.tsx";
|
||||
|
||||
export interface Tag {
|
||||
key: string;
|
||||
color?: string;
|
||||
}
|
||||
|
||||
export const getUniqueTagsFromFiles = (targets: FileResponse[]) => {
|
||||
const tags: {
|
||||
[key: string]: Tag;
|
||||
} = {};
|
||||
targets.forEach((target) => {
|
||||
if (target.metadata) {
|
||||
Object.keys(target.metadata).forEach((key: string) => {
|
||||
if (key.startsWith(Metadata.tag_prefix)) {
|
||||
// trim prefix for key
|
||||
const tagKey = key.slice(Metadata.tag_prefix.length);
|
||||
tags[tagKey] = {
|
||||
key: key.slice(Metadata.tag_prefix.length),
|
||||
color: target.metadata?.[key],
|
||||
};
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
return Object.values(tags);
|
||||
};
|
||||
|
||||
const Tags = () => {
|
||||
const { t } = useTranslation();
|
||||
const dispatch = useAppDispatch();
|
||||
const theme = useTheme();
|
||||
|
||||
const [hex, setHex] = useState<string | undefined>(undefined);
|
||||
const [tags, setTags] = useState<Tag[]>([]);
|
||||
const [name, setName] = useState("");
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
const open = useAppSelector((state) => state.globalState.tagsDialogOpen);
|
||||
const targets = useAppSelector((state) => state.globalState.tagsDialogFile);
|
||||
|
||||
const onClose = useCallback(() => {
|
||||
if (!loading) {
|
||||
dispatch(closeTagsDialog());
|
||||
}
|
||||
}, [dispatch, loading]);
|
||||
|
||||
const onAccept = useCallback(
|
||||
async (e?: React.FormEvent<HTMLFormElement>) => {
|
||||
if (e) {
|
||||
e.preventDefault();
|
||||
}
|
||||
|
||||
if (!targets) return;
|
||||
|
||||
setLoading(true);
|
||||
try {
|
||||
await dispatch(patchTags(FileManagerIndex.main, targets, tags));
|
||||
} catch (e) {
|
||||
} finally {
|
||||
setLoading(false);
|
||||
dispatch(closeTagsDialog());
|
||||
}
|
||||
},
|
||||
[name, dispatch, targets, tags, setLoading],
|
||||
);
|
||||
|
||||
const presetColors = useMemo(() => {
|
||||
const colors = new Set(defaultColors);
|
||||
|
||||
const recentColors = SessionManager.get(UserSettings.UsedCustomizedTagColors) as string[] | undefined;
|
||||
|
||||
if (recentColors) {
|
||||
recentColors.forEach((color) => {
|
||||
colors.add(color);
|
||||
});
|
||||
}
|
||||
|
||||
return [...colors];
|
||||
}, [hex]);
|
||||
|
||||
useEffect(() => {
|
||||
if (targets && open) {
|
||||
setTags(getUniqueTagsFromFiles(targets));
|
||||
}
|
||||
}, [targets, open]);
|
||||
|
||||
const onColorChange = useCallback(
|
||||
(color: string | undefined) => {
|
||||
color = color == theme.palette.action.selected ? undefined : color;
|
||||
addRecentUsedColor(color, UserSettings.UsedCustomizedTagColors);
|
||||
setHex(color);
|
||||
},
|
||||
[theme, setHex],
|
||||
);
|
||||
|
||||
const onTagAdded = useCallback(
|
||||
(_e: any, newValue: (string | Tag)[]) => {
|
||||
const duplicateMap: { [key: string]: boolean } = {};
|
||||
newValue = newValue.filter((tag) => {
|
||||
const tagKey = typeof tag === "string" ? tag : tag.key;
|
||||
if (!tagKey) {
|
||||
return false;
|
||||
}
|
||||
if (duplicateMap[tagKey]) {
|
||||
enqueueSnackbar(t("application:modals.duplicateTag", { tag: tagKey }), { variant: "warning" });
|
||||
return false;
|
||||
}
|
||||
duplicateMap[tagKey] = true;
|
||||
return true;
|
||||
});
|
||||
setTags(newValue.map((tag) => (typeof tag === "string" ? { key: tag, color: hex } : tag) as Tag));
|
||||
},
|
||||
[hex, setTags],
|
||||
);
|
||||
|
||||
// const onNameChange = useCallback(
|
||||
// (e: ChangeEvent<HTMLInputElement | HTMLTextAreaElement>) => {
|
||||
// setName(e.target.value);
|
||||
// },
|
||||
// [dispatch, setName],
|
||||
// );
|
||||
|
||||
return (
|
||||
<DraggableDialog
|
||||
title={t("application:modals.manageTags")}
|
||||
showActions
|
||||
loading={loading}
|
||||
showCancel
|
||||
onAccept={onAccept}
|
||||
dialogProps={{
|
||||
open: open ?? false,
|
||||
onClose: onClose,
|
||||
fullWidth: true,
|
||||
maxWidth: "sm",
|
||||
}}
|
||||
>
|
||||
<DialogContent>
|
||||
<Stack spacing={1}>
|
||||
<Autocomplete
|
||||
multiple
|
||||
id="tags-filled"
|
||||
options={[]}
|
||||
getOptionLabel={(o: any) => o?.key}
|
||||
value={tags}
|
||||
freeSolo
|
||||
autoSelect={true}
|
||||
onChange={onTagAdded}
|
||||
renderTags={(value: readonly Tag[], getTagProps) =>
|
||||
value.map((option: Tag, index: number) => (
|
||||
<FileTag
|
||||
defaultStyle
|
||||
openInNewTab
|
||||
spacing={1}
|
||||
label={option.key}
|
||||
size={"medium"}
|
||||
tagColor={option.color}
|
||||
{...getTagProps({ index })}
|
||||
key={option.key}
|
||||
/>
|
||||
))
|
||||
}
|
||||
renderInput={(params) => (
|
||||
<FilledTextField
|
||||
{...params}
|
||||
sx={{
|
||||
mt: 2,
|
||||
"& .MuiInputBase-root": {
|
||||
pt: "28px",
|
||||
pb: 1,
|
||||
},
|
||||
}}
|
||||
variant="filled"
|
||||
autoFocus
|
||||
helperText={t("application:modals.enterForNewTag")}
|
||||
margin="dense"
|
||||
label={t("application:fileManager.tags")}
|
||||
type="text"
|
||||
fullWidth
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
<DialogAccordion title={t("application:modals.colorForTag")}>
|
||||
<CircleColorSelector
|
||||
colors={[theme.palette.action.selected, ...presetColors, customizeMagicColor]}
|
||||
selectedColor={hex ?? theme.palette.action.selected}
|
||||
onChange={onColorChange}
|
||||
/>
|
||||
</DialogAccordion>
|
||||
</Stack>
|
||||
</DialogContent>
|
||||
</DraggableDialog>
|
||||
);
|
||||
};
|
||||
export default Tags;
|
||||
285
src/component/FileManager/Dialogs/VersionControl.tsx
Executable file
285
src/component/FileManager/Dialogs/VersionControl.tsx
Executable file
@@ -0,0 +1,285 @@
|
||||
import {
|
||||
Alert,
|
||||
Box,
|
||||
DialogContent,
|
||||
IconButton,
|
||||
ListItemText,
|
||||
Menu,
|
||||
Skeleton,
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableContainer,
|
||||
TableHead,
|
||||
TableRow,
|
||||
Typography,
|
||||
} from "@mui/material";
|
||||
import React, { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useAppDispatch, useAppSelector } from "../../../redux/hooks.ts";
|
||||
import { confirmOperation } from "../../../redux/thunks/dialog.ts";
|
||||
import { downloadSingleFile } from "../../../redux/thunks/download.ts";
|
||||
import { setFileVersion } from "../../../redux/thunks/file.ts";
|
||||
import { openViewers } from "../../../redux/thunks/viewer.ts";
|
||||
import { sizeToString } from "../../../util";
|
||||
import AutoHeight from "../../Common/AutoHeight.tsx";
|
||||
import { closeVersionControlDialog } from "../../../redux/globalStateSlice.ts";
|
||||
import { Entity, EntityType, FileResponse } from "../../../api/explorer.ts";
|
||||
import { deleteVersion, getFileInfo } from "../../../api/api.ts";
|
||||
import { NoWrapTableCell, StyledTableContainerPaper } from "../../Common/StyledComponents.tsx";
|
||||
import TimeBadge from "../../Common/TimeBadge.tsx";
|
||||
import { AnonymousUser } from "../../Common/User/UserAvatar.tsx";
|
||||
import UserBadge from "../../Common/User/UserBadge.tsx";
|
||||
import DraggableDialog from "../../Dialogs/DraggableDialog.tsx";
|
||||
import MoreVertical from "../../Icons/MoreVertical.tsx";
|
||||
import { SquareMenuItem } from "../ContextMenu/ContextMenu.tsx";
|
||||
import { FileManagerIndex } from "../FileManager.tsx";
|
||||
|
||||
const VersionControl = () => {
|
||||
const { t } = useTranslation();
|
||||
const dispatch = useAppDispatch();
|
||||
|
||||
const [anchorEl, setAnchorEl] = useState<null | HTMLElement>(null);
|
||||
const [actionTarget, setActionTarget] = useState<Entity | null>(null);
|
||||
const [fileExtended, setFileExtended] = useState<FileResponse | undefined>(undefined);
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
const open = useAppSelector((state) => state.globalState.versionControlDialogOpen);
|
||||
const target = useAppSelector((state) => state.globalState.versionControlDialogFile);
|
||||
const highlight = useAppSelector((state) => state.globalState.versionControlHighlight);
|
||||
|
||||
const onClose = useCallback(() => {
|
||||
if (!loading) {
|
||||
dispatch(closeVersionControlDialog());
|
||||
}
|
||||
}, [dispatch, loading]);
|
||||
|
||||
useEffect(() => {
|
||||
if (target && open) {
|
||||
setFileExtended(undefined);
|
||||
dispatch(
|
||||
getFileInfo({
|
||||
uri: target.path,
|
||||
extended: true,
|
||||
}),
|
||||
).then((res) => setFileExtended(res));
|
||||
}
|
||||
}, [target, open]);
|
||||
|
||||
const versionEntities = useMemo(() => {
|
||||
return fileExtended?.extended_info?.entities?.filter((e) => e.type == EntityType.version);
|
||||
}, [fileExtended?.extended_info?.entities]);
|
||||
|
||||
const hilightButNotFound = useMemo(() => {
|
||||
return highlight && fileExtended?.extended_info && !versionEntities?.some((e) => e.id == highlight);
|
||||
}, [highlight, fileExtended?.extended_info?.entities]);
|
||||
|
||||
const handleActionClose = () => {
|
||||
setAnchorEl(null);
|
||||
};
|
||||
|
||||
const handleOpenAction = (event: React.MouseEvent<HTMLElement>, element: Entity) => {
|
||||
setAnchorEl(event.currentTarget);
|
||||
setActionTarget(element);
|
||||
};
|
||||
|
||||
const downloadEntity = useCallback(() => {
|
||||
if (!target || !actionTarget) {
|
||||
return;
|
||||
}
|
||||
dispatch(downloadSingleFile(target, actionTarget.id));
|
||||
setAnchorEl(null);
|
||||
}, [target, actionTarget, dispatch]);
|
||||
|
||||
const openEntity = useCallback(() => {
|
||||
if (!target || !actionTarget) {
|
||||
return;
|
||||
}
|
||||
dispatch(openViewers(FileManagerIndex.main, target, actionTarget.size, actionTarget.id));
|
||||
setAnchorEl(null);
|
||||
}, [target, actionTarget, dispatch]);
|
||||
|
||||
const setAsCurrent = useCallback(() => {
|
||||
if (!target || !actionTarget) {
|
||||
return;
|
||||
}
|
||||
|
||||
setLoading(true);
|
||||
dispatch(setFileVersion(FileManagerIndex.main, target, actionTarget.id))
|
||||
.then(() => {
|
||||
setFileExtended((prev) =>
|
||||
prev
|
||||
? {
|
||||
...prev,
|
||||
primary_entity: actionTarget.id,
|
||||
}
|
||||
: undefined,
|
||||
);
|
||||
})
|
||||
.finally(() => {
|
||||
setLoading(false);
|
||||
});
|
||||
|
||||
setAnchorEl(null);
|
||||
}, [target, actionTarget, setLoading, dispatch]);
|
||||
|
||||
const deleteTargetVersion = useCallback(() => {
|
||||
if (!target || !actionTarget) {
|
||||
return;
|
||||
}
|
||||
|
||||
dispatch(confirmOperation(t("fileManager.deleteVersionWarning"))).then(() => {
|
||||
setLoading(true);
|
||||
dispatch(
|
||||
deleteVersion({
|
||||
uri: target.path,
|
||||
version: actionTarget.id,
|
||||
}),
|
||||
)
|
||||
.then(() => {
|
||||
setFileExtended((prev) =>
|
||||
prev
|
||||
? {
|
||||
...prev,
|
||||
extended_info: prev.extended_info
|
||||
? {
|
||||
...prev.extended_info,
|
||||
entities: prev.extended_info.entities?.filter((e) => e.id !== actionTarget.id),
|
||||
}
|
||||
: undefined,
|
||||
}
|
||||
: undefined,
|
||||
);
|
||||
})
|
||||
.finally(() => {
|
||||
setLoading(false);
|
||||
});
|
||||
});
|
||||
|
||||
setAnchorEl(null);
|
||||
}, [t, target, actionTarget, setLoading, dispatch]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<Menu
|
||||
anchorEl={anchorEl}
|
||||
open={Boolean(anchorEl)}
|
||||
onClose={handleActionClose}
|
||||
slotProps={{
|
||||
paper: {
|
||||
sx: {
|
||||
minWidth: 150,
|
||||
},
|
||||
},
|
||||
}}
|
||||
>
|
||||
<SquareMenuItem onClick={openEntity} dense>
|
||||
<ListItemText>{t("application:fileManager.open")}</ListItemText>
|
||||
</SquareMenuItem>
|
||||
<SquareMenuItem onClick={downloadEntity} dense>
|
||||
<ListItemText>{t("application:fileManager.download")}</ListItemText>
|
||||
</SquareMenuItem>
|
||||
{target?.owned && actionTarget?.id !== fileExtended?.primary_entity && (
|
||||
<SquareMenuItem onClick={setAsCurrent} dense>
|
||||
<ListItemText>{t("application:fileManager.setAsCurrent")}</ListItemText>
|
||||
</SquareMenuItem>
|
||||
)}
|
||||
{target?.owned && actionTarget?.id !== fileExtended?.primary_entity && (
|
||||
<SquareMenuItem onClick={deleteTargetVersion} dense>
|
||||
<ListItemText>{t("application:fileManager.delete")}</ListItemText>
|
||||
</SquareMenuItem>
|
||||
)}
|
||||
</Menu>
|
||||
<DraggableDialog
|
||||
title={t("application:fileManager.manageVersions")}
|
||||
loading={loading}
|
||||
dialogProps={{
|
||||
open: open ?? false,
|
||||
onClose: onClose,
|
||||
fullWidth: true,
|
||||
maxWidth: "sm",
|
||||
}}
|
||||
>
|
||||
<DialogContent>
|
||||
<AutoHeight>
|
||||
{hilightButNotFound && (
|
||||
<Alert severity="warning" sx={{ mb: 2 }}>
|
||||
{t("application:fileManager.versionNotFound")}
|
||||
</Alert>
|
||||
)}
|
||||
<TableContainer component={StyledTableContainerPaper}>
|
||||
<Table sx={{ width: "100%" }} size="small">
|
||||
<TableHead>
|
||||
<TableRow>
|
||||
<NoWrapTableCell>{t("fileManager.actions")}</NoWrapTableCell>
|
||||
<NoWrapTableCell>{t("fileManager.createdAt")}</NoWrapTableCell>
|
||||
<NoWrapTableCell>{t("fileManager.size")}</NoWrapTableCell>
|
||||
<NoWrapTableCell>{t("fileManager.createdBy")}</NoWrapTableCell>
|
||||
<NoWrapTableCell>{t("application:fileManager.storagePolicy")}</NoWrapTableCell>
|
||||
</TableRow>
|
||||
</TableHead>
|
||||
<TableBody>
|
||||
{!fileExtended && (
|
||||
<TableRow
|
||||
hover
|
||||
sx={{
|
||||
"&:last-child td, &:last-child th": { border: 0 },
|
||||
}}
|
||||
>
|
||||
<NoWrapTableCell component="th" scope="row">
|
||||
<Skeleton variant={"text"} width={100} />
|
||||
</NoWrapTableCell>
|
||||
<NoWrapTableCell>
|
||||
<Skeleton variant={"text"} width={30} />
|
||||
</NoWrapTableCell>
|
||||
</TableRow>
|
||||
)}
|
||||
{versionEntities &&
|
||||
versionEntities.map((e) => (
|
||||
<TableRow
|
||||
selected={e.id === fileExtended?.primary_entity}
|
||||
sx={{
|
||||
boxShadow: (theme) =>
|
||||
highlight == e.id ? `inset 0 0 0 2px ${theme.palette.primary.light}` : "none",
|
||||
"&:last-child td, &:last-child th": { border: 0 },
|
||||
}}
|
||||
hover
|
||||
>
|
||||
<NoWrapTableCell component="th" scope="row">
|
||||
<IconButton disabled={loading} onClick={(event) => handleOpenAction(event, e)} size={"small"}>
|
||||
<MoreVertical fontSize={"small"} />
|
||||
</IconButton>
|
||||
</NoWrapTableCell>
|
||||
<NoWrapTableCell>
|
||||
<TimeBadge variant={"body2"} datetime={e.created_at} />
|
||||
</NoWrapTableCell>
|
||||
<NoWrapTableCell>{sizeToString(e.size)}</NoWrapTableCell>
|
||||
<TableCell>
|
||||
<UserBadge
|
||||
sx={{ width: 20, height: 20 }}
|
||||
textProps={{
|
||||
variant: "body2",
|
||||
}}
|
||||
user={e.created_by ?? AnonymousUser}
|
||||
/>
|
||||
</TableCell>
|
||||
<NoWrapTableCell>{e.storage_policy?.name}</NoWrapTableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
{!versionEntities && fileExtended && (
|
||||
<Box sx={{ p: 1, width: "100%", textAlign: "center" }}>
|
||||
<Typography variant={"caption"} color={"text.secondary"}>
|
||||
{t("application:setting.listEmpty")}
|
||||
</Typography>
|
||||
</Box>
|
||||
)}
|
||||
</TableContainer>
|
||||
</AutoHeight>
|
||||
</DialogContent>
|
||||
</DraggableDialog>
|
||||
</>
|
||||
);
|
||||
};
|
||||
export default VersionControl;
|
||||
20
src/component/FileManager/Dnd/DisableDropDelay.tsx
Executable file
20
src/component/FileManager/Dnd/DisableDropDelay.tsx
Executable file
@@ -0,0 +1,20 @@
|
||||
import { useDrop } from "react-dnd";
|
||||
import { useEffect } from "react";
|
||||
|
||||
const DisableDropDelay = () => {
|
||||
const [_, bodyDropRef] = useDrop(() => ({
|
||||
accept: "file",
|
||||
drop: () => {
|
||||
// do something
|
||||
},
|
||||
}));
|
||||
|
||||
useEffect(() => {
|
||||
bodyDropRef(document.body);
|
||||
return () => {
|
||||
bodyDropRef(null);
|
||||
};
|
||||
}, []);
|
||||
};
|
||||
|
||||
export default DisableDropDelay;
|
||||
151
src/component/FileManager/Dnd/DndWrappedFile.tsx
Executable file
151
src/component/FileManager/Dnd/DndWrappedFile.tsx
Executable file
@@ -0,0 +1,151 @@
|
||||
import { memo, useCallback, useContext, useEffect } from "react";
|
||||
import { useDrag, useDrop } from "react-dnd";
|
||||
import { getEmptyImage } from "react-dnd-html5-backend";
|
||||
import { FileResponse, FileType } from "../../../api/explorer.ts";
|
||||
import { setDragging } from "../../../redux/globalStateSlice.ts";
|
||||
import { useAppDispatch, useAppSelector } from "../../../redux/hooks.ts";
|
||||
import { processDnd } from "../../../redux/thunks/file.ts";
|
||||
import { getFileLinkedUri, mergeRefs } from "../../../util";
|
||||
|
||||
import { useTheme } from "@mui/material/styles";
|
||||
import useMediaQuery from "@mui/material/useMediaQuery";
|
||||
import CrUri, { Filesystem } from "../../../util/uri.ts";
|
||||
import { FileBlockProps } from "../Explorer/Explorer.tsx";
|
||||
import { FileManagerIndex } from "../FileManager.tsx";
|
||||
import { FmIndexContext } from "../FmIndexContext.tsx";
|
||||
|
||||
export interface DragItem {
|
||||
target: FileResponse;
|
||||
includeSelected?: boolean;
|
||||
}
|
||||
|
||||
export interface DropResult {
|
||||
dropEffect: string;
|
||||
uri?: string;
|
||||
}
|
||||
|
||||
export const DropEffect = {
|
||||
copy: "copy",
|
||||
move: "move",
|
||||
};
|
||||
|
||||
export interface UseFileDragProps {
|
||||
file?: FileResponse;
|
||||
includeSelected?: boolean;
|
||||
dropUri?: string;
|
||||
}
|
||||
|
||||
export const NoOpDropUri = "noop";
|
||||
export const useFileDrag = ({ file, includeSelected, dropUri }: UseFileDragProps) => {
|
||||
const dispatch = useAppDispatch();
|
||||
const theme = useTheme();
|
||||
const isTablet = useMediaQuery(theme.breakpoints.down("md"));
|
||||
const fmIndex = useContext(FmIndexContext);
|
||||
// const { addEventListenerForWindow, removeEventListenerForWindow } =
|
||||
// useDragScrolling(["#" + MainExplorerContainerID]);
|
||||
|
||||
// @ts-ignore
|
||||
const [{ isDragging }, drag, preview] = useDrag({
|
||||
type: "file",
|
||||
item: {
|
||||
target: file,
|
||||
includeSelected,
|
||||
},
|
||||
end: (item, monitor) => {
|
||||
// Ignore NoOpDropUri
|
||||
const target = monitor.getDropResult<DropResult>();
|
||||
if (!item || !target || !target.uri || target.uri == NoOpDropUri) {
|
||||
return;
|
||||
}
|
||||
|
||||
dispatch(processDnd(0, item as DragItem, target));
|
||||
},
|
||||
canDrag: () => {
|
||||
if (!file || fmIndex == FileManagerIndex.selector || isTablet) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const crUri = new CrUri(file.path);
|
||||
return file.owned && crUri.fs() != Filesystem.share;
|
||||
},
|
||||
collect: (monitor) => ({
|
||||
isDragging: monitor.isDragging(),
|
||||
}),
|
||||
});
|
||||
|
||||
const [{ canDrop, isOver }, drop] = useDrop({
|
||||
accept: "file",
|
||||
drop: () => (file ? { uri: getFileLinkedUri(file) } : { uri: dropUri }),
|
||||
collect: (monitor) => ({
|
||||
isOver: monitor.isOver(),
|
||||
canDrop: monitor.canDrop(),
|
||||
}),
|
||||
canDrop: (item, _monitor) => {
|
||||
const dropExist = !!dropUri || (!!file && file.type == FileType.folder);
|
||||
if (!dropExist || fmIndex == FileManagerIndex.selector) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!item) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
},
|
||||
});
|
||||
const isActive = canDrop && isOver;
|
||||
|
||||
useEffect(() => {
|
||||
preview(getEmptyImage(), { captureDraggingState: true });
|
||||
// eslint-disable-next-line
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (isDragging) {
|
||||
// addEventListenerForWindow();
|
||||
}
|
||||
dispatch(
|
||||
setDragging({
|
||||
dragging: isDragging,
|
||||
draggingWithSelected: !!includeSelected,
|
||||
}),
|
||||
);
|
||||
}, [isDragging]);
|
||||
|
||||
return [drag, drop, isActive, isDragging] as const;
|
||||
};
|
||||
|
||||
export interface DndWrappedFileProps extends FileBlockProps {
|
||||
component: React.MemoExoticComponent<(props: FileBlockProps) => JSX.Element>;
|
||||
}
|
||||
|
||||
const DndWrappedFile = memo((props: DndWrappedFileProps) => {
|
||||
const fmIndex = useContext(FmIndexContext);
|
||||
const globalDragging = useAppSelector((state) => state.globalState.dndState);
|
||||
const isSelected = useAppSelector((state) => state.fileManager[fmIndex].selected[props.file.path]);
|
||||
|
||||
const [drag, drop, isOver, isDragging] = useFileDrag({
|
||||
file: props.file.placeholder ? undefined : props.file,
|
||||
includeSelected: true,
|
||||
});
|
||||
|
||||
const mergedRef = useCallback(
|
||||
(val: any) => {
|
||||
mergeRefs(drop, drag)(val);
|
||||
},
|
||||
[drop, drag],
|
||||
);
|
||||
|
||||
const Component = props.component;
|
||||
|
||||
return (
|
||||
<Component
|
||||
dragRef={mergedRef}
|
||||
isDropOver={isOver}
|
||||
isDragging={isDragging || (!!globalDragging.dragging && !!isSelected && globalDragging.draggingWithSelected)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
});
|
||||
|
||||
export default DndWrappedFile;
|
||||
112
src/component/FileManager/Dnd/DragLayer.tsx
Executable file
112
src/component/FileManager/Dnd/DragLayer.tsx
Executable file
@@ -0,0 +1,112 @@
|
||||
import { useDragLayer, XYCoord } from "react-dnd";
|
||||
import { FileResponse } from "../../../api/explorer.ts";
|
||||
import { Badge, Box, Paper, PaperProps } from "@mui/material";
|
||||
import { useAppSelector } from "../../../redux/hooks.ts";
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { DragItem } from "./DndWrappedFile.tsx";
|
||||
import DisableDropDelay from "./DisableDropDelay.tsx";
|
||||
import { FileNameText, Header } from "../Explorer/GridView/GridFile.tsx";
|
||||
import FileSmallIcon from "../Explorer/FileSmallIcon.tsx";
|
||||
|
||||
interface DragPreviewProps extends PaperProps {
|
||||
files: FileResponse[];
|
||||
pointerOffset: XYCoord | null;
|
||||
}
|
||||
|
||||
const DragPreview = ({ pointerOffset, files, ...rest }: DragPreviewProps) => {
|
||||
const [size, setSize] = useState([0, 0]);
|
||||
useEffect(() => {
|
||||
setSize([220, 48]);
|
||||
}, []);
|
||||
if (!files || files.length == 0) {
|
||||
return undefined;
|
||||
}
|
||||
return (
|
||||
<Badge
|
||||
badgeContent={files.length <= 1 ? undefined : files.length}
|
||||
color="primary"
|
||||
sx={{
|
||||
"& .MuiBadge-badge": { zIndex: 1612 },
|
||||
|
||||
transform: `translate(${pointerOffset?.x}px, ${pointerOffset?.y}px)`,
|
||||
}}
|
||||
>
|
||||
<Paper
|
||||
elevation={3}
|
||||
sx={{
|
||||
width: size[0],
|
||||
height: size[1],
|
||||
zIndex: 1610,
|
||||
transition: (theme) => theme.transitions.create(["width", "height"]),
|
||||
}}
|
||||
{...rest}
|
||||
>
|
||||
<Header>
|
||||
<FileSmallIcon ignoreHovered selected={false} file={files[0]} />
|
||||
<FileNameText variant="body2">{files[0]?.name}</FileNameText>
|
||||
</Header>
|
||||
</Paper>
|
||||
{[...Array(Math.min(2, files.length - 1)).keys()].map((i) => (
|
||||
<Paper
|
||||
sx={{
|
||||
position: "absolute",
|
||||
width: size[0],
|
||||
height: size[1],
|
||||
zIndex: 1610 - i - 1,
|
||||
top: (i + 1) * 4,
|
||||
left: (i + 1) * 4,
|
||||
transition: (theme) => theme.transitions.create(["width", "height"]),
|
||||
}}
|
||||
elevation={3}
|
||||
/>
|
||||
))}
|
||||
</Badge>
|
||||
);
|
||||
};
|
||||
|
||||
const DragLayer = () => {
|
||||
DisableDropDelay();
|
||||
|
||||
const { itemType, isDragging, item, pointerOffset } = useDragLayer((monitor) => ({
|
||||
item: monitor.getItem(),
|
||||
itemType: monitor.getItemType(),
|
||||
pointerOffset: monitor.getClientOffset(),
|
||||
isDragging: monitor.isDragging(),
|
||||
}));
|
||||
|
||||
const selected = useAppSelector((state) => state.fileManager[0].selected);
|
||||
const draggingFiles = useMemo(() => {
|
||||
if (item && (item as DragItem) && item.target) {
|
||||
const selectedList = item.includeSelected
|
||||
? Object.keys(selected)
|
||||
.map((key) => selected[key])
|
||||
.filter((x) => x.path != item.target.path)
|
||||
: [];
|
||||
return [item.target, ...selectedList];
|
||||
}
|
||||
|
||||
return [];
|
||||
}, [selected, item]);
|
||||
|
||||
if (!isDragging) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<Box
|
||||
sx={{
|
||||
position: "fixed",
|
||||
pointerEvents: "none",
|
||||
zIndex: 1600,
|
||||
left: 0,
|
||||
top: 0,
|
||||
width: "100%",
|
||||
height: "100%",
|
||||
}}
|
||||
>
|
||||
<DragPreview files={draggingFiles} pointerOffset={pointerOffset} />
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
export default DragLayer;
|
||||
72
src/component/FileManager/Dnd/useDndScrolling.ts
Executable file
72
src/component/FileManager/Dnd/useDndScrolling.ts
Executable file
@@ -0,0 +1,72 @@
|
||||
import { useRef } from "react";
|
||||
import { throttle } from "lodash";
|
||||
|
||||
const threshold = 0.1;
|
||||
|
||||
const useDragScrolling = (containers: string[]) => {
|
||||
const isScrolling = useRef(false);
|
||||
const targets = containers.map((id) => document.querySelector(id) as HTMLElement);
|
||||
const rects = useRef<DOMRect[]>([]);
|
||||
|
||||
const goDown = (target: HTMLElement) => {
|
||||
return () => {
|
||||
target.scrollTop += 5;
|
||||
|
||||
const { offsetHeight, scrollTop, scrollHeight } = target;
|
||||
const isScrollEnd = offsetHeight + scrollTop >= scrollHeight;
|
||||
|
||||
if (isScrolling.current && !isScrollEnd) {
|
||||
window.requestAnimationFrame(goDown(target));
|
||||
}
|
||||
};
|
||||
};
|
||||
|
||||
const goUp = (target: HTMLElement) => {
|
||||
return () => {
|
||||
target.scrollTop -= 5;
|
||||
if (isScrolling.current && target.scrollTop > 0) {
|
||||
window.requestAnimationFrame(goUp(target));
|
||||
}
|
||||
};
|
||||
};
|
||||
|
||||
const onDragOver = (event: MouseEvent) => {
|
||||
// detect if mouse is in any rect
|
||||
rects.current.forEach((rect, index) => {
|
||||
if (event.clientX < rect.left || event.clientX > rect.right) {
|
||||
isScrolling.current = false;
|
||||
return;
|
||||
}
|
||||
|
||||
const height = rect.bottom - rect.top;
|
||||
if (event.clientY > rect.top && event.clientY < rect.top + threshold * height) {
|
||||
isScrolling.current = true;
|
||||
window.requestAnimationFrame(goUp(targets[index]));
|
||||
} else if (event.clientY < rect.bottom && event.clientY > rect.bottom - threshold * height) {
|
||||
isScrolling.current = true;
|
||||
window.requestAnimationFrame(goDown(targets[index]));
|
||||
} else {
|
||||
isScrolling.current = false;
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
const throttleOnDragOver = throttle(onDragOver, 300);
|
||||
|
||||
const addEventListenerForWindow = () => {
|
||||
rects.current = targets.map((t) => t.getBoundingClientRect());
|
||||
window.addEventListener("dragover", throttleOnDragOver, false);
|
||||
};
|
||||
|
||||
const removeEventListenerForWindow = () => {
|
||||
window.removeEventListener("dragover", throttleOnDragOver, false);
|
||||
isScrolling.current = false;
|
||||
};
|
||||
|
||||
return {
|
||||
addEventListenerForWindow,
|
||||
removeEventListenerForWindow,
|
||||
};
|
||||
};
|
||||
|
||||
export default useDragScrolling;
|
||||
24
src/component/FileManager/Explorer/EmojiIcon.tsx
Executable file
24
src/component/FileManager/Explorer/EmojiIcon.tsx
Executable file
@@ -0,0 +1,24 @@
|
||||
import { SvgIconProps, Typography } from "@mui/material";
|
||||
|
||||
export interface EmojiIconProps extends SvgIconProps {
|
||||
emoji: string;
|
||||
}
|
||||
|
||||
const EmojiIcon = ({ sx, fontSize, emoji, ...rest }: EmojiIconProps) => {
|
||||
return (
|
||||
<Typography
|
||||
sx={{
|
||||
color: (theme) => theme.palette.text.primary,
|
||||
minWidth: "24px",
|
||||
pl: "4px",
|
||||
...sx,
|
||||
}}
|
||||
fontSize={fontSize}
|
||||
{...rest}
|
||||
>
|
||||
{emoji}
|
||||
</Typography>
|
||||
);
|
||||
};
|
||||
|
||||
export default EmojiIcon;
|
||||
229
src/component/FileManager/Explorer/EmptyFileList.tsx
Executable file
229
src/component/FileManager/Explorer/EmptyFileList.tsx
Executable file
@@ -0,0 +1,229 @@
|
||||
import {
|
||||
Alert,
|
||||
AlertTitle,
|
||||
Box,
|
||||
ListItemIcon,
|
||||
ListItemText,
|
||||
MenuList,
|
||||
Paper,
|
||||
Stack,
|
||||
Tooltip,
|
||||
Typography,
|
||||
} from "@mui/material";
|
||||
import { grey } from "@mui/material/colors";
|
||||
import React, { memo, useContext, useMemo } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { NavigatorCapability } from "../../../api/explorer.ts";
|
||||
import { useAppSelector } from "../../../redux/hooks.ts";
|
||||
import { isMacbook } from "../../../redux/thunks/file.ts";
|
||||
import Boolset from "../../../util/boolset.ts";
|
||||
import { Filesystem } from "../../../util/uri.ts";
|
||||
import Nothing from "../../Common/Nothing.tsx";
|
||||
import { KeyIndicator } from "../../Frame/NavBar/SearchBar.tsx";
|
||||
import ArrowSync from "../../Icons/ArrowSync.tsx";
|
||||
import Border from "../../Icons/Border.tsx";
|
||||
import BorderAll from "../../Icons/BorderAll.tsx";
|
||||
import BorderInside from "../../Icons/BorderInside.tsx";
|
||||
import FolderLink from "../../Icons/FolderLink.tsx";
|
||||
import MoreHorizontal from "../../Icons/MoreHorizontal.tsx";
|
||||
import PinOutlined from "../../Icons/PinOutlined.tsx";
|
||||
import { DenseDivider, SquareMenuItem } from "../ContextMenu/ContextMenu.tsx";
|
||||
import { FmIndexContext } from "../FmIndexContext.tsx";
|
||||
import { ActionButton, ActionButtonGroup } from "../TopBar/TopActions.tsx";
|
||||
|
||||
interface EmptyFileListProps {
|
||||
[key: string]: any;
|
||||
}
|
||||
|
||||
export const SearchLimitReached = () => {
|
||||
const { t } = useTranslation("application");
|
||||
return (
|
||||
<Alert severity="warning">
|
||||
<AlertTitle> {t("fileManager.recursiveLimitReached")}</AlertTitle>
|
||||
{t("fileManager.recursiveLimitReachedDes")}
|
||||
</Alert>
|
||||
);
|
||||
};
|
||||
|
||||
export const SharedWithMeEmpty = () => {
|
||||
const { t } = useTranslation("application");
|
||||
|
||||
return (
|
||||
<Stack spacing={1}>
|
||||
<Box
|
||||
sx={{
|
||||
width: "300px",
|
||||
height: "200px",
|
||||
overflow: "hidden",
|
||||
backgroundColor: (t) => (t.palette.mode == "dark" ? grey[900] : grey[100]),
|
||||
borderRadius: (t) => `${t.shape.borderRadius}px`,
|
||||
p: 1,
|
||||
position: "relative",
|
||||
"&::after": {
|
||||
content: '""',
|
||||
position: "absolute",
|
||||
bottom: 0,
|
||||
left: 0,
|
||||
width: "100%",
|
||||
height: "50px",
|
||||
background: (t) =>
|
||||
`linear-gradient(to bottom, transparent, ${t.palette.mode == "dark" ? grey[900] : grey[100]})`,
|
||||
pointerEvents: "none",
|
||||
},
|
||||
}}
|
||||
>
|
||||
<Box
|
||||
sx={{
|
||||
display: "flex",
|
||||
pointerEvents: "none",
|
||||
|
||||
flexDirection: "column",
|
||||
justifyContent: "flex-end",
|
||||
}}
|
||||
>
|
||||
<Box sx={{ display: "flex", gap: 1 }}>
|
||||
<Box
|
||||
sx={{
|
||||
flexGrow: 1,
|
||||
border: (t) => `1px solid ${t.palette.divider}`,
|
||||
backgroundColor: (t) => t.palette.background.paper,
|
||||
height: "42px",
|
||||
borderRadius: (t) => `${t.shape.borderRadius}px`,
|
||||
}}
|
||||
/>
|
||||
<ActionButtonGroup
|
||||
variant="outlined"
|
||||
sx={{
|
||||
backgroundColor: (t) => t.palette.background.paper,
|
||||
height: "42px",
|
||||
}}
|
||||
>
|
||||
<Tooltip enterDelay={200} title={t("application:fileManager.refresh")}>
|
||||
<ActionButton>
|
||||
<ArrowSync fontSize={"small"} />
|
||||
</ActionButton>
|
||||
</Tooltip>
|
||||
<ActionButton
|
||||
sx={{
|
||||
border: (t) => `1px solid ${t.palette.primary.main}`,
|
||||
}}
|
||||
>
|
||||
<MoreHorizontal fontSize={"small"} />
|
||||
</ActionButton>
|
||||
</ActionButtonGroup>
|
||||
</Box>
|
||||
<Box sx={{ display: "flex", justifyContent: "flex-end" }}>
|
||||
<Paper elevation={3} sx={{ borderRadius: "8px" }}>
|
||||
<MenuList dense sx={{ padding: "4px 0", minWidth: "200px" }}>
|
||||
<SquareMenuItem>
|
||||
<ListItemIcon>
|
||||
<PinOutlined fontSize="small" />
|
||||
</ListItemIcon>
|
||||
<ListItemText slotProps={{ primary: { variant: "body2" } }}>
|
||||
{t("application:fileManager.pin")}
|
||||
</ListItemText>
|
||||
</SquareMenuItem>
|
||||
<SquareMenuItem selected>
|
||||
<ListItemIcon>
|
||||
<FolderLink fontSize="small" />
|
||||
</ListItemIcon>
|
||||
<ListItemText slotProps={{ primary: { variant: "body2" } }}>
|
||||
{t("application:fileManager.saveShortcut")}
|
||||
</ListItemText>
|
||||
</SquareMenuItem>
|
||||
<DenseDivider />
|
||||
<SquareMenuItem>
|
||||
<ListItemIcon>
|
||||
<PinOutlined fontSize="small" />
|
||||
</ListItemIcon>
|
||||
<ListItemText slotProps={{ primary: { variant: "body2" } }}>
|
||||
{t("application:fileManager.pin")}
|
||||
</ListItemText>
|
||||
</SquareMenuItem>
|
||||
<SquareMenuItem>
|
||||
<ListItemIcon>
|
||||
<BorderAll fontSize="small" />
|
||||
</ListItemIcon>
|
||||
<ListItemText>{t("application:fileManager.selectAll")}</ListItemText>
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
<KeyIndicator>{isMacbook ? "⌘" : "Ctrl"}</KeyIndicator>+<KeyIndicator>A</KeyIndicator>
|
||||
</Typography>
|
||||
</SquareMenuItem>
|
||||
<SquareMenuItem>
|
||||
<ListItemIcon>
|
||||
<Border fontSize="small" />
|
||||
</ListItemIcon>
|
||||
<ListItemText>{t("application:fileManager.selectNone")}</ListItemText>
|
||||
</SquareMenuItem>
|
||||
<SquareMenuItem>
|
||||
<ListItemIcon>
|
||||
<BorderInside fontSize="small" />
|
||||
</ListItemIcon>
|
||||
<ListItemText>{t("application:fileManager.invertSelection")}</ListItemText>
|
||||
</SquareMenuItem>
|
||||
</MenuList>
|
||||
</Paper>
|
||||
</Box>
|
||||
</Box>
|
||||
</Box>
|
||||
<Stack spacing={1} sx={{ maxWidth: "400px" }}>
|
||||
<Typography variant="h6" fontWeight={600}>
|
||||
{t("application:fileManager.shareWithMeEmpty")}
|
||||
</Typography>
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
{t("application:fileManager.shareWithMeEmptyDes")}
|
||||
</Typography>
|
||||
</Stack>
|
||||
</Stack>
|
||||
);
|
||||
};
|
||||
|
||||
const EmptyFileList = memo(
|
||||
React.forwardRef(({ ...rest }: EmptyFileListProps, ref) => {
|
||||
const { t } = useTranslation("application");
|
||||
const fmIndex = useContext(FmIndexContext);
|
||||
const currentFs = useAppSelector((state) => state.fileManager[fmIndex]?.current_fs);
|
||||
const search_params = useAppSelector((state) => state.fileManager[fmIndex]?.search_params);
|
||||
const recursion_limit_reached = useAppSelector((state) => state.fileManager[fmIndex].list?.recursion_limit_reached);
|
||||
const capability = useAppSelector((state) => state.fileManager[fmIndex].list?.props.capability);
|
||||
|
||||
const canCreate = useMemo(() => {
|
||||
const bs = new Boolset(capability);
|
||||
return bs.enabled(NavigatorCapability.create_file);
|
||||
}, [capability]);
|
||||
|
||||
return (
|
||||
<Box
|
||||
ref={ref}
|
||||
{...rest}
|
||||
sx={{
|
||||
p: 2,
|
||||
height: "100%",
|
||||
display: "flex",
|
||||
justifyContent: "center",
|
||||
alignItems: "center",
|
||||
flexDirection: "column",
|
||||
...rest.sx,
|
||||
}}
|
||||
>
|
||||
{currentFs == Filesystem.shared_with_me && (
|
||||
<>
|
||||
<SharedWithMeEmpty />
|
||||
{recursion_limit_reached && <SearchLimitReached />}
|
||||
</>
|
||||
)}
|
||||
{currentFs != Filesystem.shared_with_me && (
|
||||
<>
|
||||
<Nothing
|
||||
primary={search_params || !canCreate ? t("fileManager.nothingFound") : t("fileManager.dropFileHere")}
|
||||
secondary={search_params || !canCreate ? undefined : t("fileManager.orClickUploadButton")}
|
||||
/>
|
||||
{recursion_limit_reached && <SearchLimitReached />}
|
||||
</>
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
}),
|
||||
);
|
||||
|
||||
export default EmptyFileList;
|
||||
160
src/component/FileManager/Explorer/Explorer.tsx
Executable file
160
src/component/FileManager/Explorer/Explorer.tsx
Executable file
@@ -0,0 +1,160 @@
|
||||
import { Box, useMediaQuery, useTheme } from "@mui/material";
|
||||
import React, { RefCallback, useCallback, useContext, useEffect, useMemo } from "react";
|
||||
import { CSSTransition, SwitchTransition } from "react-transition-group";
|
||||
import { useAreaSelection } from "../../../hooks/areaSelection.ts";
|
||||
import { useAppDispatch, useAppSelector } from "../../../redux/hooks.ts";
|
||||
import { ConfigLoadState } from "../../../redux/siteConfigSlice.ts";
|
||||
import { openEmptyContextMenu } from "../../../redux/thunks/filemanager.ts";
|
||||
import { loadSiteConfig } from "../../../redux/thunks/site.ts";
|
||||
import CircularProgress from "../../Common/CircularProgress.tsx";
|
||||
import "../../Common/FadeTransition.css";
|
||||
import { RadiusFrame } from "../../Frame/RadiusFrame.tsx";
|
||||
import ExplorerError from "./ExplorerError.tsx";
|
||||
import GridView, { FmFile } from "./GridView/GridView.tsx";
|
||||
|
||||
import { Layouts } from "../../../redux/fileManagerSlice.ts";
|
||||
import { SearchParam } from "../../../util/uri.ts";
|
||||
import { FileManagerIndex } from "../FileManager.tsx";
|
||||
import { FmIndexContext } from "../FmIndexContext.tsx";
|
||||
import EmptyFileList, { SearchLimitReached } from "./EmptyFileList.tsx";
|
||||
import GalleryView from "./GalleryView/GalleryView.tsx";
|
||||
import { ListViewColumn } from "./ListView/Column.tsx";
|
||||
import ListView from "./ListView/ListView.tsx";
|
||||
import SingleFileView from "./SingleFileView.tsx";
|
||||
|
||||
export const ExplorerPage = {
|
||||
Error: 1,
|
||||
Loading: 2,
|
||||
GridView: 0,
|
||||
SingleFileView: 3,
|
||||
Empty: 4,
|
||||
ListView: 5,
|
||||
GalleryView: 6,
|
||||
};
|
||||
|
||||
export interface FileBlockProps {
|
||||
showThumb?: boolean;
|
||||
file: FmFile;
|
||||
isDragging?: boolean;
|
||||
isDropOver?: boolean;
|
||||
dragRef?: RefCallback<any>;
|
||||
index?: number;
|
||||
search?: SearchParam;
|
||||
columns?: ListViewColumn[];
|
||||
boxHeight?: number;
|
||||
}
|
||||
|
||||
const Explorer = () => {
|
||||
const dispatch = useAppDispatch();
|
||||
const theme = useTheme();
|
||||
const isTouch = useMediaQuery("(pointer: coarse)");
|
||||
const isMobile = useMediaQuery(theme.breakpoints.down("sm"));
|
||||
const fmIndex = useContext(FmIndexContext);
|
||||
const loading = useAppSelector((state) => state.fileManager[fmIndex].loading);
|
||||
const error = useAppSelector((state) => state.fileManager[fmIndex].error);
|
||||
const showError = useAppSelector((state) => state.fileManager[fmIndex].showError);
|
||||
const singleFileView = useAppSelector((state) => state.fileManager[fmIndex].list?.single_file_view);
|
||||
const explorerConfigLoading = useAppSelector((state) => state.siteConfig.explorer.loaded);
|
||||
const files = useAppSelector((state) => state.fileManager[fmIndex].list?.files);
|
||||
const recursion_limit_reached = useAppSelector((state) => state.fileManager[fmIndex].list?.recursion_limit_reached);
|
||||
const layout = useAppSelector((state) => state.fileManager[fmIndex].layout);
|
||||
|
||||
const selectContainerRef = React.useRef<HTMLElement | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
dispatch(loadSiteConfig("explorer"));
|
||||
}, []);
|
||||
|
||||
const index = useMemo(() => {
|
||||
if (showError) {
|
||||
return ExplorerPage.Error;
|
||||
} else if (loading || explorerConfigLoading == ConfigLoadState.NotLoaded) {
|
||||
return ExplorerPage.Loading;
|
||||
} else {
|
||||
if (files?.length === 0) {
|
||||
return ExplorerPage.Empty;
|
||||
}
|
||||
|
||||
if (singleFileView && fmIndex == FileManagerIndex.main) {
|
||||
return ExplorerPage.SingleFileView;
|
||||
}
|
||||
|
||||
switch (layout) {
|
||||
case Layouts.grid:
|
||||
return ExplorerPage.GridView;
|
||||
case Layouts.list:
|
||||
return ExplorerPage.ListView;
|
||||
case Layouts.gallery:
|
||||
return ExplorerPage.GalleryView;
|
||||
default:
|
||||
return ExplorerPage.GridView;
|
||||
}
|
||||
}
|
||||
}, [loading, showError, explorerConfigLoading, singleFileView, fmIndex, files?.length, layout]);
|
||||
|
||||
const enableAreaSelection = index == ExplorerPage.GridView;
|
||||
|
||||
const [handleMouseDown, handleMouseUp, handleMouseMove] = useAreaSelection(
|
||||
selectContainerRef,
|
||||
fmIndex,
|
||||
enableAreaSelection,
|
||||
);
|
||||
|
||||
const onContextMenu = useCallback(
|
||||
(e: React.MouseEvent<HTMLElement>) => {
|
||||
if (index == ExplorerPage.Error || index == ExplorerPage.Loading) return;
|
||||
dispatch(openEmptyContextMenu(fmIndex, e));
|
||||
},
|
||||
[dispatch, index],
|
||||
);
|
||||
|
||||
return (
|
||||
<RadiusFrame
|
||||
withBorder={!isMobile}
|
||||
square={isMobile}
|
||||
sx={{ flexGrow: 1, overflow: "auto" }}
|
||||
ref={selectContainerRef}
|
||||
onContextMenu={onContextMenu}
|
||||
onMouseDown={isMobile || isTouch ? undefined : handleMouseDown}
|
||||
onMouseUp={isMobile || isTouch ? undefined : handleMouseUp}
|
||||
onMouseMove={isMobile || isTouch ? undefined : handleMouseMove}
|
||||
>
|
||||
<SwitchTransition>
|
||||
<CSSTransition
|
||||
timeout={500}
|
||||
addEndListener={(node, done) => node.addEventListener("transitionend", done, false)}
|
||||
classNames="fade"
|
||||
key={index}
|
||||
>
|
||||
<Box sx={{ height: "100%" }}>
|
||||
{index == ExplorerPage.Error && <ExplorerError error={error} />}
|
||||
{index == ExplorerPage.Loading && (
|
||||
<Box
|
||||
sx={{
|
||||
height: "100%",
|
||||
display: "flex",
|
||||
justifyContent: "center",
|
||||
alignItems: "center",
|
||||
}}
|
||||
>
|
||||
<CircularProgress />
|
||||
</Box>
|
||||
)}
|
||||
{index == ExplorerPage.GridView && <GridView />}
|
||||
{index == ExplorerPage.SingleFileView && <SingleFileView />}
|
||||
{index == ExplorerPage.Empty && <EmptyFileList />}
|
||||
{index == ExplorerPage.ListView && <ListView />}
|
||||
{index == ExplorerPage.GalleryView && <GalleryView />}
|
||||
{recursion_limit_reached && (index == ExplorerPage.GridView || index == ExplorerPage.GalleryView) && (
|
||||
<Box sx={{ px: 2, pb: 1 }}>
|
||||
<SearchLimitReached />
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
</CSSTransition>
|
||||
</SwitchTransition>
|
||||
</RadiusFrame>
|
||||
);
|
||||
};
|
||||
|
||||
export default Explorer;
|
||||
134
src/component/FileManager/Explorer/ExplorerError.tsx
Executable file
134
src/component/FileManager/Explorer/ExplorerError.tsx
Executable file
@@ -0,0 +1,134 @@
|
||||
import { Alert, AlertTitle, Box, Button, Typography } from "@mui/material";
|
||||
import React, { memo, useCallback, useContext, useMemo, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { AppError, Code, Response } from "../../../api/request.ts";
|
||||
import { useAppDispatch, useAppSelector } from "../../../redux/hooks.ts";
|
||||
import { navigateToPath, retrySharePassword } from "../../../redux/thunks/filemanager.ts";
|
||||
import { Filesystem } from "../../../util/uri.ts";
|
||||
import { FilledTextField, SecondaryButton } from "../../Common/StyledComponents.tsx";
|
||||
import ArrowLeft from "../../Icons/ArrowLeft.tsx";
|
||||
import LinkDismiss from "../../Icons/LinkDismiss.tsx";
|
||||
import LockClosed from "../../Icons/LockClosed.tsx";
|
||||
import { FmIndexContext } from "../FmIndexContext.tsx";
|
||||
|
||||
interface ExplorerErrorProps {
|
||||
error?: Response<any>;
|
||||
[key: string]: any;
|
||||
}
|
||||
|
||||
const RetryPassword = () => {
|
||||
const { t } = useTranslation();
|
||||
const dispatch = useAppDispatch();
|
||||
const fmIndex = useContext(FmIndexContext);
|
||||
const [password, setPassword] = useState("");
|
||||
|
||||
return (
|
||||
<Box sx={{ textAlign: "center" }} onClick={(e) => e.stopPropagation()} onMouseDown={(e) => e.stopPropagation()}>
|
||||
<LockClosed sx={{ fontSize: 80 }} color={"action"} />
|
||||
<Box sx={{ mt: 1 }}>
|
||||
<FilledTextField
|
||||
variant={"filled"}
|
||||
autoFocus
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
label={t("application:share.enterPassword")}
|
||||
/>
|
||||
<Button
|
||||
disabled={password == ""}
|
||||
onClick={() => dispatch(retrySharePassword(fmIndex, password))}
|
||||
variant={"contained"}
|
||||
sx={{ ml: 1, height: "56px" }}
|
||||
>
|
||||
<ArrowLeft
|
||||
sx={{
|
||||
transform: "scaleX(-1)",
|
||||
}}
|
||||
/>
|
||||
</Button>
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
const ExplorerError = memo(
|
||||
React.forwardRef(({ error, ...rest }: ExplorerErrorProps, ref) => {
|
||||
const dispatch = useAppDispatch();
|
||||
const navigate = useNavigate();
|
||||
const fmIndex = useContext(FmIndexContext);
|
||||
const fs = useAppSelector((state) => state.fileManager[fmIndex].current_fs);
|
||||
const previousPath = useAppSelector((state) => state.fileManager[fmIndex].previous_path);
|
||||
const { t } = useTranslation("application");
|
||||
const appErr = useMemo(() => {
|
||||
if (error) {
|
||||
return new AppError(error);
|
||||
}
|
||||
|
||||
return undefined;
|
||||
}, [error]);
|
||||
const navigateBack = useCallback(() => {
|
||||
previousPath && dispatch(navigateToPath(fmIndex, previousPath));
|
||||
}, [dispatch, fmIndex, previousPath]);
|
||||
|
||||
const signIn = useCallback(() => {
|
||||
navigate("/session?redirect=" + encodeURIComponent(window.location.pathname + window.location.search));
|
||||
}, [navigate]);
|
||||
|
||||
const innerError = () => {
|
||||
switch (error?.code) {
|
||||
case Code.AnonymouseAccessDenied:
|
||||
return (
|
||||
<Box sx={{ textAlign: "center" }}>
|
||||
<LockClosed sx={{ fontSize: 60 }} color={"action"} />
|
||||
<Typography color={"text.secondary"}>{t("application:fileManager.anonymousAccessDenied")}</Typography>
|
||||
<SecondaryButton variant={"contained"} color={"inherit"} onClick={signIn} sx={{ mt: 4 }}>
|
||||
{t("application:login.signIn")}
|
||||
</SecondaryButton>
|
||||
</Box>
|
||||
);
|
||||
case Code.IncorrectPassword:
|
||||
return <RetryPassword />;
|
||||
// @ts-ignore
|
||||
case Code.NodeFound:
|
||||
if (fs == Filesystem.share) {
|
||||
return (
|
||||
<Box sx={{ textAlign: "center" }}>
|
||||
<LinkDismiss sx={{ fontSize: 60 }} color={"action"} />
|
||||
<Typography color={"text.secondary"}>{t("application:share.shareNotExist")}</Typography>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
default:
|
||||
return (
|
||||
<Alert severity="warning">
|
||||
<AlertTitle> {t("application:fileManager.listError")}</AlertTitle>
|
||||
{appErr && appErr.message}
|
||||
{error?.correlation_id && (
|
||||
<Box sx={{ typography: "caption", mt: 2, opacity: 0.5 }}>
|
||||
<code>{t("common:requestID", { id: error.correlation_id })}</code>
|
||||
</Box>
|
||||
)}
|
||||
</Alert>
|
||||
);
|
||||
}
|
||||
};
|
||||
return (
|
||||
<Box
|
||||
ref={ref}
|
||||
{...rest}
|
||||
sx={{
|
||||
p: 2,
|
||||
height: "100%",
|
||||
display: "flex",
|
||||
justifyContent: "center",
|
||||
alignItems: "center",
|
||||
...rest.sx,
|
||||
}}
|
||||
>
|
||||
{innerError()}
|
||||
</Box>
|
||||
);
|
||||
}),
|
||||
);
|
||||
|
||||
export default ExplorerError;
|
||||
116
src/component/FileManager/Explorer/FileIcon.tsx
Executable file
116
src/component/FileManager/Explorer/FileIcon.tsx
Executable file
@@ -0,0 +1,116 @@
|
||||
import { Avatar, Badge, BadgeProps, Box, Skeleton, styled, SvgIconProps, Tooltip } from "@mui/material";
|
||||
import { forwardRef, memo, useMemo } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { FileResponse, FileType, Metadata } from "../../../api/explorer.ts";
|
||||
import UserAvatar from "../../Common/User/UserAvatar.tsx";
|
||||
import ShareAndroid from "../../Icons/ShareAndroid.tsx";
|
||||
import EmojiIcon from "./EmojiIcon.tsx";
|
||||
import FileTypeIcon from "./FileTypeIcon.tsx";
|
||||
|
||||
export interface FileIconProps {
|
||||
file?: FileResponse;
|
||||
variant?: "default" | "small" | "large";
|
||||
loading?: boolean;
|
||||
notLoaded?: boolean;
|
||||
[key: string]: any;
|
||||
iconProps?: SvgIconProps;
|
||||
}
|
||||
|
||||
interface StyledBadgeProps extends BadgeProps {
|
||||
iconVariant?: "default" | "small" | "large" | "largeMobile" | "shareSingle";
|
||||
}
|
||||
|
||||
const StyledBadge = styled(Badge)<StyledBadgeProps>(({ iconVariant }) => ({
|
||||
"& .MuiBadge-badge": {
|
||||
right: 3,
|
||||
top: variantTop[iconVariant ?? "default"],
|
||||
padding: "0",
|
||||
height: "initial",
|
||||
minWidth: "initial",
|
||||
},
|
||||
verticalAlign: "initial",
|
||||
}));
|
||||
|
||||
const variantTop = {
|
||||
default: 18,
|
||||
small: 15,
|
||||
large: 70,
|
||||
largeMobile: 52,
|
||||
shareSingle: 26,
|
||||
};
|
||||
|
||||
const variantAvatarSize = {
|
||||
default: 16,
|
||||
small: 13,
|
||||
large: 32,
|
||||
largeMobile: 24,
|
||||
shareSingle: 20,
|
||||
};
|
||||
|
||||
const FileIcon = memo(
|
||||
forwardRef(({ file, loading, variant = "default", iconProps, notLoaded, sx, ...rest }: FileIconProps, ref) => {
|
||||
const { t } = useTranslation();
|
||||
const iconColor = useMemo(() => {
|
||||
if (file && file.metadata && file.metadata[Metadata.icon_color]) {
|
||||
return file.metadata[Metadata.icon_color];
|
||||
}
|
||||
}, [file]);
|
||||
const typedIcon = useMemo(() => {
|
||||
if (file?.metadata?.[Metadata.emoji]) {
|
||||
const { sx, ...restIcon } = iconProps ?? {};
|
||||
return <EmojiIcon sx={sx} emoji={file.metadata[Metadata.emoji]} />;
|
||||
}
|
||||
return (
|
||||
<FileTypeIcon
|
||||
name={file?.name ?? ""}
|
||||
fileType={file?.type ?? FileType.folder}
|
||||
color={"action"}
|
||||
notLoaded={notLoaded}
|
||||
customizedColor={iconColor ?? ""}
|
||||
{...iconProps}
|
||||
/>
|
||||
);
|
||||
}, [file, iconColor, iconProps, notLoaded]);
|
||||
const badgeContent = useMemo(() => {
|
||||
const avatarSize = variantAvatarSize[variant];
|
||||
if (file?.metadata?.[Metadata.share_redirect]) {
|
||||
return (
|
||||
<UserAvatar
|
||||
overwriteTextSize
|
||||
sx={{ width: avatarSize, height: avatarSize }}
|
||||
uid={file.metadata[Metadata.share_owner]}
|
||||
/>
|
||||
);
|
||||
} else if (file?.shared) {
|
||||
return (
|
||||
<Tooltip title={t("application:fileManager.sharedWithOthers")}>
|
||||
<Avatar
|
||||
sx={{
|
||||
width: avatarSize,
|
||||
height: avatarSize,
|
||||
bgcolor: (theme) => theme.palette.background.default,
|
||||
}}
|
||||
>
|
||||
<ShareAndroid sx={{ fontSize: `${avatarSize - 0}px!important` }} color={"action"} />
|
||||
</Avatar>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
}, [file, variant]);
|
||||
return (
|
||||
<Box ref={ref} sx={{ px: 2, py: 1.5, ...sx }} {...rest}>
|
||||
{!loading &&
|
||||
(badgeContent ? (
|
||||
<StyledBadge iconVariant={variant} badgeContent={badgeContent}>
|
||||
{typedIcon}
|
||||
</StyledBadge>
|
||||
) : (
|
||||
typedIcon
|
||||
))}
|
||||
{loading && <Skeleton variant="circular" width={24} height={24} />}
|
||||
</Box>
|
||||
);
|
||||
}),
|
||||
);
|
||||
|
||||
export default FileIcon;
|
||||
95
src/component/FileManager/Explorer/FileSmallIcon.tsx
Executable file
95
src/component/FileManager/Explorer/FileSmallIcon.tsx
Executable file
@@ -0,0 +1,95 @@
|
||||
import { Box, Fade } from "@mui/material";
|
||||
import { memo, useCallback, useContext } from "react";
|
||||
import { TransitionGroup } from "react-transition-group";
|
||||
import { FileResponse } from "../../../api/explorer.ts";
|
||||
import { useAppDispatch, useAppSelector } from "../../../redux/hooks.ts";
|
||||
import { fileIconClicked } from "../../../redux/thunks/file.ts";
|
||||
import CheckmarkCircle from "../../Icons/CheckmarkCircle.tsx";
|
||||
import CheckUnchecked from "../../Icons/CheckUnchecked.tsx";
|
||||
import FileIcon from "./FileIcon.tsx";
|
||||
|
||||
import { FmIndexContext } from "../FmIndexContext.tsx";
|
||||
|
||||
export interface FileSmallIconProps {
|
||||
selected: boolean;
|
||||
file: FileResponse;
|
||||
loading?: boolean;
|
||||
ignoreHovered?: boolean;
|
||||
variant?: "list" | "grid";
|
||||
}
|
||||
|
||||
const FileSmallIcon = memo(({ selected, variant, loading, file, ignoreHovered }: FileSmallIconProps) => {
|
||||
const dispatch = useAppDispatch();
|
||||
const fmIndex = useContext(FmIndexContext);
|
||||
const hovered = useAppSelector((state) => state.fileManager[fmIndex].multiSelectHovered[file.path]);
|
||||
const onIconClick = useCallback(
|
||||
(e: React.MouseEvent<HTMLElement>) => {
|
||||
if (!loading) {
|
||||
return dispatch(fileIconClicked(fmIndex, file, e));
|
||||
}
|
||||
},
|
||||
[file, loading, dispatch],
|
||||
);
|
||||
const isInList = variant === "list";
|
||||
return (
|
||||
<TransitionGroup onClick={onIconClick}>
|
||||
{!selected && (!hovered || ignoreHovered) && (
|
||||
<Fade>
|
||||
<FileIcon
|
||||
file={file}
|
||||
loading={loading}
|
||||
sx={
|
||||
isInList
|
||||
? {
|
||||
position: "absolute",
|
||||
p: 0,
|
||||
}
|
||||
: { position: "absolute" }
|
||||
}
|
||||
/>
|
||||
</Fade>
|
||||
)}
|
||||
{!selected && hovered && !ignoreHovered && (
|
||||
<Fade>
|
||||
<Box sx={{ position: "absolute" }}>
|
||||
<CheckUnchecked
|
||||
sx={{
|
||||
width: isInList ? "20px" : "24px",
|
||||
height: "24px",
|
||||
mx: isInList ? "2px" : 2,
|
||||
my: isInList ? 0 : 1.5,
|
||||
position: "absolute",
|
||||
}}
|
||||
color={"action"}
|
||||
/>
|
||||
</Box>
|
||||
</Fade>
|
||||
)}
|
||||
{selected && (
|
||||
<Fade>
|
||||
<Box sx={{ position: "absolute" }}>
|
||||
<CheckmarkCircle
|
||||
sx={{
|
||||
width: isInList ? "20px" : "24px",
|
||||
height: "24px",
|
||||
mx: isInList ? "2px" : 2,
|
||||
my: isInList ? 0 : 1.5,
|
||||
}}
|
||||
color={"primary"}
|
||||
/>
|
||||
</Box>
|
||||
</Fade>
|
||||
)}
|
||||
<Box
|
||||
sx={{
|
||||
width: "24px",
|
||||
height: "24px",
|
||||
mx: isInList ? "2px" : 2,
|
||||
my: isInList ? 0 : 1.5,
|
||||
}}
|
||||
/>
|
||||
</TransitionGroup>
|
||||
);
|
||||
});
|
||||
|
||||
export default FileSmallIcon;
|
||||
73
src/component/FileManager/Explorer/FileTag.tsx
Executable file
73
src/component/FileManager/Explorer/FileTag.tsx
Executable file
@@ -0,0 +1,73 @@
|
||||
import { Chip, ChipProps, darken, styled, Tooltip, useTheme } from "@mui/material";
|
||||
import { useCallback, useContext } from "react";
|
||||
import { useAppDispatch, useAppSelector } from "../../../redux/hooks.ts";
|
||||
import { Metadata } from "../../../api/explorer.ts";
|
||||
import { searchMetadata } from "../../../redux/thunks/filemanager.ts";
|
||||
import { FmIndexContext } from "../FmIndexContext.tsx";
|
||||
|
||||
export const TagChip = styled(Chip)<{ defaultStyle?: boolean }>(({ defaultStyle }) => {
|
||||
const base = {
|
||||
"& .MuiChip-deleteIcon": {},
|
||||
};
|
||||
if (!defaultStyle) return { ...base, height: 18, minWidth: 32 };
|
||||
return base;
|
||||
});
|
||||
|
||||
export interface FileTagProps extends ChipProps {
|
||||
tagColor?: string;
|
||||
defaultStyle?: boolean;
|
||||
spacing?: number;
|
||||
openInNewTab?: boolean;
|
||||
disableClick?: boolean;
|
||||
}
|
||||
|
||||
const FileTag = ({ disableClick, tagColor, sx, label, defaultStyle, spacing, openInNewTab, ...rest }: FileTagProps) => {
|
||||
const theme = useTheme();
|
||||
const fmIndex = useContext(FmIndexContext);
|
||||
const dispatch = useAppDispatch();
|
||||
const root = useAppSelector((state) => state.fileManager[fmIndex].path_root);
|
||||
const stopPropagation = useCallback(
|
||||
(e: any) => {
|
||||
if (!disableClick) e.stopPropagation();
|
||||
},
|
||||
[disableClick],
|
||||
);
|
||||
const onClick = useCallback(
|
||||
(e: any) => {
|
||||
if (disableClick) {
|
||||
return;
|
||||
}
|
||||
e.stopPropagation();
|
||||
dispatch(searchMetadata(fmIndex, Metadata.tag_prefix + label, tagColor, openInNewTab));
|
||||
},
|
||||
[root, dispatch, fmIndex, disableClick],
|
||||
);
|
||||
|
||||
const hackColor =
|
||||
!!tagColor && theme.palette.getContrastText(tagColor) != theme.palette.text.primary ? "error" : undefined;
|
||||
return (
|
||||
<Tooltip title={label}>
|
||||
<TagChip
|
||||
defaultStyle={defaultStyle}
|
||||
sx={[
|
||||
!!tagColor && {
|
||||
backgroundColor: tagColor,
|
||||
color: (theme) => theme.palette.getContrastText(tagColor),
|
||||
"&:hover": {
|
||||
backgroundColor: darken(tagColor, 0.1),
|
||||
},
|
||||
},
|
||||
spacing !== undefined && { mr: spacing },
|
||||
]}
|
||||
onClick={onClick}
|
||||
onMouseDown={stopPropagation}
|
||||
size="small"
|
||||
label={label}
|
||||
color={hackColor}
|
||||
{...rest}
|
||||
/>
|
||||
</Tooltip>
|
||||
);
|
||||
};
|
||||
|
||||
export default FileTag;
|
||||
91
src/component/FileManager/Explorer/FileTagSummary.tsx
Executable file
91
src/component/FileManager/Explorer/FileTagSummary.tsx
Executable file
@@ -0,0 +1,91 @@
|
||||
import * as React from "react";
|
||||
import { memo, useCallback, useMemo } from "react";
|
||||
import { Box, Popover, Stack, useMediaQuery, useTheme } from "@mui/material";
|
||||
import { bindHover, bindPopover } from "material-ui-popup-state";
|
||||
import { bindTrigger, usePopupState } from "material-ui-popup-state/hooks";
|
||||
import HoverPopover from "material-ui-popup-state/HoverPopover";
|
||||
import FileTag, { TagChip } from "./FileTag.tsx";
|
||||
|
||||
export interface FileTagSummaryProps {
|
||||
tags: { key: string; value: string }[];
|
||||
max?: number;
|
||||
[key: string]: any;
|
||||
}
|
||||
|
||||
const FileTagSummary = memo(({ tags, sx, max = 1, ...restProps }: FileTagSummaryProps) => {
|
||||
const theme = useTheme();
|
||||
const isMobile = useMediaQuery(theme.breakpoints.down("md"));
|
||||
const popupState = usePopupState({
|
||||
variant: "popover",
|
||||
popupId: "demoMenu",
|
||||
});
|
||||
|
||||
const { open, ...rest } = bindPopover(popupState);
|
||||
const stopPropagation = useCallback((e: any) => e.stopPropagation(), []);
|
||||
const [shown, hidden] = useMemo(() => {
|
||||
if (tags.length <= max) {
|
||||
return [tags, []];
|
||||
}
|
||||
return [tags.slice(0, max), tags.slice(max)];
|
||||
}, [tags, max]);
|
||||
|
||||
const { onClick, ...triggerProps } = bindTrigger(popupState);
|
||||
const onMobileClick = (e: React.MouseEvent<HTMLElement>) => {
|
||||
e.stopPropagation();
|
||||
onClick(e);
|
||||
};
|
||||
|
||||
const PopoverComponent = isMobile ? Popover : HoverPopover;
|
||||
|
||||
return (
|
||||
<Stack direction={"row"} spacing={1} sx={{ ...sx }} {...restProps}>
|
||||
{shown.map((tag) => (
|
||||
<FileTag tagColor={tag.value == "" ? undefined : tag.value} label={tag.key} key={tag.key} />
|
||||
))}
|
||||
{hidden.length > 0 && (
|
||||
<TagChip
|
||||
size="small"
|
||||
variant={"outlined"}
|
||||
label={`+${hidden.length}`}
|
||||
{...(isMobile
|
||||
? {
|
||||
onClick: onMobileClick,
|
||||
...triggerProps,
|
||||
}
|
||||
: bindHover(popupState))}
|
||||
/>
|
||||
)}
|
||||
|
||||
{open && (
|
||||
<PopoverComponent
|
||||
onMouseDown={stopPropagation}
|
||||
onMouseUp={stopPropagation}
|
||||
onClick={stopPropagation}
|
||||
open={open}
|
||||
{...rest}
|
||||
anchorOrigin={{
|
||||
vertical: "bottom",
|
||||
horizontal: "center",
|
||||
}}
|
||||
transformOrigin={{
|
||||
vertical: "top",
|
||||
horizontal: "center",
|
||||
}}
|
||||
>
|
||||
<Box sx={{ p: 1, maxWidth: "300px" }}>
|
||||
{hidden.map((tag, i) => (
|
||||
<FileTag
|
||||
tagColor={tag.value == "" ? undefined : tag.value}
|
||||
label={tag.key}
|
||||
spacing={i === hidden.length - 1 ? undefined : 1}
|
||||
key={tag.key}
|
||||
/>
|
||||
))}
|
||||
</Box>
|
||||
</PopoverComponent>
|
||||
)}
|
||||
</Stack>
|
||||
);
|
||||
});
|
||||
|
||||
export default FileTagSummary;
|
||||
206
src/component/FileManager/Explorer/FileTypeIcon.tsx
Executable file
206
src/component/FileManager/Explorer/FileTypeIcon.tsx
Executable file
@@ -0,0 +1,206 @@
|
||||
import { Icon as IconifyIcon } from "@iconify/react/dist/iconify.js";
|
||||
import { Android } from "@mui/icons-material";
|
||||
import { Box, SvgIconProps, useTheme } from "@mui/material";
|
||||
import SvgIcon from "@mui/material/SvgIcon/SvgIcon";
|
||||
import { useMemo } from "react";
|
||||
import { useAppSelector } from "../../../redux/hooks.ts";
|
||||
import { fileExtension } from "../../../util";
|
||||
import Book from "../../Icons/Book.tsx";
|
||||
import Document from "../../Icons/Document.tsx";
|
||||
import DocumentFlowchart from "../../Icons/DocumentFlowchart.tsx";
|
||||
import DocumentPDF from "../../Icons/DocumentPDF.tsx";
|
||||
import FileExclBox from "../../Icons/FileExclBox.tsx";
|
||||
import FilePowerPointBox from "../../Icons/FilePowerPointBox.tsx";
|
||||
import FileWordBox from "../../Icons/FileWordBox.tsx";
|
||||
import Folder from "../../Icons/Folder.tsx";
|
||||
import FolderOutlined from "../../Icons/FolderOutlined.tsx";
|
||||
import FolderZip from "../../Icons/FolderZip.tsx";
|
||||
import Image from "../../Icons/Image.tsx";
|
||||
import LanguageC from "../../Icons/LanguageC.tsx";
|
||||
import LanguageCPP from "../../Icons/LanguageCPP.tsx";
|
||||
import LanguageGo from "../../Icons/LanguageGo.tsx";
|
||||
import LanguageJS from "../../Icons/LanguageJS.tsx";
|
||||
import LanguagePython from "../../Icons/LanguagePython.tsx";
|
||||
import LanguageRust from "../../Icons/LanguageRust.tsx";
|
||||
import MagnetOn from "../../Icons/MagnetOn.tsx";
|
||||
import Markdown from "../../Icons/Markdown.tsx";
|
||||
import MusicNote1 from "../../Icons/MusicNote1.tsx";
|
||||
import Notepad from "../../Icons/Notepad.tsx";
|
||||
import Raw from "../../Icons/Raw.tsx";
|
||||
import Video from "../../Icons/Video.tsx";
|
||||
import Whiteboard from "../../Icons/Whiteboard.tsx";
|
||||
import WindowApps from "../../Icons/WindowApps.tsx";
|
||||
|
||||
export interface FileTypeIconProps extends SvgIconProps {
|
||||
name: string;
|
||||
fileType: number;
|
||||
notLoaded?: boolean;
|
||||
customizedColor?: string;
|
||||
[key: string]: any;
|
||||
}
|
||||
|
||||
export interface FileTypeIconSetting {
|
||||
exts: string[];
|
||||
icon?: string;
|
||||
iconify?: string;
|
||||
img?: string;
|
||||
color?: string;
|
||||
color_dark?: string;
|
||||
}
|
||||
|
||||
export interface ExpandedIconSettings {
|
||||
[key: string]: FileTypeIconSetting;
|
||||
}
|
||||
|
||||
export const builtInIcons: {
|
||||
[key: string]: typeof SvgIcon | ((props: SvgIconProps) => JSX.Element);
|
||||
} = {
|
||||
audio: MusicNote1,
|
||||
video: Video,
|
||||
image: Image,
|
||||
pdf: DocumentPDF,
|
||||
word: FileWordBox,
|
||||
ppt: FilePowerPointBox,
|
||||
excel: FileExclBox,
|
||||
text: Notepad,
|
||||
torrent: MagnetOn,
|
||||
zip: FolderZip,
|
||||
exe: WindowApps,
|
||||
android: Android,
|
||||
go: LanguageGo,
|
||||
c: LanguageC,
|
||||
cpp: LanguageCPP,
|
||||
js: LanguageJS,
|
||||
python: LanguagePython,
|
||||
book: Book,
|
||||
rust: LanguageRust,
|
||||
raw: Raw,
|
||||
flowchart: DocumentFlowchart,
|
||||
whiteboard: Whiteboard,
|
||||
markdown: Markdown,
|
||||
};
|
||||
|
||||
interface TypeIcon {
|
||||
icon?: typeof SvgIcon | ((props: SvgIconProps) => JSX.Element);
|
||||
color?: string;
|
||||
color_dark?: string;
|
||||
img?: string;
|
||||
hideUnknown?: boolean;
|
||||
reverseDarkMode?: boolean;
|
||||
}
|
||||
|
||||
interface IconComponentProps {
|
||||
icon?: typeof SvgIcon | ((props: SvgIconProps) => JSX.Element);
|
||||
color?: string;
|
||||
color_dark?: string;
|
||||
isDefault?: boolean;
|
||||
img?: string;
|
||||
iconify?: string;
|
||||
}
|
||||
|
||||
const FileTypeIcon = ({
|
||||
name,
|
||||
fileType,
|
||||
notLoaded,
|
||||
sx,
|
||||
hideUnknown,
|
||||
customizedColor,
|
||||
reverseDarkMode,
|
||||
...rest
|
||||
}: FileTypeIconProps) => {
|
||||
const theme = useTheme();
|
||||
const iconOptions = useAppSelector((state) => state.siteConfig.explorer.typed?.icons) as ExpandedIconSettings;
|
||||
const IconComponent: IconComponentProps = useMemo(() => {
|
||||
if (fileType === 1) {
|
||||
return notLoaded ? { icon: FolderOutlined } : { icon: Folder };
|
||||
}
|
||||
|
||||
if (name) {
|
||||
const fileSuffix = fileExtension(name);
|
||||
if (fileSuffix && iconOptions) {
|
||||
const options = iconOptions[fileSuffix];
|
||||
if (options) {
|
||||
const { icon, color, color_dark, img, iconify } = options;
|
||||
if (icon) {
|
||||
return {
|
||||
icon: builtInIcons[icon],
|
||||
color,
|
||||
color_dark,
|
||||
};
|
||||
} else if (img) {
|
||||
return {
|
||||
img,
|
||||
};
|
||||
} else if (iconify) {
|
||||
return {
|
||||
iconify,
|
||||
color,
|
||||
color_dark,
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return { icon: Document, isDefault: true };
|
||||
}, [fileType, name, notLoaded]);
|
||||
|
||||
const iconColor = useMemo(() => {
|
||||
if (customizedColor) {
|
||||
return customizedColor;
|
||||
}
|
||||
if (theme.palette.mode == (reverseDarkMode ? "light" : "dark")) {
|
||||
return IconComponent.color_dark ?? IconComponent.color ?? theme.palette.action.active;
|
||||
} else {
|
||||
return IconComponent.color ?? theme.palette.action.active;
|
||||
}
|
||||
}, [IconComponent, theme, customizedColor]);
|
||||
|
||||
if (IconComponent.icon) {
|
||||
if (IconComponent.isDefault && hideUnknown) {
|
||||
return <></>;
|
||||
}
|
||||
return (
|
||||
<IconComponent.icon
|
||||
sx={{
|
||||
color: iconColor,
|
||||
...sx,
|
||||
}}
|
||||
{...rest}
|
||||
/>
|
||||
);
|
||||
} else if (IconComponent.iconify) {
|
||||
return (
|
||||
//@ts-ignore
|
||||
<Box
|
||||
component={IconifyIcon}
|
||||
sx={{
|
||||
color: iconColor,
|
||||
...sx,
|
||||
}}
|
||||
width={24}
|
||||
height={24}
|
||||
icon={IconComponent.iconify}
|
||||
{...rest}
|
||||
/>
|
||||
);
|
||||
} else {
|
||||
return (
|
||||
//@ts-ignore
|
||||
<Box
|
||||
component={"img"}
|
||||
draggable={false}
|
||||
sx={{
|
||||
width: "24px",
|
||||
height: "24px",
|
||||
objectFit: "contain",
|
||||
...sx,
|
||||
}}
|
||||
src={IconComponent.img}
|
||||
{...rest}
|
||||
/>
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
export default FileTypeIcon;
|
||||
214
src/component/FileManager/Explorer/GalleryView/GalleryImage.tsx
Executable file
214
src/component/FileManager/Explorer/GalleryView/GalleryImage.tsx
Executable file
@@ -0,0 +1,214 @@
|
||||
import { CheckCircle } from "@mui/icons-material";
|
||||
import { Box, Fade, IconButton, ImageListItem, ImageListItemBar, lighten, styled } from "@mui/material";
|
||||
import React, { memo, useCallback, useEffect, useState } from "react";
|
||||
import { TransitionGroup } from "react-transition-group";
|
||||
import { FileType, Metadata } from "../../../../api/explorer.ts";
|
||||
import { useAppDispatch } from "../../../../redux/hooks.ts";
|
||||
import { fileIconClicked, loadFileThumb } from "../../../../redux/thunks/file.ts";
|
||||
import { navigateReconcile } from "../../../../redux/thunks/filemanager.ts";
|
||||
import CheckUnchecked from "../../../Icons/CheckUnchecked.tsx";
|
||||
import { FileBlockProps } from "../Explorer.tsx";
|
||||
import FileIcon from "../FileIcon.tsx";
|
||||
import {
|
||||
LargeIconContainer,
|
||||
ThumbBox,
|
||||
ThumbBoxContainer,
|
||||
ThumbLoadingPlaceholder,
|
||||
useFileBlockState,
|
||||
} from "../GridView/GridFile.tsx";
|
||||
|
||||
const StyledImageListItem = styled(ImageListItem)<{
|
||||
transparent?: boolean;
|
||||
disabled?: boolean;
|
||||
isDropOver?: boolean;
|
||||
}>(({ transparent, isDropOver, disabled, theme }) => {
|
||||
return {
|
||||
opacity: transparent || disabled ? 0.5 : 1,
|
||||
pointerEvents: disabled ? "none" : "auto",
|
||||
cursor: "pointer",
|
||||
boxShadow: isDropOver ? `0 0 0 2px ${theme.palette.primary.light}` : "none",
|
||||
transition: theme.transitions.create(["height", "width", "opacity", "box-shadow"]),
|
||||
};
|
||||
});
|
||||
|
||||
const GalleryImage = memo((props: FileBlockProps) => {
|
||||
const { file, columns, search, isDragging, isDropOver } = props;
|
||||
const dispatch = useAppDispatch();
|
||||
|
||||
const {
|
||||
fmIndex,
|
||||
isSelected,
|
||||
isLoadingIndicator,
|
||||
noThumb,
|
||||
uploading,
|
||||
ref,
|
||||
inView,
|
||||
showLock,
|
||||
fileTag,
|
||||
onClick,
|
||||
onDoubleClicked,
|
||||
hoverStateOff,
|
||||
hoverStateOn,
|
||||
onContextMenu,
|
||||
setRefFunc,
|
||||
disabled,
|
||||
fileDisabled,
|
||||
} = useFileBlockState(props);
|
||||
|
||||
const [hovered, setHovered] = useState(false);
|
||||
|
||||
// undefined: not loaded, null: no thumb
|
||||
const [thumbSrc, setThumbSrc] = useState<string | undefined | null>(noThumb ? null : undefined);
|
||||
const [imageLoading, setImageLoading] = useState(true);
|
||||
|
||||
const tryLoadThumbSrc = useCallback(async () => {
|
||||
const thumbSrc = await dispatch(loadFileThumb(0, file));
|
||||
setThumbSrc(thumbSrc);
|
||||
}, [dispatch, file, setThumbSrc, setImageLoading]);
|
||||
|
||||
const onImgLoadError = useCallback(() => {
|
||||
setImageLoading(false);
|
||||
setThumbSrc(null);
|
||||
}, [setImageLoading, setThumbSrc]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!inView) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (isLoadingIndicator) {
|
||||
if (file.first) {
|
||||
dispatch(navigateReconcile(fmIndex, { next_page: true }));
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (file.type == FileType.folder) {
|
||||
return;
|
||||
}
|
||||
|
||||
if ((file.metadata && file.metadata[Metadata.thumbDisabled] !== undefined) || showLock) {
|
||||
// No thumb available
|
||||
setThumbSrc(null);
|
||||
return;
|
||||
}
|
||||
|
||||
tryLoadThumbSrc();
|
||||
}, [inView]);
|
||||
|
||||
const onIconClick = useCallback(
|
||||
(e: React.MouseEvent<HTMLElement>) => {
|
||||
e.stopPropagation();
|
||||
return dispatch(fileIconClicked(fmIndex, file, e));
|
||||
},
|
||||
[file, dispatch],
|
||||
);
|
||||
|
||||
return (
|
||||
<StyledImageListItem
|
||||
onClick={file.type == FileType.folder ? onClick : onDoubleClicked}
|
||||
transparent={isDragging || fileDisabled}
|
||||
isDropOver={isDropOver && !isDragging}
|
||||
disabled={disabled}
|
||||
onContextMenu={onContextMenu}
|
||||
ref={setRefFunc}
|
||||
onMouseEnter={() => setHovered(true)}
|
||||
onMouseLeave={() => setHovered(false)}
|
||||
>
|
||||
<TransitionGroup style={{ height: "100%" }}>
|
||||
{thumbSrc && (
|
||||
<Fade key={"image"}>
|
||||
<Box>
|
||||
<ThumbBoxContainer
|
||||
sx={{
|
||||
p: isSelected ? "10%" : 0,
|
||||
backgroundColor: (theme) => (isSelected ? lighten(theme.palette.primary.light, 0.85) : "initial"),
|
||||
transition: (theme) =>
|
||||
theme.transitions.create(["padding"], {
|
||||
duration: theme.transitions.duration.shortest,
|
||||
}),
|
||||
}}
|
||||
>
|
||||
<ThumbBox
|
||||
sx={{
|
||||
borderRadius: isSelected ? 1 : 0,
|
||||
}}
|
||||
loaded={!imageLoading}
|
||||
src={thumbSrc}
|
||||
onLoad={() => setImageLoading(false)}
|
||||
onError={onImgLoadError}
|
||||
/>
|
||||
</ThumbBoxContainer>
|
||||
</Box>
|
||||
</Fade>
|
||||
)}
|
||||
{(thumbSrc === undefined || (thumbSrc && imageLoading)) && (
|
||||
<Fade key={"loading"}>
|
||||
<ThumbLoadingPlaceholder
|
||||
sx={{
|
||||
borderRadius: 0,
|
||||
}}
|
||||
ref={isLoadingIndicator ? undefined : ref}
|
||||
variant={"rectangular"}
|
||||
height={"100%"}
|
||||
/>
|
||||
</Fade>
|
||||
)}
|
||||
{thumbSrc === null && (
|
||||
<Fade key={"icon"}>
|
||||
<LargeIconContainer>
|
||||
<FileIcon
|
||||
variant={"largeMobile"}
|
||||
iconProps={{
|
||||
sx: {
|
||||
fontSize: "48px",
|
||||
height: "64px",
|
||||
width: "64px",
|
||||
},
|
||||
}}
|
||||
file={file}
|
||||
loading={isLoadingIndicator}
|
||||
/>
|
||||
</LargeIconContainer>
|
||||
</Fade>
|
||||
)}
|
||||
</TransitionGroup>
|
||||
<Fade in={!isLoadingIndicator && (hovered || !!isSelected)}>
|
||||
<ImageListItemBar
|
||||
sx={{
|
||||
background: "linear-gradient(to bottom, rgba(0,0,0,0.7) 0%, " + "rgba(0,0,0,0.3) 50%, rgba(0,0,0,0) 100%)",
|
||||
}}
|
||||
position="top"
|
||||
actionIcon={
|
||||
<IconButton onClick={onIconClick} size={"small"} sx={{ color: "white", mb: 1 }}>
|
||||
<TransitionGroup
|
||||
style={{
|
||||
width: 20,
|
||||
height: 20,
|
||||
}}
|
||||
>
|
||||
{!isSelected && (
|
||||
<Fade>
|
||||
<Box sx={{ position: "absolute" }}>
|
||||
<CheckUnchecked fontSize={"small"} />
|
||||
</Box>
|
||||
</Fade>
|
||||
)}
|
||||
{isSelected && (
|
||||
<Fade>
|
||||
<Box sx={{ position: "absolute" }}>
|
||||
<CheckCircle fontSize={"small"} />
|
||||
</Box>
|
||||
</Fade>
|
||||
)}
|
||||
</TransitionGroup>
|
||||
</IconButton>
|
||||
}
|
||||
actionPosition="left"
|
||||
/>
|
||||
</Fade>
|
||||
</StyledImageListItem>
|
||||
);
|
||||
});
|
||||
|
||||
export default GalleryImage;
|
||||
115
src/component/FileManager/Explorer/GalleryView/GalleryView.tsx
Executable file
115
src/component/FileManager/Explorer/GalleryView/GalleryView.tsx
Executable file
@@ -0,0 +1,115 @@
|
||||
import { Box, ImageList } from "@mui/material";
|
||||
import React, { useCallback, useContext, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useAppDispatch, useAppSelector } from "../../../../redux/hooks.ts";
|
||||
import { mergeRefs } from "../../../../util";
|
||||
import DndWrappedFile from "../../Dnd/DndWrappedFile.tsx";
|
||||
import { FmIndexContext } from "../../FmIndexContext.tsx";
|
||||
import { FmFile, loadingPlaceHolderNumb } from "../GridView/GridView.tsx";
|
||||
import GalleryImage from "./GalleryImage.tsx";
|
||||
|
||||
const GalleryView = React.forwardRef(
|
||||
(
|
||||
{
|
||||
...rest
|
||||
}: {
|
||||
[key: string]: any;
|
||||
},
|
||||
ref,
|
||||
) => {
|
||||
const { t } = useTranslation("application");
|
||||
const dispatch = useAppDispatch();
|
||||
const containerRef = useRef<HTMLElement>();
|
||||
const fmIndex = useContext(FmIndexContext);
|
||||
const [boxHeight, setBoxHeight] = useState(0);
|
||||
const [col, setCol] = useState(0);
|
||||
|
||||
const files = useAppSelector((state) => state.fileManager[fmIndex].list?.files);
|
||||
const pagination = useAppSelector((state) => state.fileManager[fmIndex].list?.pagination);
|
||||
const search_params = useAppSelector((state) => state.fileManager[fmIndex]?.search_params);
|
||||
const galleryWidth = useAppSelector((state) => state.fileManager[fmIndex].galleryWidth);
|
||||
|
||||
const mergedRef = useCallback(
|
||||
(val: any) => {
|
||||
mergeRefs(containerRef, ref)(val);
|
||||
},
|
||||
[containerRef, ref],
|
||||
);
|
||||
|
||||
const list = useMemo(() => {
|
||||
const list: FmFile[] = [];
|
||||
if (!files) {
|
||||
return list;
|
||||
}
|
||||
|
||||
files.forEach((file) => {
|
||||
list.push(file);
|
||||
});
|
||||
|
||||
// Add loading placeholder if there is next page
|
||||
if (pagination && pagination.next_token) {
|
||||
for (let i = 0; i < loadingPlaceHolderNumb; i++) {
|
||||
const id = `loadingPlaceholder-${pagination.next_token}-${i}`;
|
||||
list.push({
|
||||
...files[0],
|
||||
path: files[0].path + "/" + id,
|
||||
id: `loadingPlaceholder-${pagination.next_token}-${i}`,
|
||||
first: i == 0,
|
||||
placeholder: true,
|
||||
});
|
||||
}
|
||||
}
|
||||
return list;
|
||||
}, [files, pagination, search_params]);
|
||||
|
||||
const resizeGallery = useCallback(
|
||||
(containerWidth: number, boxSize: number) => {
|
||||
const boxCount = Math.floor(containerWidth / boxSize);
|
||||
const newCols = Math.max(1, boxCount);
|
||||
const boxHeight = containerWidth / newCols;
|
||||
setBoxHeight(boxHeight);
|
||||
setCol(newCols);
|
||||
},
|
||||
[setBoxHeight, setCol],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (!containerRef.current) return;
|
||||
const resizeObserver = new ResizeObserver(() => {
|
||||
const containerWidth = containerRef.current?.clientWidth ?? 100;
|
||||
resizeGallery(containerWidth, galleryWidth);
|
||||
});
|
||||
resizeObserver.observe(containerRef.current);
|
||||
return () => resizeObserver.disconnect(); // clean up
|
||||
}, [galleryWidth]);
|
||||
|
||||
return (
|
||||
<Box ref={mergedRef} component={"div"} {...rest}>
|
||||
<ImageList
|
||||
gap={2}
|
||||
cols={col}
|
||||
rowHeight={boxHeight}
|
||||
sx={{
|
||||
overflow: "hidden",
|
||||
margin: 0,
|
||||
}}
|
||||
>
|
||||
{boxHeight > 0 &&
|
||||
list.map((file, index) => (
|
||||
<DndWrappedFile
|
||||
key={file.id}
|
||||
boxHeight={boxHeight}
|
||||
component={GalleryImage}
|
||||
search={search_params}
|
||||
index={index}
|
||||
showThumb={true}
|
||||
file={file}
|
||||
/>
|
||||
))}
|
||||
</ImageList>
|
||||
</Box>
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
export default GalleryView;
|
||||
463
src/component/FileManager/Explorer/GridView/GridFile.tsx
Executable file
463
src/component/FileManager/Explorer/GridView/GridFile.tsx
Executable file
@@ -0,0 +1,463 @@
|
||||
import {
|
||||
alpha,
|
||||
Box,
|
||||
ButtonBase,
|
||||
Fade,
|
||||
Skeleton,
|
||||
styled,
|
||||
Tooltip,
|
||||
Typography,
|
||||
useMediaQuery,
|
||||
useTheme,
|
||||
} from "@mui/material";
|
||||
import { bindPopover } from "material-ui-popup-state";
|
||||
import { usePopupState } from "material-ui-popup-state/hooks";
|
||||
import HoverPopover from "material-ui-popup-state/HoverPopover";
|
||||
import React, { memo, useCallback, useContext, useEffect, useMemo, useState } from "react";
|
||||
import { useInView } from "react-intersection-observer";
|
||||
import { TransitionGroup } from "react-transition-group";
|
||||
import { FileType, Metadata } from "../../../../api/explorer.ts";
|
||||
import { bindDelayedHover } from "../../../../hooks/delayedHover.tsx";
|
||||
import { useAppDispatch, useAppSelector } from "../../../../redux/hooks.ts";
|
||||
import { fileClicked, fileDoubleClicked, loadFileThumb, openFileContextMenu } from "../../../../redux/thunks/file.ts";
|
||||
import { fileHovered, navigateReconcile } from "../../../../redux/thunks/filemanager.ts";
|
||||
import FileIcon from "../FileIcon.tsx";
|
||||
import FileSmallIcon from "../FileSmallIcon.tsx";
|
||||
import FileTagSummary from "../FileTagSummary.tsx";
|
||||
// @ts-ignore
|
||||
import Highlighter from "react-highlight-words";
|
||||
|
||||
import { ContextMenuTypes } from "../../../../redux/fileManagerSlice.ts";
|
||||
import { FileManagerIndex } from "../../FileManager.tsx";
|
||||
import { FmIndexContext } from "../../FmIndexContext.tsx";
|
||||
import { getFileTags } from "../../Sidebar/Tags.tsx";
|
||||
import { FileBlockProps } from "../Explorer.tsx";
|
||||
import UploadingTag from "../UploadingTag.tsx";
|
||||
|
||||
const StyledButtonBase = styled(ButtonBase)<{
|
||||
selected: boolean;
|
||||
square?: boolean;
|
||||
transparent?: boolean;
|
||||
isDropOver?: boolean;
|
||||
}>(({ theme, transparent, isDropOver, square, selected }) => {
|
||||
let bgColor = theme.palette.mode === "light" ? theme.palette.grey[100] : theme.palette.grey[900];
|
||||
let bgColorHover = theme.palette.mode === "light" ? theme.palette.grey[300] : theme.palette.grey[700];
|
||||
|
||||
if (selected) {
|
||||
bgColor = alpha(theme.palette.primary.main, 0.18);
|
||||
bgColorHover = bgColor;
|
||||
}
|
||||
return {
|
||||
opacity: transparent ? 0.5 : 1,
|
||||
borderRadius: theme.shape.borderRadius,
|
||||
backgroundColor: bgColor,
|
||||
width: "100%",
|
||||
display: "flex",
|
||||
alignItems: "stretch",
|
||||
transition: "all 250ms cubic-bezier(0.4, 0, 0.2, 1) 0ms",
|
||||
transitionProperty: "background-color,opacity,box-shadow",
|
||||
boxShadow: isDropOver ? `0 0 0 2px ${theme.palette.primary.light}` : "none",
|
||||
"&:hover": {
|
||||
backgroundColor: bgColorHover,
|
||||
},
|
||||
"&::before": square && {
|
||||
content: "''",
|
||||
display: "inline-block",
|
||||
flex: "0 0 0px",
|
||||
height: 0,
|
||||
paddingBottom: "100%",
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
const Content = styled(Box)(() => ({
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
flexGrow: 1,
|
||||
overflow: "hidden",
|
||||
}));
|
||||
|
||||
export const Header = styled(Box)(() => ({
|
||||
height: 48,
|
||||
display: "flex",
|
||||
justifyContent: "left",
|
||||
alignItems: "initial",
|
||||
width: "100%",
|
||||
}));
|
||||
|
||||
const ThumbContainer = styled(Box)(({ theme }) => ({
|
||||
flexGrow: "1",
|
||||
borderRadius: "8px",
|
||||
height: "100%",
|
||||
overflow: "hidden",
|
||||
margin: `0 ${theme.spacing(1)} ${theme.spacing(1)} ${theme.spacing(1)}`,
|
||||
position: "relative",
|
||||
}));
|
||||
|
||||
export const FileNameText = styled(Typography)(() => ({
|
||||
flexGrow: 1,
|
||||
textAlign: "left",
|
||||
textOverflow: "ellipsis",
|
||||
whiteSpace: "nowrap",
|
||||
overflow: "hidden",
|
||||
padding: "14px 12px 14px 0",
|
||||
}));
|
||||
|
||||
export const ThumbBoxContainer = styled(Box)(() => ({
|
||||
position: "absolute",
|
||||
top: 0,
|
||||
left: 0,
|
||||
width: "100%",
|
||||
height: "100%",
|
||||
}));
|
||||
|
||||
export const ThumbBox = styled("img")<{ loaded: boolean }>(({ theme, loaded }) => ({
|
||||
objectFit: "cover",
|
||||
width: "100%",
|
||||
height: "100%",
|
||||
transition: theme.transitions.create(["opacity", "border-radius"], {
|
||||
easing: theme.transitions.easing.easeInOut,
|
||||
duration: theme.transitions.duration.standard,
|
||||
}),
|
||||
opacity: loaded ? 1 : 0,
|
||||
userSelect: "none",
|
||||
WebkitUserDrag: "none",
|
||||
MozUserDrag: "none",
|
||||
msUserDrag: "none",
|
||||
}));
|
||||
|
||||
export const ThumbLoadingPlaceholder = styled(Skeleton)(() => ({
|
||||
borderRadius: "8px",
|
||||
position: "absolute",
|
||||
height: "100%",
|
||||
width: "100%",
|
||||
}));
|
||||
|
||||
export const LargeIconContainer = styled(Box)(({ theme }) => ({
|
||||
display: "flex",
|
||||
justifyContent: "center",
|
||||
alignItems: "center",
|
||||
height: "100%",
|
||||
backgroundColor: theme.palette.background.default,
|
||||
}));
|
||||
|
||||
export const ThumbPopoverImg = styled("img")<{ width?: number; height?: number }>(({ width, height }) => ({
|
||||
display: "block",
|
||||
maxWidth: width ?? "initial",
|
||||
maxHeight: height ?? "initial",
|
||||
objectFit: "contain",
|
||||
width: "auto",
|
||||
height: "auto",
|
||||
userSelect: "none",
|
||||
WebkitUserDrag: "none",
|
||||
MozUserDrag: "none",
|
||||
msUserDrag: "none",
|
||||
}));
|
||||
|
||||
export const useFileBlockState = (props: FileBlockProps) => {
|
||||
const { file, search, dragRef } = props;
|
||||
const dispatch = useAppDispatch();
|
||||
const isTouch = useMediaQuery("(pointer: coarse)");
|
||||
const fmIndex = useContext(FmIndexContext);
|
||||
const isSelected = useAppSelector((state) => state.fileManager[fmIndex].selected[file.path]);
|
||||
const thumbWidth = useAppSelector((state) => state.siteConfig.explorer.config.thumbnail_width);
|
||||
const thumbHeight = useAppSelector((state) => state.siteConfig.explorer.config.thumbnail_height);
|
||||
const isLoadingIndicator = file.placeholder;
|
||||
const noThumb =
|
||||
(file.type == FileType.folder || (file.metadata && file.metadata[Metadata.thumbDisabled] != undefined)) &&
|
||||
!isLoadingIndicator;
|
||||
const uploading = file.metadata && file.metadata[Metadata.upload_session_id] != undefined;
|
||||
const { ref, inView } = useInView({
|
||||
triggerOnce: true,
|
||||
rootMargin: "200px 0px",
|
||||
skip: noThumb,
|
||||
});
|
||||
const fileTag = useMemo(() => getFileTags(file), [file]);
|
||||
|
||||
const onClick = useCallback(
|
||||
(e: React.MouseEvent<HTMLElement>) => {
|
||||
e.stopPropagation();
|
||||
if (!isLoadingIndicator) {
|
||||
dispatch(fileClicked(fmIndex, file, e));
|
||||
}
|
||||
},
|
||||
[file, dispatch, fmIndex, isLoadingIndicator],
|
||||
);
|
||||
|
||||
const onDoubleClicked = useCallback(
|
||||
(e: React.MouseEvent<HTMLElement>) => {
|
||||
e.stopPropagation();
|
||||
if (!isLoadingIndicator) {
|
||||
dispatch(fileDoubleClicked(fmIndex, file, e));
|
||||
}
|
||||
},
|
||||
[file, dispatch, fmIndex, isLoadingIndicator],
|
||||
);
|
||||
|
||||
const setHoverState = useCallback(
|
||||
(hovered: boolean) => {
|
||||
dispatch(fileHovered(fmIndex, file, hovered));
|
||||
},
|
||||
[dispatch, fmIndex, file],
|
||||
);
|
||||
|
||||
const hoverStateOff = useCallback(() => {
|
||||
if (!isTouch) {
|
||||
setHoverState(false);
|
||||
}
|
||||
}, [setHoverState, isTouch]);
|
||||
const hoverStateOn = useCallback(() => {
|
||||
if (!isTouch) {
|
||||
setHoverState(true);
|
||||
}
|
||||
}, [setHoverState, isTouch]);
|
||||
|
||||
const onContextMenu = useCallback(
|
||||
(e: React.MouseEvent<HTMLElement>) => {
|
||||
e.preventDefault();
|
||||
dispatch(
|
||||
openFileContextMenu(fmIndex, file, false, e, !search ? ContextMenuTypes.file : ContextMenuTypes.searchResult),
|
||||
);
|
||||
},
|
||||
[dispatch, file, fmIndex, search],
|
||||
);
|
||||
|
||||
const setRefFunc = useCallback(
|
||||
(e: HTMLElement | null) => {
|
||||
if (isLoadingIndicator) {
|
||||
ref(e);
|
||||
}
|
||||
|
||||
if (dragRef) {
|
||||
dragRef(e);
|
||||
}
|
||||
},
|
||||
[dragRef, isLoadingIndicator, ref],
|
||||
);
|
||||
|
||||
const fileDisabled = fmIndex == FileManagerIndex.selector && file.type == FileType.file;
|
||||
const disabled = isLoadingIndicator || fileDisabled;
|
||||
|
||||
return {
|
||||
onClick,
|
||||
fmIndex,
|
||||
isSelected,
|
||||
isLoadingIndicator,
|
||||
noThumb,
|
||||
uploading,
|
||||
ref,
|
||||
inView,
|
||||
fileTag,
|
||||
onDoubleClicked,
|
||||
hoverStateOff,
|
||||
hoverStateOn,
|
||||
onContextMenu,
|
||||
setRefFunc,
|
||||
disabled,
|
||||
fileDisabled,
|
||||
thumbWidth,
|
||||
thumbHeight,
|
||||
};
|
||||
};
|
||||
|
||||
const GridFile = memo((props: FileBlockProps) => {
|
||||
const { file, isDragging, isDropOver, search, showThumb, index, dragRef } = props;
|
||||
const dispatch = useAppDispatch();
|
||||
const theme = useTheme();
|
||||
const isMobile = useMediaQuery(theme.breakpoints.down("md"));
|
||||
const isTouch = useMediaQuery("(pointer: coarse)");
|
||||
const {
|
||||
fmIndex,
|
||||
isSelected,
|
||||
isLoadingIndicator,
|
||||
noThumb,
|
||||
uploading,
|
||||
ref,
|
||||
inView,
|
||||
fileTag,
|
||||
onClick,
|
||||
onDoubleClicked,
|
||||
hoverStateOff,
|
||||
hoverStateOn,
|
||||
onContextMenu,
|
||||
setRefFunc,
|
||||
disabled,
|
||||
fileDisabled,
|
||||
thumbWidth,
|
||||
thumbHeight,
|
||||
} = useFileBlockState(props);
|
||||
|
||||
const popupState = usePopupState({
|
||||
variant: "popover",
|
||||
popupId: "thumbPreview" + file.id,
|
||||
});
|
||||
|
||||
// undefined: not loaded, null: no thumb
|
||||
const [thumbSrc, setThumbSrc] = useState<string | undefined | null>(noThumb ? null : undefined);
|
||||
const [imageLoading, setImageLoading] = useState(true);
|
||||
|
||||
const tryLoadThumbSrc = useCallback(async () => {
|
||||
const thumbSrc = await dispatch(loadFileThumb(0, file));
|
||||
setThumbSrc(thumbSrc);
|
||||
}, [dispatch, file, setThumbSrc, setImageLoading]);
|
||||
|
||||
const onImgLoadError = useCallback(() => {
|
||||
setImageLoading(false);
|
||||
setThumbSrc(null);
|
||||
}, [setImageLoading, setThumbSrc]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!inView) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (isLoadingIndicator) {
|
||||
if (file.first) {
|
||||
dispatch(navigateReconcile(fmIndex, { next_page: true }));
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (!showThumb || file.type == FileType.folder) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (file.metadata && file.metadata[Metadata.thumbDisabled] !== undefined) {
|
||||
// No thumb available
|
||||
setThumbSrc(null);
|
||||
return;
|
||||
}
|
||||
|
||||
tryLoadThumbSrc();
|
||||
}, [inView]);
|
||||
|
||||
const hoverProps = bindDelayedHover(popupState, 800);
|
||||
const { open: thumbPopoverOpen, ...rest } = bindPopover(popupState);
|
||||
|
||||
const stopPop = useCallback((e: React.MouseEvent<HTMLElement>) => {
|
||||
e.stopPropagation();
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<>
|
||||
<StyledButtonBase
|
||||
onDoubleClick={onDoubleClicked}
|
||||
transparent={isDragging || fileDisabled}
|
||||
isDropOver={isDropOver && !isDragging}
|
||||
onContextMenu={onContextMenu}
|
||||
data-rect-id={index ?? ""}
|
||||
selected={!!isSelected}
|
||||
square={showThumb}
|
||||
disabled={disabled}
|
||||
onClick={onClick}
|
||||
ref={setRefFunc}
|
||||
onMouseDown={stopPop}
|
||||
onMouseEnter={hoverStateOn}
|
||||
onMouseLeave={hoverStateOff}
|
||||
>
|
||||
<Content>
|
||||
<Header>
|
||||
<FileSmallIcon selected={!!isSelected} file={file} loading={isLoadingIndicator} />
|
||||
{!isLoadingIndicator && (
|
||||
<Tooltip title={file.name}>
|
||||
<FileNameText variant="body2">
|
||||
{search?.name ? (
|
||||
<Highlighter
|
||||
highlightClassName="highlight-marker"
|
||||
searchWords={search?.name}
|
||||
autoEscape={true}
|
||||
textToHighlight={file.name}
|
||||
/>
|
||||
) : (
|
||||
file.name
|
||||
)}
|
||||
</FileNameText>
|
||||
</Tooltip>
|
||||
)}
|
||||
{!uploading && fileTag && fileTag.length > 0 && (
|
||||
<FileTagSummary sx={{ p: "14px 12px 14px 0", maxWidth: "50%" }} tags={fileTag} />
|
||||
)}
|
||||
{uploading && <UploadingTag sx={{ p: "14px 12px 14px 0", maxWidth: "50%" }} />}
|
||||
{isLoadingIndicator && (
|
||||
<Skeleton
|
||||
variant="text"
|
||||
sx={{
|
||||
fontVariant: "body2",
|
||||
width: "100%",
|
||||
margin: "14px 12px 14px 0",
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</Header>
|
||||
{showThumb && (
|
||||
<ThumbContainer>
|
||||
<TransitionGroup style={{ height: "100%" }}>
|
||||
{thumbSrc && (
|
||||
<Fade key={"image"}>
|
||||
<ThumbBoxContainer>
|
||||
<ThumbBox
|
||||
loaded={!imageLoading}
|
||||
src={thumbSrc}
|
||||
onLoad={() => setImageLoading(false)}
|
||||
onError={onImgLoadError}
|
||||
{...(isTouch ? {} : hoverProps)}
|
||||
/>
|
||||
</ThumbBoxContainer>
|
||||
</Fade>
|
||||
)}
|
||||
{(thumbSrc === undefined || (thumbSrc && imageLoading)) && (
|
||||
<Fade key={"loading"}>
|
||||
<ThumbLoadingPlaceholder
|
||||
ref={isLoadingIndicator ? undefined : ref}
|
||||
variant={"rectangular"}
|
||||
height={"100%"}
|
||||
/>
|
||||
</Fade>
|
||||
)}
|
||||
{thumbSrc === null && (
|
||||
<Fade key={"icon"}>
|
||||
<LargeIconContainer>
|
||||
<FileIcon
|
||||
variant={isMobile ? "largeMobile" : "large"}
|
||||
iconProps={{
|
||||
sx: {
|
||||
fontSize: `${isMobile ? 48 : 64}px`,
|
||||
height: `${isMobile ? 72 : 96}px`,
|
||||
width: `${isMobile ? 56 : 64}px`,
|
||||
},
|
||||
}}
|
||||
file={file}
|
||||
loading={isLoadingIndicator}
|
||||
/>
|
||||
</LargeIconContainer>
|
||||
</Fade>
|
||||
)}
|
||||
</TransitionGroup>
|
||||
</ThumbContainer>
|
||||
)}
|
||||
</Content>
|
||||
{thumbSrc && showThumb && (
|
||||
<HoverPopover
|
||||
open={thumbPopoverOpen}
|
||||
sx={{
|
||||
zIndex: (t) => t.zIndex.drawer,
|
||||
}}
|
||||
anchorOrigin={{
|
||||
vertical: "center",
|
||||
horizontal: "center",
|
||||
}}
|
||||
transformOrigin={{
|
||||
vertical: "center",
|
||||
horizontal: "center",
|
||||
}}
|
||||
{...rest}
|
||||
>
|
||||
<ThumbPopoverImg src={thumbSrc} draggable={false} width={thumbWidth} height={thumbHeight} />
|
||||
</HoverPopover>
|
||||
)}
|
||||
</StyledButtonBase>
|
||||
</>
|
||||
);
|
||||
});
|
||||
|
||||
export default GridFile;
|
||||
150
src/component/FileManager/Explorer/GridView/GridView.tsx
Executable file
150
src/component/FileManager/Explorer/GridView/GridView.tsx
Executable file
@@ -0,0 +1,150 @@
|
||||
import { Box, Grid, Stack, styled, Typography } from "@mui/material";
|
||||
import React, { useContext, useMemo } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { FileResponse, FileType } from "../../../../api/explorer.ts";
|
||||
import { useAppSelector } from "../../../../redux/hooks.ts";
|
||||
import DndWrappedFile from "../../Dnd/DndWrappedFile.tsx";
|
||||
|
||||
import { FmIndexContext } from "../../FmIndexContext.tsx";
|
||||
import GridFile from "./GridFile.tsx";
|
||||
|
||||
export interface GridViewProps {
|
||||
[key: string]: any;
|
||||
}
|
||||
|
||||
export interface FmFile extends FileResponse {
|
||||
id: string;
|
||||
first?: boolean;
|
||||
placeholder?: boolean;
|
||||
}
|
||||
|
||||
interface listComponents {
|
||||
Folders?: JSX.Element[];
|
||||
Files: JSX.Element[];
|
||||
}
|
||||
|
||||
const AutoFillGrid = styled(Grid)(({ theme }) => ({
|
||||
[theme.breakpoints.down("md")]: {
|
||||
gridTemplateColumns: "repeat(auto-fill,minmax(160px,1fr))!important",
|
||||
},
|
||||
[theme.breakpoints.up("md")]: {
|
||||
gridTemplateColumns: "repeat(auto-fill,minmax(220px,1fr))!important",
|
||||
},
|
||||
gridGap: theme.spacing(2),
|
||||
display: "grid!important",
|
||||
padding: theme.spacing(1),
|
||||
}));
|
||||
|
||||
const GridItem = styled(Grid)(() => ({
|
||||
flex: "1 1 220px!important",
|
||||
}));
|
||||
|
||||
export const loadingPlaceHolderNumb = 3;
|
||||
|
||||
const GridView = React.forwardRef(({ ...rest }: GridViewProps, ref) => {
|
||||
const { t } = useTranslation("application");
|
||||
const fmIndex = useContext(FmIndexContext);
|
||||
const files = useAppSelector((state) => state.fileManager[fmIndex].list?.files);
|
||||
const mixedType = useAppSelector((state) => state.fileManager[fmIndex].list?.mixed_type);
|
||||
const pagination = useAppSelector((state) => state.fileManager[fmIndex].list?.pagination);
|
||||
const showThumb = useAppSelector((state) => state.fileManager[fmIndex].showThumb);
|
||||
const search_params = useAppSelector((state) => state.fileManager[fmIndex]?.search_params);
|
||||
const list = useMemo(() => {
|
||||
const list: listComponents = {
|
||||
Files: [],
|
||||
};
|
||||
if (files) {
|
||||
files.forEach((file, index) => {
|
||||
if (file.type === FileType.folder && !mixedType) {
|
||||
if (!list.Folders) {
|
||||
list.Folders = [];
|
||||
}
|
||||
list.Folders.push(
|
||||
<GridItem item key={`${file.id}`}>
|
||||
<DndWrappedFile
|
||||
component={GridFile}
|
||||
search={search_params}
|
||||
index={index}
|
||||
showThumb={mixedType}
|
||||
file={file}
|
||||
/>
|
||||
</GridItem>,
|
||||
);
|
||||
} else {
|
||||
list.Files.push(
|
||||
<GridItem item key={`${file.id}`}>
|
||||
<DndWrappedFile
|
||||
component={GridFile}
|
||||
search={search_params}
|
||||
index={index}
|
||||
showThumb={showThumb}
|
||||
file={file}
|
||||
/>
|
||||
</GridItem>,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
// Add loading placeholder if there is next page
|
||||
if (pagination && pagination.next_token) {
|
||||
for (let i = 0; i < loadingPlaceHolderNumb; i++) {
|
||||
const id = `loadingPlaceholder-${pagination.next_token}-${i}`;
|
||||
const loadingPlaceholder = (
|
||||
<GridItem item key={id}>
|
||||
<DndWrappedFile
|
||||
component={GridFile}
|
||||
showThumb={list.Files.length > 0 ? showThumb : mixedType}
|
||||
file={{
|
||||
...files[0],
|
||||
path: files[0].path + "/" + id,
|
||||
id: `loadingPlaceholder-${pagination.next_token}-${i}`,
|
||||
first: i == 0,
|
||||
placeholder: true,
|
||||
}}
|
||||
/>
|
||||
</GridItem>
|
||||
);
|
||||
const _ =
|
||||
list.Files.length > 0 ? list.Files.push(loadingPlaceholder) : list.Folders?.push(loadingPlaceholder);
|
||||
}
|
||||
}
|
||||
}
|
||||
return list;
|
||||
}, [files, mixedType, pagination, showThumb]);
|
||||
return (
|
||||
<Box
|
||||
ref={ref}
|
||||
{...rest}
|
||||
sx={{
|
||||
p: 1,
|
||||
}}
|
||||
>
|
||||
<Stack spacing={1}>
|
||||
{list.Folders && list.Folders.length > 0 && (
|
||||
<Box>
|
||||
<Typography fontWeight={"medium"} sx={{ p: 1 }} variant="body2">
|
||||
{t("fileManager.folders")}
|
||||
</Typography>
|
||||
<AutoFillGrid container alignItems="flex-start" spacing={0}>
|
||||
{list.Folders.map((f) => f)}
|
||||
</AutoFillGrid>
|
||||
</Box>
|
||||
)}
|
||||
{list.Files.length > 0 && (
|
||||
<Box>
|
||||
{!mixedType && (
|
||||
<Typography sx={{ p: 1 }} fontWeight={"medium"} variant="body2">
|
||||
{t("fileManager.files")}
|
||||
</Typography>
|
||||
)}
|
||||
<AutoFillGrid container alignItems="flex-start" spacing={0}>
|
||||
{list.Files.map((f) => f)}
|
||||
</AutoFillGrid>
|
||||
</Box>
|
||||
)}
|
||||
</Stack>
|
||||
</Box>
|
||||
);
|
||||
});
|
||||
|
||||
export default GridView;
|
||||
133
src/component/FileManager/Explorer/ListView/AddColumn.tsx
Executable file
133
src/component/FileManager/Explorer/ListView/AddColumn.tsx
Executable file
@@ -0,0 +1,133 @@
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
import { Icon } from "@iconify/react/dist/iconify.js";
|
||||
import { ListItemIcon, Menu } from "@mui/material";
|
||||
import { bindMenu, bindTrigger, usePopupState } from "material-ui-popup-state/hooks";
|
||||
import { useAppSelector } from "../../../../redux/hooks.ts";
|
||||
import { SecondaryButton } from "../../../Common/StyledComponents.tsx";
|
||||
import Add from "../../../Icons/Add.tsx";
|
||||
import { CascadingSubmenu } from "../../ContextMenu/CascadingMenu.tsx";
|
||||
import { DenseDivider, SquareMenuItem } from "../../ContextMenu/ContextMenu.tsx";
|
||||
import { ColumType, ColumTypeProps, getColumnTypeDefaults, ListViewColumnSetting } from "./Column.tsx";
|
||||
|
||||
export interface AddColumnProps {
|
||||
onColumnAdded: (column: ListViewColumnSetting) => void;
|
||||
}
|
||||
|
||||
const options: ColumType[] = [
|
||||
ColumType.name,
|
||||
ColumType.size,
|
||||
ColumType.date_modified,
|
||||
ColumType.date_created,
|
||||
ColumType.parent,
|
||||
];
|
||||
|
||||
const recycleOptions: ColumType[] = [ColumType.recycle_restore_parent, ColumType.recycle_expire];
|
||||
|
||||
// null => divider
|
||||
const mediaInfoOptions: (ColumType | null)[] = [
|
||||
ColumType.aperture,
|
||||
ColumType.exposure,
|
||||
ColumType.iso,
|
||||
ColumType.focal_length,
|
||||
ColumType.exposure_bias,
|
||||
ColumType.flash,
|
||||
null,
|
||||
ColumType.camera_make,
|
||||
ColumType.camera_model,
|
||||
ColumType.lens_make,
|
||||
ColumType.lens_model,
|
||||
null,
|
||||
ColumType.software,
|
||||
ColumType.taken_at,
|
||||
ColumType.image_size,
|
||||
null,
|
||||
ColumType.title,
|
||||
ColumType.artist,
|
||||
ColumType.album,
|
||||
ColumType.duration,
|
||||
null,
|
||||
ColumType.street,
|
||||
ColumType.locality,
|
||||
ColumType.place,
|
||||
ColumType.district,
|
||||
ColumType.region,
|
||||
ColumType.country,
|
||||
];
|
||||
|
||||
const AddColumn = (props: AddColumnProps) => {
|
||||
const { t } = useTranslation();
|
||||
const customPropsOptions = useAppSelector((state) => state.siteConfig.explorer?.config?.custom_props);
|
||||
const conditionPopupState = usePopupState({
|
||||
variant: "popover",
|
||||
popupId: "columns",
|
||||
});
|
||||
const { onClose, ...menuProps } = bindMenu(conditionPopupState);
|
||||
const onConditionAdd = (type: ColumType, p?: ColumTypeProps) => {
|
||||
props.onColumnAdded({ type, props: p });
|
||||
onClose();
|
||||
};
|
||||
return (
|
||||
<>
|
||||
<SecondaryButton {...bindTrigger(conditionPopupState)} startIcon={<Add />} sx={{ px: "15px" }}>
|
||||
{t("fileManager.addColumn")}
|
||||
</SecondaryButton>
|
||||
<Menu
|
||||
onClose={onClose}
|
||||
anchorOrigin={{
|
||||
vertical: "top",
|
||||
horizontal: "left",
|
||||
}}
|
||||
transformOrigin={{
|
||||
vertical: "bottom",
|
||||
horizontal: "left",
|
||||
}}
|
||||
{...menuProps}
|
||||
>
|
||||
{options.map((option, index) => (
|
||||
<SquareMenuItem dense key={index} onClick={() => onConditionAdd(option)}>
|
||||
{t(getColumnTypeDefaults({ type: option }).title)}
|
||||
</SquareMenuItem>
|
||||
))}
|
||||
<CascadingSubmenu popupId={"mediaInfo"} title={t("application:navbar.trash")}>
|
||||
{recycleOptions.map((option, index) => (
|
||||
<SquareMenuItem dense key={index} onClick={() => onConditionAdd(option)}>
|
||||
{t(getColumnTypeDefaults({ type: option }).title)}
|
||||
</SquareMenuItem>
|
||||
))}
|
||||
</CascadingSubmenu>
|
||||
<CascadingSubmenu popupId={"mediaInfo"} title={t("application:fileManager.mediaInfo")}>
|
||||
{mediaInfoOptions.map((option, index) =>
|
||||
option ? (
|
||||
<SquareMenuItem dense key={index} onClick={() => onConditionAdd(option)}>
|
||||
{t(getColumnTypeDefaults({ type: option }).title)}
|
||||
</SquareMenuItem>
|
||||
) : (
|
||||
<DenseDivider />
|
||||
),
|
||||
)}
|
||||
</CascadingSubmenu>
|
||||
{customPropsOptions && customPropsOptions.length > 0 && (
|
||||
<CascadingSubmenu popupId={"customProps"} title={t("application:fileManager.customProps")}>
|
||||
{customPropsOptions.map((option, index) => (
|
||||
<SquareMenuItem
|
||||
dense
|
||||
key={index}
|
||||
onClick={() => onConditionAdd(ColumType.custom_props, { custom_props_id: option.id })}
|
||||
>
|
||||
{option.icon && (
|
||||
<ListItemIcon>
|
||||
<Icon icon={option.icon} />
|
||||
</ListItemIcon>
|
||||
)}
|
||||
{t(option.name)}
|
||||
</SquareMenuItem>
|
||||
))}
|
||||
</CascadingSubmenu>
|
||||
)}
|
||||
</Menu>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default AddColumn;
|
||||
381
src/component/FileManager/Explorer/ListView/Cell.tsx
Executable file
381
src/component/FileManager/Explorer/ListView/Cell.tsx
Executable file
@@ -0,0 +1,381 @@
|
||||
import { Box, Fade, PopoverProps, Tooltip, Typography, useMediaQuery, useTheme } from "@mui/material";
|
||||
import { memo, useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { sizeToString } from "../../../../util";
|
||||
import CrUri, { SearchParam } from "../../../../util/uri.ts";
|
||||
import FileSmallIcon from "../FileSmallIcon.tsx";
|
||||
import { FmFile } from "../GridView/GridView.tsx";
|
||||
import { ColumType, ListViewColumn } from "./Column.tsx";
|
||||
// @ts-ignore
|
||||
import dayjs, { Dayjs } from "dayjs";
|
||||
import { bindPopover } from "material-ui-popup-state";
|
||||
import { usePopupState } from "material-ui-popup-state/hooks";
|
||||
import HoverPopover from "material-ui-popup-state/HoverPopover";
|
||||
import Highlighter from "react-highlight-words";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { TransitionGroup } from "react-transition-group";
|
||||
import { FileType, Metadata } from "../../../../api/explorer.ts";
|
||||
import { bindDelayedHover } from "../../../../hooks/delayedHover.tsx";
|
||||
import { useAppDispatch, useAppSelector } from "../../../../redux/hooks.ts";
|
||||
import { loadFileThumb } from "../../../../redux/thunks/file.ts";
|
||||
import AutoHeight from "../../../Common/AutoHeight.tsx";
|
||||
import { NoWrapBox } from "../../../Common/StyledComponents.tsx";
|
||||
import TimeBadge from "../../../Common/TimeBadge.tsx";
|
||||
import Info from "../../../Icons/Info.tsx";
|
||||
import FileBadge from "../../FileBadge.tsx";
|
||||
import { CustomPropsItem, customPropsMetadataPrefix } from "../../Sidebar/CustomProps/CustomProps.tsx";
|
||||
import { getPropsContent } from "../../Sidebar/CustomProps/CustomPropsItem.tsx";
|
||||
import {
|
||||
getAlbum,
|
||||
getAperture,
|
||||
getArtist,
|
||||
getCameraMake,
|
||||
getCameraModel,
|
||||
getCountry,
|
||||
getDistrict,
|
||||
getDuration,
|
||||
getExposure,
|
||||
getExposureBias,
|
||||
getFlash,
|
||||
getFocalLength,
|
||||
getImageSize,
|
||||
getIso,
|
||||
getLensMake,
|
||||
getLensModel,
|
||||
getLocality,
|
||||
getMediaTitle,
|
||||
getPlace,
|
||||
getRegion,
|
||||
getSoftware,
|
||||
getStreet,
|
||||
takenAt,
|
||||
} from "../../Sidebar/MediaInfo.tsx";
|
||||
import { MediaMetaElements } from "../../Sidebar/MediaMetaCard.tsx";
|
||||
import FileTagSummary from "../FileTagSummary.tsx";
|
||||
import { ThumbLoadingPlaceholder, ThumbPopoverImg } from "../GridView/GridFile.tsx";
|
||||
import UploadingTag from "../UploadingTag.tsx";
|
||||
|
||||
export interface CellProps {
|
||||
file: FmFile;
|
||||
column: ListViewColumn;
|
||||
isSelected?: boolean;
|
||||
search?: SearchParam;
|
||||
fileTag?: {
|
||||
key: string;
|
||||
value: string;
|
||||
}[];
|
||||
uploading?: boolean;
|
||||
noThumb?: boolean;
|
||||
thumbWidth?: number;
|
||||
thumbHeight?: number;
|
||||
}
|
||||
|
||||
export interface ThumbPopoverProps {
|
||||
file: FmFile;
|
||||
popupState: PopoverProps;
|
||||
thumbWidth?: number;
|
||||
thumbHeight?: number;
|
||||
}
|
||||
|
||||
export const ThumbPopover = memo((props: ThumbPopoverProps) => {
|
||||
const { t } = useTranslation();
|
||||
const {
|
||||
file,
|
||||
popupState: { open, ...rest },
|
||||
thumbWidth,
|
||||
thumbHeight,
|
||||
} = props;
|
||||
|
||||
const dispatch = useAppDispatch();
|
||||
// undefined: not loaded, null: no thumb
|
||||
const [thumbSrc, setThumbSrc] = useState<string | undefined | null>(undefined);
|
||||
const [imageLoading, setImageLoading] = useState(true);
|
||||
|
||||
const tryLoadThumbSrc = useCallback(async () => {
|
||||
const thumbSrc = await dispatch(loadFileThumb(0, file));
|
||||
setThumbSrc(thumbSrc);
|
||||
}, [dispatch, file, setThumbSrc, setImageLoading]);
|
||||
|
||||
const onImgLoadError = useCallback(() => {
|
||||
setImageLoading(false);
|
||||
setThumbSrc(null);
|
||||
}, [setImageLoading, setThumbSrc]);
|
||||
|
||||
useEffect(() => {
|
||||
if (open && !thumbSrc) {
|
||||
tryLoadThumbSrc();
|
||||
}
|
||||
}, [open]);
|
||||
|
||||
const showPlaceholder = thumbSrc === undefined || (thumbSrc && imageLoading);
|
||||
|
||||
return (
|
||||
<HoverPopover
|
||||
open={open}
|
||||
anchorOrigin={{
|
||||
vertical: "bottom",
|
||||
horizontal: "center",
|
||||
}}
|
||||
transformOrigin={{
|
||||
vertical: "top",
|
||||
horizontal: "center",
|
||||
}}
|
||||
{...rest}
|
||||
>
|
||||
<AutoHeight>
|
||||
<TransitionGroup
|
||||
style={{
|
||||
width: !showPlaceholder ? "initial" : "300px",
|
||||
height: !showPlaceholder ? "100%" : "300px",
|
||||
}}
|
||||
>
|
||||
{showPlaceholder && (
|
||||
<Fade key={"loading"}>
|
||||
<ThumbLoadingPlaceholder variant={"rectangular"} />
|
||||
</Fade>
|
||||
)}
|
||||
{thumbSrc && (
|
||||
<Fade key={"image"}>
|
||||
<ThumbPopoverImg
|
||||
width={thumbWidth}
|
||||
height={thumbHeight}
|
||||
onLoad={() => setImageLoading(false)}
|
||||
onError={onImgLoadError}
|
||||
src={thumbSrc}
|
||||
draggable={false}
|
||||
/>
|
||||
</Fade>
|
||||
)}
|
||||
{thumbSrc === null && (
|
||||
<Fade key={"failed"}>
|
||||
<Box sx={{ py: 0.5, px: 1, display: "flex", alignItems: "center" }} color={"text.secondary"}>
|
||||
<Info sx={{ mr: 1 }} />
|
||||
<Typography variant="body2">{t("fileManager.failedLoadPreview")}</Typography>
|
||||
</Box>
|
||||
</Fade>
|
||||
)}
|
||||
</TransitionGroup>
|
||||
</AutoHeight>
|
||||
</HoverPopover>
|
||||
);
|
||||
});
|
||||
|
||||
const FileNameCell = memo((props: CellProps) => {
|
||||
const theme = useTheme();
|
||||
const isMobile = useMediaQuery(theme.breakpoints.down("md"));
|
||||
const isTouch = useMediaQuery("(pointer: coarse)");
|
||||
const { file, uploading, noThumb, fileTag, search, isSelected, thumbWidth, thumbHeight } = props;
|
||||
|
||||
const popupState = usePopupState({
|
||||
variant: "popover",
|
||||
popupId: "thumbPreview" + file.id,
|
||||
});
|
||||
|
||||
const hoverState = bindDelayedHover(popupState, 800);
|
||||
|
||||
return (
|
||||
<>
|
||||
<Box
|
||||
sx={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: 1,
|
||||
}}
|
||||
>
|
||||
<Box {...(noThumb || isMobile || isTouch ? {} : hoverState)}>
|
||||
<FileSmallIcon variant={"list"} selected={!!isSelected} file={file} />
|
||||
</Box>
|
||||
|
||||
<Tooltip title={file.name}>
|
||||
<NoWrapBox>
|
||||
{search?.name ? (
|
||||
<Highlighter
|
||||
highlightClassName="highlight-marker"
|
||||
searchWords={search?.name}
|
||||
autoEscape={true}
|
||||
textToHighlight={file.name}
|
||||
/>
|
||||
) : (
|
||||
file.name
|
||||
)}
|
||||
</NoWrapBox>
|
||||
</Tooltip>
|
||||
{!uploading && fileTag && fileTag.length > 0 && <FileTagSummary sx={{ maxWidth: "50%" }} tags={fileTag} />}
|
||||
{uploading && <UploadingTag sx={{ maxWidth: "50%" }} />}
|
||||
</Box>
|
||||
{!noThumb && (
|
||||
<ThumbPopover
|
||||
thumbWidth={thumbWidth}
|
||||
thumbHeight={thumbHeight}
|
||||
popupState={bindPopover(popupState)}
|
||||
{...props}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
});
|
||||
|
||||
interface FolderSizeCellProps {
|
||||
file: FmFile;
|
||||
}
|
||||
|
||||
const FolderSizeCell = memo(({ file }: FolderSizeCellProps) => {
|
||||
const { t } = useTranslation();
|
||||
if (file.type == FileType.folder || file.metadata?.[Metadata.share_redirect]) {
|
||||
return <Box />;
|
||||
}
|
||||
return <Box>{sizeToString(file.size)}</Box>;
|
||||
});
|
||||
|
||||
interface FolderDateCellProps {
|
||||
file: FmFile;
|
||||
dateType: "created" | "modified" | "expired";
|
||||
}
|
||||
|
||||
const FolderDateCell = memo(({ file, dateType }: FolderDateCellProps) => {
|
||||
const { t } = useTranslation();
|
||||
let datetime: string | Dayjs = "";
|
||||
switch (dateType) {
|
||||
case "created":
|
||||
datetime = file.created_at;
|
||||
break;
|
||||
case "modified":
|
||||
datetime = file.updated_at;
|
||||
break;
|
||||
case "expired":
|
||||
datetime = file.metadata?.[Metadata.expected_collect_time]
|
||||
? dayjs.unix(parseInt(file.metadata?.[Metadata.expected_collect_time]))
|
||||
: "";
|
||||
}
|
||||
|
||||
if (!datetime) {
|
||||
return <Box />;
|
||||
}
|
||||
return <TimeBadge variant={"inherit"} datetime={datetime} />;
|
||||
});
|
||||
|
||||
const FolderCell = memo(({ path }: { path: string }) => {
|
||||
return (
|
||||
<FileBadge
|
||||
clickable
|
||||
sx={{ px: 1, maxWidth: "100%" }}
|
||||
simplifiedFile={{
|
||||
path: path,
|
||||
type: FileType.folder,
|
||||
}}
|
||||
/>
|
||||
);
|
||||
});
|
||||
|
||||
const MediaElementsCell = memo(({ element }: { element?: MediaMetaElements | string }) => {
|
||||
if (!element) {
|
||||
return <Box />;
|
||||
}
|
||||
if (typeof element === "string") {
|
||||
return <Box>{element}</Box>;
|
||||
}
|
||||
return <MediaMetaElements element={element} />;
|
||||
});
|
||||
|
||||
const Cell = memo((props: CellProps) => {
|
||||
const { t } = useTranslation();
|
||||
const customProps = useAppSelector((state) => state.siteConfig.explorer?.config?.custom_props);
|
||||
const customProp = useMemo(() => {
|
||||
if (!props.column.props?.custom_props_id || props.column.type !== ColumType.custom_props) {
|
||||
return undefined;
|
||||
}
|
||||
const customProp = customProps?.find((p) => p.id === props.column.props?.custom_props_id);
|
||||
if (!customProp) {
|
||||
return undefined;
|
||||
}
|
||||
const value = props.file.metadata?.[`${customPropsMetadataPrefix}${customProp.id}`];
|
||||
if (value === undefined) {
|
||||
return undefined;
|
||||
}
|
||||
return {
|
||||
id: customProp.id,
|
||||
props: customProp,
|
||||
value: value ?? "",
|
||||
} as CustomPropsItem;
|
||||
}, [customProps, props.column.props?.custom_props_id, props.column.type, props.file.metadata]);
|
||||
|
||||
const { file, column, uploading, fileTag, search, isSelected } = props;
|
||||
switch (column.type) {
|
||||
case ColumType.name:
|
||||
return <FileNameCell {...props} />;
|
||||
case ColumType.size:
|
||||
return <FolderSizeCell file={file} />;
|
||||
case ColumType.date_modified:
|
||||
return <FolderDateCell file={file} dateType={"modified"} />;
|
||||
case ColumType.date_created:
|
||||
return <FolderDateCell file={file} dateType={"created"} />;
|
||||
case ColumType.parent: {
|
||||
let crUrl = new CrUri(file.path);
|
||||
return <FolderCell path={crUrl.parent().toString()} />;
|
||||
}
|
||||
case ColumType.recycle_restore_parent: {
|
||||
if (!file.metadata?.[Metadata.restore_uri]) {
|
||||
return <Box />;
|
||||
}
|
||||
|
||||
let crUrl = new CrUri(file.metadata[Metadata.restore_uri]);
|
||||
return <FolderCell path={crUrl.parent().toString()} />;
|
||||
}
|
||||
case ColumType.recycle_expire:
|
||||
return <FolderDateCell file={file} dateType={"expired"} />;
|
||||
case ColumType.aperture:
|
||||
return <MediaElementsCell element={getAperture(file)} />;
|
||||
case ColumType.exposure:
|
||||
return <MediaElementsCell element={getExposure(file, t)} />;
|
||||
case ColumType.iso:
|
||||
return <MediaElementsCell element={getIso(file)} />;
|
||||
case ColumType.camera_make:
|
||||
return <MediaElementsCell element={getCameraMake(file)} />;
|
||||
case ColumType.camera_model:
|
||||
return <MediaElementsCell element={getCameraModel(file)} />;
|
||||
case ColumType.lens_make:
|
||||
return <MediaElementsCell element={getLensMake(file)} />;
|
||||
case ColumType.lens_model:
|
||||
return <MediaElementsCell element={getLensModel(file)} />;
|
||||
case ColumType.focal_length:
|
||||
return <MediaElementsCell element={getFocalLength(file)} />;
|
||||
case ColumType.exposure_bias:
|
||||
return <MediaElementsCell element={getExposureBias(file)} />;
|
||||
case ColumType.flash:
|
||||
return <MediaElementsCell element={getFlash(file, t)} />;
|
||||
case ColumType.software:
|
||||
return <MediaElementsCell element={getSoftware(file)} />;
|
||||
case ColumType.taken_at:
|
||||
return <MediaElementsCell element={takenAt(file)} />;
|
||||
case ColumType.image_size:
|
||||
return (
|
||||
<Box sx={{ display: "flex" }}>{getImageSize(file)?.map((size) => <MediaElementsCell element={size} />)}</Box>
|
||||
);
|
||||
case ColumType.title:
|
||||
return <MediaElementsCell element={getMediaTitle(file)} />;
|
||||
case ColumType.artist:
|
||||
return <MediaElementsCell element={getArtist(file)} />;
|
||||
case ColumType.album:
|
||||
return <MediaElementsCell element={getAlbum(file)} />;
|
||||
case ColumType.duration:
|
||||
return <MediaElementsCell element={getDuration(file)} />;
|
||||
case ColumType.street:
|
||||
return <MediaElementsCell element={getStreet(file)} />;
|
||||
case ColumType.locality:
|
||||
return <MediaElementsCell element={getLocality(file)} />;
|
||||
case ColumType.place:
|
||||
return <MediaElementsCell element={getPlace(file)} />;
|
||||
case ColumType.district:
|
||||
return <MediaElementsCell element={getDistrict(file)} />;
|
||||
case ColumType.region:
|
||||
return <MediaElementsCell element={getRegion(file)} />;
|
||||
case ColumType.country:
|
||||
return <MediaElementsCell element={getCountry(file)} />;
|
||||
case ColumType.custom_props:
|
||||
if (customProp) {
|
||||
return getPropsContent(customProp, () => {}, false, true);
|
||||
}
|
||||
return <Box />;
|
||||
}
|
||||
});
|
||||
|
||||
export default Cell;
|
||||
332
src/component/FileManager/Explorer/ListView/Column.tsx
Executable file
332
src/component/FileManager/Explorer/ListView/Column.tsx
Executable file
@@ -0,0 +1,332 @@
|
||||
import { Box, Fade, IconButton, styled } from "@mui/material";
|
||||
import { useCallback, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { CustomProps } from "../../../../api/explorer.ts";
|
||||
import { NoWrapTypography } from "../../../Common/StyledComponents.tsx";
|
||||
import ArrowSortDownFilled from "../../../Icons/ArrowSortDownFilled.tsx";
|
||||
import Divider from "../../../Icons/Divider.tsx";
|
||||
import { ResizeProps } from "./ListHeader.tsx";
|
||||
|
||||
export interface ListViewColumn {
|
||||
type: ColumType;
|
||||
width?: number;
|
||||
defaults: ColumTypeDefaults;
|
||||
props?: ColumTypeProps;
|
||||
}
|
||||
|
||||
export interface ListViewColumnSetting {
|
||||
type: ColumType;
|
||||
width?: number;
|
||||
props?: ColumTypeProps;
|
||||
}
|
||||
|
||||
export interface ColumTypeProps {
|
||||
metadata_key?: string;
|
||||
custom_props_id?: string;
|
||||
}
|
||||
|
||||
export enum ColumType {
|
||||
name = 0,
|
||||
date_modified = 1,
|
||||
size = 2,
|
||||
metadata = 3,
|
||||
date_created = 4,
|
||||
permission = 5,
|
||||
parent = 6,
|
||||
recycle_restore_parent = 7,
|
||||
recycle_expire = 8,
|
||||
|
||||
// Media info
|
||||
aperture = 9,
|
||||
exposure = 10,
|
||||
iso = 11,
|
||||
camera_make = 12,
|
||||
camera_model = 13,
|
||||
lens_make = 14,
|
||||
lens_model = 15,
|
||||
focal_length = 16,
|
||||
exposure_bias = 17,
|
||||
flash = 18,
|
||||
software = 19,
|
||||
taken_at = 20,
|
||||
image_size = 21,
|
||||
title = 22,
|
||||
artist = 23,
|
||||
album = 24,
|
||||
duration = 25,
|
||||
street = 27,
|
||||
locality = 28,
|
||||
place = 29,
|
||||
district = 30,
|
||||
region = 31,
|
||||
country = 32,
|
||||
|
||||
// Custom props
|
||||
custom_props = 26,
|
||||
}
|
||||
|
||||
export interface ColumTypeDefaults {
|
||||
title: string;
|
||||
width: number;
|
||||
widthMobile?: number;
|
||||
minWidth?: number;
|
||||
order_by?: string;
|
||||
}
|
||||
|
||||
export interface ColumnProps {
|
||||
index: number;
|
||||
column: ListViewColumn;
|
||||
showDivider?: boolean;
|
||||
startResizing: (props: ResizeProps) => void;
|
||||
sortable?: boolean;
|
||||
sortDirection?: string;
|
||||
setSortBy?: (order_by: string, order_direction: string) => void;
|
||||
}
|
||||
|
||||
export const ColumnTypeDefaults: { [key: number]: ColumTypeDefaults } = {
|
||||
[ColumType.name]: {
|
||||
title: "application:fileManager.name",
|
||||
widthMobile: 300,
|
||||
width: 600,
|
||||
order_by: "name",
|
||||
},
|
||||
[ColumType.size]: {
|
||||
title: "application:fileManager.size",
|
||||
width: 100,
|
||||
order_by: "size",
|
||||
},
|
||||
[ColumType.date_modified]: {
|
||||
title: "application:fileManager.lastModified",
|
||||
width: 200,
|
||||
order_by: "updated_at",
|
||||
},
|
||||
[ColumType.date_created]: {
|
||||
title: "application:fileManager.createDate",
|
||||
width: 200,
|
||||
order_by: "created_at",
|
||||
},
|
||||
[ColumType.parent]: {
|
||||
title: "application:fileManager.parentFolder",
|
||||
width: 200,
|
||||
},
|
||||
[ColumType.recycle_restore_parent]: {
|
||||
title: "application:fileManager.originalLocation",
|
||||
width: 200,
|
||||
},
|
||||
[ColumType.recycle_expire]: {
|
||||
title: "application:fileManager.expires",
|
||||
width: 200,
|
||||
},
|
||||
[ColumType.aperture]: {
|
||||
title: "application:fileManager.aperture",
|
||||
width: 100,
|
||||
},
|
||||
[ColumType.exposure]: {
|
||||
title: "application:fileManager.exposure",
|
||||
width: 100,
|
||||
},
|
||||
[ColumType.iso]: {
|
||||
title: "application:fileManager.iso",
|
||||
width: 100,
|
||||
},
|
||||
[ColumType.camera_make]: {
|
||||
title: "application:fileManager.cameraMake",
|
||||
width: 100,
|
||||
},
|
||||
[ColumType.camera_model]: {
|
||||
title: "application:fileManager.cameraModel",
|
||||
width: 100,
|
||||
},
|
||||
[ColumType.lens_make]: {
|
||||
title: "application:fileManager.lensMake",
|
||||
width: 100,
|
||||
},
|
||||
[ColumType.lens_model]: {
|
||||
title: "application:fileManager.lensModel",
|
||||
width: 100,
|
||||
},
|
||||
[ColumType.focal_length]: {
|
||||
title: "application:fileManager.focalLength",
|
||||
width: 100,
|
||||
},
|
||||
[ColumType.exposure_bias]: {
|
||||
title: "application:fileManager.exposureBias",
|
||||
width: 100,
|
||||
},
|
||||
[ColumType.flash]: {
|
||||
title: "application:fileManager.flash",
|
||||
width: 100,
|
||||
},
|
||||
[ColumType.software]: {
|
||||
title: "application:fileManager.software",
|
||||
width: 100,
|
||||
},
|
||||
[ColumType.taken_at]: {
|
||||
title: "application:fileManager.takenAt",
|
||||
width: 200,
|
||||
},
|
||||
[ColumType.image_size]: {
|
||||
title: "application:fileManager.resolution",
|
||||
width: 100,
|
||||
},
|
||||
[ColumType.title]: {
|
||||
title: "application:fileManager.title",
|
||||
width: 200,
|
||||
},
|
||||
[ColumType.artist]: {
|
||||
title: "application:fileManager.artist",
|
||||
width: 100,
|
||||
},
|
||||
[ColumType.album]: {
|
||||
title: "application:fileManager.album",
|
||||
width: 200,
|
||||
},
|
||||
[ColumType.duration]: {
|
||||
title: "application:fileManager.duration",
|
||||
width: 100,
|
||||
},
|
||||
[ColumType.street]: {
|
||||
title: "application:fileManager.street",
|
||||
width: 100,
|
||||
},
|
||||
[ColumType.locality]: {
|
||||
title: "application:fileManager.locality",
|
||||
width: 100,
|
||||
},
|
||||
[ColumType.place]: {
|
||||
title: "application:fileManager.place",
|
||||
width: 100,
|
||||
},
|
||||
[ColumType.district]: {
|
||||
title: "application:fileManager.district",
|
||||
width: 100,
|
||||
},
|
||||
[ColumType.region]: {
|
||||
title: "application:fileManager.region",
|
||||
width: 100,
|
||||
},
|
||||
[ColumType.country]: {
|
||||
title: "application:fileManager.country",
|
||||
width: 100,
|
||||
},
|
||||
};
|
||||
|
||||
export const getColumnTypeDefaults = (
|
||||
c: ListViewColumnSetting,
|
||||
isMobile?: boolean,
|
||||
customProps?: CustomProps[],
|
||||
): ColumTypeDefaults => {
|
||||
if (ColumnTypeDefaults[c.type]) {
|
||||
return {
|
||||
...ColumnTypeDefaults[c.type],
|
||||
width:
|
||||
isMobile && ColumnTypeDefaults[c.type].widthMobile
|
||||
? ColumnTypeDefaults[c.type].widthMobile
|
||||
: ColumnTypeDefaults[c.type].width,
|
||||
};
|
||||
}
|
||||
|
||||
if (c.type === ColumType.custom_props) {
|
||||
const customProp = customProps?.find((p) => p.id === c.props?.custom_props_id);
|
||||
return {
|
||||
title: customProp?.name ?? "application:fileManager.customProps",
|
||||
width: 100,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
title: "application:fileManager.metadataColumn",
|
||||
width: 100,
|
||||
};
|
||||
};
|
||||
|
||||
const ColumnContainer = styled(Box)<{
|
||||
w: number;
|
||||
}>(({ w }) => ({
|
||||
height: "39px",
|
||||
width: `${w}px`,
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
padding: "0 10px",
|
||||
}));
|
||||
|
||||
const DividerContainer = styled(Box)(({ theme }) => ({
|
||||
color: theme.palette.divider,
|
||||
maxWidth: "10px",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
cursor: "col-resize",
|
||||
"&:hover": {
|
||||
color: theme.palette.primary.main,
|
||||
},
|
||||
transition: theme.transitions.create(["color"], {
|
||||
duration: theme.transitions.duration.shortest,
|
||||
}),
|
||||
position: "relative",
|
||||
right: "-8px",
|
||||
}));
|
||||
|
||||
const SortArrow = styled(ArrowSortDownFilled)<{
|
||||
direction?: string;
|
||||
}>(({ theme, direction }) => ({
|
||||
width: "18px",
|
||||
height: "18px",
|
||||
color: !direction ? theme.palette.action.disabled : theme.palette.action.active,
|
||||
transform: `rotate(${direction === "asc" ? 180 : 0}deg)`,
|
||||
transition: theme.transitions.create(["color", "transform"], {
|
||||
duration: theme.transitions.duration.shortest,
|
||||
}),
|
||||
}));
|
||||
|
||||
const Column = ({ column, showDivider, index, startResizing, sortDirection, setSortBy, sortable }: ColumnProps) => {
|
||||
const [showSortButton, setShowSortButton] = useState(false);
|
||||
const { t } = useTranslation();
|
||||
const onSortOptionChange = useCallback(() => {
|
||||
if (!sortable || !column.defaults.order_by) return;
|
||||
const newDirection = sortDirection === "asc" ? "desc" : "asc";
|
||||
setSortBy && setSortBy(column.defaults.order_by, newDirection);
|
||||
}, [setSortBy, sortDirection, sortable, column]);
|
||||
|
||||
return (
|
||||
<ColumnContainer w={column.width ?? column.defaults.width}>
|
||||
<Box
|
||||
sx={{
|
||||
width: "100%",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
cursor: sortable ? "pointer" : "default",
|
||||
}}
|
||||
onMouseEnter={() => setShowSortButton(!!sortable)}
|
||||
onMouseLeave={() => setShowSortButton(false)}
|
||||
onClick={sortable ? onSortOptionChange : undefined}
|
||||
>
|
||||
<NoWrapTypography variant={"body2"} fontWeight={600}>
|
||||
{t(column.defaults.title, {
|
||||
metadata: column.props?.metadata_key,
|
||||
})}
|
||||
</NoWrapTypography>
|
||||
{sortable && (
|
||||
<Fade in={showSortButton || !!sortDirection}>
|
||||
<IconButton sx={{ ml: 1 }} size={"small"}>
|
||||
<SortArrow direction={sortDirection} />
|
||||
</IconButton>
|
||||
</Fade>
|
||||
)}
|
||||
</Box>
|
||||
<Fade in={showDivider}>
|
||||
<DividerContainer
|
||||
onMouseDown={(e) =>
|
||||
startResizing({
|
||||
index,
|
||||
startX: e.clientX,
|
||||
})
|
||||
}
|
||||
>
|
||||
<Divider />
|
||||
</DividerContainer>
|
||||
</Fade>
|
||||
</ColumnContainer>
|
||||
);
|
||||
};
|
||||
|
||||
export default Column;
|
||||
228
src/component/FileManager/Explorer/ListView/ColumnSetting.tsx
Executable file
228
src/component/FileManager/Explorer/ListView/ColumnSetting.tsx
Executable file
@@ -0,0 +1,228 @@
|
||||
import {
|
||||
Box,
|
||||
DialogContent,
|
||||
IconButton,
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableContainer,
|
||||
TableHead,
|
||||
TableRow,
|
||||
} from "@mui/material";
|
||||
import { useSnackbar } from "notistack";
|
||||
import type { Dispatch, SetStateAction } from "react";
|
||||
import React, { useCallback, useEffect, useState } from "react";
|
||||
import { DndProvider, useDrag, useDrop } from "react-dnd";
|
||||
import { HTML5Backend } from "react-dnd-html5-backend";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { setListViewColumnSettingDialog } from "../../../../redux/globalStateSlice.ts";
|
||||
import { useAppDispatch, useAppSelector } from "../../../../redux/hooks.ts";
|
||||
import { applyListColumns } from "../../../../redux/thunks/filemanager.ts";
|
||||
import AutoHeight from "../../../Common/AutoHeight.tsx";
|
||||
import { DefaultCloseAction } from "../../../Common/Snackbar/snackbar.tsx";
|
||||
import { StyledTableContainerPaper } from "../../../Common/StyledComponents.tsx";
|
||||
import DraggableDialog from "../../../Dialogs/DraggableDialog.tsx";
|
||||
import ArrowDown from "../../../Icons/ArrowDown.tsx";
|
||||
import Dismiss from "../../../Icons/Dismiss.tsx";
|
||||
import { FileManagerIndex } from "../../FileManager.tsx";
|
||||
import AddColumn from "./AddColumn.tsx";
|
||||
import { getColumnTypeDefaults, ListViewColumnSetting } from "./Column.tsx";
|
||||
|
||||
const DND_TYPE = "column-row";
|
||||
|
||||
interface DraggableColumnRowProps {
|
||||
column: ListViewColumnSetting;
|
||||
index: number;
|
||||
moveRow: (from: number, to: number) => void;
|
||||
columns: ListViewColumnSetting[];
|
||||
setColumns: Dispatch<SetStateAction<ListViewColumnSetting[]>>;
|
||||
t: (key: string) => string;
|
||||
onDelete: (idx: number) => void;
|
||||
isFirst: boolean;
|
||||
isLast: boolean;
|
||||
}
|
||||
|
||||
const DraggableColumnRow: React.FC<DraggableColumnRowProps> = ({
|
||||
column,
|
||||
index,
|
||||
moveRow,
|
||||
columns,
|
||||
t,
|
||||
onDelete,
|
||||
isFirst,
|
||||
isLast,
|
||||
}) => {
|
||||
const ref = React.useRef<HTMLTableRowElement>(null);
|
||||
const customProps = useAppSelector((state) => state.siteConfig.explorer?.config?.custom_props);
|
||||
const [, drop] = useDrop({
|
||||
accept: DND_TYPE,
|
||||
hover(item: any, monitor) {
|
||||
if (!ref.current) return;
|
||||
|
||||
const dragIndex = item.index;
|
||||
const hoverIndex = index;
|
||||
if (dragIndex === hoverIndex) return;
|
||||
|
||||
const hoverBoundingRect = ref.current.getBoundingClientRect();
|
||||
const hoverMiddleY = (hoverBoundingRect.bottom - hoverBoundingRect.top) / 2;
|
||||
const clientOffset = monitor.getClientOffset();
|
||||
if (!clientOffset) return;
|
||||
|
||||
const hoverClientY = clientOffset.y - hoverBoundingRect.top;
|
||||
|
||||
if (dragIndex < hoverIndex && hoverClientY < hoverMiddleY) return;
|
||||
if (dragIndex > hoverIndex && hoverClientY > hoverMiddleY) return;
|
||||
|
||||
moveRow(dragIndex, hoverIndex);
|
||||
item.index = hoverIndex;
|
||||
},
|
||||
});
|
||||
const [{ isDragging }, drag] = useDrag({
|
||||
type: DND_TYPE,
|
||||
item: { index },
|
||||
collect: (monitor) => ({
|
||||
isDragging: monitor.isDragging(),
|
||||
}),
|
||||
});
|
||||
drag(drop(ref));
|
||||
return (
|
||||
<TableRow
|
||||
ref={ref}
|
||||
hover
|
||||
key={index}
|
||||
sx={{ "&:last-child td, &:last-child th": { border: 0 }, opacity: isDragging ? 0.5 : 1, cursor: "move" }}
|
||||
>
|
||||
<TableCell component="th" scope="row">
|
||||
{t(getColumnTypeDefaults(column, false, customProps).title)}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Box sx={{ display: "flex" }}>
|
||||
<IconButton size="small" onClick={() => moveRow(index, index - 1)} disabled={isFirst}>
|
||||
<ArrowDown
|
||||
sx={{
|
||||
width: "18px",
|
||||
height: "18px",
|
||||
transform: "rotate(180deg)",
|
||||
}}
|
||||
/>
|
||||
</IconButton>
|
||||
<IconButton size="small" onClick={() => moveRow(index, index + 1)} disabled={isLast}>
|
||||
<ArrowDown
|
||||
sx={{
|
||||
width: "18px",
|
||||
height: "18px",
|
||||
}}
|
||||
/>
|
||||
</IconButton>
|
||||
<IconButton size="small" onClick={() => onDelete(index)} disabled={columns.length <= 1}>
|
||||
<Dismiss sx={{ width: "18px", height: "18px" }} />
|
||||
</IconButton>
|
||||
</Box>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
);
|
||||
};
|
||||
|
||||
const ColumnSetting = () => {
|
||||
const { t } = useTranslation();
|
||||
const dispatch = useAppDispatch();
|
||||
const { enqueueSnackbar } = useSnackbar();
|
||||
const [columns, setColumns] = useState<ListViewColumnSetting[]>([]);
|
||||
|
||||
const open = useAppSelector((state) => state.globalState.listViewColumnSettingDialogOpen);
|
||||
const listViewColumns = useAppSelector((state) => state.fileManager[FileManagerIndex.main].listViewColumns);
|
||||
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
setColumns(listViewColumns ?? []);
|
||||
}
|
||||
}, [open]);
|
||||
|
||||
const onClose = useCallback(() => {
|
||||
dispatch(setListViewColumnSettingDialog(false));
|
||||
}, [dispatch]);
|
||||
|
||||
const onSubmitted = useCallback(() => {
|
||||
if (columns.length > 0) {
|
||||
dispatch(applyListColumns(FileManagerIndex.main, columns));
|
||||
}
|
||||
dispatch(setListViewColumnSettingDialog(false));
|
||||
}, [dispatch, columns]);
|
||||
|
||||
const onColumnAdded = useCallback(
|
||||
(column: ListViewColumnSetting) => {
|
||||
const existed = columns.find((c) => c.type === column.type);
|
||||
if (
|
||||
!existed ||
|
||||
existed.props?.metadata_key != column.props?.metadata_key ||
|
||||
existed.props?.custom_props_id != column.props?.custom_props_id
|
||||
) {
|
||||
setColumns((prev) => [...prev, column]);
|
||||
} else {
|
||||
enqueueSnackbar(t("application:fileManager.columnExisted"), {
|
||||
variant: "warning",
|
||||
action: DefaultCloseAction,
|
||||
});
|
||||
}
|
||||
},
|
||||
[columns],
|
||||
);
|
||||
|
||||
return (
|
||||
<DraggableDialog
|
||||
title={t("application:fileManager.listColumnSetting")}
|
||||
onAccept={onSubmitted}
|
||||
showActions
|
||||
secondaryAction={<AddColumn onColumnAdded={onColumnAdded} />}
|
||||
dialogProps={{
|
||||
open: open ?? false,
|
||||
onClose: onClose,
|
||||
fullWidth: true,
|
||||
maxWidth: "sm",
|
||||
disableRestoreFocus: true,
|
||||
}}
|
||||
>
|
||||
<DialogContent sx={{ pb: 0 }}>
|
||||
<AutoHeight>
|
||||
<DndProvider backend={HTML5Backend}>
|
||||
<TableContainer component={StyledTableContainerPaper}>
|
||||
<Table sx={{ width: "100%" }} size="small">
|
||||
<TableHead>
|
||||
<TableRow>
|
||||
<TableCell width={"50%"}>{t("fileManager.column")}</TableCell>
|
||||
<TableCell>{t("fileManager.actions")}</TableCell>
|
||||
</TableRow>
|
||||
</TableHead>
|
||||
<TableBody>
|
||||
{columns.map((column, index) => (
|
||||
<DraggableColumnRow
|
||||
key={index}
|
||||
column={column}
|
||||
index={index}
|
||||
moveRow={(from, to) => {
|
||||
if (from === to || to < 0 || to >= columns.length) return;
|
||||
setColumns((prev) => {
|
||||
const arr = [...prev];
|
||||
const [moved] = arr.splice(from, 1);
|
||||
arr.splice(to, 0, moved);
|
||||
return arr;
|
||||
});
|
||||
}}
|
||||
columns={columns}
|
||||
setColumns={setColumns}
|
||||
t={t}
|
||||
onDelete={(idx) => setColumns((prev) => prev.filter((_, i) => i !== idx))}
|
||||
isFirst={index === 0}
|
||||
isLast={index === columns.length - 1}
|
||||
/>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</TableContainer>
|
||||
</DndProvider>
|
||||
</AutoHeight>
|
||||
</DialogContent>
|
||||
</DraggableDialog>
|
||||
);
|
||||
};
|
||||
export default ColumnSetting;
|
||||
69
src/component/FileManager/Explorer/ListView/ListBody.tsx
Executable file
69
src/component/FileManager/Explorer/ListView/ListBody.tsx
Executable file
@@ -0,0 +1,69 @@
|
||||
import { ListViewColumn } from "./Column.tsx";
|
||||
import React, { useContext, useMemo } from "react";
|
||||
import { FmIndexContext } from "../../FmIndexContext.tsx";
|
||||
import { useAppSelector } from "../../../../redux/hooks.ts";
|
||||
import { Virtuoso } from "react-virtuoso";
|
||||
import DndWrappedFile from "../../Dnd/DndWrappedFile.tsx";
|
||||
import Row from "./Row.tsx";
|
||||
import { FmFile, loadingPlaceHolderNumb } from "../GridView/GridView.tsx";
|
||||
|
||||
export interface ListBodyProps {
|
||||
columns: ListViewColumn[];
|
||||
}
|
||||
|
||||
const ListBody = ({ columns }: ListBodyProps) => {
|
||||
const fmIndex = useContext(FmIndexContext);
|
||||
const files = useAppSelector((state) => state.fileManager[fmIndex].list?.files);
|
||||
const mixedType = useAppSelector((state) => state.fileManager[fmIndex].list?.mixed_type);
|
||||
const pagination = useAppSelector((state) => state.fileManager[fmIndex].list?.pagination);
|
||||
const search_params = useAppSelector((state) => state.fileManager[fmIndex]?.search_params);
|
||||
|
||||
const list = useMemo(() => {
|
||||
const list: FmFile[] = [];
|
||||
if (!files) {
|
||||
return list;
|
||||
}
|
||||
|
||||
files.forEach((file) => {
|
||||
list.push(file);
|
||||
});
|
||||
|
||||
// Add loading placeholder if there is next page
|
||||
if (pagination && pagination.next_token) {
|
||||
for (let i = 0; i < loadingPlaceHolderNumb; i++) {
|
||||
const id = `loadingPlaceholder-${pagination.next_token}-${i}`;
|
||||
list.push({
|
||||
...files[0],
|
||||
path: files[0].path + "/" + id,
|
||||
id: `loadingPlaceholder-${pagination.next_token}-${i}`,
|
||||
first: i == 0,
|
||||
placeholder: true,
|
||||
});
|
||||
}
|
||||
}
|
||||
return list;
|
||||
}, [files, mixedType, pagination, search_params]);
|
||||
|
||||
return (
|
||||
<Virtuoso
|
||||
style={{
|
||||
height: "100%",
|
||||
}}
|
||||
increaseViewportBy={180}
|
||||
data={list}
|
||||
itemContent={(index, file) => (
|
||||
<DndWrappedFile
|
||||
columns={columns}
|
||||
key={file.id}
|
||||
component={Row}
|
||||
search={search_params}
|
||||
index={index}
|
||||
showThumb={mixedType}
|
||||
file={file}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export default ListBody;
|
||||
132
src/component/FileManager/Explorer/ListView/ListHeader.tsx
Executable file
132
src/component/FileManager/Explorer/ListView/ListHeader.tsx
Executable file
@@ -0,0 +1,132 @@
|
||||
import Column, { ListViewColumn } from "./Column.tsx";
|
||||
import { Box, Fade, IconButton, Tooltip } from "@mui/material";
|
||||
import { useCallback, useContext, useMemo, useRef, useState } from "react";
|
||||
import { FmIndexContext } from "../../FmIndexContext.tsx";
|
||||
import { useAppDispatch, useAppSelector } from "../../../../redux/hooks.ts";
|
||||
import { changeSortOption } from "../../../../redux/thunks/filemanager.ts";
|
||||
import SessionManager, { UserSettings } from "../../../../session";
|
||||
import { Add } from "@mui/icons-material";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { setListViewColumnSettingDialog } from "../../../../redux/globalStateSlice.ts";
|
||||
|
||||
export interface ListHeaderProps {
|
||||
columns: ListViewColumn[];
|
||||
setColumns: React.Dispatch<React.SetStateAction<ListViewColumn[]>>;
|
||||
commitColumnSetting: () => void;
|
||||
}
|
||||
|
||||
export interface ResizeProps {
|
||||
index: number;
|
||||
startX: number;
|
||||
}
|
||||
|
||||
const ListHeader = ({ setColumns, commitColumnSetting, columns }: ListHeaderProps) => {
|
||||
const dispatch = useAppDispatch();
|
||||
const { t } = useTranslation();
|
||||
const [showDivider, setShowDivider] = useState(false);
|
||||
const resizeProps = useRef<ResizeProps | undefined>();
|
||||
const startResizing = (props: ResizeProps) => {
|
||||
resizeProps.current = props;
|
||||
document.body.style.cursor = "col-resize";
|
||||
window.addEventListener("mousemove", onMouseMove);
|
||||
window.addEventListener("mouseup", onMouseUp);
|
||||
};
|
||||
|
||||
const onMouseMove = useCallback(
|
||||
(e: MouseEvent) => {
|
||||
if (!resizeProps.current) {
|
||||
return;
|
||||
}
|
||||
const column = columns[resizeProps.current.index];
|
||||
const currentWidth = column.width ?? column.defaults.width;
|
||||
const minWidth = column.defaults.minWidth ?? 100;
|
||||
const newWidth = Math.max(minWidth, currentWidth + (e.clientX - resizeProps.current.startX));
|
||||
setColumns((prev) =>
|
||||
prev.map((c, index) => (index === resizeProps.current?.index ? { ...c, width: newWidth } : c)),
|
||||
);
|
||||
},
|
||||
[columns, setColumns],
|
||||
);
|
||||
|
||||
const onMouseUp = useCallback(() => {
|
||||
document.body.style.removeProperty("cursor");
|
||||
window.removeEventListener("mousemove", onMouseMove);
|
||||
window.removeEventListener("mouseup", onMouseUp);
|
||||
commitColumnSetting();
|
||||
}, [onMouseMove, commitColumnSetting]);
|
||||
|
||||
const fmIndex = useContext(FmIndexContext);
|
||||
const orderMethodOptions = useAppSelector((state) => state.fileManager[fmIndex].list?.props.order_by_options);
|
||||
const orderDirectionOption = useAppSelector(
|
||||
(state) => state.fileManager[fmIndex].list?.props.order_direction_options,
|
||||
);
|
||||
const sortBy = useAppSelector((state) => state.fileManager[fmIndex].sortBy);
|
||||
const sortDirection = useAppSelector((state) => state.fileManager[fmIndex].sortDirection);
|
||||
|
||||
const allAvailableSortOptions = useMemo((): {
|
||||
[key: string]: boolean;
|
||||
} => {
|
||||
if (!orderMethodOptions || !orderDirectionOption) return {};
|
||||
const res: { [key: string]: boolean } = {};
|
||||
orderMethodOptions.forEach((method) => {
|
||||
// make sure orderDirectionOption contains both asc and desc
|
||||
if (orderDirectionOption.includes("asc") && orderDirectionOption.includes("desc")) {
|
||||
res[method] = true;
|
||||
}
|
||||
});
|
||||
return res;
|
||||
}, [orderMethodOptions, sortDirection]);
|
||||
|
||||
const setSortBy = useCallback(
|
||||
(order_by: string, order_direction: string) => {
|
||||
dispatch(changeSortOption(fmIndex, order_by, order_direction));
|
||||
},
|
||||
[dispatch, fmIndex],
|
||||
);
|
||||
|
||||
return (
|
||||
<Box
|
||||
onMouseEnter={() => setShowDivider(true)}
|
||||
onMouseLeave={() => setShowDivider(false)}
|
||||
sx={{
|
||||
display: "flex",
|
||||
borderBottom: (theme) => `1px solid ${theme.palette.divider}`,
|
||||
}}
|
||||
>
|
||||
{columns.map((column, index) => (
|
||||
<Column
|
||||
startResizing={startResizing}
|
||||
index={index}
|
||||
showDivider={showDivider}
|
||||
key={index}
|
||||
column={column}
|
||||
setSortBy={setSortBy}
|
||||
sortable={!!column.defaults.order_by && allAvailableSortOptions[column.defaults.order_by]}
|
||||
sortDirection={sortBy && sortBy === column.defaults.order_by ? sortDirection : undefined}
|
||||
/>
|
||||
))}
|
||||
<Fade in={showDivider}>
|
||||
<Box
|
||||
sx={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
mr: 1,
|
||||
}}
|
||||
>
|
||||
<Tooltip title={t("fileManager.addColumn")}>
|
||||
<IconButton onClick={() => dispatch(setListViewColumnSettingDialog(true))} sx={{ ml: 1 }} size={"small"}>
|
||||
<Add
|
||||
sx={{
|
||||
width: "18px",
|
||||
height: "18px",
|
||||
}}
|
||||
/>
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
</Box>
|
||||
</Fade>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
export default ListHeader;
|
||||
98
src/component/FileManager/Explorer/ListView/ListView.tsx
Executable file
98
src/component/FileManager/Explorer/ListView/ListView.tsx
Executable file
@@ -0,0 +1,98 @@
|
||||
import { Box, useMediaQuery, useTheme } from "@mui/material";
|
||||
import React, { useCallback, useContext, useEffect, useMemo, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useAppDispatch, useAppSelector } from "../../../../redux/hooks.ts";
|
||||
import { applyListColumns } from "../../../../redux/thunks/filemanager.ts";
|
||||
import { FmIndexContext } from "../../FmIndexContext.tsx";
|
||||
import { SearchLimitReached } from "../EmptyFileList.tsx";
|
||||
import { getColumnTypeDefaults, ListViewColumn, ListViewColumnSetting } from "./Column.tsx";
|
||||
import ListBody from "./ListBody.tsx";
|
||||
import ListHeader from "./ListHeader.tsx";
|
||||
|
||||
const ListView = React.forwardRef(
|
||||
(
|
||||
{
|
||||
...rest
|
||||
}: {
|
||||
[key: string]: any;
|
||||
},
|
||||
ref,
|
||||
) => {
|
||||
const { t } = useTranslation("application");
|
||||
const theme = useTheme();
|
||||
const isMobile = useMediaQuery(theme.breakpoints.down("sm"));
|
||||
const dispatch = useAppDispatch();
|
||||
const fmIndex = useContext(FmIndexContext);
|
||||
const recursion_limit_reached = useAppSelector((state) => state.fileManager[fmIndex].list?.recursion_limit_reached);
|
||||
const columnSetting = useAppSelector((state) => state.fileManager[fmIndex].listViewColumns);
|
||||
const customProps = useAppSelector((state) => state.siteConfig.explorer?.config?.custom_props);
|
||||
|
||||
const [columns, setColumns] = useState<ListViewColumn[]>(
|
||||
columnSetting.map(
|
||||
(c): ListViewColumn => ({
|
||||
type: c.type,
|
||||
width: c.width,
|
||||
props: c.props,
|
||||
defaults: getColumnTypeDefaults(c, isMobile, customProps),
|
||||
}),
|
||||
),
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
setColumns(
|
||||
columnSetting.map(
|
||||
(c): ListViewColumn => ({
|
||||
type: c.type,
|
||||
width: c.width,
|
||||
props: c.props,
|
||||
defaults: getColumnTypeDefaults(c, isMobile, customProps),
|
||||
}),
|
||||
),
|
||||
);
|
||||
}, [columnSetting, customProps]);
|
||||
|
||||
const totalWidth = useMemo(() => {
|
||||
return columns.reduce((acc, column) => acc + (column.width ?? column.defaults.width), 0);
|
||||
}, [columns]);
|
||||
|
||||
const commitColumnSetting = useCallback(() => {
|
||||
let settings: ListViewColumnSetting[] = [];
|
||||
setColumns((prev) => {
|
||||
settings = [
|
||||
...prev.map((c) => ({
|
||||
type: c.type,
|
||||
width: c.width,
|
||||
props: c.props,
|
||||
})),
|
||||
];
|
||||
return prev;
|
||||
});
|
||||
if (settings.length > 0) {
|
||||
dispatch(applyListColumns(fmIndex, settings));
|
||||
}
|
||||
}, [dispatch, setColumns]);
|
||||
|
||||
return (
|
||||
<Box
|
||||
ref={ref}
|
||||
{...rest}
|
||||
sx={{
|
||||
minWidth: totalWidth + 44,
|
||||
height: "100%",
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
}}
|
||||
>
|
||||
<ListHeader commitColumnSetting={commitColumnSetting} setColumns={setColumns} columns={columns} />
|
||||
<ListBody columns={columns} />
|
||||
{recursion_limit_reached && (
|
||||
<Box sx={{ px: 1, py: 1 }}>
|
||||
<SearchLimitReached />
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
export default ListView;
|
||||
129
src/component/FileManager/Explorer/ListView/Row.tsx
Executable file
129
src/component/FileManager/Explorer/ListView/Row.tsx
Executable file
@@ -0,0 +1,129 @@
|
||||
import { alpha, Box, Skeleton, styled } from "@mui/material";
|
||||
import { memo, useEffect } from "react";
|
||||
import { useAppDispatch } from "../../../../redux/hooks.ts";
|
||||
import { navigateReconcile } from "../../../../redux/thunks/filemanager.ts";
|
||||
import { NoWrapTypography } from "../../../Common/StyledComponents.tsx";
|
||||
import { FileBlockProps } from "../Explorer.tsx";
|
||||
import { useFileBlockState } from "../GridView/GridFile.tsx";
|
||||
import Cell from "./Cell.tsx";
|
||||
|
||||
const RowContainer = styled(Box)<{
|
||||
selected: boolean;
|
||||
transparent?: boolean;
|
||||
isDropOver?: boolean;
|
||||
disabled?: boolean;
|
||||
}>(({ theme, disabled, transparent, isDropOver, selected }) => {
|
||||
let bgColor = "initial";
|
||||
let bgColorHover = theme.palette.action.hover;
|
||||
|
||||
if (selected) {
|
||||
bgColor = alpha(theme.palette.primary.main, 0.18);
|
||||
bgColorHover = bgColor;
|
||||
}
|
||||
return {
|
||||
minHeight: "36px",
|
||||
borderBottom: `1px solid ${theme.palette.divider}`,
|
||||
display: "flex",
|
||||
backgroundColor: bgColor,
|
||||
"&:hover": {
|
||||
backgroundColor: bgColorHover,
|
||||
},
|
||||
pointerEvents: disabled ? "none" : "auto",
|
||||
opacity: transparent || disabled ? 0.5 : 1,
|
||||
transition: "all 250ms cubic-bezier(0.4, 0, 0.2, 1) 0ms",
|
||||
transitionProperty: "background-color,opacity,box-shadow",
|
||||
boxShadow: isDropOver ? `inset 0 0 0 2px ${theme.palette.primary.light}` : "none",
|
||||
};
|
||||
});
|
||||
|
||||
const Column = styled(Box)<{ w: number }>(({ theme, w }) => ({
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
width: `${w}px`,
|
||||
padding: "0 10px",
|
||||
}));
|
||||
|
||||
const Row = memo((props: FileBlockProps) => {
|
||||
const { file, columns, search, isDragging, isDropOver } = props;
|
||||
const dispatch = useAppDispatch();
|
||||
|
||||
const {
|
||||
fmIndex,
|
||||
isSelected,
|
||||
isLoadingIndicator,
|
||||
noThumb,
|
||||
uploading,
|
||||
ref,
|
||||
inView,
|
||||
showLock,
|
||||
fileTag,
|
||||
onClick,
|
||||
onDoubleClicked,
|
||||
hoverStateOff,
|
||||
hoverStateOn,
|
||||
onContextMenu,
|
||||
setRefFunc,
|
||||
disabled,
|
||||
fileDisabled,
|
||||
thumbWidth,
|
||||
thumbHeight,
|
||||
} = useFileBlockState(props);
|
||||
|
||||
useEffect(() => {
|
||||
if (!inView) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (isLoadingIndicator) {
|
||||
if (file.first) {
|
||||
dispatch(navigateReconcile(fmIndex, { next_page: true }));
|
||||
}
|
||||
return;
|
||||
}
|
||||
}, [inView]);
|
||||
|
||||
return (
|
||||
<RowContainer
|
||||
transparent={isDragging || fileDisabled}
|
||||
isDropOver={isDropOver && !isDragging}
|
||||
ref={setRefFunc}
|
||||
selected={!!isSelected}
|
||||
onClick={onClick}
|
||||
onDoubleClick={onDoubleClicked}
|
||||
onMouseEnter={hoverStateOn}
|
||||
onMouseLeave={hoverStateOff}
|
||||
onContextMenu={onContextMenu}
|
||||
disabled={disabled}
|
||||
>
|
||||
{columns?.map((column, index) => (
|
||||
<Column w={column.width ?? column.defaults.width} key={index}>
|
||||
<NoWrapTypography
|
||||
sx={{
|
||||
width: "100%",
|
||||
}}
|
||||
variant={"body2"}
|
||||
>
|
||||
{!file.placeholder && (
|
||||
<Cell
|
||||
isSelected={!!isSelected}
|
||||
search={search}
|
||||
column={column}
|
||||
file={file}
|
||||
uploading={uploading}
|
||||
fileTag={fileTag}
|
||||
showLock={showLock}
|
||||
noThumb={noThumb}
|
||||
thumbWidth={thumbWidth}
|
||||
thumbHeight={thumbHeight}
|
||||
/>
|
||||
)}
|
||||
|
||||
{file.placeholder && <Skeleton variant={"text"} width={0.5 * (column.width ?? column.defaults.width)} />}
|
||||
</NoWrapTypography>
|
||||
</Column>
|
||||
))}
|
||||
</RowContainer>
|
||||
);
|
||||
});
|
||||
|
||||
export default Row;
|
||||
281
src/component/FileManager/Explorer/SingleFileView.tsx
Executable file
281
src/component/FileManager/Explorer/SingleFileView.tsx
Executable file
@@ -0,0 +1,281 @@
|
||||
import {
|
||||
Alert,
|
||||
Box,
|
||||
Button,
|
||||
ButtonGroup,
|
||||
Container,
|
||||
Divider,
|
||||
Link,
|
||||
Stack,
|
||||
styled,
|
||||
Typography,
|
||||
useMediaQuery,
|
||||
useTheme,
|
||||
} from "@mui/material";
|
||||
import { bindPopover, usePopupState } from "material-ui-popup-state/hooks";
|
||||
import React, { forwardRef, useCallback, useContext, useEffect, useMemo, useState } from "react";
|
||||
import { Trans, useTranslation } from "react-i18next";
|
||||
import { Link as RouterLink } from "react-router-dom";
|
||||
import { FileResponse, Share } from "../../../api/explorer.ts";
|
||||
import { bindDelayedHover } from "../../../hooks/delayedHover.tsx";
|
||||
import { useAppDispatch, useAppSelector } from "../../../redux/hooks.ts";
|
||||
import { downloadSingleFile } from "../../../redux/thunks/download.ts";
|
||||
import { createShareShortcut, openFileContextMenu } from "../../../redux/thunks/file.ts";
|
||||
import { queueLoadShareInfo } from "../../../redux/thunks/share.ts";
|
||||
import { openViewers } from "../../../redux/thunks/viewer.ts";
|
||||
import SessionManager from "../../../session/index.ts";
|
||||
import { sizeToString } from "../../../util/index.ts";
|
||||
import CrUri from "../../../util/uri.ts";
|
||||
import { SecondaryButton } from "../../Common/StyledComponents.tsx";
|
||||
import UserAvatar from "../../Common/User/UserAvatar.tsx";
|
||||
import CaretDown from "../../Icons/CaretDown.tsx";
|
||||
import Download from "../../Icons/Download.tsx";
|
||||
import Eye from "../../Icons/Eye.tsx";
|
||||
import FolderLink from "../../Icons/FolderLink.tsx";
|
||||
import Open from "../../Icons/Open.tsx";
|
||||
import Timer from "../../Icons/Timer.tsx";
|
||||
import useActionDisplayOpt from "../ContextMenu/useActionDisplayOpt.ts";
|
||||
import { FmIndexContext } from "../FmIndexContext.tsx";
|
||||
import { PropTypography, ShareExpires, ShareStatistics } from "../TopBar/ShareInfoPopover.tsx";
|
||||
import FileIcon from "./FileIcon.tsx";
|
||||
import FileTagSummary from "./FileTagSummary.tsx";
|
||||
import { useFileBlockState } from "./GridView/GridFile.tsx";
|
||||
import { ThumbPopover } from "./ListView/Cell.tsx";
|
||||
|
||||
const ShareContainer = styled(Box)(({ theme }) => ({
|
||||
border: `1px solid ${theme.palette.divider}`,
|
||||
borderRadius: theme.shape.borderRadius,
|
||||
padding: theme.spacing(2),
|
||||
width: "100%",
|
||||
backgroundColor: theme.palette.background.default,
|
||||
boxShadow: `0 0 10px 0 rgba(0, 0, 0, 0.1)`,
|
||||
}));
|
||||
|
||||
const FileList = ({ file }: { file: FileResponse }) => {
|
||||
const theme = useTheme();
|
||||
const isMobile = useMediaQuery(theme.breakpoints.down("sm"));
|
||||
const isTouch = useMediaQuery("(pointer: coarse)");
|
||||
|
||||
const { uploading, noThumb, fileTag, isSelected, thumbWidth, thumbHeight } = useFileBlockState({
|
||||
file,
|
||||
});
|
||||
|
||||
const user = useMemo(() => {
|
||||
return SessionManager.currentLoginOrNull();
|
||||
}, []);
|
||||
|
||||
const popupState = usePopupState({
|
||||
variant: "popover",
|
||||
popupId: "thumbPreview" + file.id,
|
||||
});
|
||||
|
||||
const hoverState = bindDelayedHover(popupState, 800);
|
||||
const stopPropagation = useCallback((e: React.MouseEvent<HTMLElement>) => e.stopPropagation(), []);
|
||||
|
||||
return (
|
||||
<>
|
||||
<Box
|
||||
{...(noThumb || isMobile || isTouch ? {} : hoverState)}
|
||||
sx={{ display: "flex", alignItems: "flex-start", my: 3 }}
|
||||
>
|
||||
<Box>
|
||||
<FileIcon
|
||||
variant={"shareSingle"}
|
||||
sx={{ py: 0 }}
|
||||
iconProps={{
|
||||
sx: {
|
||||
fontSize: "32px",
|
||||
height: "32px",
|
||||
width: "32px",
|
||||
},
|
||||
}}
|
||||
file={file}
|
||||
/>
|
||||
</Box>
|
||||
<Box sx={{ flexGrow: 1 }}>
|
||||
<Typography variant={"body2"} sx={{ display: "flex", flexWrap: "wrap", columnGap: 1 }}>
|
||||
{file?.name}{" "}
|
||||
{fileTag && fileTag.length > 0 && (
|
||||
<FileTagSummary onMouseOver={stopPropagation} sx={{ maxWidth: "50%" }} tags={fileTag} />
|
||||
)}
|
||||
</Typography>
|
||||
<Typography variant={"body2"} color={"text.secondary"}>
|
||||
{sizeToString(file?.size ?? 0)}
|
||||
</Typography>
|
||||
</Box>
|
||||
</Box>
|
||||
{!noThumb && (
|
||||
<ThumbPopover
|
||||
key={file.id}
|
||||
file={file}
|
||||
thumbWidth={thumbWidth}
|
||||
thumbHeight={thumbHeight}
|
||||
popupState={bindPopover(popupState)}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
const SingleFileView = forwardRef((_props, ref: React.Ref<any>) => {
|
||||
const { t } = useTranslation();
|
||||
const theme = useTheme();
|
||||
const isMobile = useMediaQuery(theme.breakpoints.down("sm"));
|
||||
const dispatch = useAppDispatch();
|
||||
const fmIndex = useContext(FmIndexContext);
|
||||
const file = useAppSelector((state) => state.fileManager[fmIndex].list?.files[0]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [shareInfo, setShareInfo] = useState<Share | null>(null);
|
||||
|
||||
const displayOpt = useActionDisplayOpt(file ? [file] : []);
|
||||
|
||||
useEffect(() => {
|
||||
if (file) {
|
||||
dispatch(queueLoadShareInfo(new CrUri(file.path)))
|
||||
.then((info) => {
|
||||
setShareInfo(info);
|
||||
})
|
||||
.catch((_e) => {
|
||||
setShareInfo(null);
|
||||
})
|
||||
.finally(() => {
|
||||
setLoading(false);
|
||||
});
|
||||
} else {
|
||||
setShareInfo(null);
|
||||
}
|
||||
}, [file]);
|
||||
|
||||
const openMore = useCallback(
|
||||
(e: React.MouseEvent<any>) => {
|
||||
if (file) {
|
||||
dispatch(openFileContextMenu(fmIndex, file, true, e));
|
||||
}
|
||||
},
|
||||
[dispatch, file],
|
||||
);
|
||||
const download = useCallback(async () => {
|
||||
if (!file) {
|
||||
return;
|
||||
}
|
||||
|
||||
setLoading(true);
|
||||
try {
|
||||
await dispatch(downloadSingleFile(file));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [file, dispatch]);
|
||||
|
||||
const user = useMemo(() => {
|
||||
return SessionManager.currentLoginOrNull();
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<Stack
|
||||
ref={ref}
|
||||
spacing={2}
|
||||
sx={{
|
||||
p: 2,
|
||||
height: "100%",
|
||||
display: "flex",
|
||||
justifyContent: "center",
|
||||
alignItems: "center",
|
||||
}}
|
||||
>
|
||||
{shareInfo && (
|
||||
<Container maxWidth="sm">
|
||||
<ShareContainer>
|
||||
<Box sx={{ display: "flex", flexDirection: "column", alignItems: "center", py: 2, px: 1 }}>
|
||||
<UserAvatar enablePopover overwriteTextSize sx={{ width: 56, height: 56 }} user={shareInfo.owner} />
|
||||
<Typography variant={isMobile ? "subtitle1" : "h6"} sx={{ mt: 2, fontWeight: 600 }}>
|
||||
<Trans
|
||||
i18nKey="application:share.sharedBy"
|
||||
components={[
|
||||
<Link
|
||||
underline="hover"
|
||||
color="inherit"
|
||||
component={RouterLink}
|
||||
to={`/profile/${shareInfo.owner.id}`}
|
||||
>
|
||||
{shareInfo.owner.nickname}
|
||||
</Link>,
|
||||
]}
|
||||
values={{ nick: shareInfo.owner.nickname, num: 1 }}
|
||||
/>
|
||||
</Typography>
|
||||
<Box
|
||||
sx={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
flexWrap: "wrap",
|
||||
mt: isMobile ? 1 : 0,
|
||||
gap: 1,
|
||||
columnGap: 2,
|
||||
}}
|
||||
>
|
||||
<PropTypography variant={"caption"} color={"text.secondary"}>
|
||||
<Eye sx={{ fontSize: "20px!important" }} />
|
||||
<ShareStatistics shareInfo={shareInfo} />
|
||||
</PropTypography>
|
||||
</Box>
|
||||
</Box>
|
||||
<Divider />
|
||||
{file && <FileList file={file} />}
|
||||
<Divider />
|
||||
<Divider />
|
||||
{(shareInfo.remain_downloads || shareInfo.expires) && (
|
||||
<Alert sx={{ mt: 2 }} severity="info" icon={<Timer />}>
|
||||
<ShareExpires expires={shareInfo.expires} remain_downloads={shareInfo.remain_downloads} />
|
||||
</Alert>
|
||||
)}
|
||||
<Box
|
||||
sx={{
|
||||
display: "flex",
|
||||
flexDirection: isMobile ? "column" : "row",
|
||||
justifyContent: isMobile ? "flex-start" : "space-between",
|
||||
alignItems: isMobile ? "flex-start" : "center",
|
||||
|
||||
mt: 2,
|
||||
gap: 1,
|
||||
}}
|
||||
>
|
||||
<Box></Box>
|
||||
<Box sx={{ display: "flex", flexWrap: "wrap", gap: 1 }}>
|
||||
{!!user && file && (
|
||||
<SecondaryButton
|
||||
variant="contained"
|
||||
onClick={() => dispatch(createShareShortcut(fmIndex))}
|
||||
disabled={loading}
|
||||
startIcon={<FolderLink />}
|
||||
>
|
||||
{t("application:fileManager.save")}
|
||||
</SecondaryButton>
|
||||
)}
|
||||
{displayOpt.showOpen && file && (
|
||||
<SecondaryButton
|
||||
variant="contained"
|
||||
onClick={() => dispatch(openViewers(0, file))}
|
||||
disabled={loading}
|
||||
startIcon={<Open />}
|
||||
>
|
||||
{t("application:fileManager.open")}
|
||||
</SecondaryButton>
|
||||
)}
|
||||
<ButtonGroup disableElevation variant="contained">
|
||||
<Button onClick={download} disabled={loading} startIcon={<Download />}>
|
||||
{t("application:fileManager.download")}
|
||||
</Button>
|
||||
<Button size="small" onClick={openMore}>
|
||||
<CaretDown sx={{ fontSize: "12px!important" }} />
|
||||
</Button>
|
||||
</ButtonGroup>
|
||||
</Box>
|
||||
</Box>
|
||||
</ShareContainer>
|
||||
</Container>
|
||||
)}
|
||||
</Stack>
|
||||
);
|
||||
});
|
||||
|
||||
export default SingleFileView;
|
||||
41
src/component/FileManager/Explorer/UploadingTag.tsx
Executable file
41
src/component/FileManager/Explorer/UploadingTag.tsx
Executable file
@@ -0,0 +1,41 @@
|
||||
import { Stack } from "@mui/material";
|
||||
import { memo, useCallback, useContext } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Metadata } from "../../../api/explorer.ts";
|
||||
import { useAppDispatch } from "../../../redux/hooks.ts";
|
||||
import { searchMetadata } from "../../../redux/thunks/filemanager.ts";
|
||||
import { FmIndexContext } from "../FmIndexContext.tsx";
|
||||
import { TagChip } from "./FileTag.tsx";
|
||||
|
||||
export interface UploadingTagProps {
|
||||
disabled?: boolean;
|
||||
[key: string]: any;
|
||||
}
|
||||
|
||||
const FileTagSummary = memo(({ sx, disabled, ...restProps }: UploadingTagProps) => {
|
||||
const fmIndex = useContext(FmIndexContext);
|
||||
const { t } = useTranslation();
|
||||
const dispatch = useAppDispatch();
|
||||
const stopPropagation = useCallback((e: any) => {
|
||||
e.stopPropagation();
|
||||
}, []);
|
||||
const onClick = useCallback(
|
||||
(e: any) => {
|
||||
e.stopPropagation();
|
||||
dispatch(searchMetadata(fmIndex, Metadata.upload_session_id));
|
||||
},
|
||||
[dispatch, fmIndex],
|
||||
);
|
||||
return (
|
||||
<Stack direction={"row"} spacing={1} sx={{ ...sx }} {...restProps}>
|
||||
<TagChip
|
||||
onClick={disabled ? undefined : onClick}
|
||||
onMouseDown={stopPropagation}
|
||||
size="small"
|
||||
label={t("fileManager.uploading")}
|
||||
/>
|
||||
</Stack>
|
||||
);
|
||||
});
|
||||
|
||||
export default FileTagSummary;
|
||||
154
src/component/FileManager/FileBadge.tsx
Executable file
154
src/component/FileManager/FileBadge.tsx
Executable file
@@ -0,0 +1,154 @@
|
||||
import { FileResponse, FileType } from "../../api/explorer.ts";
|
||||
import FileIcon from "./Explorer/FileIcon.tsx";
|
||||
import React, { useMemo } from "react";
|
||||
import { Box, ButtonProps, Skeleton, Tooltip } from "@mui/material";
|
||||
import { BadgeText, DefaultButton } from "../Common/StyledComponents.tsx";
|
||||
import CrUri from "../../util/uri.ts";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { usePopupState } from "material-ui-popup-state/hooks";
|
||||
import { bindHover, bindPopover } from "material-ui-popup-state";
|
||||
import HoverPopover from "material-ui-popup-state/HoverPopover";
|
||||
import Breadcrumb from "./TopBar/Breadcrumb.tsx";
|
||||
import { useBreadcrumbButtons } from "./TopBar/BreadcrumbButton.tsx";
|
||||
|
||||
export interface FileBadgeFile {
|
||||
path: string;
|
||||
type: number;
|
||||
}
|
||||
|
||||
export interface FileBadgeProps extends ButtonProps {
|
||||
file?: FileResponse;
|
||||
simplifiedFile?: FileBadgeFile;
|
||||
unknown?: boolean;
|
||||
clickable?: boolean;
|
||||
}
|
||||
|
||||
const FileBadge = ({ file, clickable, simplifiedFile, unknown, ...rest }: FileBadgeProps) => {
|
||||
const { t } = useTranslation();
|
||||
const popupState = usePopupState({
|
||||
variant: "popover",
|
||||
popupId: "fileBadge",
|
||||
});
|
||||
const hoverProps = bindHover(popupState);
|
||||
const popoverProps = bindPopover(popupState);
|
||||
|
||||
const name = useMemo(() => {
|
||||
if (unknown) {
|
||||
return t("application:modals.unknownParent");
|
||||
}
|
||||
|
||||
if (file?.name) {
|
||||
return file?.name;
|
||||
}
|
||||
|
||||
try {
|
||||
const uri = new CrUri(simplifiedFile?.path ?? "");
|
||||
return uri.elements().pop() ?? "";
|
||||
} catch (e) {
|
||||
return "";
|
||||
}
|
||||
}, [file, unknown, simplifiedFile]);
|
||||
|
||||
const f = useMemo(() => {
|
||||
if (file) {
|
||||
return file;
|
||||
}
|
||||
|
||||
return {
|
||||
name,
|
||||
type: simplifiedFile?.type ?? FileType.folder,
|
||||
id: "",
|
||||
created_at: "",
|
||||
updated_at: "",
|
||||
size: 0,
|
||||
path: simplifiedFile?.path ?? "",
|
||||
} as FileResponse;
|
||||
}, [file, unknown, simplifiedFile]);
|
||||
|
||||
const [loading, displayName, startIcon, onClick] = useBreadcrumbButtons({
|
||||
name,
|
||||
is_latest: false,
|
||||
path: f.path,
|
||||
});
|
||||
|
||||
const StartIcon = useMemo(() => {
|
||||
if (loading) {
|
||||
return <Skeleton width={20} height={20} variant={"rounded"} />;
|
||||
}
|
||||
if (startIcon?.Icons?.[0]) {
|
||||
const Icon = startIcon?.Icons?.[0];
|
||||
return <Icon color={"action"} fontSize={"small"} />;
|
||||
}
|
||||
if (startIcon?.Element) {
|
||||
return startIcon.Element({ sx: { width: 20, height: 20 } });
|
||||
}
|
||||
}, [startIcon, loading]);
|
||||
|
||||
const tooltip = useMemo(() => {
|
||||
if (unknown) {
|
||||
return t("application:modals.unknownParentDes");
|
||||
}
|
||||
|
||||
return "";
|
||||
}, [file, unknown, simplifiedFile]);
|
||||
|
||||
const parent = useMemo(() => {
|
||||
const uri = simplifiedFile?.path ?? file?.path;
|
||||
if (!uri) {
|
||||
return "";
|
||||
}
|
||||
|
||||
const crUri = new CrUri(uri);
|
||||
return crUri.parent().toString();
|
||||
}, [file, unknown, simplifiedFile]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<Tooltip title={tooltip}>
|
||||
<span style={{ maxWidth: "100%" }}>
|
||||
<DefaultButton
|
||||
sx={{ maxWidth: "100%" }}
|
||||
onClick={clickable ? onClick : undefined}
|
||||
disabled={unknown}
|
||||
{...rest}
|
||||
{...(unknown ? {} : hoverProps)}
|
||||
>
|
||||
{StartIcon ? (
|
||||
StartIcon
|
||||
) : (
|
||||
<FileIcon
|
||||
variant={"small"}
|
||||
file={f}
|
||||
sx={{ px: 0, py: 0, height: "20px" }}
|
||||
fontSize={"small"}
|
||||
iconProps={{ fontSize: "small", sx: { minWidth: "20px" } }}
|
||||
/>
|
||||
)}
|
||||
|
||||
<BadgeText variant={"body2"}>{name == "" ? displayName : name}</BadgeText>
|
||||
</DefaultButton>
|
||||
</span>
|
||||
</Tooltip>
|
||||
{!unknown && (
|
||||
<HoverPopover
|
||||
anchorOrigin={{
|
||||
vertical: "top",
|
||||
horizontal: "center",
|
||||
}}
|
||||
transformOrigin={{
|
||||
vertical: "bottom",
|
||||
horizontal: "center",
|
||||
}}
|
||||
{...popoverProps}
|
||||
disableScrollLock={false}
|
||||
>
|
||||
<Box sx={{ maxWidth: "600px" }}>
|
||||
<Breadcrumb targetPath={parent} displayOnly />
|
||||
</Box>
|
||||
</HoverPopover>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default FileBadge;
|
||||
173
src/component/FileManager/FileInfo/ColorCircle/CircleColorSelector.tsx
Executable file
173
src/component/FileManager/FileInfo/ColorCircle/CircleColorSelector.tsx
Executable file
@@ -0,0 +1,173 @@
|
||||
import { Box, Button, Divider, Popover, styled, Tooltip, useTheme } from "@mui/material";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { CSSProperties, useCallback, useState } from "react";
|
||||
import { bindTrigger, usePopupState } from "material-ui-popup-state/hooks";
|
||||
import { bindPopover } from "material-ui-popup-state";
|
||||
import Sketch from "@uiw/react-color-sketch";
|
||||
|
||||
export interface CircleColorSelectorProps {
|
||||
colors: string[];
|
||||
selectedColor: string;
|
||||
onChange: (color: string) => void;
|
||||
showColorValueInCustomization?: boolean;
|
||||
}
|
||||
|
||||
export const customizeMagicColor = "-";
|
||||
|
||||
export const SelectorBox = styled(Box)({
|
||||
display: "flex",
|
||||
flexWrap: "wrap",
|
||||
gap: 4,
|
||||
});
|
||||
|
||||
interface ColorCircleProps {
|
||||
color: string;
|
||||
selected: boolean;
|
||||
isCustomization?: boolean;
|
||||
onClick: (e: React.MouseEvent) => void;
|
||||
size?: number;
|
||||
noMb?: boolean;
|
||||
}
|
||||
|
||||
const ColorCircleBox = styled("div")(({
|
||||
color,
|
||||
selected,
|
||||
size = 20,
|
||||
|
||||
noMb,
|
||||
}: {
|
||||
color: string;
|
||||
selected: boolean;
|
||||
size?: number;
|
||||
noMb?: boolean;
|
||||
}) => {
|
||||
return {
|
||||
cursor: "pointer",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
width: `${size}px`,
|
||||
height: `${size}px`,
|
||||
padding: "3px",
|
||||
borderRadius: "50%",
|
||||
marginRight: 0,
|
||||
marginTop: 0,
|
||||
marginBottom: noMb ? 0 : "4px",
|
||||
boxSizing: "border-box",
|
||||
transform: "scale(1)",
|
||||
boxShadow: `${color} 0px 0px ${selected ? 5 : 0}px`,
|
||||
transition: "transform 100ms ease 0s, box-shadow 100ms ease 0s",
|
||||
background: color,
|
||||
":hover": {
|
||||
transform: "scale(1.2)",
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
const ColorCircleBoxChild = styled("div")(({ selected }: { selected: boolean }) => {
|
||||
const theme = useTheme();
|
||||
return {
|
||||
"--circle-point-background-color": theme.palette.background.default,
|
||||
height: selected ? "100%" : 0,
|
||||
width: selected ? "100%" : 0,
|
||||
borderRadius: "50%",
|
||||
backgroundColor: "var(--circle-point-background-color)",
|
||||
boxSizing: "border-box",
|
||||
transition: "height 100ms ease 0s, width 100ms ease 0s",
|
||||
transform: "scale(0.5)",
|
||||
};
|
||||
});
|
||||
|
||||
export const ColorCircle = ({ color, selected, isCustomization, onClick, size, noMb }: ColorCircleProps) => {
|
||||
const { t } = useTranslation();
|
||||
const displayColor = isCustomization
|
||||
? "conic-gradient(red, yellow, lime, aqua, blue, magenta, red)"
|
||||
: color == ""
|
||||
? "linear-gradient(45deg, rgba(217,217,217,1) 46%, rgba(217,217,217,1) 47%, rgba(128,128,128,1) 47%)"
|
||||
: color;
|
||||
return (
|
||||
<Tooltip title={isCustomization ? t("application:fileManager.customizeColor") : ""}>
|
||||
<ColorCircleBox size={size} onClick={onClick} color={displayColor} selected={selected} noMb={noMb}>
|
||||
<ColorCircleBoxChild selected={selected} />
|
||||
</ColorCircleBox>
|
||||
</Tooltip>
|
||||
);
|
||||
};
|
||||
|
||||
const CircleColorSelector = (props: CircleColorSelectorProps) => {
|
||||
const theme = useTheme();
|
||||
const { t } = useTranslation();
|
||||
const [customizeColor, setCustomizeColor] = useState<string>(props.selectedColor);
|
||||
const popupState = usePopupState({
|
||||
variant: "popover",
|
||||
popupId: "color-picker",
|
||||
});
|
||||
|
||||
const onClick = useCallback(
|
||||
(color: string) => () => {
|
||||
if (color === customizeMagicColor) {
|
||||
return;
|
||||
}
|
||||
props.onChange(color);
|
||||
},
|
||||
[props.onChange],
|
||||
);
|
||||
|
||||
const { onClose, ...restPopover } = bindPopover(popupState);
|
||||
const onApply = () => {
|
||||
onClose();
|
||||
onClick(customizeColor)();
|
||||
};
|
||||
return (
|
||||
<SelectorBox>
|
||||
{props.colors.map((color) => (
|
||||
<ColorCircle
|
||||
noMb={props.showColorValueInCustomization}
|
||||
isCustomization={color === customizeMagicColor && !props.showColorValueInCustomization}
|
||||
color={!props.showColorValueInCustomization ? color : props.selectedColor}
|
||||
onClick={onClick(color)}
|
||||
selected={color === props.selectedColor}
|
||||
{...(color === customizeMagicColor && bindTrigger(popupState))}
|
||||
/>
|
||||
))}
|
||||
<Popover
|
||||
{...restPopover}
|
||||
anchorOrigin={{
|
||||
vertical: "bottom",
|
||||
horizontal: "center",
|
||||
}}
|
||||
transformOrigin={{
|
||||
vertical: "top",
|
||||
horizontal: "center",
|
||||
}}
|
||||
onClose={onClose}
|
||||
>
|
||||
<Sketch
|
||||
presetColors={false}
|
||||
style={
|
||||
{
|
||||
border: "none",
|
||||
boxShadow: "none",
|
||||
padding: 0,
|
||||
margin: 0,
|
||||
background: theme.palette.background.default + "!important",
|
||||
} as CSSProperties
|
||||
}
|
||||
disableAlpha={true}
|
||||
color={customizeColor}
|
||||
onChange={(color) => {
|
||||
setCustomizeColor(color.hex);
|
||||
}}
|
||||
/>
|
||||
<Divider />
|
||||
<Box sx={{ p: 1 }}>
|
||||
<Button size={"small"} onClick={onApply} fullWidth variant={"contained"}>
|
||||
{t("application:fileManager.apply")}
|
||||
</Button>
|
||||
</Box>
|
||||
</Popover>
|
||||
</SelectorBox>
|
||||
);
|
||||
};
|
||||
|
||||
export default CircleColorSelector;
|
||||
55
src/component/FileManager/FileInfo/FolderColorQuickAction.tsx
Executable file
55
src/component/FileManager/FileInfo/FolderColorQuickAction.tsx
Executable file
@@ -0,0 +1,55 @@
|
||||
import { Box, BoxProps, Stack, styled, Typography, useTheme } from "@mui/material";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useMemo, useState } from "react";
|
||||
import { FileResponse, Metadata } from "../../../api/explorer.ts";
|
||||
import CircleColorSelector, { customizeMagicColor } from "./ColorCircle/CircleColorSelector.tsx";
|
||||
import SessionManager, { UserSettings } from "../../../session";
|
||||
import { defaultColors } from "../../../constants";
|
||||
|
||||
const StyledBox = styled(Box)(({ theme }) => ({
|
||||
margin: `0 ${theme.spacing(0.5)}`,
|
||||
padding: `${theme.spacing(0.5)} ${theme.spacing(1)}`,
|
||||
}));
|
||||
|
||||
export interface FolderColorQuickActionProps extends BoxProps {
|
||||
file: FileResponse;
|
||||
onColorChange: (color?: string) => void;
|
||||
}
|
||||
|
||||
const FolderColorQuickAction = ({ file, onColorChange, ...rest }: FolderColorQuickActionProps) => {
|
||||
const { t } = useTranslation();
|
||||
const theme = useTheme();
|
||||
const [hex, setHex] = useState<string>(
|
||||
(file.metadata && file.metadata[Metadata.icon_color]) ?? theme.palette.action.active,
|
||||
);
|
||||
const presetColors = useMemo(() => {
|
||||
const colors = new Set(defaultColors);
|
||||
|
||||
const recentColors = SessionManager.get(UserSettings.UsedCustomizedIconColors) as string[] | undefined;
|
||||
|
||||
if (recentColors) {
|
||||
recentColors.forEach((color) => {
|
||||
colors.add(color);
|
||||
});
|
||||
}
|
||||
|
||||
return [...colors];
|
||||
}, []);
|
||||
return (
|
||||
<StyledBox {...rest}>
|
||||
<Stack spacing={1}>
|
||||
<Typography variant={"caption"}>{t("application:fileManager.folderColor")}</Typography>
|
||||
<CircleColorSelector
|
||||
colors={[theme.palette.action.active, ...presetColors, customizeMagicColor]}
|
||||
selectedColor={hex}
|
||||
onChange={(color) => {
|
||||
onColorChange(color == theme.palette.action.active ? undefined : color);
|
||||
setHex(color);
|
||||
}}
|
||||
/>
|
||||
</Stack>
|
||||
</StyledBox>
|
||||
);
|
||||
};
|
||||
|
||||
export default FolderColorQuickAction;
|
||||
108
src/component/FileManager/FileManager.tsx
Executable file
108
src/component/FileManager/FileManager.tsx
Executable file
@@ -0,0 +1,108 @@
|
||||
import { Box, Stack, useMediaQuery, useTheme } from "@mui/material";
|
||||
import { useEffect } from "react";
|
||||
import { useHotkeys } from "react-hotkeys-hook";
|
||||
import useNavigation from "../../hooks/useNavigation.tsx";
|
||||
import { clearSelected } from "../../redux/fileManagerSlice.ts";
|
||||
import { resetDialogs } from "../../redux/globalStateSlice.ts";
|
||||
import { useAppDispatch } from "../../redux/hooks.ts";
|
||||
import { resetFm, selectAll, shortCutDelete } from "../../redux/thunks/filemanager.ts";
|
||||
import ImageViewer from "../Viewers/ImageViewer/ImageViewer.tsx";
|
||||
import Explorer from "./Explorer/Explorer.tsx";
|
||||
import { FmIndexContext } from "./FmIndexContext.tsx";
|
||||
import PaginationFooter from "./Pagination/PaginationFooter.tsx";
|
||||
import { ReadMe } from "./ReadMe/ReadMe.tsx";
|
||||
import Sidebar from "./Sidebar/Sidebar.tsx";
|
||||
import SidebarDialog from "./Sidebar/SidebarDialog.tsx";
|
||||
import NavHeader from "./TopBar/NavHeader.tsx";
|
||||
|
||||
export const FileManagerIndex = {
|
||||
main: 0,
|
||||
selector: 1,
|
||||
};
|
||||
|
||||
export interface FileManagerProps {
|
||||
index?: number;
|
||||
initialPath?: string;
|
||||
skipRender?: boolean;
|
||||
}
|
||||
|
||||
export const FileManager = ({ index = 0, initialPath, skipRender }: FileManagerProps) => {
|
||||
const dispatch = useAppDispatch();
|
||||
const theme = useTheme();
|
||||
const isMobile = useMediaQuery(theme.breakpoints.down("sm"));
|
||||
const isTablet = useMediaQuery(theme.breakpoints.down("md"));
|
||||
|
||||
useNavigation(index, initialPath);
|
||||
|
||||
useEffect(() => {
|
||||
if (index == FileManagerIndex.main) {
|
||||
dispatch(resetDialogs());
|
||||
return () => {
|
||||
dispatch(resetFm(index));
|
||||
};
|
||||
}
|
||||
}, []);
|
||||
|
||||
const selectAllRef = useHotkeys<HTMLElement>(
|
||||
["Control+a", "Meta+a"],
|
||||
() => {
|
||||
dispatch(selectAll(index));
|
||||
},
|
||||
{ enabled: index == FileManagerIndex.main, preventDefault: true },
|
||||
);
|
||||
|
||||
const delRef = useHotkeys<HTMLElement>(
|
||||
["meta+backspace", "delete"],
|
||||
() => {
|
||||
dispatch(shortCutDelete(index));
|
||||
},
|
||||
{ enabled: index == FileManagerIndex.main, preventDefault: true },
|
||||
);
|
||||
|
||||
const escRef = useHotkeys<HTMLElement>(
|
||||
"esc",
|
||||
() => {
|
||||
dispatch(clearSelected({ index, value: {} }));
|
||||
},
|
||||
{ enabled: index == FileManagerIndex.main, preventDefault: true },
|
||||
);
|
||||
|
||||
if (skipRender) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<FmIndexContext.Provider value={index}>
|
||||
<Stack
|
||||
onClick={(e) => {
|
||||
e.currentTarget.focus();
|
||||
}}
|
||||
ref={(ref) => {
|
||||
selectAllRef(ref);
|
||||
delRef(ref);
|
||||
escRef(ref);
|
||||
}}
|
||||
direction={"column"}
|
||||
sx={{
|
||||
flexGrow: 1,
|
||||
mb: index == FileManagerIndex.main && !isMobile ? 1 : 0,
|
||||
overflow: "auto",
|
||||
"&:focus": {
|
||||
outline: "none",
|
||||
},
|
||||
}}
|
||||
tabIndex={0}
|
||||
spacing={1}
|
||||
>
|
||||
<NavHeader />
|
||||
<Box sx={{ display: "flex", flexGrow: 1, overflowY: "auto" }}>
|
||||
<Explorer />
|
||||
{index == FileManagerIndex.main && (isTablet ? <SidebarDialog /> : <Sidebar />)}
|
||||
{index == FileManagerIndex.main && <ReadMe />}
|
||||
</Box>
|
||||
<PaginationFooter />
|
||||
</Stack>
|
||||
{index == FileManagerIndex.main && <ImageViewer />}
|
||||
</FmIndexContext.Provider>
|
||||
);
|
||||
};
|
||||
3
src/component/FileManager/FmIndexContext.tsx
Executable file
3
src/component/FileManager/FmIndexContext.tsx
Executable file
@@ -0,0 +1,3 @@
|
||||
import { createContext } from "react";
|
||||
|
||||
export const FmIndexContext = createContext(0);
|
||||
70
src/component/FileManager/FolderPicker.tsx
Executable file
70
src/component/FileManager/FolderPicker.tsx
Executable file
@@ -0,0 +1,70 @@
|
||||
import { Box, styled, useMediaQuery, useTheme } from "@mui/material";
|
||||
import Grid from "@mui/material/Grid2";
|
||||
import { useAppSelector } from "../../redux/hooks.ts";
|
||||
import { getFileLinkedUri } from "../../util"; // Grid version 2
|
||||
import ContextMenu from "./ContextMenu/ContextMenu.tsx";
|
||||
import { FileManager, FileManagerIndex } from "./FileManager.tsx";
|
||||
import TreeNavigation from "./TreeView/TreeNavigation.tsx";
|
||||
|
||||
const StyledGridItem = styled(Grid)(() => ({
|
||||
display: "flex",
|
||||
height: "100%",
|
||||
}));
|
||||
|
||||
export const useFolderSelector = () => {
|
||||
const currentPath = useAppSelector((state) => state.fileManager[FileManagerIndex.selector].pure_path);
|
||||
const selected = useAppSelector((state) => state.fileManager[FileManagerIndex.selector].selected);
|
||||
|
||||
if (selected && Object.keys(selected).length > 0) {
|
||||
const selectedFile = selected[Object.keys(selected)[0]];
|
||||
return [selectedFile, getFileLinkedUri(selectedFile)] as const;
|
||||
}
|
||||
|
||||
return [undefined, currentPath] as const;
|
||||
};
|
||||
|
||||
export interface FolderPickerProps {
|
||||
disableSharedWithMe?: boolean;
|
||||
disableTrash?: boolean;
|
||||
initialPath?: string;
|
||||
}
|
||||
|
||||
const FolderPicker = ({ disableSharedWithMe, disableTrash, initialPath }: FolderPickerProps) => {
|
||||
const theme = useTheme();
|
||||
const isMobile = useMediaQuery(theme.breakpoints.down("sm"));
|
||||
const path = useAppSelector((state) => state.fileManager[FileManagerIndex.main].path);
|
||||
|
||||
return (
|
||||
<Box sx={{ width: "100%", display: "flex" }}>
|
||||
<Grid container columnSpacing={2} sx={{ width: "100%", margin: "0 -4px" }}>
|
||||
<StyledGridItem
|
||||
size={{
|
||||
xs: 12,
|
||||
md: 2,
|
||||
}}
|
||||
sx={{
|
||||
overflowY: "auto",
|
||||
overflowX: "hidden",
|
||||
}}
|
||||
>
|
||||
<TreeNavigation
|
||||
index={FileManagerIndex.selector}
|
||||
disableSharedWithMe={disableSharedWithMe}
|
||||
disableTrash={disableTrash}
|
||||
/>
|
||||
</StyledGridItem>
|
||||
<StyledGridItem
|
||||
size={{
|
||||
xs: 12,
|
||||
md: 10,
|
||||
}}
|
||||
sx={{ height: isMobile ? "initial" : "100%" }}
|
||||
>
|
||||
<FileManager index={FileManagerIndex.selector} initialPath={initialPath ?? path} skipRender={isMobile} />
|
||||
</StyledGridItem>
|
||||
<ContextMenu fmIndex={FileManagerIndex.selector} />
|
||||
</Grid>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
export default FolderPicker;
|
||||
34
src/component/FileManager/NewButton.tsx
Executable file
34
src/component/FileManager/NewButton.tsx
Executable file
@@ -0,0 +1,34 @@
|
||||
import Add from "../Icons/Add.tsx";
|
||||
import { Button, IconButton, useMediaQuery, useTheme } from "@mui/material";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useAppDispatch } from "../../redux/hooks.ts";
|
||||
import { openNewContextMenu } from "../../redux/thunks/filemanager.ts";
|
||||
import { FileManagerIndex } from "./FileManager.tsx";
|
||||
|
||||
const NewButton = () => {
|
||||
const { t } = useTranslation();
|
||||
const dispatch = useAppDispatch();
|
||||
const theme = useTheme();
|
||||
const isMobile = useMediaQuery(theme.breakpoints.down("sm"));
|
||||
|
||||
if (isMobile) {
|
||||
return (
|
||||
<IconButton onClick={(e) => dispatch(openNewContextMenu(FileManagerIndex.main, e))}>
|
||||
<Add />
|
||||
</IconButton>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Button
|
||||
variant={"contained"}
|
||||
onClick={(e) => dispatch(openNewContextMenu(FileManagerIndex.main, e))}
|
||||
startIcon={<Add />}
|
||||
color={"primary"}
|
||||
>
|
||||
{t("fileManager.new")}
|
||||
</Button>
|
||||
);
|
||||
};
|
||||
|
||||
export default NewButton;
|
||||
76
src/component/FileManager/Pagination/PaginationFooter.tsx
Executable file
76
src/component/FileManager/Pagination/PaginationFooter.tsx
Executable file
@@ -0,0 +1,76 @@
|
||||
import { Box, Pagination, Slide, styled, useMediaQuery, useTheme } from "@mui/material";
|
||||
import { forwardRef, useContext } from "react";
|
||||
import { useAppDispatch, useAppSelector } from "../../../redux/hooks.ts";
|
||||
import { changePage } from "../../../redux/thunks/filemanager.ts";
|
||||
import { RadiusFrame } from "../../Frame/RadiusFrame.tsx";
|
||||
import { MinPageSize } from "../TopBar/ViewOptionPopover.tsx";
|
||||
import PaginationItem from "./PaginationItem.tsx";
|
||||
|
||||
import { PaginationResults } from "../../../api/explorer.ts";
|
||||
import { FmIndexContext } from "../FmIndexContext.tsx";
|
||||
|
||||
const PaginationFrame = styled(RadiusFrame)(({ theme }) => ({
|
||||
padding: theme.spacing(0.5),
|
||||
}));
|
||||
|
||||
export interface PaginationState {
|
||||
currentPage: number;
|
||||
totalPages: number;
|
||||
usePagination: boolean;
|
||||
moreItems: boolean;
|
||||
useEndlessLoading: boolean;
|
||||
nextToken?: string;
|
||||
}
|
||||
|
||||
export const usePaginationState = (fmIndex: number) => {
|
||||
const pagination = useAppSelector((state) => state.fileManager[fmIndex].list?.pagination);
|
||||
return getPaginationState(pagination);
|
||||
};
|
||||
|
||||
export const getPaginationState = (pagination?: PaginationResults) => {
|
||||
const totalItems = pagination?.total_items;
|
||||
const page = pagination?.page;
|
||||
const pageSize = pagination?.page_size;
|
||||
|
||||
const currentPage = (page ?? 0) + 1;
|
||||
const totalPages = Math.ceil((totalItems ?? 1) / (pageSize && pageSize > 0 ? pageSize : MinPageSize));
|
||||
const usePagination = totalPages > 1;
|
||||
return {
|
||||
currentPage,
|
||||
totalPages,
|
||||
usePagination,
|
||||
useEndlessLoading: !usePagination,
|
||||
moreItems: pagination?.next_token || (usePagination && currentPage < totalPages),
|
||||
nextToken: pagination?.next_token,
|
||||
} as PaginationState;
|
||||
};
|
||||
|
||||
const PaginationFooter = forwardRef((_props, ref) => {
|
||||
const theme = useTheme();
|
||||
const isMobile = useMediaQuery(theme.breakpoints.down("sm"));
|
||||
const dispatch = useAppDispatch();
|
||||
const fmIndex = useContext(FmIndexContext);
|
||||
const paginationState = usePaginationState(fmIndex);
|
||||
const onPageChange = (_event: unknown, page: number) => {
|
||||
dispatch(changePage(fmIndex, page - 1));
|
||||
};
|
||||
|
||||
return (
|
||||
<Slide direction={"up"} unmountOnExit in={paginationState.usePagination}>
|
||||
<Box ref={ref} sx={{ display: "flex", px: isMobile ? 1 : 0, pb: isMobile ? 1 : 0 }}>
|
||||
<PaginationFrame withBorder>
|
||||
<Pagination
|
||||
renderItem={(item) => <PaginationItem {...item} />}
|
||||
shape="rounded"
|
||||
color="primary"
|
||||
count={paginationState.totalPages}
|
||||
page={paginationState.currentPage}
|
||||
onChange={onPageChange}
|
||||
/>
|
||||
</PaginationFrame>
|
||||
</Box>
|
||||
</Slide>
|
||||
);
|
||||
});
|
||||
|
||||
export default PaginationFooter;
|
||||
46
src/component/FileManager/Pagination/PaginationItem.tsx
Executable file
46
src/component/FileManager/Pagination/PaginationItem.tsx
Executable file
@@ -0,0 +1,46 @@
|
||||
import { PaginationItem, PaginationItemProps, styled } from "@mui/material";
|
||||
import { NoOpDropUri, useFileDrag } from "../Dnd/DndWrappedFile.tsx";
|
||||
import { useCallback, useEffect, useRef } from "react";
|
||||
import { mergeRefs } from "../../../util";
|
||||
|
||||
let timeOut: ReturnType<typeof setTimeout> | undefined = undefined;
|
||||
|
||||
const StyledPaginationItem = styled(PaginationItem)<{ isDropOver?: boolean }>(({ theme, isDropOver }) => ({
|
||||
transition: "all 250ms cubic-bezier(0.4, 0, 0.2, 1) 0ms !important",
|
||||
transitionProperty: "background-color,opacity,box-shadow",
|
||||
boxShadow: isDropOver ? `inset 0 0 0 2px ${theme.palette.primary.light}` : "none",
|
||||
}));
|
||||
|
||||
const CustomPaginationItem = (props: PaginationItemProps) => {
|
||||
const [drag, drop, isOver, isDragging] = useFileDrag({
|
||||
dropUri: props.type !== "start-ellipsis" && props.type !== "end-ellipsis" ? NoOpDropUri : undefined,
|
||||
});
|
||||
const buttonRef = useRef<HTMLElement>();
|
||||
|
||||
useEffect(() => {
|
||||
if (
|
||||
isOver &&
|
||||
props.onClick &&
|
||||
props.type !== "start-ellipsis" &&
|
||||
props.type !== "end-ellipsis" &&
|
||||
buttonRef.current &&
|
||||
!props.selected
|
||||
) {
|
||||
if (timeOut) {
|
||||
clearTimeout(timeOut);
|
||||
}
|
||||
timeOut = setTimeout(() => buttonRef.current?.click(), 500);
|
||||
}
|
||||
}, [isOver]);
|
||||
|
||||
const mergedRef = useCallback(
|
||||
(val: any) => {
|
||||
mergeRefs(drop, buttonRef)(val);
|
||||
},
|
||||
[drop, buttonRef],
|
||||
);
|
||||
|
||||
return <StyledPaginationItem isDropOver={isOver} ref={mergedRef} {...props} />;
|
||||
};
|
||||
|
||||
export default CustomPaginationItem;
|
||||
36
src/component/FileManager/ReadMe/ReadMe.tsx
Executable file
36
src/component/FileManager/ReadMe/ReadMe.tsx
Executable file
@@ -0,0 +1,36 @@
|
||||
import { useMediaQuery, useTheme } from "@mui/material";
|
||||
import { useContext, useEffect } from "react";
|
||||
import { closeShareReadme } from "../../../redux/globalStateSlice.ts";
|
||||
import { useAppDispatch, useAppSelector } from "../../../redux/hooks.ts";
|
||||
import { detectReadMe } from "../../../redux/thunks/share.ts";
|
||||
import { FmIndexContext } from "../FmIndexContext.tsx";
|
||||
import ReadMeDialog from "./ReadMeDialog.tsx";
|
||||
import ReadMeSideBar from "./ReadMeSideBar.tsx";
|
||||
|
||||
export const ReadMe = () => {
|
||||
const fmIndex = useContext(FmIndexContext);
|
||||
const dispatch = useAppDispatch();
|
||||
const detect = useAppSelector((state) => state.globalState.shareReadmeDetect);
|
||||
const theme = useTheme();
|
||||
const isTablet = useMediaQuery(theme.breakpoints.down("md"));
|
||||
|
||||
useEffect(() => {
|
||||
if (detect) {
|
||||
dispatch(detectReadMe(fmIndex, isTablet));
|
||||
}
|
||||
}, [detect, dispatch]);
|
||||
|
||||
useEffect(() => {
|
||||
if (detect === 0) {
|
||||
setTimeout(() => {
|
||||
dispatch(closeShareReadme());
|
||||
}, 500);
|
||||
}
|
||||
}, [detect]);
|
||||
|
||||
if (isTablet) {
|
||||
return <ReadMeDialog />;
|
||||
}
|
||||
|
||||
return <ReadMeSideBar />;
|
||||
};
|
||||
82
src/component/FileManager/ReadMe/ReadMeContent.tsx
Executable file
82
src/component/FileManager/ReadMe/ReadMeContent.tsx
Executable file
@@ -0,0 +1,82 @@
|
||||
import { Box, Skeleton, useTheme } from "@mui/material";
|
||||
import { lazy, Suspense, useCallback, useEffect, useState } from "react";
|
||||
import { useAppDispatch, useAppSelector } from "../../../redux/hooks.ts";
|
||||
import { getEntityContent } from "../../../redux/thunks/file.ts";
|
||||
import { markdownImagePreviewHandler } from "../../../redux/thunks/viewer.ts";
|
||||
import Header from "../Sidebar/Header.tsx";
|
||||
|
||||
const MarkdownEditor = lazy(() => import("../../Viewers/MarkdownEditor/Editor.tsx"));
|
||||
|
||||
const Loading = () => {
|
||||
return (
|
||||
<Box sx={{ p: 2 }}>
|
||||
<Skeleton variant="text" width="100%" height={24} />
|
||||
<Skeleton variant="text" width="40%" height={24} />
|
||||
<Skeleton variant="text" width="75%" height={24} />
|
||||
<Skeleton variant="text" width="85%" height={24} />
|
||||
<Skeleton variant="text" width="20%" height={24} />
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
const ReadMeContent = () => {
|
||||
const theme = useTheme();
|
||||
const dispatch = useAppDispatch();
|
||||
const readMeTarget = useAppSelector((state) => state.globalState.shareReadmeTarget);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [value, setValue] = useState("");
|
||||
|
||||
useEffect(() => {
|
||||
if (readMeTarget) {
|
||||
setLoading(true);
|
||||
dispatch(getEntityContent(readMeTarget))
|
||||
.then((res) => {
|
||||
const content = new TextDecoder().decode(res);
|
||||
setValue(content);
|
||||
setLoading(false);
|
||||
})
|
||||
.catch(() => {
|
||||
setLoading(false);
|
||||
});
|
||||
}
|
||||
}, [readMeTarget]);
|
||||
|
||||
const imagePreviewHandler = useCallback(
|
||||
async (imageSource: string) => {
|
||||
return dispatch(markdownImagePreviewHandler(imageSource, readMeTarget?.path ?? ""));
|
||||
},
|
||||
[dispatch, readMeTarget],
|
||||
);
|
||||
|
||||
return (
|
||||
<Box sx={{ display: "flex", flexDirection: "column", height: "100%" }}>
|
||||
<Header target={readMeTarget} variant={"readme"} />
|
||||
<Box
|
||||
sx={{
|
||||
width: "100%",
|
||||
bgcolor: "background.paper",
|
||||
borderTop: 1,
|
||||
borderColor: "divider",
|
||||
overflow: "auto",
|
||||
}}
|
||||
>
|
||||
{loading && <Loading />}
|
||||
{!loading && (
|
||||
<Suspense fallback={<Loading />}>
|
||||
<MarkdownEditor
|
||||
displayOnly
|
||||
value={value}
|
||||
darkMode={theme.palette.mode === "dark"}
|
||||
readOnly={true}
|
||||
onChange={() => {}}
|
||||
initialValue={value}
|
||||
imagePreviewHandler={imagePreviewHandler}
|
||||
/>
|
||||
</Suspense>
|
||||
)}
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
export default ReadMeContent;
|
||||
35
src/component/FileManager/ReadMe/ReadMeDialog.tsx
Executable file
35
src/component/FileManager/ReadMe/ReadMeDialog.tsx
Executable file
@@ -0,0 +1,35 @@
|
||||
import { Dialog, Slide } from "@mui/material";
|
||||
import { TransitionProps } from "@mui/material/transitions";
|
||||
import { forwardRef } from "react";
|
||||
import { closeShareReadme } from "../../../redux/globalStateSlice.ts";
|
||||
import { useAppDispatch, useAppSelector } from "../../../redux/hooks.ts";
|
||||
import ReadMeContent from "./ReadMeContent.tsx";
|
||||
|
||||
const Transition = forwardRef(function Transition(
|
||||
props: TransitionProps & {
|
||||
children: React.ReactElement<unknown>;
|
||||
},
|
||||
ref: React.Ref<unknown>,
|
||||
) {
|
||||
return <Slide direction="up" ref={ref} {...props} />;
|
||||
});
|
||||
|
||||
const ReadMeDialog = () => {
|
||||
const dispatch = useAppDispatch();
|
||||
const readMeOpen = useAppSelector((state) => state.globalState.shareReadmeOpen);
|
||||
|
||||
return (
|
||||
<Dialog
|
||||
fullScreen
|
||||
TransitionComponent={Transition}
|
||||
open={!!readMeOpen}
|
||||
onClose={() => {
|
||||
dispatch(closeShareReadme());
|
||||
}}
|
||||
>
|
||||
<ReadMeContent />
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
|
||||
export default ReadMeDialog;
|
||||
28
src/component/FileManager/ReadMe/ReadMeSideBar.tsx
Executable file
28
src/component/FileManager/ReadMe/ReadMeSideBar.tsx
Executable file
@@ -0,0 +1,28 @@
|
||||
import { Box, Collapse } from "@mui/material";
|
||||
import { useAppSelector } from "../../../redux/hooks.ts";
|
||||
import { RadiusFrame } from "../../Frame/RadiusFrame.tsx";
|
||||
import ReadMeContent from "./ReadMeContent.tsx";
|
||||
|
||||
const ReadMeSideBar = () => {
|
||||
const readMeOpen = useAppSelector((state) => state.globalState.shareReadmeOpen);
|
||||
return (
|
||||
<Box>
|
||||
<Collapse in={readMeOpen} sx={{ height: "100%" }} orientation={"horizontal"} unmountOnExit timeout={"auto"}>
|
||||
<RadiusFrame
|
||||
sx={{
|
||||
width: "400px",
|
||||
height: "100%",
|
||||
ml: 1,
|
||||
overflow: "hidden",
|
||||
borderRadius: (theme) => theme.shape.borderRadius / 8,
|
||||
}}
|
||||
withBorder={true}
|
||||
>
|
||||
<ReadMeContent />
|
||||
</RadiusFrame>
|
||||
</Collapse>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
export default ReadMeSideBar;
|
||||
294
src/component/FileManager/Search/AdvanceSearch/AddCondition.tsx
Executable file
294
src/component/FileManager/Search/AdvanceSearch/AddCondition.tsx
Executable file
@@ -0,0 +1,294 @@
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
import { Icon } from "@iconify/react/dist/iconify.js";
|
||||
import { ListItemIcon, ListItemText, Menu } from "@mui/material";
|
||||
import dayjs from "dayjs";
|
||||
import { bindMenu, bindTrigger, usePopupState } from "material-ui-popup-state/hooks";
|
||||
import { FileType, Metadata } from "../../../../api/explorer.ts";
|
||||
import { useAppSelector } from "../../../../redux/hooks.ts";
|
||||
import { SecondaryButton } from "../../../Common/StyledComponents.tsx";
|
||||
import Add from "../../../Icons/Add.tsx";
|
||||
import CalendarClock from "../../../Icons/CalendarClock.tsx";
|
||||
import FolderOutlined from "../../../Icons/FolderOutlined.tsx";
|
||||
import HardDriveOutlined from "../../../Icons/HardDriveOutlined.tsx";
|
||||
import Info from "../../../Icons/Info.tsx";
|
||||
import Numbers from "../../../Icons/Numbers.tsx";
|
||||
import Tag from "../../../Icons/Tag.tsx";
|
||||
import TextBulletListSquareEdit from "../../../Icons/TextBulletListSquareEdit.tsx";
|
||||
import TextCaseTitle from "../../../Icons/TextCaseTitle.tsx";
|
||||
import { CascadingSubmenu } from "../../ContextMenu/CascadingMenu.tsx";
|
||||
import { DenseDivider, SquareMenuItem } from "../../ContextMenu/ContextMenu.tsx";
|
||||
import { customPropsMetadataPrefix } from "../../Sidebar/CustomProps/CustomProps.tsx";
|
||||
import { Condition, ConditionType } from "./ConditionBox.tsx";
|
||||
|
||||
export interface AddConditionProps {
|
||||
onConditionAdd: (condition: Condition) => void;
|
||||
}
|
||||
|
||||
interface ConditionOption {
|
||||
name: string;
|
||||
icon?: JSX.Element;
|
||||
condition: Condition;
|
||||
}
|
||||
|
||||
const options: ConditionOption[] = [
|
||||
{
|
||||
name: "application:modals.fileName",
|
||||
icon: <TextCaseTitle fontSize={"small"} />,
|
||||
condition: { type: ConditionType.name, case_folding: true },
|
||||
},
|
||||
{
|
||||
name: "application:navbar.fileType",
|
||||
icon: <FolderOutlined fontSize={"small"} />,
|
||||
condition: { type: ConditionType.type, file_type: FileType.file },
|
||||
},
|
||||
{
|
||||
name: "application:fileManager.tags",
|
||||
icon: <Tag fontSize={"small"} />,
|
||||
condition: { type: ConditionType.tag },
|
||||
},
|
||||
{
|
||||
name: "application:fileManager.metadata",
|
||||
icon: <Numbers fontSize={"small"} />,
|
||||
condition: { type: ConditionType.metadata },
|
||||
},
|
||||
{
|
||||
name: "application:navbar.fileSize",
|
||||
icon: <HardDriveOutlined fontSize={"small"} />,
|
||||
condition: { type: ConditionType.size, size_lte: 0, size_gte: 0 },
|
||||
},
|
||||
{
|
||||
name: "application:fileManager.createDate",
|
||||
icon: <CalendarClock fontSize={"small"} />,
|
||||
condition: {
|
||||
type: ConditionType.created,
|
||||
created_gte: dayjs().subtract(7, "d").unix(),
|
||||
created_lte: dayjs().unix(),
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "application:fileManager.updatedDate",
|
||||
icon: <CalendarClock fontSize={"small"} />,
|
||||
condition: {
|
||||
type: ConditionType.modified,
|
||||
updated_gte: dayjs().subtract(7, "d").unix(),
|
||||
updated_lte: dayjs().unix(),
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
const mediaMetaOptions: (ConditionOption | null)[] = [
|
||||
{
|
||||
name: "application:fileManager.title",
|
||||
condition: {
|
||||
type: ConditionType.metadata,
|
||||
metadata_key_readonly: true,
|
||||
metadata_key: Metadata.music_title,
|
||||
id: Metadata.music_title,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "application:fileManager.artist",
|
||||
condition: {
|
||||
type: ConditionType.metadata,
|
||||
metadata_key_readonly: true,
|
||||
metadata_key: Metadata.music_artist,
|
||||
id: Metadata.music_artist,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "application:fileManager.album",
|
||||
condition: {
|
||||
type: ConditionType.metadata,
|
||||
metadata_key_readonly: true,
|
||||
metadata_key: Metadata.music_album,
|
||||
id: Metadata.music_album,
|
||||
},
|
||||
},
|
||||
null, // divider
|
||||
{
|
||||
name: "application:fileManager.cameraMake",
|
||||
condition: {
|
||||
type: ConditionType.metadata,
|
||||
metadata_key_readonly: true,
|
||||
metadata_key: Metadata.camera_make,
|
||||
id: Metadata.camera_make,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "application:fileManager.cameraModel",
|
||||
condition: {
|
||||
type: ConditionType.metadata,
|
||||
metadata_key_readonly: true,
|
||||
metadata_key: Metadata.camera_model,
|
||||
id: Metadata.camera_model,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "application:fileManager.lensMake",
|
||||
condition: {
|
||||
type: ConditionType.metadata,
|
||||
metadata_key_readonly: true,
|
||||
metadata_key: Metadata.lens_make,
|
||||
id: Metadata.lens_make,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "application:fileManager.lensModel",
|
||||
condition: {
|
||||
type: ConditionType.metadata,
|
||||
metadata_key_readonly: true,
|
||||
metadata_key: Metadata.lens_model,
|
||||
id: Metadata.lens_model,
|
||||
},
|
||||
},
|
||||
null, // divider
|
||||
{
|
||||
name: "application:fileManager.street",
|
||||
condition: {
|
||||
type: ConditionType.metadata,
|
||||
metadata_key_readonly: true,
|
||||
metadata_key: Metadata.street,
|
||||
id: Metadata.street,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "application:fileManager.locality",
|
||||
condition: {
|
||||
type: ConditionType.metadata,
|
||||
metadata_key_readonly: true,
|
||||
metadata_key: Metadata.locality,
|
||||
id: Metadata.locality,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "application:fileManager.place",
|
||||
condition: {
|
||||
type: ConditionType.metadata,
|
||||
metadata_key_readonly: true,
|
||||
metadata_key: Metadata.place,
|
||||
id: Metadata.place,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "application:fileManager.district",
|
||||
condition: {
|
||||
type: ConditionType.metadata,
|
||||
metadata_key_readonly: true,
|
||||
metadata_key: Metadata.district,
|
||||
id: Metadata.district,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "application:fileManager.region",
|
||||
condition: {
|
||||
type: ConditionType.metadata,
|
||||
metadata_key_readonly: true,
|
||||
metadata_key: Metadata.region,
|
||||
id: Metadata.region,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "application:fileManager.country",
|
||||
condition: {
|
||||
type: ConditionType.metadata,
|
||||
metadata_key_readonly: true,
|
||||
metadata_key: Metadata.country,
|
||||
id: Metadata.country,
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
const AddCondition = (props: AddConditionProps) => {
|
||||
const { t } = useTranslation();
|
||||
const customPropsOptions = useAppSelector((state) => state.siteConfig.explorer?.config?.custom_props);
|
||||
const conditionPopupState = usePopupState({
|
||||
variant: "popover",
|
||||
popupId: "conditions",
|
||||
});
|
||||
const { onClose, ...menuProps } = bindMenu(conditionPopupState);
|
||||
const onConditionAdd = (condition: Condition) => {
|
||||
props.onConditionAdd({
|
||||
...condition,
|
||||
id: condition.type == ConditionType.metadata && !condition.id ? Math.random().toString() : condition.id,
|
||||
});
|
||||
onClose();
|
||||
};
|
||||
return (
|
||||
<>
|
||||
<SecondaryButton {...bindTrigger(conditionPopupState)} startIcon={<Add />} sx={{ px: "15px" }}>
|
||||
{t("navbar.addCondition")}
|
||||
</SecondaryButton>
|
||||
<Menu
|
||||
onClose={onClose}
|
||||
anchorOrigin={{
|
||||
vertical: "top",
|
||||
horizontal: "left",
|
||||
}}
|
||||
transformOrigin={{
|
||||
vertical: "bottom",
|
||||
horizontal: "left",
|
||||
}}
|
||||
{...menuProps}
|
||||
>
|
||||
{options.map((option, index) => (
|
||||
<SquareMenuItem dense key={index} onClick={() => onConditionAdd(option.condition)}>
|
||||
<ListItemIcon>{option.icon}</ListItemIcon>
|
||||
{t(option.name)}
|
||||
</SquareMenuItem>
|
||||
))}
|
||||
<CascadingSubmenu
|
||||
icon={<Info fontSize="small" />}
|
||||
popupId={"mediaInfo"}
|
||||
title={t("application:fileManager.mediaInfo")}
|
||||
>
|
||||
{mediaMetaOptions.map((option, index) =>
|
||||
option ? (
|
||||
<SquareMenuItem key={index} dense onClick={() => onConditionAdd(option.condition)}>
|
||||
<ListItemText
|
||||
slotProps={{
|
||||
primary: { variant: "body2" },
|
||||
}}
|
||||
>
|
||||
{t(option.name)}
|
||||
</ListItemText>
|
||||
</SquareMenuItem>
|
||||
) : (
|
||||
<DenseDivider />
|
||||
),
|
||||
)}
|
||||
</CascadingSubmenu>
|
||||
{customPropsOptions && customPropsOptions.length > 0 && (
|
||||
<CascadingSubmenu
|
||||
icon={<TextBulletListSquareEdit fontSize="small" />}
|
||||
popupId={"customProps"}
|
||||
title={t("application:fileManager.customProps")}
|
||||
>
|
||||
{customPropsOptions.map((option, index) => (
|
||||
<SquareMenuItem
|
||||
dense
|
||||
key={index}
|
||||
onClick={() =>
|
||||
onConditionAdd({
|
||||
type: ConditionType.metadata,
|
||||
id: customPropsMetadataPrefix + option.id,
|
||||
metadata_key: customPropsMetadataPrefix + option.id,
|
||||
})
|
||||
}
|
||||
>
|
||||
{option.icon && (
|
||||
<ListItemIcon>
|
||||
<Icon icon={option.icon} />
|
||||
</ListItemIcon>
|
||||
)}
|
||||
{t(option.name)}
|
||||
</SquareMenuItem>
|
||||
))}
|
||||
</CascadingSubmenu>
|
||||
)}
|
||||
</Menu>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default AddCondition;
|
||||
203
src/component/FileManager/Search/AdvanceSearch/AdvanceSearch.tsx
Executable file
203
src/component/FileManager/Search/AdvanceSearch/AdvanceSearch.tsx
Executable file
@@ -0,0 +1,203 @@
|
||||
import { Collapse, DialogContent } from "@mui/material";
|
||||
import { useSnackbar } from "notistack";
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { TransitionGroup } from "react-transition-group";
|
||||
import { Metadata } from "../../../../api/explorer.ts";
|
||||
import { defaultPath } from "../../../../hooks/useNavigation.tsx";
|
||||
import { closeAdvanceSearch } from "../../../../redux/globalStateSlice.ts";
|
||||
import { useAppDispatch, useAppSelector } from "../../../../redux/hooks.ts";
|
||||
import { advancedSearch } from "../../../../redux/thunks/filemanager.ts";
|
||||
import { SearchParam } from "../../../../util/uri.ts";
|
||||
import { DefaultCloseAction } from "../../../Common/Snackbar/snackbar.tsx";
|
||||
import DraggableDialog from "../../../Dialogs/DraggableDialog.tsx";
|
||||
import { FileManagerIndex } from "../../FileManager.tsx";
|
||||
import AddCondition from "./AddCondition.tsx";
|
||||
import ConditionBox, { Condition, ConditionType } from "./ConditionBox.tsx";
|
||||
|
||||
const searchParamToConditions = (search_params: SearchParam, base: string): Condition[] => {
|
||||
const applied: Condition[] = [
|
||||
{
|
||||
type: ConditionType.base,
|
||||
base_uri: base,
|
||||
},
|
||||
];
|
||||
if (search_params.name) {
|
||||
applied.push({
|
||||
type: ConditionType.name,
|
||||
names: search_params.name,
|
||||
name_op_or: search_params.name_op_or,
|
||||
case_folding: search_params.case_folding,
|
||||
});
|
||||
}
|
||||
|
||||
if (search_params.type != undefined) {
|
||||
applied.push({
|
||||
type: ConditionType.type,
|
||||
file_type: search_params.type,
|
||||
});
|
||||
}
|
||||
|
||||
if (search_params.size_gte != undefined || search_params.size_lte != undefined) {
|
||||
applied.push({
|
||||
type: ConditionType.size,
|
||||
size_gte: search_params.size_gte,
|
||||
size_lte: search_params.size_lte,
|
||||
});
|
||||
}
|
||||
|
||||
if (search_params.created_at_gte != undefined || search_params.created_at_lte != undefined) {
|
||||
applied.push({
|
||||
type: ConditionType.created,
|
||||
created_gte: search_params.created_at_gte,
|
||||
created_lte: search_params.created_at_lte,
|
||||
});
|
||||
}
|
||||
|
||||
if (search_params.updated_at_gte != undefined || search_params.updated_at_lte != undefined) {
|
||||
applied.push({
|
||||
type: ConditionType.modified,
|
||||
updated_gte: search_params.updated_at_gte,
|
||||
updated_lte: search_params.updated_at_lte,
|
||||
});
|
||||
}
|
||||
|
||||
const tags: string[] = [];
|
||||
if (search_params.metadata) {
|
||||
Object.entries(search_params.metadata).forEach(([key, value]) => {
|
||||
if (key.startsWith(Metadata.tag_prefix)) {
|
||||
tags.push(key.slice(Metadata.tag_prefix.length));
|
||||
} else {
|
||||
applied.push({
|
||||
type: ConditionType.metadata,
|
||||
metadata_key: key,
|
||||
metadata_value: value,
|
||||
id: key,
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
if (search_params.metadata_strong_match) {
|
||||
Object.entries(search_params.metadata_strong_match).forEach(([key, value]) => {
|
||||
applied.push({
|
||||
type: ConditionType.metadata,
|
||||
metadata_key: key,
|
||||
metadata_value: value,
|
||||
id: key,
|
||||
metadata_strong_match: true,
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
if (tags.length > 0) {
|
||||
applied.push({
|
||||
type: ConditionType.tag,
|
||||
tags: tags,
|
||||
});
|
||||
}
|
||||
|
||||
console.log(search_params);
|
||||
|
||||
return applied;
|
||||
};
|
||||
|
||||
const AdvanceSearch = () => {
|
||||
const { t } = useTranslation();
|
||||
const dispatch = useAppDispatch();
|
||||
const { enqueueSnackbar } = useSnackbar();
|
||||
|
||||
const [conditions, setConditions] = useState<Condition[]>([]);
|
||||
const open = useAppSelector((state) => state.globalState.advanceSearchOpen);
|
||||
const base = useAppSelector((state) => state.globalState.advanceSearchBasePath);
|
||||
const initialNames = useAppSelector((state) => state.globalState.advanceSearchInitialNameCondition);
|
||||
const search_params = useAppSelector((state) => state.fileManager[FileManagerIndex.main].search_params);
|
||||
const current_base = useAppSelector((state) => state.fileManager[FileManagerIndex.main].pure_path);
|
||||
|
||||
const onClose = useCallback(() => {
|
||||
dispatch(closeAdvanceSearch());
|
||||
}, [dispatch]);
|
||||
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
if (initialNames && base) {
|
||||
setConditions([
|
||||
{
|
||||
type: ConditionType.base,
|
||||
base_uri: base,
|
||||
},
|
||||
{
|
||||
type: ConditionType.name,
|
||||
names: initialNames,
|
||||
case_folding: true,
|
||||
},
|
||||
]);
|
||||
return;
|
||||
}
|
||||
|
||||
if (search_params) {
|
||||
const existedConditions = searchParamToConditions(search_params, current_base ?? defaultPath);
|
||||
if (existedConditions.length > 0) {
|
||||
setConditions(existedConditions);
|
||||
}
|
||||
}
|
||||
}
|
||||
}, [open]);
|
||||
|
||||
const onConditionRemove = (condition: Condition) => {
|
||||
setConditions(conditions.filter((c) => c !== condition));
|
||||
};
|
||||
|
||||
const onConditionAdd = (condition: Condition) => {
|
||||
if (conditions.find((c) => c.type === condition.type && c.id === condition.id)) {
|
||||
enqueueSnackbar(t("application:navbar.conditionDuplicate"), {
|
||||
variant: "warning",
|
||||
action: DefaultCloseAction,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
setConditions([...conditions, condition]);
|
||||
};
|
||||
|
||||
const submitSearch = useCallback(() => {
|
||||
dispatch(advancedSearch(FileManagerIndex.main, conditions));
|
||||
}, [dispatch, conditions]);
|
||||
|
||||
return (
|
||||
<DraggableDialog
|
||||
title={t("application:navbar.advancedSearch")}
|
||||
showActions
|
||||
onAccept={submitSearch}
|
||||
showCancel
|
||||
secondaryAction={<AddCondition onConditionAdd={onConditionAdd} />}
|
||||
dialogProps={{
|
||||
open: open ?? false,
|
||||
onClose: onClose,
|
||||
fullWidth: true,
|
||||
maxWidth: "xs",
|
||||
}}
|
||||
>
|
||||
<DialogContent>
|
||||
<TransitionGroup>
|
||||
{conditions.map((condition, index) => (
|
||||
<Collapse key={`${condition.type} ${condition.id}`}>
|
||||
<ConditionBox
|
||||
index={index}
|
||||
onRemove={conditions.length > 2 && condition.type != ConditionType.base ? onConditionRemove : undefined}
|
||||
condition={condition}
|
||||
onChange={(condition) => {
|
||||
const new_conditions = [...conditions];
|
||||
new_conditions[index] = condition;
|
||||
setConditions(new_conditions);
|
||||
}}
|
||||
/>
|
||||
</Collapse>
|
||||
))}
|
||||
</TransitionGroup>
|
||||
</DialogContent>
|
||||
</DraggableDialog>
|
||||
);
|
||||
};
|
||||
|
||||
export default AdvanceSearch;
|
||||
199
src/component/FileManager/Search/AdvanceSearch/ConditionBox.tsx
Executable file
199
src/component/FileManager/Search/AdvanceSearch/ConditionBox.tsx
Executable file
@@ -0,0 +1,199 @@
|
||||
import { Icon } from "@iconify/react/dist/iconify.js";
|
||||
import { Box, Grow, IconButton, Typography } from "@mui/material";
|
||||
import { forwardRef, useCallback, useMemo, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useAppSelector } from "../../../../redux/hooks.ts";
|
||||
import CalendarClock from "../../../Icons/CalendarClock.tsx";
|
||||
import Dismiss from "../../../Icons/Dismiss.tsx";
|
||||
import FolderOutlined from "../../../Icons/FolderOutlined.tsx";
|
||||
import HardDriveOutlined from "../../../Icons/HardDriveOutlined.tsx";
|
||||
import Numbers from "../../../Icons/Numbers.tsx";
|
||||
import Search from "../../../Icons/Search.tsx";
|
||||
import Tag from "../../../Icons/Tag.tsx";
|
||||
import TextCaseTitle from "../../../Icons/TextCaseTitle.tsx";
|
||||
import { customPropsMetadataPrefix } from "../../Sidebar/CustomProps/CustomProps.tsx";
|
||||
import { CustomPropsConditon } from "./CustomPropsConditon.tsx";
|
||||
import { DateTimeCondition } from "./DateTimeCondition.tsx";
|
||||
import { FileNameCondition, StyledBox } from "./FileNameCondition.tsx";
|
||||
import { FileTypeCondition } from "./FileTypeCondition.tsx";
|
||||
import { MetadataCondition } from "./MetadataCondition.tsx";
|
||||
import { SearchBaseCondition } from "./SearchBaseCondition.tsx";
|
||||
import { SizeCondition } from "./SizeCondition.tsx";
|
||||
import { TagCondition } from "./TagCondition.tsx";
|
||||
|
||||
export interface Condition {
|
||||
type: ConditionType;
|
||||
case_folding?: boolean;
|
||||
names?: string[];
|
||||
name_op_or?: boolean;
|
||||
file_type?: number;
|
||||
size_gte?: number;
|
||||
size_lte?: number;
|
||||
time?: number;
|
||||
metadata_key?: string;
|
||||
metadata_value?: string;
|
||||
metadata_strong_match?: boolean;
|
||||
base_uri?: string;
|
||||
tags?: string[];
|
||||
id?: string;
|
||||
metadata_key_readonly?: boolean;
|
||||
created_gte?: number;
|
||||
created_lte?: number;
|
||||
updated_gte?: number;
|
||||
updated_lte?: number;
|
||||
}
|
||||
|
||||
export enum ConditionType {
|
||||
name,
|
||||
size,
|
||||
created,
|
||||
modified,
|
||||
type,
|
||||
metadata,
|
||||
base,
|
||||
tag,
|
||||
}
|
||||
|
||||
export interface ConditionProps {
|
||||
condition: Condition;
|
||||
onChange: (condition: Condition) => void;
|
||||
onRemove?: (condition: Condition) => void;
|
||||
index: number;
|
||||
}
|
||||
|
||||
const ConditionBox = forwardRef((props: ConditionProps, ref) => {
|
||||
const { condition, index, onRemove, onChange } = props;
|
||||
const customPropsOptions = useAppSelector((state) => state.siteConfig.explorer?.config?.custom_props);
|
||||
const { t } = useTranslation();
|
||||
const [hovered, setHovered] = useState(false);
|
||||
|
||||
const onNameConditionAdded = useCallback(
|
||||
(_e: any, newValue: string[]) => {
|
||||
onChange({
|
||||
...condition,
|
||||
names: newValue,
|
||||
});
|
||||
},
|
||||
[onChange],
|
||||
);
|
||||
|
||||
const customPropsOption = useMemo(() => {
|
||||
if (
|
||||
condition.type !== ConditionType.metadata ||
|
||||
!condition.metadata_key ||
|
||||
!condition.metadata_key.startsWith(customPropsMetadataPrefix)
|
||||
) {
|
||||
return undefined;
|
||||
}
|
||||
return customPropsOptions?.find(
|
||||
(option) => option.id === condition?.metadata_key?.slice(customPropsMetadataPrefix.length),
|
||||
);
|
||||
}, [customPropsOptions, condition.type, condition.metadata_key]);
|
||||
|
||||
const title = useMemo(() => {
|
||||
switch (condition.type) {
|
||||
case ConditionType.base:
|
||||
return t("application:navbar.searchBase");
|
||||
case ConditionType.name:
|
||||
return t("application:modals.fileName");
|
||||
case ConditionType.type:
|
||||
return t("application:navbar.fileType");
|
||||
case ConditionType.tag:
|
||||
return t("application:fileManager.tags");
|
||||
case ConditionType.metadata:
|
||||
if (customPropsOption) {
|
||||
return t(customPropsOption.name);
|
||||
}
|
||||
return t("application:fileManager.metadata");
|
||||
case ConditionType.size:
|
||||
return t("application:navbar.fileSize");
|
||||
case ConditionType.modified:
|
||||
return t("application:fileManager.updatedDate");
|
||||
case ConditionType.created:
|
||||
return t("application:fileManager.createDate");
|
||||
default:
|
||||
return "Unknown";
|
||||
}
|
||||
}, [t, condition, customPropsOption]);
|
||||
|
||||
const ConditionIcon = useMemo(() => {
|
||||
switch (condition.type) {
|
||||
case ConditionType.base:
|
||||
return Search;
|
||||
case ConditionType.type:
|
||||
return FolderOutlined;
|
||||
case ConditionType.tag:
|
||||
return Tag;
|
||||
case ConditionType.metadata:
|
||||
if (customPropsOption?.icon) {
|
||||
return customPropsOption?.icon;
|
||||
}
|
||||
return Numbers;
|
||||
case ConditionType.size:
|
||||
return HardDriveOutlined;
|
||||
case ConditionType.modified:
|
||||
case ConditionType.created:
|
||||
return CalendarClock;
|
||||
|
||||
default:
|
||||
return TextCaseTitle;
|
||||
}
|
||||
}, [condition.type, customPropsOption]);
|
||||
|
||||
return (
|
||||
<StyledBox
|
||||
ref={ref}
|
||||
sx={{
|
||||
mt: index > 0 ? 1 : 0,
|
||||
}}
|
||||
onMouseEnter={() => setHovered(true)}
|
||||
onMouseLeave={() => setHovered(false)}
|
||||
>
|
||||
<Typography
|
||||
sx={{
|
||||
gap: 1,
|
||||
mb: 1,
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
}}
|
||||
variant={"body2"}
|
||||
fontWeight={600}
|
||||
>
|
||||
{typeof ConditionIcon !== "string" ? (
|
||||
<ConditionIcon sx={{ width: "20px", height: "20px" }} />
|
||||
) : (
|
||||
<Icon icon={ConditionIcon} width={20} height={20} />
|
||||
)}
|
||||
<Box sx={{ flexGrow: 1 }}>{title}</Box>
|
||||
<Grow in={hovered && !!onRemove}>
|
||||
<IconButton onClick={onRemove ? () => onRemove(condition) : undefined}>
|
||||
<Dismiss fontSize={"small"} />
|
||||
</IconButton>
|
||||
</Grow>
|
||||
</Typography>
|
||||
<Box>
|
||||
{condition.type == ConditionType.name && (
|
||||
<FileNameCondition condition={condition} onChange={onChange} onNameConditionAdded={onNameConditionAdded} />
|
||||
)}
|
||||
{condition.type == ConditionType.type && <FileTypeCondition condition={condition} onChange={onChange} />}
|
||||
{condition.type == ConditionType.base && <SearchBaseCondition condition={condition} onChange={onChange} />}
|
||||
{condition.type == ConditionType.tag && <TagCondition onChange={onChange} condition={condition} />}
|
||||
{condition.type == ConditionType.metadata && !customPropsOption && (
|
||||
<MetadataCondition onChange={onChange} condition={condition} />
|
||||
)}
|
||||
{condition.type == ConditionType.metadata && customPropsOption && (
|
||||
<CustomPropsConditon onChange={onChange} condition={condition} option={customPropsOption} />
|
||||
)}
|
||||
{condition.type == ConditionType.size && <SizeCondition condition={condition} onChange={onChange} />}
|
||||
{condition.type == ConditionType.created && (
|
||||
<DateTimeCondition condition={condition} onChange={onChange} field={"created"} />
|
||||
)}
|
||||
{condition.type == ConditionType.modified && (
|
||||
<DateTimeCondition condition={condition} onChange={onChange} field={"updated"} />
|
||||
)}
|
||||
</Box>
|
||||
</StyledBox>
|
||||
);
|
||||
});
|
||||
|
||||
export default ConditionBox;
|
||||
36
src/component/FileManager/Search/AdvanceSearch/CustomPropsConditon.tsx
Executable file
36
src/component/FileManager/Search/AdvanceSearch/CustomPropsConditon.tsx
Executable file
@@ -0,0 +1,36 @@
|
||||
import { Box } from "@mui/material";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { CustomProps } from "../../../../api/explorer.ts";
|
||||
import { getPropsContent } from "../../Sidebar/CustomProps/CustomPropsItem.tsx";
|
||||
import { Condition } from "./ConditionBox.tsx";
|
||||
|
||||
export const CustomPropsConditon = ({
|
||||
condition,
|
||||
onChange,
|
||||
option,
|
||||
}: {
|
||||
onChange: (condition: Condition) => void;
|
||||
condition: Condition;
|
||||
option: CustomProps;
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<Box
|
||||
sx={{
|
||||
width: "100%",
|
||||
}}
|
||||
>
|
||||
{getPropsContent(
|
||||
{
|
||||
props: option,
|
||||
id: option.id,
|
||||
value: condition.metadata_value ?? "",
|
||||
},
|
||||
(value) => onChange({ ...condition, metadata_value: value }),
|
||||
false,
|
||||
false,
|
||||
true,
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
54
src/component/FileManager/Search/AdvanceSearch/DateTimeCondition.tsx
Executable file
54
src/component/FileManager/Search/AdvanceSearch/DateTimeCondition.tsx
Executable file
@@ -0,0 +1,54 @@
|
||||
import { Box } from "@mui/material";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Condition } from "./ConditionBox.tsx";
|
||||
import { DateTimePicker, LocalizationProvider } from "@mui/x-date-pickers";
|
||||
import { AdapterDayjs } from "@mui/x-date-pickers/AdapterDayjs";
|
||||
import dayjs from "dayjs";
|
||||
|
||||
export const DateTimeCondition = ({
|
||||
condition,
|
||||
onChange,
|
||||
field,
|
||||
}: {
|
||||
onChange: (condition: Condition) => void;
|
||||
condition: Condition;
|
||||
field: string;
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<LocalizationProvider dateAdapter={AdapterDayjs}>
|
||||
<Box
|
||||
sx={{
|
||||
display: "flex",
|
||||
gap: 1,
|
||||
}}
|
||||
>
|
||||
<DateTimePicker
|
||||
localeText={{
|
||||
clearButtonLabel: "Vider",
|
||||
}}
|
||||
label={t("application:navbar.notBefore")}
|
||||
// @ts-ignore
|
||||
value={dayjs.unix(condition[field + "_gte"] as number)}
|
||||
onChange={(newValue) =>
|
||||
onChange({
|
||||
...condition,
|
||||
[field + "_gte"]: newValue ? newValue.unix() : 0,
|
||||
})
|
||||
}
|
||||
/>
|
||||
<DateTimePicker
|
||||
label={t("application:navbar.notAfter")}
|
||||
// @ts-ignore
|
||||
value={dayjs.unix(condition[field + "_lte"] as number)}
|
||||
onChange={(newValue) =>
|
||||
onChange({
|
||||
...condition,
|
||||
[field + "_lte"]: newValue ? newValue.unix() : 0,
|
||||
})
|
||||
}
|
||||
/>
|
||||
</Box>
|
||||
</LocalizationProvider>
|
||||
);
|
||||
};
|
||||
106
src/component/FileManager/Search/AdvanceSearch/FileNameCondition.tsx
Executable file
106
src/component/FileManager/Search/AdvanceSearch/FileNameCondition.tsx
Executable file
@@ -0,0 +1,106 @@
|
||||
import { Autocomplete, Box, Chip, FormControlLabel, styled } from "@mui/material";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { FilledTextField, StyledCheckbox } from "../../../Common/StyledComponents.tsx";
|
||||
import { Condition } from "./ConditionBox.tsx";
|
||||
|
||||
export const StyledBox = styled(Box)(({ theme }) => ({
|
||||
padding: `${theme.spacing(1)} ${theme.spacing(2)}`,
|
||||
paddingBottom: theme.spacing(2),
|
||||
borderRadius: theme.shape.borderRadius,
|
||||
border: `1px solid ${theme.palette.divider}`,
|
||||
}));
|
||||
export const FileNameCondition = ({
|
||||
condition,
|
||||
onChange,
|
||||
onNameConditionAdded,
|
||||
}: {
|
||||
onChange: (condition: Condition) => void;
|
||||
condition: Condition;
|
||||
onNameConditionAdded: (_e: any, newValue: string[]) => void;
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<>
|
||||
<Autocomplete
|
||||
multiple
|
||||
id="tags-filled"
|
||||
options={[]}
|
||||
value={condition.names ?? []}
|
||||
autoSelect
|
||||
freeSolo
|
||||
onChange={onNameConditionAdded}
|
||||
renderTags={(value: readonly string[], getTagProps) =>
|
||||
value.map((option: string, index: number) => {
|
||||
const { key, ...tagProps } = getTagProps({ index });
|
||||
return <Chip variant="outlined" label={option} key={key} {...tagProps} />;
|
||||
})
|
||||
}
|
||||
renderInput={(params) => (
|
||||
<FilledTextField
|
||||
{...params}
|
||||
sx={{
|
||||
"& .MuiInputBase-root": {
|
||||
py: 1,
|
||||
},
|
||||
}}
|
||||
variant="filled"
|
||||
autoFocus
|
||||
helperText={t("application:navbar.fileNameKeywordsHelp")}
|
||||
margin="dense"
|
||||
type="text"
|
||||
fullWidth
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
<Box sx={{ pt: 1, pl: "10px" }}>
|
||||
<FormControlLabel
|
||||
slotProps={{
|
||||
typography: {
|
||||
variant: "body2",
|
||||
pl: 1,
|
||||
color: "text.secondary",
|
||||
},
|
||||
}}
|
||||
sx={{ mr: 4 }}
|
||||
control={
|
||||
<StyledCheckbox
|
||||
onChange={(e) => {
|
||||
onChange({
|
||||
...condition,
|
||||
case_folding: e.target.checked,
|
||||
});
|
||||
}}
|
||||
disableRipple
|
||||
checked={condition.case_folding}
|
||||
size="small"
|
||||
/>
|
||||
}
|
||||
label={t("application:navbar.caseFolding")}
|
||||
/>
|
||||
<FormControlLabel
|
||||
slotProps={{
|
||||
typography: {
|
||||
variant: "body2",
|
||||
pl: 1,
|
||||
color: "text.secondary",
|
||||
},
|
||||
}}
|
||||
control={
|
||||
<StyledCheckbox
|
||||
onChange={(e) => {
|
||||
onChange({
|
||||
...condition,
|
||||
name_op_or: !e.target.checked,
|
||||
});
|
||||
}}
|
||||
disableRipple
|
||||
checked={!condition.name_op_or}
|
||||
size="small"
|
||||
/>
|
||||
}
|
||||
label={t("application:navbar.notNameOpOr")}
|
||||
/>
|
||||
</Box>
|
||||
</>
|
||||
);
|
||||
};
|
||||
57
src/component/FileManager/Search/AdvanceSearch/FileTypeCondition.tsx
Executable file
57
src/component/FileManager/Search/AdvanceSearch/FileTypeCondition.tsx
Executable file
@@ -0,0 +1,57 @@
|
||||
import { FormControl, ListItemIcon, ListItemText } from "@mui/material";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { SquareMenuItem } from "../../ContextMenu/ContextMenu.tsx";
|
||||
import { FileType } from "../../../../api/explorer.ts";
|
||||
import Document from "../../../Icons/Document.tsx";
|
||||
import Folder from "../../../Icons/Folder.tsx";
|
||||
import { Condition } from "./ConditionBox.tsx";
|
||||
import { DenseSelect } from "../../../Common/StyledComponents.tsx";
|
||||
|
||||
export const FileTypeCondition = ({
|
||||
condition,
|
||||
onChange,
|
||||
}: {
|
||||
onChange: (condition: Condition) => void;
|
||||
condition: Condition;
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<FormControl variant="outlined" fullWidth>
|
||||
<DenseSelect
|
||||
variant="outlined"
|
||||
value={condition.file_type ?? 0}
|
||||
onChange={(e) =>
|
||||
onChange({
|
||||
...condition,
|
||||
file_type: e.target.value as number,
|
||||
})
|
||||
}
|
||||
>
|
||||
<SquareMenuItem value={FileType.file}>
|
||||
<ListItemIcon>
|
||||
<Document fontSize="small" />
|
||||
</ListItemIcon>
|
||||
<ListItemText
|
||||
slotProps={{
|
||||
primary: { variant: "body2" },
|
||||
}}
|
||||
>
|
||||
{t("application:fileManager.file")}
|
||||
</ListItemText>
|
||||
</SquareMenuItem>
|
||||
<SquareMenuItem value={FileType.folder}>
|
||||
<ListItemIcon>
|
||||
<Folder fontSize="small" />
|
||||
</ListItemIcon>
|
||||
<ListItemText
|
||||
slotProps={{
|
||||
primary: { variant: "body2" },
|
||||
}}
|
||||
>
|
||||
{t("application:fileManager.folder")}
|
||||
</ListItemText>
|
||||
</SquareMenuItem>
|
||||
</DenseSelect>
|
||||
</FormControl>
|
||||
);
|
||||
};
|
||||
40
src/component/FileManager/Search/AdvanceSearch/MetadataCondition.tsx
Executable file
40
src/component/FileManager/Search/AdvanceSearch/MetadataCondition.tsx
Executable file
@@ -0,0 +1,40 @@
|
||||
import { Box } from "@mui/material";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Condition } from "./ConditionBox.tsx";
|
||||
import { FilledTextField } from "../../../Common/StyledComponents.tsx";
|
||||
|
||||
export const MetadataCondition = ({
|
||||
condition,
|
||||
onChange,
|
||||
}: {
|
||||
onChange: (condition: Condition) => void;
|
||||
condition: Condition;
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<Box
|
||||
sx={{
|
||||
display: "flex",
|
||||
gap: 1,
|
||||
}}
|
||||
>
|
||||
<FilledTextField
|
||||
variant="filled"
|
||||
label={t("application:fileManager.metadataKey")}
|
||||
value={condition.metadata_key ?? ""}
|
||||
onChange={(e) => onChange({ ...condition, metadata_key: e.target.value })}
|
||||
disabled={condition.metadata_key_readonly}
|
||||
type="text"
|
||||
fullWidth
|
||||
/>
|
||||
<FilledTextField
|
||||
variant="filled"
|
||||
label={t("application:fileManager.metadataValue")}
|
||||
value={condition.metadata_value ?? ""}
|
||||
onChange={(e) => onChange({ ...condition, metadata_value: e.target.value })}
|
||||
type="text"
|
||||
fullWidth
|
||||
/>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
27
src/component/FileManager/Search/AdvanceSearch/SearchBaseCondition.tsx
Executable file
27
src/component/FileManager/Search/AdvanceSearch/SearchBaseCondition.tsx
Executable file
@@ -0,0 +1,27 @@
|
||||
import { PathSelectorForm } from "../../../Common/Form/PathSelectorForm.tsx";
|
||||
import { defaultPath } from "../../../../hooks/useNavigation.tsx";
|
||||
import { Condition } from "./ConditionBox.tsx";
|
||||
|
||||
export const SearchBaseCondition = ({
|
||||
condition,
|
||||
onChange,
|
||||
}: {
|
||||
onChange: (condition: Condition) => void;
|
||||
condition: Condition;
|
||||
}) => {
|
||||
return (
|
||||
<PathSelectorForm
|
||||
onChange={(path) => onChange({ ...condition, base_uri: path })}
|
||||
path={condition.base_uri ?? defaultPath}
|
||||
variant={"searchIn"}
|
||||
textFieldProps={{
|
||||
sx: {
|
||||
"& .MuiOutlinedInput-input": {
|
||||
paddingTop: "15.5px",
|
||||
paddingBottom: "15.5px",
|
||||
},
|
||||
},
|
||||
}}
|
||||
/>
|
||||
);
|
||||
};
|
||||
39
src/component/FileManager/Search/AdvanceSearch/SizeCondition.tsx
Executable file
39
src/component/FileManager/Search/AdvanceSearch/SizeCondition.tsx
Executable file
@@ -0,0 +1,39 @@
|
||||
import { Box } from "@mui/material";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Condition } from "./ConditionBox.tsx";
|
||||
import SizeInput from "../../../Common/SizeInput.tsx";
|
||||
|
||||
export const SizeCondition = ({
|
||||
condition,
|
||||
onChange,
|
||||
}: {
|
||||
onChange: (condition: Condition) => void;
|
||||
condition: Condition;
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<Box
|
||||
sx={{
|
||||
display: "flex",
|
||||
gap: 1,
|
||||
}}
|
||||
>
|
||||
<SizeInput
|
||||
label={t("application:navbar.minimum")}
|
||||
value={condition.size_gte ?? 0}
|
||||
onChange={(e) => onChange({ ...condition, size_gte: e })}
|
||||
inputProps={{
|
||||
fullWidth: true,
|
||||
}}
|
||||
/>
|
||||
<SizeInput
|
||||
label={t("application:navbar.maximum")}
|
||||
value={condition.size_lte ?? 0}
|
||||
onChange={(e) => onChange({ ...condition, size_lte: e })}
|
||||
inputProps={{
|
||||
fullWidth: true,
|
||||
}}
|
||||
/>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
48
src/component/FileManager/Search/AdvanceSearch/TagCondition.tsx
Executable file
48
src/component/FileManager/Search/AdvanceSearch/TagCondition.tsx
Executable file
@@ -0,0 +1,48 @@
|
||||
import { Autocomplete, Chip } from "@mui/material";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { FilledTextField } from "../../../Common/StyledComponents.tsx";
|
||||
import { Condition } from "./ConditionBox.tsx";
|
||||
|
||||
export const TagCondition = ({
|
||||
condition,
|
||||
onChange,
|
||||
}: {
|
||||
onChange: (condition: Condition) => void;
|
||||
condition: Condition;
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<>
|
||||
<Autocomplete
|
||||
multiple
|
||||
options={[]}
|
||||
value={condition.tags ?? []}
|
||||
autoSelect
|
||||
freeSolo
|
||||
onChange={(_, value) => onChange({ ...condition, tags: value })}
|
||||
renderTags={(value: readonly string[], getTagProps) =>
|
||||
value.map((option: string, index: number) => {
|
||||
const { key, ...tagProps } = getTagProps({ index });
|
||||
return <Chip variant="outlined" label={option} key={key} {...tagProps} />;
|
||||
})
|
||||
}
|
||||
renderInput={(params) => (
|
||||
<FilledTextField
|
||||
{...params}
|
||||
sx={{
|
||||
"& .MuiInputBase-root": {
|
||||
py: 1,
|
||||
},
|
||||
}}
|
||||
variant="filled"
|
||||
autoFocus
|
||||
helperText={t("application:modals.enterForNewTag")}
|
||||
margin="dense"
|
||||
type="text"
|
||||
fullWidth
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
||||
76
src/component/FileManager/Search/FullSearchOptions.tsx
Executable file
76
src/component/FileManager/Search/FullSearchOptions.tsx
Executable file
@@ -0,0 +1,76 @@
|
||||
import { Box, List, ListItem, ListItemAvatar, ListItemButton, ListItemText } from "@mui/material";
|
||||
import { Trans, useTranslation } from "react-i18next";
|
||||
import React, { useCallback } from "react";
|
||||
// @ts-ignore
|
||||
import Highlighter from "react-highlight-words";
|
||||
import { useAppDispatch } from "../../../redux/hooks.ts";
|
||||
import { SearchOutlined } from "@mui/icons-material";
|
||||
import { FileType } from "../../../api/explorer.ts";
|
||||
import FileBadge from "../FileBadge.tsx";
|
||||
import { quickSearch } from "../../../redux/thunks/filemanager.ts";
|
||||
import { FileManagerIndex } from "../FileManager.tsx";
|
||||
|
||||
export interface FullSearchOptionProps {
|
||||
options: string[];
|
||||
keyword: string;
|
||||
}
|
||||
|
||||
const FullSearchOption = ({ options, keyword }: FullSearchOptionProps) => {
|
||||
const { t } = useTranslation();
|
||||
const dispatch = useAppDispatch();
|
||||
|
||||
const onClick = useCallback(
|
||||
(base: string) => () => dispatch(quickSearch(FileManagerIndex.main, base, keyword)),
|
||||
[keyword, dispatch],
|
||||
);
|
||||
|
||||
return (
|
||||
<List sx={{ width: "100%", px: 1 }} dense>
|
||||
{options.map((option) => (
|
||||
<ListItem disablePadding dense>
|
||||
<ListItemButton onClick={onClick(option)} sx={{ py: 0 }}>
|
||||
<ListItemAvatar sx={{ minWidth: 48 }}>
|
||||
<SearchOutlined
|
||||
sx={{
|
||||
color: (theme) => theme.palette.action.active,
|
||||
width: 24,
|
||||
height: 24,
|
||||
mt: "7px",
|
||||
ml: "5px",
|
||||
}}
|
||||
/>
|
||||
</ListItemAvatar>
|
||||
<ListItemText
|
||||
primary={
|
||||
<Trans
|
||||
ns={"application"}
|
||||
i18nKey={"navbar.searchIn"}
|
||||
values={{
|
||||
keywords: keyword,
|
||||
}}
|
||||
components={[<Box component={"span"} sx={{ fontWeight: 600 }} />]}
|
||||
/>
|
||||
}
|
||||
slotProps={{
|
||||
primary: {
|
||||
variant: "body2",
|
||||
},
|
||||
}}
|
||||
/>
|
||||
<FileBadge
|
||||
clickable
|
||||
variant={"outlined"}
|
||||
sx={{ px: 1, my: "4px" }}
|
||||
simplifiedFile={{
|
||||
path: option,
|
||||
type: FileType.folder,
|
||||
}}
|
||||
/>
|
||||
</ListItemButton>
|
||||
</ListItem>
|
||||
))}
|
||||
</List>
|
||||
);
|
||||
};
|
||||
|
||||
export default FullSearchOption;
|
||||
99
src/component/FileManager/Search/FuzzySearchResult.tsx
Executable file
99
src/component/FileManager/Search/FuzzySearchResult.tsx
Executable file
@@ -0,0 +1,99 @@
|
||||
import { FileResponse, FileType, Metadata } from "../../../api/explorer.ts";
|
||||
import { List, ListItem, ListItemAvatar, ListItemButton, ListItemText } from "@mui/material";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { sizeToString } from "../../../util";
|
||||
import FileIcon from "../Explorer/FileIcon.tsx";
|
||||
import React, { useCallback } from "react";
|
||||
import FileBadge from "../FileBadge.tsx";
|
||||
import CrUri from "../../../util/uri.ts";
|
||||
// @ts-ignore
|
||||
import Highlighter from "react-highlight-words";
|
||||
|
||||
import { openFileContextMenu } from "../../../redux/thunks/file.ts";
|
||||
import { FileManagerIndex } from "../FileManager.tsx";
|
||||
import { useAppDispatch } from "../../../redux/hooks.ts";
|
||||
import { ContextMenuTypes } from "../../../redux/fileManagerSlice.ts";
|
||||
|
||||
export interface FuzzySearchResultProps {
|
||||
files: FileResponse[];
|
||||
keyword: string;
|
||||
}
|
||||
|
||||
const FuzzySearchResult = ({ files, keyword }: FuzzySearchResultProps) => {
|
||||
const { t } = useTranslation();
|
||||
const dispatch = useAppDispatch();
|
||||
const getFileTypeText = useCallback(
|
||||
(file: FileResponse) => {
|
||||
if (file.metadata?.[Metadata.share_redirect]) {
|
||||
return t("fileManager.symbolicFile");
|
||||
}
|
||||
|
||||
if (file.type == FileType.folder) {
|
||||
return t("application:fileManager.folder");
|
||||
}
|
||||
return sizeToString(file.size);
|
||||
},
|
||||
[t],
|
||||
);
|
||||
|
||||
return (
|
||||
<List sx={{ width: "100%", px: 1 }} dense>
|
||||
{files.map((file) => (
|
||||
<ListItem disablePadding dense>
|
||||
<ListItemButton
|
||||
sx={{ py: 0 }}
|
||||
onClick={(e) =>
|
||||
dispatch(openFileContextMenu(FileManagerIndex.main, file, true, e, ContextMenuTypes.searchResult))
|
||||
}
|
||||
>
|
||||
<ListItemAvatar sx={{ minWidth: 48 }}>
|
||||
<FileIcon
|
||||
variant={"default"}
|
||||
file={file}
|
||||
sx={{ p: 0 }}
|
||||
iconProps={{
|
||||
sx: {
|
||||
fontSize: "24px",
|
||||
height: "32px",
|
||||
width: "32px",
|
||||
},
|
||||
}}
|
||||
/>
|
||||
</ListItemAvatar>
|
||||
<ListItemText
|
||||
primary={
|
||||
<Highlighter
|
||||
highlightClassName="highlight-marker"
|
||||
searchWords={keyword.split(" ")}
|
||||
autoEscape={true}
|
||||
textToHighlight={file.name}
|
||||
/>
|
||||
}
|
||||
secondary={getFileTypeText(file)}
|
||||
slotProps={{
|
||||
primary: {
|
||||
variant: "body2",
|
||||
},
|
||||
|
||||
secondary: {
|
||||
variant: "body2",
|
||||
},
|
||||
}}
|
||||
/>
|
||||
<FileBadge
|
||||
clickable
|
||||
variant={"outlined"}
|
||||
sx={{ px: 1, mt: "2px" }}
|
||||
simplifiedFile={{
|
||||
path: new CrUri(file.path).parent().toString(),
|
||||
type: FileType.folder,
|
||||
}}
|
||||
/>
|
||||
</ListItemButton>
|
||||
</ListItem>
|
||||
))}
|
||||
</List>
|
||||
);
|
||||
};
|
||||
|
||||
export default FuzzySearchResult;
|
||||
89
src/component/FileManager/Search/SearchIndicator.tsx
Executable file
89
src/component/FileManager/Search/SearchIndicator.tsx
Executable file
@@ -0,0 +1,89 @@
|
||||
import { alpha, Button, ButtonGroup, Grow, styled, useMediaQuery, useTheme } from "@mui/material";
|
||||
import { useContext, useMemo } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useAppDispatch, useAppSelector } from "../../../redux/hooks.ts";
|
||||
import { clearSearch, openAdvancedSearch } from "../../../redux/thunks/filemanager.ts";
|
||||
import Dismiss from "../../Icons/Dismiss.tsx";
|
||||
import Search from "../../Icons/Search.tsx";
|
||||
import { FileManagerIndex } from "../FileManager.tsx";
|
||||
import { FmIndexContext } from "../FmIndexContext.tsx";
|
||||
|
||||
export const StyledButtonGroup = styled(ButtonGroup)(({ theme }) => ({
|
||||
"& .MuiButtonGroup-firstButton, .MuiButtonGroup-lastButton": {
|
||||
"&:hover": {
|
||||
border: "none",
|
||||
},
|
||||
},
|
||||
}));
|
||||
export const StyledButton = styled(Button)(({ theme }) => ({
|
||||
border: "none",
|
||||
backgroundColor: alpha(theme.palette.primary.main, 0.1),
|
||||
"&:hover": {
|
||||
backgroundColor: alpha(theme.palette.primary.main, 0.2),
|
||||
},
|
||||
fontSize: theme.typography.caption.fontSize,
|
||||
minWidth: 0,
|
||||
"& .MuiButton-startIcon": {},
|
||||
}));
|
||||
|
||||
export const SearchIndicator = () => {
|
||||
const { t } = useTranslation();
|
||||
const theme = useTheme();
|
||||
const isMobile = useMediaQuery(theme.breakpoints.down("sm"));
|
||||
const dispatch = useAppDispatch();
|
||||
const fmIndex = useContext(FmIndexContext);
|
||||
|
||||
const search_params = useAppSelector((state) => state.fileManager[fmIndex].search_params);
|
||||
|
||||
const searchConditionsCount = useMemo(() => {
|
||||
if (!search_params) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
let count = 0;
|
||||
if (search_params.name) {
|
||||
count++;
|
||||
}
|
||||
if (search_params.metadata) {
|
||||
count += Object.keys(search_params.metadata).length;
|
||||
}
|
||||
if (search_params.type != undefined) {
|
||||
count++;
|
||||
}
|
||||
if (search_params.size_gte || search_params.size_lte) {
|
||||
count++;
|
||||
}
|
||||
if (search_params.created_at_gte || search_params.created_at_lte) {
|
||||
count++;
|
||||
}
|
||||
if (search_params.updated_at_gte || search_params.updated_at_lte) {
|
||||
count++;
|
||||
}
|
||||
if (search_params.metadata_strong_match) {
|
||||
count += Object.keys(search_params.metadata_strong_match).length;
|
||||
}
|
||||
return count;
|
||||
}, [search_params]);
|
||||
|
||||
return (
|
||||
<Grow unmountOnExit in={searchConditionsCount > 0}>
|
||||
<StyledButtonGroup>
|
||||
<StyledButton
|
||||
disabled={fmIndex != FileManagerIndex.main}
|
||||
size={"small"}
|
||||
startIcon={<Search fontSize={"small"} />}
|
||||
onClick={() => dispatch(openAdvancedSearch(fmIndex))}
|
||||
>
|
||||
{isMobile
|
||||
? searchConditionsCount
|
||||
: t("fileManager.searchConditions", {
|
||||
num: searchConditionsCount,
|
||||
})}
|
||||
</StyledButton>
|
||||
<StyledButton size={"small"} onClick={() => dispatch(clearSearch(fmIndex))}>
|
||||
<Dismiss fontSize={"small"} sx={{ width: 16, height: 16 }} />
|
||||
</StyledButton>
|
||||
</StyledButtonGroup>
|
||||
</Grow>
|
||||
);
|
||||
};
|
||||
249
src/component/FileManager/Search/SearchPopup.tsx
Executable file
249
src/component/FileManager/Search/SearchPopup.tsx
Executable file
@@ -0,0 +1,249 @@
|
||||
import { useAppDispatch, useAppSelector } from "../../../redux/hooks.ts";
|
||||
import { setSearchPopup } from "../../../redux/globalStateSlice.ts";
|
||||
import {
|
||||
Box,
|
||||
debounce,
|
||||
Dialog,
|
||||
Divider,
|
||||
Grow,
|
||||
IconButton,
|
||||
styled,
|
||||
Tooltip,
|
||||
Typography,
|
||||
useMediaQuery,
|
||||
useTheme,
|
||||
} from "@mui/material";
|
||||
import { forwardRef, useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { OutlineIconTextField } from "../../Common/Form/OutlineIconTextField.tsx";
|
||||
import { SearchOutlined } from "@mui/icons-material";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { FileManagerIndex } from "../FileManager.tsx";
|
||||
import { FileResponse } from "../../../api/explorer.ts";
|
||||
import Fuse from "fuse.js";
|
||||
import AutoHeight from "../../Common/AutoHeight.tsx";
|
||||
import FuzzySearchResult from "./FuzzySearchResult.tsx";
|
||||
import CrUri, { Filesystem } from "../../../util/uri.ts";
|
||||
import SessionManager from "../../../session";
|
||||
import { defaultPath } from "../../../hooks/useNavigation.tsx";
|
||||
import FullSearchOption from "./FullSearchOptions.tsx";
|
||||
import { TransitionProps } from "@mui/material/transitions";
|
||||
import { openAdvancedSearch, quickSearch } from "../../../redux/thunks/filemanager.ts";
|
||||
import Options from "../../Icons/Options.tsx";
|
||||
|
||||
const StyledDialog = styled(Dialog)<{
|
||||
expanded?: boolean;
|
||||
}>(({ theme, expanded }) => ({
|
||||
"& .MuiDialog-container": {
|
||||
alignItems: "flex-start",
|
||||
height: expanded ? "100%" : "initial",
|
||||
},
|
||||
zIndex: theme.zIndex.modal - 1,
|
||||
}));
|
||||
|
||||
const StyledOutlinedIconTextFiled = styled(OutlineIconTextField)(() => ({
|
||||
"& .MuiOutlinedInput-notchedOutline": {
|
||||
border: "none",
|
||||
},
|
||||
}));
|
||||
|
||||
export const GrowDialogTransition = forwardRef(function Transition(
|
||||
props: TransitionProps & {
|
||||
children: React.ReactElement<any, any>;
|
||||
},
|
||||
ref: React.Ref<unknown>,
|
||||
) {
|
||||
return <Grow ref={ref} {...props} />;
|
||||
});
|
||||
|
||||
const SearchPopup = () => {
|
||||
const { t } = useTranslation();
|
||||
const dispatch = useAppDispatch();
|
||||
const theme = useTheme();
|
||||
const isMobile = useMediaQuery(theme.breakpoints.down("sm"));
|
||||
|
||||
const [keywords, setKeywords] = useState("");
|
||||
const [searchedKeyword, setSearchedKeyword] = useState("");
|
||||
const [treeSearchResults, setTreeSearchResults] = useState<FileResponse[]>([]);
|
||||
|
||||
const onClose = () => {
|
||||
dispatch(setSearchPopup(false));
|
||||
setKeywords("");
|
||||
setSearchedKeyword("");
|
||||
};
|
||||
|
||||
const open = useAppSelector((state) => state.globalState.searchPopupOpen);
|
||||
const tree = useAppSelector((state) => state.fileManager[FileManagerIndex.main]?.tree);
|
||||
const path = useAppSelector((state) => state.fileManager[FileManagerIndex.main]?.path);
|
||||
const single_file_view = useAppSelector((state) => state.fileManager[FileManagerIndex.main]?.list?.single_file_view);
|
||||
|
||||
const searchTree = useMemo(
|
||||
() =>
|
||||
debounce((request: { input: string }, callback: (results?: FileResponse[]) => void) => {
|
||||
const options = {
|
||||
includeScore: true,
|
||||
// Search in `author` and in `tags` array
|
||||
keys: ["file.name"],
|
||||
};
|
||||
const fuse = new Fuse(Object.values(tree), options);
|
||||
const result = fuse.search(
|
||||
request.input
|
||||
.split(" ")
|
||||
.filter((k) => k != "")
|
||||
.join(" "),
|
||||
{ limit: 50 },
|
||||
);
|
||||
const res: FileResponse[] = [];
|
||||
result
|
||||
.filter((r) => r.item.file != undefined)
|
||||
.forEach((r) => {
|
||||
if (r.item.file) {
|
||||
res.push(r.item.file);
|
||||
}
|
||||
});
|
||||
callback(res);
|
||||
}, 400),
|
||||
[tree],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
let active = true;
|
||||
|
||||
if (keywords === "" || keywords.length < 2) {
|
||||
setTreeSearchResults([]);
|
||||
setSearchedKeyword("");
|
||||
return undefined;
|
||||
}
|
||||
|
||||
searchTree({ input: keywords }, (results?: FileResponse[]) => {
|
||||
if (active) {
|
||||
setTreeSearchResults(results ?? []);
|
||||
setSearchedKeyword(keywords);
|
||||
}
|
||||
});
|
||||
return () => {
|
||||
active = false;
|
||||
};
|
||||
}, [keywords, setSearchedKeyword, searchTree]);
|
||||
|
||||
const fullSearchOptions = useMemo(() => {
|
||||
if (!open || !keywords) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const res: string[] = [];
|
||||
const current = new CrUri(path ?? defaultPath);
|
||||
// current folder - not currently in root
|
||||
if (!current.is_root()) {
|
||||
res.push(current.toString());
|
||||
}
|
||||
// current root - not in single file view
|
||||
if (!single_file_view) {
|
||||
res.push(current.base());
|
||||
}
|
||||
// my files - user login and not my fs
|
||||
if (SessionManager.currentLoginOrNull() && !(current.fs() == Filesystem.my)) {
|
||||
res.push(defaultPath);
|
||||
}
|
||||
return res;
|
||||
}, [open, path, single_file_view, keywords]);
|
||||
|
||||
const onEnter = useCallback(
|
||||
(e: React.KeyboardEvent) => {
|
||||
if (e.key === "Enter") {
|
||||
e.stopPropagation();
|
||||
e.preventDefault();
|
||||
if (fullSearchOptions.length > 0) {
|
||||
dispatch(quickSearch(FileManagerIndex.main, fullSearchOptions[0], keywords));
|
||||
}
|
||||
}
|
||||
},
|
||||
[fullSearchOptions, keywords],
|
||||
);
|
||||
|
||||
return (
|
||||
<StyledDialog
|
||||
TransitionComponent={GrowDialogTransition}
|
||||
fullWidth
|
||||
expanded={!!keywords}
|
||||
maxWidth={"md"}
|
||||
open={!!open}
|
||||
onClose={onClose}
|
||||
>
|
||||
<Box
|
||||
sx={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
}}
|
||||
>
|
||||
<StyledOutlinedIconTextFiled
|
||||
icon={<SearchOutlined />}
|
||||
variant="outlined"
|
||||
autoFocus
|
||||
onKeyDown={onEnter}
|
||||
value={keywords}
|
||||
onChange={(e) => setKeywords(e.target.value)}
|
||||
placeholder={t("navbar.searchFiles")}
|
||||
fullWidth
|
||||
/>
|
||||
<Tooltip title={t("application:navbar.advancedSearch")}>
|
||||
<IconButton
|
||||
sx={{
|
||||
width: 40,
|
||||
height: 40,
|
||||
mr: 1.5,
|
||||
}}
|
||||
onClick={() => dispatch(openAdvancedSearch(FileManagerIndex.main, keywords))}
|
||||
>
|
||||
<Options />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
</Box>
|
||||
|
||||
{keywords && <Divider />}
|
||||
<Box
|
||||
sx={{
|
||||
flexGrow: 1,
|
||||
overflowY: "auto",
|
||||
}}
|
||||
>
|
||||
<AutoHeight>
|
||||
{fullSearchOptions.length > 0 && (
|
||||
<>
|
||||
<Typography
|
||||
variant={"body2"}
|
||||
color={"textSecondary"}
|
||||
sx={{
|
||||
px: 3,
|
||||
pt: 1.5,
|
||||
fontWeight: 600,
|
||||
}}
|
||||
>
|
||||
{t("navbar.searchFilesTitle")}
|
||||
</Typography>
|
||||
<FullSearchOption keyword={keywords} options={fullSearchOptions} />
|
||||
{treeSearchResults.length > 0 && <Divider />}
|
||||
</>
|
||||
)}
|
||||
{treeSearchResults.length > 0 && (
|
||||
<>
|
||||
<Typography
|
||||
variant={"body2"}
|
||||
color={"textSecondary"}
|
||||
sx={{
|
||||
px: 3,
|
||||
pt: 1.5,
|
||||
fontWeight: 600,
|
||||
}}
|
||||
>
|
||||
{t("navbar.recentlyViewed")}
|
||||
</Typography>
|
||||
<FuzzySearchResult keyword={searchedKeyword} files={treeSearchResults} />
|
||||
</>
|
||||
)}
|
||||
</AutoHeight>
|
||||
</Box>
|
||||
</StyledDialog>
|
||||
);
|
||||
};
|
||||
|
||||
export default SearchPopup;
|
||||
267
src/component/FileManager/Sidebar/BasicInfo.tsx
Executable file
267
src/component/FileManager/Sidebar/BasicInfo.tsx
Executable file
@@ -0,0 +1,267 @@
|
||||
import { Link, Skeleton, Typography } from "@mui/material";
|
||||
import dayjs from "dayjs";
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { getFileInfo, sendPatchViewSync } from "../../../api/api.ts";
|
||||
import { ExplorerView, FileResponse, FileType, FolderSummary, Metadata } from "../../../api/explorer.ts";
|
||||
import { useAppDispatch } from "../../../redux/hooks.ts";
|
||||
import SessionManager from "../../../session/index.ts";
|
||||
import { sizeToString } from "../../../util";
|
||||
import CrUri from "../../../util/uri.ts";
|
||||
import TimeBadge from "../../Common/TimeBadge.tsx";
|
||||
import FileBadge from "../FileBadge.tsx";
|
||||
import InfoRow from "./InfoRow.tsx";
|
||||
|
||||
export interface BasicInfoProps {
|
||||
target: FileResponse;
|
||||
}
|
||||
|
||||
const BasicInfo = ({ target }: BasicInfoProps) => {
|
||||
const { t } = useTranslation();
|
||||
const dispatch = useAppDispatch();
|
||||
|
||||
// null: not valid, undefined: not loaded, FolderSummary: loaded
|
||||
const [folderSummary, setFolderSummary] = useState<FolderSummary | undefined | null>(null);
|
||||
useEffect(() => {
|
||||
setFolderSummary(null);
|
||||
}, [target]);
|
||||
|
||||
const [viewSetting, setViewSetting] = useState<ExplorerView | undefined>(undefined);
|
||||
|
||||
useEffect(() => {
|
||||
setViewSetting(target?.extended_info?.view);
|
||||
}, [target]);
|
||||
|
||||
const isSymbolicLink = useMemo(() => {
|
||||
return !!(target.metadata && target.metadata[Metadata.share_redirect]);
|
||||
}, [target.metadata]);
|
||||
const fileType = useMemo(() => {
|
||||
let srcType = "";
|
||||
switch (target.type) {
|
||||
case FileType.file:
|
||||
srcType = t("fileManager.file");
|
||||
break;
|
||||
case FileType.folder:
|
||||
srcType = t("fileManager.folder");
|
||||
break;
|
||||
default:
|
||||
srcType = t("fileManager.file");
|
||||
}
|
||||
|
||||
if (isSymbolicLink) {
|
||||
return t("fileManager.symbolicLink", { srcType });
|
||||
}
|
||||
|
||||
return srcType;
|
||||
}, [target, isSymbolicLink, t]);
|
||||
|
||||
const displaySize = useCallback(
|
||||
(size: number): string => sizeToString(size) + t("fileManager.bytes", { bytes: size.toLocaleString() }),
|
||||
[t],
|
||||
);
|
||||
|
||||
const storagePolicy = useMemo(() => {
|
||||
if (target.extended_info) {
|
||||
if (!target.extended_info.storage_policy) {
|
||||
return t("fileManager.unset");
|
||||
}
|
||||
|
||||
return target.extended_info.storage_policy.name;
|
||||
}
|
||||
return <Skeleton variant={"text"} width={75} />;
|
||||
}, [target.extended_info, t]);
|
||||
|
||||
const targetCrUri = useMemo(() => {
|
||||
return new CrUri(target.path);
|
||||
}, [target]);
|
||||
|
||||
const viewSyncEnabled = useMemo(() => {
|
||||
return !SessionManager.currentLoginOrNull()?.user?.disable_view_sync;
|
||||
}, [target]);
|
||||
|
||||
const restoreParent = useMemo(() => {
|
||||
if (!target.metadata || !target.metadata[Metadata.restore_uri]) {
|
||||
return null;
|
||||
}
|
||||
return new CrUri(target.metadata[Metadata.restore_uri]);
|
||||
}, [target]);
|
||||
|
||||
const getFolderSummary = useCallback(() => {
|
||||
setFolderSummary(undefined);
|
||||
dispatch(getFileInfo({ uri: target.path, folder_summary: true }))
|
||||
.then((res) => {
|
||||
setFolderSummary(res.folder_summary ?? null);
|
||||
})
|
||||
.catch(() => {
|
||||
setFolderSummary(null);
|
||||
});
|
||||
}, [target, setFolderSummary, dispatch]);
|
||||
|
||||
const folderSize = useMemo(() => {
|
||||
if (!folderSummary) {
|
||||
return "";
|
||||
}
|
||||
|
||||
const sizeText = displaySize(folderSummary.size);
|
||||
if (!folderSummary.completed) {
|
||||
return t("fileManager.moreThan", { text: sizeText });
|
||||
}
|
||||
return sizeText;
|
||||
}, [folderSummary, t]);
|
||||
|
||||
const folderChildren = useMemo(() => {
|
||||
if (!folderSummary) {
|
||||
return "";
|
||||
}
|
||||
|
||||
let files = folderSummary.files.toLocaleString();
|
||||
let folders = folderSummary.folders.toLocaleString();
|
||||
|
||||
if (!folderSummary.completed) {
|
||||
files += "+";
|
||||
folders += "+";
|
||||
}
|
||||
|
||||
return t("application:fileManager.folderChildren", {
|
||||
files,
|
||||
folders,
|
||||
});
|
||||
}, [folderSummary, t]);
|
||||
|
||||
const handleDeleteViewSetting = useCallback(() => {
|
||||
dispatch(sendPatchViewSync({ uri: target.path }))
|
||||
.then(() => {
|
||||
setViewSetting(undefined);
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error("Failed to delete view setting:", error);
|
||||
});
|
||||
}, [target.path, dispatch]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<Typography sx={{ pt: 1 }} color="textPrimary" fontWeight={500} variant={"subtitle1"}>
|
||||
{t("application:fileManager.basicInfo")}
|
||||
</Typography>
|
||||
<InfoRow title={t("fileManager.type")} content={fileType} />
|
||||
<InfoRow
|
||||
title={t("fileManager.parentFolder")}
|
||||
content={
|
||||
<FileBadge
|
||||
clickable
|
||||
variant={"outlined"}
|
||||
sx={{ px: 1, mt: "2px" }}
|
||||
simplifiedFile={{
|
||||
path: targetCrUri.parent().toString(),
|
||||
type: FileType.folder,
|
||||
}}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
{restoreParent && (
|
||||
<InfoRow
|
||||
title={t("fileManager.originalLocation")}
|
||||
content={
|
||||
<FileBadge
|
||||
clickable
|
||||
variant={"outlined"}
|
||||
sx={{ px: 1, mt: "2px" }}
|
||||
simplifiedFile={{
|
||||
path: restoreParent.parent().toString(),
|
||||
type: FileType.folder,
|
||||
}}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
)}
|
||||
{target.metadata && target.metadata[Metadata.expected_collect_time] && (
|
||||
<InfoRow
|
||||
title={t("application:fileManager.expires")}
|
||||
content={
|
||||
<TimeBadge
|
||||
variant={"body2"}
|
||||
datetime={dayjs.unix(parseInt(target.metadata[Metadata.expected_collect_time]))}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
)}
|
||||
{target.type == FileType.folder && !isSymbolicLink && (
|
||||
<>
|
||||
{!folderSummary && (
|
||||
<InfoRow
|
||||
title={t("fileManager.size")}
|
||||
content={
|
||||
folderSummary === undefined ? (
|
||||
<Skeleton variant={"text"} width={75} />
|
||||
) : (
|
||||
<Link href={"#"} onClick={getFolderSummary} underline={"hover"}>
|
||||
{t("fileManager.calculate")}
|
||||
</Link>
|
||||
)
|
||||
}
|
||||
/>
|
||||
)}
|
||||
{folderSummary && (
|
||||
<>
|
||||
<InfoRow title={t("fileManager.size")} content={folderSize} />
|
||||
<InfoRow title={t("fileManager.descendant")} content={folderChildren} />
|
||||
<InfoRow
|
||||
title={t("application:fileManager.statisticAt")}
|
||||
content={<TimeBadge variant={"body2"} datetime={folderSummary.calculated_at} />}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
{target.type == FileType.file && (
|
||||
<>
|
||||
<InfoRow title={t("fileManager.size")} content={displaySize(target.size)} />
|
||||
<InfoRow
|
||||
title={t("application:fileManager.storageUsed")}
|
||||
content={
|
||||
target.extended_info ? (
|
||||
displaySize(target.extended_info.storage_used)
|
||||
) : (
|
||||
<Skeleton variant={"text"} width={75} />
|
||||
)
|
||||
}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
<InfoRow
|
||||
title={t("application:fileManager.createdAt")}
|
||||
content={<TimeBadge variant={"body2"} datetime={target.created_at} />}
|
||||
/>
|
||||
<InfoRow
|
||||
title={t("application:fileManager.modifiedAt")}
|
||||
content={<TimeBadge variant={"body2"} datetime={target.updated_at} />}
|
||||
/>
|
||||
{target.type == FileType.folder && viewSyncEnabled && target.owned && !restoreParent && !isSymbolicLink && (
|
||||
<InfoRow
|
||||
title={t("application:fileManager.viewSetting")}
|
||||
content={
|
||||
!!viewSetting ? (
|
||||
<>
|
||||
{t("application:fileManager.saved")}{" "}
|
||||
<Link
|
||||
href={"#"}
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
handleDeleteViewSetting();
|
||||
}}
|
||||
underline={"hover"}
|
||||
>
|
||||
{t("application:fileManager.deleteViewSetting")}
|
||||
</Link>
|
||||
</>
|
||||
) : (
|
||||
t("application:fileManager.notSet")
|
||||
)
|
||||
}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default BasicInfo;
|
||||
109
src/component/FileManager/Sidebar/CustomProps/AddButton.tsx
Executable file
109
src/component/FileManager/Sidebar/CustomProps/AddButton.tsx
Executable file
@@ -0,0 +1,109 @@
|
||||
import { Icon } from "@iconify/react";
|
||||
import { Box, ListItemIcon, ListItemText, Menu, styled, Typography } from "@mui/material";
|
||||
import { bindMenu, bindTrigger, usePopupState } from "material-ui-popup-state/hooks";
|
||||
import { useMemo } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { CustomProps } from "../../../../api/explorer.ts";
|
||||
import Add from "../../../Icons/Add.tsx";
|
||||
import { SquareMenuItem } from "../../ContextMenu/ContextMenu.tsx";
|
||||
import { CustomPropsItem } from "./CustomProps.tsx";
|
||||
|
||||
const BorderedCard = styled(Box)(({ theme }) => ({
|
||||
border: `1px solid ${theme.palette.divider}`,
|
||||
borderRadius: theme.shape.borderRadius,
|
||||
padding: theme.spacing(2),
|
||||
backgroundColor: theme.palette.background.paper,
|
||||
}));
|
||||
|
||||
const BorderedCardClickable = styled(BorderedCard)<{ disabled?: boolean }>(({ theme, disabled }) => ({
|
||||
cursor: "pointer",
|
||||
"&:hover": {
|
||||
backgroundColor: theme.palette.action.hover,
|
||||
},
|
||||
transition: "background-color 0.3s ease",
|
||||
height: "100%",
|
||||
borderStyle: "dashed",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: 8,
|
||||
justifyContent: "center",
|
||||
color: theme.palette.text.secondary,
|
||||
opacity: disabled ? 0.5 : 1,
|
||||
pointerEvents: disabled ? "none" : "auto",
|
||||
}));
|
||||
|
||||
export interface AddButtonProps {
|
||||
options: CustomProps[];
|
||||
existingPropIds: string[];
|
||||
onPropAdd: (prop: CustomPropsItem) => void;
|
||||
loading?: boolean;
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
const AddButton = ({ options, existingPropIds, onPropAdd, disabled }: AddButtonProps) => {
|
||||
const { t } = useTranslation();
|
||||
const propPopupState = usePopupState({
|
||||
variant: "popover",
|
||||
popupId: "customProps",
|
||||
});
|
||||
const { onClose, ...menuProps } = bindMenu(propPopupState);
|
||||
|
||||
const unSelectedOptions = useMemo(() => {
|
||||
return options?.filter((option) => !existingPropIds.includes(option.id)) ?? [];
|
||||
}, [options, existingPropIds]);
|
||||
|
||||
const handlePropAdd = (prop: CustomProps) => {
|
||||
onPropAdd({
|
||||
props: prop,
|
||||
id: prop.id,
|
||||
value: prop.default ?? "",
|
||||
});
|
||||
onClose();
|
||||
};
|
||||
|
||||
if (unSelectedOptions.length === 0) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<BorderedCardClickable disabled={disabled} {...bindTrigger(propPopupState)}>
|
||||
<Add sx={{ width: 20, height: 20 }} />
|
||||
<Typography variant="body1" fontWeight={500}>
|
||||
{t("fileManager.add")}
|
||||
</Typography>
|
||||
</BorderedCardClickable>
|
||||
<Menu
|
||||
onClose={onClose}
|
||||
anchorOrigin={{
|
||||
vertical: "bottom",
|
||||
horizontal: "left",
|
||||
}}
|
||||
transformOrigin={{
|
||||
vertical: "top",
|
||||
horizontal: "left",
|
||||
}}
|
||||
{...menuProps}
|
||||
>
|
||||
{unSelectedOptions.map((option) => (
|
||||
<SquareMenuItem dense key={option.id} onClick={() => handlePropAdd(option)}>
|
||||
{option.icon && (
|
||||
<ListItemIcon>
|
||||
<Icon icon={option.icon} />
|
||||
</ListItemIcon>
|
||||
)}
|
||||
<ListItemText
|
||||
slotProps={{
|
||||
primary: { variant: "body2" },
|
||||
}}
|
||||
>
|
||||
{t(option.name)}
|
||||
</ListItemText>
|
||||
</SquareMenuItem>
|
||||
))}
|
||||
</Menu>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default AddButton;
|
||||
31
src/component/FileManager/Sidebar/CustomProps/BooleanPropsContent.tsx
Executable file
31
src/component/FileManager/Sidebar/CustomProps/BooleanPropsContent.tsx
Executable file
@@ -0,0 +1,31 @@
|
||||
import { Box, FormControlLabel } from "@mui/material";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { isTrueVal } from "../../../../session/utils.ts";
|
||||
import { StyledCheckbox } from "../../../Common/StyledComponents.tsx";
|
||||
import { PropsContentProps } from "./CustomPropsItem.tsx";
|
||||
|
||||
const BooleanPropsItem = ({ prop, onChange, loading, readOnly, fullSize }: PropsContentProps) => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const handleChange = (_: any, checked: boolean) => {
|
||||
onChange(checked.toString());
|
||||
};
|
||||
|
||||
return (
|
||||
<Box sx={{ pl: "10px" }}>
|
||||
<FormControlLabel
|
||||
slotProps={{
|
||||
typography: {
|
||||
variant: "inherit",
|
||||
pl: 1,
|
||||
},
|
||||
}}
|
||||
control={<StyledCheckbox size={"small"} checked={isTrueVal(prop.value)} onChange={handleChange} />}
|
||||
label={fullSize ? t(prop.props.name) : undefined}
|
||||
disabled={readOnly || loading}
|
||||
/>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
export default BooleanPropsItem;
|
||||
126
src/component/FileManager/Sidebar/CustomProps/CustomProps.tsx
Executable file
126
src/component/FileManager/Sidebar/CustomProps/CustomProps.tsx
Executable file
@@ -0,0 +1,126 @@
|
||||
import { Collapse, Stack, Typography } from "@mui/material";
|
||||
import { useMemo, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { TransitionGroup } from "react-transition-group";
|
||||
import { sendMetadataPatch } from "../../../../api/api.ts";
|
||||
import { CustomProps as CustomPropsType, FileResponse } from "../../../../api/explorer.ts";
|
||||
import { useAppDispatch, useAppSelector } from "../../../../redux/hooks.ts";
|
||||
import { DisplayOption } from "../../ContextMenu/useActionDisplayOpt.ts";
|
||||
import AddButton from "./AddButton.tsx";
|
||||
import CustomPropsCard from "./CustomPropsItem.tsx";
|
||||
|
||||
export interface CustomPropsProps {
|
||||
file: FileResponse;
|
||||
setTarget: (target: FileResponse | undefined | null) => void;
|
||||
targetDisplayOptions?: DisplayOption;
|
||||
}
|
||||
|
||||
export interface CustomPropsItem {
|
||||
props: CustomPropsType;
|
||||
id: string;
|
||||
value: string;
|
||||
}
|
||||
|
||||
export const customPropsMetadataPrefix = "props:";
|
||||
|
||||
const CustomProps = ({ file, setTarget, targetDisplayOptions }: CustomPropsProps) => {
|
||||
const { t } = useTranslation();
|
||||
const [loading, setLoading] = useState(false);
|
||||
const dispatch = useAppDispatch();
|
||||
const custom_props = useAppSelector((state) => state.siteConfig.explorer?.config?.custom_props);
|
||||
|
||||
const existingProps = useMemo(() => {
|
||||
if (!file.metadata) {
|
||||
return [];
|
||||
}
|
||||
return Object.keys(file.metadata)
|
||||
.filter((key) => key.startsWith(customPropsMetadataPrefix))
|
||||
.map((key) => {
|
||||
const propId = key.slice(customPropsMetadataPrefix.length);
|
||||
return {
|
||||
id: propId,
|
||||
props: custom_props?.find((prop) => prop.id === propId),
|
||||
value: file.metadata?.[key] ?? "",
|
||||
} as CustomPropsItem;
|
||||
});
|
||||
}, [file.metadata]);
|
||||
|
||||
const existingPropIds = useMemo(() => {
|
||||
return existingProps?.map((prop) => prop.id) ?? [];
|
||||
}, [existingProps]);
|
||||
|
||||
const handlePropPatch = (remove?: boolean) => (props: CustomPropsItem[]) => {
|
||||
setLoading(true);
|
||||
dispatch(
|
||||
sendMetadataPatch({
|
||||
uris: [file.path],
|
||||
patches: props.map((prop) => ({
|
||||
key: customPropsMetadataPrefix + prop.id,
|
||||
value: prop.value,
|
||||
remove,
|
||||
})),
|
||||
}),
|
||||
)
|
||||
.then(() => {
|
||||
if (remove) {
|
||||
const newMetadata = { ...file.metadata };
|
||||
props.forEach((prop) => {
|
||||
delete newMetadata[customPropsMetadataPrefix + prop.id];
|
||||
});
|
||||
setTarget({ ...file, metadata: newMetadata });
|
||||
} else {
|
||||
setTarget({
|
||||
...file,
|
||||
metadata: {
|
||||
...file.metadata,
|
||||
...Object.assign({}, ...props.map((prop) => ({ [customPropsMetadataPrefix + prop.id]: prop.value }))),
|
||||
},
|
||||
});
|
||||
}
|
||||
})
|
||||
.finally(() => {
|
||||
setLoading(false);
|
||||
});
|
||||
};
|
||||
|
||||
if (existingProps.length === 0 && (!custom_props || custom_props.length === 0)) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return (
|
||||
<Stack spacing={1}>
|
||||
<Typography sx={{ pt: 1 }} color="textPrimary" fontWeight={500} variant={"subtitle1"}>
|
||||
{t("fileManager.customProps")}
|
||||
</Typography>
|
||||
<AddButton
|
||||
disabled={!targetDisplayOptions?.showCustomProps}
|
||||
loading={loading}
|
||||
options={custom_props ?? []}
|
||||
existingPropIds={existingPropIds}
|
||||
onPropAdd={(prop) => {
|
||||
handlePropPatch(false)([prop]);
|
||||
}}
|
||||
/>
|
||||
<TransitionGroup>
|
||||
{existingProps.map((prop, index) => (
|
||||
<Collapse key={prop.id} sx={{ mb: index === existingProps.length - 1 ? 0 : 1 }}>
|
||||
<CustomPropsCard
|
||||
key={prop.id}
|
||||
prop={prop}
|
||||
loading={loading}
|
||||
onPropPatch={(prop) => {
|
||||
handlePropPatch(false)([prop]);
|
||||
}}
|
||||
onPropDelete={(prop) => {
|
||||
handlePropPatch(true)([prop]);
|
||||
}}
|
||||
readOnly={!targetDisplayOptions?.showCustomProps}
|
||||
/>
|
||||
</Collapse>
|
||||
))}
|
||||
</TransitionGroup>
|
||||
</Stack>
|
||||
);
|
||||
};
|
||||
|
||||
export default CustomProps;
|
||||
216
src/component/FileManager/Sidebar/CustomProps/CustomPropsItem.tsx
Executable file
216
src/component/FileManager/Sidebar/CustomProps/CustomPropsItem.tsx
Executable file
@@ -0,0 +1,216 @@
|
||||
import { Icon } from "@iconify/react/dist/iconify.js";
|
||||
import {
|
||||
alpha,
|
||||
Box,
|
||||
Grow,
|
||||
IconButton,
|
||||
ListItemIcon,
|
||||
ListItemText,
|
||||
Menu,
|
||||
styled,
|
||||
Typography,
|
||||
useTheme,
|
||||
} from "@mui/material";
|
||||
import { useMemo, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { CustomProps, CustomPropsType } from "../../../../api/explorer.ts";
|
||||
import { useAppDispatch } from "../../../../redux/hooks.ts";
|
||||
import { searchMetadata } from "../../../../redux/thunks/filemanager.ts";
|
||||
import { copyToClipboard } from "../../../../util";
|
||||
import Clipboard from "../../../Icons/Clipboard.tsx";
|
||||
import DeleteOutlined from "../../../Icons/DeleteOutlined.tsx";
|
||||
import MoreVertical from "../../../Icons/MoreVertical.tsx";
|
||||
import Search from "../../../Icons/Search.tsx";
|
||||
import { SquareMenuItem } from "../../ContextMenu/ContextMenu.tsx";
|
||||
import { FileManagerIndex } from "../../FileManager.tsx";
|
||||
import { StyledButtonBase } from "../MediaMetaCard.tsx";
|
||||
import BooleanPropsItem from "./BooleanPropsContent.tsx";
|
||||
import { CustomPropsItem } from "./CustomProps.tsx";
|
||||
import LinkPropsContent from "./LinkPropsContent.tsx";
|
||||
import MultiSelectPropsContent from "./MultiSelectPropsContent.tsx";
|
||||
import NumberPropsContent from "./NumberPropsContent.tsx";
|
||||
import RatingPropsItem from "./RatingPropsItem.tsx";
|
||||
import SelectPropsContent from "./SelectPropsContent.tsx";
|
||||
import TextPropsContent from "./TextPropsContent.tsx";
|
||||
import UserPropsContent from "./UserPropsContent.tsx";
|
||||
|
||||
export interface CustomPropsCardProps {
|
||||
prop: CustomPropsItem;
|
||||
loading?: boolean;
|
||||
onPropPatch: (prop: CustomPropsItem) => void;
|
||||
onPropDelete?: (prop: CustomPropsItem) => void;
|
||||
readOnly?: boolean;
|
||||
}
|
||||
|
||||
export interface PropsContentProps {
|
||||
prop: CustomPropsItem;
|
||||
onChange: (value: string) => void;
|
||||
loading?: boolean;
|
||||
readOnly?: boolean;
|
||||
backgroundColor?: string;
|
||||
fullSize?: boolean;
|
||||
}
|
||||
|
||||
const PropsCard = styled(StyledButtonBase)(({ theme }) => ({
|
||||
flexDirection: "column",
|
||||
alignItems: "flex-start",
|
||||
gap: 9,
|
||||
}));
|
||||
|
||||
export const getPropsContent = (
|
||||
prop: CustomPropsItem,
|
||||
onChange: (value: string) => void,
|
||||
loading?: boolean,
|
||||
readOnly?: boolean,
|
||||
fullSize?: boolean,
|
||||
) => {
|
||||
switch (prop.props.type) {
|
||||
case CustomPropsType.text:
|
||||
return (
|
||||
<TextPropsContent prop={prop} onChange={onChange} loading={loading} readOnly={readOnly} fullSize={fullSize} />
|
||||
);
|
||||
case CustomPropsType.rating:
|
||||
return (
|
||||
<RatingPropsItem prop={prop} onChange={onChange} loading={loading} readOnly={readOnly} fullSize={fullSize} />
|
||||
);
|
||||
case CustomPropsType.number:
|
||||
return (
|
||||
<NumberPropsContent prop={prop} onChange={onChange} loading={loading} readOnly={readOnly} fullSize={fullSize} />
|
||||
);
|
||||
case CustomPropsType.boolean:
|
||||
return (
|
||||
<BooleanPropsItem prop={prop} onChange={onChange} loading={loading} readOnly={readOnly} fullSize={fullSize} />
|
||||
);
|
||||
case CustomPropsType.select:
|
||||
return (
|
||||
<SelectPropsContent prop={prop} onChange={onChange} loading={loading} readOnly={readOnly} fullSize={fullSize} />
|
||||
);
|
||||
case CustomPropsType.multi_select:
|
||||
return (
|
||||
<MultiSelectPropsContent
|
||||
prop={prop}
|
||||
onChange={onChange}
|
||||
loading={loading}
|
||||
readOnly={readOnly}
|
||||
fullSize={fullSize}
|
||||
/>
|
||||
);
|
||||
case CustomPropsType.link:
|
||||
return (
|
||||
<LinkPropsContent prop={prop} onChange={onChange} loading={loading} readOnly={readOnly} fullSize={fullSize} />
|
||||
);
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
export const isCustomPropStrongMatch = (prop: CustomProps) => {
|
||||
return prop.type === CustomPropsType.rating || prop.type === CustomPropsType.number;
|
||||
};
|
||||
|
||||
const CustomPropsCard = ({ prop, loading, onPropPatch, onPropDelete, readOnly }: CustomPropsCardProps) => {
|
||||
const { t } = useTranslation();
|
||||
const theme = useTheme();
|
||||
const dispatch = useAppDispatch();
|
||||
const [mouseOver, setMouseOver] = useState(false);
|
||||
const [anchorEl, setAnchorEl] = useState<null | HTMLElement>(null);
|
||||
|
||||
const handleMenuClose = () => {
|
||||
setAnchorEl(null);
|
||||
};
|
||||
|
||||
const handleCopy = () => {
|
||||
const value = prop.value || "";
|
||||
copyToClipboard(value);
|
||||
handleMenuClose();
|
||||
};
|
||||
|
||||
const handleSearch = () => {
|
||||
if (prop.value) {
|
||||
dispatch(
|
||||
searchMetadata(
|
||||
FileManagerIndex.main,
|
||||
`props:${prop.props.id}`,
|
||||
prop.value,
|
||||
false,
|
||||
isCustomPropStrongMatch(prop.props),
|
||||
),
|
||||
);
|
||||
}
|
||||
handleMenuClose();
|
||||
};
|
||||
|
||||
const handleDelete = () => {
|
||||
if (onPropDelete) {
|
||||
onPropDelete(prop);
|
||||
}
|
||||
handleMenuClose();
|
||||
};
|
||||
|
||||
const Content = useMemo(() => {
|
||||
return getPropsContent(prop, (value) => onPropPatch({ ...prop, value }), loading, readOnly, true);
|
||||
}, [prop, loading, onPropPatch, readOnly]);
|
||||
|
||||
return (
|
||||
<PropsCard onMouseEnter={() => setMouseOver(true)} onMouseLeave={() => setMouseOver(false)}>
|
||||
<Box sx={{ position: "relative", display: "flex", alignItems: "center", width: "100%", gap: 1 }}>
|
||||
<Grow in={mouseOver} unmountOnExit>
|
||||
<Box
|
||||
sx={{
|
||||
position: "absolute",
|
||||
transition: "opacity 250ms cubic-bezier(0.4, 0, 0.2, 1) 0ms",
|
||||
backgroundColor:
|
||||
theme.palette.mode === "light"
|
||||
? alpha(theme.palette.grey[100], 0.73)
|
||||
: alpha(theme.palette.grey[900], 0.73),
|
||||
top: -4,
|
||||
right: -5,
|
||||
}}
|
||||
>
|
||||
<IconButton size="small" onClick={(e) => setAnchorEl(e.currentTarget)}>
|
||||
<MoreVertical />
|
||||
</IconButton>
|
||||
</Box>
|
||||
</Grow>
|
||||
{prop.props.icon && <Icon width={24} height={24} color={theme.palette.action.active} icon={prop.props.icon} />}
|
||||
<Typography variant={"body2"} color="textPrimary" fontWeight={500} sx={{ flexGrow: 1 }}>
|
||||
{prop.props.type === CustomPropsType.boolean ? Content : t(prop.props.name)}
|
||||
</Typography>
|
||||
</Box>
|
||||
|
||||
{prop.props.type !== CustomPropsType.boolean && (
|
||||
<Typography variant={"body2"} color={"text.secondary"} sx={{ width: "100%" }}>
|
||||
{Content}
|
||||
</Typography>
|
||||
)}
|
||||
|
||||
<Menu anchorEl={anchorEl} open={Boolean(anchorEl)} onClose={handleMenuClose}>
|
||||
{prop.value && (
|
||||
<>
|
||||
<SquareMenuItem onClick={handleCopy} dense>
|
||||
<ListItemIcon>
|
||||
<Clipboard fontSize="small" />
|
||||
</ListItemIcon>
|
||||
<ListItemText>{t("application:fileManager.copyToClipboard")}</ListItemText>
|
||||
</SquareMenuItem>
|
||||
<SquareMenuItem onClick={handleSearch} dense>
|
||||
<ListItemIcon>
|
||||
<Search fontSize="small" />
|
||||
</ListItemIcon>
|
||||
<ListItemText>{t("application:fileManager.searchProperty")}</ListItemText>
|
||||
</SquareMenuItem>
|
||||
</>
|
||||
)}
|
||||
|
||||
<SquareMenuItem onClick={handleDelete} dense disabled={readOnly}>
|
||||
<ListItemIcon>
|
||||
<DeleteOutlined fontSize="small" />
|
||||
</ListItemIcon>
|
||||
<ListItemText>{t("application:fileManager.delete")}</ListItemText>
|
||||
</SquareMenuItem>
|
||||
</Menu>
|
||||
</PropsCard>
|
||||
);
|
||||
};
|
||||
|
||||
export default CustomPropsCard;
|
||||
115
src/component/FileManager/Sidebar/CustomProps/LinkPropsContent.tsx
Executable file
115
src/component/FileManager/Sidebar/CustomProps/LinkPropsContent.tsx
Executable file
@@ -0,0 +1,115 @@
|
||||
import { Box, IconButton, Link, Typography } from "@mui/material";
|
||||
import { useEffect, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { NoLabelFilledTextField } from "../../../Common/StyledComponents.tsx";
|
||||
import Edit from "../../../Icons/Edit.tsx";
|
||||
import { PropsContentProps } from "./CustomPropsItem.tsx";
|
||||
|
||||
const LinkPropsContent = ({ prop, onChange, loading, readOnly }: PropsContentProps) => {
|
||||
const { t } = useTranslation();
|
||||
const [value, setValue] = useState(prop.value);
|
||||
const [isEditing, setIsEditing] = useState(false);
|
||||
const [isHovered, setIsHovered] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
setValue(prop.value);
|
||||
}, [prop.value]);
|
||||
|
||||
const handleEditClick = () => {
|
||||
setIsEditing(true);
|
||||
};
|
||||
|
||||
const handleBlur = () => {
|
||||
setIsEditing(false);
|
||||
if (value !== prop.value) {
|
||||
onChange(value);
|
||||
}
|
||||
};
|
||||
|
||||
const handleKeyDown = (e: React.KeyboardEvent) => {
|
||||
if (e.key === "Enter") {
|
||||
handleBlur();
|
||||
}
|
||||
if (e.key === "Escape") {
|
||||
setValue(prop.value);
|
||||
setIsEditing(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (readOnly) {
|
||||
if (!value) {
|
||||
return null;
|
||||
}
|
||||
return (
|
||||
<Link href={value} target="_blank" rel="noopener noreferrer" variant="body2" sx={{ wordBreak: "break-all" }}>
|
||||
{value}
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
|
||||
if (isEditing) {
|
||||
return (
|
||||
<NoLabelFilledTextField
|
||||
variant="filled"
|
||||
placeholder={t("application:fileManager.enterUrl")}
|
||||
disabled={loading}
|
||||
fullWidth
|
||||
autoFocus
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
onChange={(e) => setValue(e.target.value)}
|
||||
value={value ?? ""}
|
||||
onBlur={handleBlur}
|
||||
onKeyDown={handleKeyDown}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
if (!value) {
|
||||
return (
|
||||
<Typography variant="body2" color="text.secondary" sx={{ cursor: "pointer" }} onClick={handleEditClick}>
|
||||
{t("application:fileManager.clickToEdit")}
|
||||
</Typography>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Box
|
||||
sx={{ position: "relative", width: "100%" }}
|
||||
onMouseEnter={() => setIsHovered(true)}
|
||||
onMouseLeave={() => setIsHovered(false)}
|
||||
>
|
||||
<Link
|
||||
href={value}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
variant="body2"
|
||||
sx={{ wordBreak: "break-all", pr: isHovered ? 4 : 0 }}
|
||||
>
|
||||
{value}
|
||||
</Link>
|
||||
{isHovered && (
|
||||
<IconButton
|
||||
size="small"
|
||||
sx={{
|
||||
position: "absolute",
|
||||
right: 0,
|
||||
top: "50%",
|
||||
transform: "translateY(-50%)",
|
||||
opacity: 0.7,
|
||||
"&:hover": {
|
||||
opacity: 1,
|
||||
},
|
||||
}}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handleEditClick();
|
||||
}}
|
||||
>
|
||||
<Edit fontSize="small" />
|
||||
</IconButton>
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
export default LinkPropsContent;
|
||||
123
src/component/FileManager/Sidebar/CustomProps/MultiSelectPropsContent.tsx
Executable file
123
src/component/FileManager/Sidebar/CustomProps/MultiSelectPropsContent.tsx
Executable file
@@ -0,0 +1,123 @@
|
||||
import { Box, Chip, MenuItem, Select, styled, Typography } from "@mui/material";
|
||||
import { useEffect, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { PropsContentProps } from "./CustomPropsItem.tsx";
|
||||
|
||||
export const NoLabelFilledSelect = styled(Select)(({ theme }) => ({
|
||||
"& .MuiSelect-select": {
|
||||
paddingTop: theme.spacing(1),
|
||||
paddingBottom: theme.spacing(1),
|
||||
fontSize: theme.typography.body2.fontSize,
|
||||
"&.Mui-disabled": {
|
||||
borderBottomStyle: "none",
|
||||
"&::before": {
|
||||
borderBottomStyle: "none !important",
|
||||
},
|
||||
},
|
||||
},
|
||||
"&.MuiInputBase-root.MuiFilledInput-root.MuiSelect-root": {
|
||||
"&.Mui-disabled": {
|
||||
borderBottomStyle: "none",
|
||||
"&::before": {
|
||||
borderBottomStyle: "none !important",
|
||||
},
|
||||
},
|
||||
},
|
||||
}));
|
||||
|
||||
const MultiSelectPropsContent = ({ prop, onChange, loading, readOnly }: PropsContentProps) => {
|
||||
const { t } = useTranslation();
|
||||
const [values, setValues] = useState<string[]>(() => {
|
||||
if (prop.value) {
|
||||
try {
|
||||
return JSON.parse(prop.value);
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
return [];
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (prop.value) {
|
||||
try {
|
||||
setValues(JSON.parse(prop.value));
|
||||
} catch {
|
||||
setValues([]);
|
||||
}
|
||||
} else {
|
||||
setValues([]);
|
||||
}
|
||||
}, [prop.value]);
|
||||
|
||||
const handleChange = (selectedValues: string[]) => {
|
||||
setValues(selectedValues);
|
||||
const newValue = JSON.stringify(selectedValues);
|
||||
if (newValue !== prop.value) {
|
||||
onChange(newValue);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDelete = (valueToDelete: string) => {
|
||||
const newValues = values.filter((value) => value !== valueToDelete);
|
||||
handleChange(newValues);
|
||||
};
|
||||
|
||||
if (readOnly) {
|
||||
return (
|
||||
<Box sx={{ display: "flex", flexWrap: "wrap", gap: 0.5 }}>
|
||||
{values.length > 0
|
||||
? values.map((value, index) => <Chip key={index} label={value} size="small" variant="outlined" />)
|
||||
: ""}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
const options = prop.props.options || [];
|
||||
|
||||
return (
|
||||
<Box>
|
||||
<NoLabelFilledSelect
|
||||
variant="filled"
|
||||
fullWidth
|
||||
disabled={loading}
|
||||
value={values}
|
||||
onChange={(e) => handleChange(e.target.value as string[])}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
multiple
|
||||
displayEmpty
|
||||
renderValue={(selected) => {
|
||||
const selectedArray = Array.isArray(selected) ? selected : [];
|
||||
if (selectedArray.length === 0) {
|
||||
return (
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
{t("application:fileManager.clickToEditSelect")}
|
||||
</Typography>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<Box sx={{ display: "flex", flexWrap: "wrap", gap: 0.5 }}>
|
||||
{selectedArray.map((value, index) => (
|
||||
<Chip
|
||||
key={index}
|
||||
label={value}
|
||||
size="small"
|
||||
onDelete={() => handleDelete(value)}
|
||||
onMouseDown={(e) => e.stopPropagation()}
|
||||
/>
|
||||
))}
|
||||
</Box>
|
||||
);
|
||||
}}
|
||||
>
|
||||
{options.map((option) => (
|
||||
<MenuItem key={option} value={option} dense>
|
||||
{option}
|
||||
</MenuItem>
|
||||
))}
|
||||
</NoLabelFilledSelect>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
export default MultiSelectPropsContent;
|
||||
49
src/component/FileManager/Sidebar/CustomProps/NumberPropsContent.tsx
Executable file
49
src/component/FileManager/Sidebar/CustomProps/NumberPropsContent.tsx
Executable file
@@ -0,0 +1,49 @@
|
||||
import { Typography } from "@mui/material";
|
||||
import { useEffect, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { NoLabelFilledTextField } from "../../../Common/StyledComponents.tsx";
|
||||
import { PropsContentProps } from "./CustomPropsItem.tsx";
|
||||
|
||||
const NumberPropsContent = ({ prop, onChange, loading, readOnly }: PropsContentProps) => {
|
||||
const { t } = useTranslation();
|
||||
const [value, setValue] = useState(prop.value);
|
||||
|
||||
useEffect(() => {
|
||||
setValue(prop.value);
|
||||
}, [prop.value]);
|
||||
|
||||
const onBlur = () => {
|
||||
if (value !== prop.value) {
|
||||
onChange(value);
|
||||
}
|
||||
};
|
||||
|
||||
if (readOnly) {
|
||||
return <Typography variant="body2">{value}</Typography>;
|
||||
}
|
||||
|
||||
return (
|
||||
<NoLabelFilledTextField
|
||||
type="number"
|
||||
variant="filled"
|
||||
placeholder={t("application:fileManager.clickToEdit")}
|
||||
fullWidth
|
||||
disabled={loading}
|
||||
slotProps={{
|
||||
input: {
|
||||
inputProps: {
|
||||
max: prop.props.max,
|
||||
min: prop.props.min ?? 0,
|
||||
},
|
||||
},
|
||||
}}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
onChange={(e) => setValue(e.target.value)}
|
||||
value={value ?? ""}
|
||||
onBlur={onBlur}
|
||||
required
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export default NumberPropsContent;
|
||||
23
src/component/FileManager/Sidebar/CustomProps/RatingPropsItem.tsx
Executable file
23
src/component/FileManager/Sidebar/CustomProps/RatingPropsItem.tsx
Executable file
@@ -0,0 +1,23 @@
|
||||
import { Rating } from "@mui/material";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { PropsContentProps } from "./CustomPropsItem.tsx";
|
||||
|
||||
const RatingPropsItem = ({ prop, onChange, loading, readOnly }: PropsContentProps) => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const handleChange = (_: any, value: number | null) => {
|
||||
onChange(value?.toString() ?? "");
|
||||
};
|
||||
|
||||
return (
|
||||
<Rating
|
||||
readOnly={readOnly}
|
||||
disabled={loading}
|
||||
onChange={handleChange}
|
||||
value={parseInt(prop.value) ?? 0}
|
||||
max={prop.props.max ?? 5}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export default RatingPropsItem;
|
||||
78
src/component/FileManager/Sidebar/CustomProps/SelectPropsContent.tsx
Executable file
78
src/component/FileManager/Sidebar/CustomProps/SelectPropsContent.tsx
Executable file
@@ -0,0 +1,78 @@
|
||||
import { MenuItem, Select, styled, Typography } from "@mui/material";
|
||||
import { useEffect, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { PropsContentProps } from "./CustomPropsItem.tsx";
|
||||
|
||||
const NoLabelFilledSelect = styled(Select)(({ theme }) => ({
|
||||
"& .MuiSelect-select": {
|
||||
paddingTop: theme.spacing(1),
|
||||
paddingBottom: theme.spacing(1),
|
||||
fontSize: theme.typography.body2.fontSize,
|
||||
"&.Mui-disabled": {
|
||||
borderBottomStyle: "none",
|
||||
"&::before": {
|
||||
borderBottomStyle: "none !important",
|
||||
},
|
||||
},
|
||||
},
|
||||
"&.MuiInputBase-root.MuiFilledInput-root.MuiSelect-root": {
|
||||
"&.Mui-disabled": {
|
||||
borderBottomStyle: "none",
|
||||
"&::before": {
|
||||
borderBottomStyle: "none !important",
|
||||
},
|
||||
},
|
||||
},
|
||||
}));
|
||||
|
||||
const SelectPropsContent = ({ prop, onChange, loading, readOnly }: PropsContentProps) => {
|
||||
const { t } = useTranslation();
|
||||
const [value, setValue] = useState(prop.value || "");
|
||||
|
||||
useEffect(() => {
|
||||
setValue(prop.value || "");
|
||||
}, [prop.value]);
|
||||
|
||||
const handleChange = (selectedValue: string) => {
|
||||
setValue(selectedValue);
|
||||
if (selectedValue !== prop.value) {
|
||||
onChange(selectedValue);
|
||||
}
|
||||
};
|
||||
|
||||
if (readOnly) {
|
||||
return <Typography variant="body2">{value}</Typography>;
|
||||
}
|
||||
|
||||
const options = prop.props.options || [];
|
||||
|
||||
return (
|
||||
<NoLabelFilledSelect
|
||||
variant="filled"
|
||||
fullWidth
|
||||
disabled={loading}
|
||||
value={value}
|
||||
onChange={(e) => handleChange(e.target.value as string)}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
displayEmpty
|
||||
renderValue={(selected) => {
|
||||
if (!selected) {
|
||||
return (
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
{t("application:fileManager.clickToEditSelect")}
|
||||
</Typography>
|
||||
);
|
||||
}
|
||||
return selected as string;
|
||||
}}
|
||||
>
|
||||
{options.map((option) => (
|
||||
<MenuItem key={option} value={option} dense>
|
||||
{option}
|
||||
</MenuItem>
|
||||
))}
|
||||
</NoLabelFilledSelect>
|
||||
);
|
||||
};
|
||||
|
||||
export default SelectPropsContent;
|
||||
48
src/component/FileManager/Sidebar/CustomProps/TextPropsContent.tsx
Executable file
48
src/component/FileManager/Sidebar/CustomProps/TextPropsContent.tsx
Executable file
@@ -0,0 +1,48 @@
|
||||
import { Typography } from "@mui/material";
|
||||
import { useEffect, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { NoLabelFilledTextField } from "../../../Common/StyledComponents.tsx";
|
||||
import { PropsContentProps } from "./CustomPropsItem.tsx";
|
||||
|
||||
const TextPropsContent = ({ prop, onChange, loading, readOnly }: PropsContentProps) => {
|
||||
const { t } = useTranslation();
|
||||
const [value, setValue] = useState(prop.value);
|
||||
|
||||
useEffect(() => {
|
||||
setValue(prop.value);
|
||||
}, [prop.value]);
|
||||
|
||||
const onBlur = () => {
|
||||
if (value !== prop.value) {
|
||||
onChange(value);
|
||||
}
|
||||
};
|
||||
|
||||
if (readOnly) {
|
||||
return <Typography variant="body2">{value}</Typography>;
|
||||
}
|
||||
|
||||
return (
|
||||
<NoLabelFilledTextField
|
||||
variant="filled"
|
||||
placeholder={t("application:fileManager.clickToEdit")}
|
||||
disabled={loading}
|
||||
fullWidth
|
||||
slotProps={{
|
||||
input: {
|
||||
inputProps: {
|
||||
maxLength: prop.props.max,
|
||||
},
|
||||
},
|
||||
}}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
onChange={(e) => setValue(e.target.value)}
|
||||
value={value ?? ""}
|
||||
onBlur={onBlur}
|
||||
required
|
||||
multiline
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export default TextPropsContent;
|
||||
112
src/component/FileManager/Sidebar/Data.tsx
Executable file
112
src/component/FileManager/Sidebar/Data.tsx
Executable file
@@ -0,0 +1,112 @@
|
||||
import { Link, Table, TableBody, TableCell, TableContainer, TableHead, TableRow, Typography } from "@mui/material";
|
||||
import { useCallback, useMemo } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { EntityType, FileResponse } from "../../../api/explorer.ts";
|
||||
import { setVersionControlDialog } from "../../../redux/globalStateSlice.ts";
|
||||
import { useAppDispatch } from "../../../redux/hooks.ts";
|
||||
import { downloadSingleFile } from "../../../redux/thunks/download.ts";
|
||||
import { sizeToString } from "../../../util";
|
||||
import { NoWrapTableCell, StyledTableContainerPaper } from "../../Common/StyledComponents.tsx";
|
||||
import TimeBadge from "../../Common/TimeBadge.tsx";
|
||||
|
||||
export interface DataProps {
|
||||
target: FileResponse;
|
||||
}
|
||||
|
||||
export const EntityTypeText: Record<EntityType, string> = {
|
||||
[EntityType.thumbnail]: "application:fileManager.thumbnails",
|
||||
[EntityType.live_photo]: "application:fileManager.livePhoto",
|
||||
[EntityType.version]: "application:fileManager.version",
|
||||
};
|
||||
|
||||
const Data = ({ target }: DataProps) => {
|
||||
const { t } = useTranslation();
|
||||
const dispatch = useAppDispatch();
|
||||
|
||||
const downloadEntity = useCallback(
|
||||
(entityId: string) => {
|
||||
dispatch(downloadSingleFile(target, entityId));
|
||||
},
|
||||
[target, dispatch],
|
||||
);
|
||||
|
||||
const versionSizes = useMemo(() => {
|
||||
let size = 0;
|
||||
let notFound = true;
|
||||
target.extended_info?.entities?.forEach((entity) => {
|
||||
if (entity.type === EntityType.version) {
|
||||
size += entity.size;
|
||||
notFound = false;
|
||||
}
|
||||
});
|
||||
|
||||
return notFound ? undefined : size;
|
||||
}, [target.extended_info?.entities]);
|
||||
|
||||
if (!target.extended_info?.entities) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<Typography sx={{ pt: 1 }} color="textPrimary" fontWeight={500} variant={"subtitle1"}>
|
||||
{t("application:fileManager.data")}
|
||||
</Typography>
|
||||
<TableContainer component={StyledTableContainerPaper}>
|
||||
<Table sx={{ width: "100%" }} size="small">
|
||||
<TableHead>
|
||||
<TableRow>
|
||||
<NoWrapTableCell>{t("fileManager.type")}</NoWrapTableCell>
|
||||
<NoWrapTableCell>{t("fileManager.size")}</NoWrapTableCell>
|
||||
<NoWrapTableCell>{t("fileManager.createdAt")}</NoWrapTableCell>
|
||||
<NoWrapTableCell>{t("fileManager.storagePolicy")}</NoWrapTableCell>
|
||||
<NoWrapTableCell>{t("fileManager.actions")}</NoWrapTableCell>
|
||||
</TableRow>
|
||||
</TableHead>
|
||||
<TableBody>
|
||||
{versionSizes != undefined && (
|
||||
<TableRow hover sx={{ "&:last-child td, &:last-child th": { border: 0 } }}>
|
||||
<NoWrapTableCell component="th" scope="row">
|
||||
{t("fileManager.versionEntity")}
|
||||
</NoWrapTableCell>
|
||||
<NoWrapTableCell>{sizeToString(versionSizes)}</NoWrapTableCell>
|
||||
<TableCell>-</TableCell>
|
||||
<TableCell>-</TableCell>
|
||||
<TableCell>
|
||||
<Link
|
||||
href={"#"}
|
||||
onClick={() => dispatch(setVersionControlDialog({ open: true, file: target }))}
|
||||
underline={"hover"}
|
||||
>
|
||||
{t("fileManager.manageVersions")}
|
||||
</Link>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)}
|
||||
{target.extended_info?.entities
|
||||
?.filter((e) => e.type != EntityType.version)
|
||||
.map((e) => (
|
||||
<TableRow hover sx={{ "&:last-child td, &:last-child th": { border: 0 } }}>
|
||||
<NoWrapTableCell component="th" scope="row">
|
||||
{t(EntityTypeText[e.type as EntityType])}
|
||||
</NoWrapTableCell>
|
||||
<NoWrapTableCell>{sizeToString(e.size)}</NoWrapTableCell>
|
||||
<TableCell>
|
||||
<TimeBadge variant={"body2"} datetime={e.created_at} />
|
||||
</TableCell>
|
||||
<NoWrapTableCell>{e.storage_policy?.name}</NoWrapTableCell>
|
||||
<NoWrapTableCell>
|
||||
<Link href={"#"} underline={"hover"} onClick={() => downloadEntity(e.id)}>
|
||||
{t("fileManager.download")}
|
||||
</Link>
|
||||
</NoWrapTableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</TableContainer>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default Data;
|
||||
68
src/component/FileManager/Sidebar/Details.tsx
Executable file
68
src/component/FileManager/Sidebar/Details.tsx
Executable file
@@ -0,0 +1,68 @@
|
||||
import { Box, Stack, Typography } from "@mui/material";
|
||||
import { useEffect, useState } from "react";
|
||||
import { FileResponse, FileType, Metadata } from "../../../api/explorer.ts";
|
||||
import { useAppDispatch } from "../../../redux/hooks.ts";
|
||||
import { loadFileThumb } from "../../../redux/thunks/file.ts";
|
||||
import { FileManagerIndex } from "../FileManager.tsx";
|
||||
import BasicInfo from "./BasicInfo.tsx";
|
||||
import CustomProps from "./CustomProps/CustomProps.tsx";
|
||||
import Data from "./Data.tsx";
|
||||
import MediaInfo from "./MediaInfo.tsx";
|
||||
import Tags from "./Tags.tsx";
|
||||
import { DisplayOption } from "../ContextMenu/useActionDisplayOpt.ts";
|
||||
|
||||
export interface DetailsProps {
|
||||
inPhotoViewer?: boolean;
|
||||
target: FileResponse;
|
||||
setTarget: (target: FileResponse | undefined | null) => void;
|
||||
targetDisplayOptions?: DisplayOption;
|
||||
}
|
||||
|
||||
const InfoBlock = ({ title, children }: { title: string; children: React.ReactNode }) => {
|
||||
return (
|
||||
<Box>
|
||||
<Typography variant={"body2"}>{title}</Typography>
|
||||
<Typography variant={"body2"} color={"text.secondary"}>
|
||||
{children}
|
||||
</Typography>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
const Details = ({ target, inPhotoViewer, setTarget, targetDisplayOptions }: DetailsProps) => {
|
||||
const dispatch = useAppDispatch();
|
||||
const [thumbSrc, setThumbSrc] = useState<string | null>(null);
|
||||
useEffect(() => {
|
||||
if (target.type == FileType.file && (!target.metadata || target.metadata[Metadata.thumbDisabled] === undefined)) {
|
||||
dispatch(loadFileThumb(FileManagerIndex.main, target)).then((src) => {
|
||||
setThumbSrc(src);
|
||||
});
|
||||
}
|
||||
|
||||
setThumbSrc(null);
|
||||
}, [target]);
|
||||
|
||||
return (
|
||||
<Stack spacing={1}>
|
||||
{thumbSrc && !inPhotoViewer && (
|
||||
<Box
|
||||
onError={() => {
|
||||
setThumbSrc(null);
|
||||
}}
|
||||
src={thumbSrc}
|
||||
sx={{
|
||||
borderRadius: "8px",
|
||||
}}
|
||||
component={"img"}
|
||||
/>
|
||||
)}
|
||||
<MediaInfo target={target} />
|
||||
<CustomProps file={target} setTarget={setTarget} targetDisplayOptions={targetDisplayOptions} />
|
||||
<BasicInfo target={target} />
|
||||
<Tags target={target} />
|
||||
<Data target={target} />
|
||||
</Stack>
|
||||
);
|
||||
};
|
||||
|
||||
export default Details;
|
||||
43
src/component/FileManager/Sidebar/Header.tsx
Executable file
43
src/component/FileManager/Sidebar/Header.tsx
Executable file
@@ -0,0 +1,43 @@
|
||||
import { Box, IconButton, Skeleton, Typography } from "@mui/material";
|
||||
import { FileResponse } from "../../../api/explorer.ts";
|
||||
import { closeShareReadme, closeSidebar } from "../../../redux/globalStateSlice.ts";
|
||||
import { useAppDispatch } from "../../../redux/hooks.ts";
|
||||
import Dismiss from "../../Icons/Dismiss.tsx";
|
||||
import FileIcon from "../Explorer/FileIcon.tsx";
|
||||
|
||||
export interface HeaderProps {
|
||||
target: FileResponse | undefined | null;
|
||||
variant?: "readme";
|
||||
}
|
||||
const Header = ({ target, variant }: HeaderProps) => {
|
||||
const dispatch = useAppDispatch();
|
||||
return (
|
||||
<Box sx={{ display: "flex", p: 2 }}>
|
||||
{target !== null && <FileIcon sx={{ p: 0 }} loading={target == undefined} file={target} type={target?.type} />}
|
||||
{target !== null && (
|
||||
<Box sx={{ flexGrow: 1, ml: 1.5 }}>
|
||||
<Typography color="textPrimary" sx={{ wordBreak: "break-all" }} variant={"subtitle2"}>
|
||||
{target && target.name}
|
||||
{!target && <Skeleton variant={"text"} width={75} />}
|
||||
</Typography>
|
||||
</Box>
|
||||
)}
|
||||
<IconButton
|
||||
onClick={() => {
|
||||
dispatch(variant == "readme" ? closeShareReadme() : closeSidebar());
|
||||
}}
|
||||
sx={{
|
||||
ml: 1,
|
||||
placeSelf: "flex-start",
|
||||
position: "relative",
|
||||
top: "-4px",
|
||||
}}
|
||||
size={"small"}
|
||||
>
|
||||
<Dismiss fontSize={"small"} />
|
||||
</IconButton>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
export default Header;
|
||||
22
src/component/FileManager/Sidebar/InfoRow.tsx
Executable file
22
src/component/FileManager/Sidebar/InfoRow.tsx
Executable file
@@ -0,0 +1,22 @@
|
||||
import { Box, Typography } from "@mui/material";
|
||||
import React from "react";
|
||||
|
||||
export interface InfoRowProps {
|
||||
title: string;
|
||||
content: React.ReactNode | string;
|
||||
}
|
||||
|
||||
const InfoRow = ({ title, content }: InfoRowProps) => {
|
||||
return (
|
||||
<Box>
|
||||
<Typography variant={"body2"} color="textPrimary" fontWeight={500}>
|
||||
{title}
|
||||
</Typography>
|
||||
<Typography variant={"body2"} color={"text.secondary"}>
|
||||
{content}
|
||||
</Typography>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
export default InfoRow;
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user