sendou.ink/app/components/layout/index.tsx
2026-01-25 11:46:37 +02:00

489 lines
12 KiB
TypeScript

import clsx from "clsx";
import { isToday, isTomorrow } from "date-fns";
import {
Bell,
Calendar,
LogIn,
PanelLeft,
Settings,
Users,
} from "lucide-react";
import * as React from "react";
import { Button } 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 type { loader as sidebarLoader } from "~/features/sidebar/routes/sidebar";
import { useIsMounted } from "~/hooks/useIsMounted";
import type { RootLoaderData } from "~/root";
import type { Breadcrumb, SendouRouteHandle } from "~/utils/remix.server";
import { navIconUrl, SETTINGS_PAGE, userPage } from "~/utils/urls";
import { Avatar } from "../Avatar";
import { SendouButton } from "../elements/Button";
import { SendouPopover } from "../elements/Popover";
import { Image } from "../Image";
import { TwitchIcon } from "../icons/Twitch";
import { MobileNav } from "../MobileNav";
import {
SideNav,
SideNavFooter,
SideNavGameStatus,
SideNavHeader,
SideNavLink,
} from "../SideNav";
import sideNavStyles from "../SideNav.module.css";
import { Footer } from "./Footer";
import styles from "./index.module.css";
import { LogInButtonContainer } from "./LogInButtonContainer";
import { NavDialog } from "./NavDialog";
import { NotificationContent, useNotifications } from "./NotificationPopover";
import notificationPopoverStyles from "./NotificationPopover.module.css";
import { TopNavMenus } from "./TopNavMenus";
import { TopRightButtons } from "./TopRightButtons";
function useTimeFormat() {
const { i18n } = useTranslation();
const formatTime = (date: Date, options: Intl.DateTimeFormatOptions) => {
return date.toLocaleTimeString(i18n.language, options);
};
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 = formatTime(date, { hour: "numeric", minute: "2-digit" });
if (isToday(date)) {
return `${formatRelativeDay(0)}, ${timeStr}`;
}
if (isTomorrow(date)) {
return `${formatRelativeDay(1)}, ${timeStr}`;
}
return date.toLocaleDateString(i18n.language, {
month: "short",
day: "numeric",
hour: "numeric",
minute: "2-digit",
});
};
return { formatTime, formatRelativeDate };
}
function useSidebarData() {
const fetcher = useFetcher<typeof sidebarLoader>();
React.useEffect(() => {
if (fetcher.state === "idle" && !fetcher.data) {
fetcher.load("/sidebar");
}
}, [fetcher]);
return fetcher.data;
}
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() {
const [navOffset, setNavOffset] = React.useState(0);
const lastScrollY = React.useRef(0);
const MOBILE_BREAKPOINT = 600;
const NAV_HEIGHT = 55;
React.useEffect(() => {
const handleScroll = () => {
if (window.innerWidth >= MOBILE_BREAKPOINT) {
setNavOffset(0);
lastScrollY.current = window.scrollY;
return;
}
const currentScrollY = window.scrollY;
const scrollDelta = currentScrollY - lastScrollY.current;
setNavOffset((prevOffset) => {
const newOffset = prevOffset - scrollDelta;
return Math.max(-NAV_HEIGHT, Math.min(0, newOffset));
});
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);
};
}, []);
return navOffset;
}
export function Layout({
children,
data,
}: {
children: React.ReactNode;
data?: RootLoaderData;
}) {
const [navDialogOpen, setNavDialogOpen] = React.useState(false);
const [sideNavCollapsed, setSideNavCollapsed] = useSideNavCollapsed(
data?.sidenavCollapsed ?? false,
);
const { t } = useTranslation(["front"]);
const { formatRelativeDate } = useTimeFormat();
const location = useLocation();
const sidebarData = useSidebarData();
const navOffset = useNavOffset();
const isMounted = useIsMounted();
const events = sidebarData?.events ?? [];
const matchStatus = sidebarData?.matchStatus;
const tournamentMatchStatus = sidebarData?.tournamentMatchStatus;
const friends = sidebarData?.friends ?? [];
const streams = sidebarData?.streams ?? [];
const isFrontPage = location.pathname === "/";
const showLeaderboard =
import.meta.env.VITE_PLAYWIRE_PUBLISHER_ID &&
!data?.user?.roles.includes("MINOR_SUPPORT") &&
!location.pathname.includes("plans");
return (
<>
<NavDialog isOpen={navDialogOpen} close={() => setNavDialogOpen(false)} />
<SideNav
collapsed={sideNavCollapsed}
footer={
<>
{matchStatus ? (
<SideNavGameStatus
iconUrl={navIconUrl("sendouq")}
text={t("front:sideNav.matchStarted", {
matchId: matchStatus.matchId,
})}
href={matchStatus.url}
/>
) : tournamentMatchStatus ? (
<SideNavGameStatus
imageUrl={tournamentMatchStatus.logoUrl ?? undefined}
text={
tournamentMatchStatus.text === "CHECKIN"
? t("front:sideNav.tournamentCheckin")
: tournamentMatchStatus.text === "WAITING"
? t("front:sideNav.tournamentWaiting")
: tournamentMatchStatus.text === "MATCH"
? t("front:sideNav.tournamentMatchReady", {
roundName: tournamentMatchStatus.roundName,
})
: tournamentMatchStatus.text
}
href={tournamentMatchStatus.url}
/>
) : null}
<SideNavFooter>
<SideNavUserPanel />
</SideNavFooter>
</>
}
top={<SiteTitle />}
topCentered={isFrontPage}
>
<SideNavHeader icon={<Calendar />}>
{t("front:sideNav.myCalendar")}
</SideNavHeader>
{events.length > 0 ? (
events.map((event) => (
<SideNavLink
key={`${event.type}-${event.id}`}
to={event.url}
imageUrl={event.logoUrl ?? undefined}
subtitle={formatRelativeDate(event.startTime)}
badge={
event.scrimStatus === "booked"
? t("front:sideNav.scrimBooked")
: event.scrimStatus === "looking"
? t("front:sideNav.scrimLooking")
: undefined
}
badgeVariant={
event.scrimStatus === "looking" ? "warning" : undefined
}
>
{event.scrimStatus === "booked"
? t("front:sideNav.scrimVs", { opponent: event.name })
: event.name}
</SideNavLink>
))
) : (
<div className={styles.sideNavEmpty}>
{t("front:sideNav.noEvents")}
</div>
)}
<SideNavHeader icon={<Users />}>
{t("front:sideNav.friends")}
</SideNavHeader>
{friends.map((friend) => (
<SideNavLink
key={friend.id}
to={friend.url}
user={{
discordId: friend.discordId,
discordAvatar: friend.discordAvatar,
}}
subtitle={friend.subtitle}
badge={friend.badge}
>
{friend.name}
</SideNavLink>
))}
<SideNavHeader icon={<TwitchIcon />}>
{t("front:sideNav.streams")}
</SideNavHeader>
{streams.map((stream) => (
<SideNavLink
key={stream.id}
to={stream.url}
imageUrl={stream.imageUrl}
subtitle={stream.subtitle}
badge={
isMounted && stream.startsAt < Date.now() ? "LIVE" : undefined
}
>
{stream.name}
</SideNavLink>
))}
</SideNav>
<MobileNav sidebarData={sidebarData} />
<div className={styles.container}>
<header
className={styles.header}
style={{
transform: `translateY(${navOffset}px)`,
}}
>
<MobileLogo />
<SideNavCollapseButton
onToggle={() => setSideNavCollapsed(!sideNavCollapsed)}
/>
<TopNavMenus />
<TopRightButtons
showSupport={Boolean(
data &&
!data?.user?.roles.includes("MINOR_SUPPORT") &&
isFrontPage,
)}
showSearch={Boolean(data?.user)}
isLoggedIn={Boolean(data?.user)}
openNavDialog={() => setNavDialogOpen(true)}
/>
</header>
{showLeaderboard ? <MyRampUnit /> : null}
{children}
<Footer />
</div>
</>
);
}
function SiteTitle() {
const location = useLocation();
const { breadcrumbs, currentPageText } = useBreadcrumbData();
const isFrontPage = location.pathname === "/";
const hasBreadcrumbs = breadcrumbs.length > 0;
return (
<Flipper flipKey={isFrontPage ? "front" : "other"}>
<div className={styles.siteTitle}>
<Flipped flipId="site-logo">
<Link to="/" className={styles.siteLogo}>
S
</Link>
</Flipped>
{hasBreadcrumbs ? (
<>
{breadcrumbs.map((crumb) => (
<React.Fragment key={crumb.href}>
<span className={styles.separator}>/</span>
<PageIcon crumb={crumb} />
</React.Fragment>
))}
{currentPageText ? (
<span className={styles.pageName}>{currentPageText}</span>
) : null}
</>
) : null}
</div>
</Flipper>
);
}
function MobileLogo() {
return (
<Link to="/" className={styles.mobileLogo}>
S
</Link>
);
}
function SideNavCollapseButton({ onToggle }: { onToggle: () => void }) {
return (
<SendouButton
className={styles.sideNavCollapseButton}
variant="minimal"
size="small"
shape="square"
icon={<PanelLeft />}
onPress={onToggle}
/>
);
}
function PageIcon({ crumb }: { crumb: Breadcrumb }) {
if (crumb.type !== "IMAGE") {
return null;
}
const isExternal = crumb.imgPath.includes(".");
const iconClass = clsx(styles.pageIcon, "rounded");
return isExternal ? (
<img src={crumb.imgPath} alt="" className={iconClass} />
) : (
<Image
path={crumb.imgPath}
alt=""
className={iconClass}
width={20}
height={20}
/>
);
}
function MyRampUnit() {
return <div className="top-leaderboard" id="pw-leaderboard_atf" />;
}
function SideNavUserPanel() {
const { t } = useTranslation();
const user = useUser();
const { notifications, unseenIds } = useNotifications();
if (user) {
return (
<>
<Link to={userPage(user)} className={sideNavStyles.sideNavFooterUser}>
<Avatar user={user} size="xs" />
<span className={sideNavStyles.sideNavFooterUsername}>
{user.username}
</span>
</Link>
<div className={sideNavStyles.sideNavFooterActions}>
{notifications ? (
<div className={sideNavStyles.sideNavFooterNotification}>
{unseenIds.length > 0 ? (
<div className={sideNavStyles.sideNavFooterUnseenDot}>
<div className={sideNavStyles.sideNavFooterUnseenDotPulse} />
</div>
) : null}
<SendouPopover
trigger={
<Button
className={sideNavStyles.sideNavFooterButton}
data-testid="notifications-button"
>
<Bell />
</Button>
}
popoverClassName={clsx(
notificationPopoverStyles.popoverContainer,
{
[notificationPopoverStyles.noNotificationsContainer]:
notifications.length === 0,
},
)}
>
<NotificationContent
notifications={notifications}
unseenIds={unseenIds}
/>
</SendouPopover>
</div>
) : null}
<Link
to={SETTINGS_PAGE}
className={sideNavStyles.sideNavFooterButton}
>
<Settings />
</Link>
</div>
</>
);
}
return (
<LogInButtonContainer>
<SendouButton type="submit" size="small" icon={<LogIn />}>
{t("header.login.discord")}
</SendouButton>
</LogInButtonContainer>
);
}