import clsx from "clsx"; import { isToday, isTomorrow } from "date-fns"; import { Bell, Calendar, ChevronRight, LogIn, PanelLeft, Settings, Tv, Users, } from "lucide-react"; import * as React from "react"; import { Button, Dialog, DialogTrigger, Modal, ModalOverlay, } from "react-aria-components"; import { Flipped, Flipper } from "react-flip-toolkit"; import { useTranslation } from "react-i18next"; import { Link, useFetcher, useLocation, useMatches } from "react-router"; import { useUser } from "~/features/auth/core/user"; import { useChatContext } from "~/features/chat/useChatContext"; import { FriendMenu } from "~/features/friends/components/FriendMenu"; import { useDateTimeFormat } from "~/hooks/intl/useDateTimeFormat"; import { useHydrated } from "~/hooks/useHydrated"; import type { RootLoaderData } from "~/root"; import type { Breadcrumb, SendouRouteHandle } from "~/utils/remix.server"; import { EVENTS_PAGE, FRIENDS_PAGE, SETTINGS_PAGE, userPage, } from "~/utils/urls"; import { Avatar } from "../Avatar"; import { SendouButton } from "../elements/Button"; import { SendouPopover } from "../elements/Popover"; import { FuseZone } from "../fuse/Fuse"; import { Image } from "../Image"; import { MobileNav } from "../MobileNav"; import { NotificationDot } from "../NotificationDot"; import { ListLink, SideNav, SideNavFooter, SideNavHeader } from "../SideNav"; import sideNavStyles from "../SideNav.module.css"; import { StreamListItems } from "../StreamListItems"; import { AuthErrorDialog } from "./AuthErrorDialog"; import { ChatSidebar } from "./ChatSidebar"; import { Footer } from "./Footer"; import styles from "./index.module.css"; import { LogInButtonContainer } from "./LogInButtonContainer"; import { NotificationContent, useNotifications } from "./NotificationPopover"; import notificationPopoverStyles from "./NotificationPopover.module.css"; import { TopNavMenus } from "./TopNavMenus"; import { TopRightButtons } from "./TopRightButtons"; const MAX_DESKTOP_FRIENDS = 4; function useRelativeDayFormat() { const { i18n } = useTranslation(); const { formatter: timeFormatter } = useDateTimeFormat({ hour: "numeric", minute: "numeric", }); const { formatter: dateTimeFormatter } = useDateTimeFormat({ month: "numeric", day: "numeric", hour: "numeric", minute: "numeric", }); const formatRelativeDay = (daysFromToday: number) => { const rtf = new Intl.RelativeTimeFormat(i18n.language, { numeric: "auto" }); const str = rtf.format(daysFromToday, "day"); return str.charAt(0).toUpperCase() + str.slice(1); }; const formatRelativeDate = (timestamp: number) => { const date = new Date(timestamp * 1000); const timeStr = timeFormatter.format(date); if (isToday(date)) { return `${formatRelativeDay(0)}, ${timeStr}`; } if (isTomorrow(date)) { return `${formatRelativeDay(1)}, ${timeStr}`; } return dateTimeFormatter.format(date); }; return { formatRelativeDate }; } function useBreadcrumbData() { const { t } = useTranslation(); const matches = useMatches(); const breadcrumbs: Breadcrumb[] = []; for (const match of [...matches].reverse()) { const handle = match.handle as SendouRouteHandle | undefined; const resolved = handle?.breadcrumb?.({ match, t }); if (resolved) { const items = Array.isArray(resolved) ? resolved : [resolved]; breadcrumbs.push(...items); } } return { breadcrumbs, currentPageText: breadcrumbs.at(-1)?.text, }; } function useSideNavCollapsed(initialCollapsed: boolean) { const [collapsed, setCollapsed] = React.useState(initialCollapsed); const fetcher = useFetcher(); const setCollapsedAndPersist = (value: boolean) => { setCollapsed(value); fetcher.submit( { collapsed: String(value) }, { method: "POST", action: "/sidenav" }, ); }; return [collapsed, setCollapsedAndPersist] as const; } function useNavOffset(headerRef: React.RefObject) { const [navOffset, setNavOffset] = React.useState(0); const lastScrollY = React.useRef(0); const MOBILE_BREAKPOINT = 600; const NAV_HEIGHT_FALLBACK = 55; const SCROLL_THRESHOLD_PX = 200; const scrollAccumulator = React.useRef(0); React.useEffect(() => { const handleScroll = () => { if (window.innerWidth >= MOBILE_BREAKPOINT) { setNavOffset(0); lastScrollY.current = window.scrollY; scrollAccumulator.current = 0; return; } const navHeight = headerRef.current?.offsetHeight ?? NAV_HEIGHT_FALLBACK; const currentScrollY = window.scrollY; const scrollDelta = currentScrollY - lastScrollY.current; const directionChanged = (scrollDelta > 0 && scrollAccumulator.current < 0) || (scrollDelta < 0 && scrollAccumulator.current > 0); if (directionChanged) { scrollAccumulator.current = 0; } scrollAccumulator.current += scrollDelta; if (Math.abs(scrollAccumulator.current) >= SCROLL_THRESHOLD_PX) { const overflow = scrollAccumulator.current > 0 ? scrollAccumulator.current - SCROLL_THRESHOLD_PX : scrollAccumulator.current + SCROLL_THRESHOLD_PX; setNavOffset((prevOffset) => { const newOffset = prevOffset - overflow; return Math.max(-navHeight, Math.min(0, newOffset)); }); scrollAccumulator.current = scrollAccumulator.current > 0 ? SCROLL_THRESHOLD_PX : -SCROLL_THRESHOLD_PX; } lastScrollY.current = currentScrollY; }; const handleResize = () => { if (window.innerWidth >= MOBILE_BREAKPOINT) { setNavOffset(0); } }; window.addEventListener("scroll", handleScroll, { passive: true }); window.addEventListener("resize", handleResize); return () => { window.removeEventListener("scroll", handleScroll); window.removeEventListener("resize", handleResize); }; }, [headerRef]); return navOffset; } export function Layout({ children, data, }: { children: React.ReactNode; data?: RootLoaderData; }) { const chatContext = useChatContext(); const [sideNavCollapsed, setSideNavCollapsed] = useSideNavCollapsed( data?.sidenavCollapsed ?? false, ); const [sideNavModalOpen, setSideNavModalOpen] = React.useState(false); const [chatSidebarModalOpen, setChatSidebarModalOpen] = React.useState(false); const chatSidebarOpen = chatContext?.chatOpen ?? false; const setChatSidebarOpen = chatContext?.setChatOpen ?? (() => {}); const { t } = useTranslation(["front", "common"]); const { formatRelativeDate } = useRelativeDayFormat(); const isHydrated = useHydrated(); const location = useLocation(); const headerRef = React.useRef(null); const navOffset = useNavOffset(headerRef); React.useEffect(() => { const handleResize = () => { if (window.innerWidth < 600 || window.innerWidth >= 1000) { setSideNavModalOpen(false); setChatSidebarModalOpen(false); } }; window.addEventListener("resize", handleResize); return () => window.removeEventListener("resize", handleResize); }, []); // biome-ignore lint/correctness/useExhaustiveDependencies: intentionally close modals on navigation React.useEffect(() => { setSideNavModalOpen(false); setChatSidebarModalOpen(false); }, [location.pathname]); const user = useUser(); const { unseenIds } = useNotifications(); const sidebarData = data?.sidebar; const events = sidebarData?.events ?? []; const friends = sidebarData?.friends ?? []; const streams = sidebarData?.streams ?? []; const isFrontPage = location.pathname === "/"; const showLeaderboard = import.meta.env.VITE_FUSE_ENABLED && !data?.user?.roles.includes("MINOR_SUPPORT") && !location.pathname.includes("plans"); const sideNavFooterContent = ( ); const sideNavChildren = ( <> } action={ user ? ( {t("common:actions.viewAll")} ) : null } > {t("front:sideNav.myCalendar")} {events.length > 0 ? ( events.map((event) => ( Placeholder ) } > {event.scrimStatus === "booked" ? t("front:sideNav.scrimVs", { opponent: event.name }) : event.scrimStatus === "looking" ? t("front:sideNav.lookingForScrim") : event.scrimStatus === "requestPending" ? t("front:sideNav.scrimRequestPending") : event.name} )) ) : (
{t("front:sideNav.noEvents")}
)} } action={ user ? ( {t("common:actions.viewAll")} ) : null } > {t("front:sideNav.friends")} {friends.length > 0 ? ( friends .slice(0, MAX_DESKTOP_FRIENDS) .map((friend) => ) ) : (
{user ? t("front:sideNav.friends.noFriends") : t("front:sideNav.friends.notLoggedIn")}
)} }>{t("front:sideNav.streams")} {streams.length === 0 ? (
{t("front:sideNav.noStreams")}
) : null} ); return ( <> } topCentered={isFrontPage} > {sideNavChildren}
0} testId="sidenav-modal-trigger" /> } topCentered={isFrontPage} > {sideNavChildren} setSideNavCollapsed(!sideNavCollapsed)} className={styles.sideNavCollapseButton} showNotificationDot={sideNavCollapsed && unseenIds.length > 0} testId="sidenav-collapse-button" /> setChatSidebarOpen(true) : undefined } onChatModalToggle={ data?.user ? () => setChatSidebarModalOpen((prev) => !prev) : undefined } chatUnreadCount={chatContext?.totalUnreadCount} />
{showLeaderboard ? ( ) : null} {children}
{chatSidebarOpen ? (
setChatSidebarOpen(false)} />
) : null} ); } function SiteTitle() { const location = useLocation(); const { breadcrumbs, currentPageText } = useBreadcrumbData(); const isFrontPage = location.pathname === "/"; const hasBreadcrumbs = breadcrumbs.length > 0; return (
{hasBreadcrumbs ? ( <> {breadcrumbs.map((crumb) => { const isCurrentPage = location.pathname === crumb.href; return ( / {isCurrentPage ? ( ) : ( )} ); })} {currentPageText ? ( {currentPageText} ) : null} ) : null}
); } function SiteLogoContent() { return ( <> S ink ); } function SideNavCollapseButton({ onToggle, className, showNotificationDot, testId, }: { onToggle?: () => void; className?: string; showNotificationDot?: boolean; testId?: string; }) { return (
} onPress={onToggle} /> {showNotificationDot ? : null}
); } function PageIcon({ crumb }: { crumb: Breadcrumb }) { if (crumb.type !== "IMAGE") { return null; } const isExternal = crumb.imgPath.includes("."); const iconClass = clsx(styles.pageIcon, "rounded"); return (
{isExternal ? ( ) : ( )}
); } function SideNavUserPanel() { const { t } = useTranslation(); const location = useLocation(); const user = useUser(); const { notifications, unseenIds } = useNotifications(); if (user) { return ( <> {user.username}
{notifications ? (
{unseenIds.length > 0 ? ( ) : null} } popoverClassName={clsx( notificationPopoverStyles.popoverContainer, { [notificationPopoverStyles.noNotificationsContainer]: notifications.length === 0, }, )} >
) : null}
); } return ( <> }> {t("header.login.discord")}
); }