このコミットが含まれているのは:
@@ -37,3 +37,56 @@ npm run test:run
|
||||
cd backend && bundle exec rspec
|
||||
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
|
||||
}
|
||||
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 (/<title>[\s\S]*?<\/title>/, '')
|
||||
.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 { createBrowserRouter,
|
||||
Navigate,
|
||||
Route,
|
||||
RouterProvider,
|
||||
Routes,
|
||||
useLocation,
|
||||
useParams } from 'react-router-dom'
|
||||
useParams,
|
||||
useRoutes } from 'react-router-dom'
|
||||
|
||||
import DevModeWatermark from '@/components/DevModeWatermark'
|
||||
import RouteBlockerOverlay from '@/components/RouteBlockerOverlay'
|
||||
@@ -38,6 +37,7 @@ import HelpDetailPage from '@/pages/help/HelpDetailPage'
|
||||
import ServiceUnavailable from '@/pages/ServiceUnavailable'
|
||||
|
||||
import type { Dispatch, FC, ReactNode, SetStateAction } from 'react'
|
||||
import type { RouteObject } from 'react-router-dom'
|
||||
|
||||
import type { ClientAnimationMode } from '@/lib/settings'
|
||||
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 }: {
|
||||
animationMode: ClientAnimationMode
|
||||
user: User | null
|
||||
setUser: Dispatch<SetStateAction<User | null>> }) => {
|
||||
const location = useLocation ()
|
||||
|
||||
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>)
|
||||
const routes = useRoutes (pageRoutes (user, setUser))
|
||||
|
||||
return (
|
||||
<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 { HelmetProvider } from 'react-helmet-async'
|
||||
import { I18nextProvider } from 'react-i18next'
|
||||
import { matchRoutes } from 'react-router-dom'
|
||||
import { createStaticHandler, createStaticRouter,
|
||||
StaticRouterProvider } from 'react-router-dom/server'
|
||||
|
||||
import { AppFrame, appRoutes } from '@/App'
|
||||
import { AppFrame, appRoutes, pageRoutes } from '@/App'
|
||||
import { matchHelpDetail, prepareHelpPage } from '@/lib/help'
|
||||
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 { withRenderContext } from '@/lib/renderContext.server'
|
||||
|
||||
@@ -21,17 +23,26 @@ type RenderedPage = {
|
||||
locale: string
|
||||
bootstrap: string }
|
||||
|
||||
type RouteResponse = { status: 404 | 308; location?: string }
|
||||
|
||||
|
||||
export const render = async (
|
||||
address: string,
|
||||
origin: string,
|
||||
apiBaseURL: string,
|
||||
): Promise<RenderedPage | null> => {
|
||||
): Promise<RenderedPage | RouteResponse | null> => {
|
||||
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)
|
||||
if (match == null)
|
||||
return null
|
||||
const locale = isLocaleCode (match[1]) ? match[1] : 'ja'
|
||||
const locale = segment
|
||||
return withRenderContext ({ pathname: url.pathname, origin, apiBaseURL }, async () => {
|
||||
const client = new QueryClient ({ defaultOptions: {
|
||||
queries: { retry: false, gcTime: Infinity } } })
|
||||
@@ -39,9 +50,12 @@ export const render = async (
|
||||
{
|
||||
const locales = await client.fetchQuery ({
|
||||
queryKey: localesKeys.index, queryFn: fetchLocales })
|
||||
const active = isLocaleCode (match[1])
|
||||
&& locales.locales.some (entry => entry.code === match[1])
|
||||
const status = active ? await prepareHelpPage (client, url) : 404
|
||||
const active = locales.locales.some (entry => entry.code === locale)
|
||||
if (!(active))
|
||||
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 handler = createStaticHandler (appRoutes)
|
||||
const context = await handler.query (new Request (url))
|
||||
@@ -66,7 +80,8 @@ export const render = async (
|
||||
query.state.status === 'success'
|
||||
&& (query.queryKey[0] === 'locales'
|
||||
|| (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 (/\u2029/g, '\\u2029')
|
||||
return { status, html, head, locale, bootstrap }
|
||||
|
||||
+75
-11
@@ -8,6 +8,7 @@ import App from '@/App'
|
||||
import { createUII18n } from '@/lib/i18n'
|
||||
import { prepareHelpPage } from '@/lib/help'
|
||||
import { currentViewingLocale } from '@/lib/locales'
|
||||
import { localesKeys } from '@/lib/queryKeys'
|
||||
import { applyClientAnimationMode, getClientAnimationMode } from '@/lib/settings'
|
||||
|
||||
import type { DehydratedState } from '@tanstack/react-query'
|
||||
@@ -21,14 +22,7 @@ const client = new QueryClient ({
|
||||
retry: 1 } } })
|
||||
|
||||
|
||||
const start = async () => {
|
||||
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 mount = (serverRendered: boolean) => {
|
||||
const instance = createUII18n (currentViewingLocale ())
|
||||
const app = (
|
||||
<HelmetProvider context={helmetContext}>
|
||||
@@ -39,12 +33,82 @@ const start = async () => {
|
||||
</QueryClientProvider>
|
||||
</HelmetProvider>)
|
||||
const root = document.getElementById ('root')!
|
||||
if (bootstrap != null)
|
||||
if (serverRendered)
|
||||
hydrateRoot (root, app)
|
||||
else
|
||||
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 { useClientBehaviourSettings } from '@/lib/useClientBehaviourSettings'
|
||||
import { useUITranslation } from '@/lib/useUITranslation'
|
||||
import { cn, localisedDateString } from '@/lib/utils'
|
||||
import { localisedDateString } from '@/lib/utils'
|
||||
|
||||
import type { FC, ReactNode } from 'react'
|
||||
|
||||
@@ -79,25 +79,23 @@ const renderPrimaryName = (
|
||||
</>)
|
||||
|
||||
|
||||
const renderNameSnapshot = (
|
||||
snapshot: TagNameSnapshot,
|
||||
const renderAliasDiff = (
|
||||
previous: TagNameSnapshot | null,
|
||||
current: TagNameSnapshot | null,
|
||||
language: string,
|
||||
label: (key: string) => ReactNode,
|
||||
) => (
|
||||
<div className="space-y-1">
|
||||
<div>
|
||||
{label ('name')}:
|
||||
{renderPrimaryName (snapshot, language, label)}
|
||||
</div>
|
||||
{snapshot.aliases.length > 0 && (
|
||||
<div>
|
||||
{label ('tags:aliases')}:
|
||||
{snapshot.aliases.map (alias => (
|
||||
<span key={`${alias.name}:${alias.scriptCode}`} className="mr-2">
|
||||
<span lang={language} className="inline-block whitespace-nowrap">{alias.name}</span>
|
||||
</span>))}
|
||||
</div>)}
|
||||
</div>)
|
||||
) => {
|
||||
const before = new Set (previous?.aliases.map (alias => alias.name) ?? [])
|
||||
const after = new Set (current?.aliases.map (alias => alias.name) ?? [])
|
||||
return [...new Set ([...after, ...before])].map (name => {
|
||||
const content = (
|
||||
<span lang={language} className="inline-block whitespace-nowrap">{name}</span>)
|
||||
if (!(before.has (name)))
|
||||
return <ins key={name} className="mr-2 text-green-600 dark:text-green-400">{content}</ins>
|
||||
if (!(after.has (name)))
|
||||
return <del key={name} className="mr-2 text-red-600 dark:text-red-400">{content}</del>
|
||||
return <span key={name} className="mr-2">{content}</span>
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
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 language = localeLanguage (locale)
|
||||
const { message, label } = useUITranslation ('history')
|
||||
@@ -149,41 +149,52 @@ const TagVersionNames = ({ change }: { change: TagVersion }) => {
|
||||
...nameChanges.map (entry => entry.languageCode)])]
|
||||
.filter (code => code !== language).sort ()
|
||||
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 (
|
||||
<>
|
||||
{renderDiff ({ current, prev: previous },
|
||||
snapshot => renderPrimaryName (snapshot, language, label),
|
||||
(prev, next) => prev?.name === next?.name
|
||||
&& prev?.autoGenerated === next?.autoGenerated)}
|
||||
{otherLanguages.length > 0 && (
|
||||
<div className="mt-2 text-sm">
|
||||
<button type="button" className="text-left underline"
|
||||
aria-expanded={expanded} aria-controls={panelId}
|
||||
onClick={() => setExpanded (value => !value)}>
|
||||
{label ('nameChanges.otherLanguages')}
|
||||
{otherChanges.length > 0 && label ('nameChanges.changed')}
|
||||
</button>
|
||||
<div id={panelId} hidden={!expanded} className="mt-2 space-y-2">
|
||||
{otherLanguages.map (code => {
|
||||
const entry = nameChanges.find (value => value.languageCode === code)
|
||||
const key = `languageNames.${code}`
|
||||
const known = i18n.exists (key, { ns: 'history', lng: locale })
|
||||
const snapshot = names[code]
|
||||
return (
|
||||
<div key={code}>
|
||||
<LocalisedText language={known ? code : 'und'} className="font-semibold">
|
||||
{known ? message (key).text : code}
|
||||
</LocalisedText>
|
||||
{entry != null
|
||||
? renderDiff (entry,
|
||||
value => renderNameSnapshot (value, code, label))
|
||||
: snapshot != null && renderNameSnapshot (snapshot, code, label)}
|
||||
</div>)
|
||||
})}
|
||||
</div>
|
||||
</div>)}
|
||||
</>)
|
||||
<tbody className="even:bg-gray-100 even:text-gray-900
|
||||
dark:even:bg-gray-700 dark:even:text-gray-100">
|
||||
{children (primary, toggle)}
|
||||
{otherLanguages.map ((code, index) => {
|
||||
const entry = nameChanges.find (value => value.languageCode === code)
|
||||
const key = `languageNames.${code}`
|
||||
const known = i18n.exists (key, { ns: 'history', lng: locale })
|
||||
const next = entry != null ? entry.current : names[code] ?? null
|
||||
const prev = entry != null ? entry.prev : next
|
||||
return (
|
||||
<tr key={code} id={rowIds[index]} hidden={!expanded}>
|
||||
<th scope="row" className="p-2 text-left text-sm font-normal">
|
||||
<LocalisedText language={known ? code : 'und'}>
|
||||
{known ? message (key).text : code}
|
||||
</LocalisedText>
|
||||
</th>
|
||||
<td className="p-2">
|
||||
{renderDiff ({ current: next, prev },
|
||||
value => renderPrimaryName (value, code, label), primaryEqual)}
|
||||
</td>
|
||||
<td/>
|
||||
<td/>
|
||||
<td className="p-2">{renderAliasDiff (prev, next, code)}</td>
|
||||
<td/>
|
||||
<td/>
|
||||
<td/>
|
||||
</tr>)
|
||||
})}
|
||||
</tbody>)
|
||||
}
|
||||
|
||||
|
||||
@@ -261,136 +272,137 @@ const TagHistoryPage: FC = () => {
|
||||
</tr>
|
||||
</thead>
|
||||
|
||||
<tbody>
|
||||
{changes.map (change => (
|
||||
<tr key={`${ change.tagId }.${ change.versionNo }`}
|
||||
className={cn ('even:bg-gray-100 dark:even:bg-gray-700')}>
|
||||
<td className="p-2">{change.tagId}.{change.versionNo}</td>
|
||||
<td className="p-2 break-all">
|
||||
<TagVersionNames change={change}/>
|
||||
</td>
|
||||
<td className="p-2 break-all">
|
||||
{renderDiff (change.category,
|
||||
category => label (`common:categories.${category}`))}
|
||||
</td>
|
||||
<td className="p-2 break-all">
|
||||
{renderDiff ({
|
||||
current: change.deprecatedAt.current ? 'deprecated' : '',
|
||||
prev: change.deprecatedAt.prev ? 'deprecated' : '' },
|
||||
state => state ? label (`common:states.${state}`) : '')}
|
||||
</td>
|
||||
<td className="p-2">
|
||||
{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'
|
||||
{changes.map (change => (
|
||||
<TagVersionRows key={`${ change.tagId }.${ change.versionNo }`} change={change}>
|
||||
{(primary, toggle) => (
|
||||
<tr>
|
||||
<td className="p-2">{change.tagId}.{change.versionNo}</td>
|
||||
<td className="p-2 break-all">
|
||||
{primary}
|
||||
{toggle}
|
||||
</td>
|
||||
<td className="p-2 break-all">
|
||||
{renderDiff (change.category,
|
||||
category => label (`common:categories.${category}`))}
|
||||
</td>
|
||||
<td className="p-2 break-all">
|
||||
{renderDiff ({
|
||||
current: change.deprecatedAt.current ? 'deprecated' : '',
|
||||
prev: change.deprecatedAt.prev ? 'deprecated' : '' },
|
||||
state => state ? label (`common:states.${state}`) : '')}
|
||||
</td>
|
||||
<td className="p-2">
|
||||
{change.aliases.map ((tag, i) => (
|
||||
tag.type === 'added'
|
||||
? (
|
||||
<del
|
||||
<ins
|
||||
key={i}
|
||||
className="mr-2 text-red-600 dark:text-red-400">
|
||||
className="mr-2 text-green-600 dark:text-green-400">
|
||||
{tag.name}
|
||||
</del>)
|
||||
</ins>)
|
||||
: (
|
||||
<span key={i} className="mr-2">
|
||||
{tag.name}
|
||||
</span>))))}
|
||||
</td>
|
||||
<td className="p-2">
|
||||
{change.parentTags.map ((tag, i) => (
|
||||
tag.type === 'added'
|
||||
? (
|
||||
<ins
|
||||
key={i}
|
||||
className="mr-2 text-green-600 dark:text-green-400">
|
||||
{tag.tag.name}
|
||||
</ins>)
|
||||
: (
|
||||
tag.type === 'removed'
|
||||
tag.type === 'removed'
|
||||
? (
|
||||
<del
|
||||
key={i}
|
||||
className="mr-2 text-red-600 dark:text-red-400">
|
||||
{tag.name}
|
||||
</del>)
|
||||
: (
|
||||
<span key={i} className="mr-2">
|
||||
{tag.name}
|
||||
</span>))))}
|
||||
</td>
|
||||
<td className="p-2">
|
||||
{change.parentTags.map ((tag, i) => (
|
||||
tag.type === 'added'
|
||||
? (
|
||||
<del
|
||||
<ins
|
||||
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}
|
||||
</del>)
|
||||
</ins>)
|
||||
: (
|
||||
<span key={i} className="mr-2">
|
||||
{tag.tag.name}
|
||||
</span>))))}
|
||||
</td>
|
||||
<td className="p-2">
|
||||
{change.createdByUser
|
||||
? (
|
||||
<PrefetchLink to={`/users/${ change.createdByUser.id }`}>
|
||||
{change.createdByUser.name
|
||||
|| label ('anonymous', { id: change.createdByUser.id })}
|
||||
</PrefetchLink>)
|
||||
: label ('bot')}
|
||||
<br/>
|
||||
{localisedDateString (change.createdAt, locale)}
|
||||
</td>
|
||||
<td className="p-2">
|
||||
<a
|
||||
href="#"
|
||||
onClick={async e => {
|
||||
e.preventDefault ()
|
||||
|
||||
if (!(await dialogue.confirm ({
|
||||
title: label ('confirmTitle'),
|
||||
description: (
|
||||
<>
|
||||
{label ('confirmTag', {
|
||||
name: change.name.current ?? '', version: change.versionNo })}
|
||||
<br/>
|
||||
{label ('restoreScope')}
|
||||
</>),
|
||||
confirmText: label ('revert'),
|
||||
cancelText: label ('settings:actions.cancel'),
|
||||
closeLabel: label ('settings:actions.close') })))
|
||||
return
|
||||
|
||||
try
|
||||
{
|
||||
if (!(change.name.current)
|
||||
|| change.parentTags.some (tag =>
|
||||
tag.type !== 'removed' && !(tag.tag.name)))
|
||||
throw new Error ()
|
||||
await apiPut (
|
||||
`/tags/${ change.tagId }`,
|
||||
{ name: change.name.current,
|
||||
category: change.category.current,
|
||||
deprecated: change.deprecatedAt.current ? '1' : '0',
|
||||
aliases:
|
||||
change.aliases
|
||||
.filter (t => t.type !== 'removed')
|
||||
.map (t => t.name)
|
||||
.join (' '),
|
||||
parent_tags:
|
||||
change.parentTags
|
||||
.filter (t => t.type !== 'removed')
|
||||
.map (t => t.tag.name)
|
||||
.join (' ') }, { locale })
|
||||
|
||||
qc.invalidateQueries ({ queryKey: postsKeys.root })
|
||||
qc.invalidateQueries ({ queryKey: tagsKeys.root })
|
||||
qc.invalidateQueries ({ queryKey: wikiKeys.root })
|
||||
toast ({ description: label ('restored') })
|
||||
}
|
||||
catch
|
||||
{
|
||||
toast ({ description: label ('restoreFailed') })
|
||||
}
|
||||
}}>
|
||||
{label ('shared:actions.restore')}
|
||||
</a>
|
||||
</td>
|
||||
</tr>))}
|
||||
</tbody>
|
||||
tag.type === 'removed'
|
||||
? (
|
||||
<del
|
||||
key={i}
|
||||
className="mr-2 text-red-600 dark:text-red-400">
|
||||
{tag.tag.name}
|
||||
</del>)
|
||||
: (
|
||||
<span key={i} className="mr-2">
|
||||
{tag.tag.name}
|
||||
</span>))))}
|
||||
</td>
|
||||
<td className="p-2">
|
||||
{change.createdByUser
|
||||
? (
|
||||
<PrefetchLink to={`/users/${ change.createdByUser.id }`}>
|
||||
{change.createdByUser.name
|
||||
|| label ('anonymous', { id: change.createdByUser.id })}
|
||||
</PrefetchLink>)
|
||||
: label ('bot')}
|
||||
<br/>
|
||||
{localisedDateString (change.createdAt, locale)}
|
||||
</td>
|
||||
<td className="p-2">
|
||||
<a
|
||||
href="#"
|
||||
onClick={async e => {
|
||||
e.preventDefault ()
|
||||
|
||||
if (!(await dialogue.confirm ({
|
||||
title: label ('confirmTitle'),
|
||||
description: (
|
||||
<>
|
||||
{label ('confirmTag', {
|
||||
name: change.name.current ?? '', version: change.versionNo })}
|
||||
<br/>
|
||||
{label ('restoreScope')}
|
||||
</>),
|
||||
confirmText: label ('revert'),
|
||||
cancelText: label ('settings:actions.cancel'),
|
||||
closeLabel: label ('settings:actions.close') })))
|
||||
return
|
||||
|
||||
try
|
||||
{
|
||||
if (!(change.name.current)
|
||||
|| change.parentTags.some (tag =>
|
||||
tag.type !== 'removed' && !(tag.tag.name)))
|
||||
throw new Error ()
|
||||
await apiPut (
|
||||
`/tags/${ change.tagId }`,
|
||||
{ name: change.name.current,
|
||||
category: change.category.current,
|
||||
deprecated: change.deprecatedAt.current ? '1' : '0',
|
||||
aliases:
|
||||
change.aliases
|
||||
.filter (t => t.type !== 'removed')
|
||||
.map (t => t.name)
|
||||
.join (' '),
|
||||
parent_tags:
|
||||
change.parentTags
|
||||
.filter (t => t.type !== 'removed')
|
||||
.map (t => t.tag.name)
|
||||
.join (' ') }, { locale })
|
||||
|
||||
qc.invalidateQueries ({ queryKey: postsKeys.root })
|
||||
qc.invalidateQueries ({ queryKey: tagsKeys.root })
|
||||
qc.invalidateQueries ({ queryKey: wikiKeys.root })
|
||||
toast ({ description: label ('restored') })
|
||||
}
|
||||
catch
|
||||
{
|
||||
toast ({ description: label ('restoreFailed') })
|
||||
}
|
||||
}}>
|
||||
{label ('shared:actions.restore')}
|
||||
</a>
|
||||
</td>
|
||||
</tr>)}
|
||||
</TagVersionRows>))}
|
||||
</table>
|
||||
</div>
|
||||
|
||||
|
||||
新しいイシューから参照
ユーザーをブロックする