mirror of
https://github.com/TautvydasDerzinskas/Thingport.git
synced 2026-10-18 20:59:55 -05:00
refactor: improve rendering settings admin UI/UX
This commit is contained in:
1 parent
ad27e950a9
commit
8ef8cd4478
7 files changed
+495
-101
No files matched your search
@@ -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,
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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)}>
|
||||
|
||||
Reference in new issue
Block a user