このコミットが含まれているのは:
2026-10-18 22:14:40 +09:00
コミット e0970a81a4
6個のファイルの変更、394行の追加、241行の削除
+53
ファイルの表示
@@ -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 ()
+187 -175
ファイルの表示
@@ -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>