このコミットが含まれているのは:
2026-10-19 00:03:55 +09:00
コミット a86129006d
37個のファイルの変更、800行の追加、224行の削除
+26 -13
ファイルの表示
@@ -1,4 +1,4 @@
import { useQuery } from '@tanstack/react-query'
import { useQuery, useQueryClient } from '@tanstack/react-query'
import { AnimatePresence, LayoutGroup, MotionConfig, motion } from 'framer-motion'
import { Suspense, lazy, useEffect, useLayoutEffect, useMemo, useState } from 'react'
import { useTranslation } from 'react-i18next'
@@ -20,7 +20,7 @@ import { apiPost, isApiError } from '@/lib/api'
import { prepareHelpPage } from '@/lib/help'
import { fetchLocales, isLocaleCode, legacyLocalePath,
switchLocalePath, unlocalisedPath } from '@/lib/locales'
import { localesKeys } from '@/lib/queryKeys'
import { localesKeys, sessionKeys } from '@/lib/queryKeys'
import { useLocale } from '@/lib/useLocale'
import { applyClientAnimationMode,
applyClientAppearance,
@@ -99,9 +99,11 @@ export const pageRoutes = (
user: User | null,
setUser: Dispatch<SetStateAction<User | null>>,
): PageRoute[] => [
{ path: '', element: <LocaleRedirect to="/posts"/> },
{ path: '', element: <LocaleRedirect to="/posts"/>,
handle: { redirectTo: '/posts' } },
{ path: 'posts', element: <PostListPage/> },
{ path: 'posts/new', element: <PostNewPage user={user}/> },
{ path: 'posts/new', element: <PostNewPage user={user}/>,
handle: { robots: 'noindex' } },
{ path: 'posts/search', element: <PostSearchPage/> },
{ path: 'posts/:id', element: <PostDetailRoute user={user}/> },
{ path: 'posts/changes', element: <PostHistoryPage/> },
@@ -115,22 +117,27 @@ export const pageRoutes = (
{ path: 'materials', element: <MaterialBasePage/>, children: [
{ index: true, element: <MaterialListPage/> },
{ path: 'changes', element: <MaterialHistoryPage/> },
{ path: 'new', element: <MaterialNewPage/> },
{ path: 'suppressions', element: <MaterialSyncSuppressionsPage/> },
{ path: 'new', element: <MaterialNewPage/>, handle: { robots: 'noindex' } },
{ path: 'suppressions', element: <MaterialSyncSuppressionsPage/>,
handle: { robots: 'noindex', authentication: 'user' } },
{ path: ':id', element: <MaterialDetailPage/> }] },
{ path: 'wiki', element: <WikiSearchPage/> },
{ path: 'wiki/:title', element: <WikiDetailPage/> },
{ path: 'wiki/new', element: <WikiNewPage user={user}/> },
{ path: 'wiki/:id/edit', element: <WikiEditPage user={user}/> },
{ path: 'wiki/new', element: <WikiNewPage user={user}/>,
handle: { robots: 'noindex' } },
{ path: 'wiki/:id/edit', element: <WikiEditPage user={user}/>,
handle: { robots: 'noindex' } },
{ path: 'wiki/:id/diff', element: <WikiDiffPage/> },
{ path: 'wiki/changes', element: <WikiHistoryPage/> },
{ path: 'help', element: <HelpListPage/> },
{ path: 'help/changes', element: <HelpChangesPage/> },
{ path: 'help/:identifier/diff', element: <HelpDiffPage/> },
{ id: 'help.detail', path: 'help/:identifier', element: <HelpDetailPage/>,
handle: { ssr: prepareHelpPage } },
{ path: 'users/settings', element: <SettingPage user={user} setUser={setUser}/> },
{ path: 'settings', element: <LocaleRedirect to="/users/settings"/> },
handle: { prepare: prepareHelpPage } },
{ path: 'users/settings', element: <SettingPage user={user} setUser={setUser}/>,
handle: { robots: 'noindex', authentication: 'user' } },
{ path: 'settings', element: <LocaleRedirect to="/users/settings"/>,
handle: { redirectTo: '/users/settings' } },
{ path: 'tos', element: <TOSPage/> },
{ id: 'gekanator', path: 'gekanator', element: <GekanatorRoute user={user}/>,
handle: { locale: 'ja' } },
@@ -168,7 +175,11 @@ const PostDetailRoute = ({ user }: { user: User | null }) => {
const RoutedApp = ({ locales }: { locales: AvailableLocale[] }) => {
const [user, setUser] = useState<User | null> (null)
const client = useQueryClient ()
const location = useLocation ()
const robots = resolvePageRoute (location.pathname)?.route.handle?.robots
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'
@@ -266,7 +277,9 @@ const RoutedApp = ({ locales }: { locales: AvailableLocale[] }) => {
return (
<DialogueProvider>
<PageHead/>
<PageHead>
{robots != null && <meta name="robots" content={robots}/>}
</PageHead>
<UnsavedChangesGuardProvider>
<KeyboardShortcutsProvider>
<MotionConfig
+4
ファイルの表示
@@ -3,6 +3,7 @@ import errorImg from '@/assets/images/not-found.gif'
import MainArea from '@/components/layout/MainArea'
import PageHead from '@/components/common/PageHead'
import { useUITranslation } from '@/lib/useUITranslation'
import { currentRenderContext } from '@/lib/renderContext'
import type { FC } from 'react'
@@ -10,6 +11,9 @@ type Props = { status: number }
const ErrorScreen: FC<Props> = ({ status }) => {
const context = currentRenderContext ()
if (context != null)
context.status = status
const { message: translate, label } = useUITranslation ('errors')
const [message]: [string, string, string] = (() => {
switch (status)
+1 -1
ファイルの表示
@@ -65,7 +65,7 @@ const PostEditForm: FC<Props> = ({ post, onSave }) => {
useState<string | null> (post.originalCreatedFrom)
const [parentPostIds, setParentPostIds] =
useState ((post.parentPosts ?? []).map (p => p.id).join (' '))
const [tags, setTags] = useState<string> ('')
const [tags, setTags] = useState<string> (() => tagsToStr (post.tags))
const [title, setTitle] = useState (post.title)
const videoFlg =
+9 -2
ファイルの表示
@@ -1,7 +1,9 @@
import { Helmet } from 'react-helmet-async'
import { useLocation } from 'react-router-dom'
import { useLocale } from '@/lib/useLocale'
import { useUITranslation } from '@/lib/useUITranslation'
import { currentRenderContext } from '@/lib/renderContext'
import type { ReactNode } from 'react'
@@ -18,19 +20,24 @@ const PageHead = (
{ title, includeSiteName = true, description: pageDescription, canonical, children }: Props,
) => {
const locale = useLocale ()
const location = useLocation ()
const origin = currentRenderContext ()?.origin
const pageCanonical = canonical ?? (origin == null
? undefined : new URL (location.pathname + location.search, origin).href)
const { message, siteTitle } = useUITranslation ('shared')
const pageTitle = typeof title === 'string' ? { text: title, language: locale } : title
const fullTitle =
pageTitle == null
? siteTitle.text
: (includeSiteName ? `${pageTitle.text} | ${siteTitle.text}` : pageTitle.text)
const description = pageDescription ?? message ('head.description', { brand: siteTitle.text })
const description = pageDescription ?? pageTitle
?? message ('head.description', { brand: siteTitle.text })
return (
<Helmet>
<title lang={pageTitle?.language ?? siteTitle.language}>{fullTitle}</title>
<meta name="description" content={description.text} lang={description.language}/>
{canonical != null && <link rel="canonical" href={canonical}/>}
{pageCanonical != null && <link rel="canonical" href={pageCanonical}/>}
{children}
</Helmet>)
}
+1 -6
ファイルの表示
@@ -103,12 +103,7 @@ const SidebarComponent: FC<Props> = ({
return Math.min (maxAllowedWidth, Math.max (MIN_SIDEBAR_WIDTH, nextWidth))
}, [])
const [width, setWidth] = useState (() => (
typeof window === 'undefined'
? DEFAULT_SIDEBAR_WIDTH
: getClampedWidth (
readStoredSidebarWidth (sidebarKey, side),
window.innerWidth)))
const [width, setWidth] = useState (DEFAULT_SIDEBAR_WIDTH)
const [resizing, setResizing] = useState (false)
const widthRef = useRef (width)
const longPressTimerRef = useRef<ReturnType<typeof setTimeout> | null> (null)
+2 -2
ファイルの表示
@@ -88,9 +88,9 @@ const BehaviourSettingsSection: FC<Props> = props => {
const { sectionClassName, onDirtyStateChange } = props
const { label } = useUITranslation ('settings')
const [savedSettings, setSavedSettings] =
useState<ClientBehaviorSettings> (() => getClientBehaviorSettings ())
useState<ClientBehaviorSettings> ({ })
const [draftSettings, setDraftSettings] =
useState<ClientBehaviorSettings> (() => getClientBehaviorSettings ())
useState<ClientBehaviorSettings> ({ })
useEffect (() => {
const current = getClientBehaviorSettings ()
+119 -23
ファイルの表示
@@ -1,17 +1,23 @@
import { QueryClient, QueryClientProvider, dehydrate } from '@tanstack/react-query'
import { renderToString } from 'react-dom/server'
import { QueryClient, QueryClientProvider, dehydrate, skipToken } from '@tanstack/react-query'
import { PassThrough } from 'node:stream'
import { renderToPipeableStream } from 'react-dom/server'
import { HelmetProvider } from 'react-helmet-async'
import { I18nextProvider } from 'react-i18next'
import { createStaticHandler, createStaticRouter,
StaticRouterProvider } from 'react-router-dom/server'
import { AppFrame, appRoutes, resolvePageRoute } from '@/App'
import { apiGet, isApiError } from '@/lib/api'
import { createUII18n } from '@/lib/i18n'
import { fetchLocales, isLocaleCode, legacyLocalePath } from '@/lib/locales'
import { localesKeys } from '@/lib/queryKeys'
import { localesKeys, sessionKeys } from '@/lib/queryKeys'
import { withRenderContext } from '@/lib/renderContext.server'
import type { HelmetServerState } from 'react-helmet-async'
import type { ReactNode } from 'react'
import type { QueryObserverOptions } from '@tanstack/react-query'
import type { RenderContext } from '@/lib/renderContext'
import type { User } from '@/types'
type RenderedPage = {
status: number
@@ -23,44 +29,91 @@ type RenderedPage = {
type RouteResponse = { status: 404 | 308; location?: string }
const renderDocument = (element: ReactNode): Promise<string> =>
new Promise ((resolve, reject) => {
const output = new PassThrough ()
const chunks: Buffer[] = []
output.on ('data', chunk => chunks.push (Buffer.from (chunk)))
output.on ('error', reject)
output.on ('end', () => resolve (Buffer.concat (chunks).toString ('utf8')))
const timeout = setTimeout (() => {
stream.abort ()
reject (new Error ('SSR rendering timed out'))
}, 30_000)
const stream = renderToPipeableStream (element, {
onAllReady: () => {
clearTimeout (timeout)
stream.pipe (output)
},
onShellError: error => {
clearTimeout (timeout)
reject (error)
},
onError: error => {
clearTimeout (timeout)
reject (error)
} })
})
export const render = async (
address: string,
origin: string,
apiBaseURL: string,
): Promise<RenderedPage | RouteResponse | null> => {
authentication: Pick<RenderContext, 'transferCode' | 'forwardedFor'> = { },
): Promise<RenderedPage | RouteResponse> => {
const url = new URL (address, origin)
const legacy = legacyLocalePath ({ pathname: url.pathname, search: url.search, hash: '' })
const pathname = legacy == null ? url.pathname : new URL (legacy, origin).pathname
const segment = pathname.split ('/')[1]
const known = resolvePageRoute (pathname)
if (!(isLocaleCode (segment)) || known == null)
return { status: 404 }
if (legacy != null)
if (legacy != null && known != null)
return { status: 308, location: legacy }
const locale = segment
return withRenderContext ({ pathname: url.pathname, origin, apiBaseURL }, async () => {
const locale = isLocaleCode (segment) ? segment : 'ja'
if (known?.route.handle?.redirectTo != null)
return { status: 308,
location: `/${locale}${known.route.handle.redirectTo}${url.search}` }
const requestContext = { pathname: url.pathname, origin, apiBaseURL,
...authentication, status: 200 }
return withRenderContext (requestContext, async () => {
const client = new QueryClient ({ defaultOptions: {
queries: { retry: false, gcTime: Infinity } } })
queries: { retry: false, gcTime: Infinity, staleTime: Infinity } } })
try
{
const locales = await client.fetchQuery ({
queryKey: localesKeys.index, queryFn: fetchLocales })
const active = locales.locales.some (entry => entry.code === locale)
if (!(active))
return { status: 404 as const }
const prepare = known.route.handle?.ssr
if (prepare == null)
return null
const status = await prepare ({ client, params: known.params, locale,
searchParams: url.searchParams })
if (authentication.transferCode != null)
{
try
{
const user = await apiGet<Pick<User, 'id' | 'name' | 'role'>> ('/users/session')
client.setQueryData (sessionKeys.current, {
id: user.id, name: user.name, role: user.role, inheritanceCode: '' })
}
catch (error)
{
if (!isApiError (error) || error.response?.status !== 401)
throw error
delete requestContext.transferCode
}
}
const prepare = known?.route.handle?.prepare
let status = known == null || !active || !isLocaleCode (segment) ? 404 : 200
if (status === 200 && known?.route.handle?.authentication != null
&& client.getQueryData (sessionKeys.current) == null)
status = 401
if (prepare != null && known != null && status === 200)
status = await prepare ({ client, params: known.params, locale,
searchParams: url.searchParams })
const instance = createUII18n (locale)
const handler = createStaticHandler (appRoutes)
const context = await handler.query (new Request (url))
if (context instanceof Response)
throw new Error (`Unexpected router response: ${context.status}`)
const router = createStaticRouter (handler.dataRoutes, context)
const helmetContext: { helmet?: HelmetServerState } = { }
const html = renderToString (
let helmetContext: { helmet?: HelmetServerState } = { }
const page = () => (
<HelmetProvider context={helmetContext}>
<QueryClientProvider client={client}>
<I18nextProvider i18n={instance}>
@@ -70,13 +123,56 @@ export const render = async (
</I18nextProvider>
</QueryClientProvider>
</HelmetProvider>)
let html = ''
const deadline = Date.now () + 30_000
// Each pass discovers the queries used by the actual page, including dependent queries.
// Streaming resolves lazy components; only the last, fully prepared pass is returned.
for (;;)
{
if (Date.now () >= deadline)
throw new Error ('SSR data preparation timed out')
helmetContext = { }
requestContext.status = status
html = await renderDocument (page ())
const pending = client.getQueryCache ().getAll ().filter (query => {
const options = query.options as QueryObserverOptions
return query.state.status === 'pending'
&& options.queryFn != null && options.queryFn !== skipToken
&& options.enabled !== false
&& (typeof options.enabled !== 'function' || options.enabled (query))
})
if (pending.length === 0)
{
status = requestContext.status
break
}
await Promise.all (pending.map (async query => {
try
{
await client.fetchQuery ({ ...query.options, queryKey: query.queryKey })
}
catch (error)
{
const code = isApiError (error) ? error.response?.status : null
if (code === 404 || code === 401 || code === 403)
status = code
else
throw error
}
}))
}
const helmet = helmetContext.helmet!
const head = [helmet.title, helmet.meta, helmet.link, helmet.style]
.map (entry => entry.toString ()).join ('')
const state = dehydrate (client, { shouldDehydrateQuery: query =>
query.state.status === 'success'
&& (query.queryKey[0] === 'locales'
|| (query.queryKey[0] === 'help' && query.queryKey[1] === 'article')) })
const dehydrated = dehydrate (client, { shouldDehydrateQuery: query =>
query.state.status !== 'pending'
&& !(query.queryKey[0] === 'help' && query.queryKey[1] === 'edition') })
const state = { ...dehydrated, queries: dehydrated.queries.map (query => ({
...query, state: { ...query.state, fetchFailureReason: null,
error: query.state.error == null ? null : {
isAxiosError: true, response: {
status: isApiError (query.state.error) ? query.state.error.response?.status : 500 } } }
})) }
const bootstrap = JSON.stringify ({ state, status, locale,
url: url.pathname + url.search })
.replace (/</g, '\\u003c').replace (/\u2028/g, '\\u2028')
+22 -1
ファイルの表示
@@ -26,7 +26,10 @@ const withUserCode = (opt?: Opt): AxiosRequestConfig => {
&& { baseURL: currentRenderContext ()!.apiBaseURL, timeout: 10_000 }),
...(locale != null && { params: { locale, ...config.params } }),
headers: { 'X-Transfer-Code': typeof window === 'undefined'
? '' : localStorage.getItem ('user_code') ?? '',
? currentRenderContext ()?.transferCode ?? ''
: localStorage.getItem ('user_code') ?? '',
...(currentRenderContext ()?.forwardedFor != null && {
'X-Forwarded-For': currentRenderContext ()!.forwardedFor }),
...(config.headers ?? { }) } }
}
@@ -37,6 +40,24 @@ const apiP = async <T> (
body?: unknown,
opt?: Opt): Promise<T> => {
const res = await client[method] (path, body ?? { }, withUserCode (opt))
if (typeof window !== 'undefined' && method === 'post'
&& ['/users', '/users/verify', '/users/code/renew'].includes (path))
{
const code = res.data?.code ?? (res.data?.valid ? (body as { code?: string })?.code : null)
if (typeof code === 'string')
{
try
{
await fetch ('/_ssr/session', {
method: 'POST', credentials: 'same-origin',
headers: { 'X-Transfer-Code': code } })
}
catch
{
// Existing CSR authentication remains usable if the SSR endpoint is unavailable.
}
}
}
if (opt?.responseType === 'blob')
return res.data as T
return toCamel (res.data as Record<string, unknown>, { deep: true }) as T
+12
ファイルの表示
@@ -123,6 +123,8 @@ export const materialsKeys = {
export const wikiKeys = {
root: ['wiki'] as const,
diff: (id: string, p: { from: string | null; to: string | null }, locale: LocaleCode) =>
['wiki', locale, 'diff', id, p] as const,
autolinkCandidates: (language: string, locale: LocaleCode) =>
['wiki', 'autolink', locale, language] as const,
index: (
@@ -147,3 +149,13 @@ export const helpKeys = {
diff: (identifier: string, language: string, from: string, to: string) =>
['help', 'diff', identifier, language, from, to] as const,
changes: (language: string) => ['help', 'changes', language] as const }
export const sessionKeys = {
current: ['session', 'current'] as const,
settings: (id: number) => ['session', id, 'settings'] as const,
themeSlots: (id: number) => ['session', id, 'themeSlots'] as const }
export const theatresKeys = {
show: (id: string, locale: LocaleCode) => ['theatres', locale, id] as const,
programmes: (id: string, locale: LocaleCode) =>
['theatres', locale, id, 'programmes'] as const }
+4 -1
ファイルの表示
@@ -1,7 +1,10 @@
export type RenderContext = {
pathname: string
origin: string
apiBaseURL?: string }
apiBaseURL?: string
transferCode?: string
forwardedFor?: string
status?: number }
// The server build replaces this module with the AsyncLocalStorage implementation.
+4 -1
ファイルの表示
@@ -10,8 +10,11 @@ export type PagePreparation = {
searchParams: URLSearchParams }
export type PageRouteHandle = {
ssr?: (request: PagePreparation) => Promise<number>
prepare?: (request: PagePreparation) => Promise<number>
locale?: LocaleCode
robots?: 'noindex'
authentication?: 'user'
redirectTo?: string
notFound?: boolean }
export type PageRoute = { handle?: PageRouteHandle } & (
+2 -1
ファイルの表示
@@ -791,7 +791,8 @@ export const seedClientThemeMode = (theme: UserSettings['theme']): void => {
const prefersDarkTheme = (): boolean =>
window.matchMedia ('(prefers-color-scheme: dark)').matches
typeof window !== 'undefined'
&& window.matchMedia ('(prefers-color-scheme: dark)').matches
export const resolveThemeMode = (
+9
ファイルの表示
@@ -0,0 +1,9 @@
import { useSyncExternalStore } from 'react'
const subscribe = () => () => { }
const clientSnapshot = () => true
const serverSnapshot = () => false
export const useHydrated = (): boolean =>
useSyncExternalStore (subscribe, clientSnapshot, serverSnapshot)
+5 -1
ファイルの表示
@@ -99,12 +99,16 @@ export const KeyboardShortcutsProvider = ({ children }: PropsWithChildren) => {
const navigate = useLocaleNavigate ()
const [keyboardSettings, setKeyboardSettingsState] =
useState<ClientKeyboardSettings> (() => getClientKeyboardSettings ())
useState<ClientKeyboardSettings> ({ enabled: true, bindings: { } })
const [registeredHandlers, setRegisteredHandlers] =
useState<Record<number, ShortcutHandlers>> ({ })
const [shortcutHelpOpen, setShortcutHelpOpen] = useState (false)
const registrationIdRef = useRef (0)
useEffect (() => {
setKeyboardSettingsState (getClientKeyboardSettings ())
}, [])
const activeScope = useMemo<ShortcutScope> (
() => resolveShortcutScope (location.pathname),
[location.pathname],
+13 -7
ファイルの表示
@@ -63,17 +63,23 @@ const start = () => {
if (data?.state == null || !(Array.isArray (data.state.queries)))
throw new Error ('Invalid SSR query state')
hydrate (client, data.state)
for (const query of data.state.queries)
{
if (query.state.status === 'error')
client.setQueryDefaults (query.queryKey, { retryOnMount: false })
}
if (client.getQueryData (localesKeys.index) == null)
throw new Error ('Missing SSR locales')
const url = new URL (window.location.href)
const match = resolvePageRoute (url.pathname)
const prepare = match?.route.handle?.ssr
if (match == null || prepare == null)
throw new Error ('Missing SSR route preparation')
const status = await prepare ({ client, params: match.params,
locale: currentViewingLocale (), searchParams: url.searchParams })
if (status !== data.status)
throw new Error ('Server and browser Help versions differ')
const prepare = match?.route.handle?.prepare
if (prepare != null && match != null)
{
const status = await prepare ({ client, params: match.params,
locale: currentViewingLocale (), searchParams: url.searchParams })
if (status !== data.status)
throw new Error ('Server and browser page versions differ')
}
mount (true)
mounted = true
for (const event of ['online', 'focus', 'pageshow'])
+15 -5
ファイルの表示
@@ -1,12 +1,13 @@
import { animate, motion, useMotionTemplate, useMotionValue } from 'framer-motion'
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { useCallback, useEffect, useId, useMemo, useRef, useState } from 'react'
import { useLocale } from '@/lib/useLocale'
import PrefetchLink from '@/components/PrefetchLink'
import MainArea from '@/components/layout/MainArea'
import PageHead from '@/components/common/PageHead'
import { useUITranslation } from '@/lib/useUITranslation'
import { useHydrated } from '@/lib/useHydrated'
import { expectedAnswerForQuestion,
fetchGekanatorExtraQuestions,
fetchGekanatorQuestions,
@@ -3679,23 +3680,32 @@ const expectedAnswerFor = (
const GekanatorPage: FC<{ user: User | null }> = ({ user }) => {
const hydrated = useHydrated ()
const initialSeed = useId ()
const { message } = useUITranslation ('gekanator')
const locale = useLocale ()
const behaviourSettings = useClientBehaviourSettings ()
const animationMode = behaviourSettings.animation ?? 'normal'
const storedGame = useMemo (loadStoredGame, [])
const storedGame = useMemo (() => hydrated ? loadStoredGame () : null, [hydrated])
const hasStoredRestore = storedGame != null && isStoredPhase (storedGame.phase)
const queryClient = useQueryClient ()
const isAdmin = user?.role === 'admin'
const canPersistGame = user != null
const [recentGames, setRecentGames] = useState<RecentGameSummary[]> (
() => loadRecentGames ())
() => hydrated ? loadRecentGames () : [])
const [backgroundMotionMode, setBackgroundMotionMode] = useState<BackgroundMotionMode> (
() => loadBackgroundMotionMode ())
() => hydrated ? loadBackgroundMotionMode () : 'on')
const [prefersReducedMotion, setPrefersReducedMotion] = useState (false)
const [gameSeed, setGameSeed] = useState (
storedGame?.gameSeed ?? createGameSeed ())
storedGame?.gameSeed ?? initialSeed)
const [restorePromptVisible, setRestorePromptVisible] = useState (hasStoredRestore)
useEffect (() => {
if (!hydrated)
return
setRecentGames (loadRecentGames ())
setBackgroundMotionMode (loadBackgroundMotionMode ())
setRestorePromptVisible (hasStoredRestore)
}, [hydrated, hasStoredRestore])
const [phase, setPhase] = useState<Phase> (
hasStoredRestore ? 'intro' : storedGame?.phase ?? 'intro')
const [scores, setScores] = useState<Map<number, number>> (
+3
ファイルの表示
@@ -1,3 +1,5 @@
import { useLocation } from 'react-router-dom'
import PrefetchLink from '@/components/PrefetchLink'
import LocalisedText from '@/components/LocalisedText'
import { menuOutline } from '@/components/TopNav'
@@ -12,6 +14,7 @@ import type { User } from '@/types'
const MorePage: FC = () => {
const location = useLocation ()
const { message } = useUITranslation ('shared')
const menu = menuOutline (
{ tag: null, wikiId: null, user: { } as User, pathName: location.pathname })
+2 -2
ファイルの表示
@@ -39,8 +39,8 @@ const DeerjikistDetailPage: FC = () => {
const deerjikists = useMemo (() => qData?.deerjikists ?? [], [qData])
const [data, setData] =
useState<(Omit<Deerjikist, 'platform'> & { platform: Platform | null })[]> ([])
const [disabled, setDisabled] = useState (true)
useState<(Omit<Deerjikist, 'platform'> & { platform: Platform | null })[]> (deerjikists)
const [disabled, setDisabled] = useState (tag == null)
const { baseErrors, fieldErrors, clearValidationErrors, applyValidationError } =
useValidationErrors<DeerjikistFormField> ()
+13 -8
ファイルの表示
@@ -23,6 +23,7 @@ import { localeLanguage } from '@/lib/locales'
import { inputClass } from '@/lib/utils'
import { useValidationErrors } from '@/lib/useValidationErrors'
import { useUITranslation } from '@/lib/useUITranslation'
import NotFound from '@/pages/NotFound'
import type { FC } from 'react'
@@ -34,19 +35,20 @@ const MaterialDetailPage: FC = () => {
const { message, label } = useUITranslation ('materials')
const { id } = useParams ()
const qc = useQueryClient ()
const { data: material, isError, isLoading } = useQuery ({
queryKey: materialsKeys.show (id ?? '', locale),
queryFn: () => fetchMaterial (id ?? '', locale),
enabled: id != null })
const [exportPath, setExportPath] = useState ('')
const [exportPath, setExportPath] = useState (material?.exportPaths.legacyDrive ?? '')
const [file, setFile] = useState<File | null> (null)
const [filePreview, setFilePreview] = useState ('')
const [tag, setTag] = useState ('')
const [url, setURL] = useState ('')
const [filePreview, setFilePreview] = useState (
material?.contentType != null ? material.file ?? '' : '')
const [tag, setTag] = useState (material?.tag?.name ?? '')
const [url, setURL] = useState (material?.url ?? '')
const { baseErrors, fieldErrors, clearValidationErrors, applyValidationError } =
useValidationErrors<MaterialFormField> ()
const { data: material, isError, isLoading } = useQuery ({
queryKey: materialsKeys.show (id ?? '', locale),
queryFn: () => fetchMaterial (id ?? '', locale),
enabled: id != null})
const materialTitle = material
? material.tag?.name ?? message ('unnamed', { id: material.id }).text
: ''
@@ -97,6 +99,9 @@ const MaterialDetailPage: FC = () => {
updateMutation.mutate ()
}
if (!isLoading && !isError && material == null)
return <NotFound/>
return (
<MainArea>
<PageHead title={material
+2 -12
ファイルの表示
@@ -1,6 +1,6 @@
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import { motion } from 'framer-motion'
import { useEffect, useRef, useState } from 'react'
import { useEffect, useRef } from 'react'
import { useParams } from 'react-router-dom'
import { useLocale } from '@/lib/useLocale'
import PostEditForm from '@/components/PostEditForm'
@@ -56,7 +56,7 @@ const PostDetailPage: FC<Props> = ({ user }) => {
const embedRef = useRef<NiconicoViewerHandle> (null)
const [status, setStatus] = useState (200)
const status = (errorFlg && isApiError (error)) ? error.response?.status ?? 200 : 200
const changeViewedFlg = useMutation ({
mutationFn: async (next: boolean) => {
@@ -79,19 +79,9 @@ const PostDetailPage: FC<Props> = ({ user }) => {
onSuccess: () => {
qc.invalidateQueries ({ queryKey: postsKeys.root })} })
useEffect (() => {
if (!(errorFlg))
return
const code = isApiError (error) ? error.response?.status : undefined
if (code)
setStatus (code)
}, [errorFlg, error])
useEffect (() => {
scroll (0, 0)
setStatus (200)
}, [id])
switch (status)
+2 -1
ファイルの表示
@@ -614,7 +614,8 @@ const PostImportReviewPage: FC<Props> = ({ user }) => {
? { duration: .08, ease: 'linear' as const }
: { duration: .2, ease: 'easeOut' as const }
const [rows, setRows] = useState<PostImportRow[] | null> (null)
const [rows, setRows] = useState<PostImportRow[] | null> (
() => buildInitialRows (parsePostNewReviewUrls (location.search)))
const [metadataLoading, setMetadataLoading] = useState (false)
const [submitting, setSubmitting] = useState (false)
const [busyRowIds, setBusyRowIds] = useState<Set<number>> (new Set ())
+6 -2
ファイルの表示
@@ -23,6 +23,7 @@ import {
import { useKeyboardShortcuts } from '@/lib/useKeyboardShortcuts'
import { fetchWikiPageByTitle } from '@/lib/wiki'
import { useUITranslation } from '@/lib/useUITranslation'
import { useHydrated } from '@/lib/useHydrated'
import type { FC } from 'react'
@@ -36,6 +37,7 @@ const parseLimit = (value: string | null): 20 | 50 | 100 | null => {
const PostListPage: FC = () => {
const locale = useLocale ()
const hydrated = useHydrated ()
const { message, label, siteTitle } = useUITranslation ('posts')
const containerRef = useRef<HTMLDivElement | null> (null)
@@ -52,7 +54,7 @@ const PostListPage: FC = () => {
const page = Number (query.get ('page') ?? 1)
const limit = (
parseLimit (query.get ('limit'))
?? getClientListLimit ('postList')
?? (hydrated ? getClientListLimit ('postList') : null)
?? DEFAULT_POST_LIST_LIMIT
)
const order = 'original_created_at:desc' as const
@@ -70,8 +72,10 @@ const PostListPage: FC = () => {
const totalPages = data ? Math.ceil (data.count / limit) : 0
useEffect (() => {
if (!hydrated)
return
setClientListSettings ('postList', { limit })
}, [limit])
}, [hydrated, limit])
const updatePage = (nextPage: number) => {
const params = new URLSearchParams (location.search)
+7 -3
ファイルの表示
@@ -31,6 +31,7 @@ import { useClientBehaviourSettings } from '@/lib/useClientBehaviourSettings'
import { useKeyboardShortcuts } from '@/lib/useKeyboardShortcuts'
import { localisedDateString, inputClass, originalCreatedAtString } from '@/lib/utils'
import { useUITranslation } from '@/lib/useUITranslation'
import { useHydrated } from '@/lib/useHydrated'
import type { FC, FormEvent } from 'react'
@@ -73,6 +74,7 @@ const parseOrder = (value: string | null): FetchPostsOrder | null => {
const PostSearchPage: FC = () => {
const locale = useLocale ()
const hydrated = useHydrated ()
const { message, label } = useUITranslation ('posts')
const behaviourSettings = useClientBehaviourSettings ()
const animationMode = behaviourSettings.animation ?? 'normal'
@@ -91,7 +93,7 @@ const PostSearchPage: FC = () => {
const page = Number (query.get ('page') ?? 1)
const limit = (
parseLimit (query.get ('limit'))
?? getClientListLimit ('postSearch')
?? (hydrated ? getClientListLimit ('postSearch') : null)
?? DEFAULT_POST_LIST_LIMIT
)
@@ -107,7 +109,7 @@ const PostSearchPage: FC = () => {
const qUpdatedTo = query.get ('updated_to') ?? ''
const order = (
parseOrder (query.get ('order'))
?? getClientListOrder<FetchPostsOrder> ('postSearch')
?? (hydrated ? getClientListOrder<FetchPostsOrder> ('postSearch') : null)
?? DEFAULT_POST_LIST_ORDER
)
@@ -140,8 +142,10 @@ const PostSearchPage: FC = () => {
const totalPages = data ? Math.ceil (data.count / limit) : 0
useEffect (() => {
if (!hydrated)
return
setClientListSettings ('postSearch', { limit, order })
}, [limit, order])
}, [hydrated, limit, order])
useEffect (() => {
setURL (qURL ?? '')
+13 -8
ファイルの表示
@@ -11,7 +11,8 @@ import MainArea from '@/components/layout/MainArea'
import PageHead from '@/components/common/PageHead'
import { toast } from '@/components/ui/use-toast'
import { CATEGORIES } from '@/consts'
import { apiPut } from '@/lib/api'
import { apiPut, isApiError } from '@/lib/api'
import NotFound from '@/pages/NotFound'
import { postsKeys, tagsKeys, wikiKeys } from '@/lib/queryKeys'
import { fetchTag } from '@/lib/tags'
import { cn, inputClass } from '@/lib/utils'
@@ -37,16 +38,17 @@ const TagDetailPage: FC = () => {
const tagId = String (id ?? '')
const tagKey = tagsKeys.show (tagId, locale)
const { data: tag, isLoading: loading } = useQuery ({
const { data: tag, isLoading: loading, error } = useQuery ({
queryKey: tagKey,
queryFn: () => fetchTag (tagId, locale) })
const [name, setName] = useState ('')
const [category, setCategory] = useState<Category> ('general')
const [aliases, setAliases] = useState ('')
const [parentTags, setParentTags] = useState ('')
const [deprecated, setDeprecated] = useState (false)
const [disabled, setDisabled] = useState (true)
const [name, setName] = useState (tag?.name ?? '')
const [category, setCategory] = useState<Category> (tag?.category ?? 'general')
const [aliases, setAliases] = useState (tag?.aliases.join (' ') ?? '')
const [parentTags, setParentTags] = useState (tag?.parents.map (entry => entry.name).join (' ')
?? '')
const [deprecated, setDeprecated] = useState (tag?.deprecatedAt != null)
const [disabled, setDisabled] = useState (tag == null || tag.category === 'nico')
const { baseErrors, fieldErrors, clearValidationErrors, applyValidationError } =
useValidationErrors<TagFormField> ()
@@ -100,6 +102,9 @@ const TagDetailPage: FC = () => {
setDisabled (tag.category === 'nico')
}, [tag])
if ((!loading && tag == null) || (isApiError (error) && error.response?.status === 404))
return <NotFound/>
return (
<MainArea>
<PageHead title={tag?.name ?? message ('title')}/>
+7 -3
ファイルの表示
@@ -27,6 +27,7 @@ import {
import { useClientBehaviourSettings } from '@/lib/useClientBehaviourSettings'
import { useKeyboardShortcuts } from '@/lib/useKeyboardShortcuts'
import { useUITranslation } from '@/lib/useUITranslation'
import { useHydrated } from '@/lib/useHydrated'
import { fetchTags } from '@/lib/tags'
import { inputClass, localisedDateString } from '@/lib/utils'
@@ -74,6 +75,7 @@ const parseOrder = (value: string | null): FetchTagsOrder | null => {
const TagListPage: FC = () => {
const locale = useLocale ()
const hydrated = useHydrated ()
const { message, label } = useUITranslation ('tags')
const behaviourSettings = useClientBehaviourSettings ()
const animationMode = behaviourSettings.animation ?? 'normal'
@@ -86,7 +88,7 @@ const TagListPage: FC = () => {
const page = Number (query.get ('page') ?? 1)
const limit = (
parseLimit (query.get ('limit'))
?? getClientListLimit ('tagList')
?? (hydrated ? getClientListLimit ('tagList') : null)
?? DEFAULT_POST_LIST_LIMIT
)
@@ -105,7 +107,7 @@ const TagListPage: FC = () => {
: null
const order = (
parseOrder (query.get ('order'))
?? getClientListOrder<FetchTagsOrder> ('tagList')
?? (hydrated ? getClientListOrder<FetchTagsOrder> ('tagList') : null)
?? DEFAULT_TAG_LIST_ORDER
)
@@ -138,8 +140,10 @@ const TagListPage: FC = () => {
const totalPages = data ? Math.ceil (data.count / limit) : 0
useEffect (() => {
if (!hydrated)
return
setClientListSettings ('tagList', { limit, order })
}, [limit, order])
}, [hydrated, limit, order])
useEffect (() => {
setName (qName)
+24 -29
ファイルの表示
@@ -1,4 +1,5 @@
import { motion } from 'framer-motion'
import { useQuery } from '@tanstack/react-query'
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { useParams } from 'react-router-dom'
@@ -19,6 +20,7 @@ import { apiDelete, apiGet, apiPatch, apiPost, apiPut, isApiError } from '@/lib/
import { clientAnimationTransition,
clientScrollBehaviour } from '@/lib/clientAnimation'
import { fetchPost } from '@/lib/posts'
import { theatresKeys } from '@/lib/queryKeys'
import {
getClientTheatreLayoutMode,
getClientTheatreTagFlow,
@@ -234,23 +236,30 @@ const TheatreDetailPage: FC<Props> = ({ user }: Props) => {
const [leftSidebarWidth, setLeftSidebarWidth] = useState (256)
const [rightSidebarWidth, setRightSidebarWidth] = useState (256)
const [sidebarContainerWidth, setSidebarContainerWidth] = useState (
() => typeof window === 'undefined' ? 0 : window.innerWidth)
const [sidebarContainerWidth, setSidebarContainerWidth] = useState (0)
const [comments, setComments] = useState<TheatreComment[]> ([])
const [content, setContent] = useState ('')
const [editingPost, setEditingPost] = useState<Post | null> (null)
const [loading, setLoading] = useState (false)
const [programmes, setProgrammes] = useState<TheatreProgramme[]> ([])
const { data: initialTheatre, error: theatreError } = useQuery ({
queryKey: theatresKeys.show (id ?? '', locale),
queryFn: () => apiGet<Theatre> (`/theatres/${ id }`, { locale }),
enabled: id != null })
const { data: initialProgrammes } = useQuery ({
queryKey: theatresKeys.programmes (id ?? '', locale),
queryFn: () => apiGet<TheatreProgramme[]> (`/theatres/${ id }/programmes`, {
locale, params: { limit: '100' } }), enabled: id != null })
const [programmes, setProgrammes] = useState<TheatreProgramme[]> (initialProgrammes ?? [])
const [sending, setSending] = useState (false)
const [status, setStatus] = useState (200)
const [theatre, setTheatre] = useState<Theatre | null> (null)
const status = isApiError (theatreError) ? theatreError.response?.status ?? 200 : 200
const [theatre, setTheatre] = useState<Theatre | null> (initialTheatre ?? null)
const [theatreInfo, setTheatreInfo] = useState<TheatreInfo> (INITIAL_THEATRE_INFO)
const [post, setPost] = useState<Post | null> (null)
const [videoLength, setVideoLength] = useState (0)
const [weights, setWeights] = useState<TheatrePostSelectionWeights> (INITIAL_WEIGHTS)
const [layoutMode, setLayoutMode] =
useState<TheatreLayoutMode> (() => getClientTheatreLayoutMode ())
const [tagFlow, setTagFlow] = useState<TagFlow> (() => getClientTheatreTagFlow ())
useState<TheatreLayoutMode> ('threeColumns')
const [tagFlow, setTagFlow] = useState<TagFlow> ('vertical')
const { fieldErrors, clearValidationErrors, applyValidationError } =
useValidationErrors<TheatreCommentField> ()
@@ -259,6 +268,11 @@ const TheatreDetailPage: FC<Props> = ({ user }: Props) => {
setClientTheatreLayoutMode (mode)
}
useEffect (() => {
setLayoutMode (getClientTheatreLayoutMode ())
setTagFlow (getClientTheatreTagFlow ())
}, [])
const changeTagFlow = (flow: TagFlow) => {
setTagFlow (flow)
setClientTheatreTagFlow (flow)
@@ -348,39 +362,20 @@ const TheatreDetailPage: FC<Props> = ({ user }: Props) => {
if (!(id))
return
let cancelled = false
setComments ([])
setEditingPost (null)
setPost (null)
setProgrammes ([])
setTheatre (null)
setProgrammes (initialProgrammes ?? [])
setTheatre (initialTheatre ?? null)
theatreInfoReceivedAtRef.current = performance.now ()
setTheatreInfo (INITIAL_THEATRE_INFO)
setVideoLength (0)
setWeights (INITIAL_WEIGHTS)
lastCommentNoRef.current = 0
void (async () => {
try
{
const data = await apiGet<Theatre> (`/theatres/${ id }`, { locale })
if (!(cancelled))
setTheatre (data)
}
catch (error)
{
setStatus (isApiError (error) ? error.response?.status ?? 200 : 200)
}
}) ()
void refreshProgrammes ()
void refreshWeights ()
return () => {
cancelled = true
}
}, [id, refreshProgrammes, refreshWeights, locale])
}, [id, initialProgrammes, initialTheatre, refreshWeights, locale])
useEffect (() => {
if (!(id))
+24 -12
ファイルの表示
@@ -1,4 +1,5 @@
import { useCallback, useEffect, useMemo, useState } from 'react'
import { useQuery } from '@tanstack/react-query'
import { useBeforeUnload, useLocation } from 'react-router-dom'
import { useLocale, useLocaleNavigate } from '@/lib/useLocale'
@@ -42,6 +43,8 @@ import { useKeyboardShortcutSettings, useKeyboardShortcuts } from '@/lib/useKeyb
import { cn, inputClass } from '@/lib/utils'
import { useValidationErrors } from '@/lib/useValidationErrors'
import { useUITranslation } from '@/lib/useUITranslation'
import { useHydrated } from '@/lib/useHydrated'
import { sessionKeys } from '@/lib/queryKeys'
import type { CSSProperties, Dispatch, FC, KeyboardEvent, ReactNode, SetStateAction } from 'react'
@@ -657,6 +660,13 @@ const ThemeSection: FC<ThemeSectionProps> = props => {
const SettingPage: FC<Props> = ({ user, setUser }) => {
const hydrated = useHydrated ()
const { data: initialSettings } = useQuery ({
queryKey: sessionKeys.settings (user?.id ?? 0),
queryFn: fetchUserSettings, enabled: user != null })
const { data: initialThemeSlots } = useQuery ({
queryKey: sessionKeys.themeSlots (user?.id ?? 0),
queryFn: fetchUserThemeSlots, enabled: user != null })
const { message, label } = useUITranslation ('settings')
const locale = useLocale ()
const location = useLocation ()
@@ -665,25 +675,27 @@ const SettingPage: FC<Props> = ({ user, setUser }) => {
const { registerUnsavedChangesSource } = useUnsavedChangesGuard ()
const [inheritVsbl, setInheritVsbl] = useState (false)
const [loading, setLoading] = useState (true)
const [name, setName] = useState ('')
const [loading, setLoading] = useState (initialSettings == null)
const [name, setName] = useState (user?.name ?? '')
const [settingsError, setSettingsError] = useState<string | null> (null)
const [savedThemeMode, setSavedThemeMode] =
useState<ClientThemeMode> (getClientThemeMode ())
useState<ClientThemeMode> (initialSettings?.theme ?? 'system')
const [draftThemeMode, setDraftThemeMode] =
useState<ClientThemeMode> (getClientThemeMode ())
useState<ClientThemeMode> (initialSettings?.theme ?? 'system')
const [savedActiveLightThemeSlotNo, setSavedActiveLightThemeSlotNo] =
useState<UserThemeSlotNo> (getClientActiveLightThemeSlotNo ())
useState<UserThemeSlotNo> (1)
const [draftActiveLightThemeSlotNo, setDraftActiveLightThemeSlotNo] =
useState<UserThemeSlotNo> (getClientActiveLightThemeSlotNo ())
useState<UserThemeSlotNo> (1)
const [savedActiveDarkThemeSlotNo, setSavedActiveDarkThemeSlotNo] =
useState<UserThemeSlotNo> (getClientActiveDarkThemeSlotNo ())
useState<UserThemeSlotNo> (1)
const [draftActiveDarkThemeSlotNo, setDraftActiveDarkThemeSlotNo] =
useState<UserThemeSlotNo> (getClientActiveDarkThemeSlotNo ())
useState<UserThemeSlotNo> (1)
const [savedThemeSlots, setSavedThemeSlots] =
useState<UserThemeSlotMap> (() => ensureCompleteThemeSlots ({ }))
useState<UserThemeSlotMap> (() => ensureCompleteThemeSlots (
normaliseUserThemeSlots (initialThemeSlots ?? [])))
const [draftThemeSlots, setDraftThemeSlots] =
useState<UserThemeSlotMap> (() => ensureCompleteThemeSlots ({ }))
useState<UserThemeSlotMap> (() => ensureCompleteThemeSlots (
normaliseUserThemeSlots (initialThemeSlots ?? [])))
const [dirtyStates, setDirtyStates] =
useState<Partial<Record<SettingsTab, SettingsTabDirtyState>>> ({ })
const [userCodeVsbl, setUserCodeVsbl] = useState (false)
@@ -701,10 +713,10 @@ const SettingPage: FC<Props> = ({ user, setUser }) => {
const activeTab = useMemo<SettingsTab> (() => parseTab (location.search), [location.search])
const displayedThemeSelection = useMemo (
() => resolveDisplayedTheme ({
themeMode: draftThemeMode,
themeMode: (!hydrated && draftThemeMode === 'system') ? 'light' : draftThemeMode,
activeLightThemeSlotNo: draftActiveLightThemeSlotNo,
activeDarkThemeSlotNo: draftActiveDarkThemeSlotNo }, draftThemeSlots),
[draftActiveDarkThemeSlotNo,
[hydrated, draftActiveDarkThemeSlotNo,
draftActiveLightThemeSlotNo,
draftThemeMode,
draftThemeSlots])
+6 -30
ファイルの表示
@@ -1,6 +1,6 @@
import type { FC } from 'react'
import { useEffect, useState } from 'react'
import { useQuery } from '@tanstack/react-query'
import { useLocation, useParams } from 'react-router-dom'
import { useLocale } from '@/lib/useLocale'
@@ -9,6 +9,7 @@ import PageTitle from '@/components/common/PageTitle'
import MainArea from '@/components/layout/MainArea'
import PageHead from '@/components/common/PageHead'
import { apiGet } from '@/lib/api'
import { wikiKeys } from '@/lib/queryKeys'
import { cn } from '@/lib/utils'
import type { WikiPageDiff } from '@/types'
@@ -21,43 +22,18 @@ const WikiDiffPage: FC = () => {
const location = useLocation ()
const [result, setResult] = useState<{
key: string
diff: WikiPageDiff } | null> (null)
const query = new URLSearchParams (location.search)
const from = query.get ('from')
const to = query.get ('to')
const requestKey = JSON.stringify ([locale, id, from, to])
const diff = result?.key === requestKey ? result.diff : null
const { data: diff } = useQuery ({
queryKey: wikiKeys.diff (id ?? '', { from, to }, locale),
queryFn: () => apiGet<WikiPageDiff> (
`/wiki/${ encodeURIComponent (id ?? '') }/diff`, { locale, params: { from, to } }) })
const displayTitle = diff
? `${ diff.title }${ diff.deprecatedAt != null
? message ('shared:labels.deprecatedSuffix').text : '' }`
: ''
useEffect (() => {
const controller = new AbortController ()
let active = true
void (async () => {
try
{
const diff = await apiGet<WikiPageDiff> (`/wiki/${ encodeURIComponent (id ?? '') }/diff`, {
locale, signal: controller.signal, params: { from, to } })
if (active)
setResult ({ key: requestKey, diff })
}
catch (error)
{
if (active)
console.error (error)
}
}) ()
return () => {
active = false
controller.abort ()
}
}, [from, id, to, locale, requestKey])
return (
<MainArea>
<PageHead title={message ('diffTitle', { title: displayTitle })}/>
+13 -34
ファイルの表示
@@ -1,4 +1,4 @@
import { useQueryClient } from '@tanstack/react-query'
import { useQuery, useQueryClient } from '@tanstack/react-query'
import MarkdownIt from 'markdown-it'
import { useEffect, useState } from 'react'
import MdEditor from 'react-markdown-editor-lite'
@@ -47,12 +47,15 @@ const WikiEditPage: FC<Props> = ({ user }) => {
const qc = useQueryClient ()
const [body, setBody] = useState ('')
const [loading, setLoading] = useState (true)
const [loadedSource, setLoadedSource] = useState ('')
const source = `${locale}:${id}`
const { data: wiki, isPending: loading } = useQuery ({
queryKey: wikiKeys.show (id ?? '', { }, locale),
queryFn: () => apiGet<WikiPage> (`/wiki/${ encodeURIComponent (id ?? '') }`, { locale }),
enabled: editable })
const [body, setBody] = useState (wiki?.body ?? '')
const [loadedSource, setLoadedSource] = useState (wiki == null ? '' : source)
const pending = loading || loadedSource !== source
const [title, setTitle] = useState ('')
const [title, setTitle] = useState (wiki?.title ?? '')
const { baseErrors, fieldErrors, clearValidationErrors, applyValidationError } =
useValidationErrors<WikiFormField> ()
@@ -85,36 +88,12 @@ const WikiEditPage: FC<Props> = ({ user }) => {
}
useEffect (() => {
if (!(editable))
if (!(editable) || wiki == null)
return
const controller = new AbortController ()
let active = true
void (async () => {
setLoading (true)
try
{
const data = await apiGet<WikiPage> (`/wiki/${ encodeURIComponent (id ?? '') }`, {
locale, signal: controller.signal })
if (active)
{
setTitle (data.title)
setBody (data.body)
setLoadedSource (source)
setLoading (false)
}
}
catch (error)
{
if (active)
console.error (error)
}
}) ()
return () => {
active = false
controller.abort ()
}
}, [editable, id, locale, source])
setTitle (wiki.title)
setBody (wiki.body)
setLoadedSource (source)
}, [editable, source, wiki])
if (!(editable))
return <Forbidden/>
+12 -9
ファイルの表示
@@ -1,4 +1,5 @@
import { useEffect, useState } from 'react'
import { useQuery } from '@tanstack/react-query'
import { useLocale } from '@/lib/useLocale'
import { useUITranslation } from '@/lib/useUITranslation'
@@ -8,6 +9,7 @@ import PageTitle from '@/components/common/PageTitle'
import MainArea from '@/components/layout/MainArea'
import PageHead from '@/components/common/PageHead'
import { apiGet } from '@/lib/api'
import { wikiKeys } from '@/lib/queryKeys'
import { dateString, inputClass } from '@/lib/utils'
import type { FormEvent , FC } from 'react'
@@ -20,21 +22,22 @@ const WikiSearchPage: FC = () => {
const locale = useLocale ()
const [title, setTitle] = useState ('')
const [text, setText] = useState ('')
const [results, setResults] = useState<WikiPage[]> ([])
const search = async () => {
setResults (await apiGet ('/wiki', { locale, params: { title } }))
}
const [submittedTitle, setSubmittedTitle] = useState ('')
const { data: results = [], refetch } = useQuery ({
queryKey: wikiKeys.index ({ title: submittedTitle }, locale),
queryFn: () => apiGet<WikiPage[]> ('/wiki', {
locale, params: { title: submittedTitle } }) })
const handleSearch = (ev: FormEvent) => {
ev.preventDefault ()
search ()
if (submittedTitle === title)
void refetch ()
else
setSubmittedTitle (title)
}
useEffect (() => {
void (async () => {
setResults (await apiGet ('/wiki', { locale, params: { title: '' } }))
}) ()
setSubmittedTitle ('')
}, [locale])
return (