import { useQuery, useQueryClient } from '@tanstack/react-query' import { AnimatePresence, LayoutGroup, MotionConfig, motion } from 'framer-motion' import { createElement, Suspense, lazy, useCallback, useEffect, useRef, useLayoutEffect, useMemo, useState } from 'react' import { useTranslation } from 'react-i18next' import { createBrowserRouter, matchRoutes, Navigate, RouterProvider, useLocation, useParams, useRoutes } from 'react-router-dom' import DevModeWatermark from '@/components/DevModeWatermark' import RouteBlockerOverlay from '@/components/RouteBlockerOverlay' import TopNav from '@/components/TopNav' import DialogueProvider from '@/components/dialogues/DialogueProvider' import PageHead from '@/components/common/PageHead' import { Toaster } from '@/components/ui/toaster' import { isApiError } from '@/lib/api' import { initialiseUserSession } from '@/lib/users' import { prepareHelpPage } from '@/lib/help' import { fetchLocales, isLocaleCode, legacyLocalePath, switchLocalePath, unlocalisedPath } from '@/lib/locales' import { isAuthorityDependentQuery, localesKeys, sessionKeys } from '@/lib/queryKeys' import { isCountsPath } from '@/lib/routeLocales' import { switchPostFilterLocale } from '@/lib/posts' import { switchWikiLocale } from '@/lib/wiki' import { useLocale } from '@/lib/useLocale' import { applyClientAnimationMode, applyClientAppearance, fetchUserThemeSlots, fetchUserSettings, getClientThemeMode, hasStoredClientThemeSelection, normaliseUserThemeSlots, setCachedUserThemeSlots, seedClientThemeMode } from '@/lib/settings' import { useClientBehaviourSettings } from '@/lib/useClientBehaviourSettings' import { UnsavedChangesGuardProvider } from '@/lib/useUnsavedChangesGuard' import { KeyboardShortcutsProvider } from '@/lib/useKeyboardShortcuts' import NotFound from '@/pages/NotFound' import HelpDetailPage from '@/pages/help/HelpDetailPage' import ServiceUnavailable from '@/pages/ServiceUnavailable' import type { ComponentType, Dispatch, FC, ReactNode, SetStateAction } from 'react' import type { RouteMatch } from 'react-router-dom' import type { AvailableLocale, LocaleCode } from '@/lib/locales' import type { PageRoute } from '@/lib/routeLocales' import type { User } from '@/types' const lazyPage = ( load: () => Promise<{ default: ComponentType }>, ) => { let loaded: ComponentType | null = null let pending: ReturnType | null = null const preload = () => pending ??= load ().then (module => { loaded = module.default return module }).catch (error => { pending = null throw error }) const Deferred = lazy (preload) // A preloaded page must render synchronously on the first hydration pass. const Page = (props: Props) => { const [Component] = useState (() => loaded ?? Deferred) return createElement (Component, props) } return Object.assign (Page, { preload }) } const GekanatorPage = lazyPage (() => import ('@/pages/GekanatorPage')) const DeerjikistDetailPage = lazyPage (() => import ('@/pages/deerjikists/DeerjikistDetailPage')) const MaterialBasePage = lazyPage (() => import ('@/pages/materials/MaterialBasePage')) const MaterialDetailPage = lazyPage (() => import ('@/pages/materials/MaterialDetailPage')) const MaterialHistoryPage = lazyPage (() => import ('@/pages/materials/MaterialHistoryPage')) const MaterialListPage = lazyPage (() => import ('@/pages/materials/MaterialListPage')) const MaterialNewPage = lazyPage (() => import ('@/pages/materials/MaterialNewPage')) const MaterialSyncSuppressionsPage = lazyPage ( () => import ('@/pages/materials/MaterialSyncSuppressionsPage')) const CountsResourcePage = lazyPage (() => import ('@/pages/counts/CountsResourcePage')) const MorePage = lazyPage (() => import ('@/pages/MorePage')) const GekanatorUnavailablePage = lazyPage (() => import ('@/pages/GekanatorUnavailablePage')) const NicoTagListPage = lazyPage (() => import ('@/pages/tags/NicoTagListPage')) const TOSPage = lazyPage (() => import ('@/pages/TOSPage')) const HelpChangesPage = lazyPage (() => import ('@/pages/help/HelpChangesPage')) const HelpDiffPage = lazyPage (() => import ('@/pages/help/HelpDiffPage')) const HelpListPage = lazyPage (() => import ('@/pages/help/HelpListPage')) const PostDetailPage = lazyPage (() => import ('@/pages/posts/PostDetailPage')) const PostHistoryPage = lazyPage (() => import ('@/pages/posts/PostHistoryPage')) const PostNewPage = lazyPage (() => import ('@/pages/posts/PostNewPage')) const PostListPage = lazyPage (() => import ('@/pages/posts/PostListPage')) const PostSearchPage = lazyPage (() => import ('@/pages/posts/PostSearchPage')) const SettingPage = lazyPage (() => import ('@/pages/users/SettingPage')) const TagDetailPage = lazyPage (() => import ('@/pages/tags/TagDetailPage')) const TagHistoryPage = lazyPage (() => import ('@/pages/tags/TagHistoryPage')) const TagListPage = lazyPage (() => import ('@/pages/tags/TagListPage')) const TheatreDetailPage = lazyPage (() => import ('@/pages/theatres/TheatreDetailPage')) const WikiDetailPage = lazyPage (() => import ('@/pages/wiki/WikiDetailPage')) const WikiDiffPage = lazyPage (() => import ('@/pages/wiki/WikiDiffPage')) const WikiEditPage = lazyPage (() => import ('@/pages/wiki/WikiEditPage')) const WikiHistoryPage = lazyPage (() => import ('@/pages/wiki/WikiHistoryPage')) const WikiNewPage = lazyPage (() => import ('@/pages/wiki/WikiNewPage')) const WikiSearchPage = lazyPage (() => import ('@/pages/wiki/WikiSearchPage')) const GekanatorRoute = ({ user }: { user: User | null }) => { const locale = useLocale () const location = useLocation () const pageLocale = resolvePageRoute (location.pathname)?.route.handle?.locale if (pageLocale != null && pageLocale !== locale) return return ( ) } export const pageRoutes = ( user: User | null, setUser: Dispatch>, ): PageRoute[] => [ { path: '', element: , handle: { redirectTo: '/posts' } }, { path: 'posts', element: , handle: { preload: PostListPage.preload, switchLocale: switchPostFilterLocale, sitemap: ['static'] } }, { path: 'posts/new', element: , handle: { preload: PostNewPage.preload, robots: 'noindex' } }, { path: 'posts/search', element: , handle: { preload: PostSearchPage.preload, switchLocale: switchPostFilterLocale } }, { path: 'posts/:id', element: , handle: { preload: PostDetailPage.preload, sitemap: ['posts'] } }, { path: 'posts/changes', element: , handle: { preload: PostHistoryPage.preload } }, { path: 'tags', element: , handle: { preload: TagListPage.preload, sitemap: ['static'] } }, { path: 'tags/:id', element: , handle: { preload: TagDetailPage.preload, sitemap: ['tags'] } }, { path: 'tags/:id/deerjikists', element: , handle: { preload: DeerjikistDetailPage.preload, sitemap: ['deerjikists'] } }, { path: 'tags/nico', element: , handle: { preload: NicoTagListPage.preload, sitemap: ['static'] } }, { path: 'nico/tags', element: , handle: { preload: NicoTagListPage.preload } }, { path: 'tags/changes', element: , handle: { preload: TagHistoryPage.preload } }, { path: 'theatres/:id', element: , handle: { preload: TheatreDetailPage.preload, sitemap: ['theatres'] } }, { path: 'materials', element: , handle: { preload: MaterialBasePage.preload }, children: [ { index: true, element: , handle: { preload: MaterialListPage.preload, sitemap: ['static'] } }, { path: 'changes', element: , handle: { preload: MaterialHistoryPage.preload } }, { path: 'new', element: , handle: { preload: MaterialNewPage.preload, robots: 'noindex' } }, { path: 'suppressions', element: , handle: { preload: MaterialSyncSuppressionsPage.preload, robots: 'noindex', authentication: 'user' } }, { path: ':id', element: , handle: { preload: MaterialDetailPage.preload, sitemap: ['materials'] } }] }, { path: 'wiki', element: , handle: { preload: WikiSearchPage.preload, sitemap: ['static'] } }, { path: 'wiki/:title', element: , handle: { preload: WikiDetailPage.preload, switchLocale: switchWikiLocale, sitemap: ['descriptions', 'tag_groups'] } }, { path: 'wiki/new', element: , handle: { preload: WikiNewPage.preload, robots: 'noindex' } }, { path: 'wiki/:id/edit', element: , handle: { preload: WikiEditPage.preload, robots: 'noindex', switchLocale: request => switchWikiLocale (request, 'edit') } }, { path: 'wiki/:id/diff', element: , handle: { preload: WikiDiffPage.preload, switchLocale: request => switchWikiLocale (request, 'diff') } }, { path: 'wiki/changes', element: , handle: { preload: WikiHistoryPage.preload, switchLocale: request => switchWikiLocale (request, 'history') } }, { path: 'help', element: , handle: { preload: HelpListPage.preload, sitemap: ['static'] } }, { path: 'help/changes', element: , handle: { preload: HelpChangesPage.preload } }, { path: 'help/:identifier/diff', element: , handle: { preload: HelpDiffPage.preload } }, { id: 'help.detail', path: 'help/:identifier', element: , handle: { prepare: prepareHelpPage, sitemap: ['help'] } }, { path: 'users/settings', element: , handle: { preload: SettingPage.preload, robots: 'noindex', authentication: 'user' } }, { path: 'settings', element: , handle: { redirectTo: '/users/settings' } }, { path: 'tos', element: , handle: { preload: TOSPage.preload, sitemap: ['static'] } }, { id: 'gekanator', path: 'gekanator', element: , handle: { locale: 'ja', sitemap: ['static'], preload: locale => locale === 'ja' ? GekanatorPage.preload () : GekanatorUnavailablePage.preload () } }, { path: 'counts', element: , handle: { preload: CountsResourcePage.preload, prefetch: false, robots: 'noindex', switchLocale: switchPostFilterLocale } }, { path: 'counts/posts', element: , handle: { preload: CountsResourcePage.preload, prefetch: false, robots: 'noindex', switchLocale: switchPostFilterLocale } }, { path: 'counts/tags', element: , handle: { preload: CountsResourcePage.preload, prefetch: false, robots: 'noindex' } }, { path: 'counts/nico-tags', element: , handle: { preload: CountsResourcePage.preload, prefetch: false, robots: 'noindex' } }, { path: 'counts/tag-descriptions', element: , handle: { preload: CountsResourcePage.preload, prefetch: false, robots: 'noindex' } }, { path: 'counts/tag-groups', element: , handle: { preload: CountsResourcePage.preload, prefetch: false, robots: 'noindex' } }, { path: 'counts/materials', element: , handle: { preload: CountsResourcePage.preload, prefetch: false, robots: 'noindex' } }, { path: 'more', element: , handle: { preload: MorePage.preload, sitemap: ['static'] } }, { path: '*', element: , handle: { notFound: true } }] const matchPageRoutes = (pathname: string) => matchRoutes (pageRoutes (null, () => { }), unlocalisedPath (pathname)) export const preloadPageRoute = async ( pathname: string, locale: LocaleCode, intent: 'navigation' | 'prefetch' = 'navigation', ): Promise => { if (intent === 'prefetch' && isCountsPath (pathname)) return await Promise.all ((matchPageRoutes (pathname) ?? []) .filter (match => intent === 'navigation' || match.route.handle?.prefetch !== false) .map (match => match.route.handle?.preload?.(locale))) } export const resolvePageRoute = ( pathname: string, ): RouteMatch | null => { const match = matchPageRoutes (pathname)?.at (-1) return (match == null || match.route.handle?.notFound) ? null : match } const RouteTransitionWrapper = ({ user, setUser }: { user: User | null setUser: Dispatch> }) => { const routes = useRoutes (pageRoutes (user, setUser)) return ( {routes} ) } const PostDetailRoute = ({ user }: { user: User | null }) => { const location = useLocation () const key = location.pathname return } const RoutedApp = ({ locales }: { locales: AvailableLocale[] }) => { const client = useQueryClient () const location = useLocation () const locale = useLocale () const route = resolvePageRoute (location.pathname) const robots = isCountsPath (location.pathname) ? 'noindex' : route?.route.handle?.robots const pagePath = unlocalisedPath (location.pathname) const countsPage = pagePath === '/counts' || pagePath.startsWith ('/counts/') const switchLocale = async (targetLocale: LocaleCode): Promise => { const resolve = route?.route.handle?.switchLocale return (resolve != null && route != null) ? resolve ({ location, params: route.params, locale, targetLocale }) : switchLocalePath (location, targetLocale) } const [user, setUserState] = useState ( () => client.getQueryData (sessionKeys.current) ?? null) const authority = useRef (user) const setUser = useCallback>> (action => { const next = typeof action === 'function' ? action (authority.current) : action if (next?.id !== authority.current?.id || next?.role !== authority.current?.role) { const filters = { predicate: (query: { queryKey: readonly unknown[] }) => isAuthorityDependentQuery (query.queryKey) } void client.cancelQueries (filters) client.removeQueries (filters) } authority.current = next client.setQueryData (sessionKeys.current, next) setUserState (next) }, [client]) const [status, setStatus] = useState (200) const behaviourSettings = useClientBehaviourSettings () const animationMode = behaviourSettings.animation ?? 'normal' const appLayoutTransition = useMemo ( () => ( animationMode === 'off' ? { duration: 0 } : (animationMode === 'reduced' ? { duration: .08, ease: 'linear' as const } : { duration: .2, ease: 'easeOut' as const }) ), [animationMode], ) useEffect (() => { applyClientAppearance () const mediaQuery = window.matchMedia ('(prefers-color-scheme: dark)') const handleThemeChange = () => { if (getClientThemeMode () === 'system') applyClientAppearance () } mediaQuery.addEventListener ('change', handleThemeChange) return () => mediaQuery.removeEventListener ('change', handleThemeChange) }, []) useEffect (() => { applyClientAnimationMode (animationMode) }, [animationMode]) useEffect (() => { let cancelled = false void (async () => { try { const user = await initialiseUserSession () if (!cancelled) setUser (user) } catch (error) { if (!cancelled && isApiError (error)) setStatus (error.response?.status ?? 503) } }) () return () => { cancelled = true } }, [setUser]) useEffect (() => { if (!(user)) return void (async () => { try { const [settings, themeSlots] = await Promise.all ([ fetchUserSettings (), fetchUserThemeSlots (), ]) const storedThemeSlots = normaliseUserThemeSlots (themeSlots) setCachedUserThemeSlots (storedThemeSlots) if (!(hasStoredClientThemeSelection ())) seedClientThemeMode (settings.theme) applyClientAppearance () } catch { return } }) () }, [user]) switch (status) { case 503: return } return ( {robots != null && } ) } const LocaleRoute: FC = () => { const { locale } = useParams () const location = useLocation () const { i18n } = useTranslation () const { data, isError } = useQuery ({ queryKey: localesKeys.index, queryFn: fetchLocales, staleTime: 60_000, refetchInterval: 60_000, refetchOnMount: true, refetchOnWindowFocus: true, refetchOnReconnect: true }) const activeLocale = (isLocaleCode (locale) && data?.locales.some (entry => entry.code === locale)) ? locale : null useLayoutEffect (() => { if (activeLocale != null) document.documentElement.lang = activeLocale if (activeLocale != null && i18n.language !== activeLocale) void i18n.changeLanguage (activeLocale) }, [activeLocale, i18n]) const redirect = legacyLocalePath (location, pathname => resolvePageRoute (pathname) != null) if (!(isLocaleCode (locale)) && redirect == null) return if (isError && data == null) return if (data == null) return null if (isLocaleCode (locale)) { return data.locales.some (entry => entry.code === locale) ? : } return redirect == null ? : } const LocaleRedirect = ({ to }: { to: string }) => { const locale = useLocale () const location = useLocation () return } export const appRoutes = [ { path: '/:locale/*', element: }, { path: '*', element: }] export const AppFrame = ({ children }: { children: ReactNode }) => ( <> {import.meta.env.DEV && } {children} ) const App: FC = () => { const [router] = useState (() => createBrowserRouter (appRoutes)) return } export default App