From a86129006dcec37b1f50c68a6bc6dfdfa14f3128 Mon Sep 17 00:00:00 2001 From: miteruzo Date: Mon, 19 Oct 2026 00:03:55 +0900 Subject: [PATCH] #428 --- backend/app/controllers/users_controller.rb | 6 + backend/config/routes.rb | 1 + backend/spec/requests/users_spec.rb | 18 + frontend/package.json | 1 + frontend/server.mjs | 88 ++++- frontend/server.ssr.test.mjs | 308 ++++++++++++++++++ frontend/src/App.tsx | 39 ++- frontend/src/components/ErrorScreen.tsx | 4 + frontend/src/components/PostEditForm.tsx | 2 +- frontend/src/components/common/PageHead.tsx | 11 +- .../components/layout/SidebarComponent.tsx | 7 +- .../users/BehaviourSettingsSection.tsx | 4 +- frontend/src/entry-server.tsx | 142 ++++++-- frontend/src/lib/api.ts | 23 +- frontend/src/lib/queryKeys.ts | 12 + frontend/src/lib/renderContext.ts | 5 +- frontend/src/lib/routeLocales.ts | 5 +- frontend/src/lib/settings.ts | 3 +- frontend/src/lib/useHydrated.ts | 9 + frontend/src/lib/useKeyboardShortcuts.tsx | 6 +- frontend/src/main.tsx | 20 +- frontend/src/pages/GekanatorPage.tsx | 20 +- frontend/src/pages/MorePage.tsx | 3 + .../deerjikists/DeerjikistDetailPage.tsx | 4 +- .../pages/materials/MaterialDetailPage.tsx | 21 +- frontend/src/pages/posts/PostDetailPage.tsx | 14 +- .../src/pages/posts/PostImportReviewPage.tsx | 3 +- frontend/src/pages/posts/PostListPage.tsx | 8 +- frontend/src/pages/posts/PostSearchPage.tsx | 10 +- frontend/src/pages/tags/TagDetailPage.tsx | 21 +- frontend/src/pages/tags/TagListPage.tsx | 10 +- .../src/pages/theatres/TheatreDetailPage.tsx | 53 ++- frontend/src/pages/users/SettingPage.tsx | 36 +- frontend/src/pages/wiki/WikiDiffPage.tsx | 36 +- frontend/src/pages/wiki/WikiEditPage.tsx | 47 +-- frontend/src/pages/wiki/WikiSearchPage.tsx | 21 +- frontend/vite.config.ts | 3 +- 37 files changed, 800 insertions(+), 224 deletions(-) create mode 100644 frontend/server.ssr.test.mjs create mode 100644 frontend/src/lib/useHydrated.ts diff --git a/backend/app/controllers/users_controller.rb b/backend/app/controllers/users_controller.rb index 95ae677..28c04b9 100644 --- a/backend/app/controllers/users_controller.rb +++ b/backend/app/controllers/users_controller.rb @@ -21,6 +21,12 @@ class UsersController < ApplicationController render json: { valid: true, user: user.slice(:id, :name, :inheritance_code, :role) } end + def session + return head :unauthorized unless current_user + + render json: current_user.slice(:id, :name, :role) + end + def renew return head :unauthorized unless current_user diff --git a/backend/config/routes.rb b/backend/config/routes.rb index 9752bde..2ad1ceb 100644 --- a/backend/config/routes.rb +++ b/backend/config/routes.rb @@ -100,6 +100,7 @@ Rails.application.routes.draw do collection do post :verify get :me + get :session post 'code/renew', action: :renew end end diff --git a/backend/spec/requests/users_spec.rb b/backend/spec/requests/users_spec.rb index c58f614..75183f5 100644 --- a/backend/spec/requests/users_spec.rb +++ b/backend/spec/requests/users_spec.rb @@ -13,6 +13,24 @@ RSpec.describe 'Users', type: :request do { 'X-Transfer-Code' => user.inheritance_code } end + describe 'GET /users/session' do + it 'requires authentication' do + get '/users/session' + expect(response).to have_http_status(:unauthorized) + end + + it 'returns public user fields without recording a visit' do + user = create(:user, name: 'SSR user') + expect { + get '/users/session', headers: auth_headers(user) + }.not_to change(UserIp, :count) + + expect(response).to have_http_status(:ok) + expect(json).to eq('id' => user.id, 'name' => user.name, 'role' => user.role) + expect(response.body).not_to include(user.inheritance_code) + end + end + describe 'POST /users' do it 'creates guest user, IpAddress and UserIp, and returns code' do expect { diff --git a/frontend/package.json b/frontend/package.json index 134535e..7d93a4d 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -12,6 +12,7 @@ "lint": "eslint .", "test": "vitest", "test:run": "vitest run", + "test:ssr": "node --test server.ssr.test.mjs", "preview": "vite preview --outDir dist/client" }, "dependencies": { diff --git a/frontend/server.mjs b/frontend/server.mjs index cc759e2..9d88f62 100644 --- a/frontend/server.mjs +++ b/frontend/server.mjs @@ -1,6 +1,7 @@ import { createReadStream } from 'node:fs' import { readFile, stat } from 'node:fs/promises' import { createServer } from 'node:http' +import { createCipheriv, createDecipheriv, createHash, randomBytes } from 'node:crypto' import path from 'node:path' import { fileURLToPath } from 'node:url' @@ -10,6 +11,39 @@ const root = fileURLToPath (new URL ('./dist/client/', import.meta.url)) const template = await readFile (path.join (root, 'index.html'), 'utf8') const origin = new URL (process.env.PUBLIC_ORIGIN ?? 'http://localhost:5173').origin const apiBaseURL = process.env.SSR_API_BASE_URL ?? 'http://127.0.0.1:3002' +const sessionSecret = process.env.SSR_COOKIE_SECRET +if (process.env.NODE_ENV === 'production' && (!sessionSecret || sessionSecret.length < 32)) + throw new Error ('SSR_COOKIE_SECRET must contain at least 32 characters in production') +const sessionKey = createHash ('sha256').update (sessionSecret ?? randomBytes (32)).digest () +const cookieName = '__Host-btrc-session' +const sessionSeconds = 7 * 24 * 60 * 60 + +const encodeSession = code => { + const iv = randomBytes (12) + const cipher = createCipheriv ('aes-256-gcm', sessionKey, iv) + const payload = JSON.stringify ({ code, expires: Date.now () + sessionSeconds * 1000 }) + const encrypted = Buffer.concat ([cipher.update (payload, 'utf8'), cipher.final ()]) + return Buffer.concat ([iv, cipher.getAuthTag (), encrypted]).toString ('base64url') +} + +const decodeSession = cookie => { + try + { + const token = cookie?.split (';').map (part => part.trim ()) + .find (part => part.startsWith (`${cookieName}=`))?.slice (cookieName.length + 1) + if (!token || token.length > 2048) return undefined + const payload = Buffer.from (token, 'base64url') + const decipher = createDecipheriv ('aes-256-gcm', sessionKey, payload.subarray (0, 12)) + decipher.setAuthTag (payload.subarray (12, 28)) + const data = JSON.parse (Buffer.concat ([ + decipher.update (payload.subarray (28)), decipher.final ()]).toString ('utf8')) + return typeof data.code === 'string' && data.expires > Date.now () ? data.code : undefined + } + catch + { + return undefined + } +} const types = { '.js': 'text/javascript', '.css': 'text/css', '.json': 'application/json', '.png': 'image/png', '.gif': 'image/gif', '.jpg': 'image/jpeg', @@ -20,8 +54,43 @@ const types = { if (process.env.NODE_ENV === 'production' && !process.env.PUBLIC_ORIGIN) throw new Error ('PUBLIC_ORIGIN is required in production') -createServer (async (request, response) => { - response.setHeader ('Cache-Control', 'no-store') +const server = createServer (async (request, response) => { + response.setHeader ('Cache-Control', 'private, no-store') + const forwardedFor = request.headers['x-forwarded-for'] ?? request.socket.remoteAddress + if (request.url === '/_ssr/session') + { + if (request.method !== 'POST' || request.headers.origin !== origin) + { + response.writeHead (403).end () + return + } + const code = request.headers['x-transfer-code'] + if (typeof code !== 'string' || code.length > 512 || code.length === 0) + { + response.writeHead (400).end () + return + } + try + { + const verified = await fetch (new URL ('users/session', apiBaseURL.replace (/\/?$/, '/')), { + headers: { 'X-Transfer-Code': code, 'X-Forwarded-For': forwardedFor }, + signal: AbortSignal.timeout (10_000) }) + if (!verified.ok) + { + response.writeHead (verified.status === 401 || verified.status === 403 + ? verified.status : 503).end () + return + } + response.setHeader ('Set-Cookie', `${cookieName}=${encodeSession (code)}; ` + + `Path=/; HttpOnly; Secure; SameSite=Lax; Max-Age=${sessionSeconds}`) + response.writeHead (204).end () + } + catch + { + response.writeHead (503).end () + } + return + } if (!['GET', 'HEAD'].includes (request.method)) { response.writeHead (405, { Allow: 'GET, HEAD' }).end () @@ -54,7 +123,8 @@ createServer (async (request, response) => { response.writeHead (404).end () return } - const result = await render (url.pathname + url.search, origin, apiBaseURL) + const result = await render (url.pathname + url.search, origin, apiBaseURL, { + transferCode: decodeSession (request.headers.cookie), forwardedFor }) if (result?.location != null) { response.writeHead (result.status, { Location: result.location }).end () @@ -77,7 +147,13 @@ createServer (async (request, response) => { } catch (error) { - console.error (error) - response.writeHead (error instanceof URIError ? 400 : 503).end () + console.error ('[SSR]', error instanceof Error ? error.message : 'Rendering failed') + const denied = error?.response?.status === 401 || error?.response?.status === 403 + response.writeHead (error instanceof URIError ? 400 + : (denied ? error.response.status : 503)).end () } -}).listen (Number (process.env.PORT ?? 5173), process.env.HOST ?? '127.0.0.1') +}) + +server.listen (Number (process.env.PORT ?? 5173), process.env.HOST ?? '127.0.0.1', () => { + console.info ('[SSR] listening on', server.address ().port) +}) diff --git a/frontend/server.ssr.test.mjs b/frontend/server.ssr.test.mjs new file mode 100644 index 0000000..c2e313d --- /dev/null +++ b/frontend/server.ssr.test.mjs @@ -0,0 +1,308 @@ +import assert from 'node:assert/strict' +import { createServer } from 'node:http' +import { spawn } from 'node:child_process' +import { after, before, test } from 'node:test' +import { createServer as createViteServer } from 'vite' + +import { render } from './dist/server/entry-server.js' + +const origin = 'https://ssr.example.invalid' +const date = '2026-09-15T00:00:00+09:00' +let api, apiURL, routes, vite, node, httpOrigin +let failAPI = false +const requests = [] +const tag = locale => ({ + id: 1, name: locale === 'ja' ? 'SSR タグ' : 'SSR シロシ', category: 'general', + deprecated_at: null, aliases: [], parents: [], children: [], post_count: 1, + created_at: date, updated_at: date, has_wiki: true, material_id: null, + has_deerjikists: false, sections: [] }) +const post = locale => ({ + id: 1, version_no: 1, title: `SSR post ${locale}`, url: 'https://example.invalid/post', + thumbnail: null, thumbnail_base: null, video_ms: null, tags: [tag (locale)], + parent_posts: [], child_posts: [], sibling_posts: {}, viewed: false, related: [], + original_created_from: date, original_created_before: date, + created_at: date, updated_at: date, uploaded_user: null }) +const wiki = locale => ({ + id: 1, tag_id: 1, resource_type: 'tag_description', language_code: locale === 'ja' + ? 'ja' : 'ain', title: tag (locale).name, body: `SSR wiki body ${locale}`, + created_at: date, updated_at: date, revision_id: 1, pred: null, succ: null, + deprecated_at: null }) +const material = locale => ({ + id: 1, version_no: 1, tag: tag (locale), file: null, url: 'https://example.invalid', + thumbnail: null, thumbnail_fallback_text: 'SSR material', + thumbnail_fallback_kind: 'tag_name', media_kind: 'url_only', content_type: null, + file_byte_size: null, export_paths: {}, export_items: [], created_at: date, + updated_at: date, created_by_user: { id: 1, name: 'SSR' }, + updated_by_user: { id: 1, name: 'SSR' } }) + +const responseFor = url => { + const locale = url.searchParams.get ('locale') ?? 'ja' + const path = url.pathname + if (path === '/locales') + return { locales: [{ code: 'ja', name: '日本語' }, { code: 'ain-Kana', name: 'アイヌ' }] } + if (path === '/users/session') return { id: 1, name: 'SSR account', role: 'admin' } + if (path === '/users/settings') return { theme: 'system', wiki_editor_mode: 'split' } + if (path === '/users/theme_slots') return [] + if (path === '/posts') return { posts: [post (locale)], count: 1 } + if (path === '/gekanator/posts') return { posts: [post (locale)] } + if (path === '/gekanator/questions') return { questions: [] } + if (path === '/posts/1') return post (locale) + if (path === '/tags') return { tags: [tag (locale)], count: 1 } + if (path === '/tags/1' || path.startsWith ('/tags/name/')) return tag (locale) + if (path === '/tags/1/deerjikists') return { tag: tag (locale), deerjikists: [] } + if (path === '/tags/with-depth') return [] + if (path === '/tags/nico' || path === '/nico/tags') return { tags: [], count: 0 } + if (path.endsWith ('/versions')) return { versions: [], count: 0 } + if (path === '/wiki') return [wiki (locale)] + if (path === '/wiki/changes') return [] + if (path.includes ('autolink')) return [] + if (path === '/wiki/1/diff') return { title: 'SSR wiki diff', diff: [] } + if (path.startsWith ('/wiki/')) return wiki (locale) + if (path === '/materials') return { materials: [material (locale)], count: 1 } + if (path === '/materials/1') return material (locale) + if (path.includes ('suppressions')) return { suppressions: [] } + if (path === '/theatres/1') return { id: 1, name: 'SSR theatre', created_at: date } + if (path === '/theatres/1/programmes') return [] + return null +} + +before (async () => { + api = createServer ((request, response) => { + requests.push ({ method: request.method, url: request.url }) + const url = new URL (request.url, 'http://localhost') + const data = responseFor (url) + response.writeHead (failAPI ? 503 : (data == null ? 404 : 200), { + 'Content-Type': 'application/json' }) + response.end (JSON.stringify (data)) + }) + await new Promise (resolve => api.listen (0, '127.0.0.1', resolve)) + apiURL = `http://127.0.0.1:${api.address ().port}` + node = spawn (process.execPath, ['server.mjs'], { env: { + ...process.env, NODE_ENV: 'production', PORT: '0', HOST: '127.0.0.1', + PUBLIC_ORIGIN: origin, SSR_API_BASE_URL: apiURL, + SSR_COOKIE_SECRET: 'automated-check-only-not-a-production-secret' }, + stdio: ['ignore', 'pipe', 'pipe'] }) + httpOrigin = await new Promise ((resolve, reject) => { + node.stdout.on ('data', data => { + const match = data.toString ().match (/listening on (\d+)/) + if (match) resolve (`http://127.0.0.1:${match[1]}`) + }) + node.on ('error', reject) + node.on ('exit', code => reject (new Error (`SSR server exited ${code}`))) + }) + vite = await createViteServer ({ server: { middlewareMode: true }, appType: 'custom' }) + const { pageRoutes } = await vite.ssrLoadModule ('/src/App.tsx') + const flatten = (entries, parent = '') => entries.flatMap (route => { + const path = [parent, route.path].filter (Boolean).join ('/') + return route.children ? flatten (route.children, path) : [{ ...route, path }] + }) + routes = flatten (pageRoutes (null, () => {})) +}) + +after (async () => { + await vite?.close () + node?.kill () + api?.closeAllConnections () + await new Promise (resolve => api?.close (resolve)) +}) + +test ('every canonical screen route returns page HTML without JavaScript', async t => { + for (const route of routes) { + for (const locale of ['ja', 'ain-Kana']) { + const path = route.path.replace (':identifier', 'home').replace (/:id|:title/g, '1') + .replace ('*', 'unknown-screen') + const url = `/${locale}/${path}?from=2026-09-15&to=2026-09-15` + await t.test (`${locale} ${route.path}`, async () => { + const result = await render (url, origin, apiURL, { transferCode: 'test-secret' }) + assert.ok (result != null) + if (route.handle?.redirectTo) { + assert.equal (result.status, 308) + return + } + assert.ok (result.html?.length > 300, 'actual page HTML is required') + assert.equal (result.status, route.path === '*' ? 404 : 200) + assert.match (result.head, /]*>/g, ''), /SSR[  ]theatre/) + if (route.path === 'wiki' || route.path === 'wiki/:title') + assert.match (result.html, new RegExp (`SSR wiki body ${locale}|SSR`)) + assert.doesNotMatch (result.html, /Switched to client rendering|template id="B:/) + }) + } + } +}) + +test ('parallel localized requests keep list data isolated', async () => { + const pages = await Promise.all (['ja', 'ain-Kana', 'ja', 'ain-Kana'].map (locale => + render (`/${locale}/posts?limit=50&page=2`, origin, apiURL))) + for (const [index, page] of pages.entries ()) { + const locale = index % 2 === 0 ? 'ja' : 'ain-Kana' + assert.match (page.html, new RegExp (`SSR post ${locale}`)) + assert.equal (page.status, 200) + const state = JSON.parse (page.bootstrap) + assert.equal (state.locale, locale) + } + assert.ok (requests.some (request => request.url.includes ('page=2'))) + assert.ok (requests.every (request => request.method === 'GET')) +}) + +test ('unknown resources and legacy URLs have correct status', async () => { + assert.equal ((await render ('/ja/unknown', origin, apiURL)).status, 404) + assert.equal ((await render ('/ja/help/unknown', origin, apiURL)).status, 404) + assert.equal ((await render ('/ja/posts/999', origin, apiURL)).status, 404) + const redirect = await render ('/help/home?edition=2026-09-15', origin, apiURL) + assert.equal (redirect.status, 308) + assert.equal (redirect.location, '/ja/help/home?edition=2026-09-15') +}) + +test ('HTTP cookie migration, CSRF protection, and API failure', async () => { + const rejected = await fetch (`${httpOrigin}/_ssr/session`, { + method: 'POST', headers: { Origin: 'https://other.invalid', 'X-Transfer-Code': 'test-secret' } }) + assert.equal (rejected.status, 403) + const created = await fetch (`${httpOrigin}/_ssr/session`, { + method: 'POST', headers: { Origin: origin, 'X-Transfer-Code': 'test-secret' } }) + assert.equal (created.status, 204) + const cookie = created.headers.get ('set-cookie') + assert.match (cookie, /HttpOnly; Secure; SameSite=Lax/) + assert.doesNotMatch (cookie, /test-secret/) + const settings = await fetch (`${httpOrigin}/ja/users/settings`, { headers: { Cookie: cookie } }) + assert.equal (settings.status, 200) + const html = await settings.text () + assert.match (html, /SSR account/) + assert.doesNotMatch (html, /test-secret/) + assert.match (settings.headers.get ('cache-control'), /private, no-store/) + assert.equal ((await fetch (`${httpOrigin}/ja/users/settings`)).status, 401) + const invalid = cookie.replace ('=', '=invalid') + assert.equal ((await fetch (`${httpOrigin}/ja/users/settings`, { + headers: { Cookie: invalid } })).status, 401) + failAPI = true + try { + assert.equal ((await fetch (`${httpOrigin}/ja/posts`)).status, 503) + } finally { + failAPI = false + } +}) + +test ('hydrate every route with stored browser preferences', { + skip: !process.env.SSR_CHROME_PATH, timeout: 180_000, +}, async t => { + const { default: WebSocket } = await import ('ws') + const chrome = spawn (process.env.SSR_CHROME_PATH, [ + '--headless', '--no-sandbox', '--disable-gpu', '--disable-dev-shm-usage', + '--remote-debugging-port=0', `--user-data-dir=/tmp/btrc-ssr-browser-${process.pid}`], + { stdio: ['ignore', 'ignore', 'pipe'] }) + let ws + try { + const endpoint = await new Promise ((resolve, reject) => { + let output = '' + chrome.stderr.on ('data', data => { + output += data + const match = output.match (/DevTools listening on (ws:\/\/\S+)/) + if (match) resolve (match[1]) + }) + chrome.on ('error', reject) + }) + ws = new WebSocket (endpoint) + await new Promise (resolve => ws.on ('open', resolve)) + const pending = new Map () + let sequence = 0, errors = [] + const send = (method, params = {}, sessionId) => new Promise ((resolve, reject) => { + const id = ++sequence + pending.set (id, { resolve, reject }) + ws.send (JSON.stringify ({ id, method, params, sessionId })) + }) + ws.on ('message', raw => { + const message = JSON.parse (raw) + if (message.id) { + const entry = pending.get (message.id) + pending.delete (message.id) + if (message.error) entry?.reject (message.error) + else entry?.resolve (message.result) + } else if (message.method === 'Fetch.requestPaused') { + const { requestId, request } = message.params + const url = new URL (request.url) + if (url.origin === httpOrigin && url.pathname !== '/_ssr/session') { + void send ('Fetch.continueRequest', { requestId }, message.sessionId) + return + } + const data = url.pathname === '/users/verify' + ? { valid: true, user: { id: 1, name: 'SSR account', role: 'admin', + inheritance_code: 'test-secret' } } + : responseFor (url) + void send ('Fetch.fulfillRequest', { requestId, + responseCode: request.method === 'OPTIONS' ? 204 : 200, + responseHeaders: [{ name: 'Content-Type', value: 'application/json' }, + { name: 'Access-Control-Allow-Origin', value: httpOrigin }, + { name: 'Access-Control-Allow-Headers', value: '*' }, + { name: 'Access-Control-Allow-Methods', value: '*' }], + body: Buffer.from (JSON.stringify (data ?? {})).toString ('base64') }, message.sessionId) + } else if (message.method === 'Runtime.exceptionThrown') { + errors.push (message.params.exceptionDetails.exception?.description + ?? message.params.exceptionDetails.text) + } else if (message.method === 'Runtime.consoleAPICalled' + && message.params.type === 'error') { + errors.push (message.params.args.map (arg => arg.value ?? arg.description).join (' ')) + } + }) + const { targetId } = await send ('Target.createTarget', { url: 'about:blank' }) + const { sessionId } = await send ('Target.attachToTarget', { targetId, flatten: true }) + await send ('Runtime.enable', {}, sessionId) + await send ('Page.enable', {}, sessionId) + await send ('Network.enable', {}, sessionId) + await send ('Fetch.enable', { patterns: [{ urlPattern: '*' }] }, sessionId) + const session = await fetch (`${httpOrigin}/_ssr/session`, { + method: 'POST', headers: { Origin: origin, 'X-Transfer-Code': 'test-secret' } }) + await send ('Network.setExtraHTTPHeaders', { headers: { + Cookie: session.headers.get ('set-cookie').split (';')[0] } }, sessionId) + await send ('Page.addScriptToEvaluateOnNewDocument', { source: ` + localStorage.setItem('user_code','test-secret'); + localStorage.setItem('btrc_hub.client_settings',JSON.stringify({ + lists:{postList:{limit:50},tagList:{limit:50},postSearch:{limit:50}}, + behavior:{animation:'off'},theatre:{layoutMode:'tagsBottom',tagFlow:'horizontal'} + })); + new MutationObserver(()=>{ + const child=document.getElementById('root')?.firstElementChild; + if(child&&!window.__initialSSRNode)window.__initialSSRNode=child; + }).observe(document,{childList:true,subtree:true});` }, sessionId) + const evaluate = async expression => (await send ('Runtime.evaluate', { + expression, returnByValue: true }, sessionId)).result.value + for (const route of routes.filter (entry => !entry.handle?.redirectTo)) { + for (const locale of ['ja', 'ain-Kana']) { + await t.test (`hydrate ${locale} ${route.path}`, async () => { + const path = route.path.replace (':identifier', 'home').replace (/:id|:title/g, '1') + .replace ('*', 'unknown-screen') + errors = [] + await send ('Page.navigate', { + url: `${httpOrigin}/${locale}/${path}?from=2026-09-15&to=2026-09-15` }, sessionId) + let hydrated = false + for (let attempt = 0; attempt < 100; ++attempt) { + await new Promise (resolve => setTimeout (resolve, 75)) + try { + hydrated = await evaluate (`Object.keys(document.getElementById('root')??{}) + .some(key=>key.startsWith('__reactContainer'))`) + } catch {} + if (hydrated) break + } + assert.ok (hydrated, 'hydrateRoot must run') + await new Promise (resolve => setTimeout (resolve, 150)) + assert.deepEqual (errors, []) + assert.ok (await evaluate (`document.getElementById('root').firstElementChild + ===window.__initialSSRNode`), 'server DOM must be preserved') + assert.equal (await evaluate ('document.documentElement.lang'), locale) + }) + } + } + } finally { + ws?.close () + chrome.kill () + } +}) diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 41a36b3..3cf0976 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -1,4 +1,4 @@ -import { useQuery } from '@tanstack/react-query' +import { useQuery, useQueryClient } from '@tanstack/react-query' import { AnimatePresence, LayoutGroup, MotionConfig, motion } from 'framer-motion' import { Suspense, lazy, useEffect, useLayoutEffect, useMemo, useState } from 'react' import { useTranslation } from 'react-i18next' @@ -20,7 +20,7 @@ import { apiPost, isApiError } from '@/lib/api' import { prepareHelpPage } from '@/lib/help' import { fetchLocales, isLocaleCode, legacyLocalePath, switchLocalePath, unlocalisedPath } from '@/lib/locales' -import { localesKeys } from '@/lib/queryKeys' +import { localesKeys, sessionKeys } from '@/lib/queryKeys' import { useLocale } from '@/lib/useLocale' import { applyClientAnimationMode, applyClientAppearance, @@ -99,9 +99,11 @@ export const pageRoutes = ( user: User | null, setUser: Dispatch<SetStateAction<User | null>>, ): PageRoute[] => [ - { path: '', element: <LocaleRedirect to="/posts"/> }, + { path: '', element: <LocaleRedirect to="/posts"/>, + handle: { redirectTo: '/posts' } }, { path: 'posts', element: <PostListPage/> }, - { path: 'posts/new', element: <PostNewPage user={user}/> }, + { path: 'posts/new', element: <PostNewPage user={user}/>, + handle: { robots: 'noindex' } }, { path: 'posts/search', element: <PostSearchPage/> }, { path: 'posts/:id', element: <PostDetailRoute user={user}/> }, { path: 'posts/changes', element: <PostHistoryPage/> }, @@ -115,22 +117,27 @@ export const pageRoutes = ( { path: 'materials', element: <MaterialBasePage/>, children: [ { index: true, element: <MaterialListPage/> }, { path: 'changes', element: <MaterialHistoryPage/> }, - { path: 'new', element: <MaterialNewPage/> }, - { path: 'suppressions', element: <MaterialSyncSuppressionsPage/> }, + { path: 'new', element: <MaterialNewPage/>, handle: { robots: 'noindex' } }, + { path: 'suppressions', element: <MaterialSyncSuppressionsPage/>, + handle: { robots: 'noindex', authentication: 'user' } }, { path: ':id', element: <MaterialDetailPage/> }] }, { path: 'wiki', element: <WikiSearchPage/> }, { path: 'wiki/:title', element: <WikiDetailPage/> }, - { path: 'wiki/new', element: <WikiNewPage user={user}/> }, - { path: 'wiki/:id/edit', element: <WikiEditPage user={user}/> }, + { path: 'wiki/new', element: <WikiNewPage user={user}/>, + handle: { robots: 'noindex' } }, + { path: 'wiki/:id/edit', element: <WikiEditPage user={user}/>, + handle: { robots: 'noindex' } }, { path: 'wiki/:id/diff', element: <WikiDiffPage/> }, { path: 'wiki/changes', element: <WikiHistoryPage/> }, { path: 'help', element: <HelpListPage/> }, { path: 'help/changes', element: <HelpChangesPage/> }, { path: 'help/:identifier/diff', element: <HelpDiffPage/> }, { id: 'help.detail', path: 'help/:identifier', element: <HelpDetailPage/>, - handle: { ssr: prepareHelpPage } }, - { path: 'users/settings', element: <SettingPage user={user} setUser={setUser}/> }, - { path: 'settings', element: <LocaleRedirect to="/users/settings"/> }, + handle: { prepare: prepareHelpPage } }, + { path: 'users/settings', element: <SettingPage user={user} setUser={setUser}/>, + handle: { robots: 'noindex', authentication: 'user' } }, + { path: 'settings', element: <LocaleRedirect to="/users/settings"/>, + handle: { redirectTo: '/users/settings' } }, { path: 'tos', element: <TOSPage/> }, { id: 'gekanator', path: 'gekanator', element: <GekanatorRoute user={user}/>, handle: { locale: 'ja' } }, @@ -168,7 +175,11 @@ const PostDetailRoute = ({ user }: { user: User | null }) => { const RoutedApp = ({ locales }: { locales: AvailableLocale[] }) => { - const [user, setUser] = useState<User | null> (null) + const client = useQueryClient () + const location = useLocation () + const robots = resolvePageRoute (location.pathname)?.route.handle?.robots + const [user, setUser] = useState<User | null> ( + () => client.getQueryData<User> (sessionKeys.current) ?? null) const [status, setStatus] = useState (200) const behaviourSettings = useClientBehaviourSettings () const animationMode = behaviourSettings.animation ?? 'normal' @@ -266,7 +277,9 @@ const RoutedApp = ({ locales }: { locales: AvailableLocale[] }) => { return ( <DialogueProvider> - <PageHead/> + <PageHead> + {robots != null && <meta name="robots" content={robots}/>} + </PageHead> <UnsavedChangesGuardProvider> <KeyboardShortcutsProvider> <MotionConfig diff --git a/frontend/src/components/ErrorScreen.tsx b/frontend/src/components/ErrorScreen.tsx index 8427747..4bfa6e9 100644 --- a/frontend/src/components/ErrorScreen.tsx +++ b/frontend/src/components/ErrorScreen.tsx @@ -3,6 +3,7 @@ import errorImg from '@/assets/images/not-found.gif' import MainArea from '@/components/layout/MainArea' import PageHead from '@/components/common/PageHead' import { useUITranslation } from '@/lib/useUITranslation' +import { currentRenderContext } from '@/lib/renderContext' import type { FC } from 'react' @@ -10,6 +11,9 @@ type Props = { status: number } const ErrorScreen: FC<Props> = ({ status }) => { + const context = currentRenderContext () + if (context != null) + context.status = status const { message: translate, label } = useUITranslation ('errors') const [message]: [string, string, string] = (() => { switch (status) diff --git a/frontend/src/components/PostEditForm.tsx b/frontend/src/components/PostEditForm.tsx index 57c6593..99039d9 100644 --- a/frontend/src/components/PostEditForm.tsx +++ b/frontend/src/components/PostEditForm.tsx @@ -65,7 +65,7 @@ const PostEditForm: FC<Props> = ({ post, onSave }) => { useState<string | null> (post.originalCreatedFrom) const [parentPostIds, setParentPostIds] = useState ((post.parentPosts ?? []).map (p => p.id).join (' ')) - const [tags, setTags] = useState<string> ('') + const [tags, setTags] = useState<string> (() => tagsToStr (post.tags)) const [title, setTitle] = useState (post.title) const videoFlg = diff --git a/frontend/src/components/common/PageHead.tsx b/frontend/src/components/common/PageHead.tsx index 109a8f9..ed5d837 100644 --- a/frontend/src/components/common/PageHead.tsx +++ b/frontend/src/components/common/PageHead.tsx @@ -1,7 +1,9 @@ import { Helmet } from 'react-helmet-async' +import { useLocation } from 'react-router-dom' import { useLocale } from '@/lib/useLocale' import { useUITranslation } from '@/lib/useUITranslation' +import { currentRenderContext } from '@/lib/renderContext' import type { ReactNode } from 'react' @@ -18,19 +20,24 @@ const PageHead = ( { title, includeSiteName = true, description: pageDescription, canonical, children }: Props, ) => { const locale = useLocale () + const location = useLocation () + const origin = currentRenderContext ()?.origin + const pageCanonical = canonical ?? (origin == null + ? undefined : new URL (location.pathname + location.search, origin).href) const { message, siteTitle } = useUITranslation ('shared') const pageTitle = typeof title === 'string' ? { text: title, language: locale } : title const fullTitle = pageTitle == null ? siteTitle.text : (includeSiteName ? `${pageTitle.text} | ${siteTitle.text}` : pageTitle.text) - const description = pageDescription ?? message ('head.description', { brand: siteTitle.text }) + const description = pageDescription ?? pageTitle + ?? message ('head.description', { brand: siteTitle.text }) return ( <Helmet> <title lang={pageTitle?.language ?? siteTitle.language}>{fullTitle} - {canonical != null && } + {pageCanonical != null && } {children} ) } diff --git a/frontend/src/components/layout/SidebarComponent.tsx b/frontend/src/components/layout/SidebarComponent.tsx index 44cef66..a1c5962 100644 --- a/frontend/src/components/layout/SidebarComponent.tsx +++ b/frontend/src/components/layout/SidebarComponent.tsx @@ -103,12 +103,7 @@ const SidebarComponent: FC = ({ return Math.min (maxAllowedWidth, Math.max (MIN_SIDEBAR_WIDTH, nextWidth)) }, []) - const [width, setWidth] = useState (() => ( - typeof window === 'undefined' - ? DEFAULT_SIDEBAR_WIDTH - : getClampedWidth ( - readStoredSidebarWidth (sidebarKey, side), - window.innerWidth))) + const [width, setWidth] = useState (DEFAULT_SIDEBAR_WIDTH) const [resizing, setResizing] = useState (false) const widthRef = useRef (width) const longPressTimerRef = useRef | null> (null) diff --git a/frontend/src/components/users/BehaviourSettingsSection.tsx b/frontend/src/components/users/BehaviourSettingsSection.tsx index 804e251..f828b23 100644 --- a/frontend/src/components/users/BehaviourSettingsSection.tsx +++ b/frontend/src/components/users/BehaviourSettingsSection.tsx @@ -88,9 +88,9 @@ const BehaviourSettingsSection: FC = props => { const { sectionClassName, onDirtyStateChange } = props const { label } = useUITranslation ('settings') const [savedSettings, setSavedSettings] = - useState (() => getClientBehaviorSettings ()) + useState ({ }) const [draftSettings, setDraftSettings] = - useState (() => getClientBehaviorSettings ()) + useState ({ }) useEffect (() => { const current = getClientBehaviorSettings () diff --git a/frontend/src/entry-server.tsx b/frontend/src/entry-server.tsx index 8c4e28e..f67e234 100644 --- a/frontend/src/entry-server.tsx +++ b/frontend/src/entry-server.tsx @@ -1,17 +1,23 @@ -import { QueryClient, QueryClientProvider, dehydrate } from '@tanstack/react-query' -import { renderToString } from 'react-dom/server' +import { QueryClient, QueryClientProvider, dehydrate, skipToken } from '@tanstack/react-query' +import { PassThrough } from 'node:stream' +import { renderToPipeableStream } from 'react-dom/server' import { HelmetProvider } from 'react-helmet-async' import { I18nextProvider } from 'react-i18next' import { createStaticHandler, createStaticRouter, StaticRouterProvider } from 'react-router-dom/server' import { AppFrame, appRoutes, resolvePageRoute } from '@/App' +import { apiGet, isApiError } from '@/lib/api' import { createUII18n } from '@/lib/i18n' import { fetchLocales, isLocaleCode, legacyLocalePath } from '@/lib/locales' -import { localesKeys } from '@/lib/queryKeys' +import { localesKeys, sessionKeys } from '@/lib/queryKeys' import { withRenderContext } from '@/lib/renderContext.server' import type { HelmetServerState } from 'react-helmet-async' +import type { ReactNode } from 'react' +import type { QueryObserverOptions } from '@tanstack/react-query' +import type { RenderContext } from '@/lib/renderContext' +import type { User } from '@/types' type RenderedPage = { status: number @@ -23,44 +29,91 @@ type RenderedPage = { type RouteResponse = { status: 404 | 308; location?: string } +const renderDocument = (element: ReactNode): Promise => + new Promise ((resolve, reject) => { + const output = new PassThrough () + const chunks: Buffer[] = [] + output.on ('data', chunk => chunks.push (Buffer.from (chunk))) + output.on ('error', reject) + output.on ('end', () => resolve (Buffer.concat (chunks).toString ('utf8'))) + const timeout = setTimeout (() => { + stream.abort () + reject (new Error ('SSR rendering timed out')) + }, 30_000) + const stream = renderToPipeableStream (element, { + onAllReady: () => { + clearTimeout (timeout) + stream.pipe (output) + }, + onShellError: error => { + clearTimeout (timeout) + reject (error) + }, + onError: error => { + clearTimeout (timeout) + reject (error) + } }) + }) + + export const render = async ( address: string, origin: string, apiBaseURL: string, -): Promise => { + authentication: Pick = { }, +): Promise => { const url = new URL (address, origin) const legacy = legacyLocalePath ({ pathname: url.pathname, search: url.search, hash: '' }) const pathname = legacy == null ? url.pathname : new URL (legacy, origin).pathname const segment = pathname.split ('/')[1] const known = resolvePageRoute (pathname) - if (!(isLocaleCode (segment)) || known == null) - return { status: 404 } - if (legacy != null) + if (legacy != null && known != null) return { status: 308, location: legacy } - const locale = segment - return withRenderContext ({ pathname: url.pathname, origin, apiBaseURL }, async () => { + const locale = isLocaleCode (segment) ? segment : 'ja' + if (known?.route.handle?.redirectTo != null) + return { status: 308, + location: `/${locale}${known.route.handle.redirectTo}${url.search}` } + const requestContext = { pathname: url.pathname, origin, apiBaseURL, + ...authentication, status: 200 } + return withRenderContext (requestContext, async () => { const client = new QueryClient ({ defaultOptions: { - queries: { retry: false, gcTime: Infinity } } }) + queries: { retry: false, gcTime: Infinity, staleTime: Infinity } } }) try { const locales = await client.fetchQuery ({ queryKey: localesKeys.index, queryFn: fetchLocales }) const active = locales.locales.some (entry => entry.code === locale) - if (!(active)) - return { status: 404 as const } - const prepare = known.route.handle?.ssr - if (prepare == null) - return null - const status = await prepare ({ client, params: known.params, locale, - searchParams: url.searchParams }) + if (authentication.transferCode != null) + { + try + { + const user = await apiGet> ('/users/session') + client.setQueryData (sessionKeys.current, { + id: user.id, name: user.name, role: user.role, inheritanceCode: '' }) + } + catch (error) + { + if (!isApiError (error) || error.response?.status !== 401) + throw error + delete requestContext.transferCode + } + } + const prepare = known?.route.handle?.prepare + let status = known == null || !active || !isLocaleCode (segment) ? 404 : 200 + if (status === 200 && known?.route.handle?.authentication != null + && client.getQueryData (sessionKeys.current) == null) + status = 401 + if (prepare != null && known != null && status === 200) + status = await prepare ({ client, params: known.params, locale, + searchParams: url.searchParams }) const instance = createUII18n (locale) const handler = createStaticHandler (appRoutes) const context = await handler.query (new Request (url)) if (context instanceof Response) throw new Error (`Unexpected router response: ${context.status}`) const router = createStaticRouter (handler.dataRoutes, context) - const helmetContext: { helmet?: HelmetServerState } = { } - const html = renderToString ( + let helmetContext: { helmet?: HelmetServerState } = { } + const page = () => ( @@ -70,13 +123,56 @@ export const render = async ( ) + let html = '' + const deadline = Date.now () + 30_000 + // Each pass discovers the queries used by the actual page, including dependent queries. + // Streaming resolves lazy components; only the last, fully prepared pass is returned. + for (;;) + { + if (Date.now () >= deadline) + throw new Error ('SSR data preparation timed out') + helmetContext = { } + requestContext.status = status + html = await renderDocument (page ()) + const pending = client.getQueryCache ().getAll ().filter (query => { + const options = query.options as QueryObserverOptions + return query.state.status === 'pending' + && options.queryFn != null && options.queryFn !== skipToken + && options.enabled !== false + && (typeof options.enabled !== 'function' || options.enabled (query)) + }) + if (pending.length === 0) + { + status = requestContext.status + break + } + await Promise.all (pending.map (async query => { + try + { + await client.fetchQuery ({ ...query.options, queryKey: query.queryKey }) + } + catch (error) + { + const code = isApiError (error) ? error.response?.status : null + if (code === 404 || code === 401 || code === 403) + status = code + else + throw error + } + })) + } const helmet = helmetContext.helmet! const head = [helmet.title, helmet.meta, helmet.link, helmet.style] .map (entry => entry.toString ()).join ('') - const state = dehydrate (client, { shouldDehydrateQuery: query => - query.state.status === 'success' - && (query.queryKey[0] === 'locales' - || (query.queryKey[0] === 'help' && query.queryKey[1] === 'article')) }) + const dehydrated = dehydrate (client, { shouldDehydrateQuery: query => + query.state.status !== 'pending' + && !(query.queryKey[0] === 'help' && query.queryKey[1] === 'edition') }) + const state = { ...dehydrated, queries: dehydrated.queries.map (query => ({ + ...query, state: { ...query.state, fetchFailureReason: null, + error: query.state.error == null ? null : { + isAxiosError: true, response: { + status: isApiError (query.state.error) ? query.state.error.response?.status : 500 } } } + })) } const bootstrap = JSON.stringify ({ state, status, locale, url: url.pathname + url.search }) .replace (/ { && { baseURL: currentRenderContext ()!.apiBaseURL, timeout: 10_000 }), ...(locale != null && { params: { locale, ...config.params } }), headers: { 'X-Transfer-Code': typeof window === 'undefined' - ? '' : localStorage.getItem ('user_code') ?? '', + ? currentRenderContext ()?.transferCode ?? '' + : localStorage.getItem ('user_code') ?? '', + ...(currentRenderContext ()?.forwardedFor != null && { + 'X-Forwarded-For': currentRenderContext ()!.forwardedFor }), ...(config.headers ?? { }) } } } @@ -37,6 +40,24 @@ const apiP = async ( body?: unknown, opt?: Opt): Promise => { const res = await client[method] (path, body ?? { }, withUserCode (opt)) + if (typeof window !== 'undefined' && method === 'post' + && ['/users', '/users/verify', '/users/code/renew'].includes (path)) + { + const code = res.data?.code ?? (res.data?.valid ? (body as { code?: string })?.code : null) + if (typeof code === 'string') + { + try + { + await fetch ('/_ssr/session', { + method: 'POST', credentials: 'same-origin', + headers: { 'X-Transfer-Code': code } }) + } + catch + { + // Existing CSR authentication remains usable if the SSR endpoint is unavailable. + } + } + } if (opt?.responseType === 'blob') return res.data as T return toCamel (res.data as Record, { deep: true }) as T diff --git a/frontend/src/lib/queryKeys.ts b/frontend/src/lib/queryKeys.ts index 42a5a2c..98400ac 100644 --- a/frontend/src/lib/queryKeys.ts +++ b/frontend/src/lib/queryKeys.ts @@ -123,6 +123,8 @@ export const materialsKeys = { export const wikiKeys = { root: ['wiki'] as const, + diff: (id: string, p: { from: string | null; to: string | null }, locale: LocaleCode) => + ['wiki', locale, 'diff', id, p] as const, autolinkCandidates: (language: string, locale: LocaleCode) => ['wiki', 'autolink', locale, language] as const, index: ( @@ -147,3 +149,13 @@ export const helpKeys = { diff: (identifier: string, language: string, from: string, to: string) => ['help', 'diff', identifier, language, from, to] as const, changes: (language: string) => ['help', 'changes', language] as const } + +export const sessionKeys = { + current: ['session', 'current'] as const, + settings: (id: number) => ['session', id, 'settings'] as const, + themeSlots: (id: number) => ['session', id, 'themeSlots'] as const } + +export const theatresKeys = { + show: (id: string, locale: LocaleCode) => ['theatres', locale, id] as const, + programmes: (id: string, locale: LocaleCode) => + ['theatres', locale, id, 'programmes'] as const } diff --git a/frontend/src/lib/renderContext.ts b/frontend/src/lib/renderContext.ts index 46e5c97..0fc4954 100644 --- a/frontend/src/lib/renderContext.ts +++ b/frontend/src/lib/renderContext.ts @@ -1,7 +1,10 @@ export type RenderContext = { pathname: string origin: string - apiBaseURL?: string } + apiBaseURL?: string + transferCode?: string + forwardedFor?: string + status?: number } // The server build replaces this module with the AsyncLocalStorage implementation. diff --git a/frontend/src/lib/routeLocales.ts b/frontend/src/lib/routeLocales.ts index 7775314..8adf967 100644 --- a/frontend/src/lib/routeLocales.ts +++ b/frontend/src/lib/routeLocales.ts @@ -10,8 +10,11 @@ export type PagePreparation = { searchParams: URLSearchParams } export type PageRouteHandle = { - ssr?: (request: PagePreparation) => Promise + prepare?: (request: PagePreparation) => Promise locale?: LocaleCode + robots?: 'noindex' + authentication?: 'user' + redirectTo?: string notFound?: boolean } export type PageRoute = { handle?: PageRouteHandle } & ( diff --git a/frontend/src/lib/settings.ts b/frontend/src/lib/settings.ts index 01470ac..98d5d4b 100644 --- a/frontend/src/lib/settings.ts +++ b/frontend/src/lib/settings.ts @@ -791,7 +791,8 @@ export const seedClientThemeMode = (theme: UserSettings['theme']): void => { const prefersDarkTheme = (): boolean => - window.matchMedia ('(prefers-color-scheme: dark)').matches + typeof window !== 'undefined' + && window.matchMedia ('(prefers-color-scheme: dark)').matches export const resolveThemeMode = ( diff --git a/frontend/src/lib/useHydrated.ts b/frontend/src/lib/useHydrated.ts new file mode 100644 index 0000000..385857f --- /dev/null +++ b/frontend/src/lib/useHydrated.ts @@ -0,0 +1,9 @@ +import { useSyncExternalStore } from 'react' + +const subscribe = () => () => { } +const clientSnapshot = () => true +const serverSnapshot = () => false + + +export const useHydrated = (): boolean => + useSyncExternalStore (subscribe, clientSnapshot, serverSnapshot) diff --git a/frontend/src/lib/useKeyboardShortcuts.tsx b/frontend/src/lib/useKeyboardShortcuts.tsx index 6cd80d2..7b09be3 100644 --- a/frontend/src/lib/useKeyboardShortcuts.tsx +++ b/frontend/src/lib/useKeyboardShortcuts.tsx @@ -99,12 +99,16 @@ export const KeyboardShortcutsProvider = ({ children }: PropsWithChildren) => { const navigate = useLocaleNavigate () const [keyboardSettings, setKeyboardSettingsState] = - useState (() => getClientKeyboardSettings ()) + useState ({ enabled: true, bindings: { } }) const [registeredHandlers, setRegisteredHandlers] = useState> ({ }) const [shortcutHelpOpen, setShortcutHelpOpen] = useState (false) const registrationIdRef = useRef (0) + useEffect (() => { + setKeyboardSettingsState (getClientKeyboardSettings ()) + }, []) + const activeScope = useMemo ( () => resolveShortcutScope (location.pathname), [location.pathname], diff --git a/frontend/src/main.tsx b/frontend/src/main.tsx index 61b2186..e8d240b 100644 --- a/frontend/src/main.tsx +++ b/frontend/src/main.tsx @@ -63,17 +63,23 @@ const start = () => { if (data?.state == null || !(Array.isArray (data.state.queries))) throw new Error ('Invalid SSR query state') hydrate (client, data.state) + for (const query of data.state.queries) + { + if (query.state.status === 'error') + client.setQueryDefaults (query.queryKey, { retryOnMount: false }) + } if (client.getQueryData (localesKeys.index) == null) throw new Error ('Missing SSR locales') const url = new URL (window.location.href) const match = resolvePageRoute (url.pathname) - const prepare = match?.route.handle?.ssr - if (match == null || prepare == null) - throw new Error ('Missing SSR route preparation') - const status = await prepare ({ client, params: match.params, - locale: currentViewingLocale (), searchParams: url.searchParams }) - if (status !== data.status) - throw new Error ('Server and browser Help versions differ') + const prepare = match?.route.handle?.prepare + if (prepare != null && match != null) + { + const status = await prepare ({ client, params: match.params, + locale: currentViewingLocale (), searchParams: url.searchParams }) + if (status !== data.status) + throw new Error ('Server and browser page versions differ') + } mount (true) mounted = true for (const event of ['online', 'focus', 'pageshow']) diff --git a/frontend/src/pages/GekanatorPage.tsx b/frontend/src/pages/GekanatorPage.tsx index f99ac4d..e37d7f3 100644 --- a/frontend/src/pages/GekanatorPage.tsx +++ b/frontend/src/pages/GekanatorPage.tsx @@ -1,12 +1,13 @@ import { animate, motion, useMotionTemplate, useMotionValue } from 'framer-motion' import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query' -import { useCallback, useEffect, useMemo, useRef, useState } from 'react' +import { useCallback, useEffect, useId, useMemo, useRef, useState } from 'react' import { useLocale } from '@/lib/useLocale' import PrefetchLink from '@/components/PrefetchLink' import MainArea from '@/components/layout/MainArea' import PageHead from '@/components/common/PageHead' import { useUITranslation } from '@/lib/useUITranslation' +import { useHydrated } from '@/lib/useHydrated' import { expectedAnswerForQuestion, fetchGekanatorExtraQuestions, fetchGekanatorQuestions, @@ -3679,23 +3680,32 @@ const expectedAnswerFor = ( const GekanatorPage: FC<{ user: User | null }> = ({ user }) => { + const hydrated = useHydrated () + const initialSeed = useId () const { message } = useUITranslation ('gekanator') const locale = useLocale () const behaviourSettings = useClientBehaviourSettings () const animationMode = behaviourSettings.animation ?? 'normal' - const storedGame = useMemo (loadStoredGame, []) + const storedGame = useMemo (() => hydrated ? loadStoredGame () : null, [hydrated]) const hasStoredRestore = storedGame != null && isStoredPhase (storedGame.phase) const queryClient = useQueryClient () const isAdmin = user?.role === 'admin' const canPersistGame = user != null const [recentGames, setRecentGames] = useState ( - () => loadRecentGames ()) + () => hydrated ? loadRecentGames () : []) const [backgroundMotionMode, setBackgroundMotionMode] = useState ( - () => loadBackgroundMotionMode ()) + () => hydrated ? loadBackgroundMotionMode () : 'on') const [prefersReducedMotion, setPrefersReducedMotion] = useState (false) const [gameSeed, setGameSeed] = useState ( - storedGame?.gameSeed ?? createGameSeed ()) + storedGame?.gameSeed ?? initialSeed) const [restorePromptVisible, setRestorePromptVisible] = useState (hasStoredRestore) + useEffect (() => { + if (!hydrated) + return + setRecentGames (loadRecentGames ()) + setBackgroundMotionMode (loadBackgroundMotionMode ()) + setRestorePromptVisible (hasStoredRestore) + }, [hydrated, hasStoredRestore]) const [phase, setPhase] = useState ( hasStoredRestore ? 'intro' : storedGame?.phase ?? 'intro') const [scores, setScores] = useState> ( diff --git a/frontend/src/pages/MorePage.tsx b/frontend/src/pages/MorePage.tsx index b2d9002..f6aba5c 100644 --- a/frontend/src/pages/MorePage.tsx +++ b/frontend/src/pages/MorePage.tsx @@ -1,3 +1,5 @@ +import { useLocation } from 'react-router-dom' + import PrefetchLink from '@/components/PrefetchLink' import LocalisedText from '@/components/LocalisedText' import { menuOutline } from '@/components/TopNav' @@ -12,6 +14,7 @@ import type { User } from '@/types' const MorePage: FC = () => { + const location = useLocation () const { message } = useUITranslation ('shared') const menu = menuOutline ( { tag: null, wikiId: null, user: { } as User, pathName: location.pathname }) diff --git a/frontend/src/pages/deerjikists/DeerjikistDetailPage.tsx b/frontend/src/pages/deerjikists/DeerjikistDetailPage.tsx index a930a59..489c486 100644 --- a/frontend/src/pages/deerjikists/DeerjikistDetailPage.tsx +++ b/frontend/src/pages/deerjikists/DeerjikistDetailPage.tsx @@ -39,8 +39,8 @@ const DeerjikistDetailPage: FC = () => { const deerjikists = useMemo (() => qData?.deerjikists ?? [], [qData]) const [data, setData] = - useState<(Omit & { platform: Platform | null })[]> ([]) - const [disabled, setDisabled] = useState (true) + useState<(Omit & { platform: Platform | null })[]> (deerjikists) + const [disabled, setDisabled] = useState (tag == null) const { baseErrors, fieldErrors, clearValidationErrors, applyValidationError } = useValidationErrors () diff --git a/frontend/src/pages/materials/MaterialDetailPage.tsx b/frontend/src/pages/materials/MaterialDetailPage.tsx index 0f62b28..957d2c8 100644 --- a/frontend/src/pages/materials/MaterialDetailPage.tsx +++ b/frontend/src/pages/materials/MaterialDetailPage.tsx @@ -23,6 +23,7 @@ import { localeLanguage } from '@/lib/locales' import { inputClass } from '@/lib/utils' import { useValidationErrors } from '@/lib/useValidationErrors' import { useUITranslation } from '@/lib/useUITranslation' +import NotFound from '@/pages/NotFound' import type { FC } from 'react' @@ -34,19 +35,20 @@ const MaterialDetailPage: FC = () => { const { message, label } = useUITranslation ('materials') const { id } = useParams () const qc = useQueryClient () + const { data: material, isError, isLoading } = useQuery ({ + queryKey: materialsKeys.show (id ?? '', locale), + queryFn: () => fetchMaterial (id ?? '', locale), + enabled: id != null }) - const [exportPath, setExportPath] = useState ('') + const [exportPath, setExportPath] = useState (material?.exportPaths.legacyDrive ?? '') const [file, setFile] = useState (null) - const [filePreview, setFilePreview] = useState ('') - const [tag, setTag] = useState ('') - const [url, setURL] = useState ('') + const [filePreview, setFilePreview] = useState ( + material?.contentType != null ? material.file ?? '' : '') + const [tag, setTag] = useState (material?.tag?.name ?? '') + const [url, setURL] = useState (material?.url ?? '') const { baseErrors, fieldErrors, clearValidationErrors, applyValidationError } = useValidationErrors () - const { data: material, isError, isLoading } = useQuery ({ - queryKey: materialsKeys.show (id ?? '', locale), - queryFn: () => fetchMaterial (id ?? '', locale), - enabled: id != null}) const materialTitle = material ? material.tag?.name ?? message ('unnamed', { id: material.id }).text : '' @@ -97,6 +99,9 @@ const MaterialDetailPage: FC = () => { updateMutation.mutate () } + if (!isLoading && !isError && material == null) + return + return ( = ({ user }) => { const embedRef = useRef (null) - const [status, setStatus] = useState (200) + const status = (errorFlg && isApiError (error)) ? error.response?.status ?? 200 : 200 const changeViewedFlg = useMutation ({ mutationFn: async (next: boolean) => { @@ -79,19 +79,9 @@ const PostDetailPage: FC = ({ user }) => { onSuccess: () => { qc.invalidateQueries ({ queryKey: postsKeys.root })} }) - useEffect (() => { - if (!(errorFlg)) - return - - const code = isApiError (error) ? error.response?.status : undefined - if (code) - setStatus (code) - }, [errorFlg, error]) - useEffect (() => { scroll (0, 0) - setStatus (200) }, [id]) switch (status) diff --git a/frontend/src/pages/posts/PostImportReviewPage.tsx b/frontend/src/pages/posts/PostImportReviewPage.tsx index 5fed86a..d7206ef 100644 --- a/frontend/src/pages/posts/PostImportReviewPage.tsx +++ b/frontend/src/pages/posts/PostImportReviewPage.tsx @@ -614,7 +614,8 @@ const PostImportReviewPage: FC = ({ user }) => { ? { duration: .08, ease: 'linear' as const } : { duration: .2, ease: 'easeOut' as const } - const [rows, setRows] = useState (null) + const [rows, setRows] = useState ( + () => buildInitialRows (parsePostNewReviewUrls (location.search))) const [metadataLoading, setMetadataLoading] = useState (false) const [submitting, setSubmitting] = useState (false) const [busyRowIds, setBusyRowIds] = useState> (new Set ()) diff --git a/frontend/src/pages/posts/PostListPage.tsx b/frontend/src/pages/posts/PostListPage.tsx index cb2942e..ee1163b 100644 --- a/frontend/src/pages/posts/PostListPage.tsx +++ b/frontend/src/pages/posts/PostListPage.tsx @@ -23,6 +23,7 @@ import { import { useKeyboardShortcuts } from '@/lib/useKeyboardShortcuts' import { fetchWikiPageByTitle } from '@/lib/wiki' import { useUITranslation } from '@/lib/useUITranslation' +import { useHydrated } from '@/lib/useHydrated' import type { FC } from 'react' @@ -36,6 +37,7 @@ const parseLimit = (value: string | null): 20 | 50 | 100 | null => { const PostListPage: FC = () => { const locale = useLocale () + const hydrated = useHydrated () const { message, label, siteTitle } = useUITranslation ('posts') const containerRef = useRef (null) @@ -52,7 +54,7 @@ const PostListPage: FC = () => { const page = Number (query.get ('page') ?? 1) const limit = ( parseLimit (query.get ('limit')) - ?? getClientListLimit ('postList') + ?? (hydrated ? getClientListLimit ('postList') : null) ?? DEFAULT_POST_LIST_LIMIT ) const order = 'original_created_at:desc' as const @@ -70,8 +72,10 @@ const PostListPage: FC = () => { const totalPages = data ? Math.ceil (data.count / limit) : 0 useEffect (() => { + if (!hydrated) + return setClientListSettings ('postList', { limit }) - }, [limit]) + }, [hydrated, limit]) const updatePage = (nextPage: number) => { const params = new URLSearchParams (location.search) diff --git a/frontend/src/pages/posts/PostSearchPage.tsx b/frontend/src/pages/posts/PostSearchPage.tsx index 6428426..f2b1c78 100644 --- a/frontend/src/pages/posts/PostSearchPage.tsx +++ b/frontend/src/pages/posts/PostSearchPage.tsx @@ -31,6 +31,7 @@ import { useClientBehaviourSettings } from '@/lib/useClientBehaviourSettings' import { useKeyboardShortcuts } from '@/lib/useKeyboardShortcuts' import { localisedDateString, inputClass, originalCreatedAtString } from '@/lib/utils' import { useUITranslation } from '@/lib/useUITranslation' +import { useHydrated } from '@/lib/useHydrated' import type { FC, FormEvent } from 'react' @@ -73,6 +74,7 @@ const parseOrder = (value: string | null): FetchPostsOrder | null => { const PostSearchPage: FC = () => { const locale = useLocale () + const hydrated = useHydrated () const { message, label } = useUITranslation ('posts') const behaviourSettings = useClientBehaviourSettings () const animationMode = behaviourSettings.animation ?? 'normal' @@ -91,7 +93,7 @@ const PostSearchPage: FC = () => { const page = Number (query.get ('page') ?? 1) const limit = ( parseLimit (query.get ('limit')) - ?? getClientListLimit ('postSearch') + ?? (hydrated ? getClientListLimit ('postSearch') : null) ?? DEFAULT_POST_LIST_LIMIT ) @@ -107,7 +109,7 @@ const PostSearchPage: FC = () => { const qUpdatedTo = query.get ('updated_to') ?? '' const order = ( parseOrder (query.get ('order')) - ?? getClientListOrder ('postSearch') + ?? (hydrated ? getClientListOrder ('postSearch') : null) ?? DEFAULT_POST_LIST_ORDER ) @@ -140,8 +142,10 @@ const PostSearchPage: FC = () => { const totalPages = data ? Math.ceil (data.count / limit) : 0 useEffect (() => { + if (!hydrated) + return setClientListSettings ('postSearch', { limit, order }) - }, [limit, order]) + }, [hydrated, limit, order]) useEffect (() => { setURL (qURL ?? '') diff --git a/frontend/src/pages/tags/TagDetailPage.tsx b/frontend/src/pages/tags/TagDetailPage.tsx index c0e9314..84eb58f 100644 --- a/frontend/src/pages/tags/TagDetailPage.tsx +++ b/frontend/src/pages/tags/TagDetailPage.tsx @@ -11,7 +11,8 @@ import MainArea from '@/components/layout/MainArea' import PageHead from '@/components/common/PageHead' import { toast } from '@/components/ui/use-toast' import { CATEGORIES } from '@/consts' -import { apiPut } from '@/lib/api' +import { apiPut, isApiError } from '@/lib/api' +import NotFound from '@/pages/NotFound' import { postsKeys, tagsKeys, wikiKeys } from '@/lib/queryKeys' import { fetchTag } from '@/lib/tags' import { cn, inputClass } from '@/lib/utils' @@ -37,16 +38,17 @@ const TagDetailPage: FC = () => { const tagId = String (id ?? '') const tagKey = tagsKeys.show (tagId, locale) - const { data: tag, isLoading: loading } = useQuery ({ + const { data: tag, isLoading: loading, error } = useQuery ({ queryKey: tagKey, queryFn: () => fetchTag (tagId, locale) }) - const [name, setName] = useState ('') - const [category, setCategory] = useState ('general') - const [aliases, setAliases] = useState ('') - const [parentTags, setParentTags] = useState ('') - const [deprecated, setDeprecated] = useState (false) - const [disabled, setDisabled] = useState (true) + const [name, setName] = useState (tag?.name ?? '') + const [category, setCategory] = useState (tag?.category ?? 'general') + const [aliases, setAliases] = useState (tag?.aliases.join (' ') ?? '') + const [parentTags, setParentTags] = useState (tag?.parents.map (entry => entry.name).join (' ') + ?? '') + const [deprecated, setDeprecated] = useState (tag?.deprecatedAt != null) + const [disabled, setDisabled] = useState (tag == null || tag.category === 'nico') const { baseErrors, fieldErrors, clearValidationErrors, applyValidationError } = useValidationErrors () @@ -100,6 +102,9 @@ const TagDetailPage: FC = () => { setDisabled (tag.category === 'nico') }, [tag]) + if ((!loading && tag == null) || (isApiError (error) && error.response?.status === 404)) + return + return ( diff --git a/frontend/src/pages/tags/TagListPage.tsx b/frontend/src/pages/tags/TagListPage.tsx index 16b54de..623dfbe 100644 --- a/frontend/src/pages/tags/TagListPage.tsx +++ b/frontend/src/pages/tags/TagListPage.tsx @@ -27,6 +27,7 @@ import { import { useClientBehaviourSettings } from '@/lib/useClientBehaviourSettings' import { useKeyboardShortcuts } from '@/lib/useKeyboardShortcuts' import { useUITranslation } from '@/lib/useUITranslation' +import { useHydrated } from '@/lib/useHydrated' import { fetchTags } from '@/lib/tags' import { inputClass, localisedDateString } from '@/lib/utils' @@ -74,6 +75,7 @@ const parseOrder = (value: string | null): FetchTagsOrder | null => { const TagListPage: FC = () => { const locale = useLocale () + const hydrated = useHydrated () const { message, label } = useUITranslation ('tags') const behaviourSettings = useClientBehaviourSettings () const animationMode = behaviourSettings.animation ?? 'normal' @@ -86,7 +88,7 @@ const TagListPage: FC = () => { const page = Number (query.get ('page') ?? 1) const limit = ( parseLimit (query.get ('limit')) - ?? getClientListLimit ('tagList') + ?? (hydrated ? getClientListLimit ('tagList') : null) ?? DEFAULT_POST_LIST_LIMIT ) @@ -105,7 +107,7 @@ const TagListPage: FC = () => { : null const order = ( parseOrder (query.get ('order')) - ?? getClientListOrder ('tagList') + ?? (hydrated ? getClientListOrder ('tagList') : null) ?? DEFAULT_TAG_LIST_ORDER ) @@ -138,8 +140,10 @@ const TagListPage: FC = () => { const totalPages = data ? Math.ceil (data.count / limit) : 0 useEffect (() => { + if (!hydrated) + return setClientListSettings ('tagList', { limit, order }) - }, [limit, order]) + }, [hydrated, limit, order]) useEffect (() => { setName (qName) diff --git a/frontend/src/pages/theatres/TheatreDetailPage.tsx b/frontend/src/pages/theatres/TheatreDetailPage.tsx index f3e4097..3a6d1c2 100644 --- a/frontend/src/pages/theatres/TheatreDetailPage.tsx +++ b/frontend/src/pages/theatres/TheatreDetailPage.tsx @@ -1,4 +1,5 @@ import { motion } from 'framer-motion' +import { useQuery } from '@tanstack/react-query' import { useCallback, useEffect, useMemo, useRef, useState } from 'react' import { useParams } from 'react-router-dom' @@ -19,6 +20,7 @@ import { apiDelete, apiGet, apiPatch, apiPost, apiPut, isApiError } from '@/lib/ import { clientAnimationTransition, clientScrollBehaviour } from '@/lib/clientAnimation' import { fetchPost } from '@/lib/posts' +import { theatresKeys } from '@/lib/queryKeys' import { getClientTheatreLayoutMode, getClientTheatreTagFlow, @@ -234,23 +236,30 @@ const TheatreDetailPage: FC = ({ user }: Props) => { const [leftSidebarWidth, setLeftSidebarWidth] = useState (256) const [rightSidebarWidth, setRightSidebarWidth] = useState (256) - const [sidebarContainerWidth, setSidebarContainerWidth] = useState ( - () => typeof window === 'undefined' ? 0 : window.innerWidth) + const [sidebarContainerWidth, setSidebarContainerWidth] = useState (0) const [comments, setComments] = useState ([]) const [content, setContent] = useState ('') const [editingPost, setEditingPost] = useState (null) const [loading, setLoading] = useState (false) - const [programmes, setProgrammes] = useState ([]) + const { data: initialTheatre, error: theatreError } = useQuery ({ + queryKey: theatresKeys.show (id ?? '', locale), + queryFn: () => apiGet (`/theatres/${ id }`, { locale }), + enabled: id != null }) + const { data: initialProgrammes } = useQuery ({ + queryKey: theatresKeys.programmes (id ?? '', locale), + queryFn: () => apiGet (`/theatres/${ id }/programmes`, { + locale, params: { limit: '100' } }), enabled: id != null }) + const [programmes, setProgrammes] = useState (initialProgrammes ?? []) const [sending, setSending] = useState (false) - const [status, setStatus] = useState (200) - const [theatre, setTheatre] = useState (null) + const status = isApiError (theatreError) ? theatreError.response?.status ?? 200 : 200 + const [theatre, setTheatre] = useState (initialTheatre ?? null) const [theatreInfo, setTheatreInfo] = useState (INITIAL_THEATRE_INFO) const [post, setPost] = useState (null) const [videoLength, setVideoLength] = useState (0) const [weights, setWeights] = useState (INITIAL_WEIGHTS) const [layoutMode, setLayoutMode] = - useState (() => getClientTheatreLayoutMode ()) - const [tagFlow, setTagFlow] = useState (() => getClientTheatreTagFlow ()) + useState ('threeColumns') + const [tagFlow, setTagFlow] = useState ('vertical') const { fieldErrors, clearValidationErrors, applyValidationError } = useValidationErrors () @@ -259,6 +268,11 @@ const TheatreDetailPage: FC = ({ user }: Props) => { setClientTheatreLayoutMode (mode) } + useEffect (() => { + setLayoutMode (getClientTheatreLayoutMode ()) + setTagFlow (getClientTheatreTagFlow ()) + }, []) + const changeTagFlow = (flow: TagFlow) => { setTagFlow (flow) setClientTheatreTagFlow (flow) @@ -348,39 +362,20 @@ const TheatreDetailPage: FC = ({ user }: Props) => { if (!(id)) return - let cancelled = false - setComments ([]) setEditingPost (null) setPost (null) - setProgrammes ([]) - setTheatre (null) + setProgrammes (initialProgrammes ?? []) + setTheatre (initialTheatre ?? null) theatreInfoReceivedAtRef.current = performance.now () setTheatreInfo (INITIAL_THEATRE_INFO) setVideoLength (0) setWeights (INITIAL_WEIGHTS) lastCommentNoRef.current = 0 - void (async () => { - try - { - const data = await apiGet (`/theatres/${ id }`, { locale }) - if (!(cancelled)) - setTheatre (data) - } - catch (error) - { - setStatus (isApiError (error) ? error.response?.status ?? 200 : 200) - } - }) () - - void refreshProgrammes () void refreshWeights () - return () => { - cancelled = true - } - }, [id, refreshProgrammes, refreshWeights, locale]) + }, [id, initialProgrammes, initialTheatre, refreshWeights, locale]) useEffect (() => { if (!(id)) diff --git a/frontend/src/pages/users/SettingPage.tsx b/frontend/src/pages/users/SettingPage.tsx index b48e339..5d4d3bc 100644 --- a/frontend/src/pages/users/SettingPage.tsx +++ b/frontend/src/pages/users/SettingPage.tsx @@ -1,4 +1,5 @@ import { useCallback, useEffect, useMemo, useState } from 'react' +import { useQuery } from '@tanstack/react-query' import { useBeforeUnload, useLocation } from 'react-router-dom' import { useLocale, useLocaleNavigate } from '@/lib/useLocale' @@ -42,6 +43,8 @@ import { useKeyboardShortcutSettings, useKeyboardShortcuts } from '@/lib/useKeyb import { cn, inputClass } from '@/lib/utils' import { useValidationErrors } from '@/lib/useValidationErrors' import { useUITranslation } from '@/lib/useUITranslation' +import { useHydrated } from '@/lib/useHydrated' +import { sessionKeys } from '@/lib/queryKeys' import type { CSSProperties, Dispatch, FC, KeyboardEvent, ReactNode, SetStateAction } from 'react' @@ -657,6 +660,13 @@ const ThemeSection: FC = props => { const SettingPage: FC = ({ user, setUser }) => { + const hydrated = useHydrated () + const { data: initialSettings } = useQuery ({ + queryKey: sessionKeys.settings (user?.id ?? 0), + queryFn: fetchUserSettings, enabled: user != null }) + const { data: initialThemeSlots } = useQuery ({ + queryKey: sessionKeys.themeSlots (user?.id ?? 0), + queryFn: fetchUserThemeSlots, enabled: user != null }) const { message, label } = useUITranslation ('settings') const locale = useLocale () const location = useLocation () @@ -665,25 +675,27 @@ const SettingPage: FC = ({ user, setUser }) => { const { registerUnsavedChangesSource } = useUnsavedChangesGuard () const [inheritVsbl, setInheritVsbl] = useState (false) - const [loading, setLoading] = useState (true) - const [name, setName] = useState ('') + const [loading, setLoading] = useState (initialSettings == null) + const [name, setName] = useState (user?.name ?? '') const [settingsError, setSettingsError] = useState (null) const [savedThemeMode, setSavedThemeMode] = - useState (getClientThemeMode ()) + useState (initialSettings?.theme ?? 'system') const [draftThemeMode, setDraftThemeMode] = - useState (getClientThemeMode ()) + useState (initialSettings?.theme ?? 'system') const [savedActiveLightThemeSlotNo, setSavedActiveLightThemeSlotNo] = - useState (getClientActiveLightThemeSlotNo ()) + useState (1) const [draftActiveLightThemeSlotNo, setDraftActiveLightThemeSlotNo] = - useState (getClientActiveLightThemeSlotNo ()) + useState (1) const [savedActiveDarkThemeSlotNo, setSavedActiveDarkThemeSlotNo] = - useState (getClientActiveDarkThemeSlotNo ()) + useState (1) const [draftActiveDarkThemeSlotNo, setDraftActiveDarkThemeSlotNo] = - useState (getClientActiveDarkThemeSlotNo ()) + useState (1) const [savedThemeSlots, setSavedThemeSlots] = - useState (() => ensureCompleteThemeSlots ({ })) + useState (() => ensureCompleteThemeSlots ( + normaliseUserThemeSlots (initialThemeSlots ?? []))) const [draftThemeSlots, setDraftThemeSlots] = - useState (() => ensureCompleteThemeSlots ({ })) + useState (() => ensureCompleteThemeSlots ( + normaliseUserThemeSlots (initialThemeSlots ?? []))) const [dirtyStates, setDirtyStates] = useState>> ({ }) const [userCodeVsbl, setUserCodeVsbl] = useState (false) @@ -701,10 +713,10 @@ const SettingPage: FC = ({ user, setUser }) => { const activeTab = useMemo (() => parseTab (location.search), [location.search]) const displayedThemeSelection = useMemo ( () => resolveDisplayedTheme ({ - themeMode: draftThemeMode, + themeMode: (!hydrated && draftThemeMode === 'system') ? 'light' : draftThemeMode, activeLightThemeSlotNo: draftActiveLightThemeSlotNo, activeDarkThemeSlotNo: draftActiveDarkThemeSlotNo }, draftThemeSlots), - [draftActiveDarkThemeSlotNo, + [hydrated, draftActiveDarkThemeSlotNo, draftActiveLightThemeSlotNo, draftThemeMode, draftThemeSlots]) diff --git a/frontend/src/pages/wiki/WikiDiffPage.tsx b/frontend/src/pages/wiki/WikiDiffPage.tsx index 29cb993..8607307 100644 --- a/frontend/src/pages/wiki/WikiDiffPage.tsx +++ b/frontend/src/pages/wiki/WikiDiffPage.tsx @@ -1,6 +1,6 @@ import type { FC } from 'react' -import { useEffect, useState } from 'react' +import { useQuery } from '@tanstack/react-query' import { useLocation, useParams } from 'react-router-dom' import { useLocale } from '@/lib/useLocale' @@ -9,6 +9,7 @@ import PageTitle from '@/components/common/PageTitle' import MainArea from '@/components/layout/MainArea' import PageHead from '@/components/common/PageHead' import { apiGet } from '@/lib/api' +import { wikiKeys } from '@/lib/queryKeys' import { cn } from '@/lib/utils' import type { WikiPageDiff } from '@/types' @@ -21,43 +22,18 @@ const WikiDiffPage: FC = () => { const location = useLocation () - const [result, setResult] = useState<{ - key: string - diff: WikiPageDiff } | null> (null) - const query = new URLSearchParams (location.search) const from = query.get ('from') const to = query.get ('to') - const requestKey = JSON.stringify ([locale, id, from, to]) - const diff = result?.key === requestKey ? result.diff : null + const { data: diff } = useQuery ({ + queryKey: wikiKeys.diff (id ?? '', { from, to }, locale), + queryFn: () => apiGet ( + `/wiki/${ encodeURIComponent (id ?? '') }/diff`, { locale, params: { from, to } }) }) const displayTitle = diff ? `${ diff.title }${ diff.deprecatedAt != null ? message ('shared:labels.deprecatedSuffix').text : '' }` : '' - useEffect (() => { - const controller = new AbortController () - let active = true - void (async () => { - try - { - const diff = await apiGet (`/wiki/${ encodeURIComponent (id ?? '') }/diff`, { - locale, signal: controller.signal, params: { from, to } }) - if (active) - setResult ({ key: requestKey, diff }) - } - catch (error) - { - if (active) - console.error (error) - } - }) () - return () => { - active = false - controller.abort () - } - }, [from, id, to, locale, requestKey]) - return ( diff --git a/frontend/src/pages/wiki/WikiEditPage.tsx b/frontend/src/pages/wiki/WikiEditPage.tsx index 5368ef4..84934a6 100644 --- a/frontend/src/pages/wiki/WikiEditPage.tsx +++ b/frontend/src/pages/wiki/WikiEditPage.tsx @@ -1,4 +1,4 @@ -import { useQueryClient } from '@tanstack/react-query' +import { useQuery, useQueryClient } from '@tanstack/react-query' import MarkdownIt from 'markdown-it' import { useEffect, useState } from 'react' import MdEditor from 'react-markdown-editor-lite' @@ -47,12 +47,15 @@ const WikiEditPage: FC = ({ user }) => { const qc = useQueryClient () - const [body, setBody] = useState ('') - const [loading, setLoading] = useState (true) - const [loadedSource, setLoadedSource] = useState ('') const source = `${locale}:${id}` + const { data: wiki, isPending: loading } = useQuery ({ + queryKey: wikiKeys.show (id ?? '', { }, locale), + queryFn: () => apiGet (`/wiki/${ encodeURIComponent (id ?? '') }`, { locale }), + enabled: editable }) + const [body, setBody] = useState (wiki?.body ?? '') + const [loadedSource, setLoadedSource] = useState (wiki == null ? '' : source) const pending = loading || loadedSource !== source - const [title, setTitle] = useState ('') + const [title, setTitle] = useState (wiki?.title ?? '') const { baseErrors, fieldErrors, clearValidationErrors, applyValidationError } = useValidationErrors () @@ -85,36 +88,12 @@ const WikiEditPage: FC = ({ user }) => { } useEffect (() => { - if (!(editable)) + if (!(editable) || wiki == null) return - - const controller = new AbortController () - let active = true - void (async () => { - setLoading (true) - try - { - const data = await apiGet (`/wiki/${ encodeURIComponent (id ?? '') }`, { - locale, signal: controller.signal }) - if (active) - { - setTitle (data.title) - setBody (data.body) - setLoadedSource (source) - setLoading (false) - } - } - catch (error) - { - if (active) - console.error (error) - } - }) () - return () => { - active = false - controller.abort () - } - }, [editable, id, locale, source]) + setTitle (wiki.title) + setBody (wiki.body) + setLoadedSource (source) + }, [editable, source, wiki]) if (!(editable)) return diff --git a/frontend/src/pages/wiki/WikiSearchPage.tsx b/frontend/src/pages/wiki/WikiSearchPage.tsx index e9a0517..412ca15 100644 --- a/frontend/src/pages/wiki/WikiSearchPage.tsx +++ b/frontend/src/pages/wiki/WikiSearchPage.tsx @@ -1,4 +1,5 @@ import { useEffect, useState } from 'react' +import { useQuery } from '@tanstack/react-query' import { useLocale } from '@/lib/useLocale' import { useUITranslation } from '@/lib/useUITranslation' @@ -8,6 +9,7 @@ import PageTitle from '@/components/common/PageTitle' import MainArea from '@/components/layout/MainArea' import PageHead from '@/components/common/PageHead' import { apiGet } from '@/lib/api' +import { wikiKeys } from '@/lib/queryKeys' import { dateString, inputClass } from '@/lib/utils' import type { FormEvent , FC } from 'react' @@ -20,21 +22,22 @@ const WikiSearchPage: FC = () => { const locale = useLocale () const [title, setTitle] = useState ('') const [text, setText] = useState ('') - const [results, setResults] = useState ([]) - - const search = async () => { - setResults (await apiGet ('/wiki', { locale, params: { title } })) - } + const [submittedTitle, setSubmittedTitle] = useState ('') + const { data: results = [], refetch } = useQuery ({ + queryKey: wikiKeys.index ({ title: submittedTitle }, locale), + queryFn: () => apiGet ('/wiki', { + locale, params: { title: submittedTitle } }) }) const handleSearch = (ev: FormEvent) => { ev.preventDefault () - search () + if (submittedTitle === title) + void refetch () + else + setSubmittedTitle (title) } useEffect (() => { - void (async () => { - setResults (await apiGet ('/wiki', { locale, params: { title: '' } })) - }) () + setSubmittedTitle ('') }, [locale]) return ( diff --git a/frontend/vite.config.ts b/frontend/vite.config.ts index 2f0a60c..10e0780 100644 --- a/frontend/vite.config.ts +++ b/frontend/vite.config.ts @@ -29,7 +29,8 @@ export default defineConfig ({ return path.resolve (__dirname, './src/lib/renderContext.server.ts') } }, helpAssets (), mdxPlugin, react ()], resolve: { alias: { '@': path.resolve (__dirname, './src') } }, - ssr: { noExternal: ['react-helmet-async'] }, + ssr: { noExternal: [ + 'react-helmet-async', 'react-markdown-editor-lite', 'eventemitter3'] }, server: { host: true, port: 5173, strictPort: true,