mirror of
https://github.com/TautvydasDerzinskas/Thingport.git
synced 2026-10-20 13:49:56 -05:00
fix: couple UX issues
This commit is contained in:
1 parent
dc7ce308c9
commit
2afd1cc713
8 files changed
+99
-55
No files matched your search
+48
-22
@@ -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 |
@@ -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 }}>
|
||||
|
||||
@@ -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}}"
|
||||
},
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in new issue
Block a user