ファイル
btrc-hub/frontend/src/App.tsx
T
2026-11-01 04:22:26 +09:00

482 行
19 KiB
TypeScript

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 = <Props extends object,> (
load: () => Promise<{ default: ComponentType<Props> }>,
) => {
let loaded: ComponentType<Props> | null = null
let pending: ReturnType<typeof load> | 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 <GekanatorUnavailablePage/>
return (
<Suspense fallback={null}>
<GekanatorPage user={user}/>
</Suspense>)
}
export const pageRoutes = (
user: User | null,
setUser: Dispatch<SetStateAction<User | null>>,
): PageRoute[] => [
{ path: '', element: <LocaleRedirect to="/posts"/>,
handle: { redirectTo: '/posts' } },
{ path: 'posts', element: <PostListPage/>,
handle: { preload: PostListPage.preload, switchLocale: switchPostFilterLocale,
sitemap: ['static'] } },
{ path: 'posts/new', element: <PostNewPage user={user}/>,
handle: { preload: PostNewPage.preload, robots: 'noindex' } },
{ path: 'posts/search', element: <PostSearchPage/>,
handle: { preload: PostSearchPage.preload, switchLocale: switchPostFilterLocale } },
{ path: 'posts/:id', element: <PostDetailRoute user={user}/>,
handle: { preload: PostDetailPage.preload, sitemap: ['posts'] } },
{ path: 'posts/changes', element: <PostHistoryPage/>,
handle: { preload: PostHistoryPage.preload } },
{ path: 'tags', element: <TagListPage/>,
handle: { preload: TagListPage.preload, sitemap: ['static'] } },
{ path: 'tags/:id', element: <TagDetailPage/>,
handle: { preload: TagDetailPage.preload, sitemap: ['tags'] } },
{ path: 'tags/:id/deerjikists', element: <DeerjikistDetailPage/>,
handle: { preload: DeerjikistDetailPage.preload, sitemap: ['deerjikists'] } },
{ path: 'tags/nico', element: <NicoTagListPage user={user}/>,
handle: { preload: NicoTagListPage.preload, sitemap: ['static'] } },
{ path: 'nico/tags', element: <NicoTagListPage user={user}/>,
handle: { preload: NicoTagListPage.preload } },
{ path: 'tags/changes', element: <TagHistoryPage/>,
handle: { preload: TagHistoryPage.preload } },
{ path: 'theatres/:id', element: <TheatreDetailPage user={user}/>,
handle: { preload: TheatreDetailPage.preload, sitemap: ['theatres'] } },
{ path: 'materials', element: <MaterialBasePage/>,
handle: { preload: MaterialBasePage.preload }, children: [
{ index: true, element: <MaterialListPage/>,
handle: { preload: MaterialListPage.preload, sitemap: ['static'] } },
{ path: 'changes', element: <MaterialHistoryPage/>,
handle: { preload: MaterialHistoryPage.preload } },
{ path: 'new', element: <MaterialNewPage/>,
handle: { preload: MaterialNewPage.preload, robots: 'noindex' } },
{ path: 'suppressions', element: <MaterialSyncSuppressionsPage/>,
handle: { preload: MaterialSyncSuppressionsPage.preload,
robots: 'noindex', authentication: 'user' } },
{ path: ':id', element: <MaterialDetailPage/>,
handle: { preload: MaterialDetailPage.preload, sitemap: ['materials'] } }] },
{ path: 'wiki', element: <WikiSearchPage/>,
handle: { preload: WikiSearchPage.preload, sitemap: ['static'] } },
{ path: 'wiki/:title', element: <WikiDetailPage/>,
handle: { preload: WikiDetailPage.preload, switchLocale: switchWikiLocale,
sitemap: ['descriptions', 'tag_groups'] } },
{ path: 'wiki/new', element: <WikiNewPage user={user}/>,
handle: { preload: WikiNewPage.preload, robots: 'noindex' } },
{ path: 'wiki/:id/edit', element: <WikiEditPage user={user}/>,
handle: { preload: WikiEditPage.preload, robots: 'noindex',
switchLocale: request => switchWikiLocale (request, 'edit') } },
{ path: 'wiki/:id/diff', element: <WikiDiffPage/>,
handle: { preload: WikiDiffPage.preload,
switchLocale: request => switchWikiLocale (request, 'diff') } },
{ path: 'wiki/changes', element: <WikiHistoryPage/>,
handle: { preload: WikiHistoryPage.preload,
switchLocale: request => switchWikiLocale (request, 'history') } },
{ path: 'help', element: <HelpListPage/>,
handle: { preload: HelpListPage.preload, sitemap: ['static'] } },
{ path: 'help/changes', element: <HelpChangesPage/>,
handle: { preload: HelpChangesPage.preload } },
{ path: 'help/:identifier/diff', element: <HelpDiffPage/>,
handle: { preload: HelpDiffPage.preload } },
{ id: 'help.detail', path: 'help/:identifier', element: <HelpDetailPage/>,
handle: { prepare: prepareHelpPage, sitemap: ['help'] } },
{ path: 'users/settings', element: <SettingPage user={user} setUser={setUser}/>,
handle: { preload: SettingPage.preload, robots: 'noindex', authentication: 'user' } },
{ path: 'settings', element: <LocaleRedirect to="/users/settings"/>,
handle: { redirectTo: '/users/settings' } },
{ path: 'tos', element: <TOSPage/>,
handle: { preload: TOSPage.preload, sitemap: ['static'] } },
{ id: 'gekanator', path: 'gekanator', element: <GekanatorRoute user={user}/>,
handle: { locale: 'ja', sitemap: ['static'], preload: locale => locale === 'ja'
? GekanatorPage.preload () : GekanatorUnavailablePage.preload () } },
{ path: 'counts', element: <CountsResourcePage resource="posts"/>,
handle: { preload: CountsResourcePage.preload, prefetch: false, robots: 'noindex',
switchLocale: switchPostFilterLocale } },
{ path: 'counts/posts', element: <CountsResourcePage resource="posts"/>,
handle: { preload: CountsResourcePage.preload, prefetch: false, robots: 'noindex',
switchLocale: switchPostFilterLocale } },
{ path: 'counts/tags', element: <CountsResourcePage resource="tags"/>,
handle: { preload: CountsResourcePage.preload, prefetch: false, robots: 'noindex' } },
{ path: 'counts/nico-tags', element: <CountsResourcePage resource="nico_tags"/>,
handle: { preload: CountsResourcePage.preload, prefetch: false, robots: 'noindex' } },
{ path: 'counts/tag-descriptions', element: <CountsResourcePage resource="tag_descriptions"/>,
handle: { preload: CountsResourcePage.preload, prefetch: false, robots: 'noindex' } },
{ path: 'counts/tag-groups', element: <CountsResourcePage resource="tag_groups"/>,
handle: { preload: CountsResourcePage.preload, prefetch: false, robots: 'noindex' } },
{ path: 'counts/materials', element: <CountsResourcePage resource="materials"/>,
handle: { preload: CountsResourcePage.preload, prefetch: false, robots: 'noindex' } },
{ path: 'more', element: <MorePage/>,
handle: { preload: MorePage.preload, sitemap: ['static'] } },
{ path: '*', element: <NotFound/>, handle: { notFound: true } }]
const matchPageRoutes = (pathname: string) =>
matchRoutes<PageRoute> (pageRoutes (null, () => { }), unlocalisedPath (pathname))
export const preloadPageRoute = async (
pathname: string,
locale: LocaleCode,
intent: 'navigation' | 'prefetch' = 'navigation',
): Promise<void> => {
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<string, PageRoute> | 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<SetStateAction<User | null>> }) => {
const routes = useRoutes (pageRoutes (user, setUser))
return (
<AnimatePresence mode="wait" initial={false}>
<Suspense fallback={null}>{routes}</Suspense>
</AnimatePresence>)
}
const PostDetailRoute = ({ user }: { user: User | null }) => {
const location = useLocation ()
const key = location.pathname
return <PostDetailPage key={key} user={user}/>
}
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<string> => {
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<User | null> (
() => client.getQueryData<User> (sessionKeys.current) ?? null)
const authority = useRef (user)
const setUser = useCallback<Dispatch<SetStateAction<User | null>>> (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 <ServiceUnavailable/>
}
return (
<DialogueProvider>
<PageHead>
{robots != null && <meta name="robots" content={robots}/>}
</PageHead>
<UnsavedChangesGuardProvider>
<KeyboardShortcutsProvider>
<MotionConfig
reducedMotion={
animationMode === 'normal'
? 'never'
: animationMode === 'reduced'
? 'user'
: 'always'
}>
<LayoutGroup>
<motion.div
layout={(animationMode === 'off' || countsPage) ? false : 'position'}
transition={{ layout: appLayoutTransition }}
className="relative flex h-dvh w-full flex-col overflow-y-hidden">
<TopNav user={user} locales={locales} resolveLocaleSwitch={switchLocale}/>
<RouteTransitionWrapper
user={user}
setUser={setUser}/>
</motion.div>
</LayoutGroup>
</MotionConfig>
<Toaster/>
</KeyboardShortcutsProvider>
</UnsavedChangesGuardProvider>
</DialogueProvider>)
}
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 <NotFound/>
if (isError && data == null)
return <ServiceUnavailable/>
if (data == null)
return null
if (isLocaleCode (locale))
{
return data.locales.some (entry => entry.code === locale)
? <RoutedApp locales={data.locales}/>
: <NotFound/>
}
return redirect == null ? <NotFound/> : <Navigate to={redirect} replace/>
}
const LocaleRedirect = ({ to }: { to: string }) => {
const locale = useLocale ()
const location = useLocation ()
return <Navigate to={switchLocalePath ({ ...location, pathname: to }, locale)} replace/>
}
export const appRoutes = [
{ path: '/:locale/*', element: <LocaleRoute/> },
{ path: '*', element: <LocaleRoute/> }]
export const AppFrame = ({ children }: { children: ReactNode }) => (
<>
<RouteBlockerOverlay/>
{import.meta.env.DEV && <DevModeWatermark/>}
{children}
</>)
const App: FC = () => {
const [router] = useState (() => createBrowserRouter (appRoutes))
return <AppFrame><RouterProvider router={router}/></AppFrame>
}
export default App