Add midpoint overlay sidenav

This commit is contained in:
hfcRed 2026-03-07 16:41:53 +01:00
parent a71aaaf464
commit 0c1d53c6f8
3 changed files with 211 additions and 124 deletions

View File

@ -11,7 +11,7 @@
display: none;
flex-direction: column;
@media screen and (min-width: 600px) {
@media screen and (min-width: 1000px) {
display: flex;
}
}

View File

@ -164,7 +164,8 @@
}
/* Doubled selector increases specificity to beat Button.module.css's display: flex */
.sideNavCollapseButton.sideNavCollapseButton {
.sideNavCollapseButton.sideNavCollapseButton,
.sideNavModalTrigger.sideNavModalTrigger {
display: none;
& svg {
@ -172,12 +173,47 @@
min-width: 20px;
max-width: 20px;
}
&.sideNavCollapseButton {
@media screen and (min-width: 1000px) {
display: flex;
}
}
&.sideNavModalTrigger {
@media screen and (min-width: 600px) and (max-width: 999px) {
display: flex;
}
}
}
@media screen and (min-width: 600px) {
.sideNavCollapseButton.sideNavCollapseButton {
display: flex;
}
.sideNavModalOverlay {
position: fixed;
inset: 0;
top: var(--layout-nav-height);
background-color: rgba(0, 0, 0, 0.4);
backdrop-filter: blur(4px);
z-index: 20;
}
.sideNavModal {
height: 100%;
width: var(--layout-sidenav-width);
border-right: 1.5px solid var(--color-border);
}
.sideNavModalDialog {
outline: none;
height: 100%;
}
.sideNavInModal {
display: flex;
position: static;
height: 100%;
min-width: unset;
max-width: unset;
border-right: none;
}
.mobileLogo {

View File

@ -10,7 +10,13 @@ import {
Users,
} from "lucide-react";
import * as React from "react";
import { Button } from "react-aria-components";
import {
Button,
Dialog,
DialogTrigger,
Modal,
ModalOverlay,
} from "react-aria-components";
import { Flipped, Flipper } from "react-flip-toolkit";
import { useTranslation } from "react-i18next";
import { Link, useFetcher, useLocation, useMatches } from "react-router";
@ -170,6 +176,7 @@ export function Layout({
const [sideNavCollapsed, setSideNavCollapsed] = useSideNavCollapsed(
data?.sidenavCollapsed ?? false,
);
const [sideNavModalOpen, setSideNavModalOpen] = React.useState(false);
const { t, i18n } = useTranslation(["front", "common"]);
const { formatRelativeDate } = useTimeFormat();
@ -177,6 +184,17 @@ export function Layout({
const navOffset = useNavOffset();
const isMounted = useIsMounted();
React.useEffect(() => {
const handleResize = () => {
if (window.innerWidth < 600 || window.innerWidth >= 1000) {
setSideNavModalOpen(false);
}
};
window.addEventListener("resize", handleResize);
return () => window.removeEventListener("resize", handleResize);
}, []);
const user = useUser();
const sidebarData = data?.sidebar;
const events = sidebarData?.events ?? [];
@ -190,128 +208,133 @@ export function Layout({
!data?.user?.roles.includes("MINOR_SUPPORT") &&
!location.pathname.includes("plans");
const sideNavFooterContent = (
<SideNavFooter>
<SideNavUserPanel />
</SideNavFooter>
);
const sideNavChildren = (
<>
<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>
);
})}
</>
);
return (
<>
<SideNav
collapsed={sideNavCollapsed}
footer={
<SideNavFooter>
<SideNavUserPanel />
</SideNavFooter>
}
footer={sideNavFooterContent}
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>
);
})}
{sideNavChildren}
</SideNav>
<MobileNav sidebarData={data?.sidebar} />
<div className={styles.container}>
@ -324,8 +347,30 @@ export function Layout({
<Link to="/" className={clsx(styles.siteLogo, styles.mobileLogo)}>
<SiteLogoContent />
</Link>
<DialogTrigger
key={location.pathname}
isOpen={sideNavModalOpen}
onOpenChange={setSideNavModalOpen}
>
<SideNavCollapseButton className={styles.sideNavModalTrigger} />
<ModalOverlay className={styles.sideNavModalOverlay} isDismissable>
<Modal className={styles.sideNavModal}>
<Dialog className={styles.sideNavModalDialog}>
<SideNav
className={styles.sideNavInModal}
footer={sideNavFooterContent}
top={<SiteTitle />}
topCentered={isFrontPage}
>
{sideNavChildren}
</SideNav>
</Dialog>
</Modal>
</ModalOverlay>
</DialogTrigger>
<SideNavCollapseButton
onToggle={() => setSideNavCollapsed(!sideNavCollapsed)}
className={styles.sideNavCollapseButton}
/>
<TopNavMenus />
<TopRightButtons
@ -390,10 +435,16 @@ function SiteLogoContent() {
);
}
function SideNavCollapseButton({ onToggle }: { onToggle: () => void }) {
function SideNavCollapseButton({
onToggle,
className,
}: {
onToggle?: () => void;
className?: string;
}) {
return (
<SendouButton
className={styles.sideNavCollapseButton}
className={className}
variant="minimal"
size="small"
shape="square"