diff --git a/app/components/Main.module.css b/app/components/Main.module.css index d33e2b1da..372a556f9 100644 --- a/app/components/Main.module.css +++ b/app/components/Main.module.css @@ -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); } diff --git a/app/components/Main.tsx b/app/components/Main.tsx index f3bd8b903..966f0959b 100644 --- a/app/components/Main.tsx +++ b/app/components/Main.tsx @@ -11,7 +11,6 @@ export const Main = ({ halfWidth, bigger, style, - sideNav, }: { children: React.ReactNode; className?: string; @@ -53,15 +52,6 @@ export const Main = ({ ); - if (sideNav) { - return ( -
-
{sideNav}
-
{mainElement}
-
- ); - } - return
{mainElement}
; }; diff --git a/app/components/SideNav.module.css b/app/components/SideNav.module.css index 78e6a1162..66991d6a2 100644 --- a/app/components/SideNav.module.css +++ b/app/components/SideNav.module.css @@ -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); diff --git a/app/components/SideNav.tsx b/app/components/SideNav.tsx index a6da9d948..da12e099b 100644 --- a/app/components/SideNav.tsx +++ b/app/components/SideNav.tsx @@ -19,6 +19,7 @@ export function SideNav({ }) { return ( ); diff --git a/app/components/layout/index.module.css b/app/components/layout/index.module.css index d7f7e9876..d151a0c12 100644 --- a/app/components/layout/index.module.css +++ b/app/components/layout/index.module.css @@ -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; } diff --git a/app/components/layout/index.tsx b/app/components/layout/index.tsx index 01ffa2f68..11c7bb2fc 100644 --- a/app/components/layout/index.tsx +++ b/app/components/layout/index.tsx @@ -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 ( -
+ <> setNavDialogOpen(false)} /> {isFrontPage ? ( setNavDialogOpen(true)} /> ) : null} -
-
- - sendou.ink - - {breadcrumbs.flatMap((breadcrumb) => { - return [ - + }> + {t("front:sideNav.myCalendar")} + + {data.tournaments.participatingFor.length > 0 ? ( + data.tournaments.participatingFor + .slice(0, 4) + .map((tournament, index) => ( + - » - , - , - ]; - })} -
- setNavDialogOpen(true)} - /> -
- {showLeaderboard ? : null} - {children} -
+ {tournament.name} + + )) + ) : ( +
+ {t("front:sideNav.noEvents")} +
+ )} + + }> + {t("front:sideNav.friends")} + + {MOCK_FRIENDS.map((friend) => ( + + {friend.name} + + ))} + + }> + {t("front:sideNav.streams")} + + {MOCK_STREAMS.map((stream) => ( + + {stream.name} + + ))} + +
+
+
+ + sendou.ink + + {breadcrumbs.flatMap((breadcrumb) => { + return [ + + » + , + , + ]; + })} +
+ setNavDialogOpen(true)} + /> +
+ {showLeaderboard ? : null} + {children} +
+ ); } diff --git a/app/features/front-page/routes/index.tsx b/app/features/front-page/routes/index.tsx index 31521f1f6..d91b9b127 100644 --- a/app/features/front-page/routes/index.tsx +++ b/app/features/front-page/routes/index.tsx @@ -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(); - const { formatTime } = useTimeFormat(); - return ( -
- }> - {t("front:sideNav.myCalendar")} - - {data.tournaments.participatingFor.length > 0 ? ( - data.tournaments.participatingFor - .slice(0, 4) - .map((tournament, index) => ( - - {tournament.name} - - )) - ) : ( -
- {t("front:sideNav.noEvents")} -
- )} - - }> - {t("front:sideNav.friends")} - - {MOCK_FRIENDS.map((friend) => ( - - {friend.name} - - ))} - - }> - {t("front:sideNav.streams")} - - {MOCK_STREAMS.map((stream) => ( - - {stream.name} - - ))} - - } - > +
); } -function useTimeFormat() { - const formatTime = (date: Date, options: Intl.DateTimeFormatOptions) => { - return date.toLocaleTimeString("en-US", options); - }; - return { formatTime }; -} - function SocialFeed({ posts }: { posts: FeedPost[] }) { return (
diff --git a/app/root.tsx b/app/root.tsx index 0fa0d501d..0ab03f70c 100644 --- a/app/root.tsx +++ b/app/root.tsx @@ -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"], }; diff --git a/app/styles/common.css b/app/styles/common.css index 244c6c4eb..40b4f8ac3 100644 --- a/app/styles/common.css +++ b/app/styles/common.css @@ -13,6 +13,8 @@ html { body { width: 100%; + min-height: 100vh; + display: flex; line-height: 1.5; overflow-x: hidden; color: var(--color-text); diff --git a/app/styles/vars.css b/app/styles/vars.css index d4724f333..6b8630b9f 100644 --- a/app/styles/vars.css +++ b/app/styles/vars.css @@ -120,6 +120,9 @@ html { --toggle-height-small: 1.25rem; --input-icon-width: 18px; + + --layout-nav-height: 55px; + --layout-sidenav-width: 250px; } html {