356 行
12 KiB
TypeScript
356 行
12 KiB
TypeScript
import { useQuery } from '@tanstack/react-query'
|
|
import { AnimatePresence, LayoutGroup, MotionConfig, motion } from 'framer-motion'
|
|
import { Fragment, Suspense, lazy, useEffect, useLayoutEffect, useMemo, useState } from 'react'
|
|
import { useTranslation } from 'react-i18next'
|
|
import { createBrowserRouter,
|
|
Navigate,
|
|
Route,
|
|
RouterProvider,
|
|
Routes,
|
|
useLocation,
|
|
useParams } 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 { apiPost, isApiError } from '@/lib/api'
|
|
import { fetchLocales, isLocaleCode, legacyLocalePath, switchLocalePath } from '@/lib/locales'
|
|
import { localesKeys } from '@/lib/queryKeys'
|
|
import { requiredPageLocale } from '@/lib/routeLocales'
|
|
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 DeerjikistDetailPage from '@/pages/deerjikists/DeerjikistDetailPage'
|
|
import MaterialBasePage from '@/pages/materials/MaterialBasePage'
|
|
import MaterialDetailPage from '@/pages/materials/MaterialDetailPage'
|
|
import MaterialHistoryPage from '@/pages/materials/MaterialHistoryPage'
|
|
import MaterialListPage from '@/pages/materials/MaterialListPage'
|
|
import MaterialNewPage from '@/pages/materials/MaterialNewPage'
|
|
import MaterialSyncSuppressionsPage from '@/pages/materials/MaterialSyncSuppressionsPage'
|
|
import MorePage from '@/pages/MorePage'
|
|
import GekanatorUnavailablePage from '@/pages/GekanatorUnavailablePage'
|
|
import NicoTagListPage from '@/pages/tags/NicoTagListPage'
|
|
import NotFound from '@/pages/NotFound'
|
|
import TOSPage from '@/pages/TOSPage'
|
|
import HelpDetailPage from '@/pages/help/HelpDetailPage'
|
|
import HelpChangesPage from '@/pages/help/HelpChangesPage'
|
|
import HelpDiffPage from '@/pages/help/HelpDiffPage'
|
|
import HelpListPage from '@/pages/help/HelpListPage'
|
|
import PostDetailPage from '@/pages/posts/PostDetailPage'
|
|
import PostHistoryPage from '@/pages/posts/PostHistoryPage'
|
|
import PostNewPage from '@/pages/posts/PostNewPage'
|
|
import PostListPage from '@/pages/posts/PostListPage'
|
|
import PostSearchPage from '@/pages/posts/PostSearchPage'
|
|
import ServiceUnavailable from '@/pages/ServiceUnavailable'
|
|
import SettingPage from '@/pages/users/SettingPage'
|
|
import TagDetailPage from '@/pages/tags/TagDetailPage'
|
|
import TagHistoryPage from '@/pages/tags/TagHistoryPage'
|
|
import TagListPage from '@/pages/tags/TagListPage'
|
|
import TheatreDetailPage from '@/pages/theatres/TheatreDetailPage'
|
|
import WikiDetailPage from '@/pages/wiki/WikiDetailPage'
|
|
import WikiDiffPage from '@/pages/wiki/WikiDiffPage'
|
|
import WikiEditPage from '@/pages/wiki/WikiEditPage'
|
|
import WikiHistoryPage from '@/pages/wiki/WikiHistoryPage'
|
|
import WikiNewPage from '@/pages/wiki/WikiNewPage'
|
|
import WikiSearchPage from '@/pages/wiki/WikiSearchPage'
|
|
|
|
import type { Dispatch, FC, SetStateAction } from 'react'
|
|
|
|
import type { ClientAnimationMode } from '@/lib/settings'
|
|
import type { AvailableLocale } from '@/lib/locales'
|
|
import type { User } from '@/types'
|
|
|
|
const GekanatorPage = lazy (() => import ('@/pages/GekanatorPage'))
|
|
|
|
|
|
const GekanatorRoute = ({ user }: { user: User | null }) => {
|
|
const locale = useLocale ()
|
|
const location = useLocation ()
|
|
const pageLocale = requiredPageLocale (location.pathname)
|
|
|
|
if (pageLocale != null && pageLocale !== locale)
|
|
return <GekanatorUnavailablePage/>
|
|
|
|
return (
|
|
<Suspense fallback={null}>
|
|
<GekanatorPage user={user}/>
|
|
</Suspense>)
|
|
}
|
|
|
|
|
|
const RouteTransitionWrapper = ({ animationMode, user, setUser }: {
|
|
animationMode: ClientAnimationMode
|
|
user: User | null
|
|
setUser: Dispatch<SetStateAction<User | null>> }) => {
|
|
const location = useLocation ()
|
|
|
|
const routes = (
|
|
<Routes location={location}>
|
|
<Route path="" element={<LocaleRedirect to="/posts"/>}/>
|
|
<Route path="posts" element={<PostListPage/>}/>
|
|
<Route path="posts/new" element={<PostNewPage user={user}/>}/>
|
|
<Route path="posts/search" element={<PostSearchPage/>}/>
|
|
<Route path="posts/:id" element={<PostDetailRoute user={user}/>}/>
|
|
<Route path="posts/changes" element={<PostHistoryPage/>}/>
|
|
<Route path="tags" element={<TagListPage/>}/>
|
|
<Route path="tags/:id" element={<TagDetailPage/>}/>
|
|
<Route path="tags/:id/deerjikists" element={<DeerjikistDetailPage/>}/>
|
|
<Route path="tags/nico" element={<NicoTagListPage user={user}/>}/>
|
|
<Route path="nico/tags" element={<NicoTagListPage user={user}/>}/>
|
|
<Route path="tags/changes" element={<TagHistoryPage/>}/>
|
|
<Route path="theatres/:id" element={<TheatreDetailPage user={user}/>}/>
|
|
<Route path="materials" element={<MaterialBasePage/>}>
|
|
<Route index element={<MaterialListPage/>}/>
|
|
<Route path="changes" element={<MaterialHistoryPage/>}/>
|
|
<Route path="new" element={<MaterialNewPage/>}/>
|
|
<Route path="suppressions" element={<MaterialSyncSuppressionsPage/>}/>
|
|
<Route path=":id" element ={<MaterialDetailPage/>}/>
|
|
</Route>
|
|
<Route path="wiki" element={<WikiSearchPage/>}/>
|
|
<Route path="wiki/:title" element={<WikiDetailPage/>}/>
|
|
<Route path="wiki/new" element={<WikiNewPage user={user}/>}/>
|
|
<Route path="wiki/:id/edit" element={<WikiEditPage user={user}/>}/>
|
|
<Route path="wiki/:id/diff" element={<WikiDiffPage/>}/>
|
|
<Route path="wiki/changes" element={<WikiHistoryPage/>}/>
|
|
<Route path="help" element={<HelpListPage/>}/>
|
|
<Route path="help/changes" element={<HelpChangesPage/>}/>
|
|
<Route path="help/:identifier/diff" element={<HelpDiffPage/>}/>
|
|
<Route path="help/:identifier" element={<HelpDetailPage/>}/>
|
|
<Route path="users/settings" element={<SettingPage user={user} setUser={setUser}/>}/>
|
|
<Route path="settings" element={<LocaleRedirect to="/users/settings"/>}/>
|
|
<Route path="tos" element={<TOSPage/>}/>
|
|
<Route path="gekanator" element={<GekanatorRoute user={user}/>}/>
|
|
<Route path="more" element={<MorePage/>}/>
|
|
<Route path="*" element={<NotFound/>}/>
|
|
</Routes>)
|
|
|
|
if (animationMode === 'off')
|
|
return routes
|
|
|
|
return (
|
|
<AnimatePresence mode="wait">
|
|
{routes}
|
|
</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 [user, setUser] = useState<User | null> (null)
|
|
const [status, setStatus] = useState (200)
|
|
const behaviourSettings = useClientBehaviourSettings ()
|
|
const animationMode = behaviourSettings.animation ?? 'normal'
|
|
const LayoutWrapper = animationMode === 'off' ? Fragment : LayoutGroup
|
|
|
|
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 (() => {
|
|
const createUser = async () => {
|
|
const data = await apiPost<{ code: string; user: User }> ('/users')
|
|
if (data.code)
|
|
{
|
|
localStorage.setItem ('user_code', data.code)
|
|
setUser (data.user)
|
|
}
|
|
}
|
|
|
|
const code = localStorage.getItem ('user_code')
|
|
if (code)
|
|
{
|
|
void (async () => {
|
|
try
|
|
{
|
|
const data = await apiPost<{ valid: boolean; user: User }> ('/users/verify', { code })
|
|
if (data.valid)
|
|
setUser (data.user)
|
|
else
|
|
await createUser ()
|
|
}
|
|
catch (err)
|
|
{
|
|
if (isApiError (err))
|
|
setStatus (err.response?.status ?? 200)
|
|
}
|
|
}) ()
|
|
}
|
|
else
|
|
createUser ()
|
|
}, [])
|
|
|
|
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/>
|
|
<UnsavedChangesGuardProvider>
|
|
<KeyboardShortcutsProvider>
|
|
<MotionConfig
|
|
reducedMotion={
|
|
animationMode === 'normal'
|
|
? 'never'
|
|
: animationMode === 'reduced'
|
|
? 'user'
|
|
: 'always'
|
|
}>
|
|
<LayoutWrapper>
|
|
<motion.div
|
|
layout={animationMode === 'off' ? false : 'position'}
|
|
transition={{ layout: appLayoutTransition }}
|
|
className="relative flex h-dvh w-full flex-col overflow-y-hidden">
|
|
<TopNav user={user} locales={locales}/>
|
|
<RouteTransitionWrapper
|
|
animationMode={animationMode}
|
|
user={user}
|
|
setUser={setUser}/>
|
|
</motion.div>
|
|
</LayoutWrapper>
|
|
</MotionConfig>
|
|
|
|
<Toaster/>
|
|
</KeyboardShortcutsProvider>
|
|
</UnsavedChangesGuardProvider>
|
|
</DialogueProvider>)
|
|
}
|
|
|
|
|
|
const LocaleRoute: FC = () => {
|
|
const { locale } = useParams ()
|
|
const location = useLocation ()
|
|
const { i18n } = useTranslation ()
|
|
const { data, isPending, isError, refetch } = useQuery ({
|
|
queryKey: localesKeys.index,
|
|
queryFn: fetchLocales,
|
|
staleTime: 0,
|
|
refetchOnMount: 'always',
|
|
refetchOnWindowFocus: 'always',
|
|
refetchOnReconnect: 'always' })
|
|
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])
|
|
|
|
useEffect (() => {
|
|
void refetch ()
|
|
}, [location.key, refetch])
|
|
|
|
const redirect = legacyLocalePath (location)
|
|
if (!(isLocaleCode (locale)) && redirect == null)
|
|
return <NotFound/>
|
|
if (isError)
|
|
return <ServiceUnavailable/>
|
|
if (isPending)
|
|
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/>
|
|
}
|
|
|
|
|
|
const router = createBrowserRouter ([
|
|
{ path: '/:locale/*', element: <LocaleRoute/> },
|
|
{ path: '*', element: <LocaleRoute/> }])
|
|
|
|
|
|
const App: FC = () => (
|
|
<>
|
|
<RouteBlockerOverlay/>
|
|
{import.meta.env.DEV && <DevModeWatermark/>}
|
|
<RouterProvider router={router}/>
|
|
</>)
|
|
|
|
|
|
export default App
|