diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index b8a2108..d121896 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -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(() => readUser()); const [health, setHealth] = React.useState(null); const [tokenTtl, setTokenTtl] = React.useState(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(() => loadSettings()); const [previewMode, setPreviewMode] = React.useState("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 = ( + setShowExpiredToast(false)} + anchorOrigin={{ vertical: "bottom", horizontal: "center" }} + > + setShowExpiredToast(false)} severity="error" variant="filled" sx={{ width: "100%" }}> + {t("shell.sessionExpired")} + + + ); + 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() { /> )} + {sessionExpiredToast} ); } return ( - - - + <> + + + + {sessionExpiredToast} + ); } diff --git a/frontend/src/assets/under-maintenance.png b/frontend/src/assets/under-maintenance.png new file mode 100644 index 0000000..08412b6 Binary files /dev/null and b/frontend/src/assets/under-maintenance.png differ diff --git a/frontend/src/components/Layout/AppLayout.tsx b/frontend/src/components/Layout/AppLayout.tsx index 2ce2bc3..7d734dd 100644 --- a/frontend/src/components/Layout/AppLayout.tsx +++ b/frontend/src/components/Layout/AppLayout.tsx @@ -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", }} > - + {apiUp === false && ( diff --git a/frontend/src/components/Layout/Sidebar/index.tsx b/frontend/src/components/Layout/Sidebar/index.tsx index c1e434c..ea23ecf 100644 --- a/frontend/src/components/Layout/Sidebar/index.tsx +++ b/frontend/src/components/Layout/Sidebar/index.tsx @@ -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 ( } label={t("sidebar.models")} selected={onModels} - onClick={() => navigate("/models")} + onClick={goToModelsRoot} /> ) : ( navigate("/models")} + onClick={goToModelsRoot} sx={{ borderRadius: 1, mb: 0.5 }} > diff --git a/frontend/src/i18n/locales/en.json b/frontend/src/i18n/locales/en.json index 92ed682..1e362f6 100644 --- a/frontend/src/i18n/locales/en.json +++ b/frontend/src/i18n/locales/en.json @@ -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}}" }, diff --git a/frontend/src/i18n/locales/lt.json b/frontend/src/i18n/locales/lt.json index 058cea8..7cc5178 100644 --- a/frontend/src/i18n/locales/lt.json +++ b/frontend/src/i18n/locales/lt.json @@ -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}}" }, diff --git a/frontend/src/pages/ModelDetailPage/ModelSidePanel.tsx b/frontend/src/pages/ModelDetailPage/ModelSidePanel.tsx index de85e4c..a2989cd 100644 --- a/frontend/src/pages/ModelDetailPage/ModelSidePanel.tsx +++ b/frontend/src/pages/ModelDetailPage/ModelSidePanel.tsx @@ -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 ( - + + + + {t("models:detail.author")} + + print.author && navigate(`/authors/${print.author.id}`)} + > + + {(authorName || "?").slice(0, 1).toUpperCase()} + + + {authorName || t("models:card.unknownAuthor")} + + + + {print.folder_id && print.folder_name && ( diff --git a/frontend/src/pages/ModelDetailPage/index.tsx b/frontend/src/pages/ModelDetailPage/index.tsx index b500183..f7ce1aa 100644 --- a/frontend/src/pages/ModelDetailPage/index.tsx +++ b/frontend/src/pages/ModelDetailPage/index.tsx @@ -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 }} > - print.author && navigate(`/authors/${print.author.id}`)} - > - - {(authorName || "?").slice(0, 1).toUpperCase()} - - - {authorName || t("models:card.unknownAuthor")} - - - {hasImages ? (