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": {