first commit
This commit is contained in:
33
src/component/Admin/AdminBundle.tsx
Executable file
33
src/component/Admin/AdminBundle.tsx
Executable 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,
|
||||
};
|
||||
41
src/component/Admin/Common/AdminCard.tsx
Executable file
41
src/component/Admin/Common/AdminCard.tsx
Executable 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,
|
||||
},
|
||||
}));
|
||||
44
src/component/Admin/Common/EndpointInput.tsx
Executable file
44
src/component/Admin/Common/EndpointInput.tsx
Executable 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}
|
||||
/>
|
||||
);
|
||||
};
|
||||
94
src/component/Admin/Common/GroupSelectionInput.tsx
Executable file
94
src/component/Admin/Common/GroupSelectionInput.tsx
Executable 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;
|
||||
60
src/component/Admin/Common/MagicVarDialog.tsx
Executable file
60
src/component/Admin/Common/MagicVarDialog.tsx
Executable 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;
|
||||
89
src/component/Admin/Common/NodeSelectionInput.tsx
Executable file
89
src/component/Admin/Common/NodeSelectionInput.tsx
Executable 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;
|
||||
141
src/component/Admin/Common/ProDialog.tsx
Executable file
141
src/component/Admin/Common/ProDialog.tsx
Executable 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;
|
||||
45
src/component/Admin/Common/SharesInput.tsx
Executable file
45
src/component/Admin/Common/SharesInput.tsx
Executable 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;
|
||||
122
src/component/Admin/Common/SinglePolicySelectionInput.tsx
Executable file
122
src/component/Admin/Common/SinglePolicySelectionInput.tsx
Executable 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;
|
||||
86
src/component/Admin/Common/TablePagination.tsx
Executable file
86
src/component/Admin/Common/TablePagination.tsx
Executable 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;
|
||||
70
src/component/Admin/Entity/EntityDeleteDialog.tsx
Executable file
70
src/component/Admin/Entity/EntityDeleteDialog.tsx
Executable 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;
|
||||
84
src/component/Admin/Entity/EntityDialog/EntityDialog.tsx
Executable file
84
src/component/Admin/Entity/EntityDialog/EntityDialog.tsx
Executable 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;
|
||||
119
src/component/Admin/Entity/EntityDialog/EntityFileList.tsx
Executable file
119
src/component/Admin/Entity/EntityDialog/EntityFileList.tsx
Executable 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;
|
||||
114
src/component/Admin/Entity/EntityDialog/EntityForm.tsx
Executable file
114
src/component/Admin/Entity/EntityDialog/EntityForm.tsx
Executable 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;
|
||||
153
src/component/Admin/Entity/EntityFilterPopover.tsx
Executable file
153
src/component/Admin/Entity/EntityFilterPopover.tsx
Executable 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;
|
||||
218
src/component/Admin/Entity/EntityRow.tsx
Executable file
218
src/component/Admin/Entity/EntityRow.tsx
Executable 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;
|
||||
328
src/component/Admin/Entity/EntitySetting.tsx
Executable file
328
src/component/Admin/Entity/EntitySetting.tsx
Executable 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;
|
||||
162
src/component/Admin/File/FileDialog/FileDialog.tsx
Executable file
162
src/component/Admin/File/FileDialog/FileDialog.tsx
Executable 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;
|
||||
109
src/component/Admin/File/FileDialog/FileDirectLinks.tsx
Executable file
109
src/component/Admin/File/FileDialog/FileDirectLinks.tsx
Executable 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;
|
||||
130
src/component/Admin/File/FileDialog/FileEntity.tsx
Executable file
130
src/component/Admin/File/FileDialog/FileEntity.tsx
Executable 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;
|
||||
162
src/component/Admin/File/FileDialog/FileForm.tsx
Executable file
162
src/component/Admin/File/FileDialog/FileForm.tsx
Executable 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;
|
||||
114
src/component/Admin/File/FileDialog/FileMetadata.tsx
Executable file
114
src/component/Admin/File/FileDialog/FileMetadata.tsx
Executable 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;
|
||||
197
src/component/Admin/File/FileFilterPopover.tsx
Executable file
197
src/component/Admin/File/FileFilterPopover.tsx
Executable 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;
|
||||
261
src/component/Admin/File/FileRow.tsx
Executable file
261
src/component/Admin/File/FileRow.tsx
Executable 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;
|
||||
353
src/component/Admin/File/FileSetting.tsx
Executable file
353
src/component/Admin/File/FileSetting.tsx
Executable 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;
|
||||
190
src/component/Admin/File/ImportFileDialog.tsx
Executable file
190
src/component/Admin/File/ImportFileDialog.tsx
Executable 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;
|
||||
142
src/component/Admin/File/UserSearchInput.tsx
Executable file
142
src/component/Admin/File/UserSearchInput.tsx
Executable 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;
|
||||
207
src/component/Admin/FileSystem/CustomProps/CustomPropsSetting.tsx
Executable file
207
src/component/Admin/FileSystem/CustomProps/CustomPropsSetting.tsx
Executable 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;
|
||||
209
src/component/Admin/FileSystem/CustomProps/DraggableCustomPropsRow.tsx
Executable file
209
src/component/Admin/FileSystem/CustomProps/DraggableCustomPropsRow.tsx
Executable 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;
|
||||
179
src/component/Admin/FileSystem/CustomProps/EditPropsDialog.tsx
Executable file
179
src/component/Admin/FileSystem/CustomProps/EditPropsDialog.tsx
Executable 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;
|
||||
133
src/component/Admin/FileSystem/Filesystem.tsx
Executable file
133
src/component/Admin/FileSystem/Filesystem.tsx
Executable 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;
|
||||
39
src/component/Admin/FileSystem/HexColorInput.tsx
Executable file
39
src/component/Admin/FileSystem/HexColorInput.tsx
Executable 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;
|
||||
245
src/component/Admin/FileSystem/Icons/EmojiList.tsx
Executable file
245
src/component/Admin/FileSystem/Icons/EmojiList.tsx
Executable 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;
|
||||
350
src/component/Admin/FileSystem/Icons/FileIconList.tsx
Executable file
350
src/component/Admin/FileSystem/Icons/FileIconList.tsx
Executable 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;
|
||||
58
src/component/Admin/FileSystem/Icons/FileIcons.tsx
Executable file
58
src/component/Admin/FileSystem/Icons/FileIcons.tsx
Executable 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;
|
||||
610
src/component/Admin/FileSystem/Parameters.tsx
Executable file
610
src/component/Admin/FileSystem/Parameters.tsx
Executable 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;
|
||||
543
src/component/Admin/FileSystem/ViewerSetting/FileViewerEditDialog.tsx
Executable file
543
src/component/Admin/FileSystem/ViewerSetting/FileViewerEditDialog.tsx
Executable 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;
|
||||
343
src/component/Admin/FileSystem/ViewerSetting/FileViewerList.tsx
Executable file
343
src/component/Admin/FileSystem/ViewerSetting/FileViewerList.tsx
Executable 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;
|
||||
151
src/component/Admin/FileSystem/ViewerSetting/FileViewerRow.tsx
Executable file
151
src/component/Admin/FileSystem/ViewerSetting/FileViewerRow.tsx
Executable 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;
|
||||
72
src/component/Admin/FileSystem/ViewerSetting/ImportWopiDialog.tsx
Executable file
72
src/component/Admin/FileSystem/ViewerSetting/ImportWopiDialog.tsx
Executable 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;
|
||||
33
src/component/Admin/FileSystem/ViewerSetting/ViewerSetting.tsx
Executable file
33
src/component/Admin/FileSystem/ViewerSetting/ViewerSetting.tsx
Executable 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;
|
||||
116
src/component/Admin/Group/EditGroup/BasicInfoSection.tsx
Executable file
116
src/component/Admin/Group/EditGroup/BasicInfoSection.tsx
Executable 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;
|
||||
27
src/component/Admin/Group/EditGroup/EditGroup.tsx
Executable file
27
src/component/Admin/Group/EditGroup/EditGroup.tsx
Executable 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;
|
||||
388
src/component/Admin/Group/EditGroup/FileManagementSection.tsx
Executable file
388
src/component/Admin/Group/EditGroup/FileManagementSection.tsx
Executable 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;
|
||||
25
src/component/Admin/Group/EditGroup/GroupForm.tsx
Executable file
25
src/component/Admin/Group/EditGroup/GroupForm.tsx
Executable 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;
|
||||
145
src/component/Admin/Group/EditGroup/GroupSettingWrapper.tsx
Executable file
145
src/component/Admin/Group/EditGroup/GroupSettingWrapper.tsx
Executable 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;
|
||||
41
src/component/Admin/Group/EditGroup/MultipleNodeSelectionInput.tsx
Executable file
41
src/component/Admin/Group/EditGroup/MultipleNodeSelectionInput.tsx
Executable 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;
|
||||
102
src/component/Admin/Group/EditGroup/PolicySelectionInput.tsx
Executable file
102
src/component/Admin/Group/EditGroup/PolicySelectionInput.tsx
Executable 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;
|
||||
113
src/component/Admin/Group/EditGroup/ShareSection.tsx
Executable file
113
src/component/Admin/Group/EditGroup/ShareSection.tsx
Executable 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;
|
||||
182
src/component/Admin/Group/EditGroup/UploadDownloadSection.tsx
Executable file
182
src/component/Admin/Group/EditGroup/UploadDownloadSection.tsx
Executable 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;
|
||||
179
src/component/Admin/Group/GroupRow.tsx
Executable file
179
src/component/Admin/Group/GroupRow.tsx
Executable 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;
|
||||
152
src/component/Admin/Group/GroupSetting.tsx
Executable file
152
src/component/Admin/Group/GroupSetting.tsx
Executable 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;
|
||||
132
src/component/Admin/Group/NewGroupDIalog.tsx
Executable file
132
src/component/Admin/Group/NewGroupDIalog.tsx
Executable 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
425
src/component/Admin/Home/Home.tsx
Executable 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;
|
||||
81
src/component/Admin/Home/SiteUrlWarning.tsx
Executable file
81
src/component/Admin/Home/SiteUrlWarning.tsx
Executable 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;
|
||||
167
src/component/Admin/Node/EditNode/BasicInfoSection.tsx
Executable file
167
src/component/Admin/Node/EditNode/BasicInfoSection.tsx
Executable 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;
|
||||
568
src/component/Admin/Node/EditNode/CapabilitiesSection.tsx
Executable file
568
src/component/Admin/Node/EditNode/CapabilitiesSection.tsx
Executable 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;
|
||||
34
src/component/Admin/Node/EditNode/EditNode.tsx
Executable file
34
src/component/Admin/Node/EditNode/EditNode.tsx
Executable 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;
|
||||
19
src/component/Admin/Node/EditNode/NodeForm.tsx
Executable file
19
src/component/Admin/Node/EditNode/NodeForm.tsx
Executable 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;
|
||||
157
src/component/Admin/Node/EditNode/NodeSettingWrapper.tsx
Executable file
157
src/component/Admin/Node/EditNode/NodeSettingWrapper.tsx
Executable 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;
|
||||
35
src/component/Admin/Node/EditNode/StoreFilesHintDialog.tsx
Executable file
35
src/component/Admin/Node/EditNode/StoreFilesHintDialog.tsx
Executable 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;
|
||||
3
src/component/Admin/Node/EditNode/index.tsx
Executable file
3
src/component/Admin/Node/EditNode/index.tsx
Executable file
@@ -0,0 +1,3 @@
|
||||
import EditNode from "./EditNode";
|
||||
|
||||
export default EditNode;
|
||||
234
src/component/Admin/Node/NewNode/NewNodeDialog.tsx
Executable file
234
src/component/Admin/Node/NewNode/NewNodeDialog.tsx
Executable 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>
|
||||
);
|
||||
};
|
||||
200
src/component/Admin/Node/NodeCard.tsx
Executable file
200
src/component/Admin/Node/NodeCard.tsx
Executable 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;
|
||||
131
src/component/Admin/Node/NodeSetting.tsx
Executable file
131
src/component/Admin/Node/NodeSetting.tsx
Executable 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;
|
||||
39
src/component/Admin/Settings/Appearance/Appearance.tsx
Executable file
39
src/component/Admin/Settings/Appearance/Appearance.tsx
Executable 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;
|
||||
249
src/component/Admin/Settings/Appearance/CustomHTML.tsx
Executable file
249
src/component/Admin/Settings/Appearance/CustomHTML.tsx
Executable 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;
|
||||
378
src/component/Admin/Settings/Appearance/CustomNavItems.tsx
Executable file
378
src/component/Admin/Settings/Appearance/CustomNavItems.tsx
Executable 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;
|
||||
220
src/component/Admin/Settings/Appearance/ThemeOptionEditDialog.tsx
Executable file
220
src/component/Admin/Settings/Appearance/ThemeOptionEditDialog.tsx
Executable 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;
|
||||
337
src/component/Admin/Settings/Appearance/ThemeOptions.tsx
Executable file
337
src/component/Admin/Settings/Appearance/ThemeOptions.tsx
Executable 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;
|
||||
139
src/component/Admin/Settings/Captcha/CapCaptcha.tsx
Executable file
139
src/component/Admin/Settings/Captcha/CapCaptcha.tsx
Executable 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;
|
||||
158
src/component/Admin/Settings/Captcha/Captcha.tsx
Executable file
158
src/component/Admin/Settings/Captcha/Captcha.tsx
Executable 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;
|
||||
118
src/component/Admin/Settings/Captcha/GraphicCaptcha.tsx
Executable file
118
src/component/Admin/Settings/Captcha/GraphicCaptcha.tsx
Executable 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;
|
||||
63
src/component/Admin/Settings/Captcha/ReCaptcha.tsx
Executable file
63
src/component/Admin/Settings/Captcha/ReCaptcha.tsx
Executable 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;
|
||||
63
src/component/Admin/Settings/Captcha/TurnstileCaptcha.tsx
Executable file
63
src/component/Admin/Settings/Captcha/TurnstileCaptcha.tsx
Executable 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;
|
||||
205
src/component/Admin/Settings/Email/Email.tsx
Executable file
205
src/component/Admin/Settings/Email/Email.tsx
Executable 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;
|
||||
307
src/component/Admin/Settings/Email/EmailTemplateEditor.tsx
Executable file
307
src/component/Admin/Settings/Email/EmailTemplateEditor.tsx
Executable 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;
|
||||
176
src/component/Admin/Settings/Email/EmailTemplates.tsx
Executable file
176
src/component/Admin/Settings/Email/EmailTemplates.tsx
Executable 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;
|
||||
199
src/component/Admin/Settings/Event/Events.tsx
Executable file
199
src/component/Admin/Settings/Event/Events.tsx
Executable 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;
|
||||
261
src/component/Admin/Settings/Media/Extractors.tsx
Executable file
261
src/component/Admin/Settings/Media/Extractors.tsx
Executable 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;
|
||||
273
src/component/Admin/Settings/Media/Generators.tsx
Executable file
273
src/component/Admin/Settings/Media/Generators.tsx
Executable 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;
|
||||
184
src/component/Admin/Settings/Media/Media.tsx
Executable file
184
src/component/Admin/Settings/Media/Media.tsx
Executable 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;
|
||||
62
src/component/Admin/Settings/Queue/Queue.tsx
Executable file
62
src/component/Admin/Settings/Queue/Queue.tsx
Executable 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;
|
||||
159
src/component/Admin/Settings/Queue/QueueCard.tsx
Executable file
159
src/component/Admin/Settings/Queue/QueueCard.tsx
Executable 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;
|
||||
202
src/component/Admin/Settings/Queue/QueueSettingDialog.tsx
Executable file
202
src/component/Admin/Settings/Queue/QueueSettingDialog.tsx
Executable 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;
|
||||
124
src/component/Admin/Settings/Server/ServerSetting.tsx
Executable file
124
src/component/Admin/Settings/Server/ServerSetting.tsx
Executable 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;
|
||||
191
src/component/Admin/Settings/SettingWrapper.tsx
Executable file
191
src/component/Admin/Settings/SettingWrapper.tsx
Executable 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;
|
||||
345
src/component/Admin/Settings/Settings.tsx
Executable file
345
src/component/Admin/Settings/Settings.tsx
Executable 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;
|
||||
50
src/component/Admin/Settings/SiteInformation/GeneralImagePreview.tsx
Executable file
50
src/component/Admin/Settings/SiteInformation/GeneralImagePreview.tsx
Executable 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;
|
||||
65
src/component/Admin/Settings/SiteInformation/LogoPreview.tsx
Executable file
65
src/component/Admin/Settings/SiteInformation/LogoPreview.tsx
Executable 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;
|
||||
258
src/component/Admin/Settings/SiteInformation/SiteInformation.tsx
Executable file
258
src/component/Admin/Settings/SiteInformation/SiteInformation.tsx
Executable 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;
|
||||
91
src/component/Admin/Settings/SiteInformation/SiteURLInput.tsx
Executable file
91
src/component/Admin/Settings/SiteInformation/SiteURLInput.tsx
Executable 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;
|
||||
66
src/component/Admin/Settings/UserSession/SSOSettings.tsx
Executable file
66
src/component/Admin/Settings/UserSession/SSOSettings.tsx
Executable 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;
|
||||
244
src/component/Admin/Settings/UserSession/UserSession.tsx
Executable file
244
src/component/Admin/Settings/UserSession/UserSession.tsx
Executable 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;
|
||||
117
src/component/Admin/Settings/VAS/GiftCodes.tsx
Executable file
117
src/component/Admin/Settings/VAS/GiftCodes.tsx
Executable 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;
|
||||
43
src/component/Admin/Settings/VAS/GroupProducts.tsx
Executable file
43
src/component/Admin/Settings/VAS/GroupProducts.tsx
Executable 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;
|
||||
22
src/component/Admin/Settings/VAS/PaymentProviders.tsx
Executable file
22
src/component/Admin/Settings/VAS/PaymentProviders.tsx
Executable 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;
|
||||
43
src/component/Admin/Settings/VAS/StorageProducts.tsx
Executable file
43
src/component/Admin/Settings/VAS/StorageProducts.tsx
Executable 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;
|
||||
225
src/component/Admin/Settings/VAS/VAS.tsx
Executable file
225
src/component/Admin/Settings/VAS/VAS.tsx
Executable 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;
|
||||
84
src/component/Admin/Share/ShareDialog/ShareDialog.tsx
Executable file
84
src/component/Admin/Share/ShareDialog/ShareDialog.tsx
Executable 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
Reference in New Issue
Block a user