import type { LoaderFunctionArgs, MetaFunction, SerializeFrom, } from "@remix-run/node"; import { json, redirect } from "@remix-run/node"; import { Links, Meta, Outlet, Scripts, ScrollRestoration, type ShouldRevalidateFunction, useLoaderData, useMatches, useNavigate, useNavigation, useSearchParams, } from "@remix-run/react"; import generalI18next from "i18next"; import NProgress from "nprogress"; import * as React from "react"; import { I18nProvider } from "react-aria-components"; import { ErrorBoundary as ClientErrorBoundary } from "react-error-boundary"; import { useTranslation } from "react-i18next"; import { useChangeLanguage } from "remix-i18next/react"; import * as NotificationRepository from "~/features/notifications/NotificationRepository.server"; import { NOTIFICATIONS } from "~/features/notifications/notifications-contants"; import type { SendouRouteHandle } from "~/utils/remix.server"; import { Catcher } from "./components/Catcher"; import { SendouToastRegion, toastQueue } from "./components/elements/Toast"; import { Layout } from "./components/layout"; import { Ramp } from "./components/ramp/Ramp"; import { CUSTOMIZED_CSS_VARS_NAME } from "./constants"; import { getUser } from "./features/auth/core/user.server"; import { userIsBanned } from "./features/ban/core/banned.server"; import { Theme, ThemeHead, ThemeProvider, isTheme, useTheme, } from "./features/theme/core/provider"; import { getThemeSession } from "./features/theme/core/session.server"; import { useIsMounted } from "./hooks/useIsMounted"; import { DEFAULT_LANGUAGE } from "./modules/i18n/config"; import i18next, { i18nCookie } from "./modules/i18n/i18next.server"; import type { Namespace } from "./modules/i18n/resources.server"; import { isRevalidation, metaTags } from "./utils/remix"; import { SUSPENDED_PAGE } from "./utils/urls"; import "nprogress/nprogress.css"; import "~/styles/common.css"; import "~/styles/elements.css"; import "~/styles/flags.css"; import "~/styles/layout.css"; import "~/styles/reset.css"; import "~/styles/utils.css"; import "~/styles/vars.css"; export const shouldRevalidate: ShouldRevalidateFunction = (args) => { if (isRevalidation(args)) return true; // // reload on language change so the selected language gets set into the cookie const lang = args.nextUrl.searchParams.get("lng"); return Boolean(lang); }; 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 = await getUser(request, false); const locale = await i18next.getLocale(request); const themeSession = await getThemeSession(request); // avoid redirection loop if ( user && userIsBanned(user?.id) && new URL(request.url).pathname !== SUSPENDED_PAGE ) { return redirect(SUSPENDED_PAGE); } return json( { locale, theme: themeSession.getTheme(), user: user ? { username: user.username, discordAvatar: user.discordAvatar, discordId: user.discordId, id: user.id, plusTier: user.plusTier, customUrl: user.customUrl, patronTier: user.patronTier, isArtist: user.isArtist, isVideoAdder: user.isVideoAdder, isTournamentOrganizer: user.isTournamentOrganizer, inGameName: user.inGameName, friendCode: user.friendCode, preferences: user.preferences ?? {}, languages: user.languages ? user.languages.split(",") : [], } : undefined, notifications: user ? await NotificationRepository.findByUserId(user.id, { limit: NOTIFICATIONS.PEEK_COUNT, }) : 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; // TODO: re-enable after testing if it causes bug where JS is not loading on revisit // useRevalidateOnRevisit(); useChangeLanguage(locale); usePreloadTranslation(); useLoadingIndicator(); useTriggerToasts(); const customizedCSSVars = useCustomizedCSSVars(); return ( {process.env.NODE_ENV === "development" && } {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(); React.useEffect(() => { if (transition.state === "loading") NProgress.start(); if (transition.state === "idle") NProgress.done(); }, [transition.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 = { 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, art: true, q: true, lfg: true, org: true, front: 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 as any)?.[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 as any)[CUSTOMIZED_CSS_VARS_NAME] as Record< string, string >, ).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 // // Update 14.10.23: not sure if this still applies as the CatchBoundary is gone const data = useLoaderData(); 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?.patronTier) { return null; } return ( ); }