mirror of
https://github.com/Sendouc/sendou.ink.git
synced 2026-05-05 20:56:13 -05:00
517 lines
13 KiB
TypeScript
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>
|
|
);
|
|
}
|