アイヌ語追加 (#428) (#433)

Reviewed-on: #433
Co-authored-by: miteruzo <miteruzo@naver.com>
このコミットはプルリクエスト #433 でマージされました。
このコミットが含まれているのは:
2026-10-20 20:44:14 +09:00
committed by みてるぞ
コミット 084a090cab
294個のファイルの変更、13978行の追加、3461行の削除
+290 -125
ファイルの表示
@@ -1,18 +1,31 @@
import { useQuery, useQueryClient } from '@tanstack/react-query'
import { AnimatePresence, LayoutGroup, MotionConfig, motion } from 'framer-motion'
import { Fragment, useEffect, useMemo, useState } from 'react'
import { createElement, Suspense, lazy, useEffect,
useLayoutEffect, useMemo, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { createBrowserRouter,
matchRoutes,
Navigate,
Route,
RouterProvider,
Routes,
useLocation } from 'react-router-dom'
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 { apiPost, isApiError } from '@/lib/api'
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 { localesKeys, sessionKeys } from '@/lib/queryKeys'
import { switchPostFilterLocale } from '@/lib/posts'
import { switchWikiLocale } from '@/lib/wiki'
import { useLocale } from '@/lib/useLocale'
import { applyClientAnimationMode,
applyClientAppearance,
fetchUserThemeSlots,
@@ -25,98 +38,196 @@ import { applyClientAnimationMode,
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 GekanatorPage from '@/pages/GekanatorPage'
import NicoTagListPage from '@/pages/tags/NicoTagListPage'
import NotFound from '@/pages/NotFound'
import TOSPage from '@/pages/TOSPage.mdx'
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 { ComponentType, Dispatch, FC, ReactNode, SetStateAction } from 'react'
import type { RouteMatch } from 'react-router-dom'
import type { ClientAnimationMode } from '@/lib/settings'
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) => createElement (loaded ?? Deferred, props)
return Object.assign (Page, { preload })
}
const RouteTransitionWrapper = ({ animationMode, user, setUser }: {
animationMode: ClientAnimationMode
user: User | null
setUser: Dispatch<SetStateAction<User | null>> }) => {
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 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
const routes = (
<Routes location={location}>
<Route path="/" element={<Navigate to="/posts" replace/>}/>
<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={<Navigate to="/users/settings" replace/>}/>
<Route path="/tos" element={<TOSPage/>}/>
<Route path="/gekanator" element={<GekanatorPage user={user}/>}/>
<Route path="/more" element={<MorePage/>}/>
<Route path="*" element={<NotFound/>}/>
</Routes>)
if (animationMode === 'off')
return routes
if (pageLocale != null && pageLocale !== locale)
return <GekanatorUnavailablePage/>
return (
<AnimatePresence mode="wait">
{routes}
<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: '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,
): Promise<void> => {
await Promise.all ((matchPageRoutes (pathname) ?? [])
.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>)
}
@@ -128,12 +239,23 @@ const PostDetailRoute = ({ user }: { user: User | null }) => {
}
const RoutedApp: FC = () => {
const [user, setUser] = useState<User | null> (null)
const RoutedApp = ({ locales }: { locales: AvailableLocale[] }) => {
const client = useQueryClient ()
const location = useLocation ()
const locale = useLocale ()
const route = resolvePageRoute (location.pathname)
const robots = route?.route.handle?.robots
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, setUser] = useState<User | null> (
() => client.getQueryData<User> (sessionKeys.current) ?? null)
const [status, setStatus] = useState (200)
const behaviourSettings = useClientBehaviourSettings ()
const animationMode = behaviourSettings.animation ?? 'normal'
const LayoutWrapper = animationMode === 'off' ? Fragment : LayoutGroup
const appLayoutTransition = useMemo (
() => (
@@ -164,36 +286,21 @@ const RoutedApp: FC = () => {
}, [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)
let cancelled = false
void (async () => {
try
{
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)
}
}) ()
const user = await initialiseUserSession ()
if (!cancelled)
setUser (user)
}
else
createUser ()
catch (error)
{
if (!cancelled && isApiError (error))
setStatus (error.response?.status ?? 503)
}
}) ()
return () => { cancelled = true }
}, [])
useEffect (() => {
@@ -228,6 +335,9 @@ const RoutedApp: FC = () => {
return (
<DialogueProvider>
<PageHead>
{robots != null && <meta name="robots" content={robots}/>}
</PageHead>
<UnsavedChangesGuardProvider>
<KeyboardShortcutsProvider>
<MotionConfig
@@ -238,18 +348,17 @@ const RoutedApp: FC = () => {
? 'user'
: 'always'
}>
<LayoutWrapper>
<LayoutGroup>
<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}/>
<TopNav user={user} locales={locales} resolveLocaleSwitch={switchLocale}/>
<RouteTransitionWrapper
animationMode={animationMode}
user={user}
setUser={setUser}/>
</motion.div>
</LayoutWrapper>
</LayoutGroup>
</MotionConfig>
<Toaster/>
@@ -259,17 +368,73 @@ const RoutedApp: FC = () => {
}
const router = createBrowserRouter ([{
path: '*',
element: <RoutedApp/> }])
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)
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 App: FC = () => (
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/>}
<RouterProvider router={router}/>
{children}
</>)
const App: FC = () => {
const [router] = useState (() => createBrowserRouter (appRoutes))
return <AppFrame><RouterProvider router={router}/></AppFrame>
}
export default App