fix: couple UX issues

This commit is contained in:
Tautvydas Derzinskas committed 2026-09-11 19:48:17 +02:00
1 parent dc7ce308c9
commit 2afd1cc713
8 files changed
+99 -55

No files matched your search

+48 -22
View File
@@ -4,6 +4,8 @@ import { ThemeProvider } from "@mui/material/styles";
import CssBaseline from "@mui/material/CssBaseline";
import Box from "@mui/material/Box";
import { BrowserRouter, Navigate, Route, Routes, useNavigate } from "react-router-dom";
import Snackbar from "@mui/material/Snackbar";
import Alert from "@mui/material/Alert";
import AppLayout from "./components/Layout/AppLayout";
import DashboardPage from "./pages/DashboardPage";
import ModelsPage from "./pages/ModelsPage";
@@ -83,6 +85,7 @@ function AppShell({
themeSelection={settings.theme.selected}
apiUp={apiUp}
folderId={folderId}
onSelectFolder={setFolderId}
onPrintsChanged={handlePrintsChanged}
onUnauthorized={onUnauthorized}
isAdmin={isAdmin}
@@ -178,7 +181,11 @@ export default function App() {
const [user, setUser] = React.useState<AuthUser | null>(() => readUser());
const [health, setHealth] = React.useState<HealthInfo | null>(null);
const [tokenTtl, setTokenTtl] = React.useState<number | null>(null);
const [sessionExpired, setSessionExpired] = React.useState(false);
const [showExpiredToast, setShowExpiredToast] = React.useState(false);
// A ref (not state) so concurrent 401s from several in-flight requests all see the guard
// synchronously -- state's setSessionExpired(true) wouldn't be visible to the others until
// the next render, letting each of them past the check and onto its own alert().
const sessionExpiredRef = React.useRef(false);
const [settings, setSettings] = React.useState<AppSettings>(() => loadSettings());
const [previewMode, setPreviewMode] = React.useState<PreviewMode>("automatic");
const resolvedTheme = useResolvedTheme(settings.theme.selected);
@@ -207,17 +214,17 @@ export default function App() {
setToken(tok);
setUser(loggedInUser);
setTokenTtl(ttl);
setSessionExpired(false);
sessionExpiredRef.current = false;
};
const handleUnauthorized = React.useCallback(() => {
if (sessionExpired) return;
if (sessionExpiredRef.current) return;
sessionExpiredRef.current = true;
clearToken();
setToken(null);
setTokenTtl(null);
setSessionExpired(true);
alert(t("shell.sessionExpired"));
}, [sessionExpired, t]);
setShowExpiredToast(true);
}, []);
const handleLogout = () => {
// Fire-and-forget: the audit-log entry is recorded server-side before the token that
@@ -257,6 +264,21 @@ export default function App() {
return () => window.clearTimeout(timer);
}, [token, tokenTtl, handleUnauthorized]);
// Shared between both branches below so the toast survives the AppShell -> AuthPage swap that
// handleUnauthorized triggers (clearing the token unmounts AppShell and mounts this instead).
const sessionExpiredToast = (
<Snackbar
open={showExpiredToast}
autoHideDuration={5000}
onClose={() => setShowExpiredToast(false)}
anchorOrigin={{ vertical: "bottom", horizontal: "center" }}
>
<Alert onClose={() => setShowExpiredToast(false)} severity="error" variant="filled" sx={{ width: "100%" }}>
{t("shell.sessionExpired")}
</Alert>
</Snackbar>
);
if (authRequired && !token) {
// No BrowserRouter wraps this branch (see AppShell's comment), so /verify-email -- reached
// pre-login from the link in the verification email -- is checked directly against
@@ -285,26 +307,30 @@ export default function App() {
/>
)}
</Box>
{sessionExpiredToast}
</ThemeProvider>
);
}
return (
<BrowserRouter>
<AppShell
isAdmin={isAdmin}
user={user}
apiUp={apiUp}
settings={settings}
setSettings={setSettings}
previewMode={previewMode}
setPreviewMode={setPreviewMode}
resolvedTheme={resolvedTheme}
muiTheme={muiTheme}
onUnauthorized={handleUnauthorized}
onLogout={handleLogout}
onUserUpdated={handleUserUpdated}
/>
</BrowserRouter>
<>
<BrowserRouter>
<AppShell
isAdmin={isAdmin}
user={user}
apiUp={apiUp}
settings={settings}
setSettings={setSettings}
previewMode={previewMode}
setPreviewMode={setPreviewMode}
resolvedTheme={resolvedTheme}
muiTheme={muiTheme}
onUnauthorized={handleUnauthorized}
onLogout={handleLogout}
onUserUpdated={handleUserUpdated}
/>
</BrowserRouter>
<ThemeProvider theme={muiTheme}>{sessionExpiredToast}</ThemeProvider>
</>
);
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 138 KiB

+3 -1
View File
@@ -24,6 +24,7 @@ type AppLayoutProps = {
themeSelection: ThemeSelection;
apiUp: boolean | null;
folderId: string | null;
onSelectFolder: (id: string | null) => void;
onPrintsChanged: () => void;
onUnauthorized: () => void;
isAdmin: boolean;
@@ -111,6 +112,7 @@ function AppLayoutShell({
themeSelection,
apiUp,
folderId,
onSelectFolder,
onPrintsChanged,
onUnauthorized,
isAdmin,
@@ -140,7 +142,7 @@ function AppLayoutShell({
display: "flex",
}}
>
<Sidebar isAdmin={isAdmin} />
<Sidebar isAdmin={isAdmin} onSelectFolder={onSelectFolder} />
<Box component="main" sx={{ flex: 1, p: 2, overflow: "auto" }}>
{apiUp === false && (
<Alert severity="error" sx={{ mb: 1.5 }}>
@@ -57,11 +57,12 @@ function CollapsedNavIcon({ icon, label, selected, onClick }: {
type Props = {
isAdmin: boolean;
onSelectFolder: (id: string | null) => void;
};
/** The persistent app-wide navigation rail: Dashboard, Models, Collections, Downloads, and
* (for admins) Administration. Folder browsing lives inside the Models page itself, not here. */
export default function Sidebar({ isAdmin }: Props) {
export default function Sidebar({ isAdmin, onSelectFolder }: Props) {
const { t } = useTranslation(["app", "common"]);
const location = useLocation();
const navigate = useNavigate();
@@ -97,6 +98,13 @@ export default function Sidebar({ isAdmin }: Props) {
const currentWidth = collapsed ? SIDEBAR_COLLAPSED_WIDTH : SIDEBAR_WIDTH;
// Always lands on the unfiltered grid, even if a category was selected the last time Models
// was open -- unlike the in-page back button, which keeps the filter (see useRouteChrome).
const goToModelsRoot = () => {
onSelectFolder(null);
navigate("/models");
};
return (
<Box
component="aside"
@@ -161,12 +169,12 @@ export default function Sidebar({ isAdmin }: Props) {
icon={<ViewInArIcon fontSize="small" />}
label={t("sidebar.models")}
selected={onModels}
onClick={() => navigate("/models")}
onClick={goToModelsRoot}
/>
) : (
<ListItemButton
selected={onModels}
onClick={() => navigate("/models")}
onClick={goToModelsRoot}
sx={{ borderRadius: 1, mb: 0.5 }}
>
<ListItemIcon sx={{ minWidth: 30 }}>
+1
View File
@@ -1080,6 +1080,7 @@
"removeFromFavorites": "Remove from Favourites",
"favoriteFailed": "Failed to update favourites. Please try again.",
"category": "Category",
"author": "Author",
"downloadModelFiles": "Download model files",
"imported": "Imported {{date}}"
},
+1
View File
@@ -1081,6 +1081,7 @@
"removeFromFavorites": "Pašalinti iš mėgstamų",
"favoriteFailed": "Nepavyko atnaujinti mėgstamų. Bandykite dar kartą.",
"category": "Kategorija",
"author": "Autorius",
"downloadModelFiles": "Atsisiųsti modelio failus",
"imported": "Importuota {{date}}"
},
@@ -4,6 +4,7 @@ import Paper from "@mui/material/Paper";
import Stack from "@mui/material/Stack";
import Box from "@mui/material/Box";
import Typography from "@mui/material/Typography";
import Avatar from "@mui/material/Avatar";
import Button from "@mui/material/Button";
import ButtonBase from "@mui/material/ButtonBase";
import FolderIcon from "@mui/icons-material/Folder";
@@ -25,12 +26,13 @@ type Props = {
onUnauthorized?: () => void;
};
/** The model detail page's right-hand summary card: category (jumps back to the Models grid
* filtered to it), "Open in {Slicer}" (only when both a preference is set and this print has a
* slicer_url -- same condition ModelActionsMenu's menu item uses), "Download model files" (the
* same picker-or-direct-download flow as ModelActionsMenu's Download, via useDownloadPrint so
* the two can't drift), view/print counts, and -- only for an actually-imported print, per
* source_provider -- when it was imported. */
/** The model detail page's right-hand summary card, sticky so it stays in view while the
* description/tags column scrolls: author (jumps to their author page), category (jumps back
* to the Models grid filtered to it), "Open in {Slicer}" (only when both a preference is set
* and this print has a slicer_url -- same condition ModelActionsMenu's menu item uses),
* "Download model files" (the same picker-or-direct-download flow as ModelActionsMenu's
* Download, via useDownloadPrint so the two can't drift), view/print counts, and -- only for an
* actually-imported print, per source_provider -- when it was imported. */
export default function ModelSidePanel({ print, onSelectFolder, onUnauthorized }: Props) {
const { t } = useTranslation(["models", "common"]);
const navigate = useNavigate();
@@ -54,9 +56,35 @@ export default function ModelSidePanel({ print, onSelectFolder, onUnauthorized }
? new Date(print.created_at).toLocaleDateString(undefined, { year: "numeric", month: "long", day: "numeric" })
: null;
const authorName = print.author?.name || print.author?.handle || print.creator || null;
return (
<Paper variant="outlined" sx={{ p: 2.5, borderRadius: "12px" }}>
<Paper variant="outlined" sx={{ p: 2.5, borderRadius: "12px", position: "sticky", top: 16 }}>
<Stack spacing={2}>
<Box>
<Typography variant="caption" color="text.secondary" sx={{ display: "block", mb: 0.5 }}>
{t("models:detail.author")}
</Typography>
<Stack
direction="row"
alignItems="center"
spacing={1}
sx={{
width: "fit-content",
cursor: print.author ? "pointer" : "default",
...(print.author ? { "&:hover": { color: "primary.main" } } : undefined),
}}
onClick={() => print.author && navigate(`/authors/${print.author.id}`)}
>
<Avatar src={print.author?.avatar_url || undefined} sx={{ width: 28, height: 28, fontSize: 13, color: "inherit !important" }}>
{(authorName || "?").slice(0, 1).toUpperCase()}
</Avatar>
<Typography variant="body2" sx={{ color: "inherit" }}>
{authorName || t("models:card.unknownAuthor")}
</Typography>
</Stack>
</Box>
{print.folder_id && print.folder_name && (
<Box>
<Typography variant="caption" color="text.secondary" sx={{ display: "block", mb: 0.5 }}>
@@ -5,7 +5,6 @@ import Box from "@mui/material/Box";
import Stack from "@mui/material/Stack";
import Paper from "@mui/material/Paper";
import Typography from "@mui/material/Typography";
import Avatar from "@mui/material/Avatar";
import Button from "@mui/material/Button";
import ButtonBase from "@mui/material/ButtonBase";
import IconButton from "@mui/material/IconButton";
@@ -103,7 +102,6 @@ export default function ModelDetailPage({ theme, onSelectFolder, onUnauthorized
const images = print.preview_images.toSorted((a, b) => a.position - b.position);
const hasImages = images.length > 0;
const activeImage = images[activeImageIndex] || images[0];
const authorName = print.author?.name || print.author?.handle || print.creator || null;
const firstPlate = print.plates[0];
const canPreview3d = Boolean(firstPlate) && MODEL_EXTS.has(extOf(firstPlate?.filename || ""));
// Every model should end up with at least one preview image -- an import brings its own cover
@@ -127,26 +125,6 @@ export default function ModelDetailPage({ theme, onSelectFolder, onUnauthorized
}}
>
<Box>
<Stack
direction="row"
alignItems="center"
spacing={1}
sx={{
mb: 2,
width: "fit-content",
cursor: print.author ? "pointer" : "default",
...(print.author ? { "&:hover": { color: "primary.main" } } : undefined),
}}
onClick={() => print.author && navigate(`/authors/${print.author.id}`)}
>
<Avatar src={print.author?.avatar_url || undefined} sx={{ width: 28, height: 28, fontSize: 13, color: "inherit !important" }}>
{(authorName || "?").slice(0, 1).toUpperCase()}
</Avatar>
<Typography variant="body2" sx={{ color: "inherit" }}>
{authorName || t("models:card.unknownAuthor")}
</Typography>
</Stack>
<Box sx={{ position: "relative", width: "100%", aspectRatio: "16 / 10", borderRadius: "12px", overflow: "hidden", bgcolor: "action.hover" }}>
{hasImages ? (
<Box