first commit

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View 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;

View 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;

View 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;

View 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;

View 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;

View 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;

View 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;

View 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;

View 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;

View 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;

View 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;

View 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;

View 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;

View 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;

View 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;

View 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;

View 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;

View 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;

View 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;

View 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;

View 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;

View 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;

View 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;

View 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;

View 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;

View 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;

View 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;

View 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;

View 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;

View 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;

View 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;

View 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;

View 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;

View 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;

View 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;

View 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;

View 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;

View 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;

View 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;

View 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;

View 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;

View 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;

View 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;

View 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;

View 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;

View 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;

View 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;

View 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;

View 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;

View 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;

View 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;

View 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>
);
};

View File

@@ -0,0 +1,3 @@
import { createContext } from "react";
export const FmIndexContext = createContext(0);

View 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;

View 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;

View 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;

View 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;

View 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 />;
};

View 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;

View 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;

View 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;

View 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;

View 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;

View 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;

View 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>
);
};

View 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>
);
};

View 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>
</>
);
};

View 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>
);
};

View 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>
);
};

View 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",
},
},
}}
/>
);
};

View 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>
);
};

View 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
/>
)}
/>
</>
);
};

View 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;

View 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;

View 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>
);
};

View 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;

View 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;

View 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;

View 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;

View 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;

View 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;

View 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;

View 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;

View 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;

View 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;

View 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;

View 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;

View 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;

View 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;

View 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;

View 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