Sidenav rework?

This commit is contained in:
hfcRed 2026-01-08 20:07:32 +01:00
parent 88e9df56ee
commit 131fa699fa
10 changed files with 285 additions and 208 deletions

View File

@ -1,4 +1,5 @@
.container {
min-height: calc(100dvh - var(--layout-nav-height));
display: flex;
flex-direction: row;
}
@ -14,20 +15,6 @@
min-width: 0;
}
.sideNavContainer {
position: sticky;
left: 0;
top: var(--sticky-top);
height: fit-content;
margin: var(--s-2-5);
}
@media (max-width: 800px) {
.sideNavContainer {
display: none;
}
}
.main {
padding-block: var(--s-4) var(--s-32);
}

View File

@ -11,7 +11,6 @@ export const Main = ({
halfWidth,
bigger,
style,
sideNav,
}: {
children: React.ReactNode;
className?: string;
@ -53,15 +52,6 @@ export const Main = ({
</main>
);
if (sideNav) {
return (
<div className={styles.containerWithSideNav}>
<div className={styles.sideNavContainer}>{sideNav}</div>
<div className={styles.mainWrapper}>{mainElement}</div>
</div>
);
}
return <div className={styles.container}>{mainElement}</div>;
};

View File

@ -1,11 +1,19 @@
.sideNav {
--side-nav-width: 200px;
background-color: var(--color-bg-high);
min-width: var(--side-nav-width);
max-width: var(--side-nav-width);
border: 1.5px solid var(--color-border);
border-radius: var(--rounded);
background-color: var(--color-bg);
min-width: var(--layout-sidenav-width);
max-width: var(--layout-sidenav-width);
border-right: 1.5px solid var(--color-border);
overflow: hidden;
position: sticky;
top: 0;
left: 0;
height: fit-content;
}
.sideNavTop {
height: var(--layout-nav-height);
background-color: var(--color-bg);
border-bottom: 1.5px solid var(--color-border);
}
.sideNavInner {
@ -15,15 +23,14 @@
padding: var(--s-1-5);
padding-block-end: var(--s-2);
overflow-y: auto;
max-height: calc(100vh - var(--sticky-top) - var(--s-3-5));
border-radius: calc(var(--rounded) - var(--s-1-5));
height: calc(100dvh - var(--layout-nav-height));
}
.sideNavHeader {
color: var(--color-text-high);
padding: var(--s-1-5) var(--s-2);
margin-inline: calc(-1 * var(--s-1-5));
background-color: var(--color-bg);
background-color: var(--color-bg-high);
display: flex;
align-items: center;
gap: var(--s-2);

View File

@ -19,6 +19,7 @@ export function SideNav({
}) {
return (
<nav className={clsx(styles.sideNav, className)}>
<div className={styles.sideNavTop} />
<div className={clsx(styles.sideNavInner, "scrollbar")}>{children}</div>
</nav>
);

View File

@ -1,12 +1,11 @@
.container {
width: 100%;
min-height: 100vh;
padding-top: 50px;
}
.header {
display: flex;
width: 100%;
height: var(--layout-nav-height);
align-items: center;
justify-content: space-between;
border-bottom: 1.5px solid var(--color-border);
@ -14,9 +13,8 @@
backdrop-filter: var(--backdrop-filter);
background-color: transparent;
font-weight: bold;
padding-block: var(--s-2);
padding-inline: var(--s-4);
position: fixed;
position: sticky;
top: 0;
z-index: 10;
}

View File

@ -1,3 +1,4 @@
import { faker } from "@faker-js/faker";
import clsx from "clsx";
import * as React from "react";
import { useTranslation } from "react-i18next";
@ -6,12 +7,85 @@ import type { RootLoaderData } from "~/root";
import type { Breadcrumb, SendouRouteHandle } from "~/utils/remix.server";
import { SendouButton } from "../elements/Button";
import { Image } from "../Image";
import { CalendarIcon } from "../icons/Calendar";
import { HamburgerIcon } from "../icons/Hamburger";
import { TwitchIcon } from "../icons/Twitch";
import { UsersIcon } from "../icons/Users";
import { SideNav, SideNavHeader, SideNavLink } from "../SideNav";
import { Footer } from "./Footer";
import styles from "./index.module.css";
import { NavDialog } from "./NavDialog";
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 useBreadcrumbs() {
const { t } = useTranslation();
const matches = useMatches();
@ -45,6 +119,9 @@ export function Layout({
const location = useLocation();
const breadcrumbs = useBreadcrumbs();
const { t } = useTranslation(["front"]);
const { formatTime } = useTimeFormat();
const isFrontPage = location.pathname === "/";
const showLeaderboard =
@ -52,7 +129,7 @@ export function Layout({
!data?.user?.roles.includes("MINOR_SUPPORT") &&
!location.pathname.includes("plans");
return (
<div className={styles.container}>
<>
<NavDialog isOpen={navDialogOpen} close={() => setNavDialogOpen(false)} />
{isFrontPage ? (
<SendouButton
@ -62,35 +139,98 @@ export function Layout({
onPress={() => setNavDialogOpen(true)}
/>
) : null}
<header className={clsx(styles.header, styles.itemSize)}>
<div className={styles.breadcrumbContainer}>
<Link to="/" className={clsx(styles.breadcrumb, styles.logo)}>
sendou.ink
</Link>
{breadcrumbs.flatMap((breadcrumb) => {
return [
<span
key={`${breadcrumb.href}-sep`}
className={styles.breadcrumbSeparator}
<SideNav>
<SideNavHeader icon={<CalendarIcon />}>
{t("front:sideNav.myCalendar")}
</SideNavHeader>
{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",
},
)}`}
>
»
</span>,
<BreadcrumbLink key={breadcrumb.href} data={breadcrumb} />,
];
})}
</div>
<TopRightButtons
isErrored={isErrored}
showSupport={Boolean(
data && !data?.user?.roles.includes("MINOR_SUPPORT") && isFrontPage,
)}
openNavDialog={() => setNavDialogOpen(true)}
/>
</header>
{showLeaderboard ? <MyRampUnit /> : null}
{children}
<Footer />
</div>
{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)}>
<div className={styles.breadcrumbContainer}>
<Link to="/" className={clsx(styles.breadcrumb, styles.logo)}>
sendou.ink
</Link>
{breadcrumbs.flatMap((breadcrumb) => {
return [
<span
key={`${breadcrumb.href}-sep`}
className={styles.breadcrumbSeparator}
>
»
</span>,
<BreadcrumbLink key={breadcrumb.href} data={breadcrumb} />,
];
})}
</div>
<TopRightButtons
isErrored={isErrored}
showSupport={Boolean(
data &&
!data?.user?.roles.includes("MINOR_SUPPORT") &&
isFrontPage,
)}
openNavDialog={() => setNavDialogOpen(true)}
/>
</header>
{showLeaderboard ? <MyRampUnit /> : null}
{children}
<Footer />
</div>
</>
);
}

View File

@ -1,14 +1,10 @@
import { faker } from "@faker-js/faker";
import clsx from "clsx";
import { useTranslation } from "react-i18next";
import { Link, useLoaderData } from "react-router";
import { Link } from "react-router";
import { Avatar } from "~/components/Avatar";
import { Image } from "~/components/Image";
import { CalendarIcon } from "~/components/icons/Calendar";
import { TwitchIcon } from "~/components/icons/Twitch";
import { UsersIcon } from "~/components/icons/Users";
import { Main } from "~/components/Main";
import { SideNav, SideNavHeader, SideNavLink } from "~/components/SideNav";
import type { SendouRouteHandle } from "~/utils/remix.server";
import {
BUILDS_PAGE,
@ -552,150 +548,14 @@ function generateMockPosts(): FeedPost[] {
const MOCK_POSTS = generateMockPosts();
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",
},
];
export default function FrontPage() {
const { t } = useTranslation(["front"]);
const data = useLoaderData<typeof loader>();
const { formatTime } = useTimeFormat();
return (
<Main
className={styles.frontPageContainer}
sideNav={
<SideNav>
<SideNavHeader icon={<CalendarIcon />}>
{t("front:sideNav.myCalendar")}
</SideNavHeader>
{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>
}
>
<Main className={styles.frontPageContainer}>
<SocialFeed posts={MOCK_POSTS} />
</Main>
);
}
function useTimeFormat() {
const formatTime = (date: Date, options: Intl.DateTimeFormatOptions) => {
return date.toLocaleTimeString("en-US", options);
};
return { formatTime };
}
function SocialFeed({ posts }: { posts: FeedPost[] }) {
return (
<div className={styles.socialFeed}>

View File

@ -52,6 +52,15 @@ import { i18nCookie, i18next } from "./modules/i18n/i18next.server";
import { IS_E2E_TEST_RUN } from "./utils/e2e";
import { allI18nNamespaces } from "./utils/i18n";
import { isRevalidation, metaTags, type SerializeFrom } from "./utils/remix";
import cachified from "@epic-web/cachified";
import type { Tables } from "~/db/tables";
import * as Changelog from "~/features/front-page/core/Changelog.server";
import { cachedFullUserLeaderboard } from "~/features/leaderboards/core/leaderboards.server";
import * as LeaderboardRepository from "~/features/leaderboards/LeaderboardRepository.server";
import * as Seasons from "~/features/mmr/core/Seasons";
import { cache, IN_MILLISECONDS, ttl } from "~/utils/cache.server";
import { discordAvatarUrl, teamPage, userPage } from "~/utils/urls";
import * as ShowcaseTournaments from "../app/features/front-page/core/ShowcaseTournaments.server";
export const middleware: Route.MiddlewareFunction[] = [userMiddleware];
@ -90,8 +99,25 @@ export const loader = async ({ request }: LoaderFunctionArgs) => {
const locale = await i18next.getLocale(request);
const themeSession = await getThemeSession(request);
const [tournaments, changelog, leaderboards] = await Promise.all([
ShowcaseTournaments.frontPageTournamentsByUserId(user?.id ?? null),
cachified({
key: "front-changelog",
cache,
ttl: ttl(IN_MILLISECONDS.ONE_HOUR),
staleWhileRevalidate: ttl(IN_MILLISECONDS.TWO_HOURS),
async getFreshValue() {
return Changelog.get();
},
}),
cachedLeaderboards(),
]);
return data(
{
tournaments,
changelog,
leaderboards,
locale,
theme: themeSession.getTheme(),
user: user
@ -121,6 +147,69 @@ export const loader = async ({ request }: LoaderFunctionArgs) => {
);
};
export interface LeaderboardEntry {
name: string;
url: string;
avatarUrl: string | null;
power: number;
}
const ENTRIES_PER_LEADERBOARD = 5;
function cachedLeaderboards(): Promise<{
user: LeaderboardEntry[];
team: LeaderboardEntry[];
}> {
return cachified({
key: "front-leaderboard",
cache,
ttl: ttl(IN_MILLISECONDS.ONE_HOUR),
staleWhileRevalidate: ttl(IN_MILLISECONDS.TWO_HOURS),
async getFreshValue() {
const season = Seasons.currentOrPrevious()?.nth ?? 1;
const [team, user] = await Promise.all([
LeaderboardRepository.teamLeaderboardBySeason({
season,
onlyOneEntryPerUser: true,
}),
cachedFullUserLeaderboard(season),
]);
return {
user: user.slice(0, ENTRIES_PER_LEADERBOARD).map((entry) => ({
power: entry.power,
name: entry.username,
url: userPage(entry),
avatarUrl: entry.discordAvatar
? discordAvatarUrl({
discordAvatar: entry.discordAvatar,
discordId: entry.discordId,
size: "sm",
})
: null,
})),
team: team
.filter((entry) => entry.team)
.slice(0, ENTRIES_PER_LEADERBOARD)
.map((entry) => {
const team = entry.team as Pick<
Tables["Team"],
"id" | "name" | "customUrl"
> & { avatarUrl: string | null };
return {
power: entry.power,
name: team.name,
url: teamPage(team.customUrl),
avatarUrl: team.avatarUrl,
};
}),
};
},
});
}
export const handle: SendouRouteHandle = {
i18n: ["common", "game-misc", "weapons"],
};

View File

@ -13,6 +13,8 @@ html {
body {
width: 100%;
min-height: 100vh;
display: flex;
line-height: 1.5;
overflow-x: hidden;
color: var(--color-text);

View File

@ -120,6 +120,9 @@ html {
--toggle-height-small: 1.25rem;
--input-icon-width: 18px;
--layout-nav-height: 55px;
--layout-sidenav-width: 250px;
}
html {