import type { ErrorBoundaryComponent } from "@remix-run/node"; import { json, type LinksFunction, type LoaderFunction, type V2_MetaFunction, } from "@remix-run/node"; import { Links, LiveReload, Meta, Outlet, Scripts, type ShouldRevalidateFunction, useLoaderData, useMatches, useFetchers, useNavigation, } from "@remix-run/react"; import * as React from "react"; import commonStyles from "~/styles/common.css"; import variableStyles from "~/styles/vars.css"; import utilStyles from "~/styles/utils.css"; import layoutStyles from "~/styles/layout.css"; import resetStyles from "~/styles/reset.css"; import flagsStyles from "~/styles/flags.css"; import { Catcher } from "./components/Catcher"; import { Layout } from "./components/layout"; import { db } from "./db"; import type { FindAllPatrons } from "./db/models/users/queries.server"; import type { UserWithPlusTier } from "./db/types"; import { getUser } from "./modules/auth"; import { DEFAULT_LANGUAGE, i18nCookie, i18next } from "./modules/i18n"; import { useChangeLanguage } from "remix-i18next"; import { type CustomTypeOptions } from "react-i18next"; import { useTranslation } from "~/hooks/useTranslation"; import { COMMON_PREVIEW_IMAGE } from "./utils/urls"; import { ConditionalScrollRestoration } from "./components/ConditionalScrollRestoration"; import { type SendouRouteHandle } from "~/utils/remix"; import generalI18next from "i18next"; import { Theme, ThemeHead, useTheme, ThemeProvider } from "./modules/theme"; import { getThemeSession } from "./modules/theme/session.server"; import { isTheme } from "./modules/theme/provider"; import { useIsMounted } from "./hooks/useIsMounted"; import invariant from "tiny-invariant"; import { CUSTOMIZED_CSS_VARS_NAME } from "./constants"; import NProgress from "nprogress"; import nProgressStyles from "nprogress/nprogress.css"; export const shouldRevalidate: ShouldRevalidateFunction = ({ nextUrl }) => { // // reload on language change so the selected language gets set into the cookie const lang = nextUrl.searchParams.get("lng"); return Boolean(lang); }; export const links: LinksFunction = () => { return [ { rel: "stylesheet", href: resetStyles }, { rel: "stylesheet", href: commonStyles }, { rel: "stylesheet", href: variableStyles }, { rel: "stylesheet", href: utilStyles }, { rel: "stylesheet", href: layoutStyles }, { rel: "stylesheet", href: flagsStyles }, { rel: "stylesheet", href: nProgressStyles }, ]; }; export const meta: V2_MetaFunction = () => { return [ { title: "sendou.ink" }, { name: "description", content: "Competitive Splatoon Hub featuring gear planner, event calendar, builds by top players, and more!", }, ]; }; export interface RootLoaderData { locale: string; theme: Theme | null; patrons: FindAllPatrons; baseUrl: string; user?: Pick< UserWithPlusTier, | "id" | "discordId" | "discordAvatar" | "plusTier" | "customUrl" | "discordName" | "patronTier" >; publisherId?: string; websiteId?: string; } export const loader: LoaderFunction = async ({ request }) => { const user = await getUser(request); const locale = await i18next.getLocale(request); const themeSession = await getThemeSession(request); invariant(process.env["BASE_URL"], "BASE_URL env var is not set"); return json( { locale, theme: themeSession.getTheme(), patrons: db.users.findAllPatrons(), baseUrl: process.env["BASE_URL"], publisherId: process.env["PLAYWIRE_PUBLISHER_ID"], websiteId: process.env["PLAYWIRE_WEBSITE_ID"], user: user ? { discordName: user.discordName, discordAvatar: user.discordAvatar, discordId: user.discordId, id: user.id, plusTier: user.plusTier, customUrl: user.customUrl, patronTier: user.patronTier, } : undefined, }, { headers: { "Set-Cookie": await i18nCookie.serialize(locale) }, } ); }; export const handle: SendouRouteHandle = { i18n: ["common", "game-misc", "weapons"], }; function Document({ children, data, isErrored = false, }: { children: React.ReactNode; data?: RootLoaderData; isErrored?: boolean; }) { const { htmlThemeClass } = useTheme(); const { i18n } = useTranslation(); const locale = data?.locale ?? DEFAULT_LANGUAGE; useChangeLanguage(locale); usePreloadTranslation(); useLoadingIndicator(); const customizedCSSVars = useCustomizedCSSVars(); return ( {/* TODO: preferably don't load this for every route */} {process.env.NODE_ENV === "development" && } {children} ); } function useLoadingIndicator() { const transition = useNavigation(); const fetchers = useFetchers(); /** * This gets the state of every fetcher active on the app and combine it with * the state of the global transition (Link and Form), then use them to * determine if the app is idle or if it's loading. * Here we consider both loading and submitting as loading. */ const state = React.useMemo<"idle" | "loading">( function getGlobalState() { const states = [ transition.state, ...fetchers.map((fetcher) => fetcher.state), ]; if (states.every((state) => state === "idle")) return "idle"; return "loading"; }, [transition.state, fetchers] ); React.useEffect(() => { // and when it's something else it means it's either submitting a form or // waiting for the loaders of the next location so we start it if (state === "loading") NProgress.start(); // when the state is idle then we can to complete the progress bar if (state === "idle") NProgress.done(); }, [state]); } // TODO: this should be an array if we can figure out how to make Typescript // enforce that it has every member of keyof CustomTypeOptions["resources"] without duplicating the type manually export const namespaceJsonsToPreloadObj: Record< keyof CustomTypeOptions["resources"], boolean > = { common: true, analyzer: true, badges: true, builds: true, calendar: true, contributions: true, faq: true, "game-misc": true, gear: true, user: true, weapons: true, tournament: true, team: true, vods: true, }; const namespaceJsonsToPreload = Object.keys(namespaceJsonsToPreloadObj); function usePreloadTranslation() { React.useEffect(() => { void generalI18next.loadNamespaces(namespaceJsonsToPreload); }, []); } function useCustomizedCSSVars() { const matches = useMatches(); for (const match of matches) { if (match.data?.[CUSTOMIZED_CSS_VARS_NAME]) { // cheating TypeScript here but no real way to keep up // even an illusion of type safety here return Object.fromEntries( Object.entries( match.data[CUSTOMIZED_CSS_VARS_NAME] as Record ).map(([key, value]) => [`--${key}`, value]) ) as React.CSSProperties; } } return; } 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 const data = useLoaderData(); return ( ); } export function CatchBoundary() { return ( ); } export const ErrorBoundary: ErrorBoundaryComponent = ({ error }) => { console.error(error); return ( ); }; function HydrationTestIndicator() { const isMounted = useIsMounted(); if (!isMounted) return null; return
; } function Fonts() { return ( <> ); } function PWALinks() { return ( <> ); } const Ramp = React.lazy(() => import("./components/ramp/Ramp")); function MyRamp({ data }: { data: RootLoaderData | undefined }) { if ( !data || !data.publisherId || !data.websiteId || data.user?.patronTier || typeof window === "undefined" ) { return null; } return ; }