refactor: improve rendering settings admin UI/UX

This commit is contained in:
Tautvydas Derzinskas committed 2026-10-15 09:50:47 +02:00
1 parent ad27e950a9
commit 8ef8cd4478
7 files changed
+495 -101

No files matched your search

+15 -2
View File
@@ -1,17 +1,30 @@
import type { ReactNode } from "react";
import Box from "@mui/material/Box";
import Tooltip from "@mui/material/Tooltip";
import HelpOutlineIcon from "@mui/icons-material/HelpOutline";
type Props = {
/** What it says, and what screen readers read. */
text: string;
/** Shown instead of `text` when the explanation needs more than a line, e.g. a title and a list. */
content?: ReactNode;
};
/** A small "?" after a label, explaining it in a tooltip. Focusable, so the explanation is there
* for keyboards and screen readers too. */
export default function HelpTip({ text }: { text: string }) {
export default function HelpTip({ text, content }: Props) {
return (
<Tooltip title={text}>
<Tooltip
title={content ?? text}
slotProps={content ? { tooltip: { sx: { maxWidth: 420, p: 1.5, fontSize: "0.8125rem" } } } : undefined}
>
<Box
component="span"
tabIndex={0}
role="note"
aria-label={text}
// Inside a checkbox or switch label, a click here is for reading, not for toggling it.
onClick={(event) => event.preventDefault()}
sx={{
display: "inline-flex",
ml: 0.5,
+4 -1
View File
@@ -568,6 +568,7 @@
"previews": {
"heading": "Model Previews",
"subtitle": "Control background preview generation for the whole instance.",
"modeLabel": "Missing card thumbnails",
"infoTitle": "What this controls",
"infoScope": "This only affects the small 3D thumbnail shown on model cards (Models, Collections) — never the full model viewer. It also only applies to files with no thumbnail yet: an embedded thumbnail from a sliced .3mf file, or one already generated before, is always reused automatically regardless of this setting. This choice only decides what happens for the rest — typically .stl/.obj files, or a .3mf with no embedded thumbnail:",
"infoAutomatic": "Automatic — a visitor's browser renders and saves the missing thumbnail as soon as its card scrolls into view.",
@@ -954,7 +955,9 @@
"helpOn": "On: previews of models over 1 million triangles are simplified.",
"helpOff": "Off: previews always show a model's full detail.",
"loadFailed": "Failed to load rendering settings.",
"saveFailed": "Failed to save rendering settings."
"saveFailed": "Failed to save rendering settings.",
"illustrationBefore": "Detailed",
"illustrationAfter": "Simplified"
}
},
"importQueue": {
+4 -1
View File
@@ -570,6 +570,7 @@
"previews": {
"heading": "Modelių peržiūros",
"subtitle": "Valdykite fono peržiūrų generavimą visai sistemai.",
"modeLabel": "Trūkstamos kortelių miniatiūros",
"infoTitle": "Ką tai valdo",
"infoScope": "Tai veikia tik mažą 3D miniatiūrą, rodomą modelio kortelėje (Modeliai, Kolekcijos) — niekada pilną modelio peržiūros langą. Taip pat tai galioja tik failams, kurie dar neturi miniatiūros: įtaisytoji suskaidyto .3mf failo miniatiūra arba anksčiau jau sugeneruota miniatiūra visada naudojama automatiškai, nepriklausomai nuo šio nustatymo. Šis pasirinkimas nulemia tik likusius atvejus — dažniausiai .stl/.obj failus arba .3mf be įtaisytos miniatiūros:",
"infoAutomatic": "Automatinis — lankytojo naršyklė sugeneruoja ir išsaugo trūkstamą miniatiūrą, vos tik kortelė atsiranda ekrane.",
@@ -962,7 +963,9 @@
"helpOn": "Įjungta: modelių, turinčių daugiau nei 1 milijoną trikampių, peržiūros supaprastinamos.",
"helpOff": "Išjungta: peržiūros visada rodo visas modelio detales.",
"loadFailed": "Nepavyko įkelti atvaizdavimo nustatymų.",
"saveFailed": "Nepavyko išsaugoti atvaizdavimo nustatymų."
"saveFailed": "Nepavyko išsaugoti atvaizdavimo nustatymų.",
"illustrationBefore": "Detalus",
"illustrationAfter": "Supaprastintas"
}
},
"importQueue": {
@@ -0,0 +1,240 @@
import { keyframes } from "@emotion/react";
import AutoAwesomeIcon from "@mui/icons-material/AutoAwesome";
import CheckCircleIcon from "@mui/icons-material/CheckCircle";
import ImageOutlinedIcon from "@mui/icons-material/ImageOutlined";
import Box from "@mui/material/Box";
import Stack from "@mui/material/Stack";
import type { PreviewMode } from "../../api/settings";
const TILE = 104;
const LOOP = "4.8s";
// Without its animation (not chosen, or motion reduced), each scene rests on the frame that explains it: the
// rendered thumbnail, the button waiting for a click, the placeholder.
const scrollIn = keyframes`
0% { transform: translateY(34px); opacity: 0; }
14%, 100% { transform: translateY(0); opacity: 1; }
`;
const renderAuto = keyframes`
0%, 40% { opacity: 0; transform: scale(0.6); }
50%, 92% { opacity: 1; transform: scale(1); }
100% { opacity: 0; transform: scale(1); }
`;
const shimmerAuto = keyframes`
0%, 14% { opacity: 0; }
18%, 42% { opacity: 1; }
50%, 100% { opacity: 0; }
`;
const pointer = keyframes`
0%, 8% { transform: translate(26px, 30px); opacity: 0; }
14% { opacity: 1; }
34%, 44% { transform: translate(0, 0); opacity: 1; }
52%, 100% { transform: translate(0, 0); opacity: 0; }
`;
const press = keyframes`
0%, 36% { transform: scale(1); opacity: 1; }
40% { transform: scale(0.82); opacity: 1; }
46%, 100% { transform: scale(1); opacity: 0; }
`;
const shimmerOnDemand = keyframes`
0%, 44% { opacity: 0; }
48%, 64% { opacity: 1; }
70%, 100% { opacity: 0; }
`;
const renderOnDemand = keyframes`
0%, 64% { opacity: 0; transform: scale(0.6); }
72%, 94% { opacity: 1; transform: scale(1); }
100% { opacity: 0; transform: scale(1); }
`;
const sweep = keyframes`
from { background-position: -80px 0; }
to { background-position: 80px 0; }
`;
/** The Thingport cube, standing in for a rendered thumbnail. */
function Cube({ animation, waiting }: { animation: string; waiting?: boolean }) {
return (
<Box
component="svg"
viewBox="0 0 40 40"
sx={{ position: "absolute", inset: 0, m: "auto", width: 30, height: 30, opacity: waiting ? 0 : 1, animation }}
>
<path d="M20 5 L34 13 L20 21 L6 13 Z" fill="#3fd13f" />
<path d="M6 13 L20 21 L20 36 L6 28 Z" fill="#00b800" />
<path d="M34 13 L20 21 L20 36 L34 28 Z" fill="#008f00" />
</Box>
);
}
function Shimmer({ animation }: { animation: string }) {
return (
<Box
sx={{
position: "absolute",
inset: 0,
opacity: 0,
background: "linear-gradient(90deg, transparent 20%, rgba(255, 255, 255, 0.85) 50%, transparent 80%)",
backgroundSize: "80px 100%",
backgroundRepeat: "no-repeat",
animation: `${animation}, ${sweep} 0.9s linear infinite`,
}}
/>
);
}
/** A model card: the thumbnail on top, its name and author as lines below. */
function Card({ children, animation }: { children: React.ReactNode; animation?: string }) {
return (
<Box
sx={{
width: 66,
height: 78,
borderRadius: "8px",
bgcolor: "#ffffff",
border: "1px solid rgba(0, 0, 0, 0.12)",
boxShadow: "0 1px 3px rgba(0, 0, 0, 0.08)",
overflow: "hidden",
animation,
}}
>
<Box sx={{ position: "relative", height: 48, bgcolor: "#eef1f4", overflow: "hidden" }}>{children}</Box>
<Box sx={{ px: 0.75, pt: 0.75 }}>
<Box sx={{ height: 5, width: "80%", borderRadius: 1, bgcolor: "rgba(0, 0, 0, 0.18)" }} />
<Box sx={{ mt: 0.5, height: 4, width: "50%", borderRadius: 1, bgcolor: "rgba(0, 0, 0, 0.1)" }} />
</Box>
</Box>
);
}
const loop = (frames: ReturnType<typeof keyframes>, timing = "ease-in-out") => `${frames} ${LOOP} ${timing} infinite`;
const SCENES: Record<PreviewMode, React.ReactNode> = {
// Scrolls into view, renders itself, keeps it.
automatic: (
<Card animation={loop(scrollIn, "ease-out")}>
<Shimmer animation={loop(shimmerAuto)} />
<Cube animation={loop(renderAuto)} />
</Card>
),
// Waits for a click on its button, then renders.
"on-demand": (
<Card>
<Box
sx={{
position: "absolute",
left: "50%",
top: "50%",
width: 30,
height: 14,
ml: "-15px",
mt: "-7px",
borderRadius: "7px",
bgcolor: "primary.main",
display: "flex",
alignItems: "center",
justifyContent: "center",
animation: loop(press),
}}
>
<AutoAwesomeIcon sx={{ fontSize: 10, color: "#ffffff" }} />
</Box>
<Shimmer animation={loop(shimmerOnDemand)} />
<Cube animation={loop(renderOnDemand)} waiting />
<Box
component="svg"
viewBox="0 0 12 16"
sx={{
position: "absolute",
left: "50%",
top: "50%",
width: 10,
height: 14,
ml: "2px",
mt: "1px",
opacity: 0,
animation: loop(pointer),
}}
>
<path d="M1 1 L1 13 L4 10 L6.5 15 L8.5 14 L6 9 L10.5 9 Z" fill="#1f1f1f" stroke="#ffffff" strokeWidth="1" />
</Box>
</Card>
),
// Never renders: the placeholder stays.
disabled: (
<Card>
<ImageOutlinedIcon
sx={{ position: "absolute", inset: 0, m: "auto", fontSize: 24, color: "rgba(0, 0, 0, 0.28)" }}
/>
</Card>
),
};
type Props = {
value: PreviewMode;
options: { value: PreviewMode; label: string }[];
onChange: (value: PreviewMode) => void;
disabled?: boolean;
};
/** One tile per mode, showing what a card does; the chosen one is lit and plays, the others wait, dimmed. */
export default function PreviewModeIllustration({ value, options, onChange, disabled }: Props) {
return (
<Stack direction="row" spacing={1.5} flexWrap="wrap" useFlexGap>
{options.map((option) => {
const chosen = option.value === value;
return (
<Box
key={option.value}
component="button"
type="button"
aria-pressed={chosen}
aria-label={option.label}
disabled={disabled}
onClick={() => onChange(option.value)}
sx={{
position: "relative",
width: TILE,
height: TILE,
p: 0,
font: "inherit",
borderRadius: "18px",
bgcolor: "#ffffff",
border: "2px solid",
borderColor: chosen ? "primary.main" : "transparent",
boxShadow: chosen
? "0 0 0 4px rgba(0, 184, 0, 0.18), 0 4px 12px rgba(0, 0, 0, 0.12)"
: "inset 0 0 0 1px rgba(0, 0, 0, 0.08), 0 1px 2px rgba(0, 0, 0, 0.04)",
display: "flex",
alignItems: "center",
justifyContent: "center",
cursor: disabled ? "default" : "pointer",
transform: chosen ? "translateY(-2px)" : "none",
transition: "transform 150ms, box-shadow 150ms, border-color 150ms, opacity 150ms, filter 150ms",
...(!chosen && { opacity: 0.6, filter: "grayscale(0.7)" }),
"&:hover": chosen || disabled ? {} : { opacity: 0.9, filter: "grayscale(0.2)" },
"&:focus-visible": { outline: "2px solid", outlineColor: "primary.main", outlineOffset: 3 },
// Only the chosen one plays; the others rest on their still.
...(!chosen && { "& *": { animation: "none !important" } }),
"@media (prefers-reduced-motion: reduce)": { "& *": { animation: "none !important" } },
}}
>
{SCENES[option.value]}
{chosen && (
<CheckCircleIcon
sx={{
position: "absolute",
top: -9,
right: -9,
fontSize: 22,
color: "primary.main",
bgcolor: "#ffffff",
borderRadius: "50%",
}}
/>
)}
</Box>
);
})}
</Stack>
);
}
@@ -4,14 +4,12 @@ import Stack from "@mui/material/Stack";
import Box from "@mui/material/Box";
import Paper from "@mui/material/Paper";
import Typography from "@mui/material/Typography";
import Radio from "@mui/material/Radio";
import RadioGroup from "@mui/material/RadioGroup";
import FormControlLabel from "@mui/material/FormControlLabel";
import CircularProgress from "@mui/material/CircularProgress";
import Alert from "@mui/material/Alert";
import AlertTitle from "@mui/material/AlertTitle";
import { UnauthorizedError } from "../../api/client";
import { settingsApi, type PreviewMode } from "../../api/settings";
import Segmented from "../../components/controls/Segmented";
import HelpTip from "../../components/HelpTip";
import PreviewModeIllustration from "./PreviewModeIllustration";
import SectionHeader from "../../components/SectionHeader";
type Props = {
@@ -83,60 +81,51 @@ export default function PreviewsSection({ onUnauthorized, onSaved }: Props) {
},
];
const points = (["infoAutomatic", "infoOnDemand", "infoDisabled"] as const).map((key) =>
t(`adminSettings.previews.${key}`),
);
const infoText = [t("adminSettings.previews.infoTitle"), t("adminSettings.previews.infoScope"), ...points].join(" ");
const info = (
<>
<Typography variant="subtitle2" component="div" sx={{ mb: 0.5 }}>
{t("adminSettings.previews.infoTitle")}
</Typography>
{t("adminSettings.previews.infoScope")}
<Box component="ul" sx={{ mt: 1, mb: 0, pl: 2.5 }}>
{points.map((point) => (
<li key={point}>{point}</li>
))}
</Box>
</>
);
return (
<Stack spacing={3}>
<SectionHeader title={t("adminSettings.previews.heading")} subtitle={t("adminSettings.previews.subtitle")} />
<Paper variant="outlined" sx={{ p: 2.5 }}>
<Stack spacing={2}>
<Alert severity="info">
<AlertTitle>{t("adminSettings.previews.infoTitle")}</AlertTitle>
{t("adminSettings.previews.infoScope")}
<Box component="ul" sx={{ mt: 1, mb: 0, pl: 2.5 }}>
<li>
<Typography variant="body2">{t("adminSettings.previews.infoAutomatic")}</Typography>
</li>
<li>
<Typography variant="body2">{t("adminSettings.previews.infoOnDemand")}</Typography>
</li>
<li>
<Typography variant="body2">{t("adminSettings.previews.infoDisabled")}</Typography>
</li>
</Box>
</Alert>
<RadioGroup value={mode} onChange={(e) => selectMode(e.target.value as PreviewMode)}>
<Stack spacing={1.5}>
{options.map((option) => {
const selected = mode === option.id;
return (
<FormControlLabel
key={option.id}
value={option.id}
control={<Radio />}
disabled={loading || saving}
sx={{
alignItems: "flex-start",
m: 0,
borderRadius: 2,
border: "1px solid",
borderColor: selected ? "primary.main" : "divider",
bgcolor: selected ? "action.selected" : "transparent",
p: 2,
}}
label={
<Box>
<Typography variant="body2" fontWeight={600}>
{option.label}
</Typography>
<Typography variant="caption" color="text.secondary">
{option.description}
</Typography>
</Box>
}
/>
);
})}
</Stack>
</RadioGroup>
<PreviewModeIllustration
value={mode}
onChange={(next) => void selectMode(next)}
disabled={loading || saving}
options={options.map((option) => ({ value: option.id, label: option.label }))}
/>
<Stack spacing={0.75} alignItems="flex-start">
<Typography variant="body2" fontWeight={600}>
{t("adminSettings.previews.modeLabel")}
<HelpTip text={infoText} content={info} />
</Typography>
<Segmented
label={t("adminSettings.previews.heading")}
value={mode}
onChange={(next) => void selectMode(next)}
disabled={loading || saving}
options={options.map((option) => ({ value: option.id, label: option.label }))}
/>
<Typography variant="caption" color="text.secondary">
{options.find((option) => option.id === mode)?.description}
</Typography>
</Stack>
{(saving || status) && (
<Stack direction="row" alignItems="center" spacing={1}>
{saving && <CircularProgress size={14} />}
@@ -0,0 +1,135 @@
import { keyframes } from "@emotion/react";
import { useTranslation } from "react-i18next";
import ArrowForwardIcon from "@mui/icons-material/ArrowForward";
import Box from "@mui/material/Box";
import Stack from "@mui/material/Stack";
import Typography from "@mui/material/Typography";
const TILE = 104;
const HEIGHT = 58;
const EDGE = "#00b800";
/** The vase's radius at height t (0 bottom, 1 top): both models are sampled from it, so they're the same shape. */
const profile = (t: number) => 14 + 12.5 * Math.sin(Math.PI * (0.15 + 0.75 * t)) - 5 * t;
const turn = keyframes`
from { transform: rotateX(-24deg) rotateY(0deg); }
to { transform: rotateX(-24deg) rotateY(360deg); }
`;
const nudge = keyframes`
0%, 100% { transform: translateX(-3px); opacity: 0.55; }
50% { transform: translateX(3px); opacity: 1; }
`;
type Face = { key: string; transform: string; width: number; length: number; points: string; diagonal: string };
/** A lathed vase of `sides` around and `rings` up; each quad is drawn as two triangles, as a mesh would be. */
function faces(sides: number, rings: number): Face[] {
const half = Math.PI / sides;
const segment = HEIGHT / (rings - 1);
const out: Face[] = [];
for (let ring = 0; ring < rings - 1; ring++) {
const below = profile(ring / (rings - 1));
const above = profile((ring + 1) / (rings - 1));
// Distance from the axis to each edge's middle, and each edge's length.
const [nearBelow, nearAbove] = [below * Math.cos(half), above * Math.cos(half)];
const [wideBelow, wideAbove] = [2 * below * Math.sin(half), 2 * above * Math.sin(half)];
const length = Math.hypot(segment, nearBelow - nearAbove);
const tilt = Math.atan2(nearBelow - nearAbove, segment);
const width = Math.max(wideBelow, wideAbove);
const middle = HEIGHT / 2 - (ring + 0.5) * segment;
const [left, right] = [(width - wideBelow) / 2, (width + wideBelow) / 2];
const [topLeft, topRight] = [(width - wideAbove) / 2, (width + wideAbove) / 2];
for (let side = 0; side < sides; side++) {
out.push({
key: `${ring}-${side}`,
transform: `rotateY(${(side * 360) / sides}deg) translate3d(0, ${middle}px, ${(nearBelow + nearAbove) / 2}px) rotateX(${tilt}rad)`,
width,
length,
points: `${topLeft},0 ${topRight},0 ${right},${length} ${left},${length}`,
diagonal: `M${left} ${length} L${topRight} 0`,
});
}
}
return out;
}
const DETAILED = faces(16, 6);
const SIMPLIFIED = faces(6, 3);
function Model({ mesh, caption }: { mesh: Face[]; caption: string }) {
return (
<Stack alignItems="center" spacing={0.75}>
<Box
aria-hidden
sx={{
width: TILE,
height: TILE,
borderRadius: "18px",
bgcolor: "#ffffff",
boxShadow: "inset 0 0 0 1px rgba(0, 0, 0, 0.08), 0 1px 2px rgba(0, 0, 0, 0.04)",
display: "flex",
alignItems: "center",
justifyContent: "center",
perspective: "420px",
}}
>
<Box
sx={{
position: "relative",
width: 0,
height: 0,
transformStyle: "preserve-3d",
transform: "rotateX(-24deg) rotateY(30deg)",
animation: `${turn} 14s linear infinite`,
"@media (prefers-reduced-motion: reduce)": { animation: "none" },
}}
>
{mesh.map((face) => (
<Box
key={face.key}
component="svg"
width={face.width}
height={face.length}
viewBox={`0 0 ${face.width} ${face.length}`}
sx={{
position: "absolute",
left: -face.width / 2,
top: -face.length / 2,
overflow: "visible",
transform: face.transform,
}}
>
<polygon points={face.points} fill="rgba(0, 184, 0, 0.1)" stroke={EDGE} strokeWidth={0.8} />
<path d={face.diagonal} stroke={EDGE} strokeWidth={0.5} strokeOpacity={0.6} />
</Box>
))}
</Box>
</Box>
<Typography variant="caption" color="text.secondary" fontWeight={600}>
{caption}
</Typography>
</Stack>
);
}
/** The same model before and after simplification, turning side by side: fewer corners and edges, same shape. */
export default function SimplifyIllustration() {
const { t } = useTranslation("app");
return (
<Stack direction="row" spacing={1.5} alignItems="center" justifyContent="center" sx={{ flexShrink: 0 }}>
<Model mesh={DETAILED} caption={t("adminSettings.rendering.simplify.illustrationBefore")} />
<ArrowForwardIcon
aria-hidden
color="action"
sx={{
mb: 3,
animation: `${nudge} 1.6s ease-in-out infinite`,
"@media (prefers-reduced-motion: reduce)": { animation: "none" },
}}
/>
<Model mesh={SIMPLIFIED} caption={t("adminSettings.rendering.simplify.illustrationAfter")} />
</Stack>
);
}
@@ -1,7 +1,6 @@
import React from "react";
import { useTranslation } from "react-i18next";
import Alert from "@mui/material/Alert";
import AlertTitle from "@mui/material/AlertTitle";
import Box from "@mui/material/Box";
import CircularProgress from "@mui/material/CircularProgress";
import FormControlLabel from "@mui/material/FormControlLabel";
@@ -11,7 +10,9 @@ import Switch from "@mui/material/Switch";
import Typography from "@mui/material/Typography";
import { UnauthorizedError } from "../../api/client";
import { settingsApi } from "../../api/settings";
import HelpTip from "../../components/HelpTip";
import SectionHeader from "../../components/SectionHeader";
import SimplifyIllustration from "./SimplifyIllustration";
type Props = {
onUnauthorized?: () => void;
@@ -57,6 +58,28 @@ export default function SimplifySection({ onUnauthorized }: Props) {
}
};
const points = (["infoFiles", "infoBenefit", "infoCost", "infoChange"] as const).map((key) =>
t(`adminSettings.rendering.simplify.${key}`),
);
const infoText = [
t("adminSettings.rendering.simplify.infoTitle"),
t("adminSettings.rendering.simplify.infoWhat"),
...points,
].join(" ");
const info = (
<>
<Typography variant="subtitle2" component="div" sx={{ mb: 0.5 }}>
{t("adminSettings.rendering.simplify.infoTitle")}
</Typography>
{t("adminSettings.rendering.simplify.infoWhat")}
<Box component="ul" sx={{ mt: 1, mb: 0, pl: 2.5 }}>
{points.map((point) => (
<li key={point}>{point}</li>
))}
</Box>
</>
);
return (
<Stack spacing={3}>
<SectionHeader
@@ -65,48 +88,36 @@ export default function SimplifySection({ onUnauthorized }: Props) {
/>
<Paper variant="outlined" sx={{ p: 2.5 }}>
<Stack spacing={2}>
<Alert severity="info">
<AlertTitle>{t("adminSettings.rendering.simplify.infoTitle")}</AlertTitle>
{t("adminSettings.rendering.simplify.infoWhat")}
<Box component="ul" sx={{ mt: 1, mb: 0, pl: 2.5 }}>
<li>
<Typography variant="body2">{t("adminSettings.rendering.simplify.infoFiles")}</Typography>
</li>
<li>
<Typography variant="body2">{t("adminSettings.rendering.simplify.infoBenefit")}</Typography>
</li>
<li>
<Typography variant="body2">{t("adminSettings.rendering.simplify.infoCost")}</Typography>
</li>
<li>
<Typography variant="body2">{t("adminSettings.rendering.simplify.infoChange")}</Typography>
</li>
</Box>
</Alert>
<Stack direction="row" spacing={1} alignItems="center">
<FormControlLabel
control={
<Switch
checked={enabled ?? false}
disabled={enabled === null || saving}
onChange={(e) => void toggle(e.target.checked)}
/>
}
label={
<Box>
<Typography variant="body2" fontWeight={600}>
{t("adminSettings.rendering.simplify.label")}
</Typography>
<Typography variant="caption" color="text.secondary">
{t(
enabled ? "adminSettings.rendering.simplify.helpOn" : "adminSettings.rendering.simplify.helpOff",
)}
</Typography>
</Box>
}
sx={{ alignItems: "flex-start", m: 0, "& .MuiSwitch-root": { mt: -0.5 } }}
/>
{saving && <CircularProgress size={16} />}
<Stack spacing={2.5} alignItems="flex-start">
<SimplifyIllustration />
<Stack direction="row" spacing={1} alignItems="center">
<FormControlLabel
control={
<Switch
checked={enabled ?? false}
disabled={enabled === null || saving}
onChange={(e) => void toggle(e.target.checked)}
/>
}
label={
<Box>
<Typography variant="body2" fontWeight={600}>
{t("adminSettings.rendering.simplify.label")}
<HelpTip text={infoText} content={info} />
</Typography>
<Typography variant="caption" color="text.secondary">
{t(
enabled
? "adminSettings.rendering.simplify.helpOn"
: "adminSettings.rendering.simplify.helpOff",
)}
</Typography>
</Box>
}
sx={{ alignItems: "flex-start", m: 0, "& .MuiSwitch-root": { mt: -0.5 } }}
/>
{saving && <CircularProgress size={16} />}
</Stack>
</Stack>
{error && (
<Alert severity="error" onClose={() => setError(null)}>