A PTR reload lands while the standalone-PWA viewport is still settling and re-races the --app-height measurement (Chromium settles without firing resize), bringing the scroll room back ~50% of the time. The immersive page is fixed-viewport, so overscroll-behavior-y: none on html+body removes the gesture (and the race trigger) entirely; restored on route leave. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
231 lines
7.9 KiB
TypeScript
231 lines
7.9 KiB
TypeScript
import { useState, useCallback, useEffect, useRef } from "react";
|
|
import { Outlet, Navigate, useLocation } from "react-router-dom";
|
|
import { motion } from "framer-motion";
|
|
import Box from "@mui/material/Box";
|
|
import Drawer from "@mui/material/Drawer";
|
|
import IconButton from "@mui/material/IconButton";
|
|
import ScopedCssBaseline from "@mui/material/ScopedCssBaseline";
|
|
import { useAuth } from "../context/AuthContext";
|
|
import ThemeToggle from "./ThemeToggle";
|
|
import { setLogoutAlert } from "../utils/api";
|
|
import SidebarNav from "./SidebarNav";
|
|
import LoadingState from "./LoadingState";
|
|
import ShortcutsHelp from "../components/ShortcutsHelp";
|
|
|
|
const DRAWER_WIDTH = 248;
|
|
|
|
export default function AppShell() {
|
|
const { isAuthenticated, loading, user, logout } = useAuth();
|
|
const [mobileOpen, setMobileOpen] = useState(false);
|
|
const [loggingOut, setLoggingOut] = useState(false);
|
|
const location = useLocation();
|
|
const logoutTimer = useRef<ReturnType<typeof setTimeout> | undefined>(
|
|
undefined,
|
|
);
|
|
|
|
// Chat-style pages own the full mobile viewport: under md the shell header
|
|
// disappears and main loses its padding — the page brings its own back
|
|
// button. Desktop keeps the normal shell.
|
|
const immersiveOnMobile = location.pathname === "/odin";
|
|
|
|
// Installed-PWA (standalone) viewports lie to CSS units: on MIUI/Android
|
|
// svh/dvh are computed against a viewport that includes system UI the real
|
|
// layout viewport doesn't have, leaving scroll room no unit can remove
|
|
// (and Chrome can serve a stale viewport after minimize/restore). Measure
|
|
// the truth — window.innerHeight — into --app-height and keep it fresh;
|
|
// immersive layouts size from it with an svh fallback.
|
|
useEffect(() => {
|
|
if (!immersiveOnMobile) return;
|
|
const root = document.documentElement;
|
|
const set = () =>
|
|
root.style.setProperty("--app-height", `${window.innerHeight}px`);
|
|
set();
|
|
window.addEventListener("resize", set);
|
|
window.visualViewport?.addEventListener("resize", set);
|
|
// Kill pull-to-refresh: a PTR reload lands mid-viewport-settle and
|
|
// re-races the measurement (Chromium settles without a resize event).
|
|
// The immersive page is fixed-viewport — the gesture has no use here.
|
|
const prevRootOverscroll = root.style.overscrollBehaviorY;
|
|
const prevBodyOverscroll = document.body.style.overscrollBehaviorY;
|
|
root.style.overscrollBehaviorY = "none";
|
|
document.body.style.overscrollBehaviorY = "none";
|
|
return () => {
|
|
window.removeEventListener("resize", set);
|
|
window.visualViewport?.removeEventListener("resize", set);
|
|
root.style.removeProperty("--app-height");
|
|
root.style.overscrollBehaviorY = prevRootOverscroll;
|
|
document.body.style.overscrollBehaviorY = prevBodyOverscroll;
|
|
};
|
|
}, [immersiveOnMobile]);
|
|
|
|
const handleLogout = useCallback(() => {
|
|
setLoggingOut(true);
|
|
setMobileOpen(false);
|
|
setLogoutAlert();
|
|
// Delay so the blur/scale exit animation can play; store the id so an
|
|
// unmount within the 400ms window cancels it (avoids logout() firing on
|
|
// an unmounted tree).
|
|
logoutTimer.current = setTimeout(() => logout(), 400);
|
|
}, [logout]);
|
|
|
|
useEffect(() => {
|
|
return () => {
|
|
if (logoutTimer.current) clearTimeout(logoutTimer.current);
|
|
};
|
|
}, []);
|
|
|
|
if (loading) {
|
|
return (
|
|
<ScopedCssBaseline>
|
|
<Box
|
|
sx={{
|
|
display: "flex",
|
|
minHeight: "100dvh",
|
|
alignItems: "center",
|
|
justifyContent: "center",
|
|
bgcolor: "background.default",
|
|
}}
|
|
>
|
|
<LoadingState />
|
|
</Box>
|
|
</ScopedCssBaseline>
|
|
);
|
|
}
|
|
if (!isAuthenticated) return <Navigate to="/login" replace />;
|
|
if (user?.require2FA && !user?.totpEnabled && location.pathname !== "/") {
|
|
return <Navigate to="/" replace />;
|
|
}
|
|
|
|
return (
|
|
<ScopedCssBaseline>
|
|
<motion.div
|
|
initial={{ opacity: 0, scale: 0.98 }}
|
|
animate={
|
|
loggingOut
|
|
? { scale: 1.5, opacity: 0, filter: "blur(12px)" }
|
|
: { scale: 1, opacity: 1, filter: "none" }
|
|
}
|
|
transition={{
|
|
duration: loggingOut ? 0.4 : 0.25,
|
|
ease: [0.4, 0, 0.2, 1],
|
|
}}
|
|
>
|
|
<Box
|
|
sx={{
|
|
display: "flex",
|
|
bgcolor: "background.default",
|
|
// Immersive mobile pages must make the DOCUMENT exactly the
|
|
// small-viewport height and clip it: MIUI/Xiaomi Chrome keeps
|
|
// 100dvh at the large-viewport size while the URL bar overlays,
|
|
// so a 100dvh min-height leaves the page scrollable by the bar
|
|
// height (invisible in desktop emulation). svh + hidden overflow
|
|
// makes body scroll impossible regardless of dvh interpretation.
|
|
minHeight: immersiveOnMobile ? { xs: 0, md: "100dvh" } : "100dvh",
|
|
...(immersiveOnMobile && {
|
|
height: { xs: "var(--app-height, 100svh)", md: "auto" },
|
|
overflow: { xs: "hidden", md: "visible" },
|
|
}),
|
|
}}
|
|
>
|
|
<Drawer
|
|
variant="permanent"
|
|
sx={{
|
|
display: { xs: "none", md: "block" },
|
|
width: DRAWER_WIDTH,
|
|
flexShrink: 0,
|
|
"& .MuiDrawer-paper": {
|
|
width: DRAWER_WIDTH,
|
|
boxSizing: "border-box",
|
|
border: 0,
|
|
bgcolor: "background.default",
|
|
},
|
|
}}
|
|
>
|
|
<SidebarNav onLogout={handleLogout} />
|
|
</Drawer>
|
|
|
|
<Drawer
|
|
variant="temporary"
|
|
open={mobileOpen}
|
|
onClose={() => setMobileOpen(false)}
|
|
ModalProps={{ keepMounted: true }}
|
|
sx={{
|
|
display: { xs: "block", md: "none" },
|
|
"& .MuiDrawer-paper": {
|
|
width: DRAWER_WIDTH,
|
|
boxSizing: "border-box",
|
|
},
|
|
}}
|
|
>
|
|
<SidebarNav
|
|
onNavigate={() => setMobileOpen(false)}
|
|
onLogout={handleLogout}
|
|
/>
|
|
</Drawer>
|
|
|
|
<Box
|
|
sx={{
|
|
flex: 1,
|
|
minWidth: 0,
|
|
minHeight: 0,
|
|
display: "flex",
|
|
flexDirection: "column",
|
|
}}
|
|
>
|
|
<Box
|
|
component="header"
|
|
sx={{
|
|
display: immersiveOnMobile
|
|
? { xs: "none", md: "flex" }
|
|
: "flex",
|
|
alignItems: "center",
|
|
gap: 1,
|
|
px: 2,
|
|
py: 1.5,
|
|
minHeight: 64,
|
|
}}
|
|
>
|
|
<IconButton
|
|
onClick={() => setMobileOpen(true)}
|
|
aria-label="Otevřít menu"
|
|
sx={{ display: { md: "none" } }}
|
|
>
|
|
<svg
|
|
width="24"
|
|
height="24"
|
|
viewBox="0 0 24 24"
|
|
fill="none"
|
|
stroke="currentColor"
|
|
strokeWidth="2"
|
|
aria-hidden="true"
|
|
>
|
|
<line x1="3" y1="12" x2="21" y2="12" />
|
|
<line x1="3" y1="6" x2="21" y2="6" />
|
|
<line x1="3" y1="18" x2="21" y2="18" />
|
|
</svg>
|
|
</IconButton>
|
|
<Box sx={{ flex: 1 }} />
|
|
<ThemeToggle />
|
|
</Box>
|
|
<Box
|
|
component="main"
|
|
sx={{
|
|
flex: 1,
|
|
px: immersiveOnMobile ? { xs: 0, md: 3 } : { xs: 2, md: 3 },
|
|
pb: immersiveOnMobile ? { xs: 0, md: 4 } : 4,
|
|
...(immersiveOnMobile && {
|
|
minHeight: 0,
|
|
overflow: { xs: "hidden", md: "visible" },
|
|
}),
|
|
}}
|
|
>
|
|
<Outlet />
|
|
</Box>
|
|
</Box>
|
|
</Box>
|
|
<ShortcutsHelp />
|
|
</motion.div>
|
|
</ScopedCssBaseline>
|
|
);
|
|
}
|