diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx
index 217c1ff..ebca5ea 100644
--- a/frontend/src/App.tsx
+++ b/frontend/src/App.tsx
@@ -1,6 +1,6 @@
import { useQuery } from '@tanstack/react-query'
import { AnimatePresence, LayoutGroup, MotionConfig, motion } from 'framer-motion'
-import { Fragment, useEffect, useLayoutEffect, useMemo, useState } from 'react'
+import { Fragment, Suspense, lazy, useEffect, useLayoutEffect, useMemo, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { createBrowserRouter,
Navigate,
@@ -40,7 +40,7 @@ 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 GekanatorUnavailablePage from '@/pages/GekanatorUnavailablePage'
import NicoTagListPage from '@/pages/tags/NicoTagListPage'
import NotFound from '@/pages/NotFound'
import TOSPage from '@/pages/TOSPage'
@@ -72,6 +72,23 @@ 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
+
+ return (
+
+
+ )
+}
+
const RouteTransitionWrapper = ({ animationMode, user, setUser }: {
animationMode: ClientAnimationMode
@@ -114,7 +131,7 @@ const RouteTransitionWrapper = ({ animationMode, user, setUser }: {
}/>
}/>
}/>
- }/>
+ }/>
}/>
}/>
)
@@ -278,11 +295,9 @@ const LocaleRoute: FC = () => {
refetchOnMount: 'always',
refetchOnWindowFocus: 'always',
refetchOnReconnect: 'always' })
- const pageLocale = requiredPageLocale (location.pathname)
const activeLocale =
(isLocaleCode (locale)
- && data?.locales.some (entry => entry.code === locale)
- && (pageLocale == null || pageLocale === locale))
+ && data?.locales.some (entry => entry.code === locale))
? locale
: null
@@ -304,12 +319,6 @@ const LocaleRoute: FC = () => {
return
if (isPending)
return null
- if (pageLocale != null && isLocaleCode (locale) && pageLocale !== locale)
- {
- return data.locales.some (entry => entry.code === pageLocale)
- ?
- :
- }
if (isLocaleCode (locale))
{
return data.locales.some (entry => entry.code === locale)
diff --git a/frontend/src/locales/ain-Kana/gekanator.json b/frontend/src/locales/ain-Kana/gekanator.json
new file mode 100644
index 0000000..f76116d
--- /dev/null
+++ b/frontend/src/locales/ain-Kana/gekanator.json
@@ -0,0 +1,8 @@
+{
+ "unavailable": {
+ "title": "Gekanator タネ シサㇺ イタㇰ アニ パテㇰ エシノッ エアㇱカイ!",
+ "back": "ホㇱキ ソㇱ ウン ホㇱピ",
+ "switchToJapanese": "シサㇺ イタㇰ ウン シンナ",
+ "imageAlt": "ポロンノ チㇱ コㇿ アン 洗澡鹿"
+ }
+}
diff --git a/frontend/src/locales/ja/gekanator.json b/frontend/src/locales/ja/gekanator.json
new file mode 100644
index 0000000..b211394
--- /dev/null
+++ b/frontend/src/locales/ja/gekanator.json
@@ -0,0 +1,8 @@
+{
+ "unavailable": {
+ "title": "グカネータは現在日本語のみでプレー可能です!",
+ "back": "前の画面に戻る",
+ "switchToJapanese": "日本語に切替へる",
+ "imageAlt": "号泣している洗澡鹿"
+ }
+}
diff --git a/frontend/src/pages/GekanatorUnavailablePage.tsx b/frontend/src/pages/GekanatorUnavailablePage.tsx
new file mode 100644
index 0000000..0e3c12c
--- /dev/null
+++ b/frontend/src/pages/GekanatorUnavailablePage.tsx
@@ -0,0 +1,143 @@
+import { motion, useReducedMotion } from 'framer-motion'
+import { useLayoutEffect, useRef } from 'react'
+import { Helmet } from 'react-helmet-async'
+import { useLocation } from 'react-router-dom'
+
+import nikumaru from '@/assets/fonts/nikumaru.otf'
+import { formatUIString } from '@/components/LocalisedText'
+import PrefetchLink from '@/components/PrefetchLink'
+import MainArea from '@/components/layout/MainArea'
+import { Button } from '@/components/ui/button'
+import { SITE_TITLE } from '@/config'
+import { switchLocalePath } from '@/lib/locales'
+import { useClientBehaviourSettings } from '@/lib/useClientBehaviourSettings'
+import { useLocaleNavigate } from '@/lib/useLocale'
+import { useUITranslation } from '@/lib/useUITranslation'
+
+
+const GekanatorUnavailablePage = () => {
+ const location = useLocation ()
+ const navigate = useLocaleNavigate ()
+ const behaviourSettings = useClientBehaviourSettings ()
+ const prefersReducedMotion = useReducedMotion ()
+ const shakeMascot = (behaviourSettings.animation ?? 'normal') === 'normal'
+ && !prefersReducedMotion
+ const { message, label } = useUITranslation ('gekanator')
+ const title = message ('unavailable.title')
+ const imageAlt = message ('unavailable.imageAlt')
+ const headingRef = useRef (null)
+
+ useLayoutEffect (() => {
+ const heading = headingRef.current
+ const context = document.createElement ('canvas').getContext ('2d')
+ if (heading == null || context == null)
+ return
+
+ const fitTitle = () => {
+ heading.style.fontSize = ''
+ heading.style.maxWidth = ''
+ const style = window.getComputedStyle (heading)
+ const minimum = Number.parseFloat (style.fontSize)
+ context.font = `${style.fontWeight} ${minimum}px ${style.fontFamily}`
+ const width = context.measureText (formatUIString (title.text, title.language)).width
+ if (width > 0)
+ {
+ const available = heading.clientWidth
+ const fitted = minimum * Math.max (0, available - 1) / width
+ const fontSize = Math.max (minimum, fitted)
+ heading.style.fontSize = `${fontSize}px`
+ let lower = available / 2
+ let upper = available
+ // Keep the one-line font size and find a width that allows two lines.
+ for (let step = 0; step < 12; ++step)
+ {
+ const candidate = (lower + upper) / 2
+ heading.style.maxWidth = `${candidate}px`
+ if (heading.scrollHeight <= fontSize * 1.25 * 2 + 1
+ && heading.scrollWidth <= heading.clientWidth)
+ upper = candidate
+ else
+ lower = candidate
+ }
+ heading.style.maxWidth = `${Math.ceil (upper)}px`
+ }
+ }
+
+ let disposed = false
+ const resizeObserver = new ResizeObserver (fitTitle)
+ resizeObserver.observe (heading)
+ window.addEventListener ('resize', fitTitle)
+ document.fonts.addEventListener ('loadingdone', fitTitle)
+ void document.fonts.ready.then (() => {
+ if (!disposed)
+ fitTitle ()
+ })
+ fitTitle ()
+
+ return () => {
+ disposed = true
+ resizeObserver.disconnect ()
+ window.removeEventListener ('resize', fitTitle)
+ document.fonts.removeEventListener ('loadingdone', fitTitle)
+ }
+ }, [title.text, title.language])
+
+ const goBack = () => {
+ // The router index excludes unrelated external history and is zero on direct entry.
+ const index: unknown = window.history.state?.idx
+ if (typeof index === 'number' && index > 0)
+ navigate (-1)
+ else
+ navigate ('/posts', { replace: true })
+ }
+
+ return (
+
+
+ {title.text} | {SITE_TITLE}
+
+
+
+
+
+ {label ('unavailable.title')}
+
+
+
+
+
+
+
+ )
+}
+
+
+export default GekanatorUnavailablePage