sendou.ink/app/components/MobileNav.tsx
2026-01-11 13:46:59 +02:00

405 lines
9.9 KiB
TypeScript

import clsx from "clsx";
import {
Bell,
Calendar,
LogIn,
Menu,
Settings,
User,
Users,
X,
} from "lucide-react";
import * as React from "react";
import { Dialog, Modal, ModalOverlay } from "react-aria-components";
import { useTranslation } from "react-i18next";
import { Link } from "react-router";
import { useUser } from "~/features/auth/core/user";
import type { loader as sidebarLoader } from "~/features/sidebar/routes/sidebar";
import { navIconUrl, SETTINGS_PAGE, userPage } from "~/utils/urls";
import { Avatar } from "./Avatar";
import { SendouButton } from "./elements/Button";
import { Image } from "./Image";
import { TwitchIcon } from "./icons/Twitch";
import { LogInButtonContainer } from "./layout/LogInButtonContainer";
import {
NotificationContent,
useNotifications,
} from "./layout/NotificationPopover";
import { navItems } from "./layout/nav-items";
import styles from "./MobileNav.module.css";
import { SideNavLink } from "./SideNav";
type SidebarData = Awaited<ReturnType<typeof sidebarLoader>> | undefined;
type PanelType = "closed" | "menu" | "friends" | "tourneys" | "you";
export function MobileNav({ sidebarData }: { sidebarData: SidebarData }) {
const [activePanel, setActivePanel] = React.useState<PanelType>("closed");
const user = useUser();
const hasActiveMatch = Boolean(sidebarData?.matchStatus);
const hasTournamentMatch = Boolean(sidebarData?.tournamentMatchStatus);
const closePanel = () => setActivePanel("closed");
return (
<div className={styles.mobileNav}>
{activePanel === "menu" ? (
<MenuOverlay
streams={sidebarData?.streams ?? []}
onClose={closePanel}
/>
) : null}
{activePanel === "friends" ? (
<FriendsPanel
friends={sidebarData?.friends ?? []}
onClose={closePanel}
/>
) : null}
{activePanel === "tourneys" ? (
<TourneysPanel
tournaments={sidebarData?.tournaments ?? []}
onClose={closePanel}
/>
) : null}
{activePanel === "you" ? <YouPanel onClose={closePanel} /> : null}
<MobileTabBar
activePanel={activePanel}
onTabPress={setActivePanel}
isLoggedIn={Boolean(user)}
hasActiveMatch={hasActiveMatch}
matchUrl={sidebarData?.matchStatus?.url}
hasTournamentMatch={hasTournamentMatch}
tournamentMatchStatus={sidebarData?.tournamentMatchStatus}
/>
</div>
);
}
function MobileTabBar({
activePanel,
onTabPress,
isLoggedIn,
hasActiveMatch,
matchUrl,
hasTournamentMatch,
tournamentMatchStatus,
}: {
activePanel: PanelType;
onTabPress: (panel: PanelType) => void;
isLoggedIn: boolean;
hasActiveMatch: boolean;
matchUrl?: string;
hasTournamentMatch: boolean;
tournamentMatchStatus?: NonNullable<SidebarData>["tournamentMatchStatus"];
}) {
const { t } = useTranslation(["front", "common"]);
return (
<nav className={styles.tabBar}>
<MobileTab
icon={<Menu />}
label={t("front:mobileNav.menu")}
isActive={activePanel === "menu"}
onPress={() => onTabPress("menu")}
/>
{isLoggedIn ? (
<>
<MobileTab
icon={<Users />}
label={t("front:mobileNav.friends")}
isActive={activePanel === "friends"}
onPress={() => onTabPress("friends")}
/>
<MobileTab
icon={<Calendar />}
label={t("common:pages.calendar")}
isActive={activePanel === "tourneys"}
onPress={() => onTabPress("tourneys")}
/>
<MobileTab
icon={<User />}
label={t("front:mobileNav.you")}
isActive={activePanel === "you"}
onPress={() => onTabPress("you")}
/>
</>
) : (
<LogInButtonContainer>
<button type="submit" className={styles.tab}>
<span className={styles.tabIcon}>
<LogIn />
</span>
<span>{t("front:mobileNav.login")}</span>
</button>
</LogInButtonContainer>
)}
{hasActiveMatch && matchUrl ? (
<Link to={matchUrl} className={styles.tab}>
<span className={styles.tabIcon}>
<Image path={navIconUrl("sendouq")} alt="" width={24} height={24} />
</span>
<span>{t("front:mobileNav.match")}</span>
</Link>
) : hasTournamentMatch && tournamentMatchStatus ? (
<Link to={tournamentMatchStatus.url} className={styles.tab}>
<span className={styles.tabIcon}>
{tournamentMatchStatus.logoUrl ? (
<img
src={tournamentMatchStatus.logoUrl}
alt=""
width={24}
height={24}
className={styles.tournamentMatchIcon}
/>
) : null}
</span>
<span>{t("front:mobileNav.match")}</span>
</Link>
) : null}
</nav>
);
}
function MobileTab({
icon,
label,
isActive,
onPress,
}: {
icon: React.ReactNode;
label: string;
isActive: boolean;
onPress: () => void;
}) {
return (
<button
type="button"
className={styles.tab}
data-active={isActive}
onClick={onPress}
>
<span className={styles.tabIcon}>{icon}</span>
<span>{label}</span>
</button>
);
}
function MobilePanel({
title,
onClose,
children,
}: {
title: string;
onClose: () => void;
children: React.ReactNode;
}) {
return (
<ModalOverlay className={styles.panelOverlay} isOpen isDismissable={false}>
<Modal className={clsx(styles.panel, "scrollbar")}>
<Dialog className={styles.panelDialog}>
<header className={styles.panelHeader}>
<h2 className={styles.panelTitle}>{title}</h2>
<SendouButton icon={<X />} variant="minimal" onPress={onClose} />
</header>
<div className={styles.panelContent}>{children}</div>
</Dialog>
</Modal>
</ModalOverlay>
);
}
function MenuOverlay({
streams,
onClose,
}: {
streams: NonNullable<SidebarData>["streams"];
onClose: () => void;
}) {
const { t } = useTranslation(["front", "common"]);
return (
<ModalOverlay className={styles.panelOverlay} isOpen isDismissable={false}>
<Modal className={styles.menuOverlay}>
<Dialog className={styles.panelDialog}>
<header className={styles.menuHeader}>
<h2 className={styles.menuTitle}>{t("front:mobileNav.menu")}</h2>
<SendouButton icon={<X />} variant="minimal" onPress={onClose} />
</header>
<section className={styles.streamsSection}>
<header className={styles.streamsSectionHeader}>
<TwitchIcon />
<h3>{t("front:sideNav.streams")}</h3>
</header>
<ul className={styles.streamsList}>
{streams.map((stream) => (
<li key={stream.id} className={styles.streamItem}>
<img
src={stream.imageUrl}
alt=""
className={styles.streamItemImage}
/>
<div className={styles.streamItemContent}>
<span className={styles.streamItemName}>{stream.name}</span>
<div className={styles.streamItemMeta}>
{stream.subtitle ? (
<span className={styles.streamItemSubtitle}>
{stream.subtitle}
</span>
) : null}
{stream.badge ? (
<span className={styles.streamItemBadge}>
{stream.badge}
</span>
) : null}
</div>
</div>
</li>
))}
</ul>
</section>
<nav aria-label={t("front:mobileNav.menu")}>
<ul className={styles.navGrid}>
{navItems.map((item) => (
<li key={item.name}>
<Link
to={`/${item.url}`}
className={styles.navItem}
onClick={onClose}
>
<div className={styles.navItemImage}>
<Image
path={navIconUrl(item.name)}
height={32}
width={32}
alt=""
/>
</div>
<span>{t(`common:pages.${item.name}` as any)}</span>
</Link>
</li>
))}
</ul>
</nav>
</Dialog>
</Modal>
</ModalOverlay>
);
}
function FriendsPanel({
friends,
onClose,
}: {
friends: NonNullable<SidebarData>["friends"];
onClose: () => void;
}) {
const { t } = useTranslation(["front"]);
return (
<MobilePanel title={t("front:sideNav.friends")} onClose={onClose}>
{friends.map((friend) => (
<SideNavLink
key={friend.id}
to=""
imageUrl={friend.avatarUrl}
subtitle={friend.subtitle}
badge={friend.badge}
>
{friend.name}
</SideNavLink>
))}
</MobilePanel>
);
}
function TourneysPanel({
tournaments,
onClose,
}: {
tournaments: NonNullable<SidebarData>["tournaments"];
onClose: () => void;
}) {
const { t } = useTranslation(["front"]);
return (
<MobilePanel title={t("front:sideNav.myCalendar")} onClose={onClose}>
{tournaments.length > 0 ? (
tournaments.map((tournament) => (
<SideNavLink
key={tournament.id}
to={tournament.url}
imageUrl={tournament.logoUrl ?? undefined}
onClick={onClose}
>
{tournament.name}
</SideNavLink>
))
) : (
<div className="text-lighter text-sm p-2">
{t("front:sideNav.noEvents")}
</div>
)}
</MobilePanel>
);
}
function YouPanel({ onClose }: { onClose: () => void }) {
const { t } = useTranslation(["front", "common"]);
const user = useUser();
const { notifications, unseenIds } = useNotifications();
if (!user) {
return null;
}
return (
<MobilePanel title={t("front:mobileNav.you")} onClose={onClose}>
<Link
to={userPage(user)}
className={styles.youPanelUser}
onClick={onClose}
>
<Avatar user={user} size="sm" />
<span className={styles.youPanelUsername}>{user.username}</span>
</Link>
{notifications ? (
<section className={styles.notificationsSection}>
<h3 className={styles.notificationsHeader}>
<Bell />
<span>{t("common:notifications.title")}</span>
{unseenIds.length > 0 ? (
<span className={styles.unseenBadge}>{unseenIds.length}</span>
) : null}
</h3>
<NotificationContent
notifications={notifications}
unseenIds={unseenIds}
/>
</section>
) : null}
<ul className={styles.youPanelActions}>
<li>
<Link
to={SETTINGS_PAGE}
className={styles.youPanelAction}
onClick={onClose}
>
<Settings />
<span>{t("common:pages.settings")}</span>
</Link>
</li>
</ul>
</MobilePanel>
);
}