From b60fbd8092ee421b430235ee994fb73259a19455 Mon Sep 17 00:00:00 2001 From: Tautvydas Derzinskas Date: Fri, 11 Sep 2026 22:31:35 +0200 Subject: [PATCH] feat: back to top button --- frontend/src/components/Layout/AppLayout.tsx | 2 + .../src/components/Layout/BackToTopButton.tsx | 58 +++++++++++++++++++ frontend/src/i18n/locales/en.json | 1 + frontend/src/i18n/locales/lt.json | 1 + 4 files changed, 62 insertions(+) create mode 100644 frontend/src/components/Layout/BackToTopButton.tsx diff --git a/frontend/src/components/Layout/AppLayout.tsx b/frontend/src/components/Layout/AppLayout.tsx index 41b9c2e..a938d1d 100644 --- a/frontend/src/components/Layout/AppLayout.tsx +++ b/frontend/src/components/Layout/AppLayout.tsx @@ -8,6 +8,7 @@ import Alert from "@mui/material/Alert"; import Sidebar from "./Sidebar"; import TopBar from "./TopBar"; import ImportProgressBar from "./ImportProgressBar"; +import BackToTopButton from "./BackToTopButton"; import { ConfirmProvider } from "../ConfirmProvider"; import { ToastProvider } from "../ToastProvider"; import { PageHeaderContext, type PageHeader } from "./PageHeaderContext"; @@ -176,6 +177,7 @@ function AppLayoutShell({ + ); } diff --git a/frontend/src/components/Layout/BackToTopButton.tsx b/frontend/src/components/Layout/BackToTopButton.tsx new file mode 100644 index 0000000..66ca11a --- /dev/null +++ b/frontend/src/components/Layout/BackToTopButton.tsx @@ -0,0 +1,58 @@ +import { useEffect, useState } from "react"; +import { useTranslation } from "react-i18next"; +import Box from "@mui/material/Box"; +import Fab from "@mui/material/Fab"; +import Tooltip from "@mui/material/Tooltip"; +import Zoom from "@mui/material/Zoom"; +import KeyboardArrowUpIcon from "@mui/icons-material/KeyboardArrowUp"; +import { useImportJob } from "./ImportJobContext"; + +// Appears once enough of the page has scrolled past that "a bit down" reads as true, not on the +// first pixel of scroll -- matches the threshold feel of most scroll-to-top buttons. +const SCROLL_SHOW_THRESHOLD = 300; + +function scrollToTop() { + window.scrollTo({ top: 0, behavior: "smooth" }); +} + +/** Fixed bottom-right "back to top" FAB, shown app-wide (mounted once in AppLayout) -- the app + * scrolls at the window level (see AppLayout's own comment on why `main` has no overflow of its + * own), so a single `window.scroll` listener here covers every route. Zoom in/out on the + * visibility flip for the "pop" MUI's own back-to-top example uses, rather than an instant + * show/hide. Shifts up while ImportProgressBar is showing (also fixed to the bottom, full-width) + * so the two don't overlap. */ +export default function BackToTopButton() { + const { t } = useTranslation("app"); + const [visible, setVisible] = useState(false); + const { activeJob } = useImportJob(); + + useEffect(() => { + const onScroll = () => setVisible(window.scrollY > SCROLL_SHOW_THRESHOLD); + onScroll(); + window.addEventListener("scroll", onScroll, { passive: true }); + return () => window.removeEventListener("scroll", onScroll); + }, []); + + const label = t("shell.backToTop"); + + return ( + + theme.zIndex.fab, + transition: (theme) => theme.transitions.create("bottom"), + }} + > + + + + + + + + ); +} diff --git a/frontend/src/i18n/locales/en.json b/frontend/src/i18n/locales/en.json index 6b9f00e..aaebde9 100644 --- a/frontend/src/i18n/locales/en.json +++ b/frontend/src/i18n/locales/en.json @@ -3,6 +3,7 @@ "shell": { "apiUnreachable": "API unreachable. Ensure the API container is running and reachable.", "backToLibrary": "Back to library", + "backToTop": "Back to top", "sessionExpired": "Your session has expired. Please sign in again." }, "auth": { diff --git a/frontend/src/i18n/locales/lt.json b/frontend/src/i18n/locales/lt.json index 96f5e94..b83448c 100644 --- a/frontend/src/i18n/locales/lt.json +++ b/frontend/src/i18n/locales/lt.json @@ -3,6 +3,7 @@ "shell": { "apiUnreachable": "API nepasiekiamas. Įsitikinkite, kad API konteineris veikia ir pasiekiamas.", "backToLibrary": "Grįžti į biblioteką", + "backToTop": "Grįžti į viršų", "sessionExpired": "Jūsų sesija baigėsi. Prašome prisijungti iš naujo." }, "auth": {