import { useEffect, useState } from "react"; import { Link, useLocation } from "react-router"; import { Menu, Search } from "lucide-react"; import { Sheet, SheetContent, SheetHeader, SheetTitle } from "@/components/ui/sheet"; import { useAuth } from "@/hooks/useAuth"; import { useCurrentProfile } from "@/hooks/useCurrentProfile"; import { useIsActingAdmin } from "@/hooks/useIsActingAdmin"; import AppSidebar from "@/components/AppSidebar"; import ServerActivity from "@/components/ServerActivity"; import { SiloBrand } from "@/components/SiloBrand"; import { GlobalSearch } from "@/components/GlobalSearch"; import ViewTransitionLink from "@/components/ViewTransitionLink"; import { buildQueryCatalogHref, parseCatalogSearchParams } from "@/pages/catalogSearchParams"; import type { ReactNode } from "react"; import { useWatchPlaybackController } from "@/playback/watchPlaybackContext"; import { useAudiobookPlaybackController } from "@/pages/audiobooks/player/audiobookPlaybackContext"; interface LayoutProps { children: ReactNode; } export default function Layout({ children }: LayoutProps) { const location = useLocation(); const [mobileOpen, setMobileOpen] = useState(false); // Tracks whether the mobile header should slide off-screen on scroll. // We auto-hide on Calendar to maximize vertical room for the sticky week strip // and the day timeline below it. Pulling up restores the header. const [mobileHeaderHidden, setMobileHeaderHidden] = useState(false); const { user } = useAuth(); const { profile } = useCurrentProfile(); const showAdminActivity = useIsActingAdmin(); const { isBackgroundBarVisible } = useWatchPlaybackController(); const audiobookPlayback = useAudiobookPlaybackController(); const hasBackgroundBar = isBackgroundBarVisible || audiobookPlayback?.isBackgroundBarVisible; const isHomePath = location.pathname === "/"; const isLibraryRoute = location.pathname.startsWith("/library/"); const isItemRoute = location.pathname.startsWith("/item/"); const isSearchLandingRoute = location.pathname === "/catalog" && (() => { const state = parseCatalogSearchParams(new URLSearchParams(location.search)); return state.source === "query" && !state.q; })(); const isRecommendationsRoute = location.pathname === "/recommendations"; const isCalendarRoute = location.pathname === "/calendar"; const isRequestDetailRoute = /^\/requests\/(movie|series)\//.test(location.pathname); const needsNoPadding = isHomePath || isLibraryRoute || isItemRoute || isRequestDetailRoute || isSearchLandingRoute || isRecommendationsRoute || isCalendarRoute; // Immersion: auto-collapse sidebar on detail pages so content gets full stage const isDetailImmersion = isItemRoute; // Publish the current sidebar width on the document root so out-of-tree // chrome (notably ImpersonationBanner, which renders above all routes) // can align with whichever sidebar state is active. useEffect(() => { const root = document.documentElement; if (isDetailImmersion) { root.dataset.sidebarCollapsed = "true"; } else { delete root.dataset.sidebarCollapsed; } return () => { delete root.dataset.sidebarCollapsed; }; }, [isDetailImmersion]); // Auto-hide the mobile header on scroll-down for the Calendar route only. // Direction-based (not threshold-based) so a small scroll-up reveals the // header even mid-page — matches the iOS Safari pattern. useEffect(() => { if (!isCalendarRoute) { setMobileHeaderHidden(false); return; } let lastY = window.scrollY; let ticking = false; const onScroll = () => { if (ticking) return; ticking = true; requestAnimationFrame(() => { const y = window.scrollY; const delta = y - lastY; if (Math.abs(delta) > 4) { if (delta > 0 && y > 80) setMobileHeaderHidden(true); else if (delta < 0) setMobileHeaderHidden(false); lastY = y; } ticking = false; }); }; window.addEventListener("scroll", onScroll, { passive: true }); return () => window.removeEventListener("scroll", onScroll); }, [isCalendarRoute]); // Publish the header visibility on so child sticky elements (e.g. the // calendar's WeekNavigator wrapper) can pin closer to the viewport top when // the mobile header is off-screen — keeping the visual gap from collapsing. useEffect(() => { const root = document.documentElement; if (mobileHeaderHidden) { root.dataset.mobileHeaderHidden = "true"; } else { delete root.dataset.mobileHeaderHidden; } return () => { delete root.dataset.mobileHeaderHidden; }; }, [mobileHeaderHidden]); return (
Skip to content
{/* Desktop sidebar — hidden below lg */}
{/* Mobile header — visible below lg. Slides up on scroll-down within the Calendar route to free vertical space; pulling up reveals it. */}
{showAdminActivity && } {profile?.name?.charAt(0).toUpperCase() ?? user?.username?.charAt(0).toUpperCase() ?? "?"}
{/* Mobile sidebar drawer */} Navigation setMobileOpen(false)} /> {/* Desktop admin activity indicator (top-right, hidden on mobile) */} {showAdminActivity && (
)} {/* Main content — offset by sidebar width on desktop */}
{needsNoPadding ? ( children ) : (
{children}
)}
); }