482 行
19 KiB
TypeScript
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
|