first commit
This commit is contained in:
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;
|
||||
Reference in New Issue
Block a user