sendou.ink/app/components/layout/index.tsx
2026-03-07 17:23:32 +02:00

517 lines
13 KiB
TypeScript

import clsx from "clsx";
import { isToday, isTomorrow } from "date-fns";
import {
Bell,
Calendar,
ChevronRight,
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 { SidebarStream } from "~/features/core/streams/streams.server";
import { FriendMenu } from "~/features/friends/components/FriendMenu";
import { useIsMounted } from "~/hooks/useIsMounted";
import type { LanguageCode } from "~/modules/i18n/config";
import type { RootLoaderData } from "~/root";
import { databaseTimestampToDate, formatDistanceToNow } from "~/utils/dates";
import type { Breadcrumb, SendouRouteHandle } from "~/utils/remix.server";
import {
FRIENDS_PAGE,
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 { ListLink, SideNav, SideNavFooter, SideNavHeader } from "../SideNav";
import sideNavStyles from "../SideNav.module.css";
import { TierPill } from "../TierPill";
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";
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 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 [sideNavCollapsed, setSideNavCollapsed] = useSideNavCollapsed(
data?.sidenavCollapsed ?? false,
);
const { t, i18n } = useTranslation(["front", "common"]);
const { formatRelativeDate } = useTimeFormat();
const location = useLocation();
const navOffset = useNavOffset();
const isMounted = useIsMounted();
const user = useUser();
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_PLAYWIRE_PUBLISHER_ID &&
!data?.user?.roles.includes("MINOR_SUPPORT") &&
!location.pathname.includes("plans");
return (
<>
<SideNav
collapsed={sideNavCollapsed}
footer={
<SideNavFooter>
<SideNavUserPanel />
</SideNavFooter>
}
top={<SiteTitle />}
topCentered={isFrontPage}
>
<SideNavHeader icon={<Calendar />}>
{t("front:sideNav.myCalendar")}
</SideNavHeader>
{events.length > 0 ? (
events.map((event) => (
<ListLink
key={`${event.type}-${event.id}`}
to={event.url}
imageUrl={event.logoUrl ?? undefined}
subtitle={formatRelativeDate(event.startTime)}
>
{event.scrimStatus === "booked"
? t("front:sideNav.scrimVs", { opponent: event.name })
: event.scrimStatus === "looking"
? t("front:sideNav.lookingForScrim")
: event.name}
</ListLink>
))
) : (
<div className={styles.sideNavEmpty}>
{t("front:sideNav.noEvents")}
</div>
)}
<SideNavHeader
icon={<Users />}
action={
user ? (
<Link to={FRIENDS_PAGE} className={styles.viewAllLink}>
{t("common:actions.viewAll")}
<ChevronRight size={14} />
</Link>
) : null
}
>
{t("front:sideNav.friends")}
</SideNavHeader>
{friends.length > 0 ? (
friends.map((friend) => <FriendMenu key={friend.id} {...friend} />)
) : (
<div className={styles.sideNavEmpty}>
{user
? t("front:sideNav.friends.noFriends")
: t("front:sideNav.friends.notLoggedIn")}
</div>
)}
<SideNavHeader icon={<TwitchIcon />}>
{t("front:sideNav.streams")}
</SideNavHeader>
{streams.length === 0 ? (
<div className={styles.sideNavEmpty}>
{t("front:sideNav.noStreams")}
</div>
) : null}
{streams.map((stream, i) => {
const startsAtDate = databaseTimestampToDate(stream.startsAt);
const isUpcoming = startsAtDate.getTime() > Date.now();
const prevStream = streams.at(i - 1);
const prevIsLive =
prevStream &&
databaseTimestampToDate(prevStream.startsAt).getTime() <=
Date.now();
const showUpcomingDivider = isMounted && isUpcoming && prevIsLive;
return (
<React.Fragment key={stream.id}>
{showUpcomingDivider ? (
<div className={styles.streamUpcomingDivider}>
{t("front:sideNav.streams.upcoming")}
</div>
) : null}
<ListLink
to={stream.url}
imageUrl={stream.imageUrl}
overlayIconUrl={stream.overlayIconUrl}
subtitle={
stream.peakXp ? (
<span className={styles.streamXpSubtitle}>
<img
src={`${navIconUrl("xsearch")}.png`}
alt=""
className={styles.streamXpIcon}
/>
{stream.peakXp}
</span>
) : stream.subtitle ? (
stream.subtitle
) : isMounted ? (
isUpcoming ? (
formatRelativeDate(stream.startsAt)
) : (
formatDistanceToNow(startsAtDate, {
addSuffix: true,
language: i18n.language as LanguageCode,
})
)
) : (
""
)
}
badge={
isMounted && !isUpcoming ? "LIVE" : streamTierBadge(stream)
}
>
{stream.name}
</ListLink>
</React.Fragment>
);
})}
</SideNav>
<MobileNav sidebarData={data?.sidebar} />
<div className={styles.container}>
<header
className={styles.header}
style={{
transform: `translateY(${navOffset}px)`,
}}
>
<Link to="/" className={clsx(styles.siteLogo, styles.mobileLogo)}>
<SiteLogoContent />
</Link>
<SideNavCollapseButton
onToggle={() => setSideNavCollapsed(!sideNavCollapsed)}
/>
<TopNavMenus />
<TopRightButtons
showSupport={Boolean(
data &&
!data?.user?.roles.includes("MINOR_SUPPORT") &&
isFrontPage,
)}
showSearch={Boolean(data?.user)}
isLoggedIn={Boolean(data?.user)}
/>
</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}>
<SiteLogoContent />
</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 SiteLogoContent() {
return (
<>
<span className={styles.siteLogoS}>S</span>
<span className={styles.siteLogoInk}>ink</span>
</>
);
}
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>
<div className={sideNavStyles.sideNavFooterActions}>
<Link to={SETTINGS_PAGE} className={sideNavStyles.sideNavFooterButton}>
<Settings />
</Link>
</div>
</>
);
}
function streamTierBadge(stream: SidebarStream): React.ReactNode {
const tier = stream.tier ?? stream.tentativeTier;
if (!tier) return undefined;
return (
<div className={styles.streamTierBadge}>
<TierPill
tier={tier}
isTentative={!stream.tier && !!stream.tentativeTier}
/>
</div>
);
}