diff --git a/docs/commands.md b/docs/commands.md index b267600..df7b4a8 100644 --- a/docs/commands.md +++ b/docs/commands.md @@ -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. diff --git a/frontend/server.mjs b/frontend/server.mjs index bbdb193..cc759e2 100644 --- a/frontend/server.mjs +++ b/frontend/server.mjs @@ -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 ('', result?.status === 404 + ? '' : '') + : template .replace ('lang="ja"', `lang="${result.locale}"`) .replace (/[\s\S]*?<\/title>/, '') .replace (/<meta name="description"[^>]*>/, '') diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 23c82e3..3c62001 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -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'}> diff --git a/frontend/src/entry-server.tsx b/frontend/src/entry-server.tsx index 9da9bb6..f685398 100644 --- a/frontend/src/entry-server.tsx +++ b/frontend/src/entry-server.tsx @@ -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 } diff --git a/frontend/src/main.tsx b/frontend/src/main.tsx index 2c7ac65..ef50446 100644 --- a/frontend/src/main.tsx +++ b/frontend/src/main.tsx @@ -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 () diff --git a/frontend/src/pages/tags/TagHistoryPage.tsx b/frontend/src/pages/tags/TagHistoryPage.tsx index e118b25..5370cae 100644 --- a/frontend/src/pages/tags/TagHistoryPage.tsx +++ b/frontend/src/pages/tags/TagHistoryPage.tsx @@ -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>