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,33 @@
import EntitySetting from "./Entity/EntitySetting";
import FileSetting from "./File/FileSetting";
import FileSystem from "./FileSystem/Filesystem";
import EditGroup from "./Group/EditGroup/EditGroup";
import GroupSetting from "./Group/GroupSetting";
import Home from "./Home/Home";
import EditNode from "./Node/EditNode";
import NodeSetting from "./Node/NodeSetting";
import Settings from "./Settings/Settings";
import ShareList from "./Share/ShareList";
import EditStoragePolicy from "./StoragePolicy/EditStoragePolicy/EditStoragePolicy";
import OauthCallback from "./StoragePolicy/OauthCallback";
import StoragePolicySetting from "./StoragePolicy/StoragePolicySetting";
import TaskList from "./Task/TaskList";
import UserSetting from "./User/UserSetting";
export {
EditGroup,
EditNode,
EditStoragePolicy,
EntitySetting,
FileSetting,
FileSystem,
GroupSetting,
Home,
NodeSetting,
OauthCallback,
Settings,
ShareList,
StoragePolicySetting,
TaskList,
UserSetting,
};

View File

@@ -0,0 +1,41 @@
import { Box, styled } from "@mui/material";
export const BorderedCard = styled(Box)(({ theme }) => ({
border: `1px solid ${theme.palette.divider}`,
borderRadius: theme.shape.borderRadius,
padding: theme.spacing(2),
backgroundColor: theme.palette.background.paper,
}));
export const BorderedCardClickable = styled(BorderedCard)(({ theme }) => ({
cursor: "pointer",
"&:hover": {
backgroundColor: theme.palette.action.hover,
},
transition: "background-color 0.3s ease",
}));
export const BorderedCardClickableBaImg = styled(BorderedCardClickable)<{ img?: string }>(({ theme, img }) => ({
position: "relative",
overflow: "hidden",
"&::before": {
content: '""',
position: "absolute",
top: "-20px",
right: "-20px",
width: "150px",
height: "150px",
backgroundImage: `url(${img})`,
backgroundSize: "cover",
backgroundPosition: "center",
transform: "rotate(-10deg)",
opacity: 0.1,
maskImage: "radial-gradient(circle at center, black 30%, transparent 80%)",
pointerEvents: "none",
zIndex: 0,
},
"& > *": {
position: "relative",
zIndex: 1,
},
}));

View File

@@ -0,0 +1,44 @@
import { TextFieldProps } from "@mui/material";
import { useCallback, useMemo, useState } from "react";
import { useTranslation } from "react-i18next";
import { DenseFilledTextField } from "../../Common/StyledComponents";
export interface EndpointInputProps extends TextFieldProps<"outlined"> {
enforceProtocol?: boolean;
enforcePrefix?: boolean;
}
export const EndpointInput = (props: EndpointInputProps) => {
const { t } = useTranslation("dashboard");
const { enforceProtocol, enforcePrefix = true, onChange, ...rest } = props;
const [value, setValue] = useState<string>((props.value as string) ?? "");
const handleChange = useCallback(
(e: React.ChangeEvent<HTMLInputElement>) => {
setValue(e.target.value);
onChange?.(e);
},
[onChange],
);
const showError = useMemo(() => {
if (!enforceProtocol) return false;
return value.startsWith("http://") && window.location.protocol == "https:";
}, [enforceProtocol, value]);
return (
<DenseFilledTextField
onChange={handleChange}
value={props.value}
slotProps={{
htmlInput: {
// start with http:// or https://
pattern: enforcePrefix ? `^https?://.*$` : undefined,
title: enforcePrefix ? t("settings.startWithProtocol") : undefined,
},
}}
error={showError}
helperText={showError ? t("settings.tlsWarning") : undefined}
{...rest}
/>
);
};

View File

@@ -0,0 +1,94 @@
import { ListItemText } from "@mui/material";
import FormControl from "@mui/material/FormControl";
import { useEffect, useState } from "react";
import { getGroupList } from "../../../api/api";
import { GroupEnt } from "../../../api/dashboard";
import { useAppDispatch } from "../../../redux/hooks";
import { DenseSelect } from "../../Common/StyledComponents";
import { SquareMenuItem } from "../../FileManager/ContextMenu/ContextMenu";
export interface GroupSelectionInputProps {
value: string;
onChange: (value: string) => void;
onChangeGroup?: (group?: GroupEnt) => void;
emptyValue?: string;
emptyText?: string;
fullWidth?: boolean;
required?: boolean;
}
const AnonymousGroupId = 3;
const GroupSelectionInput = ({
value,
onChange,
onChangeGroup,
emptyValue,
emptyText,
fullWidth,
required,
}: GroupSelectionInputProps) => {
const dispatch = useAppDispatch();
const [loading, setLoading] = useState(true);
const [groups, setGroups] = useState<GroupEnt[]>([]);
useEffect(() => {
setLoading(true);
dispatch(
getGroupList({
page_size: 1000,
page: 1,
order_by: "id",
order_direction: "asc",
}),
)
.then((res) => {
setGroups(res.groups);
})
.finally(() => {
setLoading(false);
});
}, []);
const handleChange = (value: string) => {
onChange(value);
onChangeGroup?.(groups.find((g) => g.id === parseInt(value)));
};
return (
<FormControl fullWidth={fullWidth}>
<DenseSelect
disabled={loading}
value={value}
onChange={(e) => handleChange(e.target.value as string)}
required={required}
>
{groups
.filter((g) => g.id != AnonymousGroupId)
.map((g) => (
<SquareMenuItem value={g.id.toString()}>
<ListItemText
slotProps={{
primary: { variant: "body2" },
}}
>
{g.name}
</ListItemText>
</SquareMenuItem>
))}
{emptyValue !== undefined && emptyText && (
<SquareMenuItem value={emptyValue}>
<ListItemText
primary={<em>{emptyText}</em>}
slotProps={{
primary: { variant: "body2" },
}}
/>
</SquareMenuItem>
)}
</DenseSelect>
</FormControl>
);
};
export default GroupSelectionInput;

View File

@@ -0,0 +1,60 @@
import { DialogContent, Table, TableBody, TableCell, TableContainer, TableHead, TableRow } from "@mui/material";
import { useTranslation } from "react-i18next";
import { NoWrapTableCell } from "../../Common/StyledComponents.tsx";
import DraggableDialog from "../../Dialogs/DraggableDialog";
export interface MagicVar {
name: string;
value: string;
example?: string;
}
export interface MagicVarDialogProps {
open: boolean;
onClose: () => void;
vars: MagicVar[];
}
const MagicVarDialog = ({ open, onClose, vars }: MagicVarDialogProps) => {
const { t } = useTranslation("dashboard");
return (
<DraggableDialog
title={t("policy.magicVar.variable")}
onAccept={onClose}
dialogProps={{
fullWidth: true,
maxWidth: "md",
open,
onClose,
}}
>
<DialogContent>
<TableContainer sx={{ mt: 1, maxHeight: 440 }}>
<Table stickyHeader sx={{ width: "100%", tableLayout: "fixed" }} size="small">
<TableHead>
<TableRow>
<NoWrapTableCell width={350}>{t("policy.magicVar.variable")}</NoWrapTableCell>
<NoWrapTableCell width={200}>{t("policy.magicVar.description")}</NoWrapTableCell>
<NoWrapTableCell width={250}>{t("policy.magicVar.example")}</NoWrapTableCell>
</TableRow>
</TableHead>
<TableBody>
{vars.map((v, i) => (
<TableRow key={i} sx={{ "&:last-child td, &:last-child th": { border: 0 } }} hover>
<TableCell>
<code>{v.name}</code>
</TableCell>
<TableCell>{t(v.value)}</TableCell>
<TableCell sx={{ wordBreak: "break-all" }}>{v.example}</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</TableContainer>
</DialogContent>
</DraggableDialog>
);
};
export default MagicVarDialog;

View File

@@ -0,0 +1,89 @@
import { Alert, Box, OutlinedSelectProps, Typography } from "@mui/material";
import { useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import { getNodeList } from "../../../api/api";
import { Node, NodeStatus, NodeType } from "../../../api/dashboard";
import { useAppDispatch } from "../../../redux/hooks";
import { DenseSelect } from "../../Common/StyledComponents";
import { SquareMenuItem } from "../../FileManager/ContextMenu/ContextMenu";
export interface NodeSelectionInputProps extends OutlinedSelectProps {
value: number;
onChange: (value: number) => void;
}
export const NodeStatusCondition = "node_status";
const NodeSelectionInput = ({ value, onChange, ...rest }: NodeSelectionInputProps) => {
const { t } = useTranslation("dashboard");
const dispatch = useAppDispatch();
const [loading, setLoading] = useState(true);
const [nodes, setNodes] = useState<Node[]>([]);
useEffect(() => {
setLoading(true);
dispatch(
getNodeList({
page_size: 1000,
page: 1,
order_by: "id",
order_direction: "desc",
conditions: {
[NodeStatusCondition]: NodeStatus.active,
},
}),
)
.then((res) => {
const filteredNodes = res.nodes.filter((n) => n.type != NodeType.master);
setNodes(filteredNodes);
if (!value && filteredNodes.length > 0) {
onChange(filteredNodes[0].id);
}
})
.finally(() => {
setLoading(false);
});
}, []);
if (!loading && nodes.length == 0) {
return <Alert severity="warning">{t("settings.noNodes")}</Alert>;
}
return (
<DenseSelect
disabled={loading}
value={value}
onChange={(e) => onChange(e.target.value as number)}
MenuProps={{
PaperProps: { sx: { maxWidth: 230 } },
MenuListProps: {
sx: {
"& .MuiMenuItem-root": {
whiteSpace: "normal",
},
},
},
}}
{...rest}
>
{nodes.map((g) => (
<SquareMenuItem value={g.id}>
<Box
sx={{
display: "flex",
flexDirection: "column",
}}
>
<Typography variant={"body2"} fontWeight={600}>
{g.name}
</Typography>
<Typography variant={"caption"} color={"textSecondary"}>
{g.server}
</Typography>
</Box>
</SquareMenuItem>
))}
</DenseSelect>
);
};
export default NodeSelectionInput;

View File

@@ -0,0 +1,141 @@
import {
Alert,
AlertTitle,
Button,
DialogContent,
List,
ListItem,
ListItemIcon,
ListItemText,
Typography,
styled,
} from "@mui/material";
import dayjs from "dayjs";
import { useCallback, useMemo } from "react";
import { Trans, useTranslation } from "react-i18next";
import DraggableDialog, { StyledDialogActions } from "../../Dialogs/DraggableDialog";
import CheckmarkCircleFilled from "../../Icons/CheckmarkCircleFilled";
import Gift from "../../Icons/Gift";
import { Code } from "../../Common/Code.tsx";
export interface ProDialogProps {
open: boolean;
onClose: () => void;
}
const features = [
"shareLinkCollabration",
"filePermission",
"multipleStoragePolicy",
"auditAndActivity",
"vasService",
"sso",
"more",
];
const StyledButton = styled(Button)(({ theme }) => ({
background: `linear-gradient(45deg, ${theme.palette.primary.main} 30%, ${theme.palette.primary.light} 90%)`,
color: theme.palette.primary.contrastText,
"&:hover": {
background: `linear-gradient(45deg, ${theme.palette.primary.dark} 30%, ${theme.palette.primary.main} 90%)`,
},
transition: "all 300ms cubic-bezier(0.4, 0, 0.2, 1) !important",
}));
// TODO: fetch from cloudreve.org
const currentPromotion = {
code: "FI5Q9668YV",
discount: 15,
start: "2025-08-12T00:00:00Z",
end: "2025-10-12T23:59:59Z",
};
const ProDialog = ({ open, onClose }: ProDialogProps) => {
const { t } = useTranslation("dashboard");
const openMore = useCallback((e: React.MouseEvent<HTMLButtonElement>) => {
e.stopPropagation();
window.open("https://cloudreve.org/pro", "_blank");
}, []);
const showPromotion = useMemo(() => {
const now = dayjs();
return now >= dayjs(currentPromotion.start) && now <= dayjs(currentPromotion.end);
}, []);
return (
<DraggableDialog
title={t("pro.title")}
dialogProps={{
open,
onClose,
maxWidth: "sm",
fullWidth: true,
}}
>
<DialogContent>
<Typography variant="body1" color="text.secondary">
{t("pro.description")}
</Typography>
<Typography variant="body1" fontWeight={600} sx={{ mt: 2 }}>
{t("pro.proInclude")}
</Typography>
<List dense>
{features.map((feature) => (
<ListItem key={feature}>
<ListItemIcon
sx={{
minWidth: "36px",
}}
>
<CheckmarkCircleFilled color="primary" />
</ListItemIcon>
<ListItemText
slotProps={{
primary: {
sx: {},
variant: "body1",
},
}}
>
{t(`pro.${feature}`)}
</ListItemText>
</ListItem>
))}
</List>
{showPromotion && (
<Alert
iconMapping={{
info: <Gift fontSize="inherit" />,
}}
severity="info"
sx={{ mt: 2 }}
>
<AlertTitle>{t("pro.promotionTitle")}</AlertTitle>
<Trans
i18nKey="dashboard:pro.promotion"
values={{
code: currentPromotion.code,
discount: currentPromotion.discount,
}}
components={[<Code />, <Typography component={"span"} fontWeight={600} />]}
/>
</Alert>
)}
</DialogContent>
<StyledDialogActions
sx={{
justifyContent: "flex-end",
}}
>
<Button variant="outlined" color="primary" onClick={onClose}>
{t("pro.later")}
</Button>
<StyledButton onClick={openMore} variant="contained" color="primary">
{t("pro.learnMore")}
</StyledButton>
</StyledDialogActions>
</DraggableDialog>
);
};
export default ProDialog;

View File

@@ -0,0 +1,45 @@
import { Box, debounce, useTheme } from "@mui/material";
import { useEffect, useMemo, useState } from "react";
import { useTranslation } from "react-i18next";
import { getShareList } from "../../../api/api.ts";
import { Share } from "../../../api/dashboard.ts";
import { useAppDispatch } from "../../../redux/hooks.ts";
import { DenseAutocomplete, DenseFilledTextField, NoWrapBox, SquareChip } from "../../Common/StyledComponents.tsx";
import FileTypeIcon from "../../FileManager/Explorer/FileTypeIcon.tsx";
import LinkDismiss from "../../Icons/LinkDismiss.tsx";
export interface SharesInputProps {}
const SharesInput = (props: SharesInputProps) => {
const theme = useTheme();
const { t } = useTranslation();
const [options, setOptions] = useState<number[]>([]);
return (
<DenseAutocomplete
multiple
options={options}
blurOnSelect
renderInput={(params) => (
<DenseFilledTextField
{...params}
sx={{
"& .MuiInputBase-root": {},
"& .MuiInputBase-root.MuiOutlinedInput-root": {
paddingTop: theme.spacing(0.6),
paddingBottom: theme.spacing(0.6),
},
mt: 0,
}}
variant="outlined"
margin="dense"
placeholder={t("dashboard:settings.searchShare")}
type="text"
fullWidth
/>
)}
/>
);
};
export default SharesInput;

View File

@@ -0,0 +1,122 @@
import { Box, FormControl, ListItemText, SelectChangeEvent, Typography } from "@mui/material";
import { useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import { getStoragePolicyList } from "../../../api/api";
import { StoragePolicy } from "../../../api/dashboard";
import { useAppDispatch } from "../../../redux/hooks";
import { DenseSelect } from "../../Common/StyledComponents";
import { SquareMenuItem } from "../../FileManager/ContextMenu/ContextMenu";
export interface SinglePolicySelectionInputProps {
value: number | undefined;
onChange: (value: number) => void;
emptyValue?: number;
emptyText?: string;
simplified?: boolean;
}
const SinglePolicySelectionInput = ({
value,
onChange,
emptyValue,
emptyText,
simplified,
}: SinglePolicySelectionInputProps) => {
const { t } = useTranslation("dashboard");
const dispatch = useAppDispatch();
const [policies, setPolicies] = useState<StoragePolicy[]>([]);
const [loading, setLoading] = useState(false);
const [policyMap, setPolicyMap] = useState<Record<number, StoragePolicy>>({});
const handleChange = (event: SelectChangeEvent<unknown>) => {
const {
target: { value },
} = event;
onChange(value as number);
};
useEffect(() => {
setLoading(true);
dispatch(getStoragePolicyList({ page: 1, page_size: 1000, order_by: "id", order_direction: "asc" }))
.then((res) => {
setPolicies(res.policies);
setPolicyMap(
res.policies.reduce(
(acc, policy) => {
acc[policy.id] = policy;
return acc;
},
{} as Record<number, StoragePolicy>,
),
);
})
.finally(() => {
setLoading(false);
});
}, []);
return (
<FormControl fullWidth>
<DenseSelect
value={value}
required
onChange={handleChange}
sx={{
minHeight: 39,
}}
renderValue={
simplified
? (value) => (
<ListItemText
primary={policyMap[value as number]?.name}
slotProps={{ primary: { variant: "body2" } }}
/>
)
: undefined
}
disabled={loading}
MenuProps={{
PaperProps: { sx: { maxWidth: 230 } },
MenuListProps: {
sx: {
"& .MuiMenuItem-root": {
whiteSpace: "normal",
},
},
},
}}
>
{policies.length > 0 &&
policies.map((policy) => (
<SquareMenuItem key={policy.id} value={policy.id}>
<Box
sx={{
display: "flex",
flexDirection: "column",
}}
>
<Typography variant={"body2"} fontWeight={600}>
{policy.name}
</Typography>
<Typography variant={"caption"} color={"textSecondary"}>
{t(`policy.${policy.type}`)}
</Typography>
</Box>
</SquareMenuItem>
))}
{emptyValue !== undefined && emptyText && (
<SquareMenuItem value={emptyValue}>
<ListItemText
primary={<em>{t(emptyText)}</em>}
slotProps={{
primary: { variant: "body2" },
}}
/>
</SquareMenuItem>
)}
</DenseSelect>
</FormControl>
);
};
export default SinglePolicySelectionInput;

View File

@@ -0,0 +1,86 @@
import {
Box,
ListItemText,
Pagination,
PaginationProps,
SelectChangeEvent,
useMediaQuery,
useTheme,
} from "@mui/material";
import { useEffect } from "react";
import { useTranslation } from "react-i18next";
import { DenseSelect } from "../../Common/StyledComponents";
import { SquareMenuItem } from "../../FileManager/ContextMenu/ContextMenu";
export interface TablePaginationProps extends PaginationProps {
rowsPerPageOptions?: number[];
rowsPerPage?: number;
onRowsPerPageChange?: (pageSize: number) => void;
page: number;
totalItems: number;
onChange: (event: React.ChangeEvent<unknown>, value: number) => void;
}
export const TablePagination = ({
rowsPerPageOptions = [5, 10, 25, 50],
rowsPerPage = 5,
onRowsPerPageChange,
page,
onChange,
totalItems,
...props
}: TablePaginationProps) => {
const { t } = useTranslation("dashboard");
const theme = useTheme();
const isMobile = useMediaQuery(theme.breakpoints.down("sm"));
const onDenseSelectChange = (e: SelectChangeEvent<unknown>) => {
onRowsPerPageChange?.(e.target.value as number);
};
useEffect(() => {
if ((page - 1) * rowsPerPage >= totalItems) {
onChange({} as React.ChangeEvent<unknown>, Math.ceil(totalItems / rowsPerPage));
}
}, [rowsPerPage, totalItems]);
return (
<Box
sx={{
py: 1,
display: "flex",
flexDirection: isMobile ? "column" : "row",
justifyContent: "space-between",
alignItems: isMobile ? "start" : "center",
gap: 1,
}}
>
<Pagination
size={isMobile ? "small" : "medium"}
count={Math.ceil(totalItems / rowsPerPage)}
page={page}
onChange={onChange}
{...props}
/>
<DenseSelect
variant="filled"
value={rowsPerPage}
onChange={onDenseSelectChange}
renderValue={(value) => (
<ListItemText primary={t("settings.perPage", { num: value })} slotProps={{ primary: { variant: "body2" } }} />
)}
>
{rowsPerPageOptions.map((option) => (
<SquareMenuItem key={option} value={option}>
<ListItemText
primary={t("settings.perPage", { num: option })}
slotProps={{ primary: { variant: "body2" } }}
/>
</SquareMenuItem>
))}
</DenseSelect>
</Box>
);
};
export default TablePagination;

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;

View File

@@ -0,0 +1,162 @@
import { Box, Button, Collapse, DialogActions, DialogContent } from "@mui/material";
import * as React from "react";
import { createContext, useEffect, useMemo, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import { CSSTransition, SwitchTransition } from "react-transition-group";
import { getFileDetail, upsertFile } from "../../../../api/api.ts";
import { File, UpsertFileService } 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 FileForm from "./FileForm.tsx";
export interface FileDialogProps {
open: boolean;
onClose: () => void;
fileID?: number;
onUpdated?: (file: File) => void;
}
export interface FileDialogContextProps {
values: File;
setFile: (f: (p: File) => File) => void;
formRef?: React.RefObject<HTMLFormElement>;
}
const defaultFile: File = {
id: 0,
name: "",
size: 0,
edges: {},
};
export const FileDialogContext = createContext<FileDialogContextProps>({
values: { ...defaultFile },
setFile: () => {},
});
const FileDialog = ({ open, onClose, fileID, onUpdated }: FileDialogProps) => {
const dispatch = useAppDispatch();
const { t } = useTranslation("dashboard");
const [values, setValues] = useState<File>({
...defaultFile,
});
const [modifiedValues, setModifiedValues] = useState<File>({
...defaultFile,
});
const [loading, setLoading] = useState(true);
const [submitting, setSubmitting] = useState(false);
const formRef = useRef<HTMLFormElement>(null);
const showSaveButton = useMemo(() => {
return JSON.stringify(modifiedValues) !== JSON.stringify(values);
}, [modifiedValues, values]);
useEffect(() => {
if (!fileID || !open) {
return;
}
setLoading(true);
dispatch(getFileDetail(fileID))
.then((res) => {
setValues(res);
setModifiedValues(res);
})
.catch(() => {
onClose();
})
.finally(() => {
setLoading(false);
});
}, [open]);
const revert = () => {
setModifiedValues(values);
};
const submit = () => {
if (formRef.current) {
if (!formRef.current.checkValidity()) {
formRef.current.reportValidity();
return;
}
}
const args: UpsertFileService = {
file: { ...modifiedValues },
};
setSubmitting(true);
dispatch(upsertFile(args))
.then((res) => {
setValues(res);
setModifiedValues(res);
onUpdated?.(res);
})
.finally(() => {
setSubmitting(false);
});
};
return (
<FileDialogContext.Provider
value={{
values: modifiedValues,
setFile: setModifiedValues,
formRef,
}}
>
<DraggableDialog
title={t("file.fileDialogTitle")}
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 && <FileForm />}
</Box>
</CSSTransition>
</SwitchTransition>
</AutoHeight>
</DialogContent>
<Collapse in={showSaveButton}>
<DialogActions>
<Button disabled={submitting} onClick={revert}>
{t("settings.revert")}
</Button>
<Button loading={submitting} variant="contained" onClick={submit}>
{t("settings.save")}
</Button>
</DialogActions>
</Collapse>
</DraggableDialog>
</FileDialogContext.Provider>
);
};
export default FileDialog;

View File

@@ -0,0 +1,109 @@
import { IconButton, Table, TableBody, TableCell, TableContainer, TableHead, TableRow, Tooltip } from "@mui/material";
import { useCallback, useContext } from "react";
import { useTranslation } from "react-i18next";
import { sizeToString } from "../../../../util";
import {
NoWrapCell,
NoWrapTableCell,
NoWrapTypography,
StyledTableContainerPaper,
} from "../../../Common/StyledComponents";
import TimeBadge from "../../../Common/TimeBadge";
import Delete from "../../../Icons/Delete";
import Open from "../../../Icons/Open";
import { FileDialogContext } from "./FileDialog";
const FileDirectLinks = () => {
const { t } = useTranslation("dashboard");
const { setFile, values } = useContext(FileDialogContext);
const handleDelete = (id: number) => {
setFile((prev) => ({
...prev,
edges: {
...prev.edges,
direct_links: prev.edges?.direct_links?.filter((link) => link.id !== id),
},
}));
};
const handleOpen = (id: number) => {
window.open(values?.direct_link_map?.[id] ?? "", "_blank");
};
const linkId = useCallback(
(id: number) => {
const url = new URL(values?.direct_link_map?.[id] ?? "");
return url.pathname;
},
[values?.direct_link_map],
);
return (
<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.downloads")}</NoWrapTableCell>
<NoWrapTableCell width={100}>{t("file.speed")}</NoWrapTableCell>
<NoWrapTableCell width={150}>{t("file.directLinkId")}</NoWrapTableCell>
<NoWrapTableCell width={200}>{t("file.createdAt")}</NoWrapTableCell>
<NoWrapTableCell width={100} align="right"></NoWrapTableCell>
</TableRow>
</TableHead>
<TableBody>
{values?.edges?.direct_links?.map((option, index) => {
const lid = linkId(option.id);
return (
<TableRow key={option.id} hover>
<TableCell>
<NoWrapTypography variant="inherit">{option.id}</NoWrapTypography>
</TableCell>
<TableCell>
<NoWrapTypography variant="inherit">{option.name ?? ""}</NoWrapTypography>
</TableCell>
<TableCell>
<NoWrapTypography variant="inherit">{option.downloads ?? 0}</NoWrapTypography>
</TableCell>
<TableCell>
<NoWrapTypography variant="inherit">
{option.speed ? `${sizeToString(option.speed)}/s` : "-"}
</NoWrapTypography>
</TableCell>
<TableCell>
<Tooltip title={lid}>
<NoWrapTypography variant="inherit">{lid}</NoWrapTypography>
</Tooltip>
</TableCell>
<TableCell>
<NoWrapTypography variant="inherit">
<TimeBadge datetime={option.created_at ?? ""} variant="inherit" timeAgoThreshold={0} />
</NoWrapTypography>
</TableCell>
<TableCell align="right">
<IconButton size="small" onClick={() => handleOpen(option.id)}>
<Open fontSize="small" />
</IconButton>
<IconButton size="small" onClick={() => handleDelete(option.id)}>
<Delete fontSize="small" />
</IconButton>
</TableCell>
</TableRow>
);
})}
{!values?.edges?.direct_links?.length && (
<TableRow>
<NoWrapCell colSpan={6} align="center">
{t("file.noRecords")}
</NoWrapCell>
</TableRow>
)}
</TableBody>
</Table>
</TableContainer>
);
};
export default FileDirectLinks;

View File

@@ -0,0 +1,130 @@
import { Link, Table, TableBody, TableCell, TableContainer, TableHead, TableRow, Tooltip } from "@mui/material";
import { useContext, useState } from "react";
import { useTranslation } from "react-i18next";
import { Link as RouterLink } from "react-router-dom";
import { EntityType } from "../../../../api/explorer";
import { sizeToString } from "../../../../util";
import {
NoWrapCell,
NoWrapTableCell,
NoWrapTypography,
StyledTableContainerPaper,
} from "../../../Common/StyledComponents";
import TimeBadge from "../../../Common/TimeBadge";
import { EntityTypeText } from "../../../FileManager/Sidebar/Data";
import EntityDialog from "../../Entity/EntityDialog/EntityDialog";
import UserDialog from "../../User/UserDialog/UserDialog";
import { FileDialogContext } from "./FileDialog";
const FileEntity = () => {
const { t } = useTranslation("dashboard");
const { setFile, values } = useContext(FileDialogContext);
const [userDialogOpen, setUserDialogOpen] = useState(false);
const [userDialogId, setUserDialogId] = useState<number | null>(null);
const [entityDialogOpen, setEntityDialogOpen] = useState(false);
const [entityDialogId, setEntityDialogId] = useState<number | null>(null);
const handleUserDialogOpen = (id: number) => (e: React.MouseEvent<HTMLElement>) => {
e.stopPropagation();
e.preventDefault();
setUserDialogId(id);
setUserDialogOpen(true);
};
const handleEntityDialogOpen = (id: number) => (e: React.MouseEvent<HTMLElement>) => {
e.stopPropagation();
e.preventDefault();
setEntityDialogId(id);
setEntityDialogOpen(true);
};
return (
<TableContainer component={StyledTableContainerPaper} sx={{ maxHeight: "300px" }}>
<UserDialog open={userDialogOpen} onClose={() => setUserDialogOpen(false)} userID={userDialogId ?? undefined} />
<EntityDialog
open={entityDialogOpen}
onClose={() => setEntityDialogOpen(false)}
entityID={entityDialogId ?? undefined}
/>
<Table size="small" stickyHeader sx={{ width: "100%", tableLayout: "fixed" }}>
<TableHead>
<TableRow>
<NoWrapTableCell width={90}>{t("group.#")}</NoWrapTableCell>
<NoWrapTableCell width={100}>{t("file.blobType")}</NoWrapTableCell>
<NoWrapTableCell width={100}>{t("file.size")}</NoWrapTableCell>
<NoWrapTableCell width={100}>{t("file.storagePolicy")}</NoWrapTableCell>
<NoWrapTableCell width={200}>{t("file.source")}</NoWrapTableCell>
<NoWrapTableCell width={200}>{t("file.createdAt")}</NoWrapTableCell>
<NoWrapTableCell width={150}>{t("file.creator")}</NoWrapTableCell>
</TableRow>
</TableHead>
<TableBody>
{values?.edges?.entities?.map((option, index) => (
<TableRow key={option.id} hover sx={{ cursor: "pointer" }} onClick={handleEntityDialogOpen(option.id ?? 0)}>
<TableCell>
<NoWrapTypography variant="inherit">{option.id}</NoWrapTypography>
</TableCell>
<TableCell>
<NoWrapTypography variant="inherit">
{t(EntityTypeText[option.type ?? EntityType.version])}
</NoWrapTypography>
</TableCell>
<TableCell>
<NoWrapTypography variant="inherit">{sizeToString(option.size ?? 0)}</NoWrapTypography>
</TableCell>
<TableCell>
<NoWrapTypography variant="inherit">
<Link
sx={{
whiteSpace: "nowrap",
overflow: "hidden",
textOverflow: "ellipsis",
}}
component={RouterLink}
to={`/admin/policy/${option.edges?.storage_policy?.id}`}
>
{option.edges?.storage_policy?.name ?? ""}
</Link>
</NoWrapTypography>
</TableCell>
<TableCell>
<Tooltip title={option.source ?? ""}>
<NoWrapTypography variant="inherit">{option.source ?? ""}</NoWrapTypography>
</Tooltip>
</TableCell>
<TableCell>
<NoWrapTypography variant="inherit">
<TimeBadge datetime={option.created_at ?? ""} variant="inherit" timeAgoThreshold={0} />
</NoWrapTypography>
</TableCell>
<TableCell>
<NoWrapTypography variant="inherit">
<Link
underline="hover"
sx={{
whiteSpace: "nowrap",
overflow: "hidden",
textOverflow: "ellipsis",
}}
href="#/"
onClick={handleUserDialogOpen(option.edges?.user?.id ?? 0)}
>
{option.edges?.user?.nick ?? ""}
</Link>
</NoWrapTypography>
</TableCell>
</TableRow>
))}
{!values?.edges?.entities?.length && (
<TableRow>
<NoWrapCell colSpan={6} align="center">
{t("file.noEntities")}
</NoWrapCell>
</TableRow>
)}
</TableBody>
</Table>
</TableContainer>
);
};
export default FileEntity;

View File

@@ -0,0 +1,162 @@
import { Box, Grid2 as Grid, Link, Skeleton, styled, Typography, useMediaQuery, useTheme } from "@mui/material";
import { useCallback, useContext, useEffect, useMemo, useState } from "react";
import { Trans, useTranslation } from "react-i18next";
import { Link as RouterLink } from "react-router-dom";
import { getFileInfo } from "../../../../api/api";
import { FileType } from "../../../../api/explorer";
import { useAppDispatch } from "../../../../redux/hooks";
import { sizeToString } from "../../../../util";
import CrUri from "../../../../util/uri";
import { DenseFilledTextField, NoWrapTypography } from "../../../Common/StyledComponents";
import UserAvatar from "../../../Common/User/UserAvatar";
import FileBadge from "../../../FileManager/FileBadge";
import SettingForm from "../../../Pages/Setting/SettingForm";
import SinglePolicySelectionInput from "../../Common/SinglePolicySelectionInput";
import UserDialog from "../../User/UserDialog/UserDialog";
import { FileDialogContext } from "./FileDialog";
import FileDirectLinks from "./FileDirectLinks";
import FileEntity from "./FileEntity";
import FileMetadata from "./FileMetadata";
const StyledFileBadge = styled(FileBadge)(({ theme }) => ({
minHeight: "40px",
border: `1px solid ${theme.palette.mode === "light" ? "rgba(0, 0, 0, 0.23)" : "rgba(255, 255, 255, 0.23)"}`,
}));
const FileForm = () => {
const dispatch = useAppDispatch();
const theme = useTheme();
const isMobile = useMediaQuery(theme.breakpoints.down("md"));
const { t } = useTranslation("dashboard");
const { formRef, values, setFile } = useContext(FileDialogContext);
const [userDialogOpen, setUserDialogOpen] = useState(false);
const [userDialogID, setUserDialogID] = useState<number>(0);
const [fileParentLoading, setFileParentLoading] = useState(true);
const [fileParent, setFileParent] = useState<string | null>(null);
useEffect(() => {
setFileParentLoading(true);
dispatch(getFileInfo({ id: values.file_hash_id }, true))
.then((res) => {
const crUri = new CrUri(res.path);
setFileParent(crUri.parent().toString());
})
.catch(() => {
setFileParent(null);
})
.finally(() => {
setFileParentLoading(false);
});
}, [values.id]);
const onNameChange = useCallback(
(e: React.ChangeEvent<HTMLInputElement>) => {
setFile((prev) => ({ ...prev, name: e.target.value }));
},
[setFile],
);
const userClicked = (e: React.MouseEvent<HTMLElement>) => {
e.preventDefault();
setUserDialogOpen(true);
setUserDialogID(values?.edges?.owner?.id ?? 0);
};
const sizeUsed = useMemo(() => {
return sizeToString(values?.edges?.entities?.reduce((acc, entity) => acc + (entity.size ?? 0), 0) ?? 0);
}, [values?.edges?.entities]);
return (
<>
<UserDialog open={userDialogOpen} onClose={() => setUserDialogOpen(false)} userID={userDialogID} />
<Box component={"form"} ref={formRef} onSubmit={(e) => e.preventDefault()}>
<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.sizeUsed")} noContainer lgWidth={2}>
<Typography variant={"body2"} color={"textSecondary"}>
{sizeUsed}
</Typography>
</SettingForm>
<SettingForm title={t("file.shareLink")} noContainer lgWidth={2}>
<Typography variant={"body2"} color={"textSecondary"}>
<Trans
i18nKey="file.shareLinkNum"
components={[
<Link component={RouterLink} to={`/admin/share?file=${values?.id}`} underline={"hover"} key={0} />,
]}
ns="dashboard"
values={{ num: values.edges?.shares?.length ?? 0 }}
/>
</Typography>
</SettingForm>
<SettingForm title={t("file.uploader")} 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?.owner?.nick ?? "",
created_at: values?.edges?.owner?.created_at ?? "",
}}
/>
<NoWrapTypography variant="inherit">
<Link
sx={{
whiteSpace: "nowrap",
overflow: "hidden",
textOverflow: "ellipsis",
}}
onClick={userClicked}
underline="hover"
href="#/"
>
{values?.edges?.owner?.nick}
</Link>
</NoWrapTypography>
</Box>
</NoWrapTypography>
</SettingForm>
<SettingForm title={t("file.name")} noContainer lgWidth={6}>
<DenseFilledTextField fullWidth value={values.name} required onChange={onNameChange} />
</SettingForm>
<SettingForm title={t("application:fileManager.parentFolder")} noContainer lgWidth={3}>
{!fileParentLoading && (
<>
{fileParent && (
<StyledFileBadge variant={"outlined"} simplifiedFile={{ path: fileParent, type: FileType.folder }} />
)}
{!fileParent && "-"}
</>
)}
{fileParentLoading && <Skeleton variant="text" width={100} height={40} />}
</SettingForm>
<SettingForm title={t("file.primaryStoragePolicy")} noContainer lgWidth={3} pro>
<SinglePolicySelectionInput simplified value={values.storage_policy_files ?? 0} onChange={() => {}} />
</SettingForm>
<SettingForm title={t("file.metadata")} noContainer lgWidth={6}>
<FileMetadata />
</SettingForm>
<SettingForm title={t("file.blobs")} noContainer lgWidth={6}>
<FileEntity />
</SettingForm>
<SettingForm title={t("file.directLinks")} noContainer lgWidth={12}>
<FileDirectLinks />
</SettingForm>
</Grid>
</Box>
</>
);
};
export default FileForm;

View File

@@ -0,0 +1,114 @@
import { Delete } from "@mui/icons-material";
import { Checkbox, IconButton, Table, TableBody, TableCell, TableContainer, TableHead, TableRow } from "@mui/material";
import { useContext } from "react";
import { useTranslation } from "react-i18next";
import {
DenseFilledTextField,
NoWrapCell,
NoWrapTableCell,
NoWrapTypography,
StyledTableContainerPaper,
} from "../../../Common/StyledComponents";
import { FileDialogContext } from "./FileDialog";
const FileMetadata = () => {
const { t } = useTranslation("dashboard");
const { setFile, values } = useContext(FileDialogContext);
const onKeyChange = (index: number, value: string) => {
setFile((prev) => ({
...prev,
edges: {
...prev.edges,
metadata: prev.edges?.metadata?.map((item, i) => (i === index ? { ...item, name: value } : item)),
},
}));
};
const onValueChange = (index: number, value: string) => {
setFile((prev) => ({
...prev,
edges: {
...prev.edges,
metadata: prev.edges?.metadata?.map((item, i) => (i === index ? { ...item, value: value } : item)),
},
}));
};
const onIsPublicChange = (index: number, value: boolean) => {
setFile((prev) => ({
...prev,
edges: {
...prev.edges,
metadata: prev.edges?.metadata?.map((item, i) =>
i === index ? { ...item, is_public: value ? true : undefined } : item,
),
},
}));
};
const handleDelete = (id: number) => {
setFile((prev) => ({
...prev,
edges: { ...prev.edges, metadata: prev.edges?.metadata?.filter((item) => item.id !== id) },
}));
};
return (
<TableContainer component={StyledTableContainerPaper} sx={{ maxHeight: "300px" }}>
<Table size="small" stickyHeader sx={{ width: "100%", tableLayout: "fixed" }}>
<TableHead>
<TableRow>
<NoWrapTableCell width={150}>{t("file.name")}</NoWrapTableCell>
<NoWrapTableCell width={150}>{t("file.value")}</NoWrapTableCell>
<NoWrapTableCell width={50}>{t("file.isPublic")}</NoWrapTableCell>
<NoWrapTableCell width={90}>{t("group.#")}</NoWrapTableCell>
<NoWrapTableCell width={60} align="right"></NoWrapTableCell>
</TableRow>
</TableHead>
<TableBody>
{values?.edges?.metadata?.map((option, index) => (
<TableRow key={option.id} hover>
<TableCell>
<DenseFilledTextField
fullWidth
value={option.name}
required
onChange={(e) => onKeyChange(index, e.target.value)}
/>
</TableCell>
<TableCell>
<DenseFilledTextField
fullWidth
value={option.value}
onChange={(e) => onValueChange(index, e.target.value)}
/>
</TableCell>
<TableCell>
<Checkbox
size="small"
disableRipple
color="primary"
checked={!!option.is_public}
onChange={(e) => onIsPublicChange(index, e.target.checked)}
/>
</TableCell>
<TableCell>
<NoWrapTypography variant="inherit">{option.id}</NoWrapTypography>
</TableCell>
<TableCell align="right">
<IconButton size="small" onClick={() => handleDelete(option.id)}>
<Delete fontSize="small" />
</IconButton>
</TableCell>
</TableRow>
))}
{!values?.edges?.metadata?.length && (
<TableRow>
<NoWrapCell colSpan={6} align="center">
{t("file.noMetadata")}
</NoWrapCell>
</TableRow>
)}
</TableBody>
</Table>
</TableContainer>
);
};
export default FileMetadata;

View File

@@ -0,0 +1,197 @@
import { Box, Button, Checkbox, Popover, PopoverProps, Stack, styled } from "@mui/material";
import { useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import { DenseFilledTextField, SmallFormControlLabel } from "../../Common/StyledComponents";
import SettingForm from "../../Pages/Setting/SettingForm";
import SinglePolicySelectionInput from "../Common/SinglePolicySelectionInput";
export interface FileFilterPopoverProps extends PopoverProps {
storagePolicy: string;
setStoragePolicy: (storagePolicy: string) => void;
owner: string;
setOwner: (owner: string) => void;
name: string;
setName: (name: string) => void;
hasShareLink: boolean;
setHasShareLink: (hasShareLink: boolean) => void;
hasDirectLink: boolean;
setHasDirectLink: (hasDirectLink: boolean) => void;
isUploading: boolean;
setIsUploading: (isUploading: boolean) => void;
clearFilters: () => void;
}
const StyledCheckbox = styled(Checkbox)(({ theme }) => ({
paddingTop: 0,
paddingBottom: 0,
paddingLeft: 0,
}));
const FileFilterPopover = ({
storagePolicy,
setStoragePolicy,
owner,
setOwner,
name,
setName,
hasShareLink,
setHasShareLink,
hasDirectLink,
setHasDirectLink,
isUploading,
setIsUploading,
clearFilters,
onClose,
open,
...rest
}: FileFilterPopoverProps) => {
const { t } = useTranslation("dashboard");
// Create local state to track changes before applying
const [localStoragePolicy, setLocalStoragePolicy] = useState(storagePolicy);
const [localOwner, setLocalOwner] = useState(owner);
const [localName, setLocalName] = useState(name);
const [localHasShareLink, setLocalHasShareLink] = useState(hasShareLink);
const [localHasDirectLink, setLocalHasDirectLink] = useState(hasDirectLink);
const [localIsUploading, setLocalIsUploading] = useState(isUploading);
// Initialize local state when popup opens
useEffect(() => {
if (open) {
setLocalStoragePolicy(storagePolicy);
setLocalOwner(owner);
setLocalName(name);
setLocalHasShareLink(hasShareLink);
setLocalHasDirectLink(hasDirectLink);
setLocalIsUploading(isUploading);
}
}, [open]);
// Apply filters and close popover
const handleApplyFilters = () => {
setStoragePolicy(localStoragePolicy);
setOwner(localOwner);
setName(localName);
setHasShareLink(localHasShareLink);
setHasDirectLink(localHasDirectLink);
setIsUploading(localIsUploading);
onClose?.({}, "backdropClick");
};
// Reset filters and close popover
const handleResetFilters = () => {
setLocalStoragePolicy("");
setLocalOwner("");
setLocalName("");
setLocalHasShareLink(false);
setLocalHasDirectLink(false);
setLocalIsUploading(false);
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.searchFileName")} noContainer lgWidth={12}>
<DenseFilledTextField
fullWidth
value={localName}
onChange={(e) => setLocalName(e.target.value)}
placeholder={t("user.emptyNoFilter")}
size="small"
/>
</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>
<SettingForm title={t("file.otherConditions")} noContainer lgWidth={12}>
<Stack spacing={0.5}>
<SmallFormControlLabel
control={
<StyledCheckbox
disableRipple
size="small"
checked={localHasShareLink}
onChange={(e) => setLocalHasShareLink(e.target.checked)}
/>
}
label={t("file.shareLinkExisted")}
/>
<SmallFormControlLabel
control={
<StyledCheckbox
disableRipple
size="small"
checked={localHasDirectLink}
onChange={(e) => setLocalHasDirectLink(e.target.checked)}
/>
}
label={t("file.directLinkExisted")}
/>
<SmallFormControlLabel
control={
<StyledCheckbox
disableRipple
size="small"
checked={localIsUploading}
onChange={(e) => setLocalIsUploading(e.target.checked)}
/>
}
label={t("file.isUploading")}
/>
</Stack>
</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 FileFilterPopover;

View File

@@ -0,0 +1,261 @@
import { Box, Checkbox, IconButton, Link, Skeleton, TableCell, TableRow, Tooltip } from "@mui/material";
import { useMemo, useState } from "react";
import { useTranslation } from "react-i18next";
import { useNavigate } from "react-router-dom";
import { batchDeleteFiles, getFileUrl } from "../../../api/api";
import { File } from "../../../api/dashboard";
import { FileType, Metadata } from "../../../api/explorer";
import { useAppDispatch } from "../../../redux/hooks";
import { Viewers } from "../../../redux/siteConfigSlice";
import { confirmOperation } from "../../../redux/thunks/dialog";
import { fileExtension, sizeToString } from "../../../util";
import { NoWrapTableCell, NoWrapTypography } from "../../Common/StyledComponents";
import TimeBadge from "../../Common/TimeBadge";
import UserAvatar from "../../Common/User/UserAvatar";
import FileTypeIcon from "../../FileManager/Explorer/FileTypeIcon";
import UploadingTag from "../../FileManager/Explorer/UploadingTag";
import Delete from "../../Icons/Delete";
import LinkIcon from "../../Icons/LinkOutlined";
import Open from "../../Icons/Open";
import Share from "../../Icons/Share";
export interface FileRowProps {
file?: File;
loading?: boolean;
deleting?: boolean;
selected?: boolean;
onDelete?: () => void;
onDetails?: (id: number) => void;
onSelect?: (id: number) => void;
openUserDialog?: (id: number) => void;
}
const FileRow = ({
file,
loading,
deleting,
selected,
onDelete,
onDetails,
onSelect,
openUserDialog,
}: FileRowProps) => {
const navigate = useNavigate();
const { t } = useTranslation("dashboard");
const dispatch = useAppDispatch();
const [deleteLoading, setDeleteLoading] = useState(false);
const [openLoading, setOpenLoading] = useState(false);
const onRowClick = () => {
onDetails?.(file?.id ?? 0);
};
const onDeleteClick = (e: React.MouseEvent<HTMLButtonElement>) => {
e.stopPropagation();
e.preventDefault();
dispatch(confirmOperation(t("file.confirmDelete", { file: file?.name }))).then(() => {
if (file?.id) {
setDeleteLoading(true);
dispatch(batchDeleteFiles({ ids: [file.id] }))
.then(() => {
onDelete?.();
})
.finally(() => {
setDeleteLoading(false);
});
}
});
};
const onOpenClick = (e: React.MouseEvent<HTMLButtonElement>) => {
e.stopPropagation();
setOpenLoading(true);
dispatch(getFileUrl(file?.id ?? 0))
.then((url) => {
const ext = fileExtension(file?.name ?? "");
let hasViewer = false;
try {
// check Viewers object is loaded and valid
if (ext && Viewers && typeof Viewers === 'object' && Viewers[ext]) {
hasViewer = Array.isArray(Viewers[ext]) && Viewers[ext].length > 0;
}
} catch (error) {
console.warn('Failed to check viewer availability:', error);
hasViewer = false;
}
if (hasViewer) {
// 可预览文件:新窗口打开预览,窗口保持显示预览内容
window.open(url, "_blank");
} else {
// 下载文件使用a标签的download属性强制下载
const link = document.createElement('a');
link.href = url;
link.download = file?.name || `file-${file?.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 file URL:', error);
});
};
const onSelectClick = (e: React.MouseEvent<HTMLElement>) => {
e.stopPropagation();
e.preventDefault();
onSelect?.(file?.id ?? 0);
};
const uploading = useMemo(() => {
return (
file?.edges?.metadata && file?.edges?.metadata.some((metadata) => metadata.name === Metadata.upload_session_id)
);
}, [file?.edges?.metadata]);
if (loading) {
return (
<TableRow sx={{ height: "43px" }}>
<NoWrapTableCell>
<Skeleton variant="circular" width={24} height={24} />
</NoWrapTableCell>
<NoWrapTableCell>
<Skeleton variant="text" width={30} />
</NoWrapTableCell>
<NoWrapTableCell>
<Box sx={{ display: "flex", alignItems: "center", gap: 1 }}>
<Skeleton variant="circular" width={24} height={24} />
<Skeleton variant="text" width={200} />
</Box>
</NoWrapTableCell>
<NoWrapTableCell>
<Skeleton variant="text" width={50} />
</NoWrapTableCell>
<NoWrapTableCell>
<Skeleton variant="text" width={50} />
</NoWrapTableCell>
<NoWrapTableCell>
<Skeleton variant="text" width={100} />
</NoWrapTableCell>
<NoWrapTableCell>
<Skeleton variant="text" width={100} />
</NoWrapTableCell>
<NoWrapTableCell>
<Skeleton variant="circular" width={24} height={24} />
</NoWrapTableCell>
</TableRow>
);
}
const stopPropagation = (e: React.MouseEvent<HTMLElement>) => {
e.stopPropagation();
};
const userClicked = (e: React.MouseEvent<HTMLElement>) => {
e.stopPropagation();
e.preventDefault();
openUserDialog?.(file?.owner_id ?? 0);
};
const sizeUsed = useMemo(() => {
return sizeToString(file?.edges?.entities?.reduce((acc, entity) => acc + (entity.size ?? 0), 0) ?? 0);
}, [file?.edges?.entities]);
return (
<TableRow hover key={file?.id} sx={{ cursor: "pointer" }} onClick={onRowClick} selected={selected}>
<TableCell padding="checkbox">
<Checkbox
disabled={deleting}
size="small"
disableRipple
color="primary"
onClick={onSelectClick}
checked={selected}
/>
</TableCell>
<NoWrapTableCell>
<NoWrapTypography variant="inherit">{file?.id}</NoWrapTypography>
</NoWrapTableCell>
<NoWrapTableCell>
<Box sx={{ display: "flex", alignItems: "center", gap: 1 }}>
<FileTypeIcon name={file?.name ?? ""} fileType={FileType.file} />
<NoWrapTypography variant="inherit">{file?.name}</NoWrapTypography>
<Box sx={{ display: "flex", alignItems: "center", gap: 0.5 }}>
{uploading && <UploadingTag disabled />}
{file?.edges?.direct_links?.length && (
<Tooltip title={t("file.haveDirectLinks")}>
<Box sx={{ display: "flex", alignItems: "center" }}>
<LinkIcon fontSize="small" sx={{ color: "text.secondary" }} />
</Box>
</Tooltip>
)}
{file?.edges?.shares?.length && (
<Tooltip title={t("file.haveShares")}>
<Box sx={{ display: "flex", alignItems: "center" }}>
<Share fontSize="small" sx={{ color: "text.secondary" }} />
</Box>
</Tooltip>
)}
</Box>
</Box>
</NoWrapTableCell>
<NoWrapTableCell>
<NoWrapTypography variant="inherit">{sizeToString(file?.size ?? 0)}</NoWrapTypography>
</NoWrapTableCell>
<NoWrapTableCell>
<NoWrapTypography variant="inherit">{sizeUsed}</NoWrapTypography>
</NoWrapTableCell>
<NoWrapTableCell>
<Box sx={{ display: "flex", alignItems: "center", gap: 1 }}>
<UserAvatar
sx={{ width: 24, height: 24 }}
overwriteTextSize
user={{
id: file?.user_hash_id ?? "",
nickname: file?.edges?.owner?.nick ?? "",
created_at: file?.edges?.owner?.created_at ?? "",
}}
/>
<NoWrapTypography variant="inherit">
<Link
sx={{
whiteSpace: "nowrap",
overflow: "hidden",
textOverflow: "ellipsis",
}}
onClick={userClicked}
underline="hover"
href="#/"
>
{file?.edges?.owner?.nick}
</Link>
</NoWrapTypography>
</Box>
</NoWrapTableCell>
<NoWrapTableCell>
<NoWrapTypography variant="inherit">
<TimeBadge datetime={file?.created_at ?? ""} variant="inherit" timeAgoThreshold={0} />
</NoWrapTypography>
</NoWrapTableCell>
<NoWrapTableCell>
<Box sx={{ display: "flex", alignItems: "center", gap: 1 }}>
<IconButton size="small" onClick={onOpenClick} disabled={openLoading || deleting}>
<Open fontSize="small" />
</IconButton>
<IconButton size="small" onClick={onDeleteClick} disabled={deleteLoading || deleting}>
<Delete fontSize="small" />
</IconButton>
</Box>
</NoWrapTableCell>
</TableRow>
);
};
export default FileRow;

View File

@@ -0,0 +1,353 @@
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 { batchDeleteFiles, getFlattenFileList } from "../../../api/api";
import { File } from "../../../api/dashboard";
import { Metadata } from "../../../api/explorer";
import { useAppDispatch } from "../../../redux/hooks";
import { confirmOperation } from "../../../redux/thunks/dialog";
import { NoWrapTableCell, SecondaryButton, StyledTableContainerPaper } from "../../Common/StyledComponents";
import ArrowImport from "../../Icons/ArrowImport";
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 FileDialog from "./FileDialog/FileDialog";
import FileFilterPopover from "./FileFilterPopover";
import FileRow from "./FileRow";
import { ImportFileDialog } from "./ImportFileDialog";
export const StoragePolicyQuery = "storage_policy";
export const OwnerQuery = "owner";
export const NameQuery = "name";
export const HasDirectLinkQuery = "has_direct_link";
export const SharedQuery = "shared";
export const UploadingQuery = "uploading";
const FileSetting = () => {
const { t } = useTranslation("dashboard");
const theme = useTheme();
const isMobile = useMediaQuery(theme.breakpoints.down("sm"));
const dispatch = useAppDispatch();
const [loading, setLoading] = useState(true);
const [files, setFiles] = useState<File[]>([]);
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 [owner, setOwner] = useQueryState(OwnerQuery, { defaultValue: "" });
const [name, setName] = useQueryState(NameQuery, { defaultValue: "" });
const [hasDirectLink, setHasDirectLink] = useQueryState(HasDirectLinkQuery, { defaultValue: "" });
const [shared, setShared] = useQueryState(SharedQuery, { defaultValue: "" });
const [uploading, setUploading] = useQueryState(UploadingQuery, { defaultValue: "" });
const [count, setCount] = useState(0);
const [selected, setSelected] = useState<readonly number[]>([]);
const [createNewOpen, setCreateNewOpen] = useState(false);
const [importFileDialogOpen, setImportFileDialogOpen] = useState(false);
const filterPopupState = usePopupState({
variant: "popover",
popupId: "userFilterPopover",
});
const [userDialogOpen, setUserDialogOpen] = useState(false);
const [userDialogID, setUserDialogID] = useState<number | undefined>(undefined);
const [fileDialogOpen, setFileDialogOpen] = useState(false);
const [fileDialogID, setFileDialogID] = useState<number | undefined>(undefined);
const [deleteLoading, setDeleteLoading] = useState(false);
const pageInt = parseInt(page) ?? 1;
const pageSizeInt = parseInt(pageSize) ?? 11;
const clearFilters = useCallback(() => {
setStoragePolicy("");
setOwner("");
setName("");
setHasDirectLink("");
setShared("");
setUploading("");
}, [setStoragePolicy, setOwner, setName, setHasDirectLink, setShared, setUploading]);
useEffect(() => {
fetchFiles();
}, [page, pageSize, orderBy, orderDirection, storagePolicy, owner, name, hasDirectLink, shared, uploading]);
const fetchFiles = () => {
setLoading(true);
setSelected([]);
dispatch(
getFlattenFileList({
page: pageInt,
page_size: pageSizeInt,
order_by: orderBy ?? "",
order_direction: orderDirection ?? "desc",
conditions: {
file_policy: storagePolicy,
file_user: owner,
file_name: name,
file_direct_link: hasDirectLink === "true" ? "true" : "",
file_shared: shared === "true" ? "true" : "",
file_metadata: uploading === "true" ? Metadata.upload_session_id : "",
},
}),
)
.then((res) => {
setFiles(res.files);
setPageSize(res.pagination.page_size.toString());
setCount(res.pagination.total_items ?? 0);
setLoading(false);
})
.finally(() => {
setLoading(false);
});
};
const handleDelete = () => {
setDeleteLoading(true);
dispatch(confirmOperation(t("file.confirmBatchDelete", { num: selected.length })))
.then(() => {
dispatch(batchDeleteFiles({ ids: Array.from(selected) }))
.then(() => {
fetchFiles();
})
.finally(() => {
setDeleteLoading(false);
});
})
.finally(() => {
setDeleteLoading(false);
});
};
const handleSelectAllClick = (event: React.ChangeEvent<HTMLInputElement>) => {
if (event.target.checked) {
const newSelected = files.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 || owner || name || hasDirectLink || shared || uploading);
}, [storagePolicy, owner, name, hasDirectLink, shared, uploading]);
const handleFileDialogOpen = (id: number) => {
setFileDialogID(id);
setFileDialogOpen(true);
};
const handleUserDialogOpen = (id: number) => {
setUserDialogID(id);
setUserDialogOpen(true);
};
return (
<PageContainer>
{/* <NewUserDialog
open={createNewOpen}
onClose={() => setCreateNewOpen(false)}
onCreated={(user) => {
setUserDialogID(user.id);
setUserDialogOpen(true);
}}
/> */}
<FileDialog
open={fileDialogOpen}
onClose={() => setFileDialogOpen(false)}
fileID={fileDialogID}
onUpdated={(file) => {
setFileDialogID(file.id);
setFileDialogOpen(true);
}}
/>
<UserDialog open={userDialogOpen} onClose={() => setUserDialogOpen(false)} userID={userDialogID} />
<ImportFileDialog open={importFileDialogOpen} onClose={() => setImportFileDialogOpen(false)} />
<Container maxWidth="xl">
<PageHeader title={t("dashboard:nav.files")} />
<Stack direction="row" spacing={1} sx={{ mb: 2 }}>
<Button onClick={() => setImportFileDialogOpen(true)} variant={"contained"} startIcon={<ArrowImport />}>
{t("file.import")}
</Button>
<FileFilterPopover
{...bindPopover(filterPopupState)}
storagePolicy={storagePolicy}
setStoragePolicy={setStoragePolicy}
owner={owner}
setOwner={setOwner}
name={name}
setName={setName}
clearFilters={clearFilters}
hasDirectLink={hasDirectLink === "true"}
setHasDirectLink={(value: boolean) => setHasDirectLink(value ? "true" : "")}
hasShareLink={shared === "true"}
setHasShareLink={(value: boolean) => setShared(value ? "true" : "")}
isUploading={uploading === "true"}
setIsUploading={(value: boolean) => setUploading(value ? "true" : "")}
/>
<SecondaryButton onClick={fetchFiles} 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("file.deleteXFiles", { 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("file.deleteXFiles", { 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"
disableRipple
color="primary"
indeterminate={selected.length > 0 && selected.length < files.length}
checked={files.length > 0 && selected.length === files.length}
onChange={handleSelectAllClick}
/>
</TableCell>
<NoWrapTableCell width={60} sortDirection={orderById ? direction : false}>
<TableSortLabel active={orderById} direction={direction} onClick={onSortClick("id")}>
{t("group.#")}
</TableSortLabel>
</NoWrapTableCell>
<NoWrapTableCell width={300}>
<TableSortLabel active={orderBy === "name"} direction={direction} onClick={onSortClick("name")}>
{t("file.name")}
</TableSortLabel>
</NoWrapTableCell>
<NoWrapTableCell width={70}>
<TableSortLabel active={orderBy === "size"} direction={direction} onClick={onSortClick("size")}>
{t("file.size")}
</TableSortLabel>
</NoWrapTableCell>
<NoWrapTableCell width={70}>{t("file.sizeUsed")}</NoWrapTableCell>
<NoWrapTableCell width={150}>{t("file.uploader")}</NoWrapTableCell>
<NoWrapTableCell width={150}>
<TableSortLabel
active={orderBy === "created_at"}
direction={direction}
onClick={onSortClick("created_at")}
>
{t("file.createdAt")}
</TableSortLabel>
</NoWrapTableCell>
<NoWrapTableCell width={100} align="right"></NoWrapTableCell>
</TableRow>
</TableHead>
<TableBody>
{!loading &&
files.map((file) => (
<FileRow
deleting={deleteLoading}
key={file.id}
file={file}
onDelete={fetchFiles}
selected={selected.includes(file.id)}
onSelect={handleSelect}
onDetails={handleFileDialogOpen}
openUserDialog={handleUserDialogOpen}
/>
))}
{loading &&
files.length > 0 &&
files.slice(0, 10).map((file) => <FileRow key={`loading-${file.id}`} loading={true} />)}
{loading &&
files.length === 0 &&
Array.from(Array(10)).map((_, index) => <FileRow 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 FileSetting;

View File

@@ -0,0 +1,190 @@
import { Alert, AlertTitle, Checkbox, DialogContent, Stack, Typography } from "@mui/material";
import { useSnackbar } from "notistack";
import { useEffect, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import { sendImport } from "../../../api/api";
import { User } from "../../../api/user";
import { useAppDispatch } from "../../../redux/hooks";
import { DefaultCloseAction } from "../../Common/Snackbar/snackbar";
import { DenseFilledTextField, SmallFormControlLabel } from "../../Common/StyledComponents";
import DraggableDialog from "../../Dialogs/DraggableDialog";
import SettingForm from "../../Pages/Setting/SettingForm";
import SinglePolicySelectionInput from "../Common/SinglePolicySelectionInput";
import { NoMarginHelperText } from "../Settings/Settings";
import UserSearchInput from "./UserSearchInput";
// TODO: Add API call for creating import task
export interface ImportFileDialogProps {
open: boolean;
onClose: () => void;
}
interface ImportTaskForm {
storagePolicyId: number | undefined;
externalPath: string;
recursive: boolean;
targetUser: User | null;
targetPath: string;
extractMediaMeta: boolean | undefined;
}
const defaultForm: ImportTaskForm = {
storagePolicyId: undefined,
externalPath: "",
recursive: true,
targetUser: null,
targetPath: "/",
extractMediaMeta: false,
};
export const ImportFileDialog = ({ open, onClose }: ImportFileDialogProps) => {
const { t } = useTranslation("dashboard");
const dispatch = useAppDispatch();
const { enqueueSnackbar } = useSnackbar();
const [loading, setLoading] = useState(false);
const [formState, setFormState] = useState<ImportTaskForm>({ ...defaultForm });
const formRef = useRef<HTMLFormElement>(null);
useEffect(() => {
if (open) {
setFormState({ ...defaultForm });
}
}, [open]);
const handleSubmit = () => {
if (!formRef.current?.checkValidity()) {
formRef.current?.reportValidity();
return;
}
if (!formState.targetUser) {
enqueueSnackbar(t("file.pleaseSelectUser"), { variant: "error", action: DefaultCloseAction });
return;
}
setLoading(true);
dispatch(
sendImport({
src: formState.externalPath,
dst: formState.targetPath,
extract_media_meta: false,
user_id: formState.targetUser.id,
recursive: formState.recursive,
policy_id: formState.storagePolicyId ?? 0,
}),
)
.then(() => {
enqueueSnackbar(t("file.importTaskCreated"), { variant: "success", action: DefaultCloseAction });
onClose();
})
.finally(() => {
setLoading(false);
});
};
const handlePolicyChange = (value: number) => {
setFormState({ ...formState, storagePolicyId: value });
};
const handleUserSelected = (user: User) => {
setFormState({ ...formState, targetUser: user });
};
return (
<DraggableDialog
onAccept={handleSubmit}
loading={loading}
title={t("file.importExternalFolder")}
showActions
showCancel
dialogProps={{
open,
onClose,
fullWidth: true,
maxWidth: "sm",
}}
>
<DialogContent>
<form
ref={formRef}
onSubmit={(e) => {
e.preventDefault();
handleSubmit();
}}
>
<Stack spacing={2}>
<Alert severity="info" sx={{ fontSize: (theme) => theme.typography.body2.fontSize }}>
{t("file.importExternalFolderDes")}
</Alert>
<Alert severity="warning" sx={{ fontSize: (theme) => theme.typography.body2.fontSize }}>
<AlertTitle>{t("file.importWarning")}</AlertTitle>
<ul style={{ paddingInlineStart: "20px" }}>
{t("file.importWarnings", { returnObjects: true }).map((warning, index) => (
<li key={index}>{warning.toString()}</li>
))}
</ul>
</Alert>
<SettingForm lgWidth={12} title={t("file.storagePolicy")}>
<SinglePolicySelectionInput value={formState.storagePolicyId} onChange={handlePolicyChange} />
<NoMarginHelperText>{t("file.storagePolicyDes")}</NoMarginHelperText>
</SettingForm>
<SettingForm lgWidth={12} title={t("file.srcFolderPath")}>
<DenseFilledTextField
fullWidth
required
placeholder={"/path/to/folder"}
value={formState.externalPath}
onChange={(e) => setFormState({ ...formState, externalPath: e.target.value })}
/>
<NoMarginHelperText>{t("file.selectSrcDes")}</NoMarginHelperText>
</SettingForm>
<SettingForm lgWidth={12} title={t("file.targetUser")}>
<UserSearchInput onUserSelected={handleUserSelected} label={t("file.searchUser")} required />
<NoMarginHelperText>{t("file.targetUserDes")}</NoMarginHelperText>
</SettingForm>
<SettingForm lgWidth={12} title={t("file.dstFolderPath")}>
<DenseFilledTextField
fullWidth
required
value={formState.targetPath}
onChange={(e) => setFormState({ ...formState, targetPath: e.target.value })}
placeholder={"/"}
/>
<NoMarginHelperText>{t("file.dstFolderPathDes")}</NoMarginHelperText>
</SettingForm>
<SettingForm lgWidth={12}>
<SmallFormControlLabel
control={
<Checkbox
size={"small"}
checked={formState.recursive}
onChange={(e) => setFormState({ ...formState, recursive: e.target.checked })}
/>
}
labelPlacement="end"
label={<Typography variant="body2">{t("file.recursivelyImport")}</Typography>}
/>
<NoMarginHelperText>{t("file.recursivelyImportDes")}</NoMarginHelperText>
</SettingForm>
<SettingForm lgWidth={12}>
<SmallFormControlLabel
control={
<Checkbox
size={"small"}
checked={formState.extractMediaMeta}
onChange={(e) => setFormState({ ...formState, extractMediaMeta: e.target.checked })}
/>
}
labelPlacement="end"
label={<Typography variant="body2">{t("file.extractMediaMeta")}</Typography>}
/>
<NoMarginHelperText>{t("file.extractMediaMetaDes")}</NoMarginHelperText>
</SettingForm>
</Stack>
</form>
</DialogContent>
</DraggableDialog>
);
};
export default ImportFileDialog;

View File

@@ -0,0 +1,142 @@
import { Box, createFilterOptions, debounce, useTheme } from "@mui/material";
import { useEffect, useMemo, useState } from "react";
import { useTranslation } from "react-i18next";
import { getSearchUser } from "../../../api/api.ts";
import { User } from "../../../api/user.ts";
import { useAppDispatch } from "../../../redux/hooks.ts";
import {
DenseAutocomplete,
DenseFilledTextField,
NoWrapBox,
NoWrapTypography,
} from "../../Common/StyledComponents.tsx";
import UserAvatar from "../../Common/User/UserAvatar.tsx";
export interface UserSearchInputProps {
onUserSelected: (user: User) => void;
label?: string;
required?: boolean;
}
const UserSearchInput = (props: UserSearchInputProps) => {
const theme = useTheme();
const { t } = useTranslation(["dashboard", "application"]);
const [value, setValue] = useState<User | null>(null);
const [inputValue, setInputValue] = useState("");
const [options, setOptions] = useState<readonly User[]>([]);
const [loading, setLoading] = useState(false);
const dispatch = useAppDispatch();
const fetch = useMemo(
() =>
debounce((request: { input: string }, callback: (results?: readonly User[]) => void) => {
setLoading(true);
dispatch(getSearchUser(request.input))
.then((results: readonly User[]) => {
callback(results);
})
.finally(() => {
setLoading(false);
});
}, 400),
[dispatch],
);
useEffect(() => {
let active = true;
if (inputValue === "" || inputValue.length < 2) {
setOptions([]);
return undefined;
}
fetch({ input: inputValue }, (results?: readonly User[]) => {
if (active) {
setOptions(results ?? []);
}
});
return () => {
active = false;
};
}, [value, inputValue, fetch]);
const filterOptions = useMemo(() => {
return createFilterOptions<User>({
stringify: (option) => option.nickname + " " + option.email,
});
}, []);
return (
<DenseAutocomplete
value={value}
filterOptions={filterOptions}
options={options}
loading={loading}
blurOnSelect
onChange={(_event: any, newValue: User | null) => {
setValue(newValue);
if (newValue) {
props.onUserSelected(newValue);
}
}}
onInputChange={(_event, newInputValue) => {
setInputValue(newInputValue);
}}
getOptionLabel={(option) => (typeof option === "string" ? option : `${option.nickname} <${option.email}>`)}
noOptionsText={t("application:modals.noResults")}
renderOption={(props, option) => {
return (
<li {...props}>
<Box sx={{ display: "flex", width: "100%", alignItems: "center" }}>
<Box>
<UserAvatar user={option} />
</Box>
<NoWrapBox
sx={{
width: "100%",
ml: 2,
}}
>
{option.nickname}
{option.email && (
<NoWrapTypography
sx={{
width: "100%",
}}
variant="body2"
color="text.secondary"
>
{option.email}
</NoWrapTypography>
)}
</NoWrapBox>
</Box>
</li>
);
}}
renderInput={(params) => (
<DenseFilledTextField
{...params}
sx={{
"& .MuiInputBase-root": {},
"& .MuiInputBase-root.MuiOutlinedInput-root": {
paddingTop: theme.spacing(0.6),
paddingBottom: theme.spacing(0.6),
},
mt: 0,
}}
required={props.required}
variant="outlined"
margin="dense"
placeholder={props.label || t("dashboard:file.selectUser")}
type="text"
fullWidth
/>
)}
/>
);
};
export default UserSearchInput;

View File

@@ -0,0 +1,207 @@
import {
Box,
ListItemIcon,
Menu,
Stack,
Table,
TableBody,
TableContainer,
TableHead,
TableRow,
Typography,
} from "@mui/material";
import { bindMenu, bindTrigger, usePopupState } from "material-ui-popup-state/hooks";
import { createRef, useCallback, useContext, useEffect, useState } from "react";
import { DndProvider } from "react-dnd";
import { HTML5Backend } from "react-dnd-html5-backend";
import { useTranslation } from "react-i18next";
import { CustomProps, CustomPropsType } from "../../../../api/explorer";
import { NoWrapCell, SecondaryButton, StyledTableContainerPaper } from "../../../Common/StyledComponents";
import { SquareMenuItem } from "../../../FileManager/ContextMenu/ContextMenu";
import Add from "../../../Icons/Add";
import { ProChip } from "../../../Pages/Setting/SettingForm";
import ProDialog from "../../Common/ProDialog";
import { SettingContext } from "../../Settings/SettingWrapper";
import DraggableCustomPropsRow, { FieldTypes } from "./DraggableCustomPropsRow";
import EditPropsDialog from "./EditPropsDialog";
const CustomPropsSetting = () => {
const { t } = useTranslation("dashboard");
const { formRef, setSettings, values } = useContext(SettingContext);
const [customProps, setCustomProps] = useState<CustomProps[]>([]);
const [open, setOpen] = useState(false);
const [isNew, setIsNew] = useState(false);
const [editProps, setEditProps] = useState<CustomProps | undefined>(undefined);
const [proOpen, setProOpen] = useState(false);
const newPropsPopupState = usePopupState({
variant: "popover",
popupId: "newProp",
});
const { onClose, ...menuProps } = bindMenu(newPropsPopupState);
useEffect(() => {
try {
setCustomProps(JSON.parse(values.custom_props || "[]"));
} catch {
setCustomProps([]);
}
}, [values.custom_props]);
const onChange = useCallback(
(customProps: CustomProps[]) => {
setSettings({
custom_props: JSON.stringify(customProps),
});
},
[setSettings],
);
const handleDeleteProduct = useCallback(
(id: string) => {
const newCustomProps = customProps.filter((p) => p.id !== id);
setCustomProps(newCustomProps);
onChange(newCustomProps);
},
[customProps, onChange],
);
const handleSave = useCallback(
(props: CustomProps) => {
const existingIndex = customProps.findIndex((p) => p.id === props.id);
let newCustomProps: CustomProps[];
if (existingIndex >= 0) {
newCustomProps = [...customProps];
newCustomProps[existingIndex] = props;
} else {
newCustomProps = [...customProps, props];
}
setCustomProps(newCustomProps);
onChange(newCustomProps);
},
[customProps, onChange],
);
const moveRow = useCallback(
(from: number, to: number) => {
if (from === to) return;
const updated = [...customProps];
const [moved] = updated.splice(from, 1);
updated.splice(to, 0, moved);
setCustomProps(updated);
onChange(updated);
},
[customProps, onChange],
);
const handleMoveUp = (idx: number) => {
if (idx <= 0) return;
moveRow(idx, idx - 1);
};
const handleMoveDown = (idx: number) => {
if (idx >= customProps.length - 1) return;
moveRow(idx, idx + 1);
};
const onNewProp = (type: CustomPropsType) => {
setEditProps({
type,
id: "",
name: "",
default: "",
});
setIsNew(true);
setOpen(true);
onClose();
};
return (
<Box component={"form"} ref={formRef} onSubmit={(e) => e.preventDefault()}>
<Stack spacing={1}>
<ProDialog open={proOpen} onClose={() => setProOpen(false)} />
<Box sx={{ display: "flex", justifyContent: "space-between", alignItems: "center", mb: 1 }}>
<SecondaryButton variant="contained" startIcon={<Add />} {...bindTrigger(newPropsPopupState)}>
{t("customProps.add")}
</SecondaryButton>
<Menu
onClose={onClose}
anchorOrigin={{
vertical: "bottom",
horizontal: "left",
}}
transformOrigin={{
vertical: "top",
horizontal: "left",
}}
{...menuProps}
>
{(Object.keys(FieldTypes) as CustomPropsType[]).map((type, index) => {
const fieldType = FieldTypes[type];
const Icon = fieldType.icon;
return (
<SquareMenuItem dense key={index} onClick={() => (fieldType.pro ? setProOpen(true) : onNewProp(type))}>
<ListItemIcon>
<Icon />
</ListItemIcon>
{t(fieldType.title)}
{fieldType.pro && <ProChip label="Pro" color="primary" size="small" />}
</SquareMenuItem>
);
})}
</Menu>
</Box>
<TableContainer component={StyledTableContainerPaper}>
<DndProvider backend={HTML5Backend}>
<Table sx={{ width: "100%" }} size="small">
<TableHead>
<TableRow>
<NoWrapCell>{t("settings.displayName")}</NoWrapCell>
<NoWrapCell>{t("customProps.type")}</NoWrapCell>
<NoWrapCell>{t("customProps.default")}</NoWrapCell>
<NoWrapCell>{t("settings.actions")}</NoWrapCell>
<NoWrapCell></NoWrapCell>
</TableRow>
</TableHead>
<TableBody>
{customProps.map((prop, idx) => {
const rowRef = createRef<HTMLTableRowElement>();
return (
<DraggableCustomPropsRow
key={prop.id}
ref={rowRef}
customProps={prop}
index={idx}
moveRow={moveRow}
onEdit={(props) => {
setEditProps(props);
setIsNew(false);
setOpen(true);
}}
onDelete={handleDeleteProduct}
onMoveUp={() => handleMoveUp(idx)}
onMoveDown={() => handleMoveDown(idx)}
isFirst={idx === 0}
isLast={idx === customProps.length - 1}
t={t}
/>
);
})}
{customProps.length === 0 && (
<TableRow>
<NoWrapCell colSpan={6} align="center">
<Typography variant="caption" color="text.secondary">
{t("application:setting.listEmpty")}
</Typography>
</NoWrapCell>
</TableRow>
)}
</TableBody>
</Table>
</DndProvider>
</TableContainer>
</Stack>
<EditPropsDialog open={open} onClose={() => setOpen(false)} onSave={handleSave} isNew={isNew} props={editProps} />
</Box>
);
};
export default CustomPropsSetting;

View File

@@ -0,0 +1,209 @@
import { Icon } from "@iconify/react/dist/iconify.js";
import { Box, IconButton, SvgIconProps, TableRow, useTheme } from "@mui/material";
import React from "react";
import { useDrag, useDrop } from "react-dnd";
import { CustomProps, CustomPropsType } from "../../../../api/explorer";
import { NoWrapCell } from "../../../Common/StyledComponents";
import { getPropsContent } from "../../../FileManager/Sidebar/CustomProps/CustomPropsItem";
import ArrowDown from "../../../Icons/ArrowDown";
import CheckboxChecked from "../../../Icons/CheckboxChecked";
import DataBarVerticalStar from "../../../Icons/DataBarVerticalStar";
import Dismiss from "../../../Icons/Dismiss";
import Edit from "../../../Icons/Edit";
import LinkOutlined from "../../../Icons/LinkOutlined";
import Numbers from "../../../Icons/Numbers";
import PersonOutlined from "../../../Icons/PersonOutlined";
import SlideText from "../../../Icons/SlideText";
import TaskListRegular from "../../../Icons/TaskListRegular";
import TextIndentIncrease from "../../../Icons/TextIndentIncrease";
const DND_TYPE = "storage-product-row";
// 拖拽item类型
type DragItem = { index: number };
export interface DraggableCustomPropsRowProps {
customProps: CustomProps;
index: number;
moveRow: (from: number, to: number) => void;
onEdit: (customProps: CustomProps) => void;
onDelete: (id: string) => void;
onMoveUp: () => void;
onMoveDown: () => void;
isFirst: boolean;
isLast: boolean;
t: any;
style?: React.CSSProperties;
}
export interface FieldTypeProps {
title: string;
icon: (props: SvgIconProps) => JSX.Element;
minTitle?: string;
minDes?: string;
maxTitle?: string;
maxDes?: string;
maxRequired?: boolean;
showOptions?: boolean;
pro?: boolean;
}
export const FieldTypes: Record<CustomPropsType, FieldTypeProps> = {
[CustomPropsType.text]: {
title: "customProps.text",
icon: SlideText,
minTitle: "customProps.minLength",
minDes: "customProps.emptyLimit",
maxTitle: "customProps.maxLength",
maxDes: "customProps.emptyLimit",
},
[CustomPropsType.number]: {
title: "customProps.number",
icon: Numbers,
minTitle: "customProps.minValue",
minDes: "customProps.emptyLimit",
maxTitle: "customProps.maxValue",
maxDes: "customProps.emptyLimit",
},
[CustomPropsType.boolean]: {
title: "customProps.boolean",
icon: CheckboxChecked,
},
[CustomPropsType.select]: {
title: "customProps.select",
icon: TextIndentIncrease,
showOptions: true,
},
[CustomPropsType.multi_select]: {
title: "customProps.multiSelect",
icon: TaskListRegular,
showOptions: true,
},
[CustomPropsType.user]: {
title: "customProps.user",
icon: PersonOutlined,
pro: true,
},
[CustomPropsType.link]: {
title: "customProps.link",
icon: LinkOutlined,
minTitle: "customProps.minLength",
minDes: "customProps.emptyLimit",
maxTitle: "customProps.maxLength",
maxDes: "customProps.emptyLimit",
},
[CustomPropsType.rating]: {
title: "customProps.rating",
icon: DataBarVerticalStar,
maxRequired: true,
maxTitle: "customProps.maxValue",
},
};
const DraggableCustomPropsRow = React.memo(
React.forwardRef<HTMLTableRowElement, DraggableCustomPropsRowProps>(
(
{ customProps, index, moveRow, onEdit, onDelete, onMoveUp, onMoveDown, isFirst, isLast, t, style },
ref,
): JSX.Element => {
const theme = useTheme();
const [, drop] = useDrop<DragItem>({
accept: DND_TYPE,
hover(item, monitor) {
if (!(ref && typeof ref !== "function" && 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<DragItem, void, { isDragging: boolean }>({
type: DND_TYPE,
item: { index },
collect: (monitor) => ({
isDragging: monitor.isDragging(),
}),
});
// 兼容ref为function和对象
const setRowRef = (node: HTMLTableRowElement | null) => {
if (typeof ref === "function") {
ref(node);
} else if (ref) {
(ref as React.MutableRefObject<HTMLTableRowElement | null>).current = node;
}
drag(drop(node));
};
const fieldType = FieldTypes[customProps.type];
const TypeIcon = fieldType.icon;
return (
<TableRow ref={setRowRef} hover style={{ opacity: isDragging ? 0.5 : 1, cursor: "move", ...style }}>
<NoWrapCell>
<Box sx={{ display: "flex", alignItems: "center", gap: 1 }}>
{customProps.icon && (
<Icon icon={customProps.icon} width={20} height={20} color={theme.palette.action.active} />
)}
{t(customProps.name, { ns: "application" })}
</Box>
</NoWrapCell>
<NoWrapCell>
<Box sx={{ display: "flex", alignItems: "center", gap: 1 }}>
<TypeIcon width={20} height={20} sx={{ color: theme.palette.action.active }} />
{t(fieldType.title)}
</Box>
</NoWrapCell>
<NoWrapCell>
{getPropsContent(
{
props: customProps,
id: customProps.id,
value: customProps.default ?? "",
},
() => {},
false,
true,
)}
</NoWrapCell>
<NoWrapCell>
<IconButton size="small" onClick={() => onEdit(customProps)}>
<Edit fontSize="small" />
</IconButton>
<IconButton size="small" onClick={() => onDelete(customProps.id)}>
<Dismiss fontSize="small" />
</IconButton>
</NoWrapCell>
<NoWrapCell>
<IconButton size="small" onClick={onMoveUp} disabled={isFirst}>
<ArrowDown
sx={{
width: "18px",
height: "18px",
transform: "rotate(180deg)",
}}
/>
</IconButton>
<IconButton size="small" onClick={onMoveDown} disabled={isLast}>
<ArrowDown
sx={{
width: "18px",
height: "18px",
}}
/>
</IconButton>
</NoWrapCell>
</TableRow>
);
},
),
);
export default DraggableCustomPropsRow;

View File

@@ -0,0 +1,179 @@
import { Box, DialogContent, FormControl, Grid2, Link } from "@mui/material";
import { useEffect, useRef, useState } from "react";
import { Trans, useTranslation } from "react-i18next";
import { CustomProps } from "../../../../api/explorer";
import { DenseFilledTextField } from "../../../Common/StyledComponents";
import DraggableDialog from "../../../Dialogs/DraggableDialog";
import { getPropsContent } from "../../../FileManager/Sidebar/CustomProps/CustomPropsItem";
import SettingForm from "../../../Pages/Setting/SettingForm";
import { NoMarginHelperText } from "../../Settings/Settings";
import { FieldTypes } from "./DraggableCustomPropsRow";
interface EditPropsDialogProps {
open: boolean;
onClose: () => void;
onSave: (props: CustomProps) => void;
isNew: boolean;
props?: CustomProps;
}
const EditPropsDialog = ({ open, onClose, onSave, isNew, props }: EditPropsDialogProps) => {
const { t } = useTranslation("dashboard");
const formRef = useRef<HTMLFormElement>(null);
const [editProps, setEditProps] = useState<CustomProps | undefined>(props);
const handleSave = () => {
if (!formRef.current?.checkValidity()) {
formRef.current?.reportValidity();
return;
}
onSave({ ...editProps } as CustomProps);
onClose();
};
useEffect(() => {
if (props) {
setEditProps({ ...props });
}
if (!open) {
setTimeout(() => {
setEditProps(undefined);
}, 100);
}
}, [open, props]);
if (!editProps || !editProps.type) return null;
const fieldType = FieldTypes[editProps?.type];
return (
<DraggableDialog
title={isNew ? t("customProps.addProp") : t("customProps.editProp")}
showActions
showCancel
onAccept={handleSave}
dialogProps={{
open,
onClose,
fullWidth: true,
maxWidth: "sm",
}}
>
<DialogContent>
<Box component={"form"} ref={formRef} sx={{ display: "flex", flexDirection: "column", gap: 2, mt: 1 }}>
<SettingForm title={t("customProps.id")} lgWidth={12}>
<FormControl fullWidth>
<DenseFilledTextField
disabled={!isNew}
slotProps={{
htmlInput: {
max: 128,
pattern: "^[a-zA-Z0-9_-]+$",
title: t("customProps.idPatternDes"),
},
}}
value={editProps?.id || ""}
required
onChange={(e) => setEditProps({ ...editProps, id: e.target.value } as CustomProps)}
/>
<NoMarginHelperText>{t("customProps.idDes")}</NoMarginHelperText>
</FormControl>
</SettingForm>
<SettingForm title={t("settings.displayName")} lgWidth={12}>
<FormControl fullWidth>
<DenseFilledTextField
value={editProps?.name || ""}
onChange={(e) =>
setEditProps({
...editProps,
name: e.target.value,
} as CustomProps)
}
required
/>
<NoMarginHelperText>{t("settings.displayNameDes")}</NoMarginHelperText>
</FormControl>
</SettingForm>
<SettingForm title={t("customProps.icon")} lgWidth={12}>
<FormControl fullWidth>
<DenseFilledTextField
value={editProps?.icon || ""}
onChange={(e) => setEditProps({ ...editProps, icon: e.target.value } as CustomProps)}
/>
<NoMarginHelperText>
{
<Trans
i18nKey="dashboard:customProps.iconDes"
components={[<Link target="_blank" href="https://icon-sets.iconify.design/" />]}
/>
}
</NoMarginHelperText>
</FormControl>
</SettingForm>
{(fieldType.minTitle || fieldType.maxTitle) && (
<Grid2 container spacing={2} size={{ xs: 12 }}>
{fieldType.minTitle && (
<SettingForm title={t(fieldType.minTitle)} lgWidth={6} noContainer>
<FormControl fullWidth>
<DenseFilledTextField
type="number"
value={editProps?.min || ""}
onChange={(e) => setEditProps({ ...editProps, min: parseInt(e.target.value) } as CustomProps)}
/>
{fieldType.minDes && <NoMarginHelperText>{t(fieldType.minDes)}</NoMarginHelperText>}
</FormControl>
</SettingForm>
)}
{fieldType.maxTitle && (
<SettingForm title={t(fieldType.maxTitle)} lgWidth={6} noContainer>
<FormControl fullWidth>
<DenseFilledTextField
type="number"
required={fieldType.maxRequired}
value={editProps?.max || ""}
onChange={(e) => setEditProps({ ...editProps, max: parseInt(e.target.value) } as CustomProps)}
/>
{fieldType.maxDes && <NoMarginHelperText>{t(fieldType.maxDes)}</NoMarginHelperText>}
</FormControl>
</SettingForm>
)}
</Grid2>
)}
{fieldType.showOptions && (
<SettingForm title={t("customProps.options")} lgWidth={12}>
<FormControl fullWidth>
<DenseFilledTextField
multiline
rows={4}
value={editProps?.options?.join("\n") || ""}
onChange={(e) => setEditProps({ ...editProps, options: e.target.value.split("\n") } as CustomProps)}
/>
<NoMarginHelperText>{t("customProps.optionsDes")}</NoMarginHelperText>
</FormControl>
</SettingForm>
)}
<SettingForm title={t("customProps.default")} lgWidth={12}>
<FormControl fullWidth>
{getPropsContent(
{
props: editProps,
id: editProps.id,
value: editProps.default ?? "",
},
(value) => {
setEditProps({ ...editProps, default: value } as CustomProps);
},
false,
false,
true,
)}
</FormControl>
</SettingForm>
</Box>
</DialogContent>
</DraggableDialog>
);
};
export default EditPropsDialog;

View File

@@ -0,0 +1,133 @@
import { Box, Container } from "@mui/material";
import { useQueryState } from "nuqs";
import { useMemo } from "react";
import { useTranslation } from "react-i18next";
import { CSSTransition, SwitchTransition } from "react-transition-group";
import ResponsiveTabs, { Tab } from "../../Common/ResponsiveTabs.tsx";
import AppGeneric from "../../Icons/AppGeneric.tsx";
import Icons from "../../Icons/Icons.tsx";
import SettingsOutlined from "../../Icons/SettingsOutlined.tsx";
import TextBulletListSquareEdit from "../../Icons/TextBulletListSquareEdit.tsx";
import PageContainer from "../../Pages/PageContainer.tsx";
import PageHeader, { PageTabQuery } from "../../Pages/PageHeader.tsx";
import SettingsWrapper from "../Settings/SettingWrapper.tsx";
import CustomPropsSetting from "./CustomProps/CustomPropsSetting.tsx";
import FileIcons from "./Icons/FileIcons.tsx";
import Parameters from "./Parameters.tsx";
import ViewerSetting from "./ViewerSetting/ViewerSetting.tsx";
export enum SettingsPageTab {
Parameters = "parameters",
CustomProps = "customProps",
Icon = "icon",
FileApp = "fileApp",
}
const FileSystem = () => {
const { t } = useTranslation("dashboard");
const [tab, setTab] = useQueryState(PageTabQuery);
const tabs: Tab<SettingsPageTab>[] = useMemo(() => {
const res = [];
res.push(
...[
{
label: t("nav.settings"),
value: SettingsPageTab.Parameters,
icon: <SettingsOutlined />,
},
{
label: t("settings.fileIcons"),
value: SettingsPageTab.Icon,
icon: <Icons />,
},
{
label: t("settings.fileViewers"),
value: SettingsPageTab.FileApp,
icon: <AppGeneric />,
},
{
label: t("nav.customProps"),
value: SettingsPageTab.CustomProps,
icon: <TextBulletListSquareEdit />,
},
],
);
return res;
}, [t]);
return (
<PageContainer>
<Container maxWidth="xl">
<PageHeader title={t("dashboard:nav.fileSystem")} />
<ResponsiveTabs
value={tab ?? SettingsPageTab.Parameters}
onChange={(_e, newValue) => setTab(newValue)}
tabs={tabs}
/>
<SwitchTransition>
<CSSTransition
addEndListener={(node, done) => node.addEventListener("transitionend", done, false)}
classNames="fade"
key={`${tab}`}
>
<Box>
{(!tab || tab === SettingsPageTab.Parameters) && (
<SettingsWrapper
settings={[
"maxEditSize",
"cron_trash_bin_collect",
"cron_entity_collect",
"public_resource_maxage",
"use_cursor_pagination",
"max_page_size",
"max_recursive_searched_folder",
"max_batched_file",
"map_provider",
"map_google_tile_type",
"map_mapbox_ak",
"mime_mapping",
"explorer_category_image_query",
"explorer_category_video_query",
"explorer_category_audio_query",
"explorer_category_document_query",
"archive_timeout",
"upload_session_timeout",
"slave_api_timeout",
"folder_props_timeout",
"chunk_retries",
"use_temp_chunk_buffer",
"max_parallel_transfer",
"cron_oauth_cred_refresh",
"viewer_session_timeout",
"entity_url_default_ttl",
"entity_url_cache_margin",
]}
>
<Parameters />
</SettingsWrapper>
)}
{tab === SettingsPageTab.Icon && (
<SettingsWrapper settings={["explorer_icons", "emojis"]}>
<FileIcons />
</SettingsWrapper>
)}
{tab === SettingsPageTab.FileApp && (
<SettingsWrapper settings={["file_viewers"]}>
<ViewerSetting />
</SettingsWrapper>
)}
{tab === SettingsPageTab.CustomProps && (
<SettingsWrapper settings={["custom_props"]}>
<CustomPropsSetting />
</SettingsWrapper>
)}
</Box>
</CSSTransition>
</SwitchTransition>
</Container>
</PageContainer>
);
};
export default FileSystem;

View File

@@ -0,0 +1,39 @@
import { DenseFilledTextField } from "../../Common/StyledComponents.tsx";
import { InputAdornment } from "@mui/material";
import CircleColorSelector, {
customizeMagicColor,
} from "../../FileManager/FileInfo/ColorCircle/CircleColorSelector.tsx";
import * as React from "react";
export interface HexColorInputProps {
currentColor: string;
onColorChange: (color: string) => void;
required?: boolean;
}
const HexColorInput = ({ currentColor, onColorChange, ...rest }: HexColorInputProps) => {
return (
<DenseFilledTextField
value={currentColor}
onChange={(e) => {
onColorChange(e.target.value);
}}
type="text"
InputProps={{
endAdornment: (
<InputAdornment position="end">
<CircleColorSelector
showColorValueInCustomization
colors={[customizeMagicColor]}
selectedColor={currentColor}
onChange={(color) => onColorChange(color)}
/>
</InputAdornment>
),
}}
{...rest}
/>
);
};
export default HexColorInput;

View File

@@ -0,0 +1,245 @@
import { Box, IconButton, Stack, Table, TableBody, TableContainer, TableHead, TableRow } from "@mui/material";
import React, { memo, useMemo, useState } from "react";
import { DndProvider, useDrag, useDrop } from "react-dnd";
import { HTML5Backend } from "react-dnd-html5-backend";
import { useTranslation } from "react-i18next";
import {
DenseFilledTextField,
NoWrapCell,
NoWrapTableCell,
SecondaryButton,
StyledTableContainerPaper,
} from "../../../Common/StyledComponents.tsx";
import Add from "../../../Icons/Add.tsx";
import ArrowDown from "../../../Icons/ArrowDown.tsx";
import Dismiss from "../../../Icons/Dismiss.tsx";
export interface EmojiListProps {
config: string;
onChange: (value: string) => void;
}
const DND_TYPE = "emoji-row";
interface DraggableEmojiRowProps {
r: string;
i: number;
moveRow: (from: number, to: number) => void;
configParsed: { [key: string]: string[] };
inputCache: { [key: number]: string | undefined };
setInputCache: React.Dispatch<React.SetStateAction<{ [key: number]: string | undefined }>>;
onChange: (value: string) => void;
isFirst: boolean;
isLast: boolean;
t: (key: string) => string;
}
function DraggableEmojiRow({
r,
i,
moveRow,
configParsed,
inputCache,
setInputCache,
onChange,
isFirst,
isLast,
}: DraggableEmojiRowProps) {
const ref = React.useRef<HTMLTableRowElement>(null);
const [, drop] = useDrop({
accept: DND_TYPE,
hover(item: any, monitor) {
if (!ref.current) return;
const dragIndex = item.index;
const hoverIndex = i;
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: i },
collect: (monitor) => ({
isDragging: monitor.isDragging(),
}),
});
drag(drop(ref));
return (
<TableRow
ref={ref}
sx={{ "&:last-child td, &:last-child th": { border: 0 }, opacity: isDragging ? 0.5 : 1, cursor: "move" }}
hover
>
<NoWrapCell>
<DenseFilledTextField
fullWidth
required
value={r}
onChange={(e) => {
const newConfig = {
...configParsed,
[e.target.value]: configParsed[r],
};
delete newConfig[r];
onChange(JSON.stringify(newConfig));
}}
/>
</NoWrapCell>
<NoWrapCell>
<DenseFilledTextField
fullWidth
multiline
required
value={inputCache[i] ?? configParsed[r].join()}
onBlur={() => {
onChange(
JSON.stringify({
...configParsed,
[r]: inputCache[i]?.split(",") ?? configParsed[r],
}),
);
setInputCache({
...inputCache,
[i]: undefined,
});
}}
onChange={(e) =>
setInputCache({
...inputCache,
[i]: e.target.value,
})
}
/>
</NoWrapCell>
<NoWrapCell>
<IconButton
onClick={() => {
const newConfig = {
...configParsed,
};
delete newConfig[r];
onChange(JSON.stringify(newConfig));
}}
size={"small"}
>
<Dismiss fontSize={"small"} />
</IconButton>
<IconButton size="small" onClick={() => moveRow(i, i - 1)} disabled={isFirst}>
<ArrowDown
sx={{
width: "18px",
height: "18px",
transform: "rotate(180deg)",
}}
/>
</IconButton>
<IconButton size="small" onClick={() => moveRow(i, i + 1)} disabled={isLast}>
<ArrowDown
sx={{
width: "18px",
height: "18px",
}}
/>
</IconButton>
</NoWrapCell>
</TableRow>
);
}
const EmojiList = memo(({ config, onChange }: EmojiListProps) => {
const { t } = useTranslation("dashboard");
const [render, setRender] = useState(false);
const configParsed = useMemo((): { [key: string]: string[] } => JSON.parse(config), [config]);
const [inputCache, setInputCache] = useState<{
[key: number]: string | undefined;
}>({});
return (
<Stack spacing={1}>
<Box>
{!render && (
<SecondaryButton variant={"contained"} onClick={() => setRender(!render)}>
{t("settings.showSettings")}
</SecondaryButton>
)}
{render && Object.keys(configParsed ?? {}).length > 0 && (
<DndProvider backend={HTML5Backend}>
<TableContainer sx={{ mt: 1, maxHeight: 440 }} component={StyledTableContainerPaper}>
<Table stickyHeader sx={{ width: "100%", tableLayout: "fixed" }} size="small">
<TableHead>
<TableRow>
<NoWrapTableCell width={64}>{t("settings.category")}</NoWrapTableCell>
<NoWrapTableCell width={200}>{t("settings.emojiOptions")}</NoWrapTableCell>
<NoWrapTableCell width={100}>{t("settings.actions")}</NoWrapTableCell>
</TableRow>
</TableHead>
<TableBody>
{Object.keys(configParsed ?? {}).map((r, i, arr) => (
<DraggableEmojiRow
key={i}
r={r}
i={i}
moveRow={(from, to) => {
if (from === to || to < 0 || to >= arr.length) return;
const keys = Object.keys(configParsed);
const values = Object.values(configParsed);
const [movedKey] = keys.splice(from, 1);
const [movedValue] = values.splice(from, 1);
keys.splice(to, 0, movedKey);
values.splice(to, 0, movedValue);
const newConfig: { [key: string]: string[] } = {};
keys.forEach((k, idx) => {
newConfig[k] = values[idx];
});
onChange(JSON.stringify(newConfig));
}}
configParsed={configParsed}
inputCache={inputCache}
setInputCache={setInputCache}
onChange={onChange}
isFirst={i === 0}
isLast={i === arr.length - 1}
t={t}
/>
))}
</TableBody>
</Table>
</TableContainer>
</DndProvider>
)}
</Box>
{render && (
<Box>
<SecondaryButton
variant={"contained"}
startIcon={<Add />}
onClick={() =>
onChange(
JSON.stringify({
...configParsed,
[""]: [],
}),
)
}
>
{t("settings.addCategorize")}
</SecondaryButton>
</Box>
)}
</Stack>
);
});
export default EmojiList;

View File

@@ -0,0 +1,350 @@
import { Icon } from "@iconify/react/dist/iconify.js";
import {
Box,
IconButton,
InputAdornment,
ListItemText,
Table,
TableBody,
TableContainer,
TableHead,
TableRow,
Typography,
} from "@mui/material";
import { styled, useTheme } from "@mui/material/styles";
import { memo, useMemo, useState } from "react";
import { useTranslation } from "react-i18next";
import {
DenseFilledTextField,
DenseSelect,
NoWrapCell,
NoWrapTableCell,
SecondaryButton,
StyledTableContainerPaper,
} from "../../../Common/StyledComponents.tsx";
import { SquareMenuItem } from "../../../FileManager/ContextMenu/ContextMenu.tsx";
import { builtInIcons, FileTypeIconSetting } from "../../../FileManager/Explorer/FileTypeIcon.tsx";
import Add from "../../../Icons/Add.tsx";
import Dismiss from "../../../Icons/Dismiss.tsx";
import HexColorInput from "../HexColorInput.tsx";
export interface FileIconListProps {
config: string;
onChange: (value: string) => void;
}
export enum IconType {
Image = "imageUrl",
Iconify = "iconifyName",
}
const StyledDenseSelect = styled(DenseSelect)(() => ({
"& .MuiFilledInput-input": {
"&:focus": {
backgroundColor: "initial",
},
},
backgroundColor: "initial",
}));
const IconPreview = ({ icon }: { icon: FileTypeIconSetting }) => {
const theme = useTheme();
const IconComponent = useMemo(() => {
if (icon.icon) {
return builtInIcons[icon.icon];
}
}, [icon.icon]);
const iconColor = useMemo(() => {
if (theme.palette.mode == "dark") {
return icon.color_dark ?? icon.color ?? theme.palette.action.active;
} else {
return icon.color ?? theme.palette.action.active;
}
}, [icon.color, icon.color_dark, theme]);
if (IconComponent) {
return (
<IconComponent
sx={{
color: iconColor,
fontSize: 32,
}}
/>
);
}
// Handle iconify icons
if (icon.iconify) {
return <Icon icon={icon.iconify} color={iconColor} fontSize={32} />;
}
return (
<Box
component={icon.img ? "img" : "div"}
sx={{
width: "32px",
height: "32px",
}}
src={icon.img}
/>
);
};
const FileIconList = memo(({ config, onChange }: FileIconListProps) => {
const { t } = useTranslation("dashboard");
const configParsed = useMemo((): FileTypeIconSetting[] => JSON.parse(config), [config]);
const [inputCache, setInputCache] = useState<{
[key: number]: string | undefined;
}>({});
const [iconUrlCache, setIconUrlCache] = useState<{
[key: number]: string | undefined;
}>({});
const [iconTypeCache, setIconTypeCache] = useState<{
[key: number]: IconType | undefined;
}>({});
return (
<Box>
{configParsed?.length > 0 && (
<TableContainer sx={{ mt: 1, maxHeight: 440 }} component={StyledTableContainerPaper}>
<Table stickyHeader sx={{ width: "100%", tableLayout: "fixed" }} size="small">
<TableHead>
<TableRow>
<NoWrapTableCell width={64}>{t("settings.icon")}</NoWrapTableCell>
<NoWrapTableCell width={250}>{t("settings.iconUrl")}</NoWrapTableCell>
<NoWrapTableCell width={150}>{t("settings.iconColor")}</NoWrapTableCell>
<NoWrapTableCell width={150}>{t("settings.iconColorDark")}</NoWrapTableCell>
<NoWrapTableCell width={200}>{t("settings.exts")}</NoWrapTableCell>
<NoWrapTableCell width={64}></NoWrapTableCell>
</TableRow>
</TableHead>
<TableBody>
{configParsed.map((r, i) => {
const currentIconType =
iconTypeCache[i] ?? (r.img ? IconType.Image : r.iconify ? IconType.Iconify : IconType.Image);
const currentIconUrl =
iconUrlCache[i] ?? (currentIconType === IconType.Image ? r.img : r.iconify) ?? "";
return (
<TableRow key={i} sx={{ "&:last-child td, &:last-child th": { border: 0 } }} hover>
<NoWrapCell>
<IconPreview icon={r} />
</NoWrapCell>
<NoWrapCell>
{!r.icon ? (
<DenseFilledTextField
fullWidth
required
sx={{
"& .MuiInputBase-root.MuiOutlinedInput-root": {
paddingLeft: "0",
},
}}
value={currentIconUrl}
onBlur={() => {
const newConfig = [...configParsed];
const updatedItem = { ...r };
if (currentIconType === IconType.Image) {
updatedItem.img = currentIconUrl;
updatedItem.iconify = undefined;
} else {
updatedItem.iconify = currentIconUrl;
updatedItem.img = undefined;
}
newConfig[i] = updatedItem;
onChange(JSON.stringify(newConfig));
setIconUrlCache({
...iconUrlCache,
[i]: undefined,
});
}}
onChange={(e) =>
setIconUrlCache({
...iconUrlCache,
[i]: e.target.value,
})
}
slotProps={{
input: {
startAdornment: (
<InputAdornment position="start">
<StyledDenseSelect
value={currentIconType}
onChange={(e) => {
const newType = e.target.value as IconType;
setIconTypeCache({
...iconTypeCache,
[i]: newType,
});
// Clear the URL cache when switching types
setIconUrlCache({
...iconUrlCache,
[i]: "",
});
// Update the config immediately
const newConfig = [...configParsed];
const updatedItem = { ...r };
if (newType === IconType.Image) {
updatedItem.img = "";
updatedItem.iconify = undefined;
} else {
updatedItem.iconify = "";
updatedItem.img = undefined;
}
newConfig[i] = updatedItem;
onChange(JSON.stringify(newConfig));
}}
renderValue={(value) => (
<Typography variant="body2">{t(`settings.${value}`)}</Typography>
)}
size={"small"}
variant="filled"
>
<SquareMenuItem value={IconType.Image}>
<ListItemText
slotProps={{
primary: { variant: "body2" },
}}
>
{t(`settings.${IconType.Image}`)}
</ListItemText>
</SquareMenuItem>
<SquareMenuItem value={IconType.Iconify}>
<ListItemText
slotProps={{
primary: { variant: "body2" },
}}
>
{t(`settings.${IconType.Iconify}`)}
</ListItemText>
</SquareMenuItem>
</StyledDenseSelect>
</InputAdornment>
),
},
}}
/>
) : (
t("settings.builtinIcon")
)}
</NoWrapCell>
<NoWrapCell>
{!r.icon && !r.iconify ? (
"-"
) : (
<HexColorInput
currentColor={r.color ?? ""}
onColorChange={(color) =>
onChange(
JSON.stringify([
...configParsed.slice(0, i),
{
...r,
color: color,
},
...configParsed.slice(i + 1),
]),
)
}
/>
)}
</NoWrapCell>
<NoWrapCell>
{!r.icon && !r.iconify ? (
"-"
) : (
<HexColorInput
currentColor={r.color_dark ?? ""}
onColorChange={(color) =>
onChange(
JSON.stringify([
...configParsed.slice(0, i),
{
...r,
color_dark: color,
},
...configParsed.slice(i + 1),
]),
)
}
/>
)}
</NoWrapCell>
<NoWrapCell>
<DenseFilledTextField
fullWidth
multiline
required
value={inputCache[i] ?? r.exts.join()}
onBlur={() => {
onChange(
JSON.stringify([
...configParsed.slice(0, i),
{
...r,
exts: inputCache[i]?.split(",") ?? r.exts,
},
...configParsed.slice(i + 1),
]),
);
setInputCache({
...inputCache,
[i]: undefined,
});
}}
onChange={(e) =>
setInputCache({
...inputCache,
[i]: e.target.value,
})
}
/>
</NoWrapCell>
<NoWrapCell>
{!r.icon && (
<IconButton
onClick={() => onChange(JSON.stringify(configParsed.filter((_, index) => index !== i)))}
size={"small"}
>
<Dismiss fontSize={"small"} />
</IconButton>
)}
</NoWrapCell>
</TableRow>
);
})}
</TableBody>
</Table>
</TableContainer>
)}
<SecondaryButton
variant={"contained"}
startIcon={<Add />}
sx={{ mt: 1 }}
onClick={() =>
onChange(
JSON.stringify([
...configParsed,
{
img: "",
exts: [],
},
]),
)
}
>
{t("settings.addIcon")}
</SecondaryButton>
</Box>
);
});
export default FileIconList;

View File

@@ -0,0 +1,58 @@
import { Box, Stack, Typography } from "@mui/material";
import { useSnackbar } from "notistack";
import { useCallback, useContext, useState } from "react";
import { useTranslation } from "react-i18next";
import { useAppDispatch } from "../../../../redux/hooks";
import { SettingSection, SettingSectionContent } from "../../Settings/Settings";
import { SettingContext } from "../../Settings/SettingWrapper";
import EmojiList from "./EmojiList";
import FileIconList from "./FileIconList";
const FileIcons = () => {
const { t } = useTranslation("dashboard");
const { formRef, setSettings, values } = useContext(SettingContext);
const [loading, setLoading] = useState(false);
const dispatch = useAppDispatch();
const { enqueueSnackbar } = useSnackbar();
const iconOnChange = useCallback(
(s: string) =>
setSettings({
explorer_icons: s,
}),
[],
);
const onEmojiChange = useCallback(
(s: string) =>
setSettings({
emojis: s,
}),
[],
);
return (
<Box component={"form"} ref={formRef} onSubmit={(e) => e.preventDefault()}>
<Stack spacing={5}>
<SettingSection>
<Typography variant="h6" gutterBottom>
{t("settings.fileIcons")}
</Typography>
<SettingSectionContent>
<FileIconList config={values.explorer_icons} onChange={iconOnChange} />
</SettingSectionContent>
</SettingSection>
<SettingSection>
<Typography variant="h6" gutterBottom>
{t("settings.emojiOptions")}
</Typography>
<SettingSectionContent>
<EmojiList config={values.emojis} onChange={onEmojiChange} />
</SettingSectionContent>
</SettingSection>
</Stack>
</Box>
);
};
export default FileIcons;

View File

@@ -0,0 +1,610 @@
import { DeleteOutline } from "@mui/icons-material";
import {
Box,
Collapse,
FormControl,
FormControlLabel,
Link,
ListItemText,
Stack,
Switch,
Typography,
} from "@mui/material";
import { useSnackbar } from "notistack";
import * as React from "react";
import { useCallback, useContext, useState } from "react";
import { Trans, useTranslation } from "react-i18next";
import { sendClearBlobUrlCache } from "../../../api/api.ts";
import { useAppDispatch } from "../../../redux/hooks.ts";
import { isTrueVal } from "../../../session/utils.ts";
import SizeInput from "../../Common/SizeInput.tsx";
import { DefaultCloseAction } from "../../Common/Snackbar/snackbar.tsx";
import { DenseFilledTextField, DenseSelect, SecondaryButton } from "../../Common/StyledComponents.tsx";
import { SquareMenuItem } from "../../FileManager/ContextMenu/ContextMenu.tsx";
import SettingForm from "../../Pages/Setting/SettingForm.tsx";
import { NoMarginHelperText, SettingSection, SettingSectionContent } from "../Settings/Settings.tsx";
import { SettingContext } from "../Settings/SettingWrapper.tsx";
const Parameters = () => {
const { t } = useTranslation("dashboard");
const { formRef, setSettings, values } = useContext(SettingContext);
const [loading, setLoading] = useState(false);
const dispatch = useAppDispatch();
const { enqueueSnackbar } = useSnackbar();
const clearBlobUrlCache = () => {
setLoading(true);
dispatch(sendClearBlobUrlCache())
.then(() => {
setLoading(false);
enqueueSnackbar(t("settings.cacheCleared"), { variant: "success", action: DefaultCloseAction });
})
.catch(() => {
setLoading(false);
});
};
const onMimeMappingChange = useCallback((e: React.ChangeEvent<HTMLInputElement>) => {
setSettings({
mime_mapping: e.target.value,
});
}, []);
return (
<Box component={"form"} ref={formRef} onSubmit={(e) => e.preventDefault()}>
<Stack spacing={5}>
<SettingSection>
<Typography variant="h6" gutterBottom>
{t("nav.fileSystem")}
</Typography>
<SettingSectionContent>
<SettingForm title={t("settings.textEditMaxSize")} lgWidth={5}>
<FormControl>
<SizeInput
variant={"outlined"}
required
allowZero={false}
value={parseInt(values.maxEditSize) ?? 0}
onChange={(e) =>
setSettings({
maxEditSize: e.toString(),
})
}
/>
<NoMarginHelperText>{t("settings.textEditMaxSizeDes")}</NoMarginHelperText>
</FormControl>
</SettingForm>
<SettingForm title={t("settings.trashBinInterval")} lgWidth={5}>
<FormControl fullWidth>
<DenseFilledTextField
value={values.cron_trash_bin_collect}
onChange={(e) =>
setSettings({
cron_trash_bin_collect: e.target.value,
})
}
required
/>
<NoMarginHelperText>
<Trans
i18nKey="settings.cronDes"
values={{
des: t("settings.trashBinIntervalDes"),
}}
ns={"dashboard"}
components={[<Link href="https://crontab.guru/" target="_blank" rel="noopener noreferrer" />]}
/>
</NoMarginHelperText>
</FormControl>
</SettingForm>
<SettingForm title={t("settings.entityCollectInterval")} lgWidth={5}>
<FormControl fullWidth>
<DenseFilledTextField
value={values.cron_entity_collect}
onChange={(e) =>
setSettings({
cron_entity_collect: e.target.value,
})
}
required
/>
<NoMarginHelperText>
<Trans
i18nKey="settings.cronDes"
ns={"dashboard"}
values={{
des: t("settings.entityCollectIntervalDes"),
}}
components={[<Link href="https://crontab.guru/" target="_blank" rel="noopener noreferrer" />]}
/>
</NoMarginHelperText>
</FormControl>
</SettingForm>
<SettingForm title={t("settings.publicResourceMaxAge")} lgWidth={5}>
<FormControl fullWidth>
<DenseFilledTextField
type="number"
inputProps={{ min: 0, setp: 1 }}
value={values.public_resource_maxage}
onChange={(e) =>
setSettings({
public_resource_maxage: e.target.value,
})
}
required
/>
<NoMarginHelperText>{t("settings.publicResourceMaxAgeDes")}</NoMarginHelperText>
</FormControl>
</SettingForm>
<SettingForm title={t("settings.defaultPagination")} lgWidth={5}>
<FormControl fullWidth>
<DenseSelect
renderValue={(v) => (
<ListItemText
slotProps={{
primary: { variant: "body2" },
}}
>
{v == "0" ? t("settings.offsetPagination") : t("settings.cursorPagination")}
</ListItemText>
)}
onChange={(e) =>
setSettings({
use_cursor_pagination: e.target.value as string,
})
}
MenuProps={{
PaperProps: { sx: { maxWidth: 230 } },
MenuListProps: {
sx: {
"& .MuiMenuItem-root": {
whiteSpace: "normal",
},
},
},
}}
value={values.use_cursor_pagination}
>
<SquareMenuItem value={"0"}>
<Box
sx={{
display: "flex",
flexDirection: "column",
}}
>
<Typography variant={"body2"} fontWeight={600}>
{t("settings.offsetPagination")}
</Typography>
<Typography variant={"body2"} color={"textSecondary"}>
{t("settings.offsetPaginationDes")}
</Typography>
</Box>
</SquareMenuItem>
<SquareMenuItem value={"1"}>
<Box sx={{ display: "flex", flexDirection: "column" }}>
<Typography variant={"body2"} fontWeight={600}>
{t("settings.cursorPagination")}
</Typography>
<Typography variant={"body2"} color={"textSecondary"}>
{t("settings.cursorPaginationDes")}
</Typography>
</Box>
</SquareMenuItem>
</DenseSelect>
<NoMarginHelperText>{t("settings.defaultPaginationDes")}</NoMarginHelperText>
</FormControl>
</SettingForm>
<SettingForm title={t("settings.maxPageSize")} lgWidth={5}>
<FormControl>
<DenseFilledTextField
type="number"
inputProps={{ min: 0, setp: 1 }}
value={values.max_page_size}
onChange={(e) =>
setSettings({
max_page_size: e.target.value,
})
}
required
/>
<NoMarginHelperText>{t("settings.maxPageSizeDes")}</NoMarginHelperText>
</FormControl>
</SettingForm>
<SettingForm title={t("settings.maxBatchSize")} lgWidth={5}>
<FormControl>
<DenseFilledTextField
type="number"
inputProps={{ min: 0, setp: 1 }}
value={values.max_batched_file}
onChange={(e) =>
setSettings({
max_batched_file: e.target.value,
})
}
required
/>
<NoMarginHelperText>{t("settings.maxBatchSizeDes")}</NoMarginHelperText>
</FormControl>
</SettingForm>
<SettingForm title={t("settings.maxRecursiveSearch")} lgWidth={5}>
<FormControl>
<DenseFilledTextField
type="number"
inputProps={{ min: 0, setp: 1 }}
value={values.max_recursive_searched_folder}
onChange={(e) =>
setSettings({
max_recursive_searched_folder: e.target.value,
})
}
required
/>
<NoMarginHelperText>{t("settings.maxRecursiveSearchDes")}</NoMarginHelperText>
</FormControl>
</SettingForm>
<SettingForm title={t("settings.mapProvider")} lgWidth={5}>
<FormControl>
<DenseSelect
onChange={(e) =>
setSettings({
map_provider: e.target.value as string,
})
}
value={values.map_provider}
>
<SquareMenuItem value={"google"}>
<ListItemText
slotProps={{
primary: { variant: "body2" },
}}
>
{t("settings.mapGoogle")}
</ListItemText>
</SquareMenuItem>
<SquareMenuItem value={"openstreetmap"}>
<ListItemText
slotProps={{
primary: { variant: "body2" },
}}
>
{t("settings.mapOpenStreetMap")}
</ListItemText>
</SquareMenuItem>
<SquareMenuItem value={"mapbox"}>
<ListItemText
slotProps={{
primary: { variant: "body2" },
}}
>
{t("settings.mapboxMap")}
</ListItemText>
</SquareMenuItem>
</DenseSelect>
<NoMarginHelperText>{t("settings.mapProviderDes")}</NoMarginHelperText>
</FormControl>
</SettingForm>
<Collapse in={values.map_provider === "mapbox"} unmountOnExit>
<SettingForm title={t("settings.mapboxAccessToken")} lgWidth={5}>
<FormControl fullWidth>
<DenseFilledTextField
fullWidth
required
value={values.map_mapbox_ak ?? ""}
onChange={(e) => setSettings({ map_mapbox_ak: e.target.value })}
/>
<NoMarginHelperText>
<Trans
i18nKey="settings.mapboxAccessTokenDes"
ns="dashboard"
components={[<Link href="https://account.mapbox.com/access-tokens" target="_blank" />]}
/>
</NoMarginHelperText>
</FormControl>
</SettingForm>
</Collapse>
<Collapse in={values.map_provider === "google"} unmountOnExit>
<SettingForm title={t("settings.tileType")} lgWidth={5}>
<FormControl>
<DenseSelect
onChange={(e) =>
setSettings({
map_google_tile_type: e.target.value as string,
})
}
value={values.map_google_tile_type}
>
<SquareMenuItem value={"terrain"}>
<ListItemText
slotProps={{
primary: { variant: "body2" },
}}
>
{t("settings.tileTypeTerrain")}
</ListItemText>
</SquareMenuItem>
<SquareMenuItem value={"satellite"}>
<ListItemText
slotProps={{
primary: { variant: "body2" },
}}
>
{t("settings.tileTypeSatellite")}
</ListItemText>
</SquareMenuItem>
<SquareMenuItem value={"regular"}>
<ListItemText
slotProps={{
primary: { variant: "body2" },
}}
>
{t("settings.tileTypeGeneral")}
</ListItemText>
</SquareMenuItem>
</DenseSelect>
<NoMarginHelperText>{t("settings.tileTypeDes")}</NoMarginHelperText>
</FormControl>
</SettingForm>
</Collapse>
<SettingForm title={t("settings.mimeMapping")} lgWidth={5}>
<FormControl fullWidth>
<DenseFilledTextField
multiline
rows={6}
value={values.mime_mapping}
onChange={onMimeMappingChange}
required
/>
<NoMarginHelperText>{t("settings.mimeMappingDes")}</NoMarginHelperText>
</FormControl>
</SettingForm>
</SettingSectionContent>
</SettingSection>
<SettingSection>
<Typography variant="h6" gutterBottom>
{t("settings.searchQuery")}
</Typography>
<SettingSectionContent>
<SettingForm title={t("application:navbar.photos")}>
<DenseFilledTextField
fullWidth
value={values.explorer_category_image_query}
onChange={(e) =>
setSettings({
explorer_category_image_query: e.target.value,
})
}
required
/>
</SettingForm>
<SettingForm title={t("application:navbar.videos")}>
<DenseFilledTextField
fullWidth
value={values.explorer_category_video_query}
onChange={(e) =>
setSettings({
explorer_category_video_query: e.target.value,
})
}
required
/>
</SettingForm>
<SettingForm title={t("application:navbar.music")}>
<DenseFilledTextField
fullWidth
value={values.explorer_category_audio_query}
onChange={(e) =>
setSettings({
explorer_category_audio_query: e.target.value,
})
}
required
/>
</SettingForm>
<SettingForm title={t("application:navbar.documents")}>
<DenseFilledTextField
fullWidth
value={values.explorer_category_document_query}
onChange={(e) =>
setSettings({
explorer_category_document_query: e.target.value,
})
}
required
/>
</SettingForm>
</SettingSectionContent>
</SettingSection>
<SettingSection>
<Typography variant="h6" gutterBottom>
{t("settings.advanceOptions")}
</Typography>
<SettingSectionContent>
<SettingForm title={t("settings.archiveTimeout")} lgWidth={5}>
<DenseFilledTextField
type="number"
inputProps={{ min: 1, setp: 1 }}
value={values.archive_timeout}
onChange={(e) =>
setSettings({
archive_timeout: e.target.value,
})
}
required
/>
</SettingForm>
<SettingForm title={t("settings.uploadSessionTimeout")} lgWidth={5}>
<DenseFilledTextField
type="number"
inputProps={{ min: 1, setp: 1 }}
value={values.upload_session_timeout}
onChange={(e) =>
setSettings({
upload_session_timeout: e.target.value,
})
}
required
/>
<NoMarginHelperText>{t("settings.uploadSessionDes")}</NoMarginHelperText>
</SettingForm>
<SettingForm title={t("settings.slaveAPIExpiration")} lgWidth={5}>
<DenseFilledTextField
type="number"
inputProps={{ min: 1, setp: 1 }}
value={values.slave_api_timeout}
onChange={(e) =>
setSettings({
slave_api_timeout: e.target.value,
})
}
required
/>
<NoMarginHelperText>{t("settings.slaveAPIExpirationDes")}</NoMarginHelperText>
</SettingForm>
<SettingForm title={t("settings.folderPropsTimeout")} lgWidth={5}>
<DenseFilledTextField
type="number"
inputProps={{ min: 1, setp: 1 }}
value={values.folder_props_timeout}
onChange={(e) =>
setSettings({
folder_props_timeout: e.target.value,
})
}
required
/>
<NoMarginHelperText>{t("settings.folderPropsTimeoutDes")}</NoMarginHelperText>
</SettingForm>
<SettingForm title={t("settings.failedChunkRetry")} lgWidth={5}>
<DenseFilledTextField
type="number"
inputProps={{ min: 0, setp: 1 }}
value={values.chunk_retries}
onChange={(e) =>
setSettings({
chunk_retries: e.target.value,
})
}
required
/>
<NoMarginHelperText>{t("settings.failedChunkRetryDes")}</NoMarginHelperText>
</SettingForm>
<SettingForm lgWidth={5}>
<FormControl fullWidth>
<FormControlLabel
control={
<Switch
checked={isTrueVal(values.use_temp_chunk_buffer)}
onChange={(e) =>
setSettings({
use_temp_chunk_buffer: e.target.checked ? "1" : "0",
})
}
/>
}
label={t("settings.cacheChunks")}
/>
<NoMarginHelperText>{t("settings.cacheChunksDes")}</NoMarginHelperText>
</FormControl>
</SettingForm>
<SettingForm title={t("settings.transitParallelNum")} lgWidth={5}>
<DenseFilledTextField
type="number"
inputProps={{ min: 1, setp: 1 }}
value={values.max_parallel_transfer}
onChange={(e) =>
setSettings({
max_parallel_transfer: e.target.value,
})
}
required
/>
<NoMarginHelperText>{t("settings.transitParallelNumDes")}</NoMarginHelperText>
</SettingForm>
<SettingForm title={t("settings.oauthRefresh")} lgWidth={5}>
<DenseFilledTextField
fullWidth
required
value={values.cron_oauth_cred_refresh}
onChange={(e) =>
setSettings({
cron_oauth_cred_refresh: e.target.value,
})
}
/>
<NoMarginHelperText>
<Trans
i18nKey="settings.cronDes"
values={{
des: t("settings.oauthRefreshDes"),
}}
ns={"dashboard"}
components={[<Link href="https://crontab.guru/" target="_blank" rel="noopener noreferrer" />]}
/>
</NoMarginHelperText>
</SettingForm>
<SettingForm title={t("settings.wopiSessionTimeout")} lgWidth={5}>
<DenseFilledTextField
type="number"
inputProps={{ min: 1, setp: 1 }}
value={values.viewer_session_timeout}
onChange={(e) =>
setSettings({
viewer_session_timeout: e.target.value,
})
}
required
/>
<NoMarginHelperText>{t("settings.wopiSessionTimeoutDes")}</NoMarginHelperText>
</SettingForm>
<SettingForm title={t("settings.fileBlobTimeout")} lgWidth={5}>
<DenseFilledTextField
type="number"
inputProps={{ min: 1, setp: 1 }}
value={values.entity_url_default_ttl}
onChange={(e) =>
setSettings({
entity_url_default_ttl: e.target.value,
})
}
required
/>
<NoMarginHelperText>{t("settings.fileBlobTimeoutDes")}</NoMarginHelperText>
</SettingForm>
<SettingForm title={t("settings.fileBlobMargin")} lgWidth={5}>
<DenseFilledTextField
type="number"
inputProps={{ min: 1, setp: 1 }}
value={values.entity_url_cache_margin}
onChange={(e) =>
setSettings({
entity_url_cache_margin: e.target.value,
})
}
required
/>
<NoMarginHelperText>{t("settings.fileBlobMarginDes")}</NoMarginHelperText>
</SettingForm>
<SettingForm title={t("settings.blobUrlCache")} lgWidth={5} anchorId="clearBlobUrlCache">
<FormControl fullWidth>
<Box>
<SecondaryButton
startIcon={<DeleteOutline />}
variant="contained"
loading={loading}
color="primary"
onClick={clearBlobUrlCache}
>
{t("settings.clearBlobUrlCache")}
</SecondaryButton>
</Box>
<NoMarginHelperText>{t("settings.clearBlobUrlCacheDes")}</NoMarginHelperText>
</FormControl>
</SettingForm>
</SettingSectionContent>
</SettingSection>
</Stack>
</Box>
);
};
export default Parameters;

View File

@@ -0,0 +1,543 @@
import {
DialogContent,
FormControlLabel,
IconButton,
Link,
ListItemText,
SelectChangeEvent,
Switch,
Table,
TableBody,
TableContainer,
TableHead,
TableRow,
useTheme,
} from "@mui/material";
import FormControl from "@mui/material/FormControl";
import Grid from "@mui/material/Grid2";
import { useSnackbar } from "notistack";
import React, { lazy, Suspense, useCallback, useEffect, useMemo, useState } from "react";
import { DndProvider, useDrag, useDrop } from "react-dnd";
import { HTML5Backend } from "react-dnd-html5-backend";
import { Trans, useTranslation } from "react-i18next";
import { Viewer, ViewerPlatform, ViewerType } from "../../../../api/explorer.ts";
import { builtInViewers } from "../../../../redux/thunks/viewer.ts";
import { isTrueVal } from "../../../../session/utils.ts";
import CircularProgress from "../../../Common/CircularProgress.tsx";
import SizeInput from "../../../Common/SizeInput.tsx";
import { DefaultCloseAction } from "../../../Common/Snackbar/snackbar.tsx";
import {
DenseFilledTextField,
DenseSelect,
NoWrapTableCell,
SecondaryButton,
StyledTableContainerPaper,
} from "../../../Common/StyledComponents.tsx";
import DraggableDialog from "../../../Dialogs/DraggableDialog.tsx";
import { SquareMenuItem } from "../../../FileManager/ContextMenu/ContextMenu.tsx";
import { ViewerIDWithDefaultIcons } from "../../../FileManager/Dialogs/OpenWith.tsx";
import Add from "../../../Icons/Add.tsx";
import ArrowDown from "../../../Icons/ArrowDown.tsx";
import Dismiss from "../../../Icons/Dismiss.tsx";
import SettingForm from "../../../Pages/Setting/SettingForm.tsx";
import MagicVarDialog, { MagicVar } from "../../Common/MagicVarDialog.tsx";
import { NoMarginHelperText } from "../../Settings/Settings.tsx";
const MonacoEditor = lazy(() => import("../../../Viewers/CodeViewer/MonacoEditor.tsx"));
export interface FileViewerEditDialogProps {
viewer: Viewer;
onChange: (viewer: Viewer) => void;
open: boolean;
onClose: () => void;
}
const magicVars: MagicVar[] = [
{
name: "{$src}",
value: "settings.srcEncodedVar",
example: "https%3A%2F%2Fcloudreve.org%2Fapi%2Fv4%2Ffile%2Fcontent%2FzOie%2F0%2Ftext.txt%3Fsign%3Dxxx",
},
{
name: "{$src_raw}",
value: "settings.srcVar",
example: "https://cloudreve.org/api/v4/file/content/zOie/0/text.txt?sign=xxx",
},
{
name: "{$src_raw_base64}",
value: "settings.srcBase64Var",
example: "aHR0cHM6Ly9jbG91ZHJldmUub3JnL2FwaS92NC9maWxlL2NvbnRlbnQvek9pZS8wL3RleHQudHh0P3NpZ249eHh4",
},
{
name: "{$name}",
value: "settings.nameEncodedVar",
example: "sampleFile%5B1%5D.txt",
},
{
name: "{$version}",
value: "settings.versionEntityVar",
example: "zOie",
},
{
name: "{$id}",
value: "settings.fileIdVar",
example: "jm8AF8",
},
{
name: "{$user_id}",
value: "settings.userIdVar",
example: "lpua",
},
{
name: "{$user_display_name}",
value: "settings.userDisplayNameVar",
example: "Aaron%20Liu",
},
];
const DND_TYPE = "template-row";
interface DraggableTemplateRowProps {
t: any;
i: number;
moveRow: (from: number, to: number) => void;
onExtChange: (e: SelectChangeEvent<unknown>, child: React.ReactNode) => void;
onNameChange: (e: React.ChangeEvent<HTMLInputElement>) => void;
onDelete: () => void;
isFirst: boolean;
isLast: boolean;
extList: string[];
template: any;
}
function DraggableTemplateRow({
i,
moveRow,
onExtChange,
onNameChange,
onDelete,
isFirst,
isLast,
extList,
template,
}: DraggableTemplateRowProps) {
const ref = React.useRef<HTMLTableRowElement>(null);
const [, drop] = useDrop({
accept: DND_TYPE,
hover(item: any, monitor) {
if (!ref.current) return;
const dragIndex = item.index;
const hoverIndex = i;
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: i },
collect: (monitor) => ({
isDragging: monitor.isDragging(),
}),
});
drag(drop(ref));
return (
<TableRow
ref={ref}
sx={{ "&:last-child td, &:last-child th": { border: 0 }, opacity: isDragging ? 0.5 : 1, cursor: "move" }}
hover
>
<NoWrapTableCell>
<DenseSelect value={template.ext} required onChange={onExtChange}>
{extList.map((ext) => (
<SquareMenuItem value={ext} key={ext}>
<ListItemText slotProps={{ primary: { variant: "body2" } }}>{ext}</ListItemText>
</SquareMenuItem>
))}
</DenseSelect>
</NoWrapTableCell>
<NoWrapTableCell>
<DenseFilledTextField fullWidth required value={template.display_name} onChange={onNameChange} />
</NoWrapTableCell>
<NoWrapTableCell>
<IconButton size={"small"} onClick={onDelete}>
<Dismiss fontSize={"small"} />
</IconButton>
<IconButton size="small" onClick={() => moveRow(i, i - 1)} disabled={isFirst}>
<ArrowDown
sx={{
width: "18px",
height: "18px",
transform: "rotate(180deg)",
}}
/>
</IconButton>
<IconButton size="small" onClick={() => moveRow(i, i + 1)} disabled={isLast}>
<ArrowDown
sx={{
width: "18px",
height: "18px",
}}
/>
</IconButton>
</NoWrapTableCell>
</TableRow>
);
}
const FileViewerEditDialog = ({ viewer, onChange, open, onClose }: FileViewerEditDialogProps) => {
const { t } = useTranslation("dashboard");
const theme = useTheme();
const { enqueueSnackbar } = useSnackbar();
const [viewerShadowed, setViewerShadowed] = useState<Viewer | undefined>(undefined);
const formRef = React.useRef<HTMLFormElement>(null);
const [magicVarOpen, setMagicVarOpen] = useState(false);
const [wopiCached, setWopiCached] = useState("");
const withDefaultIcon = useMemo(() => {
return ViewerIDWithDefaultIcons.includes(viewer.id);
}, [viewer.id]);
useEffect(() => {
setViewerShadowed({ ...viewer });
setWopiCached("");
}, [viewer, setWopiCached, setViewerShadowed]);
const onSubmit = useCallback(() => {
if (formRef.current && !formRef.current.checkValidity()) {
formRef.current.reportValidity();
return;
}
let changed = viewerShadowed;
if (!viewerShadowed || !changed) {
return;
}
if (wopiCached != "") {
try {
const parsed = JSON.parse(wopiCached);
changed = { ...viewerShadowed, wopi_actions: parsed };
setViewerShadowed({ ...changed });
} catch (e) {
enqueueSnackbar({
message: t("settings.invalidWopiActionMapping"),
variant: "warning",
action: DefaultCloseAction,
});
return;
}
}
onChange(changed);
onClose();
}, [viewerShadowed, wopiCached, formRef]);
const openMagicVar = useCallback((e: React.MouseEvent<HTMLElement>) => {
setMagicVarOpen(true);
e.stopPropagation();
e.preventDefault();
}, []);
if (!viewerShadowed) {
return null;
}
return (
<DraggableDialog
title={t("settings.editViewerTitle", {
name: t(viewer.display_name, { ns: "application" }),
})}
showActions
showCancel
onAccept={onSubmit}
dialogProps={{
fullWidth: true,
maxWidth: "lg",
open,
onClose,
}}
>
<DialogContent>
<MagicVarDialog open={magicVarOpen} vars={magicVars} onClose={() => setMagicVarOpen(false)} />
<form ref={formRef}>
<Grid spacing={2} container>
<SettingForm noContainer lgWidth={6} title={t("settings.iconUrl")}>
<DenseFilledTextField
fullWidth
required={!withDefaultIcon}
value={viewerShadowed.icon}
onChange={(e) => {
setViewerShadowed((v) => ({
...(v as Viewer),
icon: e.target.value,
}));
}}
/>
{withDefaultIcon && <NoMarginHelperText>{t("settings.builtInIconUrlDes")}</NoMarginHelperText>}
</SettingForm>
<SettingForm noContainer lgWidth={6} title={t("settings.displayName")}>
<DenseFilledTextField
fullWidth
required
value={viewerShadowed.display_name}
onChange={(e) => {
setViewerShadowed((v) => ({
...(v as Viewer),
display_name: e.target.value,
}));
}}
/>
<NoMarginHelperText>{t("settings.displayNameDes")}</NoMarginHelperText>
</SettingForm>
<SettingForm noContainer lgWidth={6} title={t("settings.exts")}>
<DenseFilledTextField
fullWidth
multiline
required
value={viewerShadowed.exts.join()}
onChange={(e) =>
setViewerShadowed((v) => ({
...(v as Viewer),
exts: e.target.value.split(",").map((ext) => ext.trim()),
}))
}
/>
</SettingForm>
{viewer.type == ViewerType.custom && (
<SettingForm noContainer lgWidth={6} title={t("settings.viewerUrl")}>
<DenseFilledTextField
fullWidth
required
value={viewerShadowed.url}
onChange={(e) =>
setViewerShadowed((v) => ({
...(v as Viewer),
url: e.target.value,
}))
}
/>
<NoMarginHelperText>
<Trans
i18nKey={"settings.viewerUrlDes"}
ns={"dashboard"}
components={[<Link onClick={openMagicVar} href={"#"} />]}
/>
</NoMarginHelperText>
</SettingForm>
)}
<SettingForm noContainer lgWidth={6} title={t("settings.maxSize")}>
<FormControl fullWidth>
<SizeInput
variant={"outlined"}
required
allowZero={true}
value={viewerShadowed.max_size ?? 0}
onChange={(e) =>
setViewerShadowed((v) => ({
...(v as Viewer),
max_size: e ? e : undefined,
}))
}
/>
<NoMarginHelperText>{t("settings.maxSizeDes")}</NoMarginHelperText>
</FormControl>
</SettingForm>
<SettingForm noContainer lgWidth={6} title={t("settings.viewerPlatform")}>
<FormControl fullWidth>
<DenseSelect
value={viewerShadowed.platform || ViewerPlatform.all}
onChange={(e) =>
setViewerShadowed((v) => ({
...(v as Viewer),
platform: e.target.value as ViewerPlatform,
}))
}
>
<SquareMenuItem value="pc">
<ListItemText
slotProps={{
primary: { variant: "body2" },
}}
>
{t("settings.viewerPlatformPC")}
</ListItemText>
</SquareMenuItem>
<SquareMenuItem value="mobile">
<ListItemText
slotProps={{
primary: { variant: "body2" },
}}
>
{t("settings.viewerPlatformMobile")}
</ListItemText>
</SquareMenuItem>
<SquareMenuItem value="all">
<ListItemText
slotProps={{
primary: { variant: "body2" },
}}
>
{t("settings.viewerPlatformAll")}
</ListItemText>
</SquareMenuItem>
</DenseSelect>
<NoMarginHelperText>{t("settings.viewerPlatformDes")}</NoMarginHelperText>
</FormControl>
</SettingForm>
<SettingForm noContainer lgWidth={6}>
<FormControlLabel
control={
<Switch
checked={isTrueVal(viewerShadowed.props?.openInNew ?? "")}
onChange={(e) =>
setViewerShadowed((v) => ({
...(v as Viewer),
props: {
...(v?.props ?? {}),
openInNew: e.target.checked.toString(),
},
}))
}
/>
}
label={t("settings.openInNew")}
/>
<NoMarginHelperText>{t("settings.openInNewDes")}</NoMarginHelperText>
</SettingForm>
{viewer.id == builtInViewers.drawio && (
<SettingForm noContainer title={t("settings.drawioHost")} lgWidth={6}>
<DenseFilledTextField
fullWidth
required
value={viewerShadowed.props?.host ?? ""}
onChange={(e) =>
setViewerShadowed((v) => ({
...(v as Viewer),
props: {
...(v?.props ?? {}),
host: e.target.value,
},
}))
}
/>
<NoMarginHelperText>{t("settings.drawioHostDes")}</NoMarginHelperText>
</SettingForm>
)}
{viewer.type == ViewerType.wopi && (
<SettingForm noContainer title={t("settings.woapiActionMapping")} lgWidth={12}>
<Suspense fallback={<CircularProgress />}>
<MonacoEditor
theme={theme.palette.mode === "dark" ? "vs-dark" : "vs"}
value={wopiCached == "" ? JSON.stringify(viewerShadowed.wopi_actions, null, 4) : wopiCached}
height={"300px"}
minHeight={"300px"}
language={"json"}
onChange={(e) => setWopiCached(e as string)}
/>
</Suspense>
</SettingForm>
)}
<SettingForm noContainer title={t("settings.newFileAction")} lgWidth={12}>
{viewerShadowed?.templates && viewerShadowed.templates.length > 0 && (
<DndProvider backend={HTML5Backend}>
<TableContainer sx={{ mt: 1, maxHeight: 440 }} component={StyledTableContainerPaper}>
<Table
stickyHeader
sx={{
width: "100%",
maxHeight: 300,
tableLayout: "fixed",
}}
size="small"
>
<TableHead>
<TableRow>
<NoWrapTableCell width={100}>{t("settings.ext")}</NoWrapTableCell>
<NoWrapTableCell width={200}>{t("settings.displayName")}</NoWrapTableCell>
<NoWrapTableCell width={100}>{t("settings.actions")}</NoWrapTableCell>
</TableRow>
</TableHead>
<TableBody>
{viewerShadowed.templates?.map((template, i) => (
<DraggableTemplateRow
key={i}
t={template}
i={i}
moveRow={(from, to) => {
if (from === to || to < 0 || to >= (viewerShadowed.templates?.length ?? 0)) return;
setViewerShadowed((v) => {
const arr = [...(v?.templates ?? [])];
const [moved] = arr.splice(from, 1);
arr.splice(to, 0, moved);
return { ...(v as Viewer), templates: arr };
});
}}
onExtChange={(e) => {
const newExt = e.target.value as string;
setViewerShadowed((v) => ({
...(v as Viewer),
templates: (v?.templates ?? []).map((template, index) =>
index == i ? { ...template, ext: newExt } : template,
),
}));
}}
onNameChange={(e) => {
setViewerShadowed((v) => ({
...(v as Viewer),
templates: (v?.templates ?? []).map((template, index) =>
index == i ? { ...template, display_name: e.target.value } : template,
),
}));
}}
onDelete={() => {
setViewerShadowed((v) => ({
...(v as Viewer),
templates: (v?.templates ?? []).filter((_, index) => index != i),
}));
}}
isFirst={i === 0}
isLast={i === (viewerShadowed.templates?.length ?? 0) - 1}
extList={viewerShadowed.exts}
template={template}
/>
))}
</TableBody>
</Table>
</TableContainer>
</DndProvider>
)}
<SecondaryButton
sx={{ mt: 1 }}
variant={"contained"}
startIcon={<Add />}
onClick={() =>
setViewerShadowed((v) => ({
...(v as Viewer),
templates: [...(v?.templates ?? []), { ext: viewerShadowed.exts?.[0] ?? "", display_name: "" }],
}))
}
>
{t("settings.addNewFileAction")}
</SecondaryButton>
<NoMarginHelperText>{t("settings.newFileActionDes")}</NoMarginHelperText>
</SettingForm>
</Grid>
</form>
</DialogContent>
</DraggableDialog>
);
};
export default FileViewerEditDialog;

View File

@@ -0,0 +1,343 @@
import { ExpandMoreRounded } from "@mui/icons-material";
import {
AccordionDetails,
Box,
Link,
ListItemIcon,
Menu,
Table,
TableBody,
TableContainer,
TableHead,
TableRow,
Typography,
} from "@mui/material";
import { bindMenu, bindTrigger, usePopupState } from "material-ui-popup-state/hooks";
import * as React from "react";
import { memo, useCallback, useMemo, useState } from "react";
import { DndProvider, useDrag, useDrop } from "react-dnd";
import { HTML5Backend } from "react-dnd-html5-backend";
import { useTranslation } from "react-i18next";
import { Viewer, ViewerGroup, ViewerType } from "../../../../api/explorer.ts";
import { uuidv4 } from "../../../../util";
import { NoWrapTableCell, SecondaryButton } from "../../../Common/StyledComponents.tsx";
import { SquareMenuItem } from "../../../FileManager/ContextMenu/ContextMenu.tsx";
import Add from "../../../Icons/Add.tsx";
import DesktopFlow from "../../../Icons/DesktopFlow.tsx";
import DocumentDataLink from "../../../Icons/DocumentDataLink.tsx";
import { AccordionSummary, StyledAccordion } from "../../Settings/UserSession/SSOSettings.tsx";
import FileViewerEditDialog from "./FileViewerEditDialog.tsx";
import FileViewerRow from "./FileViewerRow.tsx";
import ImportWopiDialog from "./ImportWopiDialog.tsx";
interface ViewerGroupProps {
group: ViewerGroup;
index: number;
onDelete: (e: React.MouseEvent<HTMLElement>) => void;
onGroupChange: (g: ViewerGroup) => void;
dndType: string;
}
const DND_TYPE = "viewer-row";
const DraggableViewerRow = memo(function DraggableViewerRow({
viewer,
index,
moveRow,
onChange,
onDelete,
onMoveUp,
onMoveDown,
isLast,
isFirst,
dndType,
}: any) {
const ref = React.useRef<HTMLTableRowElement>(null);
const [, drop] = useDrop({
accept: dndType,
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: dndType,
item: { index },
collect: (monitor) => ({
isDragging: monitor.isDragging(),
}),
});
drag(drop(ref));
return (
<FileViewerRow
ref={ref}
viewer={viewer}
onChange={onChange}
onDelete={onDelete}
onMoveUp={onMoveUp}
onMoveDown={onMoveDown}
isLast={isLast}
isFirst={isFirst}
style={{ opacity: isDragging ? 0.5 : 1, cursor: "move" }}
/>
);
});
const ViewerGroupRow = memo(({ group, index, onDelete, onGroupChange, dndType }: ViewerGroupProps) => {
const { t } = useTranslation("dashboard");
const onViewerChange = useMemo(() => {
return group.viewers.map((_, index) => (vChanged: Viewer) => {
onGroupChange({
viewers: group.viewers.map((v, i) => (i == index ? vChanged : v)),
});
});
}, [group.viewers]);
const onViewerDeleted = useMemo(() => {
return group.viewers.map((_, index) => (e: React.MouseEvent<HTMLElement>) => {
onGroupChange({
viewers: group.viewers.filter((_, i) => i != index),
});
e.preventDefault();
e.stopPropagation();
});
}, [group.viewers]);
const [viewers, setViewers] = useState(group.viewers);
React.useEffect(() => {
setViewers(group.viewers);
}, [group.viewers]);
const moveRow = useCallback(
(from: number, to: number) => {
if (from === to) return;
const updated = [...viewers];
const [moved] = updated.splice(from, 1);
updated.splice(to, 0, moved);
setViewers(updated);
onGroupChange({ viewers: updated });
},
[viewers, onGroupChange],
);
const handleMoveUp = (idx: number) => {
if (idx <= 0) return;
moveRow(idx, idx - 1);
};
const handleMoveDown = (idx: number) => {
if (idx >= viewers.length - 1) return;
moveRow(idx, idx + 1);
};
return (
<StyledAccordion
defaultExpanded={index == 0}
disableGutters
slotProps={{
transition: {
unmountOnExit: true,
},
}}
>
<AccordionSummary expandIcon={<ExpandMoreRounded />}>
<Box
sx={{
display: "flex",
justifyContent: "space-between",
width: "100%",
alignItems: "center",
}}
>
<Typography>{t("settings.viewerGroupTitle", { index: index + 1 })}</Typography>
{index > 0 && (
<Link href={"#"} onClick={onDelete}>
{t("policy.delete")}
</Link>
)}
</Box>
</AccordionSummary>
<AccordionDetails sx={{ display: "block" }}>
<DndProvider backend={HTML5Backend}>
<TableContainer sx={{ mt: 1, maxHeight: 440 }}>
<Table stickyHeader sx={{ width: "100%", tableLayout: "fixed" }} size="small">
<TableHead>
<TableRow>
<NoWrapTableCell width={64}>{t("settings.icon")}</NoWrapTableCell>
<NoWrapTableCell width={100}>{t("settings.viewerType")}</NoWrapTableCell>
<NoWrapTableCell width={200}>{t("settings.displayName")}</NoWrapTableCell>
<NoWrapTableCell width={250}>{t("settings.exts")}</NoWrapTableCell>
<NoWrapTableCell width={150}>{t("settings.viewerPlatform")}</NoWrapTableCell>
<NoWrapTableCell width={100}>{t("settings.newFileAction")}</NoWrapTableCell>
<NoWrapTableCell width={64}>{t("settings.viewerEnabled")}</NoWrapTableCell>
<NoWrapTableCell width={100}>{t("settings.actions")}</NoWrapTableCell>
<NoWrapTableCell width={100}></NoWrapTableCell>
</TableRow>
</TableHead>
<TableBody>
{viewers.map((viewer, idx) => (
<DraggableViewerRow
key={viewer.id}
viewer={viewer}
index={idx}
moveRow={moveRow}
onChange={onViewerChange[idx]}
onDelete={onViewerDeleted[idx]}
onMoveUp={() => handleMoveUp(idx)}
onMoveDown={() => handleMoveDown(idx)}
isFirst={idx === 0}
isLast={idx === viewers.length - 1}
dndType={dndType}
/>
))}
</TableBody>
</Table>
</TableContainer>
</DndProvider>
</AccordionDetails>
</StyledAccordion>
);
});
export interface FileViewerListProps {
config: string;
onChange: (value: string) => void;
}
const FileViewerList = memo(({ config, onChange }: FileViewerListProps) => {
const { t } = useTranslation("dashboard");
const addNewPopupState = usePopupState({
variant: "popover",
popupId: "addNewViewer",
});
const [createNewOpen, setCreateNewOpen] = useState(false);
const [newViewer, setNewViewer] = useState<Viewer | undefined>(undefined);
const [importOpen, setImportOpen] = useState(false);
const configParsed = useMemo((): ViewerGroup[] => JSON.parse(config), [config]);
const onNewViewerChange = useCallback(
(v: Viewer) => {
setNewViewer(v);
const newViewerSetting = [...configParsed];
newViewerSetting[0].viewers.push(v);
onChange(JSON.stringify(newViewerSetting));
},
[configParsed],
);
const onGroupDelete = useMemo(() => {
return configParsed.map((_, index) => (e: React.MouseEvent<HTMLElement>) => {
onChange(JSON.stringify([...configParsed].filter((_, i) => i != index)));
e.preventDefault();
e.stopPropagation();
});
}, [configParsed]);
const onGroupChange = useMemo(() => {
return configParsed.map((_, index) => (g: ViewerGroup) => {
onChange(JSON.stringify([...configParsed].map((item, i) => (i == index ? g : item))));
});
}, [configParsed]);
const { onClose, ...menuProps } = bindMenu(addNewPopupState);
const onCreateNewClosed = useCallback(() => {
setCreateNewOpen(false);
}, []);
const openCreateNew = useCallback(() => {
setNewViewer({
id: uuidv4(),
icon: "",
type: ViewerType.custom,
display_name: "",
exts: [],
});
setCreateNewOpen(true);
onClose();
}, [onClose, setNewViewer]);
const openImportNew = useCallback(() => {
setImportOpen(true);
onClose();
}, [onClose, setImportOpen]);
const onImportedNew = useCallback(
(v: ViewerGroup) => {
const newViewerSetting = [...configParsed];
newViewerSetting.push(v);
onChange(JSON.stringify(newViewerSetting));
},
[configParsed],
);
return (
<Box>
<SecondaryButton variant={"contained"} {...bindTrigger(addNewPopupState)} startIcon={<Add />} sx={{ mb: 1 }}>
{t("settings.addViewer")}
</SecondaryButton>
{configParsed?.length > 0 &&
configParsed.map((item: ViewerGroup, index) => (
<ViewerGroupRow
group={item}
index={index}
key={index}
onDelete={onGroupDelete[index]}
onGroupChange={onGroupChange[index]}
dndType={`viewer-row-${index}`}
/>
))}
<Menu
onClose={onClose}
anchorOrigin={{
vertical: "top",
horizontal: "left",
}}
transformOrigin={{
vertical: "bottom",
horizontal: "left",
}}
{...menuProps}
>
<SquareMenuItem dense onClick={openCreateNew}>
<ListItemIcon>
<DocumentDataLink />
</ListItemIcon>
{t("settings.embeddedWebpageViewer")}
</SquareMenuItem>
<SquareMenuItem dense onClick={openImportNew}>
<ListItemIcon>
<DesktopFlow />
</ListItemIcon>
{t("settings.wopiViewer")}
</SquareMenuItem>
</Menu>
{newViewer && (
<FileViewerEditDialog
viewer={newViewer}
onChange={onNewViewerChange}
open={createNewOpen}
onClose={onCreateNewClosed}
/>
)}
<ImportWopiDialog onImported={onImportedNew} onClose={() => setImportOpen(false)} open={importOpen} />
</Box>
);
});
export default FileViewerList;

View File

@@ -0,0 +1,151 @@
import * as React from "react";
import { useCallback, useState } from "react";
import { Viewer, ViewerPlatform, ViewerType } from "../../../../api/explorer.ts";
import { useTranslation } from "react-i18next";
import { IconButton, ListItemText, TableRow } from "@mui/material";
import { DenseFilledTextField, DenseSelect, NoWrapCell, StyledCheckbox } from "../../../Common/StyledComponents.tsx";
import { SquareMenuItem } from "../../../FileManager/ContextMenu/ContextMenu.tsx";
import { ViewerIcon } from "../../../FileManager/Dialogs/OpenWith.tsx";
import Dismiss from "../../../Icons/Dismiss.tsx";
import Edit from "../../../Icons/Edit.tsx";
import FileViewerEditDialog from "./FileViewerEditDialog.tsx";
import ArrowDown from "../../../Icons/ArrowDown.tsx";
export interface FileViewerRowProps {
viewer: Viewer;
onChange: (viewer: Viewer) => void;
onDelete: (e: React.MouseEvent<HTMLElement>) => void;
onMoveUp?: () => void;
onMoveDown?: () => void;
isFirst?: boolean;
isLast?: boolean;
style?: React.CSSProperties;
}
const FileViewerRow = React.memo(
React.forwardRef<HTMLTableRowElement, FileViewerRowProps>(
({ viewer, onChange, onDelete, onMoveUp, onMoveDown, isFirst, isLast, style }, ref) => {
const { t } = useTranslation("dashboard");
const [extCached, setExtCached] = useState("");
const [editOpen, setEditOpen] = useState(false);
const onClose = useCallback(() => {
setEditOpen(false);
}, [setEditOpen]);
return (
<TableRow sx={{ "&:last-child td, &:last-child th": { border: 0 } }} hover ref={ref} style={style}>
<FileViewerEditDialog viewer={viewer} onChange={onChange} open={editOpen} onClose={onClose} />
<NoWrapCell>
<ViewerIcon viewer={viewer} />
</NoWrapCell>
<NoWrapCell>{t(`settings.${viewer.type}ViewerType`)}</NoWrapCell>
<NoWrapCell>
{t(viewer.display_name, {
ns: "application",
})}
</NoWrapCell>
<NoWrapCell>
<DenseFilledTextField
fullWidth
multiline
required
value={extCached == "" ? viewer.exts.join() : extCached}
onBlur={() => {
onChange({
...viewer,
exts: extCached == "" ? viewer.exts : extCached?.split(",")?.map((ext) => ext.trim()),
});
setExtCached("");
}}
onChange={(e) => setExtCached(e.target.value)}
/>
</NoWrapCell>
<NoWrapCell>
<DenseSelect
value={viewer.platform || ViewerPlatform.all}
onChange={(e) =>
onChange({
...viewer,
platform: e.target.value as ViewerPlatform,
})
}
>
<SquareMenuItem value="pc">
<ListItemText
slotProps={{
primary: { variant: "body2" },
}}
>
{t("settings.viewerPlatformPC")}
</ListItemText>
</SquareMenuItem>
<SquareMenuItem value="mobile">
<ListItemText
slotProps={{
primary: { variant: "body2" },
}}
>
{t("settings.viewerPlatformMobile")}
</ListItemText>
</SquareMenuItem>
<SquareMenuItem value="all">
<ListItemText
slotProps={{
primary: { variant: "body2" },
}}
>
{t("settings.viewerPlatformAll")}
</ListItemText>
</SquareMenuItem>
</DenseSelect>
</NoWrapCell>
<NoWrapCell>
{viewer.templates?.length ? t("settings.nMapping", { num: viewer.templates?.length }) : t("share.none")}
</NoWrapCell>
<NoWrapCell>
<StyledCheckbox
size={"small"}
checked={!viewer.disabled}
onChange={(e) =>
onChange({
...viewer,
disabled: !e.target.checked,
})
}
/>
</NoWrapCell>
<NoWrapCell>
<IconButton size={"small"} onClick={() => setEditOpen(true)}>
<Edit fontSize={"small"} />
</IconButton>
{viewer.type != ViewerType.builtin && (
<IconButton size={"small"} onClick={onDelete}>
<Dismiss fontSize={"small"} />
</IconButton>
)}
</NoWrapCell>
<NoWrapCell>
<IconButton size="small" onClick={onMoveUp} disabled={isFirst}>
<ArrowDown
sx={{
width: "18px",
height: "18px",
transform: "rotate(180deg)",
}}
/>
</IconButton>
<IconButton size="small" onClick={onMoveDown} disabled={isLast}>
<ArrowDown
sx={{
width: "18px",
height: "18px",
}}
/>
</IconButton>
</NoWrapCell>
</TableRow>
);
},
),
);
export default FileViewerRow;

View File

@@ -0,0 +1,72 @@
import { DialogContent, Link } from "@mui/material";
import { useCallback, useState } from "react";
import { Trans, useTranslation } from "react-i18next";
import { getWopiDiscovery } from "../../../../api/api.ts";
import { ViewerGroup } from "../../../../api/explorer.ts";
import { useAppDispatch } from "../../../../redux/hooks.ts";
import { DenseFilledTextField } from "../../../Common/StyledComponents.tsx";
import DraggableDialog from "../../../Dialogs/DraggableDialog.tsx";
import SettingForm from "../../../Pages/Setting/SettingForm.tsx";
import { Code } from "../../../Common/Code.tsx";
import { NoMarginHelperText } from "../../Settings/Settings.tsx";
export interface ImportWopiDialogProps {
open: boolean;
onClose: () => void;
onImported: (v: ViewerGroup) => void;
}
const ImportWopiDialog = ({ open, onClose, onImported }: ImportWopiDialogProps) => {
const { t } = useTranslation("dashboard");
const dispatch = useAppDispatch();
const [endpoint, setEndpoint] = useState("");
const [loading, setLoading] = useState(false);
const onSubmit = useCallback(() => {
setLoading(true);
dispatch(
getWopiDiscovery({
endpoint,
}),
)
.then((res) => {
onImported(res);
onClose();
})
.finally(() => {
setLoading(false);
});
}, [endpoint, onClose, onImported]);
return (
<DraggableDialog
title={t("settings.importWopi")}
showActions
showCancel
onAccept={onSubmit}
disabled={endpoint == ""}
loading={loading}
dialogProps={{
fullWidth: true,
maxWidth: "md",
open,
onClose,
}}
>
<DialogContent>
<SettingForm lgWidth={12} title={t("settings.wopiEndpoint")}>
<DenseFilledTextField value={endpoint} fullWidth onChange={(e) => setEndpoint(e.target.value)} />
<NoMarginHelperText>
<Trans
ns="dashboard"
i18nKey="settings.wopiDes"
components={[<Code />, <Link href="https://docs.cloudreve.org/usage/wopi" target="_blank" />]}
/>
</NoMarginHelperText>
</SettingForm>
</DialogContent>
</DraggableDialog>
);
};
export default ImportWopiDialog;

View File

@@ -0,0 +1,33 @@
import { Box, Stack } from "@mui/material";
import { useSnackbar } from "notistack";
import { useCallback, useContext, useState } from "react";
import { useTranslation } from "react-i18next";
import { useAppDispatch } from "../../../../redux/hooks";
import { SettingContext } from "../../Settings/SettingWrapper";
import FileViewerList from "./FileViewerList";
const ViewerSetting = () => {
const { t } = useTranslation("dashboard");
const { formRef, setSettings, values } = useContext(SettingContext);
const [loading, setLoading] = useState(false);
const dispatch = useAppDispatch();
const { enqueueSnackbar } = useSnackbar();
const viewerOnChange = useCallback(
(s: string) =>
setSettings({
file_viewers: s,
}),
[],
);
return (
<Box component={"form"} ref={formRef} onSubmit={(e) => e.preventDefault()}>
<Stack spacing={5}>
<FileViewerList config={values.file_viewers} onChange={viewerOnChange} />
</Stack>
</Box>
);
};
export default ViewerSetting;

View File

@@ -0,0 +1,116 @@
import { Alert, FormControl, FormControlLabel, Switch, Typography } from "@mui/material";
import { useCallback, useContext, useMemo } from "react";
import { useTranslation } from "react-i18next";
import { GroupEnt, StoragePolicy } from "../../../../api/dashboard";
import { GroupPermission } from "../../../../api/user";
import Boolset from "../../../../util/boolset";
import SizeInput from "../../../Common/SizeInput";
import { DenseFilledTextField } from "../../../Common/StyledComponents";
import InPrivate from "../../../Icons/InPrivate";
import SettingForm, { ProChip } from "../../../Pages/Setting/SettingForm";
import { NoMarginHelperText, SettingSection, SettingSectionContent } from "../../Settings/Settings";
import { AnonymousGroupID } from "../GroupRow";
import { GroupSettingContext } from "./GroupSettingWrapper";
import PolicySelectionInput from "./PolicySelectionInput";
const BasicInfoSection = () => {
const { t } = useTranslation("dashboard");
const { values, setGroup } = useContext(GroupSettingContext);
const permission = useMemo(() => {
return new Boolset(values.permissions ?? "");
}, [values.permissions]);
const onNameChange = useCallback(
(e: React.ChangeEvent<HTMLInputElement>) => {
setGroup((p: GroupEnt) => ({ ...p, name: e.target.value }));
},
[setGroup],
);
const onPolicyChange = useCallback(
(value: number) => {
setGroup((p: GroupEnt) => ({
...p,
edges: { ...p.edges, storage_policies: { id: value } as StoragePolicy },
}));
},
[setGroup],
);
const onMaxStorageChange = useCallback(
(size: number) => {
setGroup((p: GroupEnt) => ({
...p,
max_storage: size ? size : undefined,
}));
},
[setGroup],
);
const onIsAdminChange = useCallback(
(e: React.ChangeEvent<HTMLInputElement>) => {
setGroup((p: GroupEnt) => ({
...p,
permissions: new Boolset(p.permissions).set(GroupPermission.is_admin, e.target.checked).toString(),
}));
},
[setGroup],
);
return (
<SettingSection>
<Typography variant="h6" gutterBottom>
{t("policy.basicInfo")}
</Typography>
<SettingSectionContent>
{values?.id == AnonymousGroupID && (
<SettingForm lgWidth={5}>
<Alert icon={<InPrivate fontSize="inherit" />} severity="info">
{t("group.anonymousHint")}
</Alert>
</SettingForm>
)}
<SettingForm title={t("group.nameOfGroup")} lgWidth={5}>
<FormControl fullWidth>
<DenseFilledTextField required value={values.name} onChange={onNameChange} />
<NoMarginHelperText>{t("group.nameOfGroupDes")}</NoMarginHelperText>
</FormControl>
</SettingForm>
{values?.id != AnonymousGroupID && (
<>
<SettingForm title={t("group.availablePolicies")} lgWidth={5}>
<PolicySelectionInput value={values.edges.storage_policies?.id ?? 0} onChange={onPolicyChange} />
<NoMarginHelperText>{t("group.availablePoliciesDes")}</NoMarginHelperText>
<NoMarginHelperText>
<ProChip size="small" label="Pro" sx={{ ml: 0 }} /> {t("group.availablePolicyDesPro")}
</NoMarginHelperText>
</SettingForm>
<SettingForm title={t("group.initialStorageQuota")} lgWidth={5}>
<FormControl fullWidth>
<SizeInput
variant={"outlined"}
required
value={values.max_storage ?? 0}
onChange={onMaxStorageChange}
/>
<NoMarginHelperText>{t("group.initialStorageQuotaDes")}</NoMarginHelperText>
</FormControl>
</SettingForm>
<SettingForm lgWidth={5}>
<FormControl fullWidth>
<FormControlLabel
disabled={values.id == 1}
control={<Switch checked={permission.enabled(GroupPermission.is_admin)} onChange={onIsAdminChange} />}
label={t("group.isAdmin")}
/>
<NoMarginHelperText>{t("group.isAdminDes")}</NoMarginHelperText>
</FormControl>
</SettingForm>
</>
)}
</SettingSectionContent>
</SettingSection>
);
};
export default BasicInfoSection;

View File

@@ -0,0 +1,27 @@
import { Container } from "@mui/material";
import { useState } from "react";
import { useTranslation } from "react-i18next";
import { useParams } from "react-router-dom";
import PageContainer from "../../../Pages/PageContainer";
import PageHeader from "../../../Pages/PageHeader";
import GroupForm from "./GroupForm";
import GroupSettingWrapper from "./GroupSettingWrapper";
const EditGroup = () => {
const { t } = useTranslation("dashboard");
const { id } = useParams();
const [name, setName] = useState("");
return (
<PageContainer>
<Container maxWidth="xl">
<PageHeader title={t("group.editGroup", { group: name })} />
<GroupSettingWrapper groupID={parseInt(id ?? "1") ?? 1} onGroupChange={(p) => setName(p.name)}>
<GroupForm />
</GroupSettingWrapper>
</Container>
</PageContainer>
);
};
export default EditGroup;

View File

@@ -0,0 +1,388 @@
import {
Box,
CircularProgress,
Collapse,
FormControl,
FormControlLabel,
Link,
Stack,
Switch,
Typography,
useTheme,
} from "@mui/material";
import { lazy, Suspense, useCallback, useContext, useEffect, useMemo, useState } from "react";
import { Trans, useTranslation } from "react-i18next";
import { Link as RouterLink } from "react-router-dom";
import { GroupEnt } from "../../../../api/dashboard";
import { GroupPermission } from "../../../../api/user";
import Boolset from "../../../../util/boolset";
import SizeInput from "../../../Common/SizeInput";
import { DenseFilledTextField } from "../../../Common/StyledComponents";
import SettingForm, { ProChip } from "../../../Pages/Setting/SettingForm";
import ProDialog from "../../Common/ProDialog";
import { NoMarginHelperText, SettingSection, SettingSectionContent } from "../../Settings/Settings";
import { AnonymousGroupID } from "../GroupRow";
import { GroupSettingContext } from "./GroupSettingWrapper";
import MultipleNodeSelectionInput from "./MultipleNodeSelectionInput";
const MonacoEditor = lazy(() => import("../../../Viewers/CodeViewer/MonacoEditor"));
const FileManagementSection = () => {
const { t } = useTranslation("dashboard");
const { values, setGroup } = useContext(GroupSettingContext);
const [proOpen, setProOpen] = useState(false);
const theme = useTheme();
const [editedConfig, setEditedConfig] = useState("");
const permission = useMemo(() => {
return new Boolset(values.permissions ?? "");
}, [values.permissions]);
useEffect(() => {
setEditedConfig(
values.settings?.remote_download_options ? JSON.stringify(values.settings?.remote_download_options, null, 2) : "",
);
}, [values.settings?.remote_download_options]);
const onAllowWabDAVChange = useCallback(
(e: React.ChangeEvent<HTMLInputElement>) => {
setGroup((p: GroupEnt) => ({
...p,
permissions: new Boolset(p.permissions).set(GroupPermission.webdav, e.target.checked).toString(),
}));
},
[setGroup],
);
const onAllowWabDAVProxyChange = useCallback(
(e: React.ChangeEvent<HTMLInputElement>) => {
setGroup((p: GroupEnt) => ({
...p,
permissions: new Boolset(p.permissions).set(GroupPermission.webdav_proxy, e.target.checked).toString(),
}));
},
[setGroup],
);
const onAllowCompressTaskChange = useCallback(
(e: React.ChangeEvent<HTMLInputElement>) => {
setGroup((p: GroupEnt) => ({
...p,
permissions: new Boolset(p.permissions).set(GroupPermission.archive_task, e.target.checked).toString(),
}));
},
[setGroup],
);
const onCompressSizeChange = useCallback(
(e: number) => {
setGroup((p: GroupEnt) => ({ ...p, settings: { ...p.settings, compress_size: e ? e : undefined } }));
},
[setGroup],
);
const onDecompressSizeChange = useCallback(
(e: number) => {
setGroup((p: GroupEnt) => ({ ...p, settings: { ...p.settings, decompress_size: e ? e : undefined } }));
},
[setGroup],
);
const onAllowRemoteDownloadChange = useCallback(
(e: React.ChangeEvent<HTMLInputElement>) => {
setGroup((p: GroupEnt) => ({
...p,
permissions: new Boolset(p.permissions).set(GroupPermission.remote_download, e.target.checked).toString(),
}));
},
[setGroup],
);
const onEditedConfigBlur = useCallback(
(value: string) => {
var res: Record<string, any> | undefined = undefined;
if (value) {
try {
res = JSON.parse(value);
} catch (e) {
console.error(e);
}
}
setGroup((p: GroupEnt) => ({ ...p, settings: { ...p.settings, remote_download_options: res } }));
},
[editedConfig, setGroup],
);
const onAria2BatchSizeChange = useCallback(
(e: React.ChangeEvent<HTMLInputElement>) => {
setGroup((p: GroupEnt) => ({
...p,
settings: { ...p.settings, aria2_batch: parseInt(e.target.value) ? parseInt(e.target.value) : undefined },
}));
},
[setGroup],
);
const onAllowAdvanceDeleteChange = useCallback(
(e: React.ChangeEvent<HTMLInputElement>) => {
setGroup((p: GroupEnt) => ({
...p,
permissions: new Boolset(p.permissions).set(GroupPermission.advance_delete, e.target.checked).toString(),
}));
},
[setGroup],
);
const onMaxWalkedFilesChange = useCallback(
(e: React.ChangeEvent<HTMLInputElement>) => {
setGroup((p: GroupEnt) => ({
...p,
settings: { ...p.settings, max_walked_files: parseInt(e.target.value) ? parseInt(e.target.value) : undefined },
}));
},
[setGroup],
);
const onTrashBinDurationChange = useCallback(
(e: React.ChangeEvent<HTMLInputElement>) => {
setGroup((p: GroupEnt) => ({
...p,
settings: { ...p.settings, trash_retention: parseInt(e.target.value) ? parseInt(e.target.value) : undefined },
}));
},
[setGroup],
);
const onProClick = useCallback((e: React.MouseEvent<HTMLDivElement>) => {
e.stopPropagation();
setProOpen(true);
}, []);
return (
<SettingSection>
<ProDialog open={proOpen} onClose={() => setProOpen(false)} />
<Typography variant="h6" gutterBottom>
{t("group.fileManagement")}
</Typography>
<SettingSectionContent>
{values?.id != AnonymousGroupID && (
<>
<SettingForm lgWidth={5}>
<FormControl fullWidth>
<FormControlLabel
control={
<Switch checked={permission.enabled(GroupPermission.webdav)} onChange={onAllowWabDAVChange} />
}
label={t("group.allowWabDAV")}
/>
<NoMarginHelperText>{t("group.allowWabDAVDes")}</NoMarginHelperText>
</FormControl>
</SettingForm>
<Collapse in={permission.enabled(GroupPermission.webdav)} unmountOnExit>
<SettingForm lgWidth={5}>
<FormControl fullWidth>
<FormControlLabel
control={
<Switch
checked={permission.enabled(GroupPermission.webdav_proxy)}
onChange={onAllowWabDAVProxyChange}
/>
}
label={t("group.allowWabDAVProxy")}
/>
<NoMarginHelperText>{t("group.allowWabDAVProxyDes")}</NoMarginHelperText>
</FormControl>
</SettingForm>
</Collapse>
<SettingForm lgWidth={5}>
<FormControl fullWidth onClick={onProClick}>
<FormControlLabel
control={<Switch checked={false} />}
label={
<Box sx={{ display: "flex", alignItems: "center" }}>
{t("group.migratePolicy")}
<ProChip size="small" label="Pro" />
</Box>
}
/>
<NoMarginHelperText>{t("group.migratePolicyDes")}</NoMarginHelperText>
</FormControl>
</SettingForm>
<SettingForm lgWidth={5}>
<FormControl fullWidth>
<FormControlLabel
control={
<Switch
checked={permission.enabled(GroupPermission.archive_task)}
onChange={onAllowCompressTaskChange}
/>
}
label={t("group.compressTask")}
/>
<NoMarginHelperText>{t("group.compressTaskDes")}</NoMarginHelperText>
</FormControl>
</SettingForm>
<Collapse in={permission.enabled(GroupPermission.archive_task)} unmountOnExit>
<Stack spacing={3}>
<SettingForm title={t("group.compressSize")} lgWidth={5}>
<FormControl fullWidth>
<SizeInput
variant={"outlined"}
required
value={values.settings?.compress_size ?? 0}
onChange={onCompressSizeChange}
/>
<NoMarginHelperText>{t("group.compressSizeDes")}</NoMarginHelperText>
</FormControl>
</SettingForm>
<SettingForm title={t("group.decompressSize")} lgWidth={5}>
<FormControl fullWidth>
<SizeInput
variant={"outlined"}
required
value={values.settings?.decompress_size ?? 0}
onChange={onDecompressSizeChange}
/>
<NoMarginHelperText>{t("group.decompressSizeDes")}</NoMarginHelperText>
</FormControl>
</SettingForm>
</Stack>
</Collapse>
<SettingForm lgWidth={5}>
<FormControl fullWidth>
<FormControlLabel
control={
<Switch
checked={permission.enabled(GroupPermission.remote_download)}
onChange={onAllowRemoteDownloadChange}
/>
}
label={t("group.allowRemoteDownload")}
/>
<NoMarginHelperText>
<Trans
ns="dashboard"
i18nKey="group.allowRemoteDownloadDes"
components={[<Link component={RouterLink} to="/admin/node" />]}
/>
</NoMarginHelperText>
</FormControl>
</SettingForm>
<Collapse in={permission.enabled(GroupPermission.remote_download)} unmountOnExit>
<Stack spacing={3}>
<SettingForm title={t("group.aria2Options")} lgWidth={5}>
<FormControl fullWidth>
<Suspense fallback={<CircularProgress />}>
<MonacoEditor
theme={theme.palette.mode === "dark" ? "vs-dark" : "vs"}
language="json"
value={editedConfig}
onChange={(value) => setEditedConfig(value || "")}
onBlur={onEditedConfigBlur}
height="200px"
minHeight="200px"
options={{
wordWrap: "on",
minimap: { enabled: false },
scrollBeyondLastLine: false,
}}
/>
</Suspense>
<NoMarginHelperText>{t("group.aria2OptionsDes")}</NoMarginHelperText>
</FormControl>
</SettingForm>
<SettingForm title={t("group.aria2BatchSize")} lgWidth={5}>
<FormControl fullWidth>
<DenseFilledTextField
slotProps={{
htmlInput: {
type: "number",
min: 0,
},
}}
value={values.settings?.aria2_batch ?? 0}
onChange={onAria2BatchSizeChange}
/>
<NoMarginHelperText>{t("group.aria2BatchSizeDes")}</NoMarginHelperText>
</FormControl>
</SettingForm>
</Stack>
</Collapse>
<SettingForm lgWidth={5}>
<FormControl fullWidth>
<FormControlLabel
control={
<Switch
checked={permission.enabled(GroupPermission.advance_delete)}
onChange={onAllowAdvanceDeleteChange}
/>
}
label={t("group.advanceDelete")}
/>
<NoMarginHelperText>{t("group.advanceDeleteDes")}</NoMarginHelperText>
</FormControl>
</SettingForm>
<SettingForm title={t("group.allowedNodes")} lgWidth={5} pro>
<FormControl fullWidth>
<MultipleNodeSelectionInput />
<NoMarginHelperText>{t("group.allowedNodesDes")}</NoMarginHelperText>
</FormControl>
</SettingForm>
<SettingForm lgWidth={5}>
<FormControl fullWidth onClick={onProClick}>
<FormControlLabel
control={<Switch checked={false} />}
label={
<Box sx={{ display: "flex", alignItems: "center" }}>
{t("group.allowSelectNode")}
<ProChip size="small" label="Pro" />
</Box>
}
/>
<NoMarginHelperText>{t("group.allowSelectNodeDes")}</NoMarginHelperText>
</FormControl>
</SettingForm>
</>
)}
<SettingForm title={t("group.maxWalkedFiles")} lgWidth={5}>
<FormControl fullWidth>
<DenseFilledTextField
required
slotProps={{
htmlInput: {
type: "number",
min: 1,
},
}}
value={values.settings?.max_walked_files ?? 0}
onChange={onMaxWalkedFilesChange}
/>
<NoMarginHelperText>{t("group.maxWalkedFilesDes")}</NoMarginHelperText>
</FormControl>
</SettingForm>
{values?.id != AnonymousGroupID && (
<SettingForm title={t("group.trashBinDuration")} lgWidth={5}>
<FormControl fullWidth>
<DenseFilledTextField
required
slotProps={{
htmlInput: {
type: "number",
min: 1,
},
}}
value={values.settings?.trash_retention ?? 0}
onChange={onTrashBinDurationChange}
/>
<NoMarginHelperText>{t("group.trashBinDurationDes")}</NoMarginHelperText>
</FormControl>
</SettingForm>
)}
</SettingSectionContent>
</SettingSection>
);
};
export default FileManagementSection;

View File

@@ -0,0 +1,25 @@
import { Box, Stack } from "@mui/material";
import { useContext } from "react";
import { useTranslation } from "react-i18next";
import BasicInfoSection from "./BasicInfoSection";
import FileManagementSection from "./FileManagementSection";
import { GroupSettingContext } from "./GroupSettingWrapper";
import ShareSection from "./ShareSection";
import UploadDownloadSection from "./UploadDownloadSection";
const GroupForm = () => {
const { t } = useTranslation("dashboard");
const { formRef, values } = useContext(GroupSettingContext);
return (
<Box component={"form"} ref={formRef} onSubmit={(e) => e.preventDefault()}>
<Stack spacing={5}>
<BasicInfoSection />
<ShareSection />
<FileManagementSection />
<UploadDownloadSection />
</Stack>
</Box>
);
};
export default GroupForm;

View File

@@ -0,0 +1,145 @@
import { Box } from "@mui/material";
import * as React from "react";
import { createContext, useEffect, useMemo, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import { CSSTransition, SwitchTransition } from "react-transition-group";
import { getGroupDetail, upsertGroup } from "../../../../api/api.ts";
import { GroupEnt, StoragePolicy } from "../../../../api/dashboard.ts";
import { useAppDispatch } from "../../../../redux/hooks.ts";
import FacebookCircularProgress from "../../../Common/CircularProgress.tsx";
import { SavingFloat } from "../../Settings/SettingWrapper.tsx";
export interface GroupSettingWrapperProps {
groupID: number;
children: React.ReactNode;
onGroupChange: (group: GroupEnt) => void;
}
export interface GroupSettingContextProps {
values: GroupEnt;
setGroup: (f: (p: GroupEnt) => GroupEnt) => void;
formRef?: React.RefObject<HTMLFormElement>;
}
const defaultGroup: GroupEnt = {
id: 0,
name: "",
edges: {},
};
export const GroupSettingContext = createContext<GroupSettingContextProps>({
values: { ...defaultGroup },
setGroup: () => {},
});
const groupValueFilter = (group: GroupEnt): GroupEnt => {
return {
...group,
edges: {
storage_policies: {
id: group.edges.storage_policies?.id ?? 0,
} as StoragePolicy,
},
};
};
const GroupSettingWrapper = ({ groupID, children, onGroupChange }: GroupSettingWrapperProps) => {
const dispatch = useAppDispatch();
const { t } = useTranslation("dashboard");
const [values, setValues] = useState<GroupEnt>({
...defaultGroup,
});
const [modifiedValues, setModifiedValues] = useState<GroupEnt>({
...defaultGroup,
});
const [loading, setLoading] = useState(true);
const [submitting, setSubmitting] = useState(false);
const formRef = useRef<HTMLFormElement>(null);
const showSaveButton = useMemo(() => {
return JSON.stringify(modifiedValues) !== JSON.stringify(values);
}, [modifiedValues, values]);
useEffect(() => {
setLoading(true);
dispatch(getGroupDetail(groupID))
.then((res) => {
setValues(groupValueFilter(res));
setModifiedValues(groupValueFilter(res));
onGroupChange(groupValueFilter(res));
})
.finally(() => {
setLoading(false);
});
}, [groupID]);
const revert = () => {
setModifiedValues(values);
};
const submit = () => {
if (formRef.current) {
if (!formRef.current.checkValidity()) {
formRef.current.reportValidity();
return;
}
}
setSubmitting(true);
dispatch(
upsertGroup({
group: { ...modifiedValues },
}),
)
.then((res) => {
setValues(groupValueFilter(res));
setModifiedValues(groupValueFilter(res));
onGroupChange(groupValueFilter(res));
})
.finally(() => {
setSubmitting(false);
});
};
return (
<GroupSettingContext.Provider
value={{
values: modifiedValues,
setGroup: setModifiedValues,
formRef,
}}
>
<SwitchTransition>
<CSSTransition
addEndListener={(node, done) => node.addEventListener("transitionend", done, false)}
classNames="fade"
key={`${loading}`}
>
<Box sx={{ mt: 3 }}>
{loading && (
<Box
sx={{
pt: 20,
height: "100%",
display: "flex",
justifyContent: "center",
alignItems: "center",
}}
>
<FacebookCircularProgress />
</Box>
)}
{!loading && (
<Box>
{children}
<SavingFloat in={showSaveButton} submitting={submitting} revert={revert} submit={submit} />
</Box>
)}
</Box>
</CSSTransition>
</SwitchTransition>
</GroupSettingContext.Provider>
);
};
export default GroupSettingWrapper;

View File

@@ -0,0 +1,41 @@
import { ListItemText } from "@mui/material";
import { useTranslation } from "react-i18next";
import { useAppDispatch } from "../../../../redux/hooks";
import { DenseSelect } from "../../../Common/StyledComponents";
const MultipleNodeSelectionInput = () => {
const { t } = useTranslation("dashboard");
const dispatch = useAppDispatch();
return (
<DenseSelect
multiple
displayEmpty
sx={{
minHeight: 39,
}}
MenuProps={{
PaperProps: { sx: { maxWidth: 230 } },
MenuListProps: {
sx: {
"& .MuiMenuItem-root": {
whiteSpace: "normal",
},
},
},
}}
renderValue={(selected) => {
return (
<ListItemText
primary={<em>{t("group.allNodes")}</em>}
slotProps={{
primary: { color: "textSecondary", variant: "body2" },
}}
/>
);
}}
></DenseSelect>
);
};
export default MultipleNodeSelectionInput;

View File

@@ -0,0 +1,102 @@
import { Box, FormControl, SelectChangeEvent, Typography } from "@mui/material";
import { useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import { getStoragePolicyList } from "../../../../api/api";
import { StoragePolicy } from "../../../../api/dashboard";
import { useAppDispatch } from "../../../../redux/hooks";
import FacebookCircularProgress from "../../../Common/CircularProgress";
import { DenseSelect, SquareChip } from "../../../Common/StyledComponents";
import { SquareMenuItem } from "../../../FileManager/ContextMenu/ContextMenu";
export interface PolicySelectionInputProps {
value: number;
onChange: (value: number) => void;
}
const PolicySelectionInput = ({ value, onChange }: PolicySelectionInputProps) => {
const { t } = useTranslation("dashboard");
const dispatch = useAppDispatch();
const [policies, setPolicies] = useState<StoragePolicy[]>([]);
const [loading, setLoading] = useState(false);
const [policyMap, setPolicyMap] = useState<Record<number, StoragePolicy>>({});
const handleChange = (event: SelectChangeEvent<unknown>) => {
const {
target: { value },
} = event;
onChange(value as number);
};
useEffect(() => {
setLoading(true);
dispatch(getStoragePolicyList({ page: 1, page_size: 1000, order_by: "id", order_direction: "asc" }))
.then((res) => {
setPolicies(res.policies);
setPolicyMap(
res.policies.reduce(
(acc, policy) => {
acc[policy.id] = policy;
return acc;
},
{} as Record<number, StoragePolicy>,
),
);
})
.finally(() => {
setLoading(false);
});
}, []);
return (
<FormControl fullWidth>
<DenseSelect
value={value}
required
onChange={handleChange}
sx={{
minHeight: 39,
}}
disabled={loading}
MenuProps={{
PaperProps: { sx: { maxWidth: 230 } },
MenuListProps: {
sx: {
"& .MuiMenuItem-root": {
whiteSpace: "normal",
},
},
},
}}
renderValue={(selected: number) => (
<Box sx={{ display: "flex", flexWrap: "wrap", gap: 0.5 }}>
{!loading ? (
<SquareChip size="small" key={selected} label={policyMap[selected]?.name} />
) : (
<FacebookCircularProgress size={20} sx={{ mt: "1px" }} />
)}
</Box>
)}
>
{policies.length > 0 &&
policies.map((policy) => (
<SquareMenuItem key={policy.id} value={policy.id}>
<Box
sx={{
display: "flex",
flexDirection: "column",
}}
>
<Typography variant={"body2"} fontWeight={600}>
{policy.name}
</Typography>
<Typography variant={"caption"} color={"textSecondary"}>
{t(`policy.${policy.type}`)}
</Typography>
</Box>
</SquareMenuItem>
))}
</DenseSelect>
</FormControl>
);
};
export default PolicySelectionInput;

View File

@@ -0,0 +1,113 @@
import { Box, FormControl, FormControlLabel, Switch, Typography } from "@mui/material";
import { useCallback, useContext, useMemo, useState } from "react";
import { useTranslation } from "react-i18next";
import { GroupEnt } from "../../../../api/dashboard";
import { GroupPermission } from "../../../../api/user";
import Boolset from "../../../../util/boolset";
import SettingForm, { ProChip } from "../../../Pages/Setting/SettingForm";
import ProDialog from "../../Common/ProDialog";
import { NoMarginHelperText, SettingSection, SettingSectionContent } from "../../Settings/Settings";
import { AnonymousGroupID } from "../GroupRow";
import { GroupSettingContext } from "./GroupSettingWrapper";
const ShareSection = () => {
const { t } = useTranslation("dashboard");
const { values, setGroup } = useContext(GroupSettingContext);
const [proOpen, setProOpen] = useState(false);
const permission = useMemo(() => {
return new Boolset(values.permissions ?? "");
}, [values.permissions]);
const onAllowCreateShareLinkChange = useCallback(
(e: React.ChangeEvent<HTMLInputElement>) => {
setGroup((p: GroupEnt) => ({
...p,
permissions: new Boolset(p.permissions).set(GroupPermission.share, e.target.checked).toString(),
}));
},
[setGroup],
);
const onShareDownloadChange = useCallback(
(e: React.ChangeEvent<HTMLInputElement>) => {
setGroup((p: GroupEnt) => ({
...p,
permissions: new Boolset(p.permissions).set(GroupPermission.share_download, e.target.checked).toString(),
}));
},
[setGroup],
);
const onProClick = (e: React.MouseEvent<HTMLDivElement>) => {
e.stopPropagation();
setProOpen(true);
};
return (
<SettingSection>
<ProDialog open={proOpen} onClose={() => setProOpen(false)} />
<Typography variant="h6" gutterBottom>
{t("group.share")}
</Typography>
<SettingSectionContent>
{values?.id != AnonymousGroupID && (
<SettingForm lgWidth={5}>
<FormControl fullWidth>
<FormControlLabel
control={
<Switch checked={permission.enabled(GroupPermission.share)} onChange={onAllowCreateShareLinkChange} />
}
label={t("group.allowCreateShareLink")}
/>
<NoMarginHelperText>{t("group.allowCreateShareLinkDes")}</NoMarginHelperText>
</FormControl>
</SettingForm>
)}
<SettingForm lgWidth={5}>
<FormControl fullWidth onClick={onProClick}>
<FormControlLabel
control={<Switch checked={false} />}
label={
<Box sx={{ display: "flex", alignItems: "center" }}>
{t("group.shareFree")}
<ProChip size="small" label="Pro" />
</Box>
}
/>
<NoMarginHelperText>{t("group.shareFreeDes")}</NoMarginHelperText>
</FormControl>
</SettingForm>
<SettingForm lgWidth={5}>
<FormControl fullWidth>
<FormControlLabel
control={
<Switch checked={permission.enabled(GroupPermission.share_download)} onChange={onShareDownloadChange} />
}
label={t("group.allowDownloadShare")}
/>
<NoMarginHelperText>{t("group.allowDownloadShareDes")}</NoMarginHelperText>
</FormControl>
</SettingForm>
{values?.id != AnonymousGroupID && (
<SettingForm lgWidth={5}>
<FormControl fullWidth onClick={onProClick}>
<FormControlLabel
control={<Switch checked={false} />}
label={
<Box sx={{ display: "flex", alignItems: "center" }}>
{t("group.esclateAnonymity")}
<ProChip size="small" label="Pro" />
</Box>
}
/>
<NoMarginHelperText>{t("group.esclateAnonymityDes")}</NoMarginHelperText>
</FormControl>
</SettingForm>
)}
</SettingSectionContent>
</SettingSection>
);
};
export default ShareSection;

View File

@@ -0,0 +1,182 @@
import { Collapse, FormControl, FormControlLabel, Stack, Switch, Typography } from "@mui/material";
import { useCallback, useContext, useMemo } from "react";
import { useTranslation } from "react-i18next";
import { GroupEnt } from "../../../../api/dashboard";
import { GroupPermission } from "../../../../api/user";
import Boolset from "../../../../util/boolset";
import SizeInput from "../../../Common/SizeInput";
import { DenseFilledTextField } from "../../../Common/StyledComponents";
import SettingForm from "../../../Pages/Setting/SettingForm";
import { NoMarginHelperText, SettingSection, SettingSectionContent } from "../../Settings/Settings";
import { AnonymousGroupID } from "../GroupRow";
import { GroupSettingContext } from "./GroupSettingWrapper";
const UploadDownloadSection = () => {
const { t } = useTranslation("dashboard");
const { values, setGroup } = useContext(GroupSettingContext);
const permission = useMemo(() => {
return new Boolset(values.permissions ?? "");
}, [values.permissions]);
const onAllowArchiveDownloadChange = useCallback(
(e: React.ChangeEvent<HTMLInputElement>) => {
setGroup((p: GroupEnt) => ({
...p,
permissions: new Boolset(p.permissions).set(GroupPermission.archive_download, e.target.checked).toString(),
}));
},
[setGroup],
);
const onAllowDirectLinkChange = useCallback(
(e: React.ChangeEvent<HTMLInputElement>) => {
setGroup((p: GroupEnt) => ({
...p,
settings: {
...p.settings,
source_batch: e.target.checked ? 1 : 0,
},
}));
},
[setGroup],
);
const onSourceBatchChange = useCallback(
(e: React.ChangeEvent<HTMLInputElement>) => {
setGroup((p: GroupEnt) => ({
...p,
settings: { ...p.settings, source_batch: parseInt(e.target.value) ? parseInt(e.target.value) : undefined },
}));
},
[setGroup],
);
const onRedirectedSourceChange = useCallback(
(e: React.ChangeEvent<HTMLInputElement>) => {
setGroup((p: GroupEnt) => ({
...p,
settings: { ...p.settings, redirected_source: e.target.checked ? true : undefined },
}));
},
[setGroup],
);
const onDownloadSpeedLimitChange = useCallback(
(e: number) => {
setGroup((p: GroupEnt) => ({ ...p, speed_limit: e ? e : undefined }));
},
[setGroup],
);
const onReuseDirectLinkChange = useCallback(
(e: React.ChangeEvent<HTMLInputElement>) => {
setGroup((p: GroupEnt) => ({
...p,
permissions: new Boolset(p.permissions).set(GroupPermission.unique_direct_link, !e.target.checked).toString(),
}));
},
[setGroup],
);
return (
<SettingSection>
<Typography variant="h6" gutterBottom>
{t("group.uploadDownload")}
</Typography>
<SettingSectionContent>
<SettingForm lgWidth={5}>
<FormControl fullWidth>
<FormControlLabel
control={
<Switch
checked={permission.enabled(GroupPermission.archive_download)}
onChange={onAllowArchiveDownloadChange}
/>
}
label={t("group.serverSideBatchDownload")}
/>
<NoMarginHelperText>{t("group.serverSideBatchDownloadDes")}</NoMarginHelperText>
</FormControl>
</SettingForm>
{values?.id != AnonymousGroupID && (
<>
<SettingForm lgWidth={5}>
<FormControl fullWidth>
<FormControlLabel
control={
<Switch checked={(values?.settings?.source_batch ?? 0) > 0} onChange={onAllowDirectLinkChange} />
}
label={t("group.getDirectLink")}
/>
<NoMarginHelperText>{t("group.getDirectLinkDes")}</NoMarginHelperText>
</FormControl>
</SettingForm>
<Collapse in={(values?.settings?.source_batch ?? 0) > 0} unmountOnExit>
<Stack spacing={3}>
<SettingForm lgWidth={5} title={t("group.bathSourceLinkLimit")}>
<FormControl fullWidth>
<DenseFilledTextField
slotProps={{
htmlInput: {
type: "number",
min: 0,
},
}}
value={values?.settings?.source_batch ?? 0}
onChange={onSourceBatchChange}
/>
<NoMarginHelperText>{t("group.bathSourceLinkLimitDes")}</NoMarginHelperText>
</FormControl>
</SettingForm>
<SettingForm lgWidth={5}>
<FormControl fullWidth>
<FormControlLabel
control={
<Switch
checked={values?.settings?.redirected_source ?? false}
onChange={onRedirectedSourceChange}
/>
}
label={t("group.redirectedSource")}
/>
<NoMarginHelperText>{t("group.redirectedSourceDes")}</NoMarginHelperText>
</FormControl>
</SettingForm>
</Stack>
<Collapse in={values?.settings?.redirected_source} unmountOnExit>
<SettingForm lgWidth={5}>
<FormControl fullWidth sx={{ mt: 3 }}>
<FormControlLabel
control={
<Switch
checked={!permission.enabled(GroupPermission.unique_direct_link)}
onChange={onReuseDirectLinkChange}
/>
}
label={t("group.reuseDirectLink")}
/>
<NoMarginHelperText>{t("group.reuseDirectLinkDes")}</NoMarginHelperText>
</FormControl>
</SettingForm>
</Collapse>
</Collapse>
</>
)}
<SettingForm lgWidth={5} title={t("group.downloadSpeedLimit")}>
<FormControl fullWidth>
<SizeInput
suffix={"/s"}
variant={"outlined"}
value={values?.speed_limit ?? 0}
onChange={onDownloadSpeedLimitChange}
/>
<NoMarginHelperText>{t("group.downloadSpeedLimitDes")}</NoMarginHelperText>
</FormControl>
</SettingForm>
</SettingSectionContent>
</SettingSection>
);
};
export default UploadDownloadSection;

View File

@@ -0,0 +1,179 @@
import { Box, IconButton, Link, Skeleton, TableRow, Tooltip } from "@mui/material";
import { useMemo, useState } from "react";
import { useTranslation } from "react-i18next";
import { Link as RouterLink, useNavigate } from "react-router-dom";
import { deleteGroup, getGroupDetail } from "../../../api/api";
import { GroupEnt } from "../../../api/dashboard";
import { GroupPermission } from "../../../api/user";
import { useAppDispatch } from "../../../redux/hooks";
import { confirmOperation } from "../../../redux/thunks/dialog";
import { sizeToString } from "../../../util";
import Boolset from "../../../util/boolset";
import { NoWrapBox, NoWrapTableCell, NoWrapTypography, SquareChip } from "../../Common/StyledComponents";
import Delete from "../../Icons/Delete";
import InPrivate from "../../Icons/InPrivate";
import PersonPasskey from "../../Icons/PersonPasskey";
import Shield from "../../Icons/Shield";
export interface GroupRowProps {
group?: GroupEnt;
loading?: boolean;
onDelete?: () => void;
}
export const AnonymousGroupID = 3;
const GroupRow = ({ group, loading, onDelete }: GroupRowProps) => {
const navigate = useNavigate();
const { t } = useTranslation("dashboard");
const dispatch = useAppDispatch();
const [userCount, setUserCount] = useState<number | undefined>(undefined);
const [countLoading, setCountLoading] = useState(false);
const [deleteLoading, setDeleteLoading] = useState(false);
const onPolicyClick =
(policyId: number): ((e: React.MouseEvent<HTMLDivElement>) => void) =>
(e) => {
e.stopPropagation();
navigate(`/admin/policy/${policyId}`);
};
const onRowClick = () => {
navigate(`/admin/group/${group?.id}`);
};
const groupBs = useMemo(() => {
return new Boolset(group?.permissions);
}, [group?.permissions]);
const onCountClick = (e: React.MouseEvent<HTMLAnchorElement>) => {
e.stopPropagation();
e.preventDefault();
setCountLoading(true);
dispatch(getGroupDetail(group?.id ?? 0, true))
.then((res) => {
setUserCount(res.total_users);
setCountLoading(false);
})
.finally(() => {
setCountLoading(false);
});
};
const onDeleteClick = (e: React.MouseEvent<HTMLButtonElement>) => {
e.stopPropagation();
e.preventDefault();
dispatch(confirmOperation(t("group.confirmDelete", { group: group?.name }))).then(() => {
if (group?.id) {
setDeleteLoading(true);
dispatch(deleteGroup(group.id))
.then(() => {
onDelete?.();
})
.finally(() => {
setDeleteLoading(false);
});
}
});
};
const stopPropagation = (e: React.MouseEvent<HTMLAnchorElement>) => {
e.stopPropagation();
};
if (loading) {
return (
<TableRow>
<NoWrapTableCell>
<Skeleton variant="text" width={50} />
</NoWrapTableCell>
<NoWrapTableCell>
<Skeleton variant="text" width={150} />
</NoWrapTableCell>
<NoWrapTableCell>
<Skeleton variant="text" width={150} />
</NoWrapTableCell>
<NoWrapTableCell>
<Skeleton variant="text" width={150} />
</NoWrapTableCell>
<NoWrapTableCell>
<Skeleton variant="text" width={150} />
</NoWrapTableCell>
<NoWrapTableCell>
<Skeleton variant="text" width={100} />
</NoWrapTableCell>
</TableRow>
);
}
return (
<TableRow hover key={group?.id} sx={{ cursor: "pointer" }} onClick={onRowClick}>
<NoWrapTableCell>{group?.id}</NoWrapTableCell>
<NoWrapTableCell>
<NoWrapBox sx={{ display: "flex", alignItems: "center", gap: 1 }}>
<NoWrapTypography variant="inherit">{group?.name}</NoWrapTypography>
<Box sx={{ display: "flex", alignItems: "center" }}>
{(group?.id ?? 0) <= 3 && (
<Tooltip title={t("group.sysGroup")}>
<Box sx={{ display: "flex", alignItems: "center" }}>
<Shield fontSize="small" sx={{ color: "text.secondary" }} />
</Box>
</Tooltip>
)}
{(group?.id ?? 0) == AnonymousGroupID && (
<Tooltip title={t("group.anonymous")}>
<Box sx={{ display: "flex", alignItems: "center" }}>
<InPrivate fontSize="small" sx={{ color: "text.secondary" }} />
</Box>
</Tooltip>
)}
{groupBs.enabled(GroupPermission.is_admin) && (
<Tooltip title={t("group.adminGroup")}>
<Box sx={{ display: "flex", alignItems: "center" }}>
<PersonPasskey fontSize="small" sx={{ color: "primary.main" }} />
</Box>
</Tooltip>
)}
</Box>
</NoWrapBox>
</NoWrapTableCell>
<NoWrapTableCell>
<Box sx={{ display: "flex", flexWrap: "wrap", gap: 1 }}>
{group?.edges.storage_policies && (
<SquareChip
onClick={onPolicyClick(group.edges.storage_policies.id)}
key={group.edges.storage_policies.id}
label={group.edges.storage_policies.name}
size="small"
/>
)}
</Box>
</NoWrapTableCell>
<NoWrapTableCell>
{countLoading ? (
<Skeleton variant="text" width={50} />
) : userCount != undefined ? (
<Link
component={RouterLink}
to={`/admin/user?group=${group?.id}`}
underline="hover"
onClick={stopPropagation}
>
{userCount}
</Link>
) : (
<Link href="/#" underline="hover" onClick={onCountClick}>
{t("group.countUser")}
</Link>
)}
</NoWrapTableCell>
<NoWrapTableCell>{sizeToString(group?.max_storage ?? 0)}</NoWrapTableCell>
<NoWrapTableCell>
<IconButton size="small" onClick={onDeleteClick} disabled={deleteLoading || (group?.id ?? 0) <= 3}>
<Delete fontSize="small" />
</IconButton>
</NoWrapTableCell>
</TableRow>
);
};
export default GroupRow;

View File

@@ -0,0 +1,152 @@
import { useTheme } from "@emotion/react";
import {
Box,
Button,
Container,
Stack,
Table,
TableBody,
TableContainer,
TableHead,
TableRow,
TableSortLabel,
} from "@mui/material";
import { useQueryState } from "nuqs";
import { useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import { getGroupList } from "../../../api/api";
import { GroupEnt } from "../../../api/dashboard";
import { useAppDispatch } from "../../../redux/hooks";
import { NoWrapTableCell, SecondaryButton, StyledTableContainerPaper } from "../../Common/StyledComponents";
import Add from "../../Icons/Add";
import ArrowSync from "../../Icons/ArrowSync";
import PageContainer from "../../Pages/PageContainer";
import PageHeader from "../../Pages/PageHeader";
import TablePagination from "../Common/TablePagination";
import { OrderByQuery, OrderDirectionQuery, PageQuery, PageSizeQuery } from "../StoragePolicy/StoragePolicySetting";
import GroupRow from "./GroupRow";
import NewGroupDialog from "./NewGroupDIalog";
const GroupSetting = () => {
const { t } = useTranslation("dashboard");
const theme = useTheme();
const dispatch = useAppDispatch();
const [loading, setLoading] = useState(true);
const [groups, setGroups] = useState<GroupEnt[]>([]);
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 [count, setCount] = useState(0);
const [createNewOpen, setCreateNewOpen] = useState(false);
const pageInt = parseInt(page) ?? 1;
const pageSizeInt = parseInt(pageSize) ?? 11;
useEffect(() => {
fetchGroups();
}, [page, pageSize, orderBy, orderDirection]);
const fetchGroups = () => {
setLoading(true);
dispatch(
getGroupList({
page: pageInt,
page_size: pageSizeInt,
order_by: orderBy ?? "",
order_direction: orderDirection ?? "desc",
}),
)
.then((res) => {
setGroups(res.groups);
setPageSize(res.pagination.page_size.toString());
setCount(res.pagination.total_items ?? 0);
setLoading(false);
})
.finally(() => {
setLoading(false);
});
};
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");
};
return (
<PageContainer>
<NewGroupDialog open={createNewOpen} onClose={() => setCreateNewOpen(false)} />
<Container maxWidth="xl">
<PageHeader title={t("dashboard:nav.groups")} />
<Stack direction="row" spacing={1} sx={{ mb: 2 }}>
<Button onClick={() => setCreateNewOpen(true)} variant={"contained"} startIcon={<Add />}>
{t("group.create")}
</Button>
<SecondaryButton onClick={fetchGroups} disabled={loading} variant={"contained"} startIcon={<ArrowSync />}>
{t("node.refresh")}
</SecondaryButton>
</Stack>
<TableContainer component={StyledTableContainerPaper} sx={{ mt: 2 }}>
<Table size="small" stickyHeader sx={{ width: "100%", tableLayout: "fixed" }}>
<TableHead>
<TableRow>
<NoWrapTableCell width={50} sortDirection={orderById ? direction : false}>
<TableSortLabel active={orderById} direction={direction} onClick={onSortClick("id")}>
{t("group.#")}
</TableSortLabel>
</NoWrapTableCell>
<NoWrapTableCell width={150}>
<TableSortLabel active={orderBy === "name"} direction={direction} onClick={onSortClick("name")}>
{t("group.name")}
</TableSortLabel>
</NoWrapTableCell>
<NoWrapTableCell width={250}>{t("group.type")}</NoWrapTableCell>
<NoWrapTableCell width={150}>{t("group.count")}</NoWrapTableCell>
<NoWrapTableCell width={150}>
<TableSortLabel
active={orderBy === "max_storage"}
direction={direction}
onClick={onSortClick("max_storage")}
>
{t("group.size")}
</TableSortLabel>
</NoWrapTableCell>
<NoWrapTableCell width={100} align="right"></NoWrapTableCell>
</TableRow>
</TableHead>
<TableBody>
{!loading && groups.map((group) => <GroupRow key={group.id} group={group} onDelete={fetchGroups} />)}
{loading &&
groups.length > 0 &&
groups.map((group) => <GroupRow key={`loading-${group.id}`} loading={true} />)}
{loading &&
groups.length === 0 &&
Array.from(Array(5)).map((_, index) => <GroupRow 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, 1000]}
onRowsPerPageChange={(value) => setPageSize(value.toString())}
onChange={(_, value) => setPage(value.toString())}
/>
</Box>
)}
</Container>
</PageContainer>
);
};
export default GroupSetting;

View File

@@ -0,0 +1,132 @@
import { DialogContent, FormControl, Stack } from "@mui/material";
import { useEffect, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import { useNavigate } from "react-router-dom";
import { upsertGroup } from "../../../api/api";
import { GroupEnt } from "../../../api/dashboard";
import { GroupPermission } from "../../../api/user";
import { useAppDispatch } from "../../../redux/hooks";
import Boolset from "../../../util/boolset";
import { DenseFilledTextField } from "../../Common/StyledComponents";
import DraggableDialog from "../../Dialogs/DraggableDialog";
import SettingForm from "../../Pages/Setting/SettingForm";
import GroupSelectionInput from "../Common/GroupSelectionInput";
import { NoMarginHelperText } from "../Settings/Settings";
export interface NewGroupDialogProps {
open: boolean;
onClose: () => void;
}
const defaultGroupBs = new Boolset("");
defaultGroupBs.sets({
[GroupPermission.share]: true,
[GroupPermission.share_download]: true,
[GroupPermission.set_anonymous_permission]: true,
});
const defaultGroup: GroupEnt = {
name: "",
permissions: defaultGroupBs.toString(),
max_storage: 1024 << 20, // 1GB
settings: {
compress_size: 1024 << 20, // 1MB
decompress_size: 1024 << 20, // 1MB
max_walked_files: 100000,
trash_retention: 7 * 24 * 3600,
source_batch: 10,
aria2_batch: 1,
redirected_source: true,
},
edges: {
storage_policies: { id: 1 },
},
id: 0,
};
const NewGroupDialog = ({ open, onClose }: NewGroupDialogProps) => {
const { t } = useTranslation("dashboard");
const dispatch = useAppDispatch();
const navigate = useNavigate();
const [copyFrom, setCopyFrom] = useState<string>("0");
const [loading, setLoading] = useState(false);
const [group, setGroup] = useState<GroupEnt>({ ...defaultGroup });
const formRef = useRef<HTMLFormElement>(null);
const copyFromSrc = useRef<GroupEnt | undefined>(undefined);
useEffect(() => {
if (open) {
setGroup({ ...defaultGroup });
setCopyFrom("0");
copyFromSrc.current = undefined;
}
}, [open]);
const handleSubmit = () => {
if (!formRef.current?.checkValidity()) {
formRef.current?.reportValidity();
return;
}
let newGroup = { ...group };
if (copyFrom != "0" && copyFromSrc.current) {
newGroup = { ...copyFromSrc.current, id: 0, name: group.name };
}
setLoading(true);
dispatch(upsertGroup({ group: newGroup }))
.then((r) => {
navigate(`/admin/group/${r.id}`);
onClose();
})
.finally(() => {
setLoading(false);
});
};
return (
<DraggableDialog
onAccept={handleSubmit}
loading={loading}
title={t("group.new")}
showActions
showCancel
dialogProps={{
open,
onClose,
fullWidth: true,
maxWidth: "sm",
}}
>
<DialogContent>
<form ref={formRef}>
<Stack spacing={2}>
<SettingForm title={t("group.nameOfGroup")} lgWidth={12}>
<DenseFilledTextField
fullWidth
required
value={group.name}
onChange={(e) => setGroup({ ...group, name: e.target.value })}
/>
<NoMarginHelperText>{t("group.nameOfGroupDes")}</NoMarginHelperText>
</SettingForm>
<SettingForm title={t("group.copyFromExisting")} lgWidth={12}>
<FormControl fullWidth>
<GroupSelectionInput
value={copyFrom}
onChange={setCopyFrom}
onChangeGroup={(g) => {
copyFromSrc.current = g;
}}
emptyValue={"0"}
emptyText={"group.notCopy"}
/>
</FormControl>
</SettingForm>
</Stack>
</form>
</DialogContent>
</DraggableDialog>
);
};
export default NewGroupDialog;

425
src/component/Admin/Home/Home.tsx Executable file
View File

@@ -0,0 +1,425 @@
import Giscus from "@giscus/react";
import { GitHub } from "@mui/icons-material";
import {
Avatar,
Box,
Container,
Divider,
List,
ListItem,
ListItemAvatar,
ListItemButton,
ListItemIcon,
ListItemText,
Paper,
Skeleton,
styled,
Typography,
} from "@mui/material";
import { blue, green, red, yellow } from "@mui/material/colors";
import Grid from "@mui/material/Grid";
import { useTheme } from "@mui/material/styles";
import dayjs from "dayjs";
import i18next from "i18next";
import { useCallback, useEffect, useState } from "react";
import { Trans, useTranslation } from "react-i18next";
import { CSSTransition, SwitchTransition } from "react-transition-group";
import { CartesianGrid, Legend, Line, LineChart, ResponsiveContainer, Tooltip, XAxis, YAxis } from "recharts";
import { getDashboardSummary } from "../../../api/api.ts";
import { HomepageSummary } from "../../../api/dashboard.ts";
import { useAppDispatch } from "../../../redux/hooks.ts";
import FacebookCircularProgress from "../../Common/CircularProgress.tsx";
import { SecondaryButton, SquareChip } from "../../Common/StyledComponents.tsx";
import TimeBadge from "../../Common/TimeBadge.tsx";
import Book from "../../Icons/Book.tsx";
import BoxMultipleFilled from "../../Icons/BoxMultipleFilled.tsx";
import Discord from "../../Icons/Discord.tsx";
import DocumentCopyFilled from "../../Icons/DocumentCopyFilled.tsx";
import HomeIcon from "../../Icons/Home.tsx";
import OpenFilled from "../../Icons/OpenFilled.tsx";
import PeopleFilled from "../../Icons/PeopleFilled.tsx";
import ShareFilled from "../../Icons/ShareFilled.tsx";
import SparkleFilled from "../../Icons/SparkleFilled.tsx";
import Telegram from "../../Icons/Telegram.tsx";
import PageContainer from "../../Pages/PageContainer.tsx";
import PageHeader from "../../Pages/PageHeader.tsx";
import ProDialog from "../Common/ProDialog.tsx";
import SiteUrlWarning from "./SiteUrlWarning.tsx";
import CommentMultiple from "../../Icons/CommentMultiple.tsx";
const StyledPaper = styled(Paper)(({ theme }) => ({
padding: theme.spacing(3),
boxShadow: "initial",
border: "1px solid " + theme.palette.divider,
}));
const StyledListItemIcon = styled(ListItemIcon)(() => ({
minWidth: 0,
}));
const Home = () => {
const { t } = useTranslation("dashboard");
const theme = useTheme();
const dispatch = useAppDispatch();
const [summary, setSummary] = useState<HomepageSummary | undefined>();
const [chartLoading, setChartLoading] = useState(false);
const [siteUrlWarning, setSiteUrlWarning] = useState(false);
const [proDialogOpen, setProDialogOpen] = useState(false);
useEffect(() => {
loadSummary(false);
}, []);
const loadSummary = useCallback((loadChart?: boolean) => {
if (loadChart) {
setChartLoading(true);
}
dispatch(getDashboardSummary(loadChart))
.then((r) => {
setSummary(r);
if (!loadChart) {
const target = r.site_urls.find((site) => site == window.location.origin);
if (!target) {
setSiteUrlWarning(true);
}
}
})
.finally(() => {
setChartLoading(false);
});
}, []);
return (
<PageContainer>
<ProDialog open={proDialogOpen} onClose={() => setProDialogOpen(false)} />
<SiteUrlWarning
open={siteUrlWarning}
onClose={() => setSiteUrlWarning(false)}
existingUrls={summary?.site_urls ?? []}
/>
<Container maxWidth="xl">
<PageHeader title={t("nav.summary")} />
<Grid container spacing={3}>
<Grid alignContent={"stretch"} item xs={12} md={8} lg={9}>
<StyledPaper>
<Typography
variant={"subtitle1"}
fontWeight={500}
sx={{
display: "flex",
justifyContent: "space-between",
alignItems: "center",
}}
>
{t("summary.trend")}
<Typography variant={"body2"} color={"text.secondary"}>
{summary?.metrics_summary?.generated_at && (
<Trans
i18nKey={"summary.generatedAt"}
ns={"dashboard"}
components={[<TimeBadge datetime={summary?.metrics_summary?.generated_at} variant={"inherit"} />]}
/>
)}
</Typography>
</Typography>
<Divider sx={{ mb: 2, mt: 1 }} />
<SwitchTransition>
<CSSTransition
addEndListener={(node, done) => node.addEventListener("transitionend", done, false)}
classNames="fade"
key={`${!!summary?.metrics_summary}-${!!chartLoading}`}
>
<Box>
{summary?.metrics_summary && (
<ResponsiveContainer width="100%" height={300}>
<LineChart
height={350}
data={summary?.metrics_summary.dates.map((i, d) => ({
name: dayjs(i).format("MM-DD"),
user: summary?.metrics_summary?.users[d] ?? 0,
file: summary?.metrics_summary?.files[d] ?? 0,
share: summary?.metrics_summary?.shares[d] ?? 0,
}))}
>
<CartesianGrid strokeDasharray="3 3" />
<XAxis dataKey="name" />
<YAxis
allowDecimals={false}
width={(() => {
const yAxisValue = [
...(summary?.metrics_summary?.users ?? []),
...(summary?.metrics_summary?.files ?? []),
...(summary?.metrics_summary?.shares ?? []),
];
const yAxisUpperLimit = yAxisValue.length ? Math.max(...yAxisValue) / 0.8 - 1 : 0;
const yAxisDigits = yAxisUpperLimit > 1 ? Math.floor(Math.log10(yAxisUpperLimit)) + 1 : 1;
return 3 + yAxisDigits * 9;
})()}
/>
<Tooltip />
<Legend />
<Line name={t("nav.users")} type="monotone" dataKey="user" stroke={blue[600]} />
<Line name={t("nav.files")} type="monotone" dataKey="file" stroke={yellow[800]} />
<Line name={t("nav.shares")} type="monotone" dataKey="share" stroke={green[800]} />
</LineChart>
</ResponsiveContainer>
)}
{chartLoading && (
<Box
sx={{
height: "300px",
display: "flex",
alignItems: "center",
justifyContent: "center",
}}
>
<FacebookCircularProgress />
</Box>
)}
{!summary?.metrics_summary?.generated_at && !chartLoading && (
<Box
sx={{
height: "300px",
display: "flex",
alignItems: "center",
justifyContent: "center",
}}
>
<SecondaryButton onClick={() => loadSummary(true)}>
{t("application:fileManager.calculate")}
</SecondaryButton>
</Box>
)}
</Box>
</CSSTransition>
</SwitchTransition>
</StyledPaper>
</Grid>
<Grid item xs={12} md={4} lg={3}>
<StyledPaper>
<Typography variant={"subtitle1"} fontWeight={500}>
{t("summary.summary")}
</Typography>
<Divider sx={{ mb: 2, mt: 1 }} />
<SwitchTransition>
<CSSTransition
addEndListener={(node, done) => node.addEventListener("transitionend", done, false)}
classNames="fade"
key={`${!!summary?.metrics_summary}-${chartLoading}`}
>
<Box>
{summary?.metrics_summary && (
<List disablePadding sx={{ minHeight: "300px" }}>
<ListItem>
<ListItemAvatar>
<Avatar
sx={{
backgroundColor: blue[100],
color: blue[600],
}}
>
<PeopleFilled />
</Avatar>
</ListItemAvatar>
<ListItemText
secondary={t("summary.totalUsers")}
primary={summary.metrics_summary.user_total.toLocaleString()}
/>
</ListItem>
<ListItem>
<ListItemAvatar>
<Avatar
sx={{
backgroundColor: yellow[100],
color: yellow[800],
}}
>
<DocumentCopyFilled />
</Avatar>
</ListItemAvatar>
<ListItemText
secondary={t("summary.totalFilesAndFolders")}
primary={summary.metrics_summary.file_total.toLocaleString()}
/>
</ListItem>
<ListItem>
<ListItemAvatar>
<Avatar
sx={{
backgroundColor: green[100],
color: green[800],
}}
>
<ShareFilled />
</Avatar>
</ListItemAvatar>
<ListItemText
secondary={t("summary.shareLinks")}
primary={summary.metrics_summary.share_total.toLocaleString()}
/>
</ListItem>
<ListItem>
<ListItemAvatar>
<Avatar
sx={{
backgroundColor: red[100],
color: red[800],
}}
>
<BoxMultipleFilled />
</Avatar>
</ListItemAvatar>
<ListItemText
secondary={t("summary.totalBlobs")}
primary={summary.metrics_summary.entities_total.toLocaleString()}
/>
</ListItem>
</List>
)}
{chartLoading && (
<Box
sx={{
height: "300px",
display: "flex",
alignItems: "center",
justifyContent: "center",
}}
>
<FacebookCircularProgress />
</Box>
)}
{!summary?.metrics_summary?.generated_at && !chartLoading && (
<Box
sx={{
height: "300px",
display: "flex",
alignItems: "center",
justifyContent: "center",
}}
>
<SecondaryButton onClick={() => loadSummary(true)}>
{t("application:fileManager.calculate")}
</SecondaryButton>
</Box>
)}
</Box>
</CSSTransition>
</SwitchTransition>
</StyledPaper>
</Grid>
<Grid item xs={12} md={5} lg={4}>
<StyledPaper sx={{ p: 0 }}>
<Box sx={{ p: 3, display: "flex", alignItems: "center" }}>
<Box component={"img"} sx={{ width: 70 }} alt="cloudreve" src={"/static/img/cloudreve.svg"} />
<Box sx={{ ml: 2 }}>
<Typography variant={"h5"} fontWeight={600}>
Cloudreve
{summary && summary.version.pro && (
<SquareChip sx={{ ml: 1, height: "initial" }} size={"small"} color={"primary"} label={"Pro"} />
)}
</Typography>
<Typography variant={"subtitle2"} color={"text.secondary"}>
{summary ? summary.version.version : <Skeleton variant={"text"} width={70} />}
{summary && (
<Box component={"span"} sx={{ ml: 1, color: (t) => t.palette.action.disabled }}>
#{summary.version.commit}
</Box>
)}
</Typography>
</Box>
</Box>
<Divider />
<List component="nav" aria-label="main mailbox folders" sx={{ mx: 2 }}>
<ListItemButton onClick={() => window.open("https://cloudreve.org")}>
<ListItemIcon>
<HomeIcon />
</ListItemIcon>
<ListItemText primary={t("summary.homepage")} />
<StyledListItemIcon>
<OpenFilled />
</StyledListItemIcon>
</ListItemButton>
<ListItemButton onClick={() => window.open("https://github.com/cloudreve/cloudreve")}>
<ListItemIcon>
<GitHub />
</ListItemIcon>
<ListItemText primary={t("summary.github")} />
<StyledListItemIcon>
<OpenFilled />
</StyledListItemIcon>
</ListItemButton>
<ListItemButton onClick={() => window.open("https://docs.cloudreve.org/")}>
<ListItemIcon>
<Book />
</ListItemIcon>
<ListItemText primary={t("summary.documents")} />
<StyledListItemIcon>
<OpenFilled />
</StyledListItemIcon>
</ListItemButton>
<ListItemButton onClick={() => window.open("https://discord.gg/WTpMFpZT76")}>
<ListItemIcon>
<Discord />
</ListItemIcon>
<ListItemText primary={t("summary.discordCommunity")} />
<StyledListItemIcon>
<OpenFilled />
</StyledListItemIcon>
</ListItemButton>
<ListItemButton onClick={() => window.open("https://t.me/cloudreve_official")}>
<ListItemIcon>
<Telegram />
</ListItemIcon>
<ListItemText primary={t("summary.telegram")} />
<StyledListItemIcon>
<OpenFilled />
</StyledListItemIcon>
</ListItemButton>
<ListItemButton onClick={() => window.open("https://github.com/cloudreve/cloudreve/discussions")}>
<ListItemIcon>
<CommentMultiple />
</ListItemIcon>
<ListItemText primary={t("summary.forum")} />
<StyledListItemIcon>
<OpenFilled />
</StyledListItemIcon>
</ListItemButton>
{summary && !summary.version.pro && (
<ListItemButton onClick={() => setProDialogOpen(true)}>
<ListItemIcon>
<SparkleFilled color={"primary"} />
</ListItemIcon>
<ListItemText primary={t("summary.buyPro")} />
</ListItemButton>
)}
</List>
<Divider />
</StyledPaper>
</Grid>
<Grid item xs={12} md={7} lg={8}>
<StyledPaper>
<Typography variant={"subtitle1"} fontWeight={500}>
</Typography>
<Divider sx={{ mb: 2, mt: 1 }} />
<Giscus
id="comments"
repo="cloudreve/cloudreve"
repoId="MDEwOlJlcG9zaXRvcnkxMjAxNTYwNzY="
mapping={"number"}
term={i18next.language == "zh-CN" ? "2170" : "2169"}
reactionsEnabled={"1"}
emitMetadata={"0"}
inputPosition={"bottom"}
theme={theme.palette.mode === "dark" ? "dark" : "light"}
lang={i18next.language == "zh-CN" ? "zh-CN" : "en"}
loading={"lazy"}
/>
</StyledPaper>
</Grid>
</Grid>
</Container>
</PageContainer>
);
};
export default Home;

View File

@@ -0,0 +1,81 @@
import { DialogContent, List, ListItemButton, Stack, Typography } from "@mui/material";
import { useTranslation } from "react-i18next";
import { sendSetSetting } from "../../../api/api.ts";
import { useAppDispatch } from "../../../redux/hooks.ts";
import { StyledListItemText } from "../../Common/StyledComponents.tsx";
import DraggableDialog from "../../Dialogs/DraggableDialog.tsx";
export interface SiteUrlWarningProps {
open: boolean;
onClose: () => void;
existingUrls: string[];
}
const SiteUrlWarning = ({ open, onClose, existingUrls }: SiteUrlWarningProps) => {
const { t } = useTranslation("dashboard");
const dispatch = useAppDispatch();
const setSiteUrl = (isPrimary: boolean) => () => {
const urls = [...existingUrls];
if (isPrimary) {
urls.unshift(window.location.origin);
} else {
urls.push(window.location.origin);
}
onClose();
dispatch(
sendSetSetting({
settings: {
siteURL: urls.join(","),
},
}),
);
};
return (
<>
<DraggableDialog
dialogProps={{
open,
onClose,
maxWidth: "sm",
fullWidth: true,
}}
title={t("summary.confirmSiteURLTitle")}
>
<DialogContent>
<Stack spacing={1}>
<Typography variant="body2" color={"textSecondary"}>
{t("summary.siteURLNotMatch", {
current: window.location.origin,
})}
</Typography>
<List dense>
<ListItemButton onClick={setSiteUrl(true)}>
<StyledListItemText
primary={t("summary.setAsPrimary")}
secondary={t("summary.setAsPrimaryDes", {
current: window.location.origin,
})}
/>
</ListItemButton>
<ListItemButton onClick={setSiteUrl(false)}>
<StyledListItemText
primary={t("summary.setAsSecondary")}
secondary={t("summary.setAsSecondaryDes", {
current: window.location.origin,
})}
/>
</ListItemButton>
</List>
<Typography variant="body2" color={"textSecondary"}>
{t("summary.siteURLDescription")}
</Typography>
</Stack>
</DialogContent>
</DraggableDialog>
</>
);
};
export default SiteUrlWarning;

View File

@@ -0,0 +1,167 @@
import { Alert, FormControl, FormControlLabel, Switch, Typography } from "@mui/material";
import { useSnackbar } from "notistack";
import { useCallback, useContext, useMemo, useState } from "react";
import { Trans, useTranslation } from "react-i18next";
import { testNode } from "../../../../api/api";
import { Node, NodeStatus, NodeType } from "../../../../api/dashboard";
import { useAppDispatch } from "../../../../redux/hooks";
import { DefaultCloseAction } from "../../../Common/Snackbar/snackbar";
import { DenseFilledTextField, SecondaryButton } from "../../../Common/StyledComponents";
import SettingForm from "../../../Pages/Setting/SettingForm";
import { Code } from "../../../Common/Code.tsx";
import { EndpointInput } from "../../Common/EndpointInput";
import { NoMarginHelperText, SettingSection, SettingSectionContent } from "../../Settings/Settings";
import { NodeSettingContext } from "./NodeSettingWrapper";
const BasicInfoSection = () => {
const { t } = useTranslation("dashboard");
const dispatch = useAppDispatch();
const { enqueueSnackbar } = useSnackbar();
const { values, setNode } = useContext(NodeSettingContext);
const [testNodeLoading, setTestNodeLoading] = useState(false);
const onNameChange = useCallback(
(e: React.ChangeEvent<HTMLInputElement>) => {
setNode((p: Node) => ({ ...p, name: e.target.value }));
},
[setNode],
);
const onServerChange = useCallback(
(e: React.ChangeEvent<HTMLInputElement>) => {
setNode((p: Node) => ({ ...p, server: e.target.value }));
},
[setNode],
);
const onSlaveKeyChange = useCallback(
(e: React.ChangeEvent<HTMLInputElement>) => {
setNode((p: Node) => ({ ...p, slave_key: e.target.value }));
},
[setNode],
);
const onWeightChange = useCallback(
(e: React.ChangeEvent<HTMLInputElement>) => {
const weight = parseInt(e.target.value);
setNode((p: Node) => ({ ...p, weight: isNaN(weight) ? 1 : weight }));
},
[setNode],
);
const onStatusChange = useCallback(
(e: React.ChangeEvent<HTMLInputElement>) => {
setNode((p: Node) => ({
...p,
status: e.target.checked ? NodeStatus.active : NodeStatus.suspended,
}));
},
[setNode],
);
const isActive = useMemo(() => {
return values.status === NodeStatus.active;
}, [values.status]);
const nodeTypeText = useMemo(() => {
return values.type === NodeType.master ? t("node.master") : t("node.slave");
}, [values.type, t]);
const onTestNode = useCallback(() => {
setTestNodeLoading(true);
dispatch(testNode({ node: values }))
.then((res) => {
enqueueSnackbar(t("node.testNodeSuccess"), { variant: "success", action: DefaultCloseAction });
})
.finally(() => {
setTestNodeLoading(false);
});
}, [dispatch, values]);
return (
<SettingSection>
<Typography variant="h6" gutterBottom>
{t("policy.basicInfo")}
</Typography>
<SettingSectionContent>
{values.type === NodeType.master && (
<SettingForm lgWidth={5}>
<Alert severity="info">{t("node.thisIsMasterNodes")}</Alert>
</SettingForm>
)}
<SettingForm lgWidth={5}>
<FormControl fullWidth>
<FormControlLabel
disabled={values.type === NodeType.master}
control={<Switch checked={isActive} onChange={onStatusChange} />}
label={t("node.enableNode")}
/>
<NoMarginHelperText>{t("node.enableNodeDes")}</NoMarginHelperText>
</FormControl>
</SettingForm>
<SettingForm title={t("node.name")} lgWidth={5}>
<FormControl fullWidth>
<DenseFilledTextField required value={values.name} onChange={onNameChange} />
<NoMarginHelperText>{t("node.nameNode")}</NoMarginHelperText>
</FormControl>
</SettingForm>
<SettingForm title={t("node.type")} lgWidth={5}>
<FormControl fullWidth>
<DenseFilledTextField disabled value={nodeTypeText} />
</FormControl>
</SettingForm>
{values.type === NodeType.slave && (
<>
<SettingForm title={t("node.server")} lgWidth={5}>
<FormControl fullWidth>
<EndpointInput
fullWidth
enforceProtocol
required
value={values.server}
onChange={onServerChange}
variant={"outlined"}
/>
<NoMarginHelperText>{t("node.serverDes")}</NoMarginHelperText>
</FormControl>
</SettingForm>
<SettingForm title={t("node.slaveSecret")} lgWidth={5}>
<FormControl fullWidth>
<DenseFilledTextField required value={values.slave_key} onChange={onSlaveKeyChange} />
<NoMarginHelperText>
<Trans i18nKey="node.slaveSecretDes" ns="dashboard" components={[<Code />, <Code />]} />
</NoMarginHelperText>
</FormControl>
</SettingForm>
</>
)}
<SettingForm title={t("node.loadBalancerRank")} lgWidth={5}>
<FormControl fullWidth>
<DenseFilledTextField
type="number"
slotProps={{
htmlInput: {
inputProps: {
min: 1,
},
},
}}
required
value={values.weight}
onChange={onWeightChange}
/>
<NoMarginHelperText>{t("node.loadBalancerRankDes")}</NoMarginHelperText>
</FormControl>
</SettingForm>
{values.type === NodeType.slave && (
<SettingForm lgWidth={5}>
<SecondaryButton loading={testNodeLoading} variant="contained" onClick={onTestNode}>
{t("node.testNode")}
</SecondaryButton>
</SettingForm>
)}
</SettingSectionContent>
</SettingSection>
);
};
export default BasicInfoSection;

View File

@@ -0,0 +1,568 @@
import {
CircularProgress,
Collapse,
FormControl,
FormControlLabel,
IconButton,
Link,
ListItemText,
SelectChangeEvent,
Switch,
Typography,
useTheme,
} from "@mui/material";
import { useSnackbar } from "notistack";
import { lazy, Suspense, useCallback, useContext, useEffect, useMemo, useState } from "react";
import { Trans, useTranslation } from "react-i18next";
import { testNodeDownloader } from "../../../../api/api";
import { DownloaderProvider, Node, NodeType } from "../../../../api/dashboard";
import { NodeCapability } from "../../../../api/workflow";
import { useAppDispatch } from "../../../../redux/hooks";
import Boolset from "../../../../util/boolset";
import { DefaultCloseAction } from "../../../Common/Snackbar/snackbar";
import { DenseFilledTextField, DenseSelect, SecondaryButton } from "../../../Common/StyledComponents";
import { SquareMenuItem } from "../../../FileManager/ContextMenu/ContextMenu";
import QuestionCircle from "../../../Icons/QuestionCircle";
import SettingForm from "../../../Pages/Setting/SettingForm";
import { Code } from "../../../Common/Code.tsx";
import { EndpointInput } from "../../Common/EndpointInput";
import { NoMarginHelperText, SettingSection, SettingSectionContent } from "../../Settings/Settings";
import { NodeSettingContext } from "./NodeSettingWrapper";
import StoreFilesHintDialog from "./StoreFilesHintDialog";
const MonacoEditor = lazy(() => import("../../../Viewers/CodeViewer/MonacoEditor"));
const CapabilitiesSection = () => {
const { t } = useTranslation("dashboard");
const { values, setNode } = useContext(NodeSettingContext);
const theme = useTheme();
const dispatch = useAppDispatch();
const { enqueueSnackbar } = useSnackbar();
const [editedConfigAria2, setEditedConfigAria2] = useState("");
const [editedConfigQbittorrent, setEditedConfigQbittorrent] = useState("");
const [testDownloaderLoading, setTestDownloaderLoading] = useState(false);
const [storeFilesHintDialogOpen, setStoreFilesHintDialogOpen] = useState(false);
const capabilities = useMemo(() => {
return new Boolset(values.capabilities ?? "");
}, [values.capabilities]);
const hasRemoteDownload = useMemo(() => {
return capabilities.enabled(NodeCapability.remote_download);
}, [capabilities]);
useEffect(() => {
setEditedConfigAria2(
values.settings?.aria2?.options ? JSON.stringify(values.settings?.aria2?.options, null, 2) : "",
);
}, [values.settings?.aria2?.options]);
useEffect(() => {
setEditedConfigQbittorrent(
values.settings?.qbittorrent?.options ? JSON.stringify(values.settings?.qbittorrent?.options, null, 2) : "",
);
}, [values.settings?.qbittorrent?.options]);
const onCapabilityChange = useCallback(
(capability: number) => (e: React.ChangeEvent<HTMLInputElement>) => {
setNode((p: Node) => ({
...p,
capabilities: new Boolset(p.capabilities).set(capability, e.target.checked).toString(),
}));
},
[setNode],
);
const onProviderChange = useCallback(
(e: SelectChangeEvent<unknown>) => {
const provider = e.target.value as DownloaderProvider;
setNode((p: Node) => ({
...p,
settings: {
...p.settings,
provider,
},
}));
},
[setNode],
);
const onAria2ServerChange = useCallback(
(e: React.ChangeEvent<HTMLInputElement>) => {
setNode((p: Node) => ({
...p,
settings: {
...p.settings,
aria2: {
...p.settings?.aria2,
server: e.target.value,
},
},
}));
},
[setNode],
);
const onAria2TokenChange = useCallback(
(e: React.ChangeEvent<HTMLInputElement>) => {
setNode((p: Node) => ({
...p,
settings: {
...p.settings,
aria2: {
...p.settings?.aria2,
token: e.target.value,
},
},
}));
},
[setNode],
);
const onAria2TempPathChange = useCallback(
(e: React.ChangeEvent<HTMLInputElement>) => {
setNode((p: Node) => ({
...p,
settings: {
...p.settings,
aria2: {
...p.settings?.aria2,
temp_path: e.target.value ? e.target.value : undefined,
},
},
}));
},
[setNode],
);
const onQBittorrentServerChange = useCallback(
(e: React.ChangeEvent<HTMLInputElement>) => {
setNode((p: Node) => ({
...p,
settings: {
...p.settings,
qbittorrent: {
...p.settings?.qbittorrent,
server: e.target.value,
},
},
}));
},
[setNode],
);
const onQBittorrentUserChange = useCallback(
(e: React.ChangeEvent<HTMLInputElement>) => {
setNode((p: Node) => ({
...p,
settings: {
...p.settings,
qbittorrent: {
...p.settings?.qbittorrent,
user: e.target.value ? e.target.value : undefined,
},
},
}));
},
[setNode],
);
const onQBittorrentPasswordChange = useCallback(
(e: React.ChangeEvent<HTMLInputElement>) => {
setNode((p: Node) => ({
...p,
settings: {
...p.settings,
qbittorrent: {
...p.settings?.qbittorrent,
password: e.target.value ? e.target.value : undefined,
},
},
}));
},
[setNode],
);
const onQBittorrentTempPathChange = useCallback(
(e: React.ChangeEvent<HTMLInputElement>) => {
setNode((p: Node) => ({
...p,
settings: {
...p.settings,
qbittorrent: {
...p.settings?.qbittorrent,
temp_path: e.target.value ? e.target.value : undefined,
},
},
}));
},
[setNode],
);
const onIntervalChange = useCallback(
(e: React.ChangeEvent<HTMLInputElement>) => {
const interval = parseInt(e.target.value);
setNode((p: Node) => ({
...p,
settings: {
...p.settings,
interval: isNaN(interval) ? undefined : interval,
},
}));
},
[setNode],
);
const onWaitForSeedingChange = useCallback(
(e: React.ChangeEvent<HTMLInputElement>) => {
setNode((p: Node) => ({
...p,
settings: {
...p.settings,
wait_for_seeding: e.target.checked ? true : undefined,
},
}));
},
[setNode],
);
const onEditedConfigAria2Blur = useCallback(
(value: string) => {
var res: Record<string, any> | undefined = undefined;
if (value) {
try {
res = JSON.parse(value);
} catch (e) {
console.error(e);
}
}
setNode((p: Node) => ({ ...p, settings: { ...p.settings, aria2: { ...p.settings?.aria2, options: res } } }));
},
[editedConfigAria2, setNode],
);
const onEditedConfigQbittorrentBlur = useCallback(
(value: string) => {
var res: Record<string, any> | undefined = undefined;
if (value) {
try {
res = JSON.parse(value);
} catch (e) {
console.error(e);
}
}
setNode((p: Node) => ({
...p,
settings: { ...p.settings, qbittorrent: { ...p.settings?.qbittorrent, options: res } },
}));
},
[editedConfigQbittorrent, setNode],
);
const onTestDownloaderClick = useCallback(() => {
setTestDownloaderLoading(true);
dispatch(testNodeDownloader({ node: values }))
.then((res) => {
enqueueSnackbar({
variant: "success",
message: t("node.downloaderTestPass", { version: res }),
action: DefaultCloseAction,
});
})
.finally(() => {
setTestDownloaderLoading(false);
});
}, [values]);
const onStoreFilesClick = useCallback(() => {
setStoreFilesHintDialogOpen(true);
}, []);
return (
<>
<StoreFilesHintDialog open={storeFilesHintDialogOpen} onClose={() => setStoreFilesHintDialogOpen(false)} />
<SettingSection>
<Typography variant="h6" gutterBottom>
{t("node.features")}
</Typography>
<SettingSectionContent>
<SettingForm lgWidth={5}>
<FormControl fullWidth>
<FormControlLabel
control={
<Switch
checked={capabilities.enabled(NodeCapability.create_archive)}
onChange={onCapabilityChange(NodeCapability.create_archive)}
/>
}
label={t("application:fileManager.createArchive")}
/>
<NoMarginHelperText>{t("node.createArchiveDes")}</NoMarginHelperText>
</FormControl>
</SettingForm>
<SettingForm lgWidth={5}>
<FormControl fullWidth>
<FormControlLabel
control={
<Switch
checked={capabilities.enabled(NodeCapability.extract_archive)}
onChange={onCapabilityChange(NodeCapability.extract_archive)}
/>
}
label={t("application:fileManager.extractArchive")}
/>
<NoMarginHelperText>{t("node.extractArchiveDes")}</NoMarginHelperText>
</FormControl>
</SettingForm>
<SettingForm lgWidth={5}>
<FormControl fullWidth>
<FormControlLabel
control={
<Switch
checked={capabilities.enabled(NodeCapability.remote_download)}
onChange={onCapabilityChange(NodeCapability.remote_download)}
/>
}
label={t("application:navbar.remoteDownload")}
/>
<NoMarginHelperText>{t("node.remoteDownloadDes")}</NoMarginHelperText>
</FormControl>
</SettingForm>
{values.type === NodeType.slave && (
<SettingForm lgWidth={5}>
<FormControl fullWidth>
<FormControlLabel
control={
<Switch
onChange={onStoreFilesClick}
disabled={(values.edges?.storage_policy?.length ?? 0) > 0}
checked={(values.edges?.storage_policy?.length ?? 0) > 0}
/>
}
label={t("node.storeFiles")}
/>
<NoMarginHelperText>{t("node.storeFilesDes")}</NoMarginHelperText>
</FormControl>
</SettingForm>
)}
</SettingSectionContent>
</SettingSection>
<Collapse in={hasRemoteDownload} unmountOnExit>
<SettingSection>
<Typography
variant="h6"
gutterBottom
sx={{
display: "flex",
alignItems: "center",
gap: 0.5,
}}
>
{t("node.remoteDownload")}
<IconButton
onClick={() => {
window.open("https://docs.cloudreve.org/usage/remote-download", "_blank");
}}
>
<QuestionCircle />
</IconButton>
</Typography>
<SettingSectionContent>
<SettingForm title={t("node.downloader")} lgWidth={5}>
<FormControl fullWidth>
<DenseSelect value={values.settings?.provider || DownloaderProvider.aria2} onChange={onProviderChange}>
<SquareMenuItem value={DownloaderProvider.aria2}>
<ListItemText primary="Aria2" slotProps={{ primary: { variant: "body2" } }} />
</SquareMenuItem>
<SquareMenuItem value={DownloaderProvider.qbittorrent}>
<ListItemText primary="qBittorrent" slotProps={{ primary: { variant: "body2" } }} />
</SquareMenuItem>
</DenseSelect>
<NoMarginHelperText>
{values.settings?.provider === DownloaderProvider.qbittorrent
? t("node.qbittorrentDes")
: t("node.aria2Des")}
</NoMarginHelperText>
</FormControl>
</SettingForm>
{values.settings?.provider === DownloaderProvider.aria2 && (
<>
<SettingForm title={t("node.rpcServer")} lgWidth={5}>
<FormControl fullWidth>
<EndpointInput
fullWidth
required
value={values.settings?.aria2?.server || ""}
onChange={onAria2ServerChange}
variant={"outlined"}
/>
<NoMarginHelperText>
<Trans i18nKey="node.rpcServerHelpDes" ns="dashboard" components={[<Code />]} />
</NoMarginHelperText>
</FormControl>
</SettingForm>
<SettingForm title={t("node.rpcToken")} lgWidth={5}>
<FormControl fullWidth>
<DenseFilledTextField value={values.settings?.aria2?.token || ""} onChange={onAria2TokenChange} />
<NoMarginHelperText>
<Trans i18nKey="node.rpcTokenDes" ns="dashboard" components={[<Code />]} />
</NoMarginHelperText>
</FormControl>
</SettingForm>
<SettingForm title={t("group.aria2Options")} lgWidth={5}>
<FormControl fullWidth>
<Suspense fallback={<CircularProgress />}>
<MonacoEditor
theme={theme.palette.mode === "dark" ? "vs-dark" : "vs"}
language="json"
value={editedConfigAria2}
onChange={(value) => setEditedConfigAria2(value || "")}
onBlur={onEditedConfigAria2Blur}
height="200px"
minHeight="200px"
options={{
wordWrap: "on",
minimap: { enabled: false },
scrollBeyondLastLine: false,
}}
/>
</Suspense>
<NoMarginHelperText>
<Trans
i18nKey="node.downloaderOptionDes"
ns="dashboard"
components={[
<Link href="https://aria2.github.io/manual/en/html/aria2c.html#id2" target="_blank" />,
]}
/>
</NoMarginHelperText>
</FormControl>
</SettingForm>
<SettingForm title={t("node.tempPath")} lgWidth={5}>
<FormControl fullWidth>
<DenseFilledTextField
value={values.settings?.aria2?.temp_path || ""}
onChange={onAria2TempPathChange}
/>
<NoMarginHelperText>{t("node.tempPathDes")}</NoMarginHelperText>
</FormControl>
</SettingForm>
</>
)}
{values.settings?.provider === DownloaderProvider.qbittorrent && (
<>
<SettingForm title={t("node.webUIEndpoint")} lgWidth={5}>
<FormControl fullWidth>
<EndpointInput
fullWidth
required
value={values.settings?.qbittorrent?.server || ""}
onChange={onQBittorrentServerChange}
variant={"outlined"}
/>
<NoMarginHelperText>
<Trans i18nKey="node.webUIEndpointDes" ns="dashboard" components={[<Code />]} />
</NoMarginHelperText>
</FormControl>
</SettingForm>
<SettingForm title={t("policy.accessCredential")} lgWidth={5}>
<FormControl fullWidth>
<DenseFilledTextField
placeholder={t("node.webUIUsername")}
value={values.settings?.qbittorrent?.user || ""}
onChange={onQBittorrentUserChange}
/>
<DenseFilledTextField
placeholder={t("node.webUIPassword")}
type="password"
sx={{ mt: 1 }}
value={values.settings?.qbittorrent?.password || ""}
onChange={onQBittorrentPasswordChange}
/>
<NoMarginHelperText>{t("node.webUICredDes")}</NoMarginHelperText>
</FormControl>
</SettingForm>
<SettingForm title={t("group.aria2Options")} lgWidth={5}>
<FormControl fullWidth>
<Suspense fallback={<CircularProgress />}>
<MonacoEditor
theme={theme.palette.mode === "dark" ? "vs-dark" : "vs"}
language="json"
value={editedConfigQbittorrent}
onChange={(value) => setEditedConfigQbittorrent(value || "")}
onBlur={onEditedConfigQbittorrentBlur}
height="200px"
minHeight="200px"
options={{
wordWrap: "on",
minimap: { enabled: false },
scrollBeyondLastLine: false,
}}
/>
</Suspense>
<NoMarginHelperText>
<Trans
i18nKey="node.downloaderOptionDes"
ns="dashboard"
components={[
<Link
href="https://github.com/qbittorrent/qBittorrent/wiki/WebUI-API-(qBittorrent-4.1)#add-new-torrent"
target="_blank"
/>,
]}
/>
</NoMarginHelperText>
</FormControl>
</SettingForm>
<SettingForm title={t("node.tempPath")} lgWidth={5}>
<FormControl fullWidth>
<DenseFilledTextField
value={values.settings?.qbittorrent?.temp_path || ""}
onChange={onQBittorrentTempPathChange}
/>
<NoMarginHelperText>{t("node.tempPathDes")}</NoMarginHelperText>
</FormControl>
</SettingForm>
</>
)}
<SettingForm title={t("node.refreshInterval")} lgWidth={5}>
<FormControl fullWidth>
<DenseFilledTextField
type="number"
slotProps={{ htmlInput: { min: 1 } }}
required
value={values.settings?.interval || ""}
onChange={onIntervalChange}
/>
<NoMarginHelperText>{t("node.refreshIntervalDes")}</NoMarginHelperText>
</FormControl>
</SettingForm>
<SettingForm lgWidth={5}>
<FormControl fullWidth>
<FormControlLabel
control={
<Switch checked={values.settings?.wait_for_seeding || false} onChange={onWaitForSeedingChange} />
}
label={t("node.waitForSeeding")}
/>
<NoMarginHelperText>{t("node.waitForSeedingDes")}</NoMarginHelperText>
</FormControl>
</SettingForm>
<SettingForm lgWidth={5}>
<SecondaryButton onClick={onTestDownloaderClick} variant="contained" loading={testDownloaderLoading}>
{t("node.testDownloader")}
</SecondaryButton>
</SettingForm>
</SettingSectionContent>
</SettingSection>
</Collapse>
</>
);
};
export default CapabilitiesSection;

View File

@@ -0,0 +1,34 @@
import { Container } from "@mui/material";
import { useState } from "react";
import { useTranslation } from "react-i18next";
import { useParams } from "react-router-dom";
import { Node } from "../../../../api/dashboard";
import PageContainer from "../../../Pages/PageContainer";
import PageHeader from "../../../Pages/PageHeader";
import BasicInfoSection from "./BasicInfoSection";
import CapabilitiesSection from "./CapabilitiesSection";
import NodeForm from "./NodeForm";
import NodeSettingWrapper from "./NodeSettingWrapper";
const EditNode = () => {
const { t } = useTranslation("dashboard");
const { id } = useParams<{ id: string }>();
const [node, setNode] = useState<Node | null>(null);
const nodeID = parseInt(id ?? "0");
return (
<PageContainer>
<Container maxWidth="xl">
<PageHeader title={t("node.editNode", { node: node?.name })} />
<NodeSettingWrapper nodeID={nodeID} onNodeChange={setNode}>
<NodeForm>
<BasicInfoSection />
<CapabilitiesSection />
</NodeForm>
</NodeSettingWrapper>
</Container>
</PageContainer>
);
};
export default EditNode;

View File

@@ -0,0 +1,19 @@
import { Box, Stack } from "@mui/material";
import { useContext } from "react";
import { NodeSettingContext } from "./NodeSettingWrapper";
export interface NodeFormProps {
children: React.ReactNode;
}
const NodeForm = ({ children }: NodeFormProps) => {
const { formRef } = useContext(NodeSettingContext);
return (
<Box component="form" ref={formRef} noValidate>
<Stack spacing={5}>{children}</Stack>
</Box>
);
};
export default NodeForm;

View File

@@ -0,0 +1,157 @@
import { Box } from "@mui/material";
import * as React from "react";
import { createContext, useEffect, useMemo, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import { CSSTransition, SwitchTransition } from "react-transition-group";
import { getNodeDetail, upsertNode } from "../../../../api/api.ts";
import { Node, StoragePolicy } from "../../../../api/dashboard.ts";
import { useAppDispatch } from "../../../../redux/hooks.ts";
import FacebookCircularProgress from "../../../Common/CircularProgress.tsx";
import { SavingFloat } from "../../Settings/SettingWrapper.tsx";
export interface NodeSettingWrapperProps {
nodeID: number;
children: React.ReactNode;
onNodeChange: (node: Node) => void;
}
export interface NodeSettingContextProps {
values: Node;
setNode: (f: (p: Node) => Node) => void;
formRef?: React.RefObject<HTMLFormElement>;
}
const defaultNode: Node = {
id: 0,
name: "",
status: undefined,
type: undefined,
server: "",
slave_key: "",
capabilities: "",
weight: 1,
settings: {},
edges: {
storage_policy: [],
},
};
export const NodeSettingContext = createContext<NodeSettingContextProps>({
values: { ...defaultNode },
setNode: () => {},
});
const nodeValueFilter = (node: Node): Node => {
return {
...node,
edges: {
storage_policy: node.edges.storage_policy?.map(
(p): StoragePolicy =>
({
id: p.id,
}) as StoragePolicy,
),
},
};
};
const NodeSettingWrapper = ({ nodeID, children, onNodeChange }: NodeSettingWrapperProps) => {
const dispatch = useAppDispatch();
const { t } = useTranslation("dashboard");
const [values, setValues] = useState<Node>({
...defaultNode,
});
const [modifiedValues, setModifiedValues] = useState<Node>({
...defaultNode,
});
const [loading, setLoading] = useState(true);
const [submitting, setSubmitting] = useState(false);
const formRef = useRef<HTMLFormElement>(null);
const showSaveButton = useMemo(() => {
return JSON.stringify(modifiedValues) !== JSON.stringify(values);
}, [modifiedValues, values]);
useEffect(() => {
setLoading(true);
dispatch(getNodeDetail(nodeID))
.then((res) => {
setValues(nodeValueFilter(res));
setModifiedValues(nodeValueFilter(res));
onNodeChange(nodeValueFilter(res));
})
.finally(() => {
setLoading(false);
});
}, [nodeID]);
const revert = () => {
setModifiedValues(values);
};
const submit = () => {
if (formRef.current) {
if (!formRef.current.checkValidity()) {
formRef.current.reportValidity();
return;
}
}
setSubmitting(true);
dispatch(
upsertNode({
node: { ...modifiedValues },
}),
)
.then((res) => {
setValues(nodeValueFilter(res));
setModifiedValues(nodeValueFilter(res));
onNodeChange(nodeValueFilter(res));
})
.finally(() => {
setSubmitting(false);
});
};
return (
<NodeSettingContext.Provider
value={{
values: modifiedValues,
setNode: setModifiedValues,
formRef,
}}
>
<SwitchTransition>
<CSSTransition
addEndListener={(node, done) => node.addEventListener("transitionend", done, false)}
classNames="fade"
key={`${loading}`}
>
<Box sx={{ mt: 3 }}>
{loading && (
<Box
sx={{
pt: 20,
height: "100%",
display: "flex",
justifyContent: "center",
alignItems: "center",
}}
>
<FacebookCircularProgress />
</Box>
)}
{!loading && (
<Box>
{children}
<SavingFloat in={showSaveButton} submitting={submitting} revert={revert} submit={submit} />
</Box>
)}
</Box>
</CSSTransition>
</SwitchTransition>
</NodeSettingContext.Provider>
);
};
export default NodeSettingWrapper;

View File

@@ -0,0 +1,35 @@
import { DialogContent, Link, Typography } from "@mui/material";
import { Trans, useTranslation } from "react-i18next";
import { Link as RouterLink } from "react-router-dom";
import DraggableDialog from "../../../Dialogs/DraggableDialog";
export interface StoreFilesHintDialogProps {
open: boolean;
onClose: () => void;
}
const StoreFilesHintDialog = ({ open, onClose }: StoreFilesHintDialogProps) => {
const { t } = useTranslation("dashboard");
return (
<DraggableDialog
dialogProps={{
open,
onClose,
fullWidth: true,
maxWidth: "sm",
}}
title={t("node.storeFiles")}
>
<DialogContent>
<Typography variant="body2">
<Trans
i18nKey="node.storeFilesHint"
ns="dashboard"
components={[<Link component={RouterLink} to="/admin/policy" />]}
/>
</Typography>
</DialogContent>
</DraggableDialog>
);
};
export default StoreFilesHintDialog;

View File

@@ -0,0 +1,3 @@
import EditNode from "./EditNode";
export default EditNode;

View File

@@ -0,0 +1,234 @@
import { Box, Stack, Typography, useTheme } from "@mui/material";
import { grey } from "@mui/material/colors";
import { useSnackbar } from "notistack";
import { lazy, Suspense, useEffect, useMemo, useRef, useState } from "react";
import { Trans, useTranslation } from "react-i18next";
import { useNavigate } from "react-router-dom";
import { testNode, upsertNode } from "../../../../api/api";
import { DownloaderProvider, Node, NodeStatus, NodeType } from "../../../../api/dashboard";
import { useAppDispatch } from "../../../../redux/hooks";
import { randomString } from "../../../../util";
import FacebookCircularProgress from "../../../Common/CircularProgress";
import { DefaultCloseAction } from "../../../Common/Snackbar/snackbar";
import { DenseFilledTextField, SecondaryButton } from "../../../Common/StyledComponents";
import DraggableDialog from "../../../Dialogs/DraggableDialog";
import SettingForm from "../../../Pages/Setting/SettingForm";
import { Code } from "../../../Common/Code.tsx";
import { EndpointInput } from "../../Common/EndpointInput";
import { NoMarginHelperText } from "../../Settings/Settings";
const MonacoEditor = lazy(() => import("../../../Viewers/CodeViewer/MonacoEditor"));
export interface NewNodeDialogProps {
open: boolean;
onClose: () => void;
}
const defaultNode: Node = {
id: 0,
name: "",
type: NodeType.slave,
status: NodeStatus.active,
server: "",
slave_key: "",
capabilities: "",
weight: 1,
settings: {
provider: DownloaderProvider.aria2,
qbittorrent: {},
aria2: {},
interval: 5,
},
edges: {
storage_policy: [],
},
};
export const Step = ({ step, children }: { step: number; children: React.ReactNode }) => {
return (
<Box
sx={{
display: "flex",
padding: "10px",
transition: (theme) =>
theme.transitions.create("background-color", {
easing: theme.transitions.easing.sharp,
duration: theme.transitions.duration.leavingScreen,
}),
"&:focus-within": {
backgroundColor: (theme) => (theme.palette.mode == "dark" ? grey[900] : grey[100]),
},
}}
>
<Box sx={{ ml: "20px" }}>
<Box
sx={{
width: "20px",
fontSize: (t) => t.typography.body2.fontSize,
height: "20px",
backgroundColor: (theme) => theme.palette.primary.light,
color: (theme) => theme.palette.primary.contrastText,
textAlign: "center",
borderRadius: " 50%",
}}
>
{step}
</Box>
</Box>
<Box sx={{ ml: "10px", mr: "20px", flexGrow: 1 }}>{children}</Box>
</Box>
);
};
export const NewNodeDialog = ({ open, onClose }: NewNodeDialogProps) => {
const { t } = useTranslation("dashboard");
const theme = useTheme();
const dispatch = useAppDispatch();
const navigate = useNavigate();
const { enqueueSnackbar } = useSnackbar();
const [loading, setLoading] = useState(false);
const [node, setNode] = useState<Node>({ ...defaultNode });
const formRef = useRef<HTMLFormElement>(null);
useEffect(() => {
if (open) {
setNode({ ...defaultNode, slave_key: randomString(64) });
}
}, [open]);
const handleSubmit = () => {
if (!formRef.current?.checkValidity()) {
formRef.current?.reportValidity();
return;
}
setLoading(true);
dispatch(upsertNode({ node }))
.then((r) => {
navigate(`/admin/node/${r.id}`);
onClose();
})
.finally(() => {
setLoading(false);
});
};
const config = useMemo(() => {
return `[System]
Mode = slave
Listen = :5212
[Slave]
Secret = ${node.slave_key}
; ${t("node.keepIfUpload")}
[CORS]
AllowOrigins = *
AllowMethods = OPTIONS,GET,POST
AllowHeaders = *
`;
}, [t, node.slave_key]);
const handleTest = () => {
if (!formRef.current?.checkValidity()) {
formRef.current?.reportValidity();
return;
}
setLoading(true);
dispatch(testNode({ node }))
.then(() => {
setLoading(false);
enqueueSnackbar(t("node.testNodeSuccess"), { variant: "success", action: DefaultCloseAction });
})
.finally(() => setLoading(false));
};
return (
<DraggableDialog
onAccept={handleSubmit}
loading={loading}
title={t("node.addNewNode")}
showActions
showCancel
dialogProps={{
open,
onClose,
fullWidth: true,
maxWidth: "sm",
}}
>
<form ref={formRef} onSubmit={handleSubmit}>
<Stack spacing={1}>
<Step step={1}>
<SettingForm lgWidth={12}>
<Typography variant="body2" sx={{ mb: 1 }}>
{t("node.nameTheNode")}
</Typography>
<DenseFilledTextField
fullWidth
required
value={node.name}
onChange={(e) => setNode({ ...node, name: e.target.value })}
/>
<NoMarginHelperText>{t("node.nameNode")}</NoMarginHelperText>
</SettingForm>
</Step>
<Step step={2}>
<SettingForm lgWidth={12}>
<Typography variant="body2" sx={{ mb: 1 }}>
{t("node.runCrSlave")}
</Typography>
<Suspense fallback={<FacebookCircularProgress />}>
<MonacoEditor
theme={theme.palette.mode === "dark" ? "vs-dark" : "vs"}
language="ini"
value={config}
height="200px"
minHeight="200px"
options={{
wordWrap: "on",
minimap: { enabled: false },
scrollBeyondLastLine: false,
readOnly: true,
}}
/>
</Suspense>
<NoMarginHelperText sx={{ mt: 1 }}>
<Trans i18nKey="node.runCrWithConfig" ns="dashboard" components={[<Code />]} />
</NoMarginHelperText>
</SettingForm>
</Step>
<Step step={3}>
<SettingForm lgWidth={12}>
<Typography variant="body2" sx={{ mb: 1 }}>
{t("node.inputServer")}
</Typography>
<EndpointInput
variant={"outlined"}
fullWidth
required
enforceProtocol
value={node.server}
onChange={(e) => setNode({ ...node, server: e.target.value })}
/>
<NoMarginHelperText>{t("node.serverDes")}</NoMarginHelperText>
</SettingForm>
</Step>
<Step step={4}>
<SettingForm lgWidth={12}>
<Typography variant="body2" sx={{ mb: 1 }}>
{t("node.testButton")}
</Typography>
<SecondaryButton loading={loading} variant="contained" onClick={handleTest}>
{t("node.testNode")}
</SecondaryButton>
<NoMarginHelperText sx={{ mt: 1 }}>
<Trans i18nKey="node.hostHeaderHint" ns="dashboard" components={[<Code />]} />
</NoMarginHelperText>
</SettingForm>
</Step>
</Stack>
</form>
</DraggableDialog>
);
};

View File

@@ -0,0 +1,200 @@
import { Box, Divider, IconButton, Skeleton, Typography } from "@mui/material";
import Grid from "@mui/material/Grid2";
import { useCallback, useState } from "react";
import { useTranslation } from "react-i18next";
import { useNavigate } from "react-router-dom";
import { deleteNode } from "../../../api/api";
import { Node, NodeStatus, NodeType } from "../../../api/dashboard";
import { NodeCapability } from "../../../api/workflow";
import { useAppDispatch } from "../../../redux/hooks";
import { confirmOperation } from "../../../redux/thunks/dialog";
import Boolset from "../../../util/boolset";
import { NoWrapBox, SquareChip } from "../../Common/StyledComponents";
import CheckCircleFilled from "../../Icons/CheckCircleFilled";
import Delete from "../../Icons/Delete";
import DismissCircleFilled from "../../Icons/DismissCircleFilled";
import Info from "../../Icons/Info";
import StarFilled from "../../Icons/StarFilled";
import { BorderedCardClickable } from "../Common/AdminCard";
export interface NodeCardProps {
node?: Node;
onRefresh?: () => void;
loading?: boolean;
}
const NodeCard = ({ node, onRefresh, loading }: NodeCardProps) => {
const { t } = useTranslation("dashboard");
const dispatch = useAppDispatch();
const navigate = useNavigate();
const [deleteLoading, setDeleteLoading] = useState(false);
const handleDelete = useCallback(
(e: React.MouseEvent<HTMLButtonElement>) => {
e.stopPropagation();
dispatch(confirmOperation(t("node.deleteNodeConfirmation", { name: node?.name ?? "" }))).then(() => {
setDeleteLoading(true);
dispatch(deleteNode(node?.id ?? 0))
.then(() => {
onRefresh?.();
})
.finally(() => {
setDeleteLoading(false);
});
});
},
[node, dispatch, onRefresh],
);
const handleEdit = useCallback(
(e: React.MouseEvent<HTMLElement>) => {
e.stopPropagation();
navigate(`/admin/node/${node?.id}`);
},
[node, navigate],
);
// Decode node capabilities
const getCapabilities = useCallback(() => {
if (!node?.capabilities) return [];
const boolset = new Boolset(node.capabilities);
const capabilities = [];
if (boolset.enabled(NodeCapability.create_archive)) {
capabilities.push({ id: NodeCapability.create_archive, name: t("application:fileManager.createArchive") });
}
if (boolset.enabled(NodeCapability.extract_archive)) {
capabilities.push({ id: NodeCapability.extract_archive, name: t("application:fileManager.extractArchive") });
}
if (boolset.enabled(NodeCapability.remote_download)) {
capabilities.push({ id: NodeCapability.remote_download, name: t("application:navbar.remoteDownload") });
}
return capabilities;
}, [node, t]);
// If loading is true, render a skeleton placeholder
if (loading) {
return (
<Grid
size={{
xs: 12,
md: 6,
lg: 4,
}}
>
<BorderedCardClickable>
<Box
sx={{
display: "flex",
justifyContent: "space-between",
alignItems: "center",
}}
>
<Typography variant="subtitle1" fontWeight={600}>
<Skeleton variant="text" width={100} />
</Typography>
<Typography variant="body2" color="text.secondary">
<Skeleton variant="text" width={60} />
</Typography>
</Box>
<NoWrapBox sx={{ mt: 1, mb: 2 }}>
<Box sx={{ display: "flex", alignItems: "center", minHeight: "25px" }}>
<Skeleton width={60} height={25} sx={{ mr: 1 }} />
</Box>
</NoWrapBox>
<Divider sx={{ my: 1 }} />
<Box
sx={{
display: "flex",
justifyContent: "space-between",
alignItems: "center",
}}
>
<Skeleton variant="text" width="40%" height={20} />
<Skeleton variant="circular" width={30} height={30} />
</Box>
</BorderedCardClickable>
</Grid>
);
}
const capabilities = getCapabilities();
return (
<Grid
size={{
xs: 12,
md: 6,
lg: 4,
}}
>
<BorderedCardClickable onClick={handleEdit}>
<Box
sx={{
display: "flex",
justifyContent: "space-between",
alignItems: "center",
}}
>
<Typography variant="subtitle1" fontWeight={600}>
{node?.name}
</Typography>
<Box sx={{ display: "flex", alignItems: "center" }}>
{node?.type === NodeType.master && <StarFilled sx={{ mr: 0.5, fontSize: 16, color: "primary.main" }} />}
<Typography variant="body2" color="text.secondary">
{node?.type === NodeType.master ? t("node.master") : t("node.slave")}
</Typography>
</Box>
</Box>
<NoWrapBox sx={{ mt: 1, mb: 2 }}>
{capabilities.length > 0 ? (
capabilities.map((capability) => (
<SquareChip sx={{ mr: 1 }} key={capability.id} label={capability.name} size="small" />
))
) : (
<Box sx={{ display: "flex", alignItems: "center", minHeight: "25px" }} color={"text.secondary"}>
<Info sx={{ mr: 0.5, fontSize: 20 }} />
<Typography variant="caption">{t("node.noCapabilities")}</Typography>
</Box>
)}
</NoWrapBox>
<Divider sx={{ my: 1 }} />
<Box
sx={{
display: "flex",
justifyContent: "space-between",
alignItems: "center",
}}
>
<Box sx={{ display: "flex", alignItems: "center" }}>
{node?.status === NodeStatus.active ? (
<>
<CheckCircleFilled sx={{ mr: 0.5, fontSize: 20, color: "success.main" }} />
<Typography variant="body2" color="success.main">
{t("node.active")}
</Typography>
</>
) : (
<>
<DismissCircleFilled sx={{ mr: 0.5, fontSize: 20, color: "text.secondary" }} />
<Typography variant="body2" color="text.secondary">
{t("node.suspended")}
</Typography>
</>
)}
</Box>
<Box>
<IconButton size="small" onClick={handleDelete} disabled={deleteLoading}>
<Delete fontSize="small" />
</IconButton>
</Box>
</Box>
</BorderedCardClickable>
</Grid>
);
};
export default NodeCard;

View File

@@ -0,0 +1,131 @@
import { Add } from "@mui/icons-material";
import { Box, Container, Grid2 as Grid, IconButton, Stack, Typography } from "@mui/material";
import { useQueryState } from "nuqs";
import { useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import { getNodeList } from "../../../api/api";
import { Node } from "../../../api/dashboard";
import { useAppDispatch } from "../../../redux/hooks";
import { SecondaryButton } from "../../Common/StyledComponents";
import ArrowSync from "../../Icons/ArrowSync";
import QuestionCircle from "../../Icons/QuestionCircle";
import PageContainer from "../../Pages/PageContainer";
import PageHeader from "../../Pages/PageHeader";
import { BorderedCardClickable } from "../Common/AdminCard";
import TablePagination from "../Common/TablePagination";
import { OrderByQuery, OrderDirectionQuery, PageQuery, PageSizeQuery } from "../StoragePolicy/StoragePolicySetting";
import { NewNodeDialog } from "./NewNode/NewNodeDialog";
import NodeCard from "./NodeCard";
const NodeSetting = () => {
const { t } = useTranslation("dashboard");
const dispatch = useAppDispatch();
const [loading, setLoading] = useState(true);
const [nodes, setNodes] = useState<Node[]>([]);
const [page, setPage] = useQueryState(PageQuery, { defaultValue: "1" });
const [pageSize, setPageSize] = useQueryState(PageSizeQuery, {
defaultValue: "11",
});
const [orderBy, setOrderBy] = useQueryState(OrderByQuery, {
defaultValue: "",
});
const [orderDirection, setOrderDirection] = useQueryState(OrderDirectionQuery, { defaultValue: "desc" });
const [count, setCount] = useState(0);
const [selectProviderOpen, setSelectProviderOpen] = useState(false);
const [createNewOpen, setCreateNewOpen] = useState(false);
const pageInt = parseInt(page) ?? 1;
const pageSizeInt = parseInt(pageSize) ?? 11;
useEffect(() => {
fetchNodes();
}, [page, pageSize, orderBy, orderDirection]);
const fetchNodes = () => {
setLoading(true);
dispatch(
getNodeList({
page: pageInt,
page_size: pageSizeInt,
order_by: orderBy ?? "",
order_direction: orderDirection ?? "desc",
conditions: {},
}),
)
.then((res) => {
setNodes(res.nodes);
setPage((res.pagination.page + 1).toString());
setPageSize(res.pagination.page_size.toString());
setCount(res.pagination.total_items ?? 0);
setLoading(false);
})
.finally(() => {
setLoading(false);
});
};
return (
<PageContainer>
<NewNodeDialog open={createNewOpen} onClose={() => setCreateNewOpen(false)} />
<Container maxWidth="xl">
<PageHeader
title={t("dashboard:nav.nodes")}
secondaryAction={
<IconButton onClick={() => window.open("https://docs.cloudreve.org/usage/slave-node", "_blank")}>
<QuestionCircle />
</IconButton>
}
/>
<Stack direction="row" spacing={1} sx={{ mb: 2 }}>
<SecondaryButton onClick={fetchNodes} disabled={loading} variant={"contained"} startIcon={<ArrowSync />}>
{t("node.refresh")}
</SecondaryButton>
</Stack>
<Grid container spacing={2}>
<Grid
size={{
xs: 12,
md: 6,
lg: 4,
}}
>
<BorderedCardClickable
onClick={() => setCreateNewOpen(true)}
sx={{
height: "100%",
borderStyle: "dashed",
display: "flex",
alignItems: "center",
gap: 1,
justifyContent: "center",
color: (t) => t.palette.text.secondary,
}}
>
<Add />
<Typography variant="h6">{t("node.addNewNode")}</Typography>
</BorderedCardClickable>
</Grid>
{!loading && nodes.map((n) => <NodeCard key={n.name} node={n} onRefresh={fetchNodes} />)}
{loading && nodes.length > 0 && nodes.map((n) => <NodeCard key={`loading-${n.name}`} loading={true} />)}
{loading &&
nodes.length === 0 &&
Array.from(Array(5)).map((_, index) => <NodeCard key={`loading-${index}`} loading={true} />)}
</Grid>
{count > 0 && (
<Box sx={{ mt: 1 }}>
<TablePagination
page={pageInt}
totalItems={count}
rowsPerPage={pageSizeInt}
rowsPerPageOptions={[11, 25, 50, 100, 200, 500, 1000]}
onRowsPerPageChange={(value) => setPageSize(value.toString())}
onChange={(_, value) => setPage(value.toString())}
/>
</Box>
)}
</Container>
</PageContainer>
);
};
export default NodeSetting;

View File

@@ -0,0 +1,39 @@
import { Box, Stack } from "@mui/material";
import { useContext } from "react";
import { useTranslation } from "react-i18next";
import { SettingSection } from "../Settings";
import { SettingContext } from "../SettingWrapper";
import CustomHTML from "./CustomHTML";
import CustomNavItems from "./CustomNavItems";
import ThemeOptions from "./ThemeOptions";
const Appearance = () => {
const { t } = useTranslation("dashboard");
const { formRef, setSettings, values } = useContext(SettingContext);
return (
<Box component={"form"} ref={formRef} onSubmit={(e) => e.preventDefault()}>
<Stack spacing={5}>
<SettingSection>
<ThemeOptions
value={values.theme_options || "{}"}
onChange={(value: string) => setSettings({ theme_options: value })}
defaultTheme={values.defaultTheme || ""}
onDefaultThemeChange={(value: string) => setSettings({ defaultTheme: value })}
/>
</SettingSection>
<SettingSection>
<CustomNavItems
value={values.custom_nav_items || "[]"}
onChange={(value: string) => setSettings({ custom_nav_items: value })}
/>
</SettingSection>
<SettingSection>
<CustomHTML />
</SettingSection>
</Stack>
</Box>
);
};
export default Appearance;

View File

@@ -0,0 +1,249 @@
import { LoadingButton } from "@mui/lab";
import {
Box,
CircularProgress,
Container,
FormControl,
Grid,
Grid2,
Paper,
Stack,
Typography,
useTheme,
} from "@mui/material";
import { Suspense, useContext } from "react";
import { useTranslation } from "react-i18next";
import { OutlineIconTextField } from "../../../Common/Form/OutlineIconTextField";
import Logo from "../../../Common/Logo";
import DrawerHeader from "../../../Frame/NavBar/DrawerHeader";
import { SideNavItemComponent } from "../../../Frame/NavBar/PageNavigation";
import StorageSummary from "../../../Frame/NavBar/StorageSummary";
import PoweredBy from "../../../Frame/PoweredBy";
import CloudDownload from "../../../Icons/CloudDownload";
import CloudDownloadOutlined from "../../../Icons/CloudDownloadOutlined";
import CubeSync from "../../../Icons/CubeSync";
import CubeSyncFilled from "../../../Icons/CubeSyncFilled";
import MailOutlined from "../../../Icons/MailOutlined";
import PhoneLaptop from "../../../Icons/PhoneLaptop";
import PhoneLaptopOutlined from "../../../Icons/PhoneLaptopOutlined";
import SettingForm from "../../../Pages/Setting/SettingForm";
import MonacoEditor from "../../../Viewers/CodeViewer/MonacoEditor";
import { SettingContext } from "../SettingWrapper";
import { NoMarginHelperText } from "../Settings";
export interface CustomHTMLProps {}
const HeadlessFooterPreview = ({ footer, bottom }: { footer?: string; bottom?: string }) => {
const { t } = useTranslation("application");
return (
<Box
sx={{
borderRadius: 1,
backgroundColor: (theme) =>
theme.palette.mode === "light" ? theme.palette.grey[100] : theme.palette.grey[900],
}}
>
<Container maxWidth={"xs"}>
<Grid
container
spacing={0}
direction="column"
alignItems="center"
justifyContent="center"
sx={{ minHeight: "100px" }}
>
<Box sx={{ width: "100%", py: 2 }}>
<Paper
sx={{
padding: (theme) => `${theme.spacing(2)} ${theme.spacing(3)} ${theme.spacing(3)}`,
}}
>
<Logo
sx={{
maxWidth: "40%",
maxHeight: "40px",
mb: 2,
}}
/>
<div>
<Box
sx={{
display: "block",
}}
>
<Typography variant={"h6"}>{t("login.siginToYourAccount")}</Typography>
<FormControl variant="standard" margin="normal" fullWidth>
<OutlineIconTextField label={t("login.email")} variant={"outlined"} icon={<MailOutlined />} />
</FormControl>
<LoadingButton sx={{ mt: 2 }} fullWidth variant="contained" color="primary">
<span>{t("login.continue")}</span>
</LoadingButton>
{bottom && (
<Box sx={{ width: "100%" }}>
<div dangerouslySetInnerHTML={{ __html: bottom }} />
</Box>
)}
</Box>
</div>
</Paper>
</Box>
<PoweredBy />
{footer && (
<Box sx={{ mb: 2, width: "100%" }}>
<div dangerouslySetInnerHTML={{ __html: footer }} />
</Box>
)}
</Grid>
</Container>
</Box>
);
};
const SidebarBottomPreview = ({ bottom }: { bottom?: string }) => {
return (
<Box
sx={{
maxWidth: "300px",
borderRadius: 1,
backgroundColor: (theme) =>
theme.palette.mode === "light" ? theme.palette.grey[100] : theme.palette.grey[900],
}}
>
<DrawerHeader disabled />
<Stack
direction={"column"}
spacing={2}
sx={{
px: 1,
pb: 1,
flexGrow: 1,
mx: 1,
overflow: "auto",
}}
>
<Box sx={{ width: "100%" }}>
<SideNavItemComponent
item={{
label: "navbar.remoteDownload",
icon: [CloudDownload, CloudDownloadOutlined],
path: "#1",
}}
/>
<SideNavItemComponent
item={{
label: "navbar.connect",
icon: [PhoneLaptop, PhoneLaptopOutlined],
path: "#1",
}}
/>
<SideNavItemComponent
item={{
label: "navbar.taskQueue",
icon: [CubeSyncFilled, CubeSync],
path: "#1",
}}
/>
</Box>
<StorageSummary />
{bottom && (
<Box sx={{ width: "100%" }}>
<div dangerouslySetInnerHTML={{ __html: bottom ?? "" }} />
</Box>
)}
</Stack>
</Box>
);
};
const CustomHTML = ({}: CustomHTMLProps) => {
const { t } = useTranslation("dashboard");
const { formRef, setSettings, values } = useContext(SettingContext);
const theme = useTheme();
return (
<Box>
<Typography variant="h6" gutterBottom>
{t("settings.customHTML")}
</Typography>
<Typography variant="body2" color="text.secondary" paragraph>
{t("settings.customHTMLDes")}
</Typography>
<Stack spacing={3}>
<SettingForm
title={t("settings.headlessFooter")}
lgWidth={5}
spacing={3}
secondary={
<Grid2 size={{ md: 7, xs: 12 }}>
<HeadlessFooterPreview footer={values.headless_footer_html ?? ""} />
</Grid2>
}
>
<FormControl fullWidth>
<Suspense fallback={<CircularProgress />}>
<MonacoEditor
theme={theme.palette.mode === "dark" ? "vs-dark" : "vs"}
value={values.headless_footer_html}
height={"300px"}
minHeight={"300px"}
language={"html"}
onChange={(e) => setSettings({ headless_footer_html: e as string })}
/>
</Suspense>
<NoMarginHelperText>{t("settings.headlessFooterDes")}</NoMarginHelperText>
</FormControl>
</SettingForm>
<SettingForm
title={t("settings.headlessBottom")}
lgWidth={5}
spacing={3}
secondary={
<Grid2 size={{ md: 7, xs: 12 }}>
<HeadlessFooterPreview bottom={values.headless_bottom_html ?? ""} />
</Grid2>
}
>
<FormControl fullWidth>
<Suspense fallback={<CircularProgress />}>
<MonacoEditor
theme={theme.palette.mode === "dark" ? "vs-dark" : "vs"}
value={values.headless_bottom_html}
height={"300px"}
minHeight={"300px"}
language={"html"}
onChange={(e) => setSettings({ headless_bottom_html: e as string })}
/>
</Suspense>
<NoMarginHelperText>{t("settings.headlessBottomDes")}</NoMarginHelperText>
</FormControl>
</SettingForm>
<SettingForm
title={t("settings.sidebarBottom")}
lgWidth={5}
spacing={3}
secondary={
<Grid2 size={{ md: 7, xs: 12 }}>
<SidebarBottomPreview bottom={values.sidebar_bottom_html ?? ""} />
</Grid2>
}
>
<FormControl fullWidth>
<Suspense fallback={<CircularProgress />}>
<MonacoEditor
theme={theme.palette.mode === "dark" ? "vs-dark" : "vs"}
value={values.sidebar_bottom_html}
height={"300px"}
minHeight={"300px"}
language={"html"}
onChange={(e) => setSettings({ sidebar_bottom_html: e as string })}
/>
</Suspense>
<NoMarginHelperText>{t("settings.sidebarBottomDes")}</NoMarginHelperText>
</FormControl>
</SettingForm>
</Stack>
</Box>
);
};
export default CustomHTML;

View File

@@ -0,0 +1,378 @@
import { Icon } from "@iconify/react/dist/iconify.js";
import {
Box,
IconButton,
Table,
TableBody,
TableCell,
TableContainer,
TableHead,
TableRow,
Typography,
useTheme,
} from "@mui/material";
import React, { useCallback, useEffect, useMemo, useState } from "react";
import { DndProvider, useDrag, useDrop } from "react-dnd";
import { HTML5Backend } from "react-dnd-html5-backend";
import { useTranslation } from "react-i18next";
import { CustomNavItem } from "../../../../api/site";
import {
DenseFilledTextField,
NoWrapCell,
NoWrapTableCell,
SecondaryButton,
StyledTableContainerPaper,
} from "../../../Common/StyledComponents";
import Add from "../../../Icons/Add";
import ArrowDown from "../../../Icons/ArrowDown";
import Delete from "../../../Icons/Delete";
export interface CustomNavItemsProps {
value: string;
onChange: (value: string) => void;
}
const DND_TYPE = "custom-nav-item-row";
// 拖拽item类型
type DragItem = { index: number };
interface DraggableNavItemRowProps {
item: CustomNavItem;
index: number;
moveRow: (from: number, to: number) => void;
onDelete: (index: number) => void;
onMoveUp: () => void;
onMoveDown: () => void;
isFirst: boolean;
isLast: boolean;
inputCache: { [key: number]: { [field: string]: string | undefined } };
onInputChange: (index: number, field: string, value: string) => void;
onInputBlur: (index: number, field: keyof CustomNavItem) => void;
IconPreview: React.ComponentType<{ iconName: string }>;
t: any;
style?: React.CSSProperties;
}
const DraggableNavItemRow = React.memo(
React.forwardRef<HTMLTableRowElement, DraggableNavItemRowProps>(
(
{
item,
index,
moveRow,
onDelete,
onMoveUp,
onMoveDown,
isFirst,
isLast,
inputCache,
onInputChange,
onInputBlur,
IconPreview,
t,
style,
},
ref,
): JSX.Element => {
const [, drop] = useDrop<DragItem>({
accept: DND_TYPE,
hover(dragItem, monitor) {
if (!(ref && typeof ref !== "function" && ref.current)) return;
const dragIndex = dragItem.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);
dragItem.index = hoverIndex;
},
});
const [{ isDragging }, drag] = useDrag<DragItem, void, { isDragging: boolean }>({
type: DND_TYPE,
item: { index },
collect: (monitor) => ({
isDragging: monitor.isDragging(),
}),
});
// 兼容ref为function和对象
const setRowRef = (node: HTMLTableRowElement | null) => {
if (typeof ref === "function") {
ref(node);
} else if (ref) {
(ref as React.MutableRefObject<HTMLTableRowElement | null>).current = node;
}
drag(drop(node));
};
return (
<TableRow ref={setRowRef} hover style={{ opacity: isDragging ? 0.5 : 1, cursor: "move", ...style }}>
<TableCell>
<IconPreview iconName={item.icon} />
</TableCell>
<TableCell>
<DenseFilledTextField
fullWidth
required
value={inputCache[index]?.icon ?? item.icon}
onChange={(e) => onInputChange(index, "icon", e.target.value)}
onBlur={() => onInputBlur(index, "icon")}
placeholder={t("settings.iconifyNamePlaceholder")}
/>
</TableCell>
<TableCell>
<DenseFilledTextField
fullWidth
required
value={inputCache[index]?.name ?? item.name}
onChange={(e) => onInputChange(index, "name", e.target.value)}
onBlur={() => onInputBlur(index, "name")}
placeholder={t("settings.displayNameDes")}
/>
</TableCell>
<TableCell>
<DenseFilledTextField
fullWidth
required
value={inputCache[index]?.url ?? item.url}
onChange={(e) => onInputChange(index, "url", e.target.value)}
onBlur={() => onInputBlur(index, "url")}
placeholder="https://example.com"
/>
</TableCell>
<TableCell align="right">
<IconButton size="small" onClick={() => onDelete(index)}>
<Delete fontSize="small" />
</IconButton>
</TableCell>
<TableCell>
<IconButton size="small" onClick={onMoveUp} disabled={isFirst}>
<ArrowDown
sx={{
width: "18px",
height: "18px",
transform: "rotate(180deg)",
}}
/>
</IconButton>
<IconButton size="small" onClick={onMoveDown} disabled={isLast}>
<ArrowDown
sx={{
width: "18px",
height: "18px",
}}
/>
</IconButton>
</TableCell>
</TableRow>
);
},
),
);
const CustomNavItems = ({ value, onChange }: CustomNavItemsProps) => {
const { t } = useTranslation("dashboard");
const theme = useTheme();
const [items, setItems] = useState<CustomNavItem[]>([]);
const [inputCache, setInputCache] = useState<{
[key: number]: { [field: string]: string | undefined };
}>({});
useEffect(() => {
try {
const parsedItems = JSON.parse(value);
setItems(Array.isArray(parsedItems) ? parsedItems : []);
} catch (e) {
setItems([]);
}
}, [value]);
const handleSave = useCallback(
(newItems: CustomNavItem[]) => {
onChange(JSON.stringify(newItems));
},
[onChange],
);
const handleDelete = useCallback(
(index: number) => {
const newItems = items.filter((_, i) => i !== index);
handleSave(newItems);
},
[items, handleSave],
);
const handleAdd = useCallback(() => {
const newItems = [
...items,
{
name: "",
url: "",
icon: "fluent:home-24-regular",
},
];
handleSave(newItems);
}, [items, handleSave]);
const handleFieldChange = useCallback(
(index: number, field: keyof CustomNavItem, value: string) => {
const newItems = [...items];
newItems[index] = {
...newItems[index],
[field]: value,
};
handleSave(newItems);
},
[items, handleSave],
);
const handleInputChange = useCallback((index: number, field: string, value: string) => {
setInputCache((prev) => ({
...prev,
[index]: {
...prev[index],
[field]: value,
},
}));
}, []);
const handleInputBlur = useCallback(
(index: number, field: keyof CustomNavItem) => {
const cachedValue = inputCache[index]?.[field];
if (cachedValue !== undefined) {
handleFieldChange(index, field, cachedValue);
setInputCache((prev) => ({
...prev,
[index]: {
...prev[index],
[field]: undefined,
},
}));
}
},
[inputCache, handleFieldChange],
);
// 拖拽排序逻辑
const moveRow = useCallback(
(from: number, to: number) => {
if (from === to) return;
const updated = [...items];
const [moved] = updated.splice(from, 1);
updated.splice(to, 0, moved);
setItems(updated);
handleSave(updated);
},
[items, handleSave],
);
const handleMoveUp = (idx: number) => {
if (idx <= 0) return;
moveRow(idx, idx - 1);
};
const handleMoveDown = (idx: number) => {
if (idx >= items.length - 1) return;
moveRow(idx, idx + 1);
};
const IconPreview = useMemo(
() =>
({ iconName }: { iconName: string }) => {
if (!iconName) {
return (
<Box
sx={{
width: 24,
height: 24,
bgcolor: "grey.300",
borderRadius: "4px",
}}
/>
);
}
return (
<Icon
icon={iconName}
width={24}
height={24}
style={{
color: theme.palette.action.active,
}}
/>
);
},
[],
);
return (
<Box>
<Typography variant="h6" gutterBottom>
{t("settings.customNavItems")}
</Typography>
<Typography variant="body2" color="text.secondary" paragraph>
{t("settings.customNavItemsDes")}
</Typography>
<TableContainer component={StyledTableContainerPaper} sx={{ mt: 2 }}>
<DndProvider backend={HTML5Backend}>
<Table size="small" stickyHeader sx={{ width: "100%", tableLayout: "fixed" }}>
<TableHead>
<TableRow>
<NoWrapTableCell width={60}>{t("settings.icon")}</NoWrapTableCell>
<NoWrapTableCell width={200}>{t("settings.iconifyName")}</NoWrapTableCell>
<NoWrapTableCell width={200}>{t("settings.displayName")}</NoWrapTableCell>
<NoWrapTableCell width={250}>{t("settings.navItemUrl")}</NoWrapTableCell>
<NoWrapTableCell width={80} align="right"></NoWrapTableCell>
<NoWrapTableCell width={80}></NoWrapTableCell>
</TableRow>
</TableHead>
<TableBody>
{items.map((item, index) => {
const rowRef = React.createRef<HTMLTableRowElement>();
return (
<DraggableNavItemRow
key={index}
ref={rowRef}
item={item}
index={index}
moveRow={moveRow}
onDelete={handleDelete}
onMoveUp={() => handleMoveUp(index)}
onMoveDown={() => handleMoveDown(index)}
isFirst={index === 0}
isLast={index === items.length - 1}
inputCache={inputCache}
onInputChange={handleInputChange}
onInputBlur={handleInputBlur}
IconPreview={IconPreview}
t={t}
/>
);
})}
{items.length === 0 && (
<TableRow>
<NoWrapCell colSpan={6} align="center">
<Typography variant="caption" color="text.secondary">
{t("application:setting.listEmpty")}
</Typography>
</NoWrapCell>
</TableRow>
)}
</TableBody>
</Table>
</DndProvider>
</TableContainer>
<SecondaryButton variant="contained" startIcon={<Add />} onClick={handleAdd} sx={{ mt: 2 }}>
{t("settings.addNavItem")}
</SecondaryButton>
</Box>
);
};
export default CustomNavItems;

View File

@@ -0,0 +1,220 @@
import {
Badge,
Box,
Button,
createTheme,
DialogContent,
Divider,
Grid,
Link,
Paper,
Stack,
TextField,
ThemeProvider,
Typography,
useTheme,
} from "@mui/material";
import { useSnackbar } from "notistack";
import { lazy, Suspense, useCallback, useEffect, useMemo, useState } from "react";
import { Trans, useTranslation } from "react-i18next";
import { applyThemeWithOverrides } from "../../../../App";
import CircularProgress from "../../../Common/CircularProgress";
import { DefaultCloseAction } from "../../../Common/Snackbar/snackbar";
import DraggableDialog from "../../../Dialogs/DraggableDialog";
import SideNavItem from "../../../Frame/NavBar/SideNavItem";
import Setting from "../../../Icons/Setting";
const MonacoEditor = lazy(() => import("../../../Viewers/CodeViewer/MonacoEditor"));
export interface ThemeOptionEditDialogProps {
open: boolean;
onClose: () => void;
id: string;
config: string;
onSave: (id: string, newId: string, config: string) => void;
}
const ThemeOptionEditDialog = ({ open, onClose, id, config, onSave }: ThemeOptionEditDialogProps) => {
const { t } = useTranslation("dashboard");
const theme = useTheme();
const { enqueueSnackbar } = useSnackbar();
const [editedConfig, setEditedConfig] = useState(config);
const [parsedConfig, setParsedConfig] = useState<any>(null);
useEffect(() => {
try {
setParsedConfig(JSON.parse(config));
} catch (e) {
setParsedConfig(null);
}
}, [config]);
useEffect(() => {
try {
setParsedConfig(JSON.parse(editedConfig));
} catch (e) {
// Don't update parsedConfig if JSON is invalid
}
}, [editedConfig]);
const handleSave = useCallback(() => {
try {
// Validate JSON
const parsed = JSON.parse(editedConfig);
// make sure minimum config is provided
if (!parsed.light?.palette?.primary?.main) {
throw new Error("Invalid theme config");
}
// Get the new primary color (ID)
const newId = parsed.light.palette.primary.main;
onSave(id, newId, editedConfig);
} catch (e) {
enqueueSnackbar({
message: t("settings.invalidThemeConfig"),
variant: "warning",
action: DefaultCloseAction,
});
}
}, [editedConfig, id, onSave, enqueueSnackbar, t]);
// Create preview themes
const lightTheme = useMemo(() => {
if (!parsedConfig) return null;
try {
return createTheme({
palette: {
mode: "light",
...parsedConfig.light.palette,
},
});
} catch (e) {
return null;
}
}, [parsedConfig]);
const darkTheme = useMemo(() => {
if (!parsedConfig) return null;
try {
return createTheme({
palette: {
mode: "dark",
...parsedConfig.dark.palette,
},
});
} catch (e) {
return null;
}
}, [parsedConfig]);
return (
<DraggableDialog
title={t("settings.editThemeOption")}
showActions
showCancel
onAccept={handleSave}
dialogProps={{
fullWidth: true,
maxWidth: "lg",
open,
onClose,
}}
>
<DialogContent>
<Grid container spacing={2}>
<Grid item xs={12} md={6}>
<Typography variant="subtitle1" gutterBottom>
{t("settings.themeConfiguration")}
</Typography>
<Suspense fallback={<CircularProgress />}>
<MonacoEditor
theme={theme.palette.mode === "dark" ? "vs-dark" : "vs"}
value={editedConfig}
height={"500px"}
minHeight={"500px"}
language={"json"}
onChange={(e) => setEditedConfig(e as string)}
/>
</Suspense>
<Typography sx={{ mt: 1 }} variant="caption" color="text.secondary" gutterBottom>
<Trans
i18nKey={"settings.themeDes"}
ns={"dashboard"}
components={[
<Link href={"https://mui.com/material-ui/customization/default-theme/"} target={"_blank"} />,
]}
/>
</Typography>
</Grid>
<Grid item xs={12} md={6}>
<Typography variant="subtitle1" gutterBottom>
{t("settings.themePreview")}
</Typography>
<Box sx={{ mb: 2 }}>
<Typography variant="subtitle2" gutterBottom>
{t("settings.lightTheme")}
</Typography>
{lightTheme ? (
<ThemeProvider theme={applyThemeWithOverrides(lightTheme)}>
<Paper elevation={2} sx={{ p: 2, mb: 2 }}>
<Stack spacing={2}>
<Typography variant="h6">{t("settings.previewTitle")}</Typography>
<SideNavItem
active
label={t("settings.previewTitle")}
icon={<Setting fontSize="small" color="action" />}
/>
<TextField label={t("settings.previewTextField")} variant="outlined" size="small" />
<Box>
<Badge badgeContent={10} color="secondary">
<Button variant="contained" color="primary">
{t("settings.previewPrimary")}
</Button>
</Badge>
</Box>
</Stack>
</Paper>
</ThemeProvider>
) : (
<Typography color="error">{t("settings.invalidThemePreview")}</Typography>
)}
</Box>
<Divider sx={{ my: 2 }} />
<Box>
<Typography variant="subtitle2" gutterBottom>
{t("settings.darkTheme")}
</Typography>
{darkTheme ? (
<ThemeProvider theme={applyThemeWithOverrides(darkTheme)}>
<Paper elevation={2} sx={{ p: 2, mb: 2 }}>
<Stack spacing={2}>
<Typography variant="h6">{t("settings.previewTitle")}</Typography>
<SideNavItem
active
label={t("settings.previewTitle")}
icon={<Setting fontSize="small" color="action" />}
/>
<TextField label={t("settings.previewTextField")} variant="outlined" size="small" />
<Box>
<Badge badgeContent={10} color="secondary">
<Button variant="contained" color="primary">
{t("settings.previewPrimary")}
</Button>
</Badge>
</Box>
</Stack>
</Paper>
</ThemeProvider>
) : (
<Typography color="error">{t("settings.invalidThemePreview")}</Typography>
)}
</Box>
</Grid>
</Grid>
</DialogContent>
</DraggableDialog>
);
};
export default ThemeOptionEditDialog;

View File

@@ -0,0 +1,337 @@
import {
Box,
IconButton,
Table,
TableBody,
TableCell,
TableContainer,
TableHead,
TableRow,
Typography,
useTheme,
} from "@mui/material";
import { useSnackbar } from "notistack";
import { useCallback, useEffect, useMemo, useState } from "react";
import { useTranslation } from "react-i18next";
import { DefaultCloseAction } from "../../../Common/Snackbar/snackbar";
import {
NoWrapTableCell,
SecondaryButton,
StyledCheckbox,
StyledTableContainerPaper,
} from "../../../Common/StyledComponents";
import Add from "../../../Icons/Add";
import Delete from "../../../Icons/Delete";
import Edit from "../../../Icons/Edit";
import HexColorInput from "../../FileSystem/HexColorInput.tsx";
import ThemeOptionEditDialog from "./ThemeOptionEditDialog";
export interface ThemeOptionsProps {
value: string;
onChange: (value: string) => void;
defaultTheme: string;
onDefaultThemeChange: (value: string) => void;
}
interface ThemeOption {
id: string;
config: {
light: {
palette: {
primary: {
main: string;
light?: string;
dark?: string;
};
secondary: {
main: string;
light?: string;
dark?: string;
};
};
};
dark: {
palette: {
primary: {
main: string;
light?: string;
dark?: string;
};
secondary: {
main: string;
light?: string;
dark?: string;
};
};
};
};
}
const ThemeOptions = ({ value, onChange, defaultTheme, onDefaultThemeChange }: ThemeOptionsProps) => {
const { t } = useTranslation("dashboard");
const theme = useTheme();
const { enqueueSnackbar } = useSnackbar();
const [options, setOptions] = useState<Record<string, ThemeOption["config"]>>({});
const [editingOption, setEditingOption] = useState<{ id: string; config: ThemeOption["config"] } | null>(null);
const [isDialogOpen, setIsDialogOpen] = useState(false);
useEffect(() => {
try {
const parsedOptions = JSON.parse(value);
setOptions(parsedOptions);
} catch (e) {
setOptions({});
}
}, [value]);
const handleSave = useCallback(
(newOptions: Record<string, ThemeOption["config"]>) => {
onChange(JSON.stringify(newOptions));
},
[onChange],
);
const handleDelete = useCallback(
(id: string) => {
// Prevent deleting the default theme
if (id === defaultTheme) {
enqueueSnackbar({
message: t("settings.cannotDeleteDefaultTheme"),
variant: "warning",
action: DefaultCloseAction,
});
return;
}
const newOptions = { ...options };
delete newOptions[id];
handleSave(newOptions);
},
[options, handleSave, defaultTheme, enqueueSnackbar, t],
);
const handleEdit = useCallback(
(id: string) => {
setEditingOption({ id, config: options[id] });
setIsDialogOpen(true);
},
[options],
);
const handleAdd = useCallback(() => {
// Generate a new default theme option with a random color
const randomColor = `#${Math.floor(Math.random() * 16777215).toString(16)}`;
setEditingOption({
id: randomColor,
config: {
light: {
palette: {
primary: { main: randomColor },
secondary: { main: "#f50057" },
},
},
dark: {
palette: {
primary: { main: randomColor },
secondary: { main: "#f50057" },
},
},
},
});
setIsDialogOpen(true);
}, []);
const handleDialogClose = useCallback(() => {
setIsDialogOpen(false);
setEditingOption(null);
}, []);
const handleDialogSave = useCallback(
(id: string, newId: string, config: string) => {
try {
const parsedConfig = JSON.parse(config);
const newOptions = { ...options };
// If ID has changed (primary color changed), delete the old entry and create a new one
if (id !== newId) {
// Check if the new ID already exists
if (newOptions[newId]) {
enqueueSnackbar({
message: t("settings.duplicateThemeColor"),
variant: "warning",
action: DefaultCloseAction,
});
return;
}
// If we're changing the ID of the default theme, update the default theme reference
if (id === defaultTheme) {
onDefaultThemeChange(newId);
}
delete newOptions[id];
}
newOptions[newId] = parsedConfig;
handleSave(newOptions);
setIsDialogOpen(false);
setEditingOption(null);
} catch (e) {
// Handle error
enqueueSnackbar({
message: t("settings.invalidThemeConfig"),
variant: "warning",
action: DefaultCloseAction,
});
}
},
[options, handleSave, enqueueSnackbar, defaultTheme, onDefaultThemeChange, t],
);
const handleColorChange = useCallback(
(id: string, type: "primary" | "secondary", mode: "light" | "dark", color: string) => {
const newOptions = { ...options };
if (type === "primary" && mode === "light") {
// If changing the primary color (which is the ID), we need to create a new entry
const newId = color;
// Check if the new ID already exists
if (newOptions[newId] && newId !== id) {
enqueueSnackbar({
message: t("settings.duplicateThemeColor"),
variant: "warning",
action: DefaultCloseAction,
});
return;
}
const config = { ...newOptions[id] };
config[mode].palette[type].main = color;
// Delete old entry and create new one with the updated ID
delete newOptions[id];
newOptions[newId] = config;
// If we're changing the ID of the default theme, update the default theme reference
if (id === defaultTheme) {
onDefaultThemeChange(newId);
}
} else {
// For other colors, just update the value
newOptions[id][mode].palette[type].main = color;
}
handleSave(newOptions);
},
[options, handleSave, enqueueSnackbar, t, defaultTheme, onDefaultThemeChange],
);
const handleDefaultThemeChange = useCallback(
(id: string) => {
onDefaultThemeChange(id);
},
[onDefaultThemeChange],
);
const optionsArray = useMemo(() => {
return Object.entries(options).map(([id, config]) => ({
id,
config,
}));
}, [options]);
return (
<Box>
<Typography variant="h6" gutterBottom>
{t("settings.themeOptions")}
</Typography>
<Typography variant="body2" color="text.secondary" paragraph>
{t("settings.themeOptionsDes")}
</Typography>
{optionsArray.length > 0 && (
<TableContainer component={StyledTableContainerPaper} sx={{ mt: 2 }}>
<Table size="small" stickyHeader sx={{ width: "100%", tableLayout: "fixed" }}>
<TableHead>
<TableRow>
<NoWrapTableCell width={50}>{t("settings.defaultTheme")}</NoWrapTableCell>
<NoWrapTableCell width={150}>{t("settings.primaryColor")}</NoWrapTableCell>
<NoWrapTableCell width={150}>{t("settings.secondaryColor")}</NoWrapTableCell>
<NoWrapTableCell width={150}>{t("settings.primaryColorDark")}</NoWrapTableCell>
<NoWrapTableCell width={150}>{t("settings.secondaryColorDark")}</NoWrapTableCell>
<NoWrapTableCell width={100} align="right"></NoWrapTableCell>
</TableRow>
</TableHead>
<TableBody>
{optionsArray.map((option) => (
<TableRow key={option.id} hover>
<TableCell>
<StyledCheckbox
size="small"
checked={option.id === defaultTheme}
onChange={() => handleDefaultThemeChange(option.id)}
/>
</TableCell>
<TableCell>
<HexColorInput
required
currentColor={option.config.light.palette.primary.main}
onColorChange={(color) => handleColorChange(option.id, "primary", "light", color)}
/>
</TableCell>
<TableCell>
<HexColorInput
currentColor={option.config.light.palette.secondary.main}
onColorChange={(color) => handleColorChange(option.id, "secondary", "light", color)}
/>
</TableCell>
<TableCell>
<HexColorInput
currentColor={option.config.dark.palette.primary.main}
onColorChange={(color) => handleColorChange(option.id, "primary", "dark", color)}
/>
</TableCell>
<TableCell>
<HexColorInput
currentColor={option.config.dark.palette.secondary.main}
onColorChange={(color) => handleColorChange(option.id, "secondary", "dark", color)}
/>
</TableCell>
<TableCell align="right">
<IconButton size="small" onClick={() => handleEdit(option.id)}>
<Edit fontSize="small" />
</IconButton>
<IconButton
size="small"
onClick={() => handleDelete(option.id)}
disabled={optionsArray.length === 1 || option.id === defaultTheme}
>
<Delete fontSize="small" />
</IconButton>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</TableContainer>
)}
<SecondaryButton variant="contained" startIcon={<Add />} onClick={handleAdd} sx={{ mt: 2 }}>
{t("settings.addThemeOption")}
</SecondaryButton>
{editingOption && (
<ThemeOptionEditDialog
open={isDialogOpen}
onClose={handleDialogClose}
id={editingOption.id}
config={JSON.stringify(editingOption.config, null, 2)}
onSave={handleDialogSave}
/>
)}
</Box>
);
};
export default ThemeOptions;

View File

@@ -0,0 +1,139 @@
import { Trans, useTranslation } from "react-i18next";
import { FormControl, Link, Stack, ListItemText } from "@mui/material";
import SettingForm from "../../../Pages/Setting/SettingForm.tsx";
import { DenseFilledTextField, DenseSelect } from "../../../Common/StyledComponents.tsx";
import { SquareMenuItem } from "../../../FileManager/ContextMenu/ContextMenu.tsx";
import * as React from "react";
import { NoMarginHelperText } from "../Settings.tsx";
export interface CapCaptchaProps {
values: {
[key: string]: string;
};
setSettings: (settings: { [key: string]: string }) => void;
}
const CapCaptcha = ({ values, setSettings }: CapCaptchaProps) => {
const { t } = useTranslation("dashboard");
return (
<Stack spacing={3}>
<SettingForm title={t("settings.capInstanceURL")} lgWidth={5}>
<FormControl fullWidth>
<DenseFilledTextField
value={values.captcha_cap_instance_url}
onChange={(e) =>
setSettings({
captcha_cap_instance_url: e.target.value,
})
}
placeholder="https://cap.example.com"
required
/>
<NoMarginHelperText>
<Trans
i18nKey="settings.capInstanceURLDes"
ns={"dashboard"}
components={[<Link key={0} href={"https://capjs.js.org/guide/standalone/"} target={"_blank"} />]}
/>
</NoMarginHelperText>
</FormControl>
</SettingForm>
<SettingForm title={t("settings.capSiteKey")} lgWidth={5}>
<FormControl fullWidth>
<DenseFilledTextField
value={values.captcha_cap_site_key}
onChange={(e) =>
setSettings({
captcha_cap_site_key: e.target.value,
})
}
required
/>
<NoMarginHelperText>
<Trans
i18nKey="settings.capSiteKeyDes"
ns={"dashboard"}
components={[<Link key={0} href={"https://capjs.js.org/guide/standalone/"} target={"_blank"} />]}
/>
</NoMarginHelperText>
</FormControl>
</SettingForm>
<SettingForm title={t("settings.capSecretKey")} lgWidth={5}>
<FormControl fullWidth>
<DenseFilledTextField
value={values.captcha_cap_secret_key}
onChange={(e) =>
setSettings({
captcha_cap_secret_key: e.target.value,
})
}
type="password"
required
/>
<NoMarginHelperText>
<Trans
i18nKey="settings.capSecretKeyDes"
ns={"dashboard"}
components={[<Link key={0} href={"https://capjs.js.org/guide/standalone/"} target={"_blank"} />]}
/>
</NoMarginHelperText>
</FormControl>
</SettingForm>
<SettingForm title={t("settings.capAssetServer")} lgWidth={5}>
<FormControl>
<DenseSelect
value={values.captcha_cap_asset_server || "jsdelivr"}
onChange={(e) =>
setSettings({
captcha_cap_asset_server: e.target.value,
})
}
>
<SquareMenuItem value="jsdelivr">
<ListItemText
slotProps={{
primary: { variant: "body2" },
}}
>
{t("settings.capAssetServerJsdelivr")}
</ListItemText>
</SquareMenuItem>
<SquareMenuItem value="unpkg">
<ListItemText
slotProps={{
primary: { variant: "body2" },
}}
>
{t("settings.capAssetServerUnpkg")}
</ListItemText>
</SquareMenuItem>
<SquareMenuItem value="instance">
<ListItemText
slotProps={{
primary: { variant: "body2" },
}}
>
{t("settings.capAssetServerInstance")}
</ListItemText>
</SquareMenuItem>
</DenseSelect>
<NoMarginHelperText>
<Trans
i18nKey="settings.capAssetServerDes"
ns={"dashboard"}
components={[
<Link
key={0}
href={"https://capjs.js.org/guide/standalone/options.html#asset-server"}
target={"_blank"}
/>,
]}
/>
</NoMarginHelperText>
</FormControl>
</SettingForm>
</Stack>
);
};
export default CapCaptcha;

View File

@@ -0,0 +1,158 @@
import { Box, Collapse, FormControl, FormControlLabel, ListItemText, Stack, Switch, Typography } from "@mui/material";
import { useContext } from "react";
import { useTranslation } from "react-i18next";
import { CaptchaType } from "../../../../api/site.ts";
import { isTrueVal } from "../../../../session/utils.ts";
import { DenseSelect } from "../../../Common/StyledComponents.tsx";
import { SquareMenuItem } from "../../../FileManager/ContextMenu/ContextMenu.tsx";
import SettingForm from "../../../Pages/Setting/SettingForm.tsx";
import { NoMarginHelperText, SettingSection, SettingSectionContent } from "../Settings.tsx";
import { SettingContext } from "../SettingWrapper.tsx";
import CapCaptcha from "./CapCaptcha.tsx";
import GraphicCaptcha from "./GraphicCaptcha.tsx";
import ReCaptcha from "./ReCaptcha.tsx";
import TurnstileCaptcha from "./TurnstileCaptcha.tsx";
const Captcha = () => {
const { t } = useTranslation("dashboard");
const { formRef, setSettings, values } = useContext(SettingContext);
return (
<Box component={"form"} ref={formRef} onSubmit={(e) => e.preventDefault()}>
<Stack spacing={5}>
<SettingSection>
<Typography variant="h6" gutterBottom>
{t("nav.captcha")}
</Typography>
<SettingSectionContent>
<SettingForm lgWidth={5}>
<FormControl fullWidth>
<FormControlLabel
control={
<Switch
checked={isTrueVal(values.login_captcha)}
onChange={(e) =>
setSettings({
login_captcha: e.target.checked ? "1" : "0",
})
}
/>
}
label={t("settings.captchaForLogin")}
/>
<NoMarginHelperText>{t("settings.captchaForLoginDes")}</NoMarginHelperText>
</FormControl>
</SettingForm>
<SettingForm lgWidth={5}>
<FormControl fullWidth>
<FormControlLabel
control={
<Switch
checked={isTrueVal(values.reg_captcha)}
onChange={(e) =>
setSettings({
reg_captcha: e.target.checked ? "1" : "0",
})
}
/>
}
label={t("settings.captchaForSignup")}
/>
<NoMarginHelperText>{t("settings.captchaForSignupDes")}</NoMarginHelperText>
</FormControl>
</SettingForm>
<SettingForm lgWidth={5}>
<FormControl fullWidth>
<FormControlLabel
control={
<Switch
checked={isTrueVal(values.forget_captcha)}
onChange={(e) =>
setSettings({
forget_captcha: e.target.checked ? "1" : "0",
})
}
/>
}
label={t("settings.captchaForReset")}
/>
<NoMarginHelperText>{t("settings.captchaForResetDes")}</NoMarginHelperText>
</FormControl>
</SettingForm>
</SettingSectionContent>
</SettingSection>
<SettingSection>
<Typography variant="h6" gutterBottom>
{t("settings.captchaType")}
</Typography>
<SettingSectionContent>
<SettingForm title={t("settings.captchaType")} lgWidth={5}>
<FormControl>
<DenseSelect
onChange={(e) =>
setSettings({
captcha_type: e.target.value as string,
})
}
value={values.captcha_type}
>
<SquareMenuItem value={CaptchaType.NORMAL}>
<ListItemText
slotProps={{
primary: { variant: "body2" },
}}
>
{t("settings.plainCaptcha")}
</ListItemText>
</SquareMenuItem>
<SquareMenuItem value={CaptchaType.RECAPTCHA}>
<ListItemText
slotProps={{
primary: { variant: "body2" },
}}
>
{t("settings.reCaptchaV2")}
</ListItemText>
</SquareMenuItem>
<SquareMenuItem value={CaptchaType.TURNSTILE}>
<ListItemText
slotProps={{
primary: { variant: "body2" },
}}
>
{t("settings.turnstile")}
</ListItemText>
</SquareMenuItem>
<SquareMenuItem value={CaptchaType.CAP}>
<ListItemText
slotProps={{
primary: { variant: "body2" },
}}
>
{t("settings.cap")}
</ListItemText>
</SquareMenuItem>
</DenseSelect>
<NoMarginHelperText>{t("settings.captchaTypeDes")}</NoMarginHelperText>
</FormControl>
</SettingForm>
<Collapse in={values.captcha_type === CaptchaType.NORMAL} unmountOnExit>
<GraphicCaptcha setSettings={setSettings} values={values} />
</Collapse>
<Collapse in={values.captcha_type === CaptchaType.RECAPTCHA} unmountOnExit>
<ReCaptcha setSettings={setSettings} values={values} />
</Collapse>
<Collapse in={values.captcha_type === CaptchaType.TURNSTILE} unmountOnExit>
<TurnstileCaptcha setSettings={setSettings} values={values} />
</Collapse>
<Collapse in={values.captcha_type === CaptchaType.CAP} unmountOnExit>
<CapCaptcha setSettings={setSettings} values={values} />
</Collapse>
</SettingSectionContent>
</SettingSection>
</Stack>
</Box>
);
};
export default Captcha;

View File

@@ -0,0 +1,118 @@
import { Collapse, FormControl, FormControlLabel, ListItemText, Stack, Switch } from "@mui/material";
import { useTranslation } from "react-i18next";
import { isTrueVal } from "../../../../session/utils.ts";
import { DenseFilledTextField, DenseSelect } from "../../../Common/StyledComponents.tsx";
import { SquareMenuItem } from "../../../FileManager/ContextMenu/ContextMenu.tsx";
import SettingForm from "../../../Pages/Setting/SettingForm.tsx";
import { NoMarginHelperText } from "../Settings.tsx";
export interface GraphicCaptchaProps {
values: {
[key: string]: string;
};
setSettings: (settings: { [key: string]: string }) => void;
}
const GraphicCaptcha = ({ values, setSettings }: GraphicCaptchaProps) => {
const { t } = useTranslation("dashboard");
return (
<Stack spacing={3}>
<SettingForm title={t("settings.captchaMode")} lgWidth={5}>
<FormControl>
<DenseSelect
onChange={(e) =>
setSettings({
captcha_mode: e.target.value as string,
})
}
value={values.captcha_mode}
>
{["captchaModeNumber", "captchaModeLetter", "captchaModeMath", "captchaModeNumberLetter"].map((k, i) => (
<SquareMenuItem key={k} value={i.toString()}>
<ListItemText
slotProps={{
primary: { variant: "body2" },
}}
>
{t(`settings.${k}`)}
</ListItemText>
</SquareMenuItem>
))}
</DenseSelect>
</FormControl>
</SettingForm>
<Collapse in={values.captcha_mode !== "2"} unmountOnExit>
<SettingForm title={t("settings.captchaLength")} lgWidth={5}>
<FormControl>
<DenseFilledTextField
value={values.captcha_CaptchaLen}
slotProps={{
htmlInput: {
type: "number",
min: 1,
max: 10,
},
}}
onChange={(e) =>
setSettings({
captcha_CaptchaLen: e.target.value,
})
}
/>
<NoMarginHelperText>{t("settings.captchaLengthDes")}</NoMarginHelperText>
</FormControl>
</SettingForm>
</Collapse>
{[
{
name: "complexOfNoiseText",
field: "captcha_ComplexOfNoiseText",
},
{
name: "complexOfNoiseDot",
field: "captcha_ComplexOfNoiseDot",
},
{
name: "showHollowLine",
field: "captcha_IsShowHollowLine",
},
{
name: "showNoiseDot",
field: "captcha_IsShowNoiseDot",
},
{
name: "showNoiseText",
field: "captcha_IsShowNoiseText",
},
{
name: "showSlimeLine",
field: "captcha_IsShowSlimeLine",
},
{
name: "showSineLine",
field: "captcha_IsShowSineLine",
},
].map((v) => (
<SettingForm lgWidth={5}>
<FormControl fullWidth>
<FormControlLabel
control={
<Switch
checked={isTrueVal(values[v.field])}
onChange={(e) =>
setSettings({
[v.field]: e.target.checked ? "1" : "0",
})
}
/>
}
label={t(`settings.${v.name}`)}
/>
</FormControl>
</SettingForm>
))}
</Stack>
);
};
export default GraphicCaptcha;

View File

@@ -0,0 +1,63 @@
import { Trans, useTranslation } from "react-i18next";
import { FormControl, Link, Stack } from "@mui/material";
import SettingForm from "../../../Pages/Setting/SettingForm.tsx";
import { DenseFilledTextField } from "../../../Common/StyledComponents.tsx";
import * as React from "react";
import { NoMarginHelperText } from "../Settings.tsx";
export interface ReCaptchaProps {
values: {
[key: string]: string;
};
setSettings: (settings: { [key: string]: string }) => void;
}
const ReCaptcha = ({ values, setSettings }: ReCaptchaProps) => {
const { t } = useTranslation("dashboard");
return (
<Stack spacing={3}>
<SettingForm title={t("settings.siteKey")} lgWidth={5}>
<FormControl fullWidth>
<DenseFilledTextField
value={values.captcha_ReCaptchaKey}
onChange={(e) =>
setSettings({
captcha_ReCaptchaKey: e.target.value,
})
}
required
/>
<NoMarginHelperText>
<Trans
i18nKey="settings.siteKeyDes"
ns={"dashboard"}
components={[<Link key={0} href={"https://www.google.com/recaptcha/admin/create"} target={"_blank"} />]}
/>
</NoMarginHelperText>
</FormControl>
</SettingForm>
<SettingForm title={t("settings.siteSecret")} lgWidth={5}>
<FormControl fullWidth>
<DenseFilledTextField
value={values.captcha_ReCaptchaSecret}
onChange={(e) =>
setSettings({
captcha_ReCaptchaSecret: e.target.value,
})
}
required
/>
<NoMarginHelperText>
<Trans
i18nKey="settings.siteSecretDes"
ns={"dashboard"}
components={[<Link key={0} href={"https://www.google.com/recaptcha/admin/create"} target={"_blank"} />]}
/>
</NoMarginHelperText>
</FormControl>
</SettingForm>
</Stack>
);
};
export default ReCaptcha;

View File

@@ -0,0 +1,63 @@
import { Trans, useTranslation } from "react-i18next";
import { FormControl, Link, Stack } from "@mui/material";
import SettingForm from "../../../Pages/Setting/SettingForm.tsx";
import { DenseFilledTextField } from "../../../Common/StyledComponents.tsx";
import * as React from "react";
import { NoMarginHelperText } from "../Settings.tsx";
export interface TurnstileCaptchaProps {
values: {
[key: string]: string;
};
setSettings: (settings: { [key: string]: string }) => void;
}
const Turnstile = ({ values, setSettings }: TurnstileCaptchaProps) => {
const { t } = useTranslation("dashboard");
return (
<Stack spacing={3}>
<SettingForm title={t("settings.turnstileSiteKey")} lgWidth={5}>
<FormControl fullWidth>
<DenseFilledTextField
value={values.captcha_turnstile_site_key}
onChange={(e) =>
setSettings({
captcha_turnstile_site_key: e.target.value,
})
}
required
/>
<NoMarginHelperText>
<Trans
i18nKey="settings.siteKeyDes"
ns={"dashboard"}
components={[<Link key={0} href={"https://dash.cloudflare.com/"} target={"_blank"} />]}
/>
</NoMarginHelperText>
</FormControl>
</SettingForm>
<SettingForm title={t("settings.turnstileSiteKSecret")} lgWidth={5}>
<FormControl fullWidth>
<DenseFilledTextField
value={values.captcha_turnstile_site_secret}
onChange={(e) =>
setSettings({
captcha_turnstile_site_secret: e.target.value,
})
}
required
/>
<NoMarginHelperText>
<Trans
i18nKey="settings.siteSecretDes"
ns={"dashboard"}
components={[<Link key={0} href={"https://dash.cloudflare.com/"} target={"_blank"} />]}
/>
</NoMarginHelperText>
</FormControl>
</SettingForm>
</Stack>
);
};
export default Turnstile;

View File

@@ -0,0 +1,205 @@
import { Box, DialogContent, FormControl, FormControlLabel, Stack, Switch, Typography } from "@mui/material";
import { useSnackbar } from "notistack";
import { useContext, useState } from "react";
import { useTranslation } from "react-i18next";
import { sendTestSMTP } from "../../../../api/api.ts";
import { useAppDispatch } from "../../../../redux/hooks.ts";
import { isTrueVal } from "../../../../session/utils.ts";
import { DefaultCloseAction } from "../../../Common/Snackbar/snackbar.tsx";
import { DenseFilledTextField, SecondaryButton } from "../../../Common/StyledComponents.tsx";
import DraggableDialog, { StyledDialogContentText } from "../../../Dialogs/DraggableDialog.tsx";
import MailOutlined from "../../../Icons/MailOutlined.tsx";
import SettingForm from "../../../Pages/Setting/SettingForm.tsx";
import { NoMarginHelperText, SettingSection, SettingSectionContent } from "../Settings.tsx";
import { SettingContext } from "../SettingWrapper.tsx";
import EmailTemplates from "./EmailTemplates.tsx";
const Email = () => {
const { t } = useTranslation("dashboard");
const dispatch = useAppDispatch();
const { enqueueSnackbar } = useSnackbar();
const { formRef, setSettings, values } = useContext(SettingContext);
const [testEmailOpen, setTestEmailOpen] = useState(false);
const [testEmailAddress, setTestEmailAddress] = useState("");
const [sending, setSending] = useState(false);
const handleTestEmail = async () => {
setSending(true);
try {
await dispatch(
sendTestSMTP({
to: testEmailAddress,
settings: values,
}),
);
enqueueSnackbar({
message: t("settings.testMailSent"),
variant: "success",
action: DefaultCloseAction,
});
setTestEmailOpen(false);
} catch (error) {
} finally {
setSending(false);
}
};
return (
<Box component={"form"} ref={formRef} onSubmit={(e) => e.preventDefault()}>
<Stack spacing={5}>
<DraggableDialog
dialogProps={{
open: testEmailOpen,
onClose: () => setTestEmailOpen(false),
}}
loading={sending}
showActions
showCancel
onAccept={handleTestEmail}
title={t("settings.testSMTPSettings")}
>
<DialogContent>
<StyledDialogContentText sx={{ mb: 2 }}>{t("settings.testSMTPTooltip")}</StyledDialogContentText>
<SettingForm title={t("settings.recipient")} lgWidth={12}>
<DenseFilledTextField
required
autoFocus
value={testEmailAddress}
onChange={(e) => setTestEmailAddress(e.target.value)}
type="email"
fullWidth
/>
</SettingForm>
</DialogContent>
</DraggableDialog>
<SettingSection>
<Typography variant="h6" gutterBottom>
{t("settings.smtp")}
</Typography>
<SettingSectionContent>
<SettingForm title={t("settings.senderName")} lgWidth={5}>
<FormControl fullWidth>
<DenseFilledTextField
required
value={values.fromName ?? ""}
onChange={(e) => setSettings({ fromName: e.target.value })}
/>
<NoMarginHelperText>{t("settings.senderNameDes")}</NoMarginHelperText>
</FormControl>
</SettingForm>
<SettingForm title={t("settings.senderAddress")} lgWidth={5}>
<FormControl fullWidth>
<DenseFilledTextField
type="email"
required
value={values.fromAdress ?? ""}
onChange={(e) => setSettings({ fromAdress: e.target.value })}
/>
<NoMarginHelperText>{t("settings.senderAddressDes")}</NoMarginHelperText>
</FormControl>
</SettingForm>
<SettingForm title={t("settings.smtpServer")} lgWidth={5}>
<FormControl fullWidth>
<DenseFilledTextField
required
value={values.smtpHost ?? ""}
onChange={(e) => setSettings({ smtpHost: e.target.value })}
/>
<NoMarginHelperText>{t("settings.smtpServerDes")}</NoMarginHelperText>
</FormControl>
</SettingForm>
<SettingForm title={t("settings.smtpPort")} lgWidth={5}>
<FormControl fullWidth>
<DenseFilledTextField
type="number"
required
inputProps={{ min: 1, step: 1 }}
value={values.smtpPort ?? ""}
onChange={(e) => setSettings({ smtpPort: e.target.value })}
/>
<NoMarginHelperText>{t("settings.smtpPortDes")}</NoMarginHelperText>
</FormControl>
</SettingForm>
<SettingForm title={t("settings.smtpUsername")} lgWidth={5}>
<FormControl fullWidth>
<DenseFilledTextField
required
value={values.smtpUser ?? ""}
onChange={(e) => setSettings({ smtpUser: e.target.value })}
/>
<NoMarginHelperText>{t("settings.smtpUsernameDes")}</NoMarginHelperText>
</FormControl>
</SettingForm>
<SettingForm title={t("settings.smtpPassword")} lgWidth={5}>
<FormControl fullWidth>
<DenseFilledTextField
type="password"
required
value={values.smtpPass ?? ""}
onChange={(e) => setSettings({ smtpPass: e.target.value })}
/>
<NoMarginHelperText>{t("settings.smtpPasswordDes")}</NoMarginHelperText>
</FormControl>
</SettingForm>
<SettingForm title={t("settings.replyToAddress")} lgWidth={5}>
<FormControl fullWidth>
<DenseFilledTextField
required
value={values.replyTo ?? ""}
onChange={(e) => setSettings({ replyTo: e.target.value })}
/>
<NoMarginHelperText>{t("settings.replyToAddressDes")}</NoMarginHelperText>
</FormControl>
</SettingForm>
<SettingForm lgWidth={5}>
<FormControl fullWidth>
<FormControlLabel
control={
<Switch
checked={isTrueVal(values.smtpEncryption)}
onChange={(e) => setSettings({ smtpEncryption: e.target.checked ? "1" : "0" })}
/>
}
label={t("settings.enforceSSL")}
/>
<NoMarginHelperText>{t("settings.enforceSSLDes")}</NoMarginHelperText>
</FormControl>
</SettingForm>
<SettingForm title={t("settings.smtpTTL")} lgWidth={5}>
<FormControl fullWidth>
<DenseFilledTextField
type="number"
required
inputProps={{ min: 1, step: 1 }}
value={values.mail_keepalive ?? "30"}
onChange={(e) => setSettings({ mail_keepalive: e.target.value })}
/>
<NoMarginHelperText>{t("settings.smtpTTLDes")}</NoMarginHelperText>
</FormControl>
</SettingForm>
<Box display="flex" gap={2} mt={2}>
<SecondaryButton variant="contained" startIcon={<MailOutlined />} onClick={() => setTestEmailOpen(true)}>
{t("settings.sendTestEmail")}
</SecondaryButton>
</Box>
</SettingSectionContent>
</SettingSection>
{/* Email Templates Section */}
<EmailTemplates />
</Stack>
</Box>
);
};
export default Email;

View File

@@ -0,0 +1,307 @@
import { Delete } from "@mui/icons-material";
import {
Box,
Button,
DialogContent,
FormControl,
Link,
ListItemText,
Tab,
Tabs,
Typography,
useTheme,
} from "@mui/material";
import React, { lazy, Suspense, useCallback, useEffect, useRef, useState } from "react";
import { Trans, useTranslation } from "react-i18next";
import { languages } from "../../../../i18n.ts";
import CircularProgress from "../../../Common/CircularProgress.tsx";
import { DenseFilledTextField, DenseSelect, SecondaryButton } from "../../../Common/StyledComponents.tsx";
import Add from "../../../Icons/Add";
import DraggableDialog from "../../../Dialogs/DraggableDialog.tsx";
import { SquareMenuItem } from "../../../FileManager/ContextMenu/ContextMenu.tsx";
import SettingForm from "../../../Pages/Setting/SettingForm.tsx";
import MagicVarDialog from "../../Common/MagicVarDialog.tsx";
import { NoMarginHelperText } from "../Settings.tsx";
const MonacoEditor = lazy(() => import("../../../Viewers/CodeViewer/MonacoEditor.tsx"));
interface TemplateItem {
language: string;
title: string;
body: string;
}
interface EmailTemplateEditorProps {
value: string;
onChange: (value: string) => void;
templateType: string;
magicVars: MagicVar[];
}
const EmailTemplateEditor: React.FC<EmailTemplateEditorProps> = ({ value, onChange, templateType, magicVars }) => {
const theme = useTheme();
const { t } = useTranslation("dashboard");
const [templates, setTemplates] = useState<TemplateItem[]>([]);
const [currentTab, setCurrentTab] = useState(0);
const [addLanguageOpen, setAddLanguageOpen] = useState(false);
const [newLanguageCode, setNewLanguageCode] = useState("");
const isUpdatingFromProp = useRef(false);
const [magicVarOpen, setMagicVarOpen] = useState(false);
// Parse templates when component mounts or value changes
useEffect(() => {
try {
isUpdatingFromProp.current = true;
const parsedTemplates = value ? JSON.parse(value) : [];
setTemplates(parsedTemplates);
// If no templates, create a default English one
if (parsedTemplates.length === 0) {
setTemplates([{ language: "en-US", title: "", body: "" }]);
}
if (currentTab > parsedTemplates.length) {
setCurrentTab(0);
}
} catch (e) {
console.error("Failed to parse email template:", e);
setTemplates([{ language: "en-US", title: "", body: "" }]);
} finally {
// Use setTimeout to ensure this runs after React finishes the update
setTimeout(() => {
isUpdatingFromProp.current = true; // Prevent infinite loop
}, 0);
}
}, [value]);
// Update the parent component when templates change, but only from user interaction
useEffect(() => {
if (templates.length > 0 && !isUpdatingFromProp.current) {
onChange(JSON.stringify(templates));
}
}, [templates, onChange]);
const handleTabChange = (event: React.SyntheticEvent, newValue: number) => {
setCurrentTab(newValue);
};
const updateTemplate = (index: number, field: keyof TemplateItem, newValue: string) => {
isUpdatingFromProp.current = false; // Ensure this is a user interaction
const updatedTemplates = [...templates];
updatedTemplates[index] = {
...updatedTemplates[index],
[field]: newValue,
};
setTemplates(updatedTemplates);
};
const addNewLanguage = () => {
if (!newLanguageCode.trim()) return;
// Check if language already exists
const langTemplateIndex = templates.findIndex((l) => l.language === newLanguageCode);
if (langTemplateIndex !== -1) {
setNewLanguageCode("");
setAddLanguageOpen(false);
setCurrentTab(langTemplateIndex);
return;
}
// Add new language template
isUpdatingFromProp.current = false; // Ensure this is a user interaction
setTemplates([...templates, { language: newLanguageCode, title: "", body: "" }]);
// Reset and close dialog
setNewLanguageCode("");
setAddLanguageOpen(false);
// Switch to the new tab
setCurrentTab(templates.length);
};
const removeLanguage = (index: number) => {
isUpdatingFromProp.current = false; // Ensure this is a user interaction
const updatedTemplates = templates.filter((_, i) => i !== index);
setTemplates(updatedTemplates);
if (currentTab >= updatedTemplates.length) {
setCurrentTab(updatedTemplates.length - 1); // Move to the last tab if current is out of range
}
};
const setPreferredLanguage = (index: number) => {
isUpdatingFromProp.current = false; // Ensure this is a user interaction
setTemplates([templates[index], ...templates.filter((_, i) => i !== index)]);
setCurrentTab(0); // Switch to the first tab as the preferred language is now at the top
};
const openMagicVar = useCallback((e: React.MouseEvent<HTMLElement>) => {
setMagicVarOpen(true);
e.stopPropagation();
e.preventDefault();
}, []);
return (
<Box sx={{ width: "100%" }}>
<Box sx={{ borderBottom: 1, borderColor: "divider", display: "flex" }}>
<Tabs
value={currentTab}
onChange={handleTabChange}
variant="scrollable"
scrollButtons="auto"
sx={{ flexGrow: 1 }}
>
{templates.map((template, index) => {
const lang = languages.find((l) => l.code === template.language);
return <Tab key={index} label={lang ? lang.displayName : template.language} />;
})}
</Tabs>
<Button
startIcon={<Add />}
onClick={() => setAddLanguageOpen(true)}
sx={{ minWidth: "auto", mt: 0.5, mb: 0.5 }}
>
{t("settings.addLanguage")}
</Button>
</Box>
{templates.map((template, index) => (
<Box
key={index}
role="tabpanel"
hidden={currentTab !== index}
id={`template-tabpanel-${index}`}
aria-labelledby={`template-tab-${index}`}
sx={{ mt: 2 }}
>
{currentTab === index && (
<Box>
<FormControl fullWidth sx={{ mb: 2 }}>
<Typography variant="subtitle2" sx={{ mb: index === 0 ? 0 : 1 }}>
{t("settings.preferredLanguage")}
</Typography>
{index != 0 && (
<Box>
<SecondaryButton
variant="contained"
onClick={() => (setPreferredLanguage(index))}
>
{t("settings.setAsPreferredLanguage")}
</SecondaryButton>
</Box>
)}
<NoMarginHelperText>
{t(index === 0 ? "settings.alreadyAsPreferredLanguageDes" : "settings.setAsPreferredLanguageDes")}
</NoMarginHelperText>
</FormControl>
<FormControl fullWidth sx={{ mb: 2 }}>
<Typography variant="subtitle2" sx={{ mb: 1 }}>
{t("settings.emailSubject")}
</Typography>
<DenseFilledTextField
fullWidth
value={template.title}
onChange={(e) => updateTemplate(index, "title", e.target.value || "")}
/>
<NoMarginHelperText>
<Trans
i18nKey={"settings.emailSubjectDes"}
ns={"dashboard"}
components={[<Link onClick={openMagicVar} href={"#"} />]}
/>
</NoMarginHelperText>
</FormControl>
<Typography variant="subtitle2" sx={{ mb: 1 }}>
{t("settings.emailBody")}
</Typography>
<Box sx={{ height: 400 }}>
<Suspense fallback={<CircularProgress />}>
<MonacoEditor
theme={theme.palette.mode === "dark" ? "vs-dark" : "vs"}
language="html"
value={template.body}
onChange={(value) => updateTemplate(index, "body", value || "")}
height="400px"
minHeight="400px"
options={{
wordWrap: "on",
minimap: { enabled: false },
scrollBeyondLastLine: false,
}}
/>
</Suspense>
</Box>
<NoMarginHelperText sx={{ mb: 2 }}>
<Trans
i18nKey={"settings.emailBodyDes"}
ns={"dashboard"}
components={[<Link onClick={openMagicVar} href={"#"} />]}
/>
</NoMarginHelperText>
<FormControl fullWidth>
<Typography variant="subtitle2" sx={{ mb: index === 0 ? 0 : 1 }}>
{t("settings.removeLanguage")}
</Typography>
{index != 0 && (
<Box>
<Button
startIcon={<Delete />}
variant="contained"
color="error"
onClick={() => removeLanguage(index)}
>
{t("settings.removeLanguageBtn")}
</Button>
</Box>
)}
{index === 0 && (
<NoMarginHelperText>
{t("settings.cannotRemovePreferredLanguageDes")}
</NoMarginHelperText>
)}
</FormControl>
</Box>
)}
</Box>
))}
{/* Add Language Dialog */}
<DraggableDialog
title={t("settings.addLanguage")}
showActions
showCancel
onAccept={addNewLanguage}
dialogProps={{
maxWidth: "xs",
fullWidth: true,
open: addLanguageOpen,
onClose: () => setAddLanguageOpen(false),
}}
>
<DialogContent>
<SettingForm title={t("application:setting.language")} lgWidth={12}>
<FormControl fullWidth>
<DenseSelect value={newLanguageCode} onChange={(e) => setNewLanguageCode(e.target.value as string)}>
{languages.map((l) => (
<SquareMenuItem value={l.code}>
<ListItemText
slotProps={{
primary: { variant: "body2" },
}}
>
{l.displayName}
</ListItemText>
</SquareMenuItem>
))}
</DenseSelect>
<NoMarginHelperText>{t("settings.languageCodeDes")}</NoMarginHelperText>
</FormControl>
</SettingForm>
</DialogContent>
</DraggableDialog>
<MagicVarDialog open={magicVarOpen} vars={magicVars} onClose={() => setMagicVarOpen(false)} />
</Box>
);
};
export default EmailTemplateEditor;

View File

@@ -0,0 +1,176 @@
import { ExpandMoreRounded } from "@mui/icons-material";
import { Box, Typography } from "@mui/material";
import AccordionDetails from "@mui/material/AccordionDetails";
import React, { useContext, useState } from "react";
import { useTranslation } from "react-i18next";
import SettingForm, { ProChip } from "../../../Pages/Setting/SettingForm.tsx";
import { MagicVar } from "../../Common/MagicVarDialog.tsx";
import ProDialog from "../../Common/ProDialog.tsx";
import { SettingContext } from "../SettingWrapper.tsx";
import { SettingSection, SettingSectionContent } from "../Settings.tsx";
import { AccordionSummary, StyledAccordion } from "../UserSession/SSOSettings.tsx";
import EmailTemplateEditor from "./EmailTemplateEditor.tsx";
interface EmailTemplate {
key: string;
title: string;
description: string;
magicVars: MagicVar[];
pro: boolean;
}
const commonMagicVars: MagicVar[] = [
{
value: "settings.mainTitle",
name: "{{ .CommonContext.SiteBasic.Name }}",
example: "Cloudreve",
},
{
value: "settings.siteDescription",
name: "{{ .CommonContext.SiteBasic.Description }}",
example: "Another Cloudreve instance",
},
{
value: "settings.siteID",
name: "{{ .CommonContext.SiteBasic.ID }}",
example: "123e4567-e89b-12d3-a456-426614174000",
},
{
value: "settings.logo",
name: "{{ .CommonContext.Logo.Normal }}",
example: "https://cloudreve.org/logo.svg",
},
{
value: "settings.logo",
name: "{{ .CommonContext.Logo.Light }}",
example: "https://cloudreve.org/logo_light.svg",
},
{
value: "settings.siteURL",
name: "{{ .CommonContext.SiteUrl }}",
example: "https://cloudreve.org",
},
];
const userMagicVars: MagicVar[] = [
{
value: "policy.magicVar.uid",
name: "{{ .User.ID }}",
example: "2534",
},
{
value: "application:login.email",
name: "{{ .User.Email }}",
example: "example@cloudreve.org",
},
{
value: "application:setting.nickname",
name: "{{ .User.Nick }}",
example: "Aaron Liu",
},
{
value: "user.usedStorage",
name: "{{ .User.Storage }}",
example: "123221000",
},
];
const EmailTemplates: React.FC = () => {
const { t } = useTranslation("dashboard");
const { setSettings, values } = useContext(SettingContext);
const [proOpen, setProOpen] = useState(false);
// Template setting keys
const templateSettings = [
{
key: "mail_receipt_template",
title: "receiptEmailTemplate",
description: "receiptEmailTemplateDes",
pro: true,
},
{
key: "mail_activation_template",
title: "activationEmailTemplate",
description: "activationEmailTemplateDes",
magicVars: [
...commonMagicVars,
...userMagicVars,
{
value: "settings.activateUrl",
name: "{{ .Url }}",
example: "https://cloudreve.org/activate",
},
],
},
{
key: "mail_exceed_quota_template",
title: "quotaExceededEmailTemplate",
description: "quotaExceededEmailTemplateDes",
pro: true,
},
{
key: "mail_reset_template",
title: "resetPasswordEmailTemplate",
description: "resetPasswordEmailTemplateDes",
magicVars: [
...commonMagicVars,
...userMagicVars,
{
value: "settings.resetUrl",
name: "{{ .Url }}",
example: "https://cloudreve.org/reset",
},
],
},
];
const handleProClick = (e: React.MouseEvent<HTMLDivElement>) => {
e.preventDefault();
setProOpen(true);
};
return (
<SettingSection>
<ProDialog open={proOpen} onClose={() => setProOpen(false)} />
<Typography variant="h6" gutterBottom>
{t("settings.emailTemplates")}
</Typography>
<SettingSectionContent>
<Box>
{templateSettings.map((template) => (
<StyledAccordion
disableGutters
onClick={template.pro ? handleProClick : undefined}
key={template.key}
expanded={template.pro ? false : undefined}
TransitionProps={{ unmountOnExit: true }}
>
<AccordionSummary expandIcon={<ExpandMoreRounded />}>
<Typography>
{t("settings." + template.title)}
{template.pro && <ProChip label="Pro" color="primary" size="small" />}
</Typography>
</AccordionSummary>
<AccordionDetails sx={{ display: "block" }}>
<Typography variant="body2" color="textSecondary" sx={{ mb: 2 }}>
{t("settings." + template.description)}{" "}
</Typography>
<SettingForm noContainer lgWidth={12}>
<Box sx={{ width: "100%" }}>
<EmailTemplateEditor
magicVars={template.magicVars || []}
value={values[template.key] || "[]"}
onChange={(value) => setSettings({ [template.key]: value })}
templateType={template.key}
/>
</Box>
</SettingForm>
</AccordionDetails>
</StyledAccordion>
))}
</Box>
</SettingSectionContent>
</SettingSection>
);
};
export default EmailTemplates;

View File

@@ -0,0 +1,199 @@
import {
Box,
Checkbox,
Divider,
FormControl,
FormControlLabel,
FormGroup,
Grid,
Stack,
Typography,
} from "@mui/material";
import { useContext, useState } from "react";
import { useTranslation } from "react-i18next";
import { AuditLogType } from "../../../../api/explorer";
import { ProChip } from "../../../Pages/Setting/SettingForm";
import ProDialog from "../../Common/ProDialog";
import { NoMarginHelperText, SettingSection, SettingSectionContent } from "../Settings";
import { SettingContext } from "../SettingWrapper";
// Categorize audit events
export const eventCategories = {
system: {
title: "settings.systemEvents",
description: "settings.systemEventsDes",
events: [AuditLogType.server_start],
},
user: {
title: "settings.userEvents",
description: "settings.userEventsDes",
events: [
AuditLogType.user_signup,
AuditLogType.user_activated,
AuditLogType.user_login,
AuditLogType.user_login_failed,
AuditLogType.user_token_refresh,
AuditLogType.user_changed,
AuditLogType.user_exceed_quota_notified,
AuditLogType.change_nick,
AuditLogType.change_avatar,
AuditLogType.change_password,
AuditLogType.enable_2fa,
AuditLogType.disable_2fa,
AuditLogType.add_passkey,
AuditLogType.remove_passkey,
AuditLogType.link_account,
AuditLogType.unlink_account,
AuditLogType.report_abuse,
],
},
file: {
title: "settings.fileEvents",
description: "settings.fileEventsDes",
events: [
AuditLogType.file_create,
AuditLogType.file_imported,
AuditLogType.file_rename,
AuditLogType.set_file_permission,
AuditLogType.entity_uploaded,
AuditLogType.entity_downloaded,
AuditLogType.copy_from,
AuditLogType.copy_to,
AuditLogType.move_to,
AuditLogType.delete_file,
AuditLogType.move_to_trash,
AuditLogType.update_metadata,
AuditLogType.get_direct_link,
AuditLogType.delete_direct_link,
AuditLogType.update_view,
],
},
share: {
title: "settings.shareEvents",
description: "settings.shareEventsDes",
events: [AuditLogType.share, AuditLogType.share_link_viewed, AuditLogType.edit_share, AuditLogType.delete_share],
},
version: {
title: "settings.versionEvents",
description: "settings.versionEventsDes",
events: [AuditLogType.set_current_version, AuditLogType.delete_version],
},
media: {
title: "settings.mediaEvents",
description: "settings.mediaEventsDes",
events: [AuditLogType.thumb_generated, AuditLogType.live_photo_uploaded],
},
filesystem: {
title: "settings.filesystemEvents",
description: "settings.filesystemEventsDes",
events: [AuditLogType.mount, AuditLogType.relocate, AuditLogType.create_archive, AuditLogType.extract_archive],
},
webdav: {
title: "settings.webdavEvents",
description: "settings.webdavEventsDes",
events: [
AuditLogType.webdav_login_failed,
AuditLogType.webdav_account_create,
AuditLogType.webdav_account_update,
AuditLogType.webdav_account_delete,
],
},
payment: {
title: "settings.paymentEvents",
description: "settings.paymentEventsDes",
events: [
AuditLogType.payment_created,
AuditLogType.points_change,
AuditLogType.payment_paid,
AuditLogType.payment_fulfilled,
AuditLogType.payment_fulfill_failed,
AuditLogType.storage_added,
AuditLogType.group_changed,
AuditLogType.membership_unsubscribe,
AuditLogType.redeem_gift_code,
],
},
email: {
title: "settings.emailEvents",
description: "settings.emailEventsDes",
events: [AuditLogType.email_sent],
},
};
// Get event name from AuditLogType
export const getEventName = (eventType: number): string => {
return Object.entries(AuditLogType).find(([_, value]) => value === eventType)?.[0] || `event_${eventType}`;
};
const Events = () => {
const { t } = useTranslation("dashboard");
const { formRef, setSettings, values } = useContext(SettingContext);
const [proOpen, setProOpen] = useState(false);
const handleProClick = (e: React.MouseEvent<HTMLDivElement>) => {
e.preventDefault();
setProOpen(true);
};
return (
<Box component={"form"} ref={formRef} onSubmit={(e) => e.preventDefault()}>
<ProDialog open={proOpen} onClose={() => setProOpen(false)} />
<Stack spacing={5}>
<SettingSection>
<Typography variant="h6" sx={{ display: "flex", alignItems: "center" }}>
{t("settings.auditLog")} <ProChip label="Pro" color="primary" size="small" />
</Typography>
<Typography variant="body2" color="text.secondary" sx={{ mb: 2 }}>
{t("settings.auditLogDes")}
</Typography>
{Object.entries(eventCategories).map(([categoryKey, category]) => (
<SettingSection key={categoryKey} onClick={handleProClick}>
<Box>
<Typography variant="subtitle1">{t(category.title)}</Typography>
<Typography variant="body2" color="text.secondary">
{t(category.description)}
</Typography>
</Box>
<SettingSectionContent>
<FormControl component="fieldset">
<FormGroup>
<FormControlLabel
slotProps={{
typography: {
variant: "body2",
},
}}
control={<Checkbox size={"small"} checked={false} />}
label={t("settings.toggleAll")}
/>
<NoMarginHelperText>{t("settings.toggleAllDes")}</NoMarginHelperText>
</FormGroup>
</FormControl>
<Grid container spacing={1}>
{category.events.map((eventType) => (
<Grid item xs={12} sm={6} md={4} lg={3} key={eventType}>
<FormControlLabel
slotProps={{
typography: {
variant: "body2",
},
}}
control={<Checkbox size={"small"} checked={false} />}
label={t(`settings.event.${getEventName(eventType)}`, getEventName(eventType))}
/>
</Grid>
))}
</Grid>
</SettingSectionContent>
<Divider sx={{ mt: 1 }} />
</SettingSection>
))}
</SettingSection>
</Stack>
</Box>
);
};
export default Events;

View File

@@ -0,0 +1,261 @@
import { ExpandMoreRounded } from "@mui/icons-material";
import { LoadingButton } from "@mui/lab";
import {
AccordionDetails,
Box,
FormControl,
FormControlLabel,
InputAdornment,
Switch,
Typography,
} from "@mui/material";
import { useSnackbar } from "notistack";
import * as React from "react";
import { useState } from "react";
import { useTranslation } from "react-i18next";
import { sendTestThumbGeneratorExecutable } from "../../../../api/api.ts";
import { useAppDispatch } from "../../../../redux/hooks.ts";
import { isTrueVal } from "../../../../session/utils.ts";
import SizeInput from "../../../Common/SizeInput.tsx";
import { DefaultCloseAction } from "../../../Common/Snackbar/snackbar.tsx";
import { DenseFilledTextField, StyledCheckbox } from "../../../Common/StyledComponents.tsx";
import SettingForm from "../../../Pages/Setting/SettingForm.tsx";
import { NoMarginHelperText, SettingSectionContent } from "../Settings.tsx";
import { AccordionSummary, StyledAccordion } from "../UserSession/SSOSettings.tsx";
export interface ExtractorsProps {
values: {
[key: string]: any;
};
setSetting: (v: { [key: string]: any }) => void;
}
interface ExtractorRenderProps {
name: string;
des: string;
enableFlag?: string;
executableSetting?: string;
maxSizeLocalSetting?: string;
maxSizeRemoteSetting?: string;
additionalSettings?: {
name: string;
label: string;
des: string;
type?: "switch";
}[];
}
const extractors: ExtractorRenderProps[] = [
{
name: "exif",
des: "exifDes",
enableFlag: "media_meta_exif",
maxSizeLocalSetting: "media_meta_exif_size_local",
maxSizeRemoteSetting: "media_meta_exif_size_remote",
additionalSettings: [
{
name: "media_meta_exif_brute_force",
label: "exifBruteForce",
des: "exifBruteForceDes",
type: "switch",
},
],
},
{
name: "music",
des: "musicDes",
enableFlag: "media_meta_music",
maxSizeLocalSetting: "media_meta_music_size_local",
maxSizeRemoteSetting: "media_exif_music_size_remote",
},
{
name: "ffprobe",
des: "ffprobeDes",
enableFlag: "media_meta_ffprobe",
executableSetting: "media_meta_ffprobe_path",
maxSizeLocalSetting: "media_meta_ffprobe_size_local",
maxSizeRemoteSetting: "media_meta_ffprobe_size_remote",
},
{
name: "geocoding",
des: "geocodingDes",
enableFlag: "media_meta_geocoding",
additionalSettings: [
{
name: "media_meta_geocoding_mapbox_ak",
label: "mapboxAK",
des: "mapboxAKDes",
},
],
},
];
const Extractors = ({ values, setSetting }: ExtractorsProps) => {
const { t } = useTranslation("dashboard");
const dispatch = useAppDispatch();
const [testing, setTesting] = useState(false);
const { enqueueSnackbar } = useSnackbar();
const handleEnableChange = (name: string) => (e: React.ChangeEvent<HTMLInputElement>) => {
setSetting({
[name]: e.target.checked ? "1" : "0",
});
const newValues = { ...values, [name]: e.target.checked ? "1" : "0" };
if (isTrueVal(newValues["media_meta_geocoding"]) && !isTrueVal(newValues["media_meta_exif"])) {
enqueueSnackbar({
message: t("settings.geocodingDependencyWarning"),
variant: "warning",
action: DefaultCloseAction,
});
}
};
const doTest = (name: string, executable: string) => {
setTesting(true);
dispatch(
sendTestThumbGeneratorExecutable({
name,
executable,
}),
)
.then((res) => {
enqueueSnackbar({
message: t("settings.executableTestSuccess", { version: res }),
variant: "success",
action: DefaultCloseAction,
});
})
.finally(() => {
setTesting(false);
});
};
return (
<Box>
{extractors.map((e) => (
<StyledAccordion key={e.name} disableGutters>
<AccordionSummary expandIcon={<ExpandMoreRounded />}>
<FormControlLabel
onClick={(event) => event.stopPropagation()}
onFocus={(event) => event.stopPropagation()}
control={
<StyledCheckbox
size={"small"}
checked={isTrueVal(values[e.enableFlag ?? ""])}
onChange={handleEnableChange(e.enableFlag ?? "")}
/>
}
label={t(`settings.${e.name}`)}
/>
</AccordionSummary>
<AccordionDetails sx={{ display: "block" }}>
<Typography color="textSecondary" variant={"body2"}>
{t(`settings.${e.des}`)}
</Typography>
<SettingSectionContent sx={{ mt: 2 }}>
{e.executableSetting && (
<SettingForm lgWidth={12} title={t("settings.executable")}>
<FormControl fullWidth>
<DenseFilledTextField
required
value={values[e.executableSetting]}
InputProps={{
endAdornment: (
<InputAdornment position="end">
<LoadingButton
onClick={() => doTest(e.name, values[e.executableSetting ?? ""])}
loading={testing}
color="primary"
>
<span>{t("settings.executableTest")}</span>
</LoadingButton>
</InputAdornment>
),
}}
onChange={(ev) =>
setSetting({
[e.executableSetting ?? ""]: ev.target.value,
})
}
/>
<NoMarginHelperText>{t("settings.executableDes")}</NoMarginHelperText>
</FormControl>
</SettingForm>
)}
{e.maxSizeLocalSetting && (
<SettingForm lgWidth={12} title={t("settings.maxSizeLocal")}>
<FormControl fullWidth>
<SizeInput
variant={"outlined"}
required
value={parseInt(values[e.maxSizeLocalSetting ?? ""]) ?? 0}
onChange={(v) =>
setSetting({
[e.maxSizeLocalSetting ?? ""]: v.toString(),
})
}
/>
<NoMarginHelperText>{t("settings.maxSizeLocalDes")}</NoMarginHelperText>
</FormControl>
</SettingForm>
)}
{e.maxSizeRemoteSetting && (
<SettingForm lgWidth={12} title={t("settings.maxSizeRemote")}>
<FormControl fullWidth>
<SizeInput
variant={"outlined"}
required
value={parseInt(values[e.maxSizeRemoteSetting ?? ""]) ?? 0}
onChange={(v) =>
setSetting({
[e.maxSizeRemoteSetting ?? ""]: v.toString(),
})
}
/>
<NoMarginHelperText>{t("settings.maxSizeRemoteDes")}</NoMarginHelperText>
</FormControl>
</SettingForm>
)}
{e.additionalSettings?.map((setting) => (
<SettingForm key={setting.name} lgWidth={12}>
<FormControl fullWidth>
{setting.type === "switch" ? (
<FormControlLabel
control={
<Switch
checked={isTrueVal(values[setting.name])}
onChange={(ev) =>
setSetting({
[setting.name]: ev.target.checked ? "1" : "0",
})
}
/>
}
label={t(`settings.${setting.label}`)}
/>
) : (
<DenseFilledTextField
label={t(`settings.${setting.label}`)}
required={isTrueVal(values[e.enableFlag ?? ""])}
value={values[setting.name]}
onChange={(ev) =>
setSetting({
[setting.name]: ev.target.value,
})
}
/>
)}
<NoMarginHelperText>{t(`settings.${setting.des}`)}</NoMarginHelperText>
</FormControl>
</SettingForm>
))}
</SettingSectionContent>
</AccordionDetails>
</StyledAccordion>
))}
</Box>
);
};
export default Extractors;

View File

@@ -0,0 +1,273 @@
import { ExpandMoreRounded } from "@mui/icons-material";
import { LoadingButton } from "@mui/lab";
import { AccordionDetails, Box, FormControl, FormControlLabel, InputAdornment, Typography } from "@mui/material";
import { useSnackbar } from "notistack";
import * as React from "react";
import { useState } from "react";
import { useTranslation } from "react-i18next";
import { sendTestThumbGeneratorExecutable } from "../../../../api/api.ts";
import { useAppDispatch } from "../../../../redux/hooks.ts";
import { isTrueVal } from "../../../../session/utils.ts";
import SizeInput from "../../../Common/SizeInput.tsx";
import { DefaultCloseAction } from "../../../Common/Snackbar/snackbar.tsx";
import { DenseFilledTextField, StyledCheckbox } from "../../../Common/StyledComponents.tsx";
import SettingForm from "../../../Pages/Setting/SettingForm.tsx";
import { NoMarginHelperText, SettingSectionContent } from "../Settings.tsx";
import { AccordionSummary, StyledAccordion } from "../UserSession/SSOSettings.tsx";
export interface GeneratorsProps {
values: {
[key: string]: any;
};
setSetting: (v: { [key: string]: any }) => void;
}
interface GeneratorRenderProps {
name: string;
des: string;
enableFlag?: string;
executableSetting?: string;
maxSizeSetting?: string;
readOnly?: boolean;
inputs?: {
name: string;
label: string;
des: string;
required?: boolean;
}[];
}
const generators: GeneratorRenderProps[] = [
{
name: "policyBuiltin",
des: "policyBuiltinDes",
readOnly: true,
},
{
name: "musicCover",
des: "musicCoverDes",
enableFlag: "thumb_music_cover_enabled",
maxSizeSetting: "thumb_music_cover_max_size",
inputs: [
{
name: "thumb_music_cover_exts",
label: "generatorExts",
des: "generatorExtsDes",
},
],
},
{
name: "libreOffice",
des: "libreOfficeDes",
enableFlag: "thumb_libreoffice_enabled",
maxSizeSetting: "thumb_libreoffice_max_size",
executableSetting: "thumb_libreoffice_path",
inputs: [
{
name: "thumb_libreoffice_exts",
label: "generatorExts",
des: "generatorExtsDes",
},
],
},
{
name: "libraw",
des: "librawDes",
enableFlag: "thumb_libraw_enabled",
maxSizeSetting: "thumb_libraw_max_size",
executableSetting: "thumb_libraw_path",
inputs: [
{
name: "thumb_libraw_exts",
label: "generatorExts",
des: "generatorExtsDes",
},
],
},
{
name: "vips",
des: "vipsDes",
enableFlag: "thumb_vips_enabled",
maxSizeSetting: "thumb_vips_max_size",
executableSetting: "thumb_vips_path",
inputs: [
{
name: "thumb_vips_exts",
label: "generatorExts",
des: "generatorExtsDes",
},
],
},
{
name: "ffmpeg",
des: "ffmpegDes",
enableFlag: "thumb_ffmpeg_enabled",
maxSizeSetting: "thumb_ffmpeg_max_size",
executableSetting: "thumb_ffmpeg_path",
inputs: [
{
name: "thumb_ffmpeg_exts",
label: "generatorExts",
des: "generatorExtsDes",
},
{
name: "thumb_ffmpeg_seek",
label: "ffmpegSeek",
des: "ffmpegSeekDes",
required: true,
},
{
name: "thumb_ffmpeg_extra_args",
label: "ffmpegExtraArgs",
des: "ffmpegExtraArgsDes",
},
],
},
{
name: "cloudreveBuiltin",
maxSizeSetting: "thumb_builtin_max_size",
des: "cloudreveBuiltinDes",
enableFlag: "thumb_builtin_enabled",
},
];
const Generators = ({ values, setSetting }: GeneratorsProps) => {
const { t } = useTranslation("dashboard");
const dispatch = useAppDispatch();
const [testing, setTesting] = useState(false);
const { enqueueSnackbar } = useSnackbar();
const handleEnableChange = (name: string) => (e: React.ChangeEvent<HTMLInputElement>) => {
setSetting({
[name]: e.target.checked ? "1" : "0",
});
const newValues = { ...values, [name]: e.target.checked ? "1" : "0" };
if (
(newValues["thumb_libreoffice_enabled"] === "1" || newValues["thumb_music_cover_enabled"] === "1") &&
newValues["thumb_builtin_enabled"] === "0" &&
newValues["thumb_vips_enabled"] === "0"
) {
enqueueSnackbar({
message: t("settings.thumbDependencyWarning"),
variant: "warning",
action: DefaultCloseAction,
});
}
};
const doTest = (name: string, executable: string) => {
setTesting(true);
dispatch(
sendTestThumbGeneratorExecutable({
name,
executable,
}),
)
.then((res) => {
enqueueSnackbar({
message: t("settings.executableTestSuccess", { version: res }),
variant: "success",
action: DefaultCloseAction,
});
})
.finally(() => {
setTesting(false);
});
};
return (
<Box>
{generators.map((g) => (
<StyledAccordion key={g.name} disableGutters>
<AccordionSummary expandIcon={<ExpandMoreRounded />}>
<FormControlLabel
onClick={(event) => event.stopPropagation()}
onFocus={(event) => event.stopPropagation()}
control={
<StyledCheckbox
size={"small"}
checked={g.readOnly || isTrueVal(values[g.enableFlag ?? ""])}
onChange={handleEnableChange(g.enableFlag ?? "")}
/>
}
label={t(`settings.${g.name}`)}
disabled={g.readOnly}
/>
</AccordionSummary>
<AccordionDetails sx={{ display: "block" }}>
<Typography color="textSecondary" variant={"body2"}>
{t(`settings.${g.des}`)}
</Typography>
<SettingSectionContent sx={{ mt: 2 }}>
{g.executableSetting && (
<SettingForm lgWidth={12} title={t("settings.executable")}>
<FormControl fullWidth>
<DenseFilledTextField
required
value={values[g.executableSetting]}
InputProps={{
endAdornment: (
<InputAdornment position="end">
<LoadingButton
onClick={() => doTest(g.name, values[g.executableSetting ?? ""])}
loading={testing}
color="primary"
>
<span>{t("settings.executableTest")}</span>
</LoadingButton>
</InputAdornment>
),
}}
onChange={(e) =>
setSetting({
[g.executableSetting ?? ""]: e.target.value,
})
}
/>
<NoMarginHelperText>{t("settings.executableDes")}</NoMarginHelperText>
</FormControl>
</SettingForm>
)}
{g.maxSizeSetting && (
<SettingForm lgWidth={12} title={t("settings.thumbMaxSize")}>
<FormControl fullWidth>
<SizeInput
variant={"outlined"}
required
allowZero={false}
value={parseInt(values[g.maxSizeSetting ?? ""]) ?? 0}
onChange={(e) =>
setSetting({
[g.maxSizeSetting ?? ""]: e.toString(),
})
}
/>
<NoMarginHelperText>{t("settings.thumbMaxSizeDes")}</NoMarginHelperText>
</FormControl>
</SettingForm>
)}
{g.inputs?.map((input) => (
<SettingForm key={input.name} lgWidth={12} title={t(`settings.${input.label}`)}>
<FormControl fullWidth>
<DenseFilledTextField
value={values[input.name]}
onChange={(e) =>
setSetting({
[input.name]: e.target.value,
})
}
required={!!input.required}
/>
<NoMarginHelperText>{t(`settings.${input.des}`)}</NoMarginHelperText>
</FormControl>
</SettingForm>
))}
</SettingSectionContent>
</AccordionDetails>
</StyledAccordion>
))}
</Box>
);
};
export default Generators;

View File

@@ -0,0 +1,184 @@
import { Alert, Box, Collapse, FormControlLabel, Link, ListItemText, Stack, Switch, Typography } from "@mui/material";
import FormControl from "@mui/material/FormControl";
import { useContext } from "react";
import { Trans, useTranslation } from "react-i18next";
import { isTrueVal } from "../../../../session/utils.ts";
import { DenseFilledTextField, DenseSelect } from "../../../Common/StyledComponents.tsx";
import { SquareMenuItem } from "../../../FileManager/ContextMenu/ContextMenu.tsx";
import SettingForm from "../../../Pages/Setting/SettingForm.tsx";
import { NoMarginHelperText, SettingSection, SettingSectionContent } from "../Settings.tsx";
import { SettingContext } from "../SettingWrapper.tsx";
import Extractors from "./Extractors.tsx";
import Generators from "./Generators.tsx";
const Media = () => {
const { t } = useTranslation("dashboard");
const { formRef, setSettings, values } = useContext(SettingContext);
return (
<Box component={"form"} ref={formRef} onSubmit={(e) => e.preventDefault()}>
<Stack spacing={5}>
<SettingSection>
<Typography variant="h6" gutterBottom>
{t("settings.thumbnails")}
</Typography>
<Typography variant="subtitle1" gutterBottom>
{t("settings.thumbnailBasic")}
</Typography>
<SettingSectionContent>
<SettingForm title={t("settings.thumbWidth")} lgWidth={5}>
<FormControl>
<DenseFilledTextField
type="number"
required
inputProps={{ min: 1, step: 1 }}
value={values.thumb_width}
onChange={(e) => {
setSettings({
thumb_width: e.target.value,
});
}}
/>
</FormControl>
</SettingForm>
<SettingForm title={t("settings.thumbHeight")} lgWidth={5}>
<FormControl>
<DenseFilledTextField
type="number"
required
inputProps={{ min: 1, step: 1 }}
value={values.thumb_height}
onChange={(e) => {
setSettings({
thumb_height: e.target.value,
});
}}
/>
</FormControl>
</SettingForm>
<SettingForm title={t("settings.thumbSuffix")} lgWidth={5}>
<FormControl fullWidth>
<DenseFilledTextField
required
value={values.thumb_entity_suffix}
onChange={(e) => {
setSettings({
thumb_entity_suffix: e.target.value,
});
}}
/>
<NoMarginHelperText>
{t("settings.notAppliedToNativeGenerator", {
prefix: t("settings.thumbSuffixDes"),
})}
</NoMarginHelperText>
</FormControl>
</SettingForm>
<SettingForm title={t("settings.thumbFormat")} lgWidth={5}>
<FormControl>
<DenseSelect
value={values.thumb_encode_method}
onChange={(e) => {
setSettings({
thumb_encode_method: e.target.value as string,
});
}}
required
>
{["jpg", "png", "webp"].map((f) => (
<SquareMenuItem value={f} key={f}>
<ListItemText
slotProps={{
primary: { variant: "body2" },
}}
>
{f}
</ListItemText>
</SquareMenuItem>
))}
</DenseSelect>
<NoMarginHelperText>
{t("settings.notAppliedToOneDriveNativeGenerator", { prefix: t("settings.thumbFormatDes") })}
</NoMarginHelperText>
</FormControl>
</SettingForm>
<Collapse in={values.thumb_encode_method == "jpg" || values.thumb_encode_method == "webp"} unmountOnExit>
<SettingForm title={t("settings.thumbQuality")} lgWidth={5}>
<FormControl>
<DenseFilledTextField
type="number"
required
inputProps={{ min: 50, max: 100, step: 1 }}
value={values.thumb_encode_quality}
onChange={(e) => {
setSettings({
thumb_encode_quality: e.target.value,
});
}}
/>
<NoMarginHelperText>
{t("settings.notAppliedToOneDriveNativeGenerator", {
prefix: t("settings.thumbQualityDes"),
})}
</NoMarginHelperText>
</FormControl>
</SettingForm>
</Collapse>
<SettingForm lgWidth={5}>
<FormControl fullWidth>
<FormControlLabel
control={
<Switch
checked={isTrueVal(values.thumb_gc_after_gen)}
onChange={(e) =>
setSettings({
thumb_gc_after_gen: e.target.checked ? "1" : "0",
})
}
/>
}
label={t("settings.thumbGC")}
/>
<NoMarginHelperText>{t("settings.notAppliedToNativeGenerator", { prefix: "" })}</NoMarginHelperText>
</FormControl>
</SettingForm>
</SettingSectionContent>
<Typography variant="subtitle1" gutterBottom sx={{ mt: 1 }}>
{t("settings.generators")}
</Typography>
<SettingSectionContent>
<SettingForm lgWidth={6}>
<Alert severity="info" sx={{ mb: 1 }}>
<Trans
ns="dashboard"
i18nKey="settings.generatorProxyWarning"
components={[<Link href="https://docs.cloudreve.org/usage/thumbnails" target="_blank" />]}
/>
</Alert>
<Generators values={values} setSetting={setSettings} />
</SettingForm>
</SettingSectionContent>
</SettingSection>
<SettingSection>
<Typography variant="h6" gutterBottom>
{t("settings.extractMediaMeta")}
</Typography>
<SettingSectionContent>
<SettingForm lgWidth={6}>
<Alert severity="info" sx={{ mb: 1 }}>
<Trans
ns="dashboard"
i18nKey="settings.extractMediaMetaDes"
components={[<Link href="https://docs.cloudreve.org/usage/media-meta" target="_blank" />]}
/>
</Alert>
<Extractors values={values} setSetting={setSettings} />
</SettingForm>
</SettingSectionContent>
</SettingSection>
</Stack>
</Box>
);
};
export default Media;

View File

@@ -0,0 +1,62 @@
import { Box, Grid, Stack } from "@mui/material";
import { useContext, useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import { getQueueMetrics } from "../../../../api/api.ts";
import { QueueMetric } from "../../../../api/dashboard.ts";
import { useAppDispatch } from "../../../../redux/hooks.ts";
import { SecondaryButton } from "../../../Common/StyledComponents.tsx";
import ArrowSync from "../../../Icons/ArrowSync.tsx";
import { SettingContext } from "../SettingWrapper.tsx";
import QueueCard from "./QueueCard.tsx";
const Queue = () => {
const { t } = useTranslation("dashboard");
const dispatch = useAppDispatch();
const { formRef, setSettings, values } = useContext(SettingContext);
const [metrics, setMetrics] = useState<QueueMetric[]>([]);
const [loading, setLoading] = useState(true);
const fetchQueueMetrics = () => {
setLoading(true);
dispatch(getQueueMetrics())
.then((res) => {
setMetrics(res);
})
.finally(() => {
setLoading(false);
});
};
useEffect(() => {
fetchQueueMetrics();
}, []);
return (
<Box component={"form"} ref={formRef} sx={{ p: 2, pt: 0 }}>
<Stack direction="row" spacing={2} sx={{ mb: 2 }}>
<SecondaryButton onClick={fetchQueueMetrics} disabled={loading} variant={"contained"} startIcon={<ArrowSync />}>
{t("node.refresh")}
</SecondaryButton>
</Stack>
<Grid container spacing={2}>
{!loading &&
metrics.map((metric) => (
<QueueCard
key={metric.name}
metrics={metric}
queue={metric.name}
settings={values}
setSettings={setSettings}
loading={loading}
/>
))}
{loading &&
Array.from(Array(5)).map((_, index) => (
<QueueCard key={`loading-${index}`} settings={values} setSettings={setSettings} loading={true} />
))}
</Grid>
</Box>
);
};
export default Queue;

View File

@@ -0,0 +1,159 @@
import { Box, Divider, Grid, IconButton, Skeleton, Stack, Typography, useMediaQuery, useTheme } from "@mui/material";
import { useState } from "react";
import { useTranslation } from "react-i18next";
import { QueueMetric, QueueType } from "../../../../api/dashboard.ts";
import Setting from "../../../Icons/Setting.tsx";
import { StorageBar, StorageBlock, StoragePart } from "../../../Pages/Setting/StorageSetting.tsx";
import { BorderedCard } from "../../Common/AdminCard.tsx";
import QueueSettingDialog from "./QueueSettingDialog.tsx";
export interface QueueCardProps {
queue?: QueueType;
settings: {
[key: string]: string;
};
setSettings: (settings: { [key: string]: string }) => void;
metrics?: QueueMetric;
loading: boolean;
}
export const QueueCard = ({ queue, settings, metrics, setSettings, loading }: QueueCardProps) => {
const { t } = useTranslation("dashboard");
const theme = useTheme();
const isMobile = useMediaQuery(theme.breakpoints.down("sm"));
const [settingDialogOpen, setSettingDialogOpen] = useState(false);
if (loading) {
return (
<Grid item xs={12} md={6} lg={4}>
<BorderedCard>
<Box sx={{ display: "flex", justifyContent: "space-between", alignItems: "center" }}>
<Skeleton variant="text" width={150} height={28} />
<Skeleton variant="circular" width={24} height={24} />
</Box>
<Skeleton variant="text" width="80%" height={20} sx={{ mt: 1 }} />
<Divider sx={{ my: 2 }} />
<Skeleton variant="rectangular" height={8} width="100%" sx={{ borderRadius: 1 }} />
<Stack spacing={isMobile ? 1 : 2} direction={isMobile ? "column" : "row"} sx={{ mt: 1 }}>
{Array.from(Array(5)).map((_, index) => (
<Skeleton key={index} variant="text" width={isMobile ? "100%" : 80} height={20} />
))}
</Stack>
</BorderedCard>
</Grid>
);
}
return (
<Grid item xs={12} md={6} lg={4}>
<BorderedCard>
<Box sx={{ display: "flex", justifyContent: "space-between", alignItems: "center" }}>
<Typography variant="subtitle1" fontWeight={600}>
{t(`queue.queueName_${queue}`)}
</Typography>
<IconButton size="small" onClick={() => setSettingDialogOpen(true)}>
<Setting fontSize="small" />
</IconButton>
</Box>
<Typography variant="body2" color="text.secondary">
{t(`queue.queueName_${queue}Des`)}
</Typography>
<Divider sx={{ my: 2 }} />
{metrics && (
<>
<StorageBar>
<StoragePart
sx={{
backgroundColor: (theme) => theme.palette.success.light,
width: `${(metrics.success_tasks / metrics.submitted_tasks) * 100}%`,
}}
/>
<StoragePart
sx={{
backgroundColor: (theme) => theme.palette.error.light,
width: `${(metrics.failure_tasks / metrics.submitted_tasks) * 100}%`,
}}
/>
<StoragePart
sx={{
backgroundColor: (theme) => theme.palette.action.active,
width: `${(metrics.suspending_tasks / metrics.submitted_tasks) * 100}%`,
}}
/>
<StoragePart
sx={{
backgroundColor: (theme) => theme.palette.info.light,
width: `${(metrics.busy_workers / metrics.submitted_tasks) * 100}%`,
}}
/>
</StorageBar>
<Stack spacing={isMobile ? 1 : 2} direction={isMobile ? "column" : "row"} sx={{ mt: 1 }}>
<Typography variant={"caption"}>
<StorageBlock
sx={{
backgroundColor: (theme) => theme.palette.success.light,
}}
/>
{t("queue.success", {
count: metrics.success_tasks,
})}
</Typography>
<Typography variant={"caption"}>
<StorageBlock
sx={{
backgroundColor: (theme) => theme.palette.error.light,
}}
/>
{t("queue.failed", {
count: metrics.failure_tasks,
})}
</Typography>
<Typography variant={"caption"}>
<StorageBlock
sx={{
backgroundColor: (theme) => theme.palette.info.light,
}}
/>
{t("queue.busyWorker", {
count: metrics.busy_workers,
})}
</Typography>
<Typography variant={"caption"}>
<StorageBlock
sx={{
backgroundColor: (theme) => theme.palette.action.active,
}}
/>
{t("queue.suspending", {
count: metrics.suspending_tasks,
})}
</Typography>
<Typography variant={"caption"}>
<StorageBlock
sx={{
backgroundColor: (theme) => theme.palette.grey[theme.palette.mode === "light" ? 200 : 800],
}}
/>
{t("queue.submited", {
count: metrics.submitted_tasks,
})}
</Typography>
</Stack>
</>
)}
{queue && (
<QueueSettingDialog
open={settingDialogOpen}
onClose={() => setSettingDialogOpen(false)}
queue={queue}
settings={settings}
setSettings={setSettings}
/>
)}
</BorderedCard>
</Grid>
);
};
export default QueueCard;

View File

@@ -0,0 +1,202 @@
import { Box, FormControl, FormHelperText } from "@mui/material";
import { useEffect, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import { QueueType } from "../../../../api/dashboard.ts";
import { DenseFilledTextField } from "../../../Common/StyledComponents.tsx";
import DraggableDialog from "../../../Dialogs/DraggableDialog.tsx";
import SettingForm from "../../../Pages/Setting/SettingForm.tsx";
export interface QueueSettingDialogProps {
open: boolean;
onClose: () => void;
queue: QueueType;
settings: {
[key: string]: string;
};
setSettings: (settings: { [key: string]: string }) => void;
}
const NoMarginHelperText = (props: any) => (
<FormHelperText
{...props}
sx={{
marginLeft: 0,
marginRight: 0,
marginTop: 0.5,
}}
/>
);
const QueueSettingDialog = ({ open, onClose, queue, settings, setSettings }: QueueSettingDialogProps) => {
const { t } = useTranslation("dashboard");
const formRef = useRef<HTMLFormElement>(null);
const [localSettings, setLocalSettings] = useState<{ [key: string]: string }>({});
// Initialize local settings when dialog opens or queue changes
useEffect(() => {
if (open) {
const queueSettings: { [key: string]: string } = {};
const settingKeys = [
"worker_num",
"max_execution",
"backoff_factor",
"backoff_max_duration",
"max_retry",
"retry_delay",
];
settingKeys.forEach((key) => {
const fullKey = `queue_${queue}_${key}`;
queueSettings[key] = settings[fullKey] || "";
});
setLocalSettings(queueSettings);
}
}, [open, queue, settings]);
const handleSave = () => {
if (formRef.current?.reportValidity()) {
// Apply all settings at once
const updatedSettings: { [key: string]: string } = {};
Object.entries(localSettings).forEach(([key, value]) => {
updatedSettings[`queue_${queue}_${key}`] = value as string;
});
setSettings(updatedSettings);
onClose();
}
};
const updateLocalSetting = (key: string, value: string) => {
setLocalSettings((prev: { [key: string]: string }) => ({
...prev,
[key]: value,
}));
};
return (
<DraggableDialog
title={t("queue.editQueueSettings", {
name: t(`queue.queueName_${queue}`),
})}
showActions
showCancel
onAccept={handleSave}
dialogProps={{
open,
onClose,
fullWidth: true,
maxWidth: "sm",
}}
>
<Box
component={"form"}
ref={formRef}
sx={{ display: "flex", flexDirection: "column", gap: 2, mt: 1, px: 3, pb: 2 }}
>
<SettingForm title={t("queue.workerNum")} lgWidth={12}>
<FormControl fullWidth>
<DenseFilledTextField
value={localSettings.worker_num || ""}
onChange={(e) => updateLocalSetting("worker_num", e.target.value)}
type="number"
slotProps={{
htmlInput: {
min: 1,
},
}}
required
/>
<NoMarginHelperText>{t("queue.workerNumDes")}</NoMarginHelperText>
</FormControl>
</SettingForm>
<SettingForm title={t("queue.maxExecution")} lgWidth={12}>
<FormControl fullWidth>
<DenseFilledTextField
value={localSettings.max_execution || ""}
onChange={(e) => updateLocalSetting("max_execution", e.target.value)}
type="number"
inputProps={{
min: 1,
}}
required
/>
<NoMarginHelperText>{t("queue.maxExecutionDes")}</NoMarginHelperText>
</FormControl>
</SettingForm>
<SettingForm title={t("queue.backoffFactor")} lgWidth={12}>
<FormControl fullWidth>
<DenseFilledTextField
value={localSettings.backoff_factor || ""}
onChange={(e) => updateLocalSetting("backoff_factor", e.target.value)}
type="number"
slotProps={{
htmlInput: {
min: 1,
step: 0.1,
},
}}
required
/>
<NoMarginHelperText>{t("queue.backoffFactorDes")}</NoMarginHelperText>
</FormControl>
</SettingForm>
<SettingForm title={t("queue.backoffMaxDuration")} lgWidth={12}>
<FormControl fullWidth>
<DenseFilledTextField
value={localSettings.backoff_max_duration || ""}
onChange={(e) => updateLocalSetting("backoff_max_duration", e.target.value)}
type="number"
slotProps={{
htmlInput: {
min: 1,
},
}}
required
/>
<NoMarginHelperText>{t("queue.backoffMaxDurationDes")}</NoMarginHelperText>
</FormControl>
</SettingForm>
<SettingForm title={t("queue.maxRetry")} lgWidth={12}>
<FormControl fullWidth>
<DenseFilledTextField
value={localSettings.max_retry || ""}
onChange={(e) => updateLocalSetting("max_retry", e.target.value)}
type="number"
slotProps={{
htmlInput: {
min: 0,
},
}}
required
/>
<NoMarginHelperText>{t("queue.maxRetryDes")}</NoMarginHelperText>
</FormControl>
</SettingForm>
<SettingForm title={t("queue.retryDelay")} lgWidth={12}>
<FormControl fullWidth>
<DenseFilledTextField
value={localSettings.retry_delay || ""}
onChange={(e) => updateLocalSetting("retry_delay", e.target.value)}
type="number"
slotProps={{
htmlInput: {
min: 0,
},
}}
required
/>
<NoMarginHelperText>{t("queue.retryDelayDes")}</NoMarginHelperText>
</FormControl>
</SettingForm>
</Box>
</DraggableDialog>
);
};
export default QueueSettingDialog;

View File

@@ -0,0 +1,124 @@
import { Box, FormControl, Link, Stack, Typography } from "@mui/material";
import { useContext } from "react";
import { Trans, useTranslation } from "react-i18next";
import { DenseFilledTextField, SecondaryButton } from "../../../Common/StyledComponents";
import ArrowSync from "../../../Icons/ArrowSync";
import SettingForm from "../../../Pages/Setting/SettingForm";
import { NoMarginHelperText, SettingSection, SettingSectionContent } from "../Settings";
import { SettingContext } from "../SettingWrapper";
const ServerSetting = () => {
const { t } = useTranslation("dashboard");
const { formRef, setSettings, values } = useContext(SettingContext);
const rotateSecretKey = () => {
setSettings({ secret_key: "[Placeholder]" });
};
return (
<Box component={"form"} ref={formRef} onSubmit={(e) => e.preventDefault()}>
<Stack spacing={5}>
<SettingSection>
<Typography variant="h6" gutterBottom>
{t("settings.server")}
</Typography>
<SettingSectionContent>
<SettingForm title={t("settings.tempPath")} lgWidth={5}>
<FormControl fullWidth>
<DenseFilledTextField
required
value={values.temp_path ?? ""}
onChange={(e) => setSettings({ temp_path: e.target.value })}
/>
<NoMarginHelperText>{t("settings.tempPathDes")}</NoMarginHelperText>
</FormControl>
</SettingForm>
<SettingForm title={t("settings.siteID")} lgWidth={5}>
<FormControl fullWidth>
<DenseFilledTextField
required
value={values.siteID ?? ""}
onChange={(e) => setSettings({ siteID: e.target.value })}
/>
<NoMarginHelperText>{t("settings.siteIDDes")}</NoMarginHelperText>
</FormControl>
</SettingForm>
<SettingForm title={t("settings.siteSecretKey")} lgWidth={5}>
<SecondaryButton onClick={rotateSecretKey} startIcon={<ArrowSync />} variant="contained">
{t("settings.rotateSecretKey")}
</SecondaryButton>
<NoMarginHelperText>{t("settings.siteSecretKeyDes")}</NoMarginHelperText>
</SettingForm>
<SettingForm title={t("settings.hashidSalt")} lgWidth={5}>
<FormControl fullWidth>
<DenseFilledTextField
required
value={values.hash_id_salt ?? ""}
onChange={(e) => setSettings({ hash_id_salt: e.target.value })}
/>
<NoMarginHelperText>{t("settings.hashidSaltDes")}</NoMarginHelperText>
</FormControl>
</SettingForm>
<SettingForm title={t("settings.accessTokenTTL")} lgWidth={5}>
<FormControl fullWidth>
<DenseFilledTextField
required
slotProps={{
input: {
type: "number",
inputProps: {
min: 100,
},
},
}}
value={values.access_token_ttl ?? ""}
onChange={(e) => setSettings({ access_token_ttl: e.target.value })}
/>
<NoMarginHelperText>{t("settings.accessTokenTTLDes")}</NoMarginHelperText>
</FormControl>
</SettingForm>
<SettingForm title={t("settings.refreshTokenTTL")} lgWidth={5}>
<FormControl fullWidth>
<DenseFilledTextField
required
slotProps={{
input: {
type: "number",
inputProps: {
min: parseInt(values.access_token_ttl ?? "1000") + 100,
},
},
}}
value={values.refresh_token_ttl ?? ""}
onChange={(e) => setSettings({ refresh_token_ttl: e.target.value })}
/>
<NoMarginHelperText>{t("settings.refreshTokenTTLDes")}</NoMarginHelperText>
</FormControl>
</SettingForm>
<SettingForm title={t("settings.cronGarbageCollect")} lgWidth={5}>
<FormControl fullWidth>
<DenseFilledTextField
required
value={values.cron_garbage_collect ?? ""}
onChange={(e) => setSettings({ cron_garbage_collect: e.target.value })}
/>
<NoMarginHelperText>
<Trans
i18nKey="settings.cronDes"
values={{
des: t("settings.cronGarbageCollectDes"),
}}
ns={"dashboard"}
components={[<Link href="https://crontab.guru/" target="_blank" rel="noopener noreferrer" />]}
/>
</NoMarginHelperText>
</FormControl>
</SettingForm>
</SettingSectionContent>
</SettingSection>
</Stack>
</Box>
);
};
export default ServerSetting;

View File

@@ -0,0 +1,191 @@
import { LoadingButton } from "@mui/lab";
import { Box, Grow, styled } from "@mui/material";
import * as React from "react";
import { createContext, useEffect, useMemo, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import { CSSTransition, SwitchTransition } from "react-transition-group";
import { getSettings, sendSetSetting } from "../../../api/api.ts";
import { useAppDispatch } from "../../../redux/hooks.ts";
import FacebookCircularProgress from "../../Common/CircularProgress.tsx";
import { SecondaryButton } from "../../Common/StyledComponents.tsx";
import ArrowHookUpRight from "../../Icons/ArrowHookUpRight.tsx";
import Save from "../../Icons/Save.tsx";
export interface SettingsWrapperProps {
settings: string[];
children: React.ReactNode;
}
export interface SettingContextProps {
values: {
[key: string]: string;
};
setSettings: (settings: { [key: string]: string }) => void;
formRef?: React.RefObject<HTMLFormElement>;
}
const SavingFloatContainer = styled(Box)(({ theme }) => ({
padding: theme.spacing(2),
border: `1px solid ${theme.palette.divider}`,
borderRadius: theme.shape.borderRadius,
marginTop: theme.spacing(2),
position: "fixed",
backgroundColor: theme.palette.background.paper,
bottom: 23,
zIndex: theme.zIndex.modal,
}));
export interface SavingFloatProps {
disabled?: boolean;
in: boolean;
submitting: boolean;
revert: () => void;
submit: () => void;
}
export const SavingFloat = ({ in: inProp, submitting, revert, submit, disabled }: SavingFloatProps) => {
const { t } = useTranslation("dashboard");
return (
<>
<Box sx={{ height: 70 }} />
<Grow in={inProp}>
<SavingFloatContainer>
<LoadingButton
loading={submitting}
onClick={submit}
variant={"contained"}
startIcon={<Save />}
disabled={disabled}
>
<span>{t("settings.save")}</span>
</LoadingButton>
<SecondaryButton
disabled={submitting}
onClick={revert}
sx={{ ml: 1 }}
variant={"contained"}
startIcon={<ArrowHookUpRight />}
>
{t("settings.revert")}
</SecondaryButton>
</SavingFloatContainer>
</Grow>
</>
);
};
export const SettingContext = createContext<SettingContextProps>({
values: {},
setSettings: () => {},
});
const SettingsWrapper = ({ settings, children }: SettingsWrapperProps) => {
const dispatch = useAppDispatch();
const { t } = useTranslation("dashboard");
const [values, setValues] = useState<{ [key: string]: string }>({});
const [modifiedValues, setModifiedValues] = useState<{
[key: string]: string;
}>({});
const [loading, setLoading] = useState(true);
const [submitting, setSubmitting] = useState(false);
const setSettings = (settings: { [key: string]: string }) => {
setModifiedValues((prev) => ({ ...prev, ...settings }));
};
const formRef = useRef<HTMLFormElement>(null);
const showSaveButton = useMemo(() => {
return JSON.stringify(modifiedValues) !== JSON.stringify(values);
}, [modifiedValues, values]);
useEffect(() => {
setLoading(true);
dispatch(
getSettings({
keys: settings,
}),
)
.then((res) => {
setValues(res);
setModifiedValues(res);
})
.finally(() => {
setLoading(false);
});
}, [settings]);
const revert = () => {
setModifiedValues(values);
};
const submit = () => {
if (formRef.current) {
if (!formRef.current.checkValidity()) {
formRef.current.reportValidity();
return;
}
}
const modified: { [key: string]: string } = {};
Object.keys(modifiedValues).forEach((key) => {
if (modifiedValues[key] !== values[key]) {
modified[key] = modifiedValues[key];
}
});
setSubmitting(true);
dispatch(
sendSetSetting({
settings: modified,
}),
)
.then((res) => {
setValues((s) => ({ ...s, ...res }));
setModifiedValues((s) => ({ ...s, ...res }));
})
.finally(() => {
setSubmitting(false);
});
};
return (
<SettingContext.Provider
value={{
values: modifiedValues,
setSettings,
formRef,
}}
>
<SwitchTransition>
<CSSTransition
addEndListener={(node, done) => node.addEventListener("transitionend", done, false)}
classNames="fade"
key={`${loading}`}
>
<Box sx={{ mt: 3 }}>
{loading && (
<Box
sx={{
pt: 20,
height: "100%",
display: "flex",
justifyContent: "center",
alignItems: "center",
}}
>
<FacebookCircularProgress />
</Box>
)}
{!loading && (
<Box>
{children}
<SavingFloat in={showSaveButton} submitting={submitting} revert={revert} submit={submit} />
</Box>
)}
</Box>
</CSSTransition>
</SwitchTransition>
</SettingContext.Provider>
);
};
export default SettingsWrapper;

View File

@@ -0,0 +1,345 @@
import { Box, Container, FormHelperText, InputAdornment, styled } from "@mui/material";
import { useQueryState } from "nuqs";
import { useMemo } from "react";
import { useTranslation } from "react-i18next";
import { CSSTransition, SwitchTransition } from "react-transition-group";
import { QueueType } from "../../../api/dashboard.ts";
import ResponsiveTabs, { Tab } from "../../Common/ResponsiveTabs.tsx";
import Bot from "../../Icons/Bot.tsx";
import Color from "../../Icons/Color.tsx";
import CubeSync from "../../Icons/CubeSync.tsx";
import Currency from "../../Icons/Currency.tsx";
import FilmstripImage from "../../Icons/FilmstripImage.tsx";
import Globe from "../../Icons/Globe.tsx";
import MailOutlined from "../../Icons/MailOutlined.tsx";
import PersonPasskey from "../../Icons/PersonPasskey.tsx";
import SendLogging from "../../Icons/SendLogging.tsx";
import Server from "../../Icons/Server.tsx";
import PageContainer from "../../Pages/PageContainer.tsx";
import PageHeader, { PageTabQuery } from "../../Pages/PageHeader.tsx";
import Appearance from "./Appearance/Appearance.tsx";
import Captcha from "./Captcha/Captcha.tsx";
import Email from "./Email/Email.tsx";
import Events from "./Event/Events.tsx";
import Media from "./Media/Media.tsx";
import Queue from "./Queue/Queue.tsx";
import ServerSetting from "./Server/ServerSetting.tsx";
import SettingsWrapper from "./SettingWrapper.tsx";
import SiteInformation from "./SiteInformation/SiteInformation.tsx";
import UserSession from "./UserSession/UserSession.tsx";
import VAS from "./VAS/VAS.tsx";
export const StyledInputAdornment = styled(InputAdornment)(({ theme }) => ({
fontSize: theme.typography.body2.fontSize,
}));
export const SettingSection = styled(Box)(({ theme }) => ({
display: "flex",
flexDirection: "column",
gap: theme.spacing(1),
[theme.breakpoints.up("md")]: {
padding: theme.spacing(0, 4),
},
}));
export const SettingSectionContent = styled(Box)(({ theme }) => ({
[theme.breakpoints.up("md")]: {
padding: theme.spacing(0, 4),
},
display: "flex",
flexDirection: "column",
gap: theme.spacing(3),
}));
export const NoMarginHelperText = styled(FormHelperText)(() => ({
marginLeft: 0,
marginRight: 0,
}));
const allQueueSettings = Object.values(QueueType)
.map((queue) => [
`queue_${queue}_worker_num`,
`queue_${queue}_max_execution`,
`queue_${queue}_backoff_factor`,
`queue_${queue}_backoff_max_duration`,
`queue_${queue}_max_retry`,
`queue_${queue}_retry_delay`,
])
.flat();
export enum SettingsPageTab {
SiteInformation = "siteInformation",
UserSession = "userSession",
Captcha = "captcha",
FileSystem = "fileSystem",
MediaProcessing = "mediaProcessing",
VAS = "vas",
Email = "email",
Queue = "queue",
Appearance = "appearance",
Events = "events",
Server = "server",
}
const Settings = () => {
const { t } = useTranslation("dashboard");
const [tab, setTab] = useQueryState(PageTabQuery);
const tabs: Tab<SettingsPageTab>[] = useMemo(() => {
const res = [];
res.push(
...[
{
label: t("nav.basicSetting"),
value: SettingsPageTab.SiteInformation,
icon: <Globe />,
},
{
label: t("nav.userSession"),
value: SettingsPageTab.UserSession,
icon: <PersonPasskey />,
},
{
label: t("nav.captcha"),
value: SettingsPageTab.Captcha,
icon: <Bot />,
},
{
label: t("nav.mediaProcessing"),
value: SettingsPageTab.MediaProcessing,
icon: <FilmstripImage />,
},
{
label: t("vas.vas"),
value: SettingsPageTab.VAS,
icon: <Currency />,
},
{
label: t("nav.email"),
value: SettingsPageTab.Email,
icon: <MailOutlined />,
},
{
label: t("nav.queue"),
value: SettingsPageTab.Queue,
icon: <CubeSync />,
},
{
label: t("nav.appearance"),
value: SettingsPageTab.Appearance,
icon: <Color />,
},
{
label: t("nav.events"),
value: SettingsPageTab.Events,
icon: <SendLogging />,
},
{
label: t("nav.server"),
value: SettingsPageTab.Server,
icon: <Server />,
},
],
);
return res;
}, [t]);
return (
<PageContainer>
<Container maxWidth="xl">
<PageHeader title={t("dashboard:nav.settings")} />
<ResponsiveTabs
value={tab ?? SettingsPageTab.SiteInformation}
onChange={(_e, newValue) => setTab(newValue)}
tabs={tabs}
/>
<SwitchTransition>
<CSSTransition
addEndListener={(node, done) => node.addEventListener("transitionend", done, false)}
classNames="fade"
key={`${tab}`}
>
<Box>
{(!tab || tab === SettingsPageTab.SiteInformation) && (
<SettingsWrapper
settings={[
"siteName",
"siteDes",
"siteURL",
"siteScript",
"pwa_small_icon",
"pwa_medium_icon",
"pwa_large_icon",
"site_logo",
"site_logo_light",
"tos_url",
"privacy_policy_url",
"show_app_promotion",
]}
>
<SiteInformation />
</SettingsWrapper>
)}
{tab === SettingsPageTab.UserSession && (
<SettingsWrapper
settings={[
"register_enabled",
"email_active",
"default_group",
"authn_enabled",
"avatar_path",
"avatar_size",
"avatar_size_l",
"gravatar_server",
]}
>
<UserSession />
</SettingsWrapper>
)}
{tab === SettingsPageTab.Captcha && (
<SettingsWrapper
settings={[
"login_captcha",
"reg_captcha",
"forget_captcha",
"captcha_type",
"captcha_mode",
"captcha_ComplexOfNoiseText",
"captcha_ComplexOfNoiseDot",
"captcha_IsShowHollowLine",
"captcha_IsShowNoiseDot",
"captcha_IsShowNoiseText",
"captcha_IsShowSlimeLine",
"captcha_IsShowSineLine",
"captcha_CaptchaLen",
"captcha_ReCaptchaKey",
"captcha_ReCaptchaSecret",
"captcha_turnstile_site_key",
"captcha_turnstile_site_secret",
"captcha_cap_instance_url",
"captcha_cap_site_key",
"captcha_cap_secret_key",
"captcha_cap_asset_server",
]}
>
<Captcha />
</SettingsWrapper>
)}
{tab === SettingsPageTab.MediaProcessing && (
<SettingsWrapper
settings={[
"thumb_width",
"thumb_height",
"thumb_entity_suffix",
"thumb_encode_method",
"thumb_gc_after_gen",
"thumb_encode_quality",
"thumb_builtin_enabled",
"thumb_builtin_max_size",
"thumb_vips_max_size",
"thumb_vips_enabled",
"thumb_vips_exts",
"thumb_ffmpeg_enabled",
"thumb_vips_path",
"thumb_ffmpeg_path",
"thumb_ffmpeg_max_size",
"thumb_ffmpeg_exts",
"thumb_ffmpeg_seek",
"thumb_libreoffice_path",
"thumb_libreoffice_max_size",
"thumb_libreoffice_enabled",
"thumb_libreoffice_exts",
"thumb_music_cover_enabled",
"thumb_music_cover_exts",
"thumb_music_cover_max_size",
"thumb_libraw_path",
"thumb_libraw_max_size",
"thumb_libraw_enabled",
"thumb_libraw_exts",
"media_meta_exif",
"media_meta_exif_size_local",
"media_meta_exif_size_remote",
"media_meta_exif_brute_force",
"media_meta_music",
"media_meta_music_size_local",
"media_exif_music_size_remote",
"media_meta_ffprobe",
"media_meta_ffprobe_path",
"media_meta_ffprobe_size_local",
"media_meta_ffprobe_size_remote",
"media_meta_geocoding",
"media_meta_geocoding_mapbox_ak",
]}
>
<Media />
</SettingsWrapper>
)}
{tab === SettingsPageTab.VAS && (
<SettingsWrapper settings={[]}>
<VAS />
</SettingsWrapper>
)}
{tab === SettingsPageTab.Email && (
<SettingsWrapper
settings={[
"mail_keepalive",
"fromAdress",
"smtpHost",
"smtpPort",
"replyTo",
"smtpUser",
"smtpPass",
"smtpEncryption",
"fromName",
"mail_activation_template",
"mail_reset_template",
]}
>
<Email />
</SettingsWrapper>
)}
{tab === SettingsPageTab.Queue && (
<SettingsWrapper settings={allQueueSettings}>
<Queue />
</SettingsWrapper>
)}
{tab === SettingsPageTab.Appearance && (
<SettingsWrapper
settings={[
"theme_options",
"defaultTheme",
"custom_nav_items",
"headless_footer_html",
"headless_bottom_html",
"sidebar_bottom_html",
]}
>
<Appearance />
</SettingsWrapper>
)}
{tab === SettingsPageTab.Events && (
<SettingsWrapper settings={[]}>
<Events />
</SettingsWrapper>
)}
{tab === SettingsPageTab.Server && (
<SettingsWrapper
settings={[
"temp_path",
"siteID",
"cron_garbage_collect",
"hash_id_salt",
"access_token_ttl",
"refresh_token_ttl",
]}
>
<ServerSetting />
</SettingsWrapper>
)}
</Box>
</CSSTransition>
</SwitchTransition>
</Container>
</PageContainer>
);
};
export default Settings;

View File

@@ -0,0 +1,50 @@
import { Box } from "@mui/material";
import { useState, useEffect } from "react";
import { useTranslation } from "react-i18next";
import useMediaQuery from "@mui/material/useMediaQuery";
import { useTheme } from "@mui/material/styles";
export interface GeneralImagePreviewProps {
src: string;
debounce?: number; // (可选) 防抖时间
}
const GeneralImagePreview = ({ src, debounce = 0 }: GeneralImagePreviewProps) => {
const { t } = useTranslation("dashboard");
const theme = useTheme();
const isMobile = useMediaQuery(theme.breakpoints.down("sm"));
const [debouncedSrc, setDebouncedSrc] = useState(src);
useEffect(() => {
const handler = setTimeout(() => {
setDebouncedSrc(src);
}, debounce);
return () => clearTimeout(handler);
}, [src, debounce]);
return (
<Box sx={{ mt: isMobile ? 0 : 3 }}>
<Box
sx={{
border: (t) => `1px solid ${t.palette.divider}`,
p: 1,
display: "inline-block",
borderRadius: (theme) => `${theme.shape.borderRadius}px`,
}}
>
<Box
component={"img"}
src={debouncedSrc}
sx={{
display: "block",
maxWidth: "100%",
}}
/>
</Box>
</Box>
);
};
export default GeneralImagePreview;

View File

@@ -0,0 +1,65 @@
import { Box, Stack } from "@mui/material";
import React from "react";
import { useTranslation } from "react-i18next";
import useMediaQuery from "@mui/material/useMediaQuery";
import { useTheme } from "@mui/material/styles";
export interface LogoPreviewProps {
logoLight: string;
logoDark: string;
}
const LogoPreview = ({ logoLight, logoDark }: LogoPreviewProps) => {
const { t } = useTranslation("dashboard");
const theme = useTheme();
const isMobile = useMediaQuery(theme.breakpoints.down("sm"));
return (
<Stack spacing={1} direction={"row"} sx={{ mt: isMobile ? 0 : 3 }}>
<Box
sx={{
backgroundColor: (theme) => theme.palette.grey[100],
p: 1,
borderRadius: (theme) => `${theme.shape.borderRadius}px`,
}}
>
<Box
component={"img"}
src={logoDark}
sx={{
display: "block",
height: "auto",
maxWidth: 160,
maxHeight: 35,
width: "100%",
objectPosition: "left",
objectFit: "contain",
}}
/>
</Box>
<Box
sx={{
backgroundColor: (theme) => theme.palette.grey[900],
p: 1,
borderRadius: (theme) => `${theme.shape.borderRadius}px`,
}}
>
<Box
component={"img"}
src={logoLight}
sx={{
display: "block",
height: "auto",
maxWidth: 160,
maxHeight: 35,
width: "100%",
objectPosition: "left",
objectFit: "contain",
}}
/>
</Box>
</Stack>
);
};
export default LogoPreview;

View File

@@ -0,0 +1,258 @@
import { Box, FormControl, FormControlLabel, Stack, Switch, Typography } from "@mui/material";
import Grid from "@mui/material/Grid";
import { useContext } from "react";
import { useTranslation } from "react-i18next";
import { isTrueVal } from "../../../../session/utils.ts";
import { DenseFilledTextField } from "../../../Common/StyledComponents.tsx";
import SettingForm from "../../../Pages/Setting/SettingForm.tsx";
import { NoMarginHelperText, SettingSection, SettingSectionContent, StyledInputAdornment } from "../Settings.tsx";
import { SettingContext } from "../SettingWrapper.tsx";
import GeneralImagePreview from "./GeneralImagePreview.tsx";
import LogoPreview from "./LogoPreview.tsx";
import SiteURLInput from "./SiteURLInput.tsx";
const SiteInformation = () => {
const { t } = useTranslation("dashboard");
const { formRef, setSettings, values } = useContext(SettingContext);
return (
<Box component={"form"} ref={formRef} onSubmit={(e) => e.preventDefault()}>
<Stack spacing={5}>
<SettingSection>
<Typography variant="h6" gutterBottom>
{t("settings.basicInformation")}
</Typography>
<SettingSectionContent>
<SettingForm title={t("settings.mainTitle")} lgWidth={5}>
<FormControl fullWidth>
<DenseFilledTextField
fullWidth
onChange={(e) => setSettings({ siteName: e.target.value })}
value={values.siteName}
required
inputProps={{ maxLength: 255 }}
/>
<NoMarginHelperText>{t("settings.mainTitleDes")}</NoMarginHelperText>
</FormControl>
</SettingForm>
<SettingForm title={t("settings.siteDescription")} lgWidth={5}>
<FormControl fullWidth>
<DenseFilledTextField
fullWidth
onChange={(e) => setSettings({ siteDes: e.target.value })}
value={values.siteDes}
multiline
rows={4}
/>
<NoMarginHelperText>{t("settings.siteDescriptionDes")}</NoMarginHelperText>
</FormControl>
</SettingForm>
<SettingForm title={t("settings.siteURL")} lgWidth={5}>
<FormControl fullWidth>
<SiteURLInput urls={values.siteURL} onChange={(v) => setSettings({ siteURL: v })} />
</FormControl>
</SettingForm>
<SettingForm title={t("settings.customFooterHTML")} lgWidth={5}>
<FormControl fullWidth>
<DenseFilledTextField
fullWidth
onChange={(e) => setSettings({ siteScript: e.target.value })}
value={values.siteScript}
multiline
rows={4}
/>
<NoMarginHelperText>{t("settings.customFooterHTMLDes")}</NoMarginHelperText>
</FormControl>
</SettingForm>
<SettingForm title={t("settings.announcement")} lgWidth={5} pro>
<FormControl fullWidth>
<DenseFilledTextField inputProps={{ readOnly: true }} fullWidth multiline rows={4} />
<NoMarginHelperText>{t("settings.announcementDes")}</NoMarginHelperText>
</FormControl>
</SettingForm>
<SettingForm title={t("settings.tosUrl")} lgWidth={5}>
<FormControl fullWidth>
<DenseFilledTextField
fullWidth
onChange={(e) => setSettings({ tos_url: e.target.value })}
value={values.tos_url}
/>
<NoMarginHelperText>{t("settings.tosUrlDes")}</NoMarginHelperText>
</FormControl>
</SettingForm>
<SettingForm title={t("settings.privacyUrl")} lgWidth={5}>
<FormControl fullWidth>
<DenseFilledTextField
fullWidth
onChange={(e) => setSettings({ privacy_policy_url: e.target.value })}
value={values.privacy_policy_url}
/>
<NoMarginHelperText>{t("settings.privacyUrlDes")}</NoMarginHelperText>
</FormControl>
</SettingForm>
</SettingSectionContent>
</SettingSection>
<SettingSection>
<Typography variant="h6" gutterBottom>
{t("settings.branding")}
</Typography>
<SettingSectionContent>
<SettingForm
title={t("settings.logo")}
lgWidth={5}
spacing={3}
secondary={
<Grid item md={7} xs={12}>
<LogoPreview logoDark={values.site_logo} logoLight={values.site_logo_light} />
</Grid>
}
>
<FormControl fullWidth>
<DenseFilledTextField
fullWidth
onChange={(e) => setSettings({ site_logo: e.target.value })}
value={values.site_logo}
required
InputProps={{
startAdornment: (
<StyledInputAdornment disableTypography position="start">
{t("settings.light")}
</StyledInputAdornment>
),
}}
/>
<DenseFilledTextField
sx={{ mt: 1 }}
fullWidth
onChange={(e) => setSettings({ site_logo_light: e.target.value })}
value={values.site_logo_light}
required
InputProps={{
startAdornment: (
<StyledInputAdornment disableTypography position="start">
{t("settings.dark")}
</StyledInputAdornment>
),
}}
/>
<NoMarginHelperText>{t("settings.logoDes")}</NoMarginHelperText>
</FormControl>
</SettingForm>
<SettingForm
title={t("settings.smallIcon")}
lgWidth={5}
spacing={3}
secondary={
<Grid item md={7} xs={12}>
<Box sx={{ maxWidth: 160 }}>
<GeneralImagePreview src={values.pwa_small_icon} debounce={250} />
</Box>
</Grid>
}
>
<FormControl fullWidth>
<DenseFilledTextField
fullWidth
onChange={(e) => setSettings({ pwa_small_icon: e.target.value })}
value={values.pwa_small_icon}
required
/>
<NoMarginHelperText>{t("settings.smallIconDes")}</NoMarginHelperText>
</FormControl>
</SettingForm>
<SettingForm
title={t("settings.mediumIcon")}
lgWidth={5}
spacing={3}
secondary={
<Grid item md={7} xs={12}>
<Box sx={{ maxWidth: 160 }}>
<GeneralImagePreview src={values.pwa_medium_icon} debounce={250} />
</Box>
</Grid>
}
>
<FormControl fullWidth>
<DenseFilledTextField
fullWidth
onChange={(e) => setSettings({ pwa_medium_icon: e.target.value })}
value={values.pwa_medium_icon}
required
/>
<NoMarginHelperText>{t("settings.mediumIconDes")}</NoMarginHelperText>
</FormControl>
</SettingForm>
<SettingForm
title={t("settings.largeIcon")}
lgWidth={5}
spacing={3}
secondary={
<Grid item md={7} xs={12}>
<Box sx={{ maxWidth: 160 }}>
<GeneralImagePreview src={values.pwa_large_icon} debounce={250} />
</Box>
</Grid>
}
>
<FormControl fullWidth>
<DenseFilledTextField
fullWidth
onChange={(e) => setSettings({ pwa_large_icon: e.target.value })}
value={values.pwa_large_icon}
required
/>
<NoMarginHelperText>{t("settings.largeIconDes")}</NoMarginHelperText>
</FormControl>
</SettingForm>
</SettingSectionContent>
</SettingSection>
<SettingSection>
<Typography variant="h6" gutterBottom>
{t("vas.mobileApp")}
</Typography>
<SettingSectionContent>
<SettingForm lgWidth={5}>
<FormControl fullWidth>
<FormControlLabel
control={
<Switch
checked={isTrueVal(values.show_app_promotion)}
onChange={(e) =>
setSettings({
show_app_promotion: e.target.checked ? "1" : "0",
})
}
/>
}
label={t("vas.showAppPromotion")}
/>
<NoMarginHelperText>{t("vas.showAppPromotionDes")}</NoMarginHelperText>
</FormControl>
</SettingForm>
<SettingForm title={t("vas.appFeedback")} lgWidth={5} pro>
<FormControl fullWidth>
<DenseFilledTextField
fullWidth
slotProps={{
input: {
readOnly: true,
},
}}
/>
<NoMarginHelperText>{t("vas.appLinkDes")}</NoMarginHelperText>
</FormControl>
</SettingForm>
<SettingForm title={t("vas.appForum")} lgWidth={5} pro>
<FormControl fullWidth>
<DenseFilledTextField fullWidth slotProps={{ input: { readOnly: true } }} />
<NoMarginHelperText>{t("vas.appLinkDes")}</NoMarginHelperText>
</FormControl>
</SettingForm>
</SettingSectionContent>
</SettingSection>
</Stack>
</Box>
);
};
export default SiteInformation;

View File

@@ -0,0 +1,91 @@
import { useTranslation } from "react-i18next";
import { useMemo } from "react";
import { Box, Collapse, Divider, IconButton, InputAdornment, Stack } from "@mui/material";
import { DenseFilledTextField, SecondaryButton } from "../../../Common/StyledComponents.tsx";
import FormControl from "@mui/material/FormControl";
import Dismiss from "../../../Icons/Dismiss.tsx";
import Add from "../../../Icons/Add.tsx";
import { TransitionGroup } from "react-transition-group";
import { NoMarginHelperText, StyledInputAdornment } from "../Settings.tsx";
export interface SiteURLInputProps {
urls: string;
onChange: (url: string) => void;
}
const SiteURLInput = ({ urls, onChange }: SiteURLInputProps) => {
const { t } = useTranslation("dashboard");
const urlSplit = useMemo(() => {
return urls.split(",").map((url) => url);
}, [urls]);
const onUrlChange = (index: number) => (e: React.ChangeEvent<HTMLInputElement>) => {
const newUrls = [...urlSplit];
newUrls[index] = e.target.value;
onChange(newUrls.join(","));
};
const removeUrl = (index: number) => () => {
const newUrls = [...urlSplit];
newUrls.splice(index, 1);
onChange(newUrls.join(","));
};
return (
<Stack spacing={1}>
<FormControl fullWidth>
<DenseFilledTextField
fullWidth
onChange={onUrlChange(0)}
value={urlSplit[0]}
InputProps={{
startAdornment: (
<StyledInputAdornment disableTypography position="start">
{t("settings.primarySiteURL")}
</StyledInputAdornment>
),
}}
required
/>
<NoMarginHelperText>{t("settings.primarySiteURLDes")}</NoMarginHelperText>
</FormControl>
<Divider />
<NoMarginHelperText>{t("settings.secondaryDes")}</NoMarginHelperText>
<TransitionGroup>
{urlSplit.slice(1).map((url, index) => (
<Collapse key={index}>
<FormControl fullWidth sx={{ mb: 1 }}>
<DenseFilledTextField
fullWidth
onChange={onUrlChange(index + 1)}
value={url}
InputProps={{
startAdornment: (
<StyledInputAdornment disableTypography position="start">
{t("settings.secondarySiteURL")}
</StyledInputAdornment>
),
endAdornment: (
<InputAdornment position="end">
<IconButton size={"small"} onClick={removeUrl(index + 1)}>
<Dismiss fontSize={"small"} />
</IconButton>
</InputAdornment>
),
}}
required
/>
</FormControl>
</Collapse>
))}
</TransitionGroup>
<Box sx={{ mt: "0!important" }}>
<SecondaryButton variant={"contained"} startIcon={<Add />} onClick={() => onChange(`${urls},`)}>
{t("settings.addSecondary")}
</SecondaryButton>
</Box>
</Stack>
);
};
export default SiteURLInput;

View File

@@ -0,0 +1,66 @@
import { ExpandMoreRounded } from "@mui/icons-material";
import { Accordion, AccordionDetails, FormControlLabel, styled } from "@mui/material";
import MuiAccordionSummary, { AccordionSummaryProps } from "@mui/material/AccordionSummary";
import { useCallback, useState } from "react";
import { useTranslation } from "react-i18next";
import { StyledCheckbox } from "../../../Common/StyledComponents.tsx";
import ProDialog from "../../Common/ProDialog.tsx";
export const AccordionSummary = styled((props: AccordionSummaryProps) => <MuiAccordionSummary {...props} />)(
({ theme }) => ({
fontSize: theme.typography.body2.fontSize,
paddingLeft: theme.spacing(4),
"& .MuiFormControlLabel-label": {
fontSize: theme.typography.body2.fontSize,
},
"& .MuiCheckbox-root": {
marginRight: theme.spacing(2),
},
}),
);
export const StyledAccordion = styled(Accordion)(({ theme }) => ({
boxShadow: "none",
border: `1px solid ${theme.palette.divider}`,
"&::before": {
display: "none",
},
}));
export interface SettingSectionProps {}
const SSOSettings = () => {
const [open, setOpen] = useState(false);
const { t } = useTranslation("dashboard");
const onClick = useCallback((event: React.MouseEvent<HTMLDivElement>) => {
event.stopPropagation();
setOpen(true);
}, []);
return (
<>
<ProDialog open={open} onClose={() => setOpen(false)} />
<div onClick={onClick}>
<StyledAccordion expanded={false} disableGutters>
<AccordionSummary expandIcon={<ExpandMoreRounded />}>
<FormControlLabel control={<StyledCheckbox size={"small"} checked={false} />} label={t("vas.qqConnect")} />
</AccordionSummary>
<AccordionDetails sx={{ display: "block" }}></AccordionDetails>
</StyledAccordion>
<StyledAccordion expanded={false} disableGutters>
<AccordionSummary expandIcon={<ExpandMoreRounded />}>
<FormControlLabel control={<StyledCheckbox size={"small"} checked={false} />} label={t("settings.logto")} />
</AccordionSummary>
<AccordionDetails sx={{ display: "block" }}></AccordionDetails>
</StyledAccordion>
<StyledAccordion expanded={false} disableGutters>
<AccordionSummary expandIcon={<ExpandMoreRounded />}>
<FormControlLabel control={<StyledCheckbox size={"small"} checked={false} />} label={t("settings.oidc")} />
</AccordionSummary>
<AccordionDetails sx={{ display: "block" }}></AccordionDetails>
</StyledAccordion>
</div>
</>
);
};
export default SSOSettings;

View File

@@ -0,0 +1,244 @@
import { Box, FormControl, FormControlLabel, Link, ListItemText, Stack, Switch, Typography } from "@mui/material";
import { useContext, useMemo } from "react";
import { Trans, useTranslation } from "react-i18next";
import { Link as RouterLink } from "react-router-dom";
import { isTrueVal } from "../../../../session/utils.ts";
import SizeInput from "../../../Common/SizeInput.tsx";
import { DenseFilledTextField, DenseSelect } from "../../../Common/StyledComponents.tsx";
import { SquareMenuItem } from "../../../FileManager/ContextMenu/ContextMenu.tsx";
import SettingForm, { ProChip } from "../../../Pages/Setting/SettingForm.tsx";
import { Code } from "../../../Common/Code.tsx";
import GroupSelectionInput from "../../Common/GroupSelectionInput.tsx";
import SharesInput from "../../Common/SharesInput.tsx";
import { NoMarginHelperText, SettingSection, SettingSectionContent } from "../Settings.tsx";
import { SettingContext } from "../SettingWrapper.tsx";
import SSOSettings from "./SSOSettings.tsx";
const UserSession = () => {
const { t } = useTranslation("dashboard");
const { formRef, setSettings, values } = useContext(SettingContext);
const defaultSymbolics = useMemo(() => {
let result: number[] = [];
try {
result = JSON.parse(values?.default_symbolics ?? "[]");
} catch (e) {
console.error(e);
}
return result;
}, [values?.default_symbolics]);
return (
<Box component={"form"} ref={formRef} onSubmit={(e) => e.preventDefault()}>
<Stack spacing={5}>
<SettingSection>
<Typography variant="h6" gutterBottom>
{t("settings.accountManagement")}
</Typography>
<SettingSectionContent>
<SettingForm lgWidth={5}>
<FormControl fullWidth>
<FormControlLabel
control={
<Switch
checked={isTrueVal(values.register_enabled)}
onChange={(e) =>
setSettings({
register_enabled: e.target.checked ? "1" : "0",
})
}
/>
}
label={t("settings.allowNewRegistrations")}
/>
<NoMarginHelperText>{t("settings.allowNewRegistrationsDes")}</NoMarginHelperText>
</FormControl>
</SettingForm>
<SettingForm lgWidth={5}>
<FormControl fullWidth>
<FormControlLabel
control={
<Switch
checked={isTrueVal(values.email_active)}
onChange={(e) =>
setSettings({
email_active: e.target.checked ? "1" : "0",
})
}
/>
}
label={t("settings.emailActivation")}
/>
<NoMarginHelperText>
<Trans
i18nKey="settings.emailActivationDes"
ns={"dashboard"}
components={[<Link href={"/admin/settings?tab=email"} />]}
/>
</NoMarginHelperText>
</FormControl>
</SettingForm>
<SettingForm lgWidth={5}>
<FormControl fullWidth>
<FormControlLabel
control={
<Switch
checked={isTrueVal(values.authn_enabled)}
onChange={(e) =>
setSettings({
authn_enabled: e.target.checked ? "1" : "0",
})
}
/>
}
label={t("settings.webauthn")}
/>
<NoMarginHelperText>{t("settings.webauthnDes")}</NoMarginHelperText>
</FormControl>
</SettingForm>
<SettingForm title={t("settings.defaultGroup")} lgWidth={5}>
<FormControl>
<GroupSelectionInput
value={values.default_group}
onChange={(g) =>
setSettings({
default_group: g,
})
}
/>
<NoMarginHelperText>{t("settings.defaultGroupDes")}</NoMarginHelperText>
</FormControl>
</SettingForm>
<SettingForm title={t("settings.defaultSymbolics")} lgWidth={5} pro>
<FormControl>
<SharesInput />
<NoMarginHelperText>
<Trans
i18nKey="settings.defaultSymbolicsDes"
ns={"dashboard"}
components={[<Link component={RouterLink} to={"/admin/share"} />]}
/>
</NoMarginHelperText>
</FormControl>
</SettingForm>
<SettingForm title={t("vas.filterEmailProvider")} lgWidth={5} pro>
<FormControl>
<DenseSelect value={0}>
{["filterEmailProviderDisabled", "filterEmailProviderWhitelist", "filterEmailProviderBlacklist"].map(
(v, i) => (
<SquareMenuItem value={i.toString()}>
<ListItemText
slotProps={{
primary: { variant: "body2" },
}}
>
{t(`vas.${v}`)}
</ListItemText>
</SquareMenuItem>
),
)}
</DenseSelect>
<NoMarginHelperText>{t("vas.filterEmailProviderDes")}</NoMarginHelperText>
</FormControl>
</SettingForm>
<SettingForm lgWidth={5} pro>
<FormControl fullWidth>
<FormControlLabel
control={<Switch checked={false} />}
label={
<>
{t("vas.disableSubAddressEmail")}
<ProChip label="Pro" color="primary" size="small" />
</>
}
/>
<NoMarginHelperText>
<Trans i18nKey="vas.disableSubAddressEmailDes" ns={"dashboard"} components={[<Code />]} />
</NoMarginHelperText>
</FormControl>
</SettingForm>
</SettingSectionContent>
</SettingSection>
<SettingSection>
<Typography variant="h6" gutterBottom sx={{ display: "flex", alignItems: "center" }}>
{t("settings.thirdPartySignIn")} <ProChip label="Pro" color="primary" size="small" />
</Typography>
<SettingSectionContent>
<SettingForm lgWidth={5}>
<SSOSettings />
</SettingForm>
</SettingSectionContent>
</SettingSection>
<SettingSection>
<Typography variant="h6" gutterBottom>
{t("settings.avatar")}
</Typography>
<SettingSectionContent>
<SettingForm title={t("settings.avatarFilePath")} lgWidth={5}>
<FormControl fullWidth>
<DenseFilledTextField
value={values.avatar_path}
onChange={(e) =>
setSettings({
avatar_path: e.target.value,
})
}
required
/>
<NoMarginHelperText>{t("settings.avatarFilePathDes")}</NoMarginHelperText>
</FormControl>
</SettingForm>
<SettingForm title={t("settings.avatarSize")} lgWidth={5}>
<FormControl>
<SizeInput
variant={"outlined"}
required
label={t("application:navbar.minimum")}
value={parseInt(values.avatar_size) ?? 0}
onChange={(e) =>
setSettings({
avatar_size: e.toString(),
})
}
/>
<NoMarginHelperText>{t("settings.avatarSizeDes")}</NoMarginHelperText>
</FormControl>
</SettingForm>
<SettingForm title={t("settings.avatarImageSize")} lgWidth={5}>
<FormControl fullWidth>
<DenseFilledTextField
value={values.avatar_size_l}
onChange={(e) =>
setSettings({
avatar_size_l: e.target.value,
})
}
type={"number"}
inputProps={{ step: 1, min: 1 }}
required
/>
<NoMarginHelperText>{t("settings.avatarImageSizeDes")}</NoMarginHelperText>
</FormControl>
</SettingForm>
<SettingForm title={t("settings.gravatarServer")} lgWidth={5}>
<FormControl fullWidth>
<DenseFilledTextField
value={values.gravatar_server}
onChange={(e) =>
setSettings({
gravatar_server: e.target.value,
})
}
required
/>
<NoMarginHelperText>{t("settings.gravatarServerDes")}</NoMarginHelperText>
</FormControl>
</SettingForm>
</SettingSectionContent>
</SettingSection>
</Stack>
</Box>
);
};
export default UserSession;

View File

@@ -0,0 +1,117 @@
import { Box, Chip, Stack, Table, TableBody, TableContainer, TableHead, TableRow } from "@mui/material";
import { useSnackbar } from "notistack";
import { useState } from "react";
import { useTranslation } from "react-i18next";
import { useDispatch } from "react-redux";
import { AnyAction } from "redux";
import { ThunkDispatch } from "redux-thunk";
import { NoWrapCell, SecondaryButton, StyledTableContainerPaper } from "../../../Common/StyledComponents.tsx";
import Add from "../../../Icons/Add.tsx";
import TablePagination from "../../Common/TablePagination.tsx";
interface GiftCodesProps {
storageProductsConfig: string;
groupProductsConfig: string;
}
// Simplified GiftCode interface for our component use
interface GiftCode {
id: number;
code: string;
used: boolean;
qyt: number;
}
// Pagination params
interface PaginationParams {
page: number;
perPage: number;
total: number;
}
const GiftCodeStatusChip = ({ used }: { used: boolean }) => {
const { t } = useTranslation("dashboard");
return (
<Chip
color={used ? "default" : "success"}
label={used ? t("giftCodes.giftCodeUsed") : t("giftCodes.giftCodeUnused")}
size="small"
/>
);
};
const GiftCodes = ({ storageProductsConfig, groupProductsConfig }: GiftCodesProps) => {
const { t } = useTranslation("dashboard");
const dispatch = useDispatch<ThunkDispatch<any, any, AnyAction>>();
const { enqueueSnackbar } = useSnackbar();
const [dialogOpen, setDialogOpen] = useState(false);
const [giftCodes, setGiftCodes] = useState<GiftCode[]>([]);
const [loading, setLoading] = useState(false);
// Pagination state
const [pagination, setPagination] = useState<PaginationParams>({
page: 1,
perPage: 10,
total: 0,
});
const handleChangeRowsPerPage = (pageSize: number) => {
setPagination({
page: 1,
perPage: pageSize,
total: pagination.total,
});
};
return (
<Stack spacing={2}>
<Box>
<SecondaryButton variant="contained" startIcon={<Add />} onClick={() => setDialogOpen(true)}>
{t("giftCodes.generateGiftCodes")}
</SecondaryButton>
</Box>
<StyledTableContainerPaper>
<TableContainer>
<Table size="small">
<TableHead>
<TableRow>
<NoWrapCell>#</NoWrapCell>
<NoWrapCell>{t("giftCodes.giftCodeProduct")}</NoWrapCell>
<NoWrapCell>{t("giftCodes.giftCodeAmount")}</NoWrapCell>
<NoWrapCell>{t("giftCodes.giftCode")}</NoWrapCell>
<NoWrapCell>{t("giftCodes.giftCodeStatus")}</NoWrapCell>
<NoWrapCell>{t("giftCodes.giftCodeUsedBy")}</NoWrapCell>
<NoWrapCell align="right"></NoWrapCell>
</TableRow>
</TableHead>
<TableBody>
{giftCodes.length === 0 && !loading && (
<TableRow>
<NoWrapCell colSpan={6} align="center">
{t("giftCodes.noGiftCodes")}
</NoWrapCell>
</TableRow>
)}
</TableBody>
</Table>
</TableContainer>
{pagination?.total > 0 && (
<Box sx={{ px: 1 }}>
<TablePagination
totalItems={pagination.total}
page={pagination.page}
rowsPerPage={pagination.perPage}
rowsPerPageOptions={[10, 25, 50, 100]}
onRowsPerPageChange={handleChangeRowsPerPage}
onChange={(_, page) => setPagination({ ...pagination, page })}
/>
</Box>
)}
</StyledTableContainerPaper>
</Stack>
);
};
export default GiftCodes;

View File

@@ -0,0 +1,43 @@
import { Box, Table, TableBody, TableContainer, TableHead, TableRow, Typography } from "@mui/material";
import { useTranslation } from "react-i18next";
import { NoWrapCell, SecondaryButton, StyledTableContainerPaper } from "../../../Common/StyledComponents.tsx";
import Add from "../../../Icons/Add.tsx";
const GroupProducts = () => {
const { t } = useTranslation("dashboard");
return (
<Box>
<Box sx={{ display: "flex", justifyContent: "space-between", alignItems: "center", mb: 1 }}>
<SecondaryButton variant="contained" startIcon={<Add />}>
{t("settings.addGroupProduct")}
</SecondaryButton>
</Box>
<TableContainer component={StyledTableContainerPaper}>
<Table sx={{ width: "100%" }} size="small">
<TableHead>
<TableRow>
<NoWrapCell>{t("settings.displayName")}</NoWrapCell>
<NoWrapCell>{t("settings.price")}</NoWrapCell>
<NoWrapCell>{t("settings.duration")}</NoWrapCell>
<NoWrapCell>{t("settings.description")}</NoWrapCell>
<NoWrapCell>{t("settings.actions")}</NoWrapCell>
</TableRow>
</TableHead>
<TableBody>
<TableRow>
<NoWrapCell colSpan={5} align="center">
<Typography variant="caption" color="text.secondary">
{t("application:setting.listEmpty")}
</Typography>
</NoWrapCell>
</TableRow>
</TableBody>
</Table>
</TableContainer>
</Box>
);
};
export default GroupProducts;

View File

@@ -0,0 +1,22 @@
import { Add } from "@mui/icons-material";
import { Box, Stack } from "@mui/material";
import { useTranslation } from "react-i18next";
import { SecondaryButton } from "../../../Common/StyledComponents";
export interface PaymentProviderProps {}
const PaymentProviders: React.FC<PaymentProviderProps> = ({}) => {
const { t } = useTranslation("dashboard");
return (
<Stack spacing={2}>
<Box>
<SecondaryButton variant="contained" startIcon={<Add />}>
{t("settings.addPaymentProvider")}
</SecondaryButton>
</Box>
</Stack>
);
};
export default PaymentProviders;

View File

@@ -0,0 +1,43 @@
import { Box, Table, TableBody, TableContainer, TableHead, TableRow, Typography } from "@mui/material";
import { useTranslation } from "react-i18next";
import { NoWrapCell, SecondaryButton, StyledTableContainerPaper } from "../../../Common/StyledComponents.tsx";
import Add from "../../../Icons/Add.tsx";
const StorageProducts = () => {
const { t } = useTranslation("dashboard");
return (
<Box>
<Box sx={{ display: "flex", justifyContent: "space-between", alignItems: "center", mb: 1 }}>
<SecondaryButton variant="contained" startIcon={<Add />}>
{t("settings.addStorageProduct")}
</SecondaryButton>
</Box>
<TableContainer component={StyledTableContainerPaper}>
<Table sx={{ width: "100%" }} size="small">
<TableHead>
<TableRow>
<NoWrapCell>{t("settings.displayName")}</NoWrapCell>
<NoWrapCell>{t("settings.price")}</NoWrapCell>
<NoWrapCell>{t("settings.duration")}</NoWrapCell>
<NoWrapCell>{t("settings.storageSize")}</NoWrapCell>
<NoWrapCell>{t("settings.actions")}</NoWrapCell>
</TableRow>
</TableHead>
<TableBody>
<TableRow>
<NoWrapCell colSpan={5} align="center">
<Typography variant="caption" color="text.secondary">
{t("application:setting.listEmpty")}
</Typography>
</NoWrapCell>
</TableRow>
</TableBody>
</Table>
</TableContainer>
</Box>
);
};
export default StorageProducts;

View File

@@ -0,0 +1,225 @@
import {
Box,
Button,
FormControl,
FormControlLabel,
InputAdornment,
Link,
Stack,
Switch,
Typography,
} from "@mui/material";
import { bindTrigger, usePopupState } from "material-ui-popup-state/hooks";
import { useContext, useMemo, useState } from "react";
import { Trans, useTranslation } from "react-i18next";
import { DenseFilledTextField } from "../../../Common/StyledComponents.tsx";
import SettingForm, { ProChip } from "../../../Pages/Setting/SettingForm.tsx";
import ProDialog from "../../Common/ProDialog.tsx";
import { NoMarginHelperText, SettingSection, SettingSectionContent } from "../Settings.tsx";
import { SettingContext } from "../SettingWrapper.tsx";
import GiftCodes from "./GiftCodes.tsx";
import GroupProducts from "./GroupProducts.tsx";
import PaymentProviders from "./PaymentProviders.tsx";
import StorageProducts from "./StorageProducts.tsx";
interface CurrencyOption {
code: string;
symbol: string;
unit: number;
label: string;
}
const VAS = () => {
const { t } = useTranslation("dashboard");
const [proOpen, setProOpen] = useState(false);
const { formRef, setSettings, values } = useContext(SettingContext);
const currencyPopupState = usePopupState({
variant: "popover",
popupId: "currencySelector",
});
const paymentConfig = useMemo(() => JSON.parse(values.payment || "{}"), [values.payment]);
const storageProducts = useMemo(() => values.storage_products || "[]", [values.storage_products]);
const groupSellData = useMemo(() => values.group_sell_data || "[]", [values.group_sell_data]);
const onProClick = (e: React.MouseEvent<HTMLDivElement>) => {
e.preventDefault();
setProOpen(true);
};
return (
<Box component={"form"} ref={formRef}>
<ProDialog open={proOpen} onClose={() => setProOpen(false)} />
<Stack spacing={5}>
<SettingSection>
<Typography variant="h6" gutterBottom sx={{ display: "flex", alignItems: "center" }}>
{t("settings.creditAndVAS")} <ProChip label="Pro" color="primary" size="small" />
</Typography>
<SettingSectionContent onClick={onProClick}>
<SettingForm lgWidth={5}>
<FormControl fullWidth>
<FormControlLabel control={<Switch checked={false} />} label={t("settings.enableCredit")} />
<NoMarginHelperText>{t("settings.enableCreditDes")}</NoMarginHelperText>
</FormControl>
</SettingForm>
<Stack spacing={2}>
<SettingForm title={t("settings.creditPrice")} lgWidth={5}>
<FormControl fullWidth>
<DenseFilledTextField type="number" slotProps={{ input: { readOnly: true } }} value={1} />
<NoMarginHelperText>{t("settings.creditPriceDes")}</NoMarginHelperText>
</FormControl>
</SettingForm>
<SettingForm title={t("settings.shareScoreRate")} lgWidth={5}>
<FormControl fullWidth>
<DenseFilledTextField type="number" value={80} slotProps={{ input: { readOnly: true } }} />
<NoMarginHelperText>{t("settings.shareScoreRateDes")}</NoMarginHelperText>
</FormControl>
</SettingForm>
</Stack>
<SettingForm title={t("vas.banBufferPeriod")} lgWidth={5}>
<FormControl fullWidth>
<DenseFilledTextField type="number" slotProps={{ input: { readOnly: true } }} value={864000} />
<NoMarginHelperText>{t("vas.banBufferPeriodDes")}</NoMarginHelperText>
</FormControl>
</SettingForm>
<SettingForm title={t("settings.cronNotifyUser")} lgWidth={5}>
<FormControl fullWidth>
<DenseFilledTextField value={"@every 1h"} slotProps={{ input: { readOnly: true } }} />
<NoMarginHelperText>
<Trans
i18nKey="settings.cronDes"
values={{
des: t("settings.cronNotifyUserDes"),
}}
ns={"dashboard"}
components={[<Link href="https://crontab.guru/" target="_blank" rel="noopener noreferrer" />]}
/>
</NoMarginHelperText>
</FormControl>
</SettingForm>
<SettingForm title={t("settings.cronBanUser")} lgWidth={5}>
<FormControl fullWidth>
<DenseFilledTextField value={"@every 1h"} slotProps={{ input: { readOnly: true } }} />
<NoMarginHelperText>
<Trans
i18nKey="settings.cronDes"
values={{
des: t("settings.cronBanUserDes"),
}}
ns={"dashboard"}
components={[<Link href="https://crontab.guru/" target="_blank" rel="noopener noreferrer" />]}
/>
</NoMarginHelperText>
</FormControl>
</SettingForm>
<SettingForm lgWidth={5}>
<FormControl fullWidth>
<FormControlLabel control={<Switch checked={false} />} label={t("settings.anonymousPurchase")} />
<NoMarginHelperText>{t("settings.anonymousPurchaseDes")}</NoMarginHelperText>
</FormControl>
</SettingForm>
<SettingForm lgWidth={5}>
<FormControl fullWidth>
<FormControlLabel control={<Switch checked={false} />} label={t("settings.shopNavEnabled")} />
<NoMarginHelperText>{t("settings.shopNavEnabledDes")}</NoMarginHelperText>
</FormControl>
</SettingForm>
</SettingSectionContent>
</SettingSection>
<SettingSection>
<Typography variant="h6" gutterBottom sx={{ display: "flex", alignItems: "center" }}>
{t("settings.paymentSettings")} <ProChip label="Pro" color="primary" size="small" />
</Typography>
<SettingSectionContent onClick={onProClick}>
<SettingForm title={t("settings.currencyCode")} lgWidth={5}>
<FormControl fullWidth>
<DenseFilledTextField
value="USD"
slotProps={{
input: {
readOnly: true,
endAdornment: (
<InputAdornment position="end">
<Button {...bindTrigger(currencyPopupState)}>{t("settings.selectCurrency")}</Button>
</InputAdornment>
),
},
}}
/>
<NoMarginHelperText>{t("settings.currencyCodeDes")}</NoMarginHelperText>
</FormControl>
</SettingForm>
<SettingForm title={t("settings.currencySymbol")} lgWidth={5}>
<FormControl fullWidth>
<DenseFilledTextField value={"$"} slotProps={{ input: { readOnly: true } }} />
<NoMarginHelperText>{t("settings.currencySymbolDes")}</NoMarginHelperText>
</FormControl>
</SettingForm>
<SettingForm title={t("settings.currencyUnit")} lgWidth={5}>
<FormControl fullWidth>
<DenseFilledTextField type="number" value={100} slotProps={{ input: { readOnly: true } }} />
<NoMarginHelperText>{t("settings.currencyUnitDes")}</NoMarginHelperText>
</FormControl>
</SettingForm>
<Box sx={{ mt: 2 }}>
<Typography variant="subtitle1" gutterBottom>
{t("settings.paymentProviders")}
</Typography>
<SettingForm lgWidth={6}>
<PaymentProviders />
</SettingForm>
</Box>
</SettingSectionContent>
</SettingSection>
<SettingSection>
<Typography variant="h6" gutterBottom sx={{ display: "flex", alignItems: "center" }}>
{t("settings.storageProductSettings")} <ProChip label="Pro" color="primary" size="small" />
</Typography>
<SettingSectionContent onClick={onProClick}>
<SettingForm lgWidth={12}>
<FormControl fullWidth>
<StorageProducts />
<NoMarginHelperText>{t("settings.storageProductsDes")}</NoMarginHelperText>
</FormControl>
</SettingForm>
</SettingSectionContent>
</SettingSection>
<SettingSection>
<Typography variant="h6" gutterBottom sx={{ display: "flex", alignItems: "center" }}>
{t("settings.groupProductSettings")} <ProChip label="Pro" color="primary" size="small" />
</Typography>
<SettingSectionContent onClick={onProClick}>
<SettingForm lgWidth={12}>
<FormControl fullWidth>
<GroupProducts />
<NoMarginHelperText>{t("settings.groupProductsDes")}</NoMarginHelperText>
</FormControl>
</SettingForm>
</SettingSectionContent>
</SettingSection>
<SettingSection>
<Typography variant="h6" gutterBottom sx={{ display: "flex", alignItems: "center" }}>
{t("giftCodes.giftCodesSettings")} <ProChip label="Pro" color="primary" size="small" />
</Typography>
<SettingSectionContent onClick={onProClick}>
<GiftCodes storageProductsConfig={storageProducts} groupProductsConfig={groupSellData} />
</SettingSectionContent>
</SettingSection>
</Stack>
</Box>
);
};
export default VAS;

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 { getShareDetail } from "../../../../api/api.ts";
import { Share } 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 ShareForm from "./ShareForm.tsx";
export interface ShareDialogProps {
open: boolean;
onClose: () => void;
shareID?: number;
}
const ShareDialog = ({ open, onClose, shareID }: ShareDialogProps) => {
const dispatch = useAppDispatch();
const { t } = useTranslation("dashboard");
const [values, setValues] = useState<Share>({ edges: {}, id: 0 });
const [loading, setLoading] = useState(true);
useEffect(() => {
if (!shareID || !open) {
return;
}
setLoading(true);
dispatch(getShareDetail(shareID))
.then((res) => {
setValues(res);
})
.catch(() => {
onClose();
})
.finally(() => {
setLoading(false);
});
}, [open]);
return (
<DraggableDialog
title={t("share.shareDialogTitle")}
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 && <ShareForm values={values} />}
</Box>
</CSSTransition>
</SwitchTransition>
</AutoHeight>
</DialogContent>
</DraggableDialog>
);
};
export default ShareDialog;

Some files were not shown because too many files have changed in this diff Show More