mirror of
https://github.com/Sendouc/sendou.ink.git
synced 2026-06-02 22:26:57 -05:00
366 lines
8.8 KiB
TypeScript
366 lines
8.8 KiB
TypeScript
import { faker } from "@faker-js/faker";
|
|
import clsx from "clsx";
|
|
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, useLocation, useMatches } from "react-router";
|
|
import { useUser } from "~/features/auth/core/user";
|
|
import type { RootLoaderData } from "~/root";
|
|
import type { Breadcrumb, SendouRouteHandle } from "~/utils/remix.server";
|
|
import { 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 { BellIcon } from "../icons/Bell";
|
|
import { CalendarIcon } from "../icons/Calendar";
|
|
import { GearIcon } from "../icons/Gear";
|
|
import { HamburgerIcon } from "../icons/Hamburger";
|
|
import { TwitchIcon } from "../icons/Twitch";
|
|
import { UsersIcon } from "../icons/Users";
|
|
import { SideNav, SideNavFooter, 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,
|
|
notificationPopoverClassName,
|
|
useNotifications,
|
|
} from "./NotificationPopover";
|
|
import { TopNavMenus } from "./TopNavMenus";
|
|
import { TopRightButtons } from "./TopRightButtons";
|
|
|
|
function useTimeFormat() {
|
|
const formatTime = (date: Date, options: Intl.DateTimeFormatOptions) => {
|
|
return date.toLocaleTimeString("en-US", options);
|
|
};
|
|
return { formatTime };
|
|
}
|
|
|
|
const MOCK_STREAMS = [
|
|
{
|
|
id: 3,
|
|
name: "Paddling Pool 252",
|
|
imageUrl: faker.image.avatar(),
|
|
subtitle: "Losers Finals",
|
|
badge: "LIVE",
|
|
},
|
|
{
|
|
id: 1,
|
|
name: "Splash Go!",
|
|
imageUrl:
|
|
"https://liquipedia.net/commons/images/7/73/Splash_Go_allmode.png",
|
|
subtitle: "Tomorrow, 9:00 AM",
|
|
},
|
|
{
|
|
id: 2,
|
|
name: "Area Cup",
|
|
imageUrl:
|
|
"https://pbs.twimg.com/profile_images/1830601967821017088/4SDZVKdj_400x400.jpg",
|
|
subtitle: "Saturday, 10 AM",
|
|
},
|
|
];
|
|
|
|
const MOCK_FRIENDS = [
|
|
{
|
|
id: 1,
|
|
name: faker.internet.username(),
|
|
avatarUrl: faker.image.avatar(),
|
|
subtitle: "SendouQ",
|
|
badge: "2/4",
|
|
},
|
|
{
|
|
id: 2,
|
|
name: faker.internet.username(),
|
|
avatarUrl: faker.image.avatar(),
|
|
subtitle: "Lobby",
|
|
badge: "2/8",
|
|
},
|
|
{
|
|
id: 3,
|
|
name: faker.internet.username(),
|
|
avatarUrl: faker.image.avatar(),
|
|
subtitle: "In The Zone 22",
|
|
badge: "3/4",
|
|
},
|
|
{
|
|
id: 4,
|
|
name: faker.internet.username(),
|
|
avatarUrl: faker.image.avatar(),
|
|
subtitle: "SendouQ",
|
|
badge: "1/4",
|
|
},
|
|
{
|
|
id: 5,
|
|
name: faker.internet.username(),
|
|
avatarUrl: faker.image.avatar(),
|
|
subtitle: "Lobby",
|
|
badge: "5/8",
|
|
},
|
|
];
|
|
|
|
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,
|
|
};
|
|
}
|
|
|
|
export function Layout({
|
|
children,
|
|
data,
|
|
}: {
|
|
children: React.ReactNode;
|
|
data?: RootLoaderData;
|
|
}) {
|
|
const [navDialogOpen, setNavDialogOpen] = React.useState(false);
|
|
const location = useLocation();
|
|
|
|
const { t } = useTranslation(["front"]);
|
|
const { formatTime } = useTimeFormat();
|
|
|
|
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)} />
|
|
{isFrontPage ? (
|
|
<SendouButton
|
|
icon={<HamburgerIcon />}
|
|
className={clsx(styles.hamburger, styles.fab)}
|
|
variant="outlined"
|
|
onPress={() => setNavDialogOpen(true)}
|
|
/>
|
|
) : null}
|
|
<SideNav
|
|
footer={
|
|
<SideNavFooter>
|
|
<SideNavUserPanel />
|
|
</SideNavFooter>
|
|
}
|
|
top={<SiteTitle />}
|
|
topCentered={isFrontPage}
|
|
>
|
|
<SideNavHeader icon={<CalendarIcon />}>
|
|
{t("front:sideNav.myCalendar")}
|
|
</SideNavHeader>
|
|
{data && data.tournaments.participatingFor.length > 0 ? (
|
|
data.tournaments.participatingFor
|
|
.slice(0, 4)
|
|
.map((tournament, index) => (
|
|
<SideNavLink
|
|
key={tournament.id}
|
|
href={tournament.url}
|
|
imageUrl={tournament.logoUrl ?? undefined}
|
|
subtitle={`${index < 2 ? "Today" : "Tomorrow"}, ${formatTime(
|
|
new Date(tournament.startTime),
|
|
{
|
|
hour: "numeric",
|
|
minute: "2-digit",
|
|
},
|
|
)}`}
|
|
>
|
|
{tournament.name}
|
|
</SideNavLink>
|
|
))
|
|
) : (
|
|
<div className={styles.sideNavEmpty}>
|
|
{t("front:sideNav.noEvents")}
|
|
</div>
|
|
)}
|
|
|
|
<SideNavHeader icon={<UsersIcon />}>
|
|
{t("front:sideNav.friends")}
|
|
</SideNavHeader>
|
|
{MOCK_FRIENDS.map((friend) => (
|
|
<SideNavLink
|
|
key={friend.id}
|
|
href=""
|
|
imageUrl={friend.avatarUrl}
|
|
subtitle={friend.subtitle}
|
|
badge={friend.badge}
|
|
>
|
|
{friend.name}
|
|
</SideNavLink>
|
|
))}
|
|
|
|
<SideNavHeader icon={<TwitchIcon />}>
|
|
{t("front:sideNav.streams")}
|
|
</SideNavHeader>
|
|
{MOCK_STREAMS.map((stream) => (
|
|
<SideNavLink
|
|
key={stream.id}
|
|
href=""
|
|
imageUrl={stream.imageUrl}
|
|
subtitle={stream.subtitle}
|
|
badge={stream.badge}
|
|
>
|
|
{stream.name}
|
|
</SideNavLink>
|
|
))}
|
|
</SideNav>
|
|
<div className={styles.container}>
|
|
<header className={clsx(styles.header, styles.itemSize)}>
|
|
<TopNavMenus />
|
|
<TopRightButtons
|
|
showSupport={Boolean(
|
|
data &&
|
|
!data?.user?.roles.includes("MINOR_SUPPORT") &&
|
|
isFrontPage,
|
|
)}
|
|
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">
|
|
{/** xxx: placeholder 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 PageIcon({ crumb }: { crumb: Breadcrumb }) {
|
|
if (crumb.type !== "IMAGE") {
|
|
return null;
|
|
}
|
|
|
|
const isExternal = crumb.imgPath.includes(".");
|
|
|
|
const iconClass = clsx(styles.pageIcon, {
|
|
"rounded-full": crumb.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} />
|
|
) : null}
|
|
<SendouPopover
|
|
trigger={
|
|
<Button
|
|
className={sideNavStyles.sideNavFooterButton}
|
|
data-testid="notifications-button"
|
|
>
|
|
<BellIcon />
|
|
</Button>
|
|
}
|
|
popoverClassName={notificationPopoverClassName(
|
|
notifications.length,
|
|
)}
|
|
>
|
|
<NotificationContent
|
|
notifications={notifications}
|
|
unseenIds={unseenIds}
|
|
/>
|
|
</SendouPopover>
|
|
</div>
|
|
) : null}
|
|
<Link
|
|
to={SETTINGS_PAGE}
|
|
className={sideNavStyles.sideNavFooterButton}
|
|
>
|
|
<GearIcon />
|
|
</Link>
|
|
</div>
|
|
</>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<LogInButtonContainer>
|
|
<button type="submit" className={styles.sideNavLoginButton}>
|
|
{t("header.login")}
|
|
</button>
|
|
</LogInButtonContainer>
|
|
);
|
|
}
|