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,70 @@
import { Checkbox, DialogContent, FormGroup, Stack, Tooltip } from "@mui/material";
import { useCallback, useState } from "react";
import { useTranslation } from "react-i18next";
import { batchDeleteEntities } from "../../../api/api.ts";
import { useAppDispatch } from "../../../redux/hooks.ts";
import { SmallFormControlLabel } from "../../Common/StyledComponents.tsx";
import DialogAccordion from "../../Dialogs/DialogAccordion.tsx";
import DraggableDialog, { StyledDialogContentText } from "../../Dialogs/DraggableDialog.tsx";
export interface EntityDeleteDialogProps {
entityID?: number[];
open: boolean;
onClose?: () => void;
onDelete?: () => void;
}
const EntityDeleteDialog = ({ entityID, open, onDelete, onClose }: EntityDeleteDialogProps) => {
const { t } = useTranslation("dashboard");
const dispatch = useAppDispatch();
const [force, setForce] = useState(false);
const [deleting, setDeleting] = useState(false);
const onAccept = useCallback(() => {
if (entityID) {
setDeleting?.(true);
dispatch(batchDeleteEntities({ ids: entityID, force }))
.then(() => {
onDelete?.();
onClose?.();
})
.finally(() => {
setDeleting?.(false);
});
}
}, [entityID, force, setDeleting]);
return (
<DraggableDialog
title={t("common:areYouSure")}
showActions
loading={deleting}
showCancel
onAccept={onAccept}
dialogProps={{
open: open ?? false,
onClose: onClose,
fullWidth: true,
maxWidth: "xs",
}}
>
<DialogContent>
<Stack spacing={2}>
<StyledDialogContentText>{t("entity.confirmBatchDelete", { num: entityID?.length })}</StyledDialogContentText>
<DialogAccordion defaultExpanded={force} title={t("application:modals.advanceOptions")}>
<FormGroup>
<Tooltip title={t("entity.forceDeleteDes")}>
<SmallFormControlLabel
control={<Checkbox size="small" onChange={(e) => setForce(e.target.checked)} checked={force} />}
label={t("entity.forceDelete")}
/>
</Tooltip>
</FormGroup>
</DialogAccordion>
</Stack>
</DialogContent>
</DraggableDialog>
);
};
export default EntityDeleteDialog;

View File

@@ -0,0 +1,84 @@
import { Box, DialogContent } from "@mui/material";
import { useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import { CSSTransition, SwitchTransition } from "react-transition-group";
import { getEntityDetail } from "../../../../api/api.ts";
import { Entity } from "../../../../api/dashboard.ts";
import { useAppDispatch } from "../../../../redux/hooks.ts";
import AutoHeight from "../../../Common/AutoHeight.tsx";
import FacebookCircularProgress from "../../../Common/CircularProgress.tsx";
import DraggableDialog from "../../../Dialogs/DraggableDialog.tsx";
import EntityForm from "./EntityForm.tsx";
export interface EntityDialogProps {
open: boolean;
onClose: () => void;
entityID?: number;
}
const EntityDialog = ({ open, onClose, entityID }: EntityDialogProps) => {
const dispatch = useAppDispatch();
const { t } = useTranslation("dashboard");
const [values, setValues] = useState<Entity>({ edges: {}, id: 0 });
const [loading, setLoading] = useState(true);
useEffect(() => {
if (!entityID || !open) {
return;
}
setLoading(true);
dispatch(getEntityDetail(entityID))
.then((res) => {
setValues(res);
})
.catch(() => {
onClose();
})
.finally(() => {
setLoading(false);
});
}, [open]);
return (
<DraggableDialog
title={t("entity.entityDialogTitle")}
dialogProps={{
fullWidth: true,
maxWidth: "md",
open: open,
onClose: onClose,
}}
>
<DialogContent>
<AutoHeight>
<SwitchTransition>
<CSSTransition
addEndListener={(node, done) => node.addEventListener("transitionend", done, false)}
classNames="fade"
key={`${loading}`}
>
<Box>
{loading && (
<Box
sx={{
py: 15,
height: "100%",
display: "flex",
justifyContent: "center",
alignItems: "center",
}}
>
<FacebookCircularProgress />
</Box>
)}
{!loading && <EntityForm values={values} />}
</Box>
</CSSTransition>
</SwitchTransition>
</AutoHeight>
</DialogContent>
</DraggableDialog>
);
};
export default EntityDialog;

View File

@@ -0,0 +1,119 @@
import { Box, Link, Table, TableBody, TableCell, TableContainer, TableHead, TableRow } from "@mui/material";
import { useState } from "react";
import { useTranslation } from "react-i18next";
import { File } from "../../../../api/dashboard";
import { FileType } from "../../../../api/explorer";
import { sizeToString } from "../../../../util";
import {
NoWrapCell,
NoWrapTableCell,
NoWrapTypography,
StyledTableContainerPaper,
} from "../../../Common/StyledComponents";
import TimeBadge from "../../../Common/TimeBadge";
import UserAvatar from "../../../Common/User/UserAvatar";
import FileTypeIcon from "../../../FileManager/Explorer/FileTypeIcon";
import FileDialog from "../../File/FileDialog/FileDialog";
import UserDialog from "../../User/UserDialog/UserDialog";
const EntityFileList = ({ files, userHashIDMap }: { files: File[]; userHashIDMap: Record<number, string> }) => {
const { t } = useTranslation("dashboard");
const [userDialogOpen, setUserDialogOpen] = useState(false);
const [userDialogID, setUserDialogID] = useState<number>(0);
const [fileDialogOpen, setFileDialogOpen] = useState(false);
const [fileDialogID, setFileDialogID] = useState<number>(0);
const userClicked = (uid: number) => (e: React.MouseEvent<HTMLElement>) => {
e.stopPropagation();
e.preventDefault();
setUserDialogOpen(true);
setUserDialogID(uid);
};
const fileClicked = (fid: number) => (e: React.MouseEvent<HTMLElement>) => {
e.stopPropagation();
e.preventDefault();
setFileDialogOpen(true);
setFileDialogID(fid);
};
return (
<>
<UserDialog open={userDialogOpen} onClose={() => setUserDialogOpen(false)} userID={userDialogID} />
<FileDialog open={fileDialogOpen} onClose={() => setFileDialogOpen(false)} fileID={fileDialogID} />
<TableContainer component={StyledTableContainerPaper} sx={{ maxHeight: "300px" }}>
<Table size="small" stickyHeader sx={{ width: "100%", tableLayout: "fixed" }}>
<TableHead>
<TableRow>
<NoWrapTableCell width={90}>{t("group.#")}</NoWrapTableCell>
<NoWrapTableCell width={200}>{t("file.name")}</NoWrapTableCell>
<NoWrapTableCell width={100}>{t("file.size")}</NoWrapTableCell>
<NoWrapTableCell width={100}>{t("file.uploader")}</NoWrapTableCell>
<NoWrapTableCell width={200}>{t("file.createdAt")}</NoWrapTableCell>
</TableRow>
</TableHead>
<TableBody>
{files?.map((option, index) => {
return (
<TableRow key={option.id} hover sx={{ cursor: "pointer" }} onClick={fileClicked(option.id ?? 0)}>
<TableCell>
<NoWrapTypography variant="inherit">{option.id}</NoWrapTypography>
</TableCell>
<NoWrapTableCell>
<Box sx={{ display: "flex", alignItems: "center", gap: 1 }}>
<FileTypeIcon name={option.name ?? ""} fileType={FileType.file} />
<NoWrapTypography variant="inherit">{option.name}</NoWrapTypography>
</Box>
</NoWrapTableCell>
<TableCell>
<NoWrapTypography variant="inherit">{sizeToString(option.size ?? 0)}</NoWrapTypography>
</TableCell>
<NoWrapTableCell>
<Box sx={{ display: "flex", alignItems: "center", gap: 1 }}>
<UserAvatar
sx={{ width: 24, height: 24 }}
overwriteTextSize
user={{
id: userHashIDMap[option.owner_id ?? 0] ?? "",
nickname: option.edges?.owner?.nick ?? "",
created_at: option.edges?.owner?.created_at ?? "",
}}
/>
<NoWrapTypography variant="inherit">
<Link
sx={{
whiteSpace: "nowrap",
overflow: "hidden",
textOverflow: "ellipsis",
}}
onClick={userClicked(option.owner_id ?? 0)}
underline="hover"
href="#/"
>
{option.edges?.owner?.nick}
</Link>
</NoWrapTypography>
</Box>
</NoWrapTableCell>
<TableCell>
<NoWrapTypography variant="inherit">
<TimeBadge datetime={option.created_at ?? ""} variant="inherit" timeAgoThreshold={0} />
</NoWrapTypography>
</TableCell>
</TableRow>
);
})}
{!files?.length && (
<TableRow>
<NoWrapCell colSpan={5} align="center">
{t("file.noRecords")}
</NoWrapCell>
</TableRow>
)}
</TableBody>
</Table>
</TableContainer>
</>
);
};
export default EntityFileList;

View File

@@ -0,0 +1,114 @@
import { Box, Grid2 as Grid, Link, Typography, useMediaQuery, useTheme } from "@mui/material";
import { useState } from "react";
import { useTranslation } from "react-i18next";
import { Link as RouterLink } from "react-router-dom";
import { Entity } from "../../../../api/dashboard";
import { EntityType } from "../../../../api/explorer";
import { useAppDispatch } from "../../../../redux/hooks";
import { sizeToString } from "../../../../util";
import { NoWrapTypography } from "../../../Common/StyledComponents";
import UserAvatar from "../../../Common/User/UserAvatar";
import { EntityTypeText } from "../../../FileManager/Sidebar/Data";
import SettingForm from "../../../Pages/Setting/SettingForm";
import UserDialog from "../../User/UserDialog/UserDialog";
import EntityFileList from "./EntityFileList";
const EntityForm = ({ values }: { values: Entity }) => {
const dispatch = useAppDispatch();
const theme = useTheme();
const isMobile = useMediaQuery(theme.breakpoints.down("md"));
const { t } = useTranslation("dashboard");
const [userDialogOpen, setUserDialogOpen] = useState(false);
const [userDialogID, setUserDialogID] = useState<number>(0);
const userClicked = (e: React.MouseEvent<HTMLElement>) => {
e.preventDefault();
setUserDialogOpen(true);
setUserDialogID(values?.edges?.user?.id ?? 0);
};
return (
<>
<UserDialog open={userDialogOpen} onClose={() => setUserDialogOpen(false)} userID={userDialogID} />
<Box>
<Grid container spacing={isMobile ? 2 : 3} alignItems={"stretch"}>
<SettingForm title={t("file.id")} noContainer lgWidth={2}>
<Typography variant={"body2"} color={"textSecondary"}>
{values.id}
</Typography>
</SettingForm>
<SettingForm title={t("file.size")} noContainer lgWidth={2}>
<Typography variant={"body2"} color={"textSecondary"}>
{sizeToString(values.size ?? 0)}
</Typography>
</SettingForm>
<SettingForm title={t("file.blobType")} noContainer lgWidth={2}>
<Typography variant={"body2"} color={"textSecondary"}>
{t(EntityTypeText[values.type ?? EntityType.version])}
</Typography>
</SettingForm>
<SettingForm title={t("entity.refenenceCount")} noContainer lgWidth={2}>
<Typography variant={"body2"} color={"textSecondary"}>
{values.reference_count ?? 0}
</Typography>
</SettingForm>
<SettingForm title={t("file.creator")} noContainer lgWidth={4}>
<NoWrapTypography variant={"body2"} color={"textSecondary"}>
<Box sx={{ display: "flex", alignItems: "center", gap: 1 }}>
<UserAvatar
sx={{ width: 24, height: 24 }}
overwriteTextSize
user={{
id: values?.user_hash_id ?? "",
nickname: values?.edges?.user?.nick ?? "",
created_at: values?.edges?.user?.created_at ?? "",
}}
/>
<NoWrapTypography variant="inherit">
<Link
sx={{
whiteSpace: "nowrap",
overflow: "hidden",
textOverflow: "ellipsis",
}}
onClick={userClicked}
underline="hover"
href="#/"
>
{values?.edges?.user?.nick}
</Link>
</NoWrapTypography>
</Box>
</NoWrapTypography>
</SettingForm>
<SettingForm title={t("entity.uploadSessionID")} noContainer lgWidth={4}>
<Typography variant={"body2"} color={"textSecondary"} sx={{ wordBreak: "break-all" }}>
{values.upload_session_id ?? "-"}
</Typography>
</SettingForm>
<SettingForm title={t("file.source")} noContainer lgWidth={4}>
<Typography variant={"body2"} color={"textSecondary"} sx={{ wordBreak: "break-all" }}>
{values.source ?? "-"}
</Typography>
</SettingForm>
<SettingForm title={t("file.storagePolicy")} noContainer lgWidth={4}>
<Typography variant={"body2"} color={"textSecondary"}>
<Link
component={RouterLink}
underline="hover"
to={`/admin/policy/${values.edges?.storage_policy?.id}`}
target="_blank"
>
{values.edges?.storage_policy?.name}
</Link>
</Typography>
</SettingForm>
<SettingForm title={t("entity.referredFiles")} noContainer lgWidth={12}>
<EntityFileList files={values.edges?.file ?? []} userHashIDMap={values.user_hash_id_map ?? {}} />
</SettingForm>
</Grid>
</Box>
</>
);
};
export default EntityForm;

View File

@@ -0,0 +1,153 @@
import { Box, Button, ListItemText, Popover, PopoverProps, Stack } from "@mui/material";
import { useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import { EntityType } from "../../../api/explorer";
import { DenseFilledTextField, DenseSelect } from "../../Common/StyledComponents";
import { SquareMenuItem } from "../../FileManager/ContextMenu/ContextMenu";
import { EntityTypeText } from "../../FileManager/Sidebar/Data";
import SettingForm from "../../Pages/Setting/SettingForm";
import SinglePolicySelectionInput from "../Common/SinglePolicySelectionInput";
export interface EntityFilterPopoverProps extends PopoverProps {
storagePolicy: string;
setStoragePolicy: (storagePolicy: string) => void;
owner: string;
setOwner: (owner: string) => void;
type?: EntityType;
setType: (type?: EntityType) => void;
clearFilters: () => void;
}
const EntityFilterPopover = ({
storagePolicy,
setStoragePolicy,
owner,
setOwner,
type,
setType,
clearFilters,
onClose,
open,
...rest
}: EntityFilterPopoverProps) => {
const { t } = useTranslation("dashboard");
// Create local state to track changes before applying
const [localStoragePolicy, setLocalStoragePolicy] = useState(storagePolicy);
const [localOwner, setLocalOwner] = useState(owner);
const [localType, setLocalType] = useState(type);
// Initialize local state when popup opens
useEffect(() => {
if (open) {
setLocalStoragePolicy(storagePolicy);
setLocalOwner(owner);
setLocalType(type);
}
}, [open]);
// Apply filters and close popover
const handleApplyFilters = () => {
setStoragePolicy(localStoragePolicy);
setOwner(localOwner);
setType(localType);
onClose?.({}, "backdropClick");
};
// Reset filters and close popover
const handleResetFilters = () => {
setLocalStoragePolicy("");
setLocalOwner("");
setLocalType(undefined);
clearFilters();
onClose?.({}, "backdropClick");
};
return (
<Popover
anchorOrigin={{
vertical: "bottom",
horizontal: "left",
}}
transformOrigin={{
vertical: "top",
horizontal: "left",
}}
slotProps={{
paper: {
sx: {
p: 2,
width: 300,
maxWidth: "100%",
},
},
}}
onClose={onClose}
open={open}
{...rest}
>
<Stack spacing={2}>
<SettingForm title={t("file.uploaderID")} noContainer lgWidth={12}>
<DenseFilledTextField
fullWidth
value={localOwner}
onChange={(e) => setLocalOwner(e.target.value)}
placeholder={t("user.emptyNoFilter")}
size="small"
/>
</SettingForm>
<SettingForm title={t("file.blobType")} noContainer lgWidth={12}>
<DenseSelect
fullWidth
displayEmpty
value={localType != undefined ? localType : -1}
onChange={(e) => setLocalType(e.target.value === -1 ? undefined : (e.target.value as EntityType))}
>
{[EntityType.version, EntityType.thumbnail, EntityType.live_photo].map((type) => (
<SquareMenuItem key={type} value={type}>
<ListItemText
primary={t(EntityTypeText[type])}
slotProps={{
primary: {
variant: "body2",
},
}}
/>
</SquareMenuItem>
))}
<SquareMenuItem value={-1}>
<ListItemText
primary={<em>{t("user.all")}</em>}
slotProps={{
primary: {
variant: "body2",
},
}}
/>
</SquareMenuItem>
</DenseSelect>
</SettingForm>
<SettingForm title={t("file.storagePolicy")} noContainer lgWidth={12}>
<SinglePolicySelectionInput
value={localStoragePolicy == "" ? -1 : parseInt(localStoragePolicy)}
onChange={(value) => setLocalStoragePolicy(value.toString())}
emptyValue={-1}
emptyText={t("user.all")}
/>
</SettingForm>
<Box display="flex" justifyContent="space-between">
<Button variant="outlined" size="small" onClick={handleResetFilters}>
{t("user.reset")}
</Button>
<Button variant="contained" size="small" onClick={handleApplyFilters}>
{t("user.apply")}
</Button>
</Box>
</Stack>
</Popover>
);
};
export default EntityFilterPopover;

View File

@@ -0,0 +1,218 @@
import { Box, Checkbox, IconButton, Link, Skeleton, TableCell, TableRow, Tooltip } from "@mui/material";
import { useState } from "react";
import { useTranslation } from "react-i18next";
import { Link as RouterLink } from "react-router-dom";
import { getEntityUrl } from "../../../api/api";
import { Entity } from "../../../api/dashboard";
import { EntityType } from "../../../api/explorer";
import { useAppDispatch } from "../../../redux/hooks";
import { sizeToString } from "../../../util";
import { NoWrapTableCell, NoWrapTypography, SquareChip } from "../../Common/StyledComponents";
import TimeBadge from "../../Common/TimeBadge";
import UserAvatar from "../../Common/User/UserAvatar";
import { EntityTypeText } from "../../FileManager/Sidebar/Data";
import Delete from "../../Icons/Delete";
import Download from "../../Icons/Download";
export interface EntityRowProps {
entity?: Entity;
loading?: boolean;
selected?: boolean;
onDelete?: (id: number) => void;
onSelect?: (id: number) => void;
openEntityDialog?: (id: number) => void;
openUserDialog?: (id: number) => void;
}
const EntityRow = ({
entity,
loading,
selected,
onDelete,
onSelect,
openUserDialog,
openEntityDialog,
}: EntityRowProps) => {
const { t } = useTranslation("dashboard");
const dispatch = useAppDispatch();
const [deleteLoading, setDeleteLoading] = useState(false);
const [openLoading, setOpenLoading] = useState(false);
const onSelectClick = (e: React.MouseEvent<HTMLElement>) => {
e.stopPropagation();
e.preventDefault();
onSelect?.(entity?.id ?? 0);
};
const onOpenClick = (e: React.MouseEvent<HTMLButtonElement>) => {
e.stopPropagation();
setOpenLoading(true);
dispatch(getEntityUrl(entity?.id ?? 0))
.then((url) => {
// 直接下载文件使用a标签的download属性强制下载
const link = document.createElement('a');
link.href = url;
link.download = `entity-${entity?.id}`;
link.style.display = 'none';
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
})
.finally(() => {
setOpenLoading(false);
})
.catch((error) => {
console.error('Failed to get entity URL:', error);
});
};
const userClicked = (e: React.MouseEvent<HTMLElement>) => {
e.stopPropagation();
e.preventDefault();
openUserDialog?.(entity?.edges?.user?.id ?? 0);
};
const onDeleteClick = (e: React.MouseEvent<HTMLButtonElement>) => {
e.stopPropagation();
e.preventDefault();
onDelete?.(entity?.id ?? 0);
};
if (loading) {
return (
<TableRow sx={{ height: "43px" }}>
<NoWrapTableCell>
<Skeleton variant="circular" width={24} height={24} />
</NoWrapTableCell>
<NoWrapTableCell>
<Skeleton variant="text" width={30} />
</NoWrapTableCell>
<NoWrapTableCell>
<Skeleton variant="text" width={80} />
</NoWrapTableCell>
<NoWrapTableCell>
<Skeleton variant="text" width={200} />
</NoWrapTableCell>
<NoWrapTableCell>
<Skeleton variant="text" width={50} />
</NoWrapTableCell>
<NoWrapTableCell>
<Skeleton variant="text" width={100} />
</NoWrapTableCell>
<NoWrapTableCell>
<Skeleton variant="text" width={30} />
</NoWrapTableCell>
<NoWrapTableCell>
<Skeleton variant="text" width={100} />
</NoWrapTableCell>
<NoWrapTableCell>
<Box sx={{ display: "flex", alignItems: "center", gap: 1 }}>
<Skeleton variant="circular" width={24} height={24} />
<Skeleton variant="text" width={100} />
</Box>
</NoWrapTableCell>
<NoWrapTableCell>
<Skeleton variant="circular" width={24} height={24} />
</NoWrapTableCell>
</TableRow>
);
}
return (
<TableRow
hover
key={entity?.id}
sx={{ cursor: "pointer" }}
onClick={() => openEntityDialog?.(entity?.id ?? 0)}
selected={selected}
>
<TableCell padding="checkbox">
<Checkbox size="small" disableRipple color="primary" onClick={onSelectClick} checked={selected} />
</TableCell>
<NoWrapTableCell>
<NoWrapTypography variant="inherit">{entity?.id}</NoWrapTypography>
</NoWrapTableCell>
<NoWrapTableCell>
<NoWrapTypography variant="inherit">{t(EntityTypeText[entity?.type ?? EntityType.version])}</NoWrapTypography>
</NoWrapTableCell>
<NoWrapTableCell>
<Box sx={{ display: "flex", alignItems: "center", gap: 1 }}>
<Tooltip title={entity?.source || ""}>
<NoWrapTypography variant="inherit">{entity?.source || "-"}</NoWrapTypography>
</Tooltip>
<Box sx={{ display: "flex", alignItems: "center", gap: 0.5 }}>
{!entity?.reference_count && <SquareChip size="small" label={t("entity.waitForRecycle")} />}
</Box>
</Box>
</NoWrapTableCell>
<NoWrapTableCell>
<NoWrapTypography variant="inherit">{sizeToString(entity?.size ?? 0)}</NoWrapTypography>
</NoWrapTableCell>
<NoWrapTableCell>
<NoWrapTypography variant="inherit">
<Link
sx={{
whiteSpace: "nowrap",
overflow: "hidden",
textOverflow: "ellipsis",
}}
component={RouterLink}
underline="hover"
to={`/admin/policy/${entity?.edges?.storage_policy?.id}`}
>
{entity?.edges?.storage_policy?.name || "-"}
</Link>
</NoWrapTypography>
</NoWrapTableCell>
<NoWrapTableCell>
<NoWrapTypography variant="inherit">{entity?.reference_count ?? 0}</NoWrapTypography>
</NoWrapTableCell>
<NoWrapTableCell>
<NoWrapTypography variant="inherit">
<TimeBadge datetime={entity?.created_at ?? ""} variant="inherit" timeAgoThreshold={0} />
</NoWrapTypography>
</NoWrapTableCell>
<NoWrapTableCell>
<Box sx={{ display: "flex", alignItems: "center", gap: 1 }}>
<UserAvatar
sx={{ width: 24, height: 24 }}
overwriteTextSize
user={{
id: entity?.user_hash_id ?? "",
nickname: entity?.edges?.user?.nick ?? "",
created_at: entity?.edges?.user?.created_at ?? "",
}}
/>
<NoWrapTypography variant="inherit">
<Link
sx={{
whiteSpace: "nowrap",
overflow: "hidden",
textOverflow: "ellipsis",
}}
onClick={userClicked}
underline="hover"
href="#/"
>
{entity?.edges?.user?.nick || "-"}
</Link>
</NoWrapTypography>
</Box>
</NoWrapTableCell>
<NoWrapTableCell>
<Box sx={{ display: "flex", alignItems: "center", gap: 1 }}>
<IconButton size="small" onClick={onOpenClick} disabled={openLoading}>
<Download fontSize="small" />
</IconButton>
<IconButton size="small" onClick={onDeleteClick} disabled={deleteLoading}>
<Delete fontSize="small" />
</IconButton>
</Box>
</NoWrapTableCell>
</TableRow>
);
};
export default EntityRow;

View File

@@ -0,0 +1,328 @@
import { Delete } from "@mui/icons-material";
import {
Badge,
Box,
Button,
Checkbox,
Container,
Divider,
Stack,
Table,
TableBody,
TableCell,
TableContainer,
TableHead,
TableRow,
TableSortLabel,
useMediaQuery,
useTheme,
} from "@mui/material";
import { bindPopover, bindTrigger, usePopupState } from "material-ui-popup-state/hooks";
import { useQueryState } from "nuqs";
import { useCallback, useEffect, useMemo, useState } from "react";
import { useTranslation } from "react-i18next";
import { getEntityList } from "../../../api/api";
import { AdminListService, Entity } from "../../../api/dashboard";
import { useAppDispatch } from "../../../redux/hooks";
import { NoWrapTableCell, SecondaryButton, StyledTableContainerPaper } from "../../Common/StyledComponents";
import ArrowSync from "../../Icons/ArrowSync";
import Filter from "../../Icons/Filter";
import PageContainer from "../../Pages/PageContainer";
import PageHeader from "../../Pages/PageHeader";
import TablePagination from "../Common/TablePagination";
import { OrderByQuery, OrderDirectionQuery, PageQuery, PageSizeQuery } from "../StoragePolicy/StoragePolicySetting";
import UserDialog from "../User/UserDialog/UserDialog";
import EntityDeleteDialog from "./EntityDeleteDialog";
import EntityDialog from "./EntityDialog/EntityDialog";
import EntityFilterPopover from "./EntityFilterPopover";
import EntityRow from "./EntityRow";
export const StoragePolicyQuery = "storage_policy";
export const UserQuery = "user";
export const TypeQuery = "type";
const EntitySetting = () => {
const { t } = useTranslation("dashboard");
const theme = useTheme();
const isMobile = useMediaQuery(theme.breakpoints.down("sm"));
const dispatch = useAppDispatch();
const [loading, setLoading] = useState(true);
const [entities, setEntities] = useState<Entity[]>([]);
const [page, setPage] = useQueryState(PageQuery, { defaultValue: "1" });
const [pageSize, setPageSize] = useQueryState(PageSizeQuery, {
defaultValue: "10",
});
const [orderBy, setOrderBy] = useQueryState(OrderByQuery, {
defaultValue: "",
});
const [orderDirection, setOrderDirection] = useQueryState(OrderDirectionQuery, { defaultValue: "desc" });
const [storagePolicy, setStoragePolicy] = useQueryState(StoragePolicyQuery, { defaultValue: "" });
const [user, setUser] = useQueryState(UserQuery, { defaultValue: "" });
const [type, setType] = useQueryState(TypeQuery, { defaultValue: "" });
const [count, setCount] = useState(0);
const [selected, setSelected] = useState<readonly number[]>([]);
const filterPopupState = usePopupState({
variant: "popover",
popupId: "entityFilterPopover",
});
const [userDialogOpen, setUserDialogOpen] = useState(false);
const [userDialogID, setUserDialogID] = useState<number | undefined>(undefined);
const [entityDialogOpen, setEntityDialogOpen] = useState(false);
const [entityDialogID, setEntityDialogID] = useState<number | undefined>(undefined);
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
const [deleteDialogID, setDeleteDialogID] = useState<number[] | undefined>(undefined);
const pageInt = parseInt(page) ?? 1;
const pageSizeInt = parseInt(pageSize) ?? 10;
const clearFilters = useCallback(() => {
setStoragePolicy("");
setUser("");
setType("");
}, [setStoragePolicy, setUser, setType]);
useEffect(() => {
fetchEntities();
}, [page, pageSize, orderBy, orderDirection, storagePolicy, user, type]);
const fetchEntities = () => {
setLoading(true);
setSelected([]);
const params: AdminListService = {
page: pageInt,
page_size: pageSizeInt,
order_by: orderBy ?? "",
order_direction: orderDirection ?? "desc",
conditions: {},
};
if (storagePolicy) {
params.conditions!.entity_policy = storagePolicy;
}
if (user) {
params.conditions!.entity_user = user;
}
if (type) {
params.conditions!.entity_type = type;
}
dispatch(getEntityList(params))
.then((res) => {
setEntities(res.entities);
setPageSize(res.pagination.page_size.toString());
setCount(res.pagination.total_items ?? 0);
setLoading(false);
})
.finally(() => {
setLoading(false);
});
};
const handleDelete = () => {
setDeleteDialogOpen(true);
setDeleteDialogID(Array.from(selected));
};
const handleSelectAllClick = (event: React.ChangeEvent<HTMLInputElement>) => {
if (event.target.checked) {
const newSelected = entities.map((n) => n.id);
setSelected(newSelected);
return;
}
setSelected([]);
};
const handleSelect = useCallback(
(id: number) => {
const selectedIndex = selected.indexOf(id);
let newSelected: readonly number[] = [];
if (selectedIndex === -1) {
newSelected = newSelected.concat(selected, id);
} else if (selectedIndex === 0) {
newSelected = newSelected.concat(selected.slice(1));
} else if (selectedIndex === selected.length - 1) {
newSelected = newSelected.concat(selected.slice(0, -1));
} else if (selectedIndex > 0) {
newSelected = newSelected.concat(selected.slice(0, selectedIndex), selected.slice(selectedIndex + 1));
}
setSelected(newSelected);
},
[selected],
);
const orderById = orderBy === "id" || orderBy === "";
const direction = orderDirection as "asc" | "desc";
const onSortClick = (field: string) => () => {
const alreadySorted = orderBy === field || (field === "id" && orderById);
setOrderBy(field);
setOrderDirection(alreadySorted ? (direction === "asc" ? "desc" : "asc") : "asc");
};
const hasActiveFilters = useMemo(() => {
return !!(storagePolicy || user || type);
}, [storagePolicy, user, type]);
const handleUserDialogOpen = (id: number) => {
setUserDialogID(id);
setUserDialogOpen(true);
};
const handleEntityDialogOpen = (id: number) => {
setEntityDialogID(id);
setEntityDialogOpen(true);
};
const handleSingleDelete = (id: number) => {
setDeleteDialogOpen(true);
setDeleteDialogID([id]);
};
return (
<PageContainer>
<EntityDialog open={entityDialogOpen} onClose={() => setEntityDialogOpen(false)} entityID={entityDialogID} />
<UserDialog open={userDialogOpen} onClose={() => setUserDialogOpen(false)} userID={userDialogID} />
<EntityDeleteDialog
open={deleteDialogOpen}
onClose={() => setDeleteDialogOpen(false)}
entityID={deleteDialogID}
onDelete={fetchEntities}
/>
<Container maxWidth="xl">
<PageHeader title={t("dashboard:nav.entities")} />
<Stack direction="row" spacing={1} sx={{ mb: 2 }}>
<EntityFilterPopover
{...bindPopover(filterPopupState)}
storagePolicy={storagePolicy}
setStoragePolicy={setStoragePolicy}
owner={user}
setOwner={setUser}
type={type !== "" ? parseInt(type) : undefined}
setType={(type) => setType(type !== undefined ? type.toString() : "")}
clearFilters={clearFilters}
/>
<SecondaryButton onClick={fetchEntities} disabled={loading} variant={"contained"} startIcon={<ArrowSync />}>
{t("node.refresh")}
</SecondaryButton>
<Badge color="primary" variant="dot" invisible={!hasActiveFilters}>
<SecondaryButton startIcon={<Filter />} variant="contained" {...bindTrigger(filterPopupState)}>
{t("user.filter")}
</SecondaryButton>
</Badge>
{selected.length > 0 && !isMobile && (
<>
<Divider orientation="vertical" flexItem />
<Button startIcon={<Delete />} variant="contained" color="error" onClick={handleDelete}>
{t("entity.deleteXEntities", { num: selected.length })}
</Button>
</>
)}
</Stack>
{isMobile && selected.length > 0 && (
<Stack direction="row" spacing={1} sx={{ mb: 2 }}>
<Button startIcon={<Delete />} variant="contained" color="error" onClick={handleDelete}>
{t("entity.deleteXEntities", { num: selected.length })}
</Button>
</Stack>
)}
<TableContainer component={StyledTableContainerPaper} sx={{ mt: 2 }}>
<Table size="small" stickyHeader sx={{ width: "100%", tableLayout: "fixed" }}>
<TableHead>
<TableRow>
<TableCell padding="checkbox" sx={{ width: "36px!important" }} width={50}>
<Checkbox
size="small"
indeterminate={selected.length > 0 && selected.length < entities.length}
checked={entities.length > 0 && selected.length === entities.length}
onChange={handleSelectAllClick}
/>
</TableCell>
<NoWrapTableCell width={80}>
<TableSortLabel
active={orderById}
direction={orderById ? direction : "asc"}
onClick={onSortClick("id")}
>
{t("group.#")}
</TableSortLabel>
</NoWrapTableCell>
<NoWrapTableCell width={100}>{t("file.blobType")}</NoWrapTableCell>
<NoWrapTableCell width={250}>{t("file.source")}</NoWrapTableCell>
<NoWrapTableCell width={100}>
<TableSortLabel
active={orderBy === "size"}
direction={orderBy === "size" ? direction : "asc"}
onClick={onSortClick("size")}
>
{t("file.size")}
</TableSortLabel>
</NoWrapTableCell>
<NoWrapTableCell width={150}>{t("file.storagePolicy")}</NoWrapTableCell>
<NoWrapTableCell width={100}>
<TableSortLabel
active={orderBy === "reference_count"}
direction={orderBy === "reference_count" ? direction : "asc"}
onClick={onSortClick("reference_count")}
>
{t("entity.refenenceCount")}
</TableSortLabel>
</NoWrapTableCell>
<NoWrapTableCell width={180}>
<TableSortLabel
active={orderBy === "created_at"}
direction={orderBy === "created_at" ? direction : "asc"}
onClick={onSortClick("created_at")}
>
{t("file.createdAt")}
</TableSortLabel>
</NoWrapTableCell>
<NoWrapTableCell width={180}>{t("file.creator")}</NoWrapTableCell>
<NoWrapTableCell width={100}></NoWrapTableCell>
</TableRow>
</TableHead>
<TableBody>
{!loading &&
entities.map((entity) => (
<EntityRow
key={entity.id}
entity={entity}
onDelete={handleSingleDelete}
selected={selected.indexOf(entity.id) !== -1}
onSelect={handleSelect}
openUserDialog={handleUserDialogOpen}
openEntityDialog={handleEntityDialogOpen}
/>
))}
{loading &&
entities.length > 0 &&
entities.slice(0, 10).map((entity) => <EntityRow key={`loading-${entity.id}`} loading={true} />)}
{loading &&
entities.length === 0 &&
Array.from(Array(10)).map((_, index) => <EntityRow key={`loading-${index}`} loading={true} />)}
</TableBody>
</Table>
</TableContainer>
{count > 0 && (
<Box sx={{ mt: 1 }}>
<TablePagination
page={pageInt}
totalItems={count}
rowsPerPage={pageSizeInt}
rowsPerPageOptions={[10, 25, 50, 100, 200, 500]}
onRowsPerPageChange={(value) => setPageSize(value.toString())}
onChange={(_, value) => setPage(value.toString())}
/>
</Box>
)}
</Container>
</PageContainer>
);
};
export default EntitySetting;