このコミットが含まれているのは:
@@ -37,3 +37,56 @@ npm run test:run
|
|||||||
cd backend && bundle exec rspec
|
cd backend && bundle exec rspec
|
||||||
cd ../frontend && npm run test:run && npm run build && npm run lint
|
cd ../frontend && npm run test:run && npm run build && npm run lint
|
||||||
```
|
```
|
||||||
|
|
||||||
|
### Help SSR in production
|
||||||
|
|
||||||
|
Build both `dist/client` and `dist/server` with `npm run build`, deploy them
|
||||||
|
together, and restart the Node process. `npm run dev` and `npm run preview`
|
||||||
|
remain CSR servers; use `npm start` for SSR. The old sitemap generator is not
|
||||||
|
part of this build.
|
||||||
|
|
||||||
|
The following environment is a deployment proposal, not an applied VPS change:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
cd frontend
|
||||||
|
export NODE_ENV=production
|
||||||
|
export HOST=127.0.0.1
|
||||||
|
export PORT=5174
|
||||||
|
export PUBLIC_ORIGIN=https://hub.nizika.monster
|
||||||
|
export SSR_API_BASE_URL=http://127.0.0.1:3002
|
||||||
|
npm start
|
||||||
|
```
|
||||||
|
|
||||||
|
Run this command through the VPS's process supervisor. Port 5174 avoids the
|
||||||
|
Vite development port 5173. Rails remains on 127.0.0.1:3002. `PUBLIC_ORIGIN`
|
||||||
|
must match the public HTTPS origin, including any non-default port; redirect
|
||||||
|
alternate hostnames to that origin. `SSR_API_BASE_URL` configures server-side
|
||||||
|
public API requests only. Keep the browser's existing API configuration pointed
|
||||||
|
at the public `/api` endpoint.
|
||||||
|
|
||||||
|
Inside the existing Apache HTTPS virtual host, with `proxy`, `proxy_http` and
|
||||||
|
`headers` enabled, preserve the API rules before the catch-all frontend rule:
|
||||||
|
|
||||||
|
```apache
|
||||||
|
ProxyPreserveHost On
|
||||||
|
RequestHeader set X-Forwarded-Proto "https"
|
||||||
|
|
||||||
|
ProxyPass "/api/" "http://127.0.0.1:3002/"
|
||||||
|
ProxyPassReverse "/api/" "http://127.0.0.1:3002/"
|
||||||
|
|
||||||
|
ProxyPass "/" "http://127.0.0.1:5174/"
|
||||||
|
ProxyPassReverse "/" "http://127.0.0.1:5174/"
|
||||||
|
```
|
||||||
|
|
||||||
|
Retain existing TLS, authentication-header handling and any exact `/api` rule.
|
||||||
|
Replace the previous SPA catch-all rewrite instead of rewriting Help or missing
|
||||||
|
URLs to `index.html` before they reach Node. Do not override Node's 404/503
|
||||||
|
responses with an SPA fallback or cache its HTML. Hashed assets may be cached;
|
||||||
|
deployments must keep old hashed files available for already-open clients.
|
||||||
|
|
||||||
|
Only Help detail pages are server-rendered. Known CSR routes remain client
|
||||||
|
rendered, old known routes redirect to Japanese URLs, and unknown routes/assets
|
||||||
|
return 404. A failed Help preload keeps the readable server HTML, retries on
|
||||||
|
online/focus/pageshow, and permits one automatic reload per bundle and URL in
|
||||||
|
session storage. If storage is unavailable or the failure persists, the HTML
|
||||||
|
remains intact; native links and manual reload remain available.
|
||||||
|
|||||||
+9
-1
@@ -55,7 +55,15 @@ createServer (async (request, response) => {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
const result = await render (url.pathname + url.search, origin, apiBaseURL)
|
const result = await render (url.pathname + url.search, origin, apiBaseURL)
|
||||||
const html = result == null ? template : template
|
if (result?.location != null)
|
||||||
|
{
|
||||||
|
response.writeHead (result.status, { Location: result.location }).end ()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const html = result?.html == null
|
||||||
|
? template.replace ('<!-- helmet -->', result?.status === 404
|
||||||
|
? '<meta name="robots" content="noindex" data-rh="true">' : '')
|
||||||
|
: template
|
||||||
.replace ('lang="ja"', `lang="${result.locale}"`)
|
.replace ('lang="ja"', `lang="${result.locale}"`)
|
||||||
.replace (/<title>[\s\S]*?<\/title>/, '')
|
.replace (/<title>[\s\S]*?<\/title>/, '')
|
||||||
.replace (/<meta name="description"[^>]*>/, '')
|
.replace (/<meta name="description"[^>]*>/, '')
|
||||||
|
|||||||
+45
-44
@@ -4,11 +4,10 @@ import { Suspense, lazy, useEffect, useLayoutEffect, useMemo, useState } from 'r
|
|||||||
import { useTranslation } from 'react-i18next'
|
import { useTranslation } from 'react-i18next'
|
||||||
import { createBrowserRouter,
|
import { createBrowserRouter,
|
||||||
Navigate,
|
Navigate,
|
||||||
Route,
|
|
||||||
RouterProvider,
|
RouterProvider,
|
||||||
Routes,
|
|
||||||
useLocation,
|
useLocation,
|
||||||
useParams } from 'react-router-dom'
|
useParams,
|
||||||
|
useRoutes } from 'react-router-dom'
|
||||||
|
|
||||||
import DevModeWatermark from '@/components/DevModeWatermark'
|
import DevModeWatermark from '@/components/DevModeWatermark'
|
||||||
import RouteBlockerOverlay from '@/components/RouteBlockerOverlay'
|
import RouteBlockerOverlay from '@/components/RouteBlockerOverlay'
|
||||||
@@ -38,6 +37,7 @@ import HelpDetailPage from '@/pages/help/HelpDetailPage'
|
|||||||
import ServiceUnavailable from '@/pages/ServiceUnavailable'
|
import ServiceUnavailable from '@/pages/ServiceUnavailable'
|
||||||
|
|
||||||
import type { Dispatch, FC, ReactNode, SetStateAction } from 'react'
|
import type { Dispatch, FC, ReactNode, SetStateAction } from 'react'
|
||||||
|
import type { RouteObject } from 'react-router-dom'
|
||||||
|
|
||||||
import type { ClientAnimationMode } from '@/lib/settings'
|
import type { ClientAnimationMode } from '@/lib/settings'
|
||||||
import type { AvailableLocale } from '@/lib/locales'
|
import type { AvailableLocale } from '@/lib/locales'
|
||||||
@@ -92,51 +92,52 @@ const GekanatorRoute = ({ user }: { user: User | null }) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
export const pageRoutes = (
|
||||||
|
user: User | null,
|
||||||
|
setUser: Dispatch<SetStateAction<User | null>>,
|
||||||
|
): RouteObject[] => [
|
||||||
|
{ path: '', element: <LocaleRedirect to="/posts"/> },
|
||||||
|
{ path: 'posts', element: <PostListPage/> },
|
||||||
|
{ path: 'posts/new', element: <PostNewPage user={user}/> },
|
||||||
|
{ path: 'posts/search', element: <PostSearchPage/> },
|
||||||
|
{ path: 'posts/:id', element: <PostDetailRoute user={user}/> },
|
||||||
|
{ path: 'posts/changes', element: <PostHistoryPage/> },
|
||||||
|
{ path: 'tags', element: <TagListPage/> },
|
||||||
|
{ path: 'tags/:id', element: <TagDetailPage/> },
|
||||||
|
{ path: 'tags/:id/deerjikists', element: <DeerjikistDetailPage/> },
|
||||||
|
{ path: 'tags/nico', element: <NicoTagListPage user={user}/> },
|
||||||
|
{ path: 'nico/tags', element: <NicoTagListPage user={user}/> },
|
||||||
|
{ path: 'tags/changes', element: <TagHistoryPage/> },
|
||||||
|
{ path: 'theatres/:id', element: <TheatreDetailPage user={user}/> },
|
||||||
|
{ path: 'materials', element: <MaterialBasePage/>, children: [
|
||||||
|
{ index: true, element: <MaterialListPage/> },
|
||||||
|
{ path: 'changes', element: <MaterialHistoryPage/> },
|
||||||
|
{ path: 'new', element: <MaterialNewPage/> },
|
||||||
|
{ path: 'suppressions', element: <MaterialSyncSuppressionsPage/> },
|
||||||
|
{ 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/:id/diff', element: <WikiDiffPage/> },
|
||||||
|
{ path: 'wiki/changes', element: <WikiHistoryPage/> },
|
||||||
|
{ path: 'help', element: <HelpListPage/> },
|
||||||
|
{ path: 'help/changes', element: <HelpChangesPage/> },
|
||||||
|
{ path: 'help/:identifier/diff', element: <HelpDiffPage/> },
|
||||||
|
{ path: 'help/:identifier', element: <HelpDetailPage/> },
|
||||||
|
{ path: 'users/settings', element: <SettingPage user={user} setUser={setUser}/> },
|
||||||
|
{ path: 'settings', element: <LocaleRedirect to="/users/settings"/> },
|
||||||
|
{ path: 'tos', element: <TOSPage/> },
|
||||||
|
{ path: 'gekanator', element: <GekanatorRoute user={user}/> },
|
||||||
|
{ path: 'more', element: <MorePage/> },
|
||||||
|
{ path: '*', element: <NotFound/> }]
|
||||||
|
|
||||||
|
|
||||||
const RouteTransitionWrapper = ({ animationMode, user, setUser }: {
|
const RouteTransitionWrapper = ({ animationMode, user, setUser }: {
|
||||||
animationMode: ClientAnimationMode
|
animationMode: ClientAnimationMode
|
||||||
user: User | null
|
user: User | null
|
||||||
setUser: Dispatch<SetStateAction<User | null>> }) => {
|
setUser: Dispatch<SetStateAction<User | null>> }) => {
|
||||||
const location = useLocation ()
|
const routes = useRoutes (pageRoutes (user, setUser))
|
||||||
|
|
||||||
const routes = (
|
|
||||||
<Routes location={location}>
|
|
||||||
<Route path="" element={<LocaleRedirect to="/posts"/>}/>
|
|
||||||
<Route path="posts" element={<PostListPage/>}/>
|
|
||||||
<Route path="posts/new" element={<PostNewPage user={user}/>}/>
|
|
||||||
<Route path="posts/search" element={<PostSearchPage/>}/>
|
|
||||||
<Route path="posts/:id" element={<PostDetailRoute user={user}/>}/>
|
|
||||||
<Route path="posts/changes" element={<PostHistoryPage/>}/>
|
|
||||||
<Route path="tags" element={<TagListPage/>}/>
|
|
||||||
<Route path="tags/:id" element={<TagDetailPage/>}/>
|
|
||||||
<Route path="tags/:id/deerjikists" element={<DeerjikistDetailPage/>}/>
|
|
||||||
<Route path="tags/nico" element={<NicoTagListPage user={user}/>}/>
|
|
||||||
<Route path="nico/tags" element={<NicoTagListPage user={user}/>}/>
|
|
||||||
<Route path="tags/changes" element={<TagHistoryPage/>}/>
|
|
||||||
<Route path="theatres/:id" element={<TheatreDetailPage user={user}/>}/>
|
|
||||||
<Route path="materials" element={<MaterialBasePage/>}>
|
|
||||||
<Route index element={<MaterialListPage/>}/>
|
|
||||||
<Route path="changes" element={<MaterialHistoryPage/>}/>
|
|
||||||
<Route path="new" element={<MaterialNewPage/>}/>
|
|
||||||
<Route path="suppressions" element={<MaterialSyncSuppressionsPage/>}/>
|
|
||||||
<Route path=":id" element ={<MaterialDetailPage/>}/>
|
|
||||||
</Route>
|
|
||||||
<Route path="wiki" element={<WikiSearchPage/>}/>
|
|
||||||
<Route path="wiki/:title" element={<WikiDetailPage/>}/>
|
|
||||||
<Route path="wiki/new" element={<WikiNewPage user={user}/>}/>
|
|
||||||
<Route path="wiki/:id/edit" element={<WikiEditPage user={user}/>}/>
|
|
||||||
<Route path="wiki/:id/diff" element={<WikiDiffPage/>}/>
|
|
||||||
<Route path="wiki/changes" element={<WikiHistoryPage/>}/>
|
|
||||||
<Route path="help" element={<HelpListPage/>}/>
|
|
||||||
<Route path="help/changes" element={<HelpChangesPage/>}/>
|
|
||||||
<Route path="help/:identifier/diff" element={<HelpDiffPage/>}/>
|
|
||||||
<Route path="help/:identifier" element={<HelpDetailPage/>}/>
|
|
||||||
<Route path="users/settings" element={<SettingPage user={user} setUser={setUser}/>}/>
|
|
||||||
<Route path="settings" element={<LocaleRedirect to="/users/settings"/>}/>
|
|
||||||
<Route path="tos" element={<TOSPage/>}/>
|
|
||||||
<Route path="gekanator" element={<GekanatorRoute user={user}/>}/>
|
|
||||||
<Route path="more" element={<MorePage/>}/>
|
|
||||||
<Route path="*" element={<NotFound/>}/>
|
|
||||||
</Routes>)
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<AnimatePresence mode="wait" initial={animationMode !== 'off'}>
|
<AnimatePresence mode="wait" initial={animationMode !== 'off'}>
|
||||||
|
|||||||
+25
-10
@@ -2,13 +2,15 @@ import { QueryClient, QueryClientProvider, dehydrate } from '@tanstack/react-que
|
|||||||
import { renderToString } from 'react-dom/server'
|
import { renderToString } from 'react-dom/server'
|
||||||
import { HelmetProvider } from 'react-helmet-async'
|
import { HelmetProvider } from 'react-helmet-async'
|
||||||
import { I18nextProvider } from 'react-i18next'
|
import { I18nextProvider } from 'react-i18next'
|
||||||
|
import { matchRoutes } from 'react-router-dom'
|
||||||
import { createStaticHandler, createStaticRouter,
|
import { createStaticHandler, createStaticRouter,
|
||||||
StaticRouterProvider } from 'react-router-dom/server'
|
StaticRouterProvider } from 'react-router-dom/server'
|
||||||
|
|
||||||
import { AppFrame, appRoutes } from '@/App'
|
import { AppFrame, appRoutes, pageRoutes } from '@/App'
|
||||||
import { matchHelpDetail, prepareHelpPage } from '@/lib/help'
|
import { matchHelpDetail, prepareHelpPage } from '@/lib/help'
|
||||||
import { createUII18n } from '@/lib/i18n'
|
import { createUII18n } from '@/lib/i18n'
|
||||||
import { fetchLocales, isLocaleCode } from '@/lib/locales'
|
import { fetchLocales, isLocaleCode, legacyLocalePath,
|
||||||
|
unlocalisedPath } from '@/lib/locales'
|
||||||
import { localesKeys } from '@/lib/queryKeys'
|
import { localesKeys } from '@/lib/queryKeys'
|
||||||
import { withRenderContext } from '@/lib/renderContext.server'
|
import { withRenderContext } from '@/lib/renderContext.server'
|
||||||
|
|
||||||
@@ -21,17 +23,26 @@ type RenderedPage = {
|
|||||||
locale: string
|
locale: string
|
||||||
bootstrap: string }
|
bootstrap: string }
|
||||||
|
|
||||||
|
type RouteResponse = { status: 404 | 308; location?: string }
|
||||||
|
|
||||||
|
|
||||||
export const render = async (
|
export const render = async (
|
||||||
address: string,
|
address: string,
|
||||||
origin: string,
|
origin: string,
|
||||||
apiBaseURL: string,
|
apiBaseURL: string,
|
||||||
): Promise<RenderedPage | null> => {
|
): Promise<RenderedPage | RouteResponse | null> => {
|
||||||
const url = new URL (address, origin)
|
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 = matchRoutes (pageRoutes (null, () => { })
|
||||||
|
.filter (route => route.path !== '*'), unlocalisedPath (pathname))
|
||||||
|
if (!(isLocaleCode (segment)) || known == null)
|
||||||
|
return { status: 404 }
|
||||||
|
if (legacy != null)
|
||||||
|
return { status: 308, location: legacy }
|
||||||
const match = matchHelpDetail (url.pathname)
|
const match = matchHelpDetail (url.pathname)
|
||||||
if (match == null)
|
const locale = segment
|
||||||
return null
|
|
||||||
const locale = isLocaleCode (match[1]) ? match[1] : 'ja'
|
|
||||||
return withRenderContext ({ pathname: url.pathname, origin, apiBaseURL }, async () => {
|
return withRenderContext ({ pathname: url.pathname, origin, apiBaseURL }, async () => {
|
||||||
const client = new QueryClient ({ defaultOptions: {
|
const client = new QueryClient ({ defaultOptions: {
|
||||||
queries: { retry: false, gcTime: Infinity } } })
|
queries: { retry: false, gcTime: Infinity } } })
|
||||||
@@ -39,9 +50,12 @@ export const render = async (
|
|||||||
{
|
{
|
||||||
const locales = await client.fetchQuery ({
|
const locales = await client.fetchQuery ({
|
||||||
queryKey: localesKeys.index, queryFn: fetchLocales })
|
queryKey: localesKeys.index, queryFn: fetchLocales })
|
||||||
const active = isLocaleCode (match[1])
|
const active = locales.locales.some (entry => entry.code === locale)
|
||||||
&& locales.locales.some (entry => entry.code === match[1])
|
if (!(active))
|
||||||
const status = active ? await prepareHelpPage (client, url) : 404
|
return { status: 404 as const }
|
||||||
|
if (match == null || known.at (-1)?.route.path !== 'help/:identifier')
|
||||||
|
return null
|
||||||
|
const status = await prepareHelpPage (client, url)
|
||||||
const instance = createUII18n (locale)
|
const instance = createUII18n (locale)
|
||||||
const handler = createStaticHandler (appRoutes)
|
const handler = createStaticHandler (appRoutes)
|
||||||
const context = await handler.query (new Request (url))
|
const context = await handler.query (new Request (url))
|
||||||
@@ -66,7 +80,8 @@ export const render = async (
|
|||||||
query.state.status === 'success'
|
query.state.status === 'success'
|
||||||
&& (query.queryKey[0] === 'locales'
|
&& (query.queryKey[0] === 'locales'
|
||||||
|| (query.queryKey[0] === 'help' && query.queryKey[1] === 'article')) })
|
|| (query.queryKey[0] === 'help' && query.queryKey[1] === 'article')) })
|
||||||
const bootstrap = JSON.stringify ({ state, locale, url: url.pathname + url.search })
|
const bootstrap = JSON.stringify ({ state, status, locale,
|
||||||
|
url: url.pathname + url.search })
|
||||||
.replace (/</g, '\\u003c').replace (/\u2028/g, '\\u2028')
|
.replace (/</g, '\\u003c').replace (/\u2028/g, '\\u2028')
|
||||||
.replace (/\u2029/g, '\\u2029')
|
.replace (/\u2029/g, '\\u2029')
|
||||||
return { status, html, head, locale, bootstrap }
|
return { status, html, head, locale, bootstrap }
|
||||||
|
|||||||
+75
-11
@@ -8,6 +8,7 @@ import App from '@/App'
|
|||||||
import { createUII18n } from '@/lib/i18n'
|
import { createUII18n } from '@/lib/i18n'
|
||||||
import { prepareHelpPage } from '@/lib/help'
|
import { prepareHelpPage } from '@/lib/help'
|
||||||
import { currentViewingLocale } from '@/lib/locales'
|
import { currentViewingLocale } from '@/lib/locales'
|
||||||
|
import { localesKeys } from '@/lib/queryKeys'
|
||||||
import { applyClientAnimationMode, getClientAnimationMode } from '@/lib/settings'
|
import { applyClientAnimationMode, getClientAnimationMode } from '@/lib/settings'
|
||||||
|
|
||||||
import type { DehydratedState } from '@tanstack/react-query'
|
import type { DehydratedState } from '@tanstack/react-query'
|
||||||
@@ -21,14 +22,7 @@ const client = new QueryClient ({
|
|||||||
retry: 1 } } })
|
retry: 1 } } })
|
||||||
|
|
||||||
|
|
||||||
const start = async () => {
|
const mount = (serverRendered: boolean) => {
|
||||||
const bootstrap = document.getElementById ('ssr-state')
|
|
||||||
if (bootstrap?.textContent)
|
|
||||||
{
|
|
||||||
const data = JSON.parse (bootstrap.textContent) as { state: DehydratedState }
|
|
||||||
hydrate (client, data.state)
|
|
||||||
await prepareHelpPage (client, new URL (window.location.href))
|
|
||||||
}
|
|
||||||
const instance = createUII18n (currentViewingLocale ())
|
const instance = createUII18n (currentViewingLocale ())
|
||||||
const app = (
|
const app = (
|
||||||
<HelmetProvider context={helmetContext}>
|
<HelmetProvider context={helmetContext}>
|
||||||
@@ -39,12 +33,82 @@ const start = async () => {
|
|||||||
</QueryClientProvider>
|
</QueryClientProvider>
|
||||||
</HelmetProvider>)
|
</HelmetProvider>)
|
||||||
const root = document.getElementById ('root')!
|
const root = document.getElementById ('root')!
|
||||||
if (bootstrap != null)
|
if (serverRendered)
|
||||||
hydrateRoot (root, app)
|
hydrateRoot (root, app)
|
||||||
else
|
else
|
||||||
createRoot (root).render (app)
|
createRoot (root).render (app)
|
||||||
applyClientAnimationMode (getClientAnimationMode ())
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
void start ()
|
const start = () => {
|
||||||
|
applyClientAnimationMode (getClientAnimationMode ())
|
||||||
|
const bootstrap = document.getElementById ('ssr-state')
|
||||||
|
if (bootstrap == null)
|
||||||
|
{
|
||||||
|
mount (false)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const storageKey = 'btrc_hub.ssr-recovery'
|
||||||
|
const identity = `${import.meta.url}:${location.pathname}${location.search}`
|
||||||
|
let pending = false
|
||||||
|
let mounted = false
|
||||||
|
const retry = () => { void attempt () }
|
||||||
|
const attempt = async () => {
|
||||||
|
if (pending || mounted)
|
||||||
|
return
|
||||||
|
pending = true
|
||||||
|
try
|
||||||
|
{
|
||||||
|
const data = JSON.parse (bootstrap.textContent ?? '') as {
|
||||||
|
state: DehydratedState; status: number }
|
||||||
|
if (data?.state == null || !(Array.isArray (data.state.queries)))
|
||||||
|
throw new Error ('Invalid SSR query state')
|
||||||
|
hydrate (client, data.state)
|
||||||
|
if (client.getQueryData (localesKeys.index) == null)
|
||||||
|
throw new Error ('Missing SSR locales')
|
||||||
|
const status = await prepareHelpPage (client, new URL (window.location.href))
|
||||||
|
if (status !== data.status)
|
||||||
|
throw new Error ('Server and browser Help versions differ')
|
||||||
|
mount (true)
|
||||||
|
mounted = true
|
||||||
|
for (const event of ['online', 'focus', 'pageshow'])
|
||||||
|
window.removeEventListener (event, retry)
|
||||||
|
try
|
||||||
|
{
|
||||||
|
sessionStorage.removeItem (storageKey)
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
// Storage may be unavailable; successful hydration needs no recovery marker.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (error)
|
||||||
|
{
|
||||||
|
console.error ('[SSR] Preparation failed; keeping server HTML', error)
|
||||||
|
// A new document also clears failed dynamic-import module caches.
|
||||||
|
// Never clear the root or attempt CSR over unmatched server markup.
|
||||||
|
try
|
||||||
|
{
|
||||||
|
if (navigator.onLine && sessionStorage.getItem (storageKey) !== identity)
|
||||||
|
{
|
||||||
|
sessionStorage.setItem (storageKey, identity)
|
||||||
|
window.location.reload ()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
// Keep the readable document and retry on online/focus/pageshow instead.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
pending = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for (const event of ['online', 'focus', 'pageshow'])
|
||||||
|
window.addEventListener (event, retry)
|
||||||
|
retry ()
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
start ()
|
||||||
|
|||||||
@@ -19,7 +19,7 @@ import { postsKeys, tagsKeys, wikiKeys } from '@/lib/queryKeys'
|
|||||||
import { fetchTagChanges } from '@/lib/tags'
|
import { fetchTagChanges } from '@/lib/tags'
|
||||||
import { useClientBehaviourSettings } from '@/lib/useClientBehaviourSettings'
|
import { useClientBehaviourSettings } from '@/lib/useClientBehaviourSettings'
|
||||||
import { useUITranslation } from '@/lib/useUITranslation'
|
import { useUITranslation } from '@/lib/useUITranslation'
|
||||||
import { cn, localisedDateString } from '@/lib/utils'
|
import { localisedDateString } from '@/lib/utils'
|
||||||
|
|
||||||
import type { FC, ReactNode } from 'react'
|
import type { FC, ReactNode } from 'react'
|
||||||
|
|
||||||
@@ -79,25 +79,23 @@ const renderPrimaryName = (
|
|||||||
</>)
|
</>)
|
||||||
|
|
||||||
|
|
||||||
const renderNameSnapshot = (
|
const renderAliasDiff = (
|
||||||
snapshot: TagNameSnapshot,
|
previous: TagNameSnapshot | null,
|
||||||
|
current: TagNameSnapshot | null,
|
||||||
language: string,
|
language: string,
|
||||||
label: (key: string) => ReactNode,
|
) => {
|
||||||
) => (
|
const before = new Set (previous?.aliases.map (alias => alias.name) ?? [])
|
||||||
<div className="space-y-1">
|
const after = new Set (current?.aliases.map (alias => alias.name) ?? [])
|
||||||
<div>
|
return [...new Set ([...after, ...before])].map (name => {
|
||||||
{label ('name')}:
|
const content = (
|
||||||
{renderPrimaryName (snapshot, language, label)}
|
<span lang={language} className="inline-block whitespace-nowrap">{name}</span>)
|
||||||
</div>
|
if (!(before.has (name)))
|
||||||
{snapshot.aliases.length > 0 && (
|
return <ins key={name} className="mr-2 text-green-600 dark:text-green-400">{content}</ins>
|
||||||
<div>
|
if (!(after.has (name)))
|
||||||
{label ('tags:aliases')}:
|
return <del key={name} className="mr-2 text-red-600 dark:text-red-400">{content}</del>
|
||||||
{snapshot.aliases.map (alias => (
|
return <span key={name} className="mr-2">{content}</span>
|
||||||
<span key={`${alias.name}:${alias.scriptCode}`} className="mr-2">
|
})
|
||||||
<span lang={language} className="inline-block whitespace-nowrap">{alias.name}</span>
|
}
|
||||||
</span>))}
|
|
||||||
</div>)}
|
|
||||||
</div>)
|
|
||||||
|
|
||||||
|
|
||||||
const renderDiff = <T,> (
|
const renderDiff = <T,> (
|
||||||
@@ -120,7 +118,9 @@ const renderDiff = <T,> (
|
|||||||
</>)
|
</>)
|
||||||
|
|
||||||
|
|
||||||
const TagVersionNames = ({ change }: { change: TagVersion }) => {
|
const TagVersionRows = ({ change, children }: {
|
||||||
|
change: TagVersion
|
||||||
|
children: (primary: ReactNode, toggle: ReactNode) => ReactNode }) => {
|
||||||
const locale = useLocale ()
|
const locale = useLocale ()
|
||||||
const language = localeLanguage (locale)
|
const language = localeLanguage (locale)
|
||||||
const { message, label } = useUITranslation ('history')
|
const { message, label } = useUITranslation ('history')
|
||||||
@@ -149,41 +149,52 @@ const TagVersionNames = ({ change }: { change: TagVersion }) => {
|
|||||||
...nameChanges.map (entry => entry.languageCode)])]
|
...nameChanges.map (entry => entry.languageCode)])]
|
||||||
.filter (code => code !== language).sort ()
|
.filter (code => code !== language).sort ()
|
||||||
const otherChanges = nameChanges.filter (entry => entry.languageCode !== language)
|
const otherChanges = nameChanges.filter (entry => entry.languageCode !== language)
|
||||||
|
const rowIds = otherLanguages.map ((_, index) => `${panelId}-${index}`)
|
||||||
|
const primaryEqual = (prev: TagNameSnapshot | null, next: TagNameSnapshot | null) =>
|
||||||
|
prev?.name === next?.name && prev?.autoGenerated === next?.autoGenerated
|
||||||
|
const primary = renderDiff ({ current, prev: previous },
|
||||||
|
snapshot => renderPrimaryName (snapshot, language, label), primaryEqual)
|
||||||
|
const toggle = otherLanguages.length > 0 && (
|
||||||
|
<div className="mt-2 text-sm">
|
||||||
|
<button type="button" className="text-left"
|
||||||
|
aria-expanded={expanded} aria-controls={rowIds.join (' ')}
|
||||||
|
onClick={() => setExpanded (value => !value)}>
|
||||||
|
{label ('nameChanges.otherLanguages')}
|
||||||
|
{otherChanges.length > 0 && label ('nameChanges.changed')}
|
||||||
|
<span aria-hidden="true" className="ml-1">{expanded ? '▲' : '▼'}</span>
|
||||||
|
</button>
|
||||||
|
</div>)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<tbody className="even:bg-gray-100 even:text-gray-900
|
||||||
{renderDiff ({ current, prev: previous },
|
dark:even:bg-gray-700 dark:even:text-gray-100">
|
||||||
snapshot => renderPrimaryName (snapshot, language, label),
|
{children (primary, toggle)}
|
||||||
(prev, next) => prev?.name === next?.name
|
{otherLanguages.map ((code, index) => {
|
||||||
&& prev?.autoGenerated === next?.autoGenerated)}
|
const entry = nameChanges.find (value => value.languageCode === code)
|
||||||
{otherLanguages.length > 0 && (
|
const key = `languageNames.${code}`
|
||||||
<div className="mt-2 text-sm">
|
const known = i18n.exists (key, { ns: 'history', lng: locale })
|
||||||
<button type="button" className="text-left underline"
|
const next = entry != null ? entry.current : names[code] ?? null
|
||||||
aria-expanded={expanded} aria-controls={panelId}
|
const prev = entry != null ? entry.prev : next
|
||||||
onClick={() => setExpanded (value => !value)}>
|
return (
|
||||||
{label ('nameChanges.otherLanguages')}
|
<tr key={code} id={rowIds[index]} hidden={!expanded}>
|
||||||
{otherChanges.length > 0 && label ('nameChanges.changed')}
|
<th scope="row" className="p-2 text-left text-sm font-normal">
|
||||||
</button>
|
<LocalisedText language={known ? code : 'und'}>
|
||||||
<div id={panelId} hidden={!expanded} className="mt-2 space-y-2">
|
{known ? message (key).text : code}
|
||||||
{otherLanguages.map (code => {
|
</LocalisedText>
|
||||||
const entry = nameChanges.find (value => value.languageCode === code)
|
</th>
|
||||||
const key = `languageNames.${code}`
|
<td className="p-2">
|
||||||
const known = i18n.exists (key, { ns: 'history', lng: locale })
|
{renderDiff ({ current: next, prev },
|
||||||
const snapshot = names[code]
|
value => renderPrimaryName (value, code, label), primaryEqual)}
|
||||||
return (
|
</td>
|
||||||
<div key={code}>
|
<td/>
|
||||||
<LocalisedText language={known ? code : 'und'} className="font-semibold">
|
<td/>
|
||||||
{known ? message (key).text : code}
|
<td className="p-2">{renderAliasDiff (prev, next, code)}</td>
|
||||||
</LocalisedText>
|
<td/>
|
||||||
{entry != null
|
<td/>
|
||||||
? renderDiff (entry,
|
<td/>
|
||||||
value => renderNameSnapshot (value, code, label))
|
</tr>)
|
||||||
: snapshot != null && renderNameSnapshot (snapshot, code, label)}
|
})}
|
||||||
</div>)
|
</tbody>)
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
</div>)}
|
|
||||||
</>)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -261,136 +272,137 @@ const TagHistoryPage: FC = () => {
|
|||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
|
|
||||||
<tbody>
|
{changes.map (change => (
|
||||||
{changes.map (change => (
|
<TagVersionRows key={`${ change.tagId }.${ change.versionNo }`} change={change}>
|
||||||
<tr key={`${ change.tagId }.${ change.versionNo }`}
|
{(primary, toggle) => (
|
||||||
className={cn ('even:bg-gray-100 dark:even:bg-gray-700')}>
|
<tr>
|
||||||
<td className="p-2">{change.tagId}.{change.versionNo}</td>
|
<td className="p-2">{change.tagId}.{change.versionNo}</td>
|
||||||
<td className="p-2 break-all">
|
<td className="p-2 break-all">
|
||||||
<TagVersionNames change={change}/>
|
{primary}
|
||||||
</td>
|
{toggle}
|
||||||
<td className="p-2 break-all">
|
</td>
|
||||||
{renderDiff (change.category,
|
<td className="p-2 break-all">
|
||||||
category => label (`common:categories.${category}`))}
|
{renderDiff (change.category,
|
||||||
</td>
|
category => label (`common:categories.${category}`))}
|
||||||
<td className="p-2 break-all">
|
</td>
|
||||||
{renderDiff ({
|
<td className="p-2 break-all">
|
||||||
current: change.deprecatedAt.current ? 'deprecated' : '',
|
{renderDiff ({
|
||||||
prev: change.deprecatedAt.prev ? 'deprecated' : '' },
|
current: change.deprecatedAt.current ? 'deprecated' : '',
|
||||||
state => state ? label (`common:states.${state}`) : '')}
|
prev: change.deprecatedAt.prev ? 'deprecated' : '' },
|
||||||
</td>
|
state => state ? label (`common:states.${state}`) : '')}
|
||||||
<td className="p-2">
|
</td>
|
||||||
{change.aliases.map ((tag, i) => (
|
<td className="p-2">
|
||||||
tag.type === 'added'
|
{change.aliases.map ((tag, i) => (
|
||||||
? (
|
tag.type === 'added'
|
||||||
<ins
|
|
||||||
key={i}
|
|
||||||
className="mr-2 text-green-600 dark:text-green-400">
|
|
||||||
{tag.name}
|
|
||||||
</ins>)
|
|
||||||
: (
|
|
||||||
tag.type === 'removed'
|
|
||||||
? (
|
? (
|
||||||
<del
|
<ins
|
||||||
key={i}
|
key={i}
|
||||||
className="mr-2 text-red-600 dark:text-red-400">
|
className="mr-2 text-green-600 dark:text-green-400">
|
||||||
{tag.name}
|
{tag.name}
|
||||||
</del>)
|
</ins>)
|
||||||
: (
|
: (
|
||||||
<span key={i} className="mr-2">
|
tag.type === 'removed'
|
||||||
{tag.name}
|
? (
|
||||||
</span>))))}
|
<del
|
||||||
</td>
|
key={i}
|
||||||
<td className="p-2">
|
className="mr-2 text-red-600 dark:text-red-400">
|
||||||
{change.parentTags.map ((tag, i) => (
|
{tag.name}
|
||||||
tag.type === 'added'
|
</del>)
|
||||||
? (
|
: (
|
||||||
<ins
|
<span key={i} className="mr-2">
|
||||||
key={i}
|
{tag.name}
|
||||||
className="mr-2 text-green-600 dark:text-green-400">
|
</span>))))}
|
||||||
{tag.tag.name}
|
</td>
|
||||||
</ins>)
|
<td className="p-2">
|
||||||
: (
|
{change.parentTags.map ((tag, i) => (
|
||||||
tag.type === 'removed'
|
tag.type === 'added'
|
||||||
? (
|
? (
|
||||||
<del
|
<ins
|
||||||
key={i}
|
key={i}
|
||||||
className="mr-2 text-red-600 dark:text-red-400">
|
className="mr-2 text-green-600 dark:text-green-400">
|
||||||
{tag.tag.name}
|
{tag.tag.name}
|
||||||
</del>)
|
</ins>)
|
||||||
: (
|
: (
|
||||||
<span key={i} className="mr-2">
|
tag.type === 'removed'
|
||||||
{tag.tag.name}
|
? (
|
||||||
</span>))))}
|
<del
|
||||||
</td>
|
key={i}
|
||||||
<td className="p-2">
|
className="mr-2 text-red-600 dark:text-red-400">
|
||||||
{change.createdByUser
|
{tag.tag.name}
|
||||||
? (
|
</del>)
|
||||||
<PrefetchLink to={`/users/${ change.createdByUser.id }`}>
|
: (
|
||||||
{change.createdByUser.name
|
<span key={i} className="mr-2">
|
||||||
|| label ('anonymous', { id: change.createdByUser.id })}
|
{tag.tag.name}
|
||||||
</PrefetchLink>)
|
</span>))))}
|
||||||
: label ('bot')}
|
</td>
|
||||||
<br/>
|
<td className="p-2">
|
||||||
{localisedDateString (change.createdAt, locale)}
|
{change.createdByUser
|
||||||
</td>
|
? (
|
||||||
<td className="p-2">
|
<PrefetchLink to={`/users/${ change.createdByUser.id }`}>
|
||||||
<a
|
{change.createdByUser.name
|
||||||
href="#"
|
|| label ('anonymous', { id: change.createdByUser.id })}
|
||||||
onClick={async e => {
|
</PrefetchLink>)
|
||||||
e.preventDefault ()
|
: label ('bot')}
|
||||||
|
<br/>
|
||||||
|
{localisedDateString (change.createdAt, locale)}
|
||||||
|
</td>
|
||||||
|
<td className="p-2">
|
||||||
|
<a
|
||||||
|
href="#"
|
||||||
|
onClick={async e => {
|
||||||
|
e.preventDefault ()
|
||||||
|
|
||||||
if (!(await dialogue.confirm ({
|
if (!(await dialogue.confirm ({
|
||||||
title: label ('confirmTitle'),
|
title: label ('confirmTitle'),
|
||||||
description: (
|
description: (
|
||||||
<>
|
<>
|
||||||
{label ('confirmTag', {
|
{label ('confirmTag', {
|
||||||
name: change.name.current ?? '', version: change.versionNo })}
|
name: change.name.current ?? '', version: change.versionNo })}
|
||||||
<br/>
|
<br/>
|
||||||
{label ('restoreScope')}
|
{label ('restoreScope')}
|
||||||
</>),
|
</>),
|
||||||
confirmText: label ('revert'),
|
confirmText: label ('revert'),
|
||||||
cancelText: label ('settings:actions.cancel'),
|
cancelText: label ('settings:actions.cancel'),
|
||||||
closeLabel: label ('settings:actions.close') })))
|
closeLabel: label ('settings:actions.close') })))
|
||||||
return
|
return
|
||||||
|
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
if (!(change.name.current)
|
if (!(change.name.current)
|
||||||
|| change.parentTags.some (tag =>
|
|| change.parentTags.some (tag =>
|
||||||
tag.type !== 'removed' && !(tag.tag.name)))
|
tag.type !== 'removed' && !(tag.tag.name)))
|
||||||
throw new Error ()
|
throw new Error ()
|
||||||
await apiPut (
|
await apiPut (
|
||||||
`/tags/${ change.tagId }`,
|
`/tags/${ change.tagId }`,
|
||||||
{ name: change.name.current,
|
{ name: change.name.current,
|
||||||
category: change.category.current,
|
category: change.category.current,
|
||||||
deprecated: change.deprecatedAt.current ? '1' : '0',
|
deprecated: change.deprecatedAt.current ? '1' : '0',
|
||||||
aliases:
|
aliases:
|
||||||
change.aliases
|
change.aliases
|
||||||
.filter (t => t.type !== 'removed')
|
.filter (t => t.type !== 'removed')
|
||||||
.map (t => t.name)
|
.map (t => t.name)
|
||||||
.join (' '),
|
.join (' '),
|
||||||
parent_tags:
|
parent_tags:
|
||||||
change.parentTags
|
change.parentTags
|
||||||
.filter (t => t.type !== 'removed')
|
.filter (t => t.type !== 'removed')
|
||||||
.map (t => t.tag.name)
|
.map (t => t.tag.name)
|
||||||
.join (' ') }, { locale })
|
.join (' ') }, { locale })
|
||||||
|
|
||||||
qc.invalidateQueries ({ queryKey: postsKeys.root })
|
qc.invalidateQueries ({ queryKey: postsKeys.root })
|
||||||
qc.invalidateQueries ({ queryKey: tagsKeys.root })
|
qc.invalidateQueries ({ queryKey: tagsKeys.root })
|
||||||
qc.invalidateQueries ({ queryKey: wikiKeys.root })
|
qc.invalidateQueries ({ queryKey: wikiKeys.root })
|
||||||
toast ({ description: label ('restored') })
|
toast ({ description: label ('restored') })
|
||||||
}
|
}
|
||||||
catch
|
catch
|
||||||
{
|
{
|
||||||
toast ({ description: label ('restoreFailed') })
|
toast ({ description: label ('restoreFailed') })
|
||||||
}
|
}
|
||||||
}}>
|
}}>
|
||||||
{label ('shared:actions.restore')}
|
{label ('shared:actions.restore')}
|
||||||
</a>
|
</a>
|
||||||
</td>
|
</td>
|
||||||
</tr>))}
|
</tr>)}
|
||||||
</tbody>
|
</TagVersionRows>))}
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
新しいイシューから参照
ユーザーをブロックする