feat: list reordering with drag-and-drop and move buttons

This commit is contained in:
MasonDye 2025-07-01 03:20:18 +08:00
parent 0c9e7b8f7a
commit bd712f506f
5 changed files with 667 additions and 346 deletions

View File

@ -1,3 +1,4 @@
import React from "react";
import { Box, IconButton, Stack, Table, TableBody, TableContainer, TableHead, TableRow } from "@mui/material";
import { memo, useMemo, useState } from "react";
import { useTranslation } from "react-i18next";
@ -10,12 +11,145 @@ import {
} from "../../../Common/StyledComponents.tsx";
import Add from "../../../Icons/Add.tsx";
import Dismiss from "../../../Icons/Dismiss.tsx";
import KeyboardArrowUpIcon from "@mui/icons-material/KeyboardArrowUp";
import KeyboardArrowDownIcon from "@mui/icons-material/KeyboardArrowDown";
import { DndProvider, useDrag, useDrop } from "react-dnd";
import { HTML5Backend } from "react-dnd-html5-backend";
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}>
<KeyboardArrowUpIcon fontSize="small" />
</IconButton>
<IconButton size="small" onClick={() => moveRow(i, i + 1)} disabled={isLast}>
<KeyboardArrowDownIcon fontSize="small" />
</IconButton>
</NoWrapCell>
</TableRow>
);
}
const EmojiList = memo(({ config, onChange }: EmojiListProps) => {
const { t } = useTranslation("dashboard");
const [render, setRender] = useState(false);
@ -32,80 +166,49 @@ const EmojiList = memo(({ config, onChange }: EmojiListProps) => {
</SecondaryButton>
)}
{render && Object.keys(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.category")}</NoWrapTableCell>
<NoWrapTableCell width={200}>{t("settings.emojiOptions")}</NoWrapTableCell>
<NoWrapTableCell width={64}></NoWrapTableCell>
</TableRow>
</TableHead>
<TableBody>
{Object.keys(configParsed ?? {}).map((r, i) => (
<TableRow key={i} sx={{ "&:last-child td, &:last-child th": { border: 0 } }} 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>
</NoWrapCell>
<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>
))}
</TableBody>
</Table>
</TableContainer>
</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 && (

View File

@ -38,6 +38,11 @@ import Dismiss from "../../../../Icons/Dismiss.tsx";
import SettingForm from "../../../../Pages/Setting/SettingForm.tsx";
import MagicVarDialog, { MagicVar } from "../../../Common/MagicVarDialog.tsx";
import { NoMarginHelperText } from "../../Settings.tsx";
import KeyboardArrowUpIcon from "@mui/icons-material/KeyboardArrowUp";
import KeyboardArrowDownIcon from "@mui/icons-material/KeyboardArrowDown";
import { DndProvider, useDrag, useDrop } from "react-dnd";
import { HTML5Backend } from "react-dnd-html5-backend";
import { SelectChangeEvent } from "@mui/material";
const MonacoEditor = lazy(() => import("../../../../Viewers/CodeViewer/MonacoEditor.tsx"));
@ -86,6 +91,96 @@ const magicVars: MagicVar[] = [
},
];
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}>
<KeyboardArrowUpIcon fontSize="small" />
</IconButton>
<IconButton size="small" onClick={() => moveRow(i, i + 1)} disabled={isLast}>
<KeyboardArrowDownIcon fontSize="small" />
</IconButton>
</NoWrapTableCell>
</TableRow>
);
}
const FileViewerEditDialog = ({ viewer, onChange, open, onClose }: FileViewerEditDialogProps) => {
const { t } = useTranslation("dashboard");
const theme = useTheme();
@ -300,99 +395,72 @@ const FileViewerEditDialog = ({ viewer, onChange, open, onClose }: FileViewerEdi
)}
<SettingForm noContainer title={t("settings.newFileAction")} lgWidth={12}>
{viewerShadowed?.templates && viewerShadowed.templates.length > 0 && (
<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={64}></NoWrapTableCell>
</TableRow>
</TableHead>
<TableBody>
{viewerShadowed.templates?.map((t, i) => (
<TableRow
key={i}
sx={{
"&:last-child td, &:last-child th": { border: 0 },
}}
hover
>
<NoWrapTableCell>
<DenseSelect
value={t.ext}
required
onChange={(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,
),
}));
}}
>
{viewerShadowed.exts.map((ext) => (
<SquareMenuItem value={ext}>
<ListItemText
slotProps={{
primary: {
variant: "body2",
},
}}
>
{ext}
</ListItemText>
</SquareMenuItem>
))}
</DenseSelect>
</NoWrapTableCell>
<NoWrapTableCell>
<DenseFilledTextField
fullWidth
required
value={t.display_name}
onChange={(e) => {
setViewerShadowed((v) => ({
...(v as Viewer),
templates: (v?.templates ?? []).map((template, index) =>
index == i
? {
...template,
display_name: e.target.value,
}
: template,
),
}));
}}
/>
</NoWrapTableCell>
<NoWrapTableCell>
<IconButton
size={"small"}
onClick={() =>
setViewerShadowed((v) => ({
...(v as Viewer),
templates: (v?.templates ?? []).filter((_, index) => index != i),
}))
}
>
<Dismiss fontSize={"small"} />
</IconButton>
</NoWrapTableCell>
<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>
))}
</TableBody>
</Table>
</TableContainer>
</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 }}

View File

@ -27,6 +27,8 @@ import { AccordionSummary, StyledAccordion } from "../../UserSession/SSOSettings
import FileViewerEditDialog from "./FileViewerEditDialog.tsx";
import FileViewerRow from "./FileViewerRow.tsx";
import ImportWopiDialog from "./ImportWopiDialog.tsx";
import { DndProvider, useDrag, useDrop } from "react-dnd";
import { HTML5Backend } from "react-dnd-html5-backend";
interface ViewerGroupProps {
group: ViewerGroup;
@ -35,6 +37,66 @@ interface ViewerGroupProps {
onGroupChange: (g: ViewerGroup) => void;
}
const DND_TYPE = "viewer-row";
const DraggableViewerRow = memo(function DraggableViewerRow({
viewer,
index,
moveRow,
onChange,
onDelete,
onMoveUp,
onMoveDown,
isLast,
isFirst,
}: any) {
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 = index;
if (dragIndex === hoverIndex) return;
const hoverBoundingRect = ref.current.getBoundingClientRect();
const hoverMiddleY = (hoverBoundingRect.bottom - hoverBoundingRect.top) / 2;
const clientOffset = monitor.getClientOffset();
if (!clientOffset) return;
const hoverClientY = clientOffset.y - hoverBoundingRect.top;
if (dragIndex < hoverIndex && hoverClientY < hoverMiddleY) return;
if (dragIndex > hoverIndex && hoverClientY > hoverMiddleY) return;
moveRow(dragIndex, hoverIndex);
item.index = hoverIndex;
},
});
const [{ isDragging }, drag] = useDrag({
type: DND_TYPE,
item: { index },
collect: (monitor) => ({
isDragging: monitor.isDragging(),
}),
});
drag(drop(ref));
return (
<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 }: ViewerGroupProps) => {
const { t } = useTranslation("dashboard");
@ -56,6 +118,27 @@ const ViewerGroupRow = memo(({ group, index, onDelete, onGroupChange }: ViewerGr
});
}, [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}
@ -84,31 +167,40 @@ const ViewerGroupRow = memo(({ group, index, onDelete, onGroupChange }: ViewerGr
</Box>
</AccordionSummary>
<AccordionDetails sx={{ display: "block" }}>
<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={100}>{t("settings.newFileAction")}</NoWrapTableCell>
<NoWrapTableCell width={64}>{t("settings.viewerEnabled")}</NoWrapTableCell>
<NoWrapTableCell width={100}></NoWrapTableCell>
</TableRow>
</TableHead>
<TableBody>
{group.viewers.map((viewer, index) => (
<FileViewerRow
onChange={onViewerChange[index]}
onDelete={onViewerDeleted[index]}
viewer={viewer}
key={viewer.id}
/>
))}
</TableBody>
</Table>
</TableContainer>
<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={100}>{t("settings.newFileAction")}</NoWrapTableCell>
<NoWrapTableCell width={64}>{t("settings.viewerEnabled")}</NoWrapTableCell>
<NoWrapTableCell width={64}>{t("settings.actions")}</NoWrapTableCell>
<NoWrapTableCell width={64}></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}
/>
))}
</TableBody>
</Table>
</TableContainer>
</DndProvider>
</AccordionDetails>
</StyledAccordion>
);

View File

@ -8,75 +8,111 @@ 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 KeyboardArrowUpIcon from "@mui/icons-material/KeyboardArrowUp";
import KeyboardArrowDownIcon from "@mui/icons-material/KeyboardArrowDown";
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 = memo(({ viewer, onChange, onDelete }: FileViewerRowProps) => {
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>
<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>
{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>
</TableRow>
);
});
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>
{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}>
<KeyboardArrowUpIcon fontSize="small" />
</IconButton>
<IconButton size="small" onClick={onMoveDown} disabled={isLast}>
<KeyboardArrowDownIcon fontSize="small" />
</IconButton>
</NoWrapCell>
</TableRow>
);
}
)
);
export default FileViewerRow;

View File

@ -19,11 +19,90 @@ import AutoHeight from "../../../Common/AutoHeight.tsx";
import { DefaultCloseAction } from "../../../Common/Snackbar/snackbar.tsx";
import { StyledTableContainerPaper } from "../../../Common/StyledComponents.tsx";
import DraggableDialog from "../../../Dialogs/DraggableDialog.tsx";
import ArrowDown from "../../../Icons/ArrowDown.tsx";
import Dismiss from "../../../Icons/Dismiss.tsx";
import { FileManagerIndex } from "../../FileManager.tsx";
import AddColumn from "./AddColumn.tsx";
import { getColumnTypeDefaults, ListViewColumnSetting } from "./Column.tsx";
import KeyboardArrowUpIcon from "@mui/icons-material/KeyboardArrowUp";
import KeyboardArrowDownIcon from "@mui/icons-material/KeyboardArrowDown";
import { DndProvider, useDrag, useDrop } from "react-dnd";
import { HTML5Backend } from "react-dnd-html5-backend";
import React from "react";
import type { Dispatch, SetStateAction } from "react";
const DND_TYPE = "column-row";
interface DraggableColumnRowProps {
column: ListViewColumnSetting;
index: number;
moveRow: (from: number, to: number) => void;
columns: ListViewColumnSetting[];
setColumns: Dispatch<SetStateAction<ListViewColumnSetting[]>>;
t: (key: string) => string;
onDelete: (idx: number) => void;
isFirst: boolean;
isLast: boolean;
}
const DraggableColumnRow: React.FC<DraggableColumnRowProps> = ({ column, index, moveRow, columns, t, onDelete, isFirst, isLast }) => {
const ref = React.useRef<HTMLTableRowElement>(null);
const [, drop] = useDrop({
accept: DND_TYPE,
hover(item: any, monitor) {
if (!ref.current) return;
const dragIndex = item.index;
const hoverIndex = index;
if (dragIndex === hoverIndex) return;
const hoverBoundingRect = ref.current.getBoundingClientRect();
const hoverMiddleY = (hoverBoundingRect.bottom - hoverBoundingRect.top) / 2;
const clientOffset = monitor.getClientOffset();
if (!clientOffset) return;
const hoverClientY = clientOffset.y - hoverBoundingRect.top;
if (dragIndex < hoverIndex && hoverClientY < hoverMiddleY) return;
if (dragIndex > hoverIndex && hoverClientY > hoverMiddleY) return;
moveRow(dragIndex, hoverIndex);
item.index = hoverIndex;
},
});
const [{ isDragging }, drag] = useDrag({
type: DND_TYPE,
item: { index },
collect: (monitor) => ({
isDragging: monitor.isDragging(),
}),
});
drag(drop(ref));
return (
<TableRow
ref={ref}
hover
key={index}
sx={{ "&:last-child td, &:last-child th": { border: 0 }, opacity: isDragging ? 0.5 : 1, cursor: "move" }}
>
<TableCell component="th" scope="row">
{t(getColumnTypeDefaults(column).title)}
</TableCell>
<TableCell>
<Box sx={{ display: "flex" }}>
<IconButton size="small" onClick={() => moveRow(index, index - 1)} disabled={isFirst}>
<KeyboardArrowUpIcon fontSize="small" />
</IconButton>
<IconButton size="small" onClick={() => moveRow(index, index + 1)} disabled={isLast}>
<KeyboardArrowDownIcon fontSize="small" />
</IconButton>
<IconButton size="small" onClick={() => onDelete(index)} disabled={columns.length <= 1}>
<Dismiss sx={{ width: "18px", height: "18px" }} />
</IconButton>
</Box>
</TableCell>
</TableRow>
);
};
const ColumnSetting = () => {
const { t } = useTranslation();
@ -82,99 +161,42 @@ const ColumnSetting = () => {
>
<DialogContent sx={{ pb: 0 }}>
<AutoHeight>
<TableContainer component={StyledTableContainerPaper}>
<Table sx={{ width: "100%" }} size="small">
<TableHead>
<TableRow>
<TableCell width={"50%"}>{t("fileManager.column")}</TableCell>
<TableCell>{t("fileManager.actions")}</TableCell>
</TableRow>
</TableHead>
<TableBody>
{columns.map((column, index) => (
<TableRow hover key={index} sx={{ "&:last-child td, &:last-child th": { border: 0 } }}>
<TableCell component="th" scope="row">
{t(getColumnTypeDefaults(column).title)}
</TableCell>
<TableCell>
<Box
sx={{
display: "flex",
}}
>
{index > 0 && columns.length > 1 ? (
<IconButton
size="small"
onClick={() => {
setColumns((prev) => {
const newColumns = [...prev];
const temp = newColumns[index];
newColumns[index] = newColumns[index - 1];
newColumns[index - 1] = temp;
return newColumns;
});
}}
>
<ArrowDown
sx={{
width: "18px",
height: "18px",
transform: "rotate(180deg)",
}}
/>
</IconButton>
) : (
<Box sx={{ width: 28, height: 28 }} />
)}
{index < columns.length - 1 && columns.length > 1 ? (
<IconButton
size="small"
onClick={() => {
setColumns((prev) => {
const newColumns = [...prev];
const temp = newColumns[index];
newColumns[index] = newColumns[index + 1];
newColumns[index + 1] = temp;
return newColumns;
});
}}
>
<ArrowDown
sx={{
width: "18px",
height: "18px",
}}
/>
</IconButton>
) : (
<Box sx={{ width: 28, height: 28 }} />
)}
{columns.length > 1 ? (
<IconButton
size="small"
onClick={() => {
setColumns((prev) => {
return prev.filter((_, i) => i !== index);
});
}}
>
<Dismiss
sx={{
width: "18px",
height: "18px",
}}
/>
</IconButton>
) : (
<Box sx={{ width: 28, height: 28 }} />
)}
</Box>
</TableCell>
<DndProvider backend={HTML5Backend}>
<TableContainer component={StyledTableContainerPaper}>
<Table sx={{ width: "100%" }} size="small">
<TableHead>
<TableRow>
<TableCell width={"50%"}>{t("fileManager.column")}</TableCell>
<TableCell>{t("fileManager.actions")}</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</TableContainer>
</TableHead>
<TableBody>
{columns.map((column, index) => (
<DraggableColumnRow
key={index}
column={column}
index={index}
moveRow={(from, to) => {
if (from === to || to < 0 || to >= columns.length) return;
setColumns((prev) => {
const arr = [...prev];
const [moved] = arr.splice(from, 1);
arr.splice(to, 0, moved);
return arr;
});
}}
columns={columns}
setColumns={setColumns}
t={t}
onDelete={(idx) => setColumns((prev) => prev.filter((_, i) => i !== idx))}
isFirst={index === 0}
isLast={index === columns.length - 1}
/>
))}
</TableBody>
</Table>
</TableContainer>
</DndProvider>
</AutoHeight>
</DialogContent>
</DraggableDialog>