このコミットが含まれているのは:
+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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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 =
|
||||
|
||||
@@ -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>)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 }
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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 } & (
|
||||
|
||||
@@ -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 = (
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
import { useSyncExternalStore } from 'react'
|
||||
|
||||
const subscribe = () => () => { }
|
||||
const clientSnapshot = () => true
|
||||
const serverSnapshot = () => false
|
||||
|
||||
|
||||
export const useHydrated = (): boolean =>
|
||||
useSyncExternalStore (subscribe, clientSnapshot, serverSnapshot)
|
||||
@@ -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'])
|
||||
|
||||
@@ -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>> (
|
||||
|
||||
@@ -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 })
|
||||
|
||||
@@ -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> ()
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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 ())
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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 ?? '')
|
||||
|
||||
@@ -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')}/>
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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])
|
||||
|
||||
@@ -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 })}/>
|
||||
|
||||
@@ -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/>
|
||||
|
||||
@@ -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 (
|
||||
|
||||
新しいイシューから参照
ユーザーをブロックする