import cachified from "@epic-web/cachified"; import clsx from "clsx"; import generalI18next from "i18next"; import NProgress from "nprogress"; import * as React from "react"; import { useEffect } from "react"; import { I18nProvider, RouterProvider } from "react-aria-components"; import { ErrorBoundary as ClientErrorBoundary } from "react-error-boundary"; import { useTranslation } from "react-i18next"; import type { LoaderFunctionArgs, MetaFunction, NavigateOptions, } from "react-router"; import { data, Links, Meta, Outlet, Scripts, ScrollRestoration, type ShouldRevalidateFunction, useHref, useLoaderData, useMatches, useNavigate, useNavigation, useSearchParams, } from "react-router"; import { useDebounce } from "react-use"; import { useChangeLanguage } from "remix-i18next/react"; import type { CustomTheme, 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 * as NotificationRepository from "~/features/notifications/NotificationRepository.server"; import { NOTIFICATIONS } from "~/features/notifications/notifications-contants"; import { cache, IN_MILLISECONDS, ttl } from "~/utils/cache.server"; import type { SendouRouteHandle } from "~/utils/remix.server"; import { discordAvatarUrl, teamPage, userPage } from "~/utils/urls"; import * as ShowcaseTournaments from "../app/features/front-page/core/ShowcaseTournaments.server"; import type { Route } from "./+types/root"; import { Catcher } from "./components/Catcher"; import { SendouToastRegion, toastQueue } from "./components/elements/Toast"; import { Layout } from "./components/layout"; import { Ramp } from "./components/ramp/Ramp"; import { apiCorsMiddleware } from "./features/api-public/api-cors-middleware.server"; import { getUser } from "./features/auth/core/user.server"; import { userMiddleware } from "./features/auth/core/user-middleware.server"; import { getSidenavSession } from "./features/layout/core/sidenav-session.server"; import { sessionIdMiddleware } from "./features/session-id/session-id-middleware.server"; import { isTheme, Theme, ThemeHead, ThemeProvider, useTheme, } from "./features/theme/core/provider"; import { getThemeSession } from "./features/theme/core/theme-session.server"; import { useIsMounted } from "./hooks/useIsMounted"; import { DEFAULT_LANGUAGE } from "./modules/i18n/config"; 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"; export const middleware: Route.MiddlewareFunction[] = [ sessionIdMiddleware, apiCorsMiddleware, userMiddleware, ]; import "~/styles/vars.css"; import "~/styles/normalize.css"; import "~/styles/common.css"; import "~/styles/utils.css"; import "~/styles/flags.css"; import "nprogress/nprogress.css"; export const shouldRevalidate: ShouldRevalidateFunction = (args) => { if (isRevalidation(args)) return true; // user settings, lang change etc. require revalidation on root loader const isSettingsPage = args.currentUrl.pathname === "/settings"; if (isSettingsPage) return true; return false; }; export const meta: MetaFunction = (args) => { return metaTags({ title: "sendou.ink", ogTitle: "sendou.ink - Competitive Splatoon Hub", location: args.location, description: "Sendou.ink is the home of competitive Splatoon featuring daily tournaments and a seasonal ladder. Variety of tools and the largest collection of builds by top players allow you to level up your skill in Splatoon 3.", }); }; export type RootLoaderData = SerializeFrom; export type LoggedInUser = NonNullable; export const loader = async ({ request }: LoaderFunctionArgs) => { const user = getUser(); const locale = await i18next.getLocale(request); const themeSession = await getThemeSession(request); const sidenavSession = await getSidenavSession(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(), sidenavCollapsed: sidenavSession.getCollapsed(), user: user ? { username: user.username, discordAvatar: user.discordAvatar, discordId: user.discordId, id: user.id, customUrl: user.customUrl, inGameName: user.inGameName, friendCode: user.friendCode, preferences: user.preferences ?? {}, languages: user.languages ? user.languages.split(",") : [], plusTier: user.plusTier, roles: user.roles, } : undefined, customTheme: user?.customTheme, notifications: user ? await NotificationRepository.findByUserId(user.id, { limit: NOTIFICATIONS.PEEK_COUNT, }) : undefined, }, { headers: { "Set-Cookie": await i18nCookie.serialize(locale) }, }, ); }; 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", "forms", "game-misc", "weapons", "front"], }; function Document({ children, data, }: { children: React.ReactNode; data?: RootLoaderData; }) { const { htmlThemeClass } = useTheme(); const { i18n } = useTranslation(); const navigate = useNavigate(); const locale = data?.locale ?? DEFAULT_LANGUAGE; const customThemeStyle = useCustomThemeVars(); useChangeLanguage(locale); usePreloadTranslation(); useLoadingIndicator(); useTriggerToasts(); return ( {IS_E2E_TEST_RUN && } {children} { return location.pathname; }} /> ); } function useTriggerToasts() { const [searchParams] = useSearchParams(); const navigate = useNavigate(); const error = searchParams.get("__error"); const success = searchParams.get("__success"); React.useEffect(() => { if (!error && !success) return; if (error) { toastQueue.add({ message: error, variant: "error", }); } else if (success) { toastQueue.add( { message: success, variant: "success", }, { timeout: 5000, }, ); } navigate({ search: "" }, { replace: true }); }, [error, success, navigate]); } function useLoadingIndicator() { const transition = useNavigation(); useDebounce( () => { if (transition.state === "loading") { NProgress.start(); } else if (transition.state === "idle") { NProgress.done(); } }, 250, [transition.state], ); } function usePreloadTranslation() { React.useEffect(() => { void generalI18next.loadNamespaces(allI18nNamespaces()); }, []); } declare module "react-aria-components" { interface RouterConfig { routerOptions: NavigateOptions; } } function useCustomThemeVars(): React.CSSProperties | undefined { const matches = useMatches(); let styles: React.CSSProperties | undefined; for (const match of matches) { const data = match.data as { customTheme?: CustomTheme } | undefined; if (data?.customTheme) { const styleObj: Record = {}; for (const [key, value] of Object.entries(data.customTheme)) { styleObj[key] = value; } styles = styleObj as React.CSSProperties; } } return styles; } export default function App() { // prop drilling data instead of using useLoaderData in the child components directly because // useLoaderData can't be used in CatchBoundary and layout is rendered in it as well // // Update 14.10.23: not sure if this still applies as the CatchBoundary is gone const data = useLoaderData(); // Move overflow:hidden from html to body to allow position: sticky and position: fixed // elements to work properly when a React Aria Component disabled scrolling useEffect(() => { const htmlStyle = document.documentElement.style; const bodyStyle = document.body.style; const observer = new MutationObserver(() => { if (htmlStyle.overflow === "hidden") { htmlStyle.overflow = ""; bodyStyle.overflow = "hidden"; bodyStyle.scrollbarGutter = "stable"; } else if ( htmlStyle.overflow === "" && htmlStyle.scrollbarGutter !== "stable" ) { bodyStyle.overflow = ""; bodyStyle.scrollbarGutter = ""; } }); observer.observe(document.documentElement, { attributes: true, attributeFilter: ["style"], }); return () => observer.disconnect(); }, []); return ( ); } export const ErrorBoundary = () => { return ( ); }; function HydrationTestIndicator() { const isMounted = useIsMounted(); if (!isMounted) return null; return
; } function Fonts() { return ( <> ); } function PWALinks() { return ( <> ); } function MyRamp({ data }: { data: RootLoaderData | undefined }) { if (!data || data.user?.roles.includes("MINOR_SUPPORT")) { return null; } return ( ); }