このコミットが含まれているのは:
@@ -21,6 +21,12 @@ class UsersController < ApplicationController
|
|||||||
render json: { valid: true, user: user.slice(:id, :name, :inheritance_code, :role) }
|
render json: { valid: true, user: user.slice(:id, :name, :inheritance_code, :role) }
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def session
|
||||||
|
return head :unauthorized unless current_user
|
||||||
|
|
||||||
|
render json: current_user.slice(:id, :name, :role)
|
||||||
|
end
|
||||||
|
|
||||||
def renew
|
def renew
|
||||||
return head :unauthorized unless current_user
|
return head :unauthorized unless current_user
|
||||||
|
|
||||||
|
|||||||
@@ -100,6 +100,7 @@ Rails.application.routes.draw do
|
|||||||
collection do
|
collection do
|
||||||
post :verify
|
post :verify
|
||||||
get :me
|
get :me
|
||||||
|
get :session
|
||||||
post 'code/renew', action: :renew
|
post 'code/renew', action: :renew
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -13,6 +13,24 @@ RSpec.describe 'Users', type: :request do
|
|||||||
{ 'X-Transfer-Code' => user.inheritance_code }
|
{ 'X-Transfer-Code' => user.inheritance_code }
|
||||||
end
|
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
|
describe 'POST /users' do
|
||||||
it 'creates guest user, IpAddress and UserIp, and returns code' do
|
it 'creates guest user, IpAddress and UserIp, and returns code' do
|
||||||
expect {
|
expect {
|
||||||
|
|||||||
@@ -12,6 +12,7 @@
|
|||||||
"lint": "eslint .",
|
"lint": "eslint .",
|
||||||
"test": "vitest",
|
"test": "vitest",
|
||||||
"test:run": "vitest run",
|
"test:run": "vitest run",
|
||||||
|
"test:ssr": "node --test server.ssr.test.mjs",
|
||||||
"preview": "vite preview --outDir dist/client"
|
"preview": "vite preview --outDir dist/client"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
|||||||
+82
-6
@@ -1,6 +1,7 @@
|
|||||||
import { createReadStream } from 'node:fs'
|
import { createReadStream } from 'node:fs'
|
||||||
import { readFile, stat } from 'node:fs/promises'
|
import { readFile, stat } from 'node:fs/promises'
|
||||||
import { createServer } from 'node:http'
|
import { createServer } from 'node:http'
|
||||||
|
import { createCipheriv, createDecipheriv, createHash, randomBytes } from 'node:crypto'
|
||||||
import path from 'node:path'
|
import path from 'node:path'
|
||||||
import { fileURLToPath } from 'node:url'
|
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 template = await readFile (path.join (root, 'index.html'), 'utf8')
|
||||||
const origin = new URL (process.env.PUBLIC_ORIGIN ?? 'http://localhost:5173').origin
|
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 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 = {
|
const types = {
|
||||||
'.js': 'text/javascript', '.css': 'text/css', '.json': 'application/json',
|
'.js': 'text/javascript', '.css': 'text/css', '.json': 'application/json',
|
||||||
'.png': 'image/png', '.gif': 'image/gif', '.jpg': 'image/jpeg',
|
'.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)
|
if (process.env.NODE_ENV === 'production' && !process.env.PUBLIC_ORIGIN)
|
||||||
throw new Error ('PUBLIC_ORIGIN is required in production')
|
throw new Error ('PUBLIC_ORIGIN is required in production')
|
||||||
|
|
||||||
createServer (async (request, response) => {
|
const server = createServer (async (request, response) => {
|
||||||
response.setHeader ('Cache-Control', 'no-store')
|
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))
|
if (!['GET', 'HEAD'].includes (request.method))
|
||||||
{
|
{
|
||||||
response.writeHead (405, { Allow: 'GET, HEAD' }).end ()
|
response.writeHead (405, { Allow: 'GET, HEAD' }).end ()
|
||||||
@@ -54,7 +123,8 @@ createServer (async (request, response) => {
|
|||||||
response.writeHead (404).end ()
|
response.writeHead (404).end ()
|
||||||
return
|
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)
|
if (result?.location != null)
|
||||||
{
|
{
|
||||||
response.writeHead (result.status, { Location: result.location }).end ()
|
response.writeHead (result.status, { Location: result.location }).end ()
|
||||||
@@ -77,7 +147,13 @@ createServer (async (request, response) => {
|
|||||||
}
|
}
|
||||||
catch (error)
|
catch (error)
|
||||||
{
|
{
|
||||||
console.error (error)
|
console.error ('[SSR]', error instanceof Error ? error.message : 'Rendering failed')
|
||||||
response.writeHead (error instanceof URIError ? 400 : 503).end ()
|
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)
|
||||||
|
})
|
||||||
|
|||||||
@@ -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, /<title/)
|
||||||
|
assert.match (result.head, /rel="canonical"/)
|
||||||
|
assert.equal (result.locale, locale)
|
||||||
|
assert.doesNotMatch (result.html + result.bootstrap, /test-secret/)
|
||||||
|
if (route.path === 'wiki/:id/edit') assert.match (result.html, /<textarea/)
|
||||||
|
if (route.path === 'users/settings') assert.match (result.html, /SSR account/)
|
||||||
|
if (route.path === 'posts')
|
||||||
|
assert.match (result.html, new RegExp (`SSR post ${locale}`))
|
||||||
|
if (route.path === 'posts/:id')
|
||||||
|
assert.match (result.head, new RegExp (`SSR post ${locale}`))
|
||||||
|
if (route.path === 'theatres/:id')
|
||||||
|
assert.match (result.html.replace (/<[^>]*>/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 ()
|
||||||
|
}
|
||||||
|
})
|
||||||
+26
-13
@@ -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 { AnimatePresence, LayoutGroup, MotionConfig, motion } from 'framer-motion'
|
||||||
import { Suspense, lazy, useEffect, useLayoutEffect, useMemo, useState } from 'react'
|
import { Suspense, lazy, useEffect, useLayoutEffect, useMemo, useState } from 'react'
|
||||||
import { useTranslation } from 'react-i18next'
|
import { useTranslation } from 'react-i18next'
|
||||||
@@ -20,7 +20,7 @@ import { apiPost, isApiError } from '@/lib/api'
|
|||||||
import { prepareHelpPage } from '@/lib/help'
|
import { prepareHelpPage } from '@/lib/help'
|
||||||
import { fetchLocales, isLocaleCode, legacyLocalePath,
|
import { fetchLocales, isLocaleCode, legacyLocalePath,
|
||||||
switchLocalePath, unlocalisedPath } from '@/lib/locales'
|
switchLocalePath, unlocalisedPath } from '@/lib/locales'
|
||||||
import { localesKeys } from '@/lib/queryKeys'
|
import { localesKeys, sessionKeys } from '@/lib/queryKeys'
|
||||||
import { useLocale } from '@/lib/useLocale'
|
import { useLocale } from '@/lib/useLocale'
|
||||||
import { applyClientAnimationMode,
|
import { applyClientAnimationMode,
|
||||||
applyClientAppearance,
|
applyClientAppearance,
|
||||||
@@ -99,9 +99,11 @@ export const pageRoutes = (
|
|||||||
user: User | null,
|
user: User | null,
|
||||||
setUser: Dispatch<SetStateAction<User | null>>,
|
setUser: Dispatch<SetStateAction<User | null>>,
|
||||||
): PageRoute[] => [
|
): PageRoute[] => [
|
||||||
{ path: '', element: <LocaleRedirect to="/posts"/> },
|
{ path: '', element: <LocaleRedirect to="/posts"/>,
|
||||||
|
handle: { redirectTo: '/posts' } },
|
||||||
{ path: 'posts', element: <PostListPage/> },
|
{ 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/search', element: <PostSearchPage/> },
|
||||||
{ path: 'posts/:id', element: <PostDetailRoute user={user}/> },
|
{ path: 'posts/:id', element: <PostDetailRoute user={user}/> },
|
||||||
{ path: 'posts/changes', element: <PostHistoryPage/> },
|
{ path: 'posts/changes', element: <PostHistoryPage/> },
|
||||||
@@ -115,22 +117,27 @@ export const pageRoutes = (
|
|||||||
{ path: 'materials', element: <MaterialBasePage/>, children: [
|
{ path: 'materials', element: <MaterialBasePage/>, children: [
|
||||||
{ index: true, element: <MaterialListPage/> },
|
{ index: true, element: <MaterialListPage/> },
|
||||||
{ path: 'changes', element: <MaterialHistoryPage/> },
|
{ path: 'changes', element: <MaterialHistoryPage/> },
|
||||||
{ path: 'new', element: <MaterialNewPage/> },
|
{ path: 'new', element: <MaterialNewPage/>, handle: { robots: 'noindex' } },
|
||||||
{ path: 'suppressions', element: <MaterialSyncSuppressionsPage/> },
|
{ path: 'suppressions', element: <MaterialSyncSuppressionsPage/>,
|
||||||
|
handle: { robots: 'noindex', authentication: 'user' } },
|
||||||
{ path: ':id', element: <MaterialDetailPage/> }] },
|
{ path: ':id', element: <MaterialDetailPage/> }] },
|
||||||
{ path: 'wiki', element: <WikiSearchPage/> },
|
{ path: 'wiki', element: <WikiSearchPage/> },
|
||||||
{ path: 'wiki/:title', element: <WikiDetailPage/> },
|
{ path: 'wiki/:title', element: <WikiDetailPage/> },
|
||||||
{ path: 'wiki/new', element: <WikiNewPage user={user}/> },
|
{ path: 'wiki/new', element: <WikiNewPage user={user}/>,
|
||||||
{ path: 'wiki/:id/edit', element: <WikiEditPage user={user}/> },
|
handle: { robots: 'noindex' } },
|
||||||
|
{ path: 'wiki/:id/edit', element: <WikiEditPage user={user}/>,
|
||||||
|
handle: { robots: 'noindex' } },
|
||||||
{ path: 'wiki/:id/diff', element: <WikiDiffPage/> },
|
{ path: 'wiki/:id/diff', element: <WikiDiffPage/> },
|
||||||
{ path: 'wiki/changes', element: <WikiHistoryPage/> },
|
{ path: 'wiki/changes', element: <WikiHistoryPage/> },
|
||||||
{ path: 'help', element: <HelpListPage/> },
|
{ path: 'help', element: <HelpListPage/> },
|
||||||
{ path: 'help/changes', element: <HelpChangesPage/> },
|
{ path: 'help/changes', element: <HelpChangesPage/> },
|
||||||
{ path: 'help/:identifier/diff', element: <HelpDiffPage/> },
|
{ path: 'help/:identifier/diff', element: <HelpDiffPage/> },
|
||||||
{ id: 'help.detail', path: 'help/:identifier', element: <HelpDetailPage/>,
|
{ id: 'help.detail', path: 'help/:identifier', element: <HelpDetailPage/>,
|
||||||
handle: { ssr: prepareHelpPage } },
|
handle: { prepare: prepareHelpPage } },
|
||||||
{ path: 'users/settings', element: <SettingPage user={user} setUser={setUser}/> },
|
{ path: 'users/settings', element: <SettingPage user={user} setUser={setUser}/>,
|
||||||
{ path: 'settings', element: <LocaleRedirect to="/users/settings"/> },
|
handle: { robots: 'noindex', authentication: 'user' } },
|
||||||
|
{ path: 'settings', element: <LocaleRedirect to="/users/settings"/>,
|
||||||
|
handle: { redirectTo: '/users/settings' } },
|
||||||
{ path: 'tos', element: <TOSPage/> },
|
{ path: 'tos', element: <TOSPage/> },
|
||||||
{ id: 'gekanator', path: 'gekanator', element: <GekanatorRoute user={user}/>,
|
{ id: 'gekanator', path: 'gekanator', element: <GekanatorRoute user={user}/>,
|
||||||
handle: { locale: 'ja' } },
|
handle: { locale: 'ja' } },
|
||||||
@@ -168,7 +175,11 @@ const PostDetailRoute = ({ user }: { user: User | null }) => {
|
|||||||
|
|
||||||
|
|
||||||
const RoutedApp = ({ locales }: { locales: AvailableLocale[] }) => {
|
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 [status, setStatus] = useState (200)
|
||||||
const behaviourSettings = useClientBehaviourSettings ()
|
const behaviourSettings = useClientBehaviourSettings ()
|
||||||
const animationMode = behaviourSettings.animation ?? 'normal'
|
const animationMode = behaviourSettings.animation ?? 'normal'
|
||||||
@@ -266,7 +277,9 @@ const RoutedApp = ({ locales }: { locales: AvailableLocale[] }) => {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<DialogueProvider>
|
<DialogueProvider>
|
||||||
<PageHead/>
|
<PageHead>
|
||||||
|
{robots != null && <meta name="robots" content={robots}/>}
|
||||||
|
</PageHead>
|
||||||
<UnsavedChangesGuardProvider>
|
<UnsavedChangesGuardProvider>
|
||||||
<KeyboardShortcutsProvider>
|
<KeyboardShortcutsProvider>
|
||||||
<MotionConfig
|
<MotionConfig
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import errorImg from '@/assets/images/not-found.gif'
|
|||||||
import MainArea from '@/components/layout/MainArea'
|
import MainArea from '@/components/layout/MainArea'
|
||||||
import PageHead from '@/components/common/PageHead'
|
import PageHead from '@/components/common/PageHead'
|
||||||
import { useUITranslation } from '@/lib/useUITranslation'
|
import { useUITranslation } from '@/lib/useUITranslation'
|
||||||
|
import { currentRenderContext } from '@/lib/renderContext'
|
||||||
|
|
||||||
import type { FC } from 'react'
|
import type { FC } from 'react'
|
||||||
|
|
||||||
@@ -10,6 +11,9 @@ type Props = { status: number }
|
|||||||
|
|
||||||
|
|
||||||
const ErrorScreen: FC<Props> = ({ status }) => {
|
const ErrorScreen: FC<Props> = ({ status }) => {
|
||||||
|
const context = currentRenderContext ()
|
||||||
|
if (context != null)
|
||||||
|
context.status = status
|
||||||
const { message: translate, label } = useUITranslation ('errors')
|
const { message: translate, label } = useUITranslation ('errors')
|
||||||
const [message]: [string, string, string] = (() => {
|
const [message]: [string, string, string] = (() => {
|
||||||
switch (status)
|
switch (status)
|
||||||
|
|||||||
@@ -65,7 +65,7 @@ const PostEditForm: FC<Props> = ({ post, onSave }) => {
|
|||||||
useState<string | null> (post.originalCreatedFrom)
|
useState<string | null> (post.originalCreatedFrom)
|
||||||
const [parentPostIds, setParentPostIds] =
|
const [parentPostIds, setParentPostIds] =
|
||||||
useState ((post.parentPosts ?? []).map (p => p.id).join (' '))
|
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 [title, setTitle] = useState (post.title)
|
||||||
|
|
||||||
const videoFlg =
|
const videoFlg =
|
||||||
|
|||||||
@@ -1,7 +1,9 @@
|
|||||||
import { Helmet } from 'react-helmet-async'
|
import { Helmet } from 'react-helmet-async'
|
||||||
|
import { useLocation } from 'react-router-dom'
|
||||||
|
|
||||||
import { useLocale } from '@/lib/useLocale'
|
import { useLocale } from '@/lib/useLocale'
|
||||||
import { useUITranslation } from '@/lib/useUITranslation'
|
import { useUITranslation } from '@/lib/useUITranslation'
|
||||||
|
import { currentRenderContext } from '@/lib/renderContext'
|
||||||
|
|
||||||
import type { ReactNode } from 'react'
|
import type { ReactNode } from 'react'
|
||||||
|
|
||||||
@@ -18,19 +20,24 @@ const PageHead = (
|
|||||||
{ title, includeSiteName = true, description: pageDescription, canonical, children }: Props,
|
{ title, includeSiteName = true, description: pageDescription, canonical, children }: Props,
|
||||||
) => {
|
) => {
|
||||||
const locale = useLocale ()
|
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 { message, siteTitle } = useUITranslation ('shared')
|
||||||
const pageTitle = typeof title === 'string' ? { text: title, language: locale } : title
|
const pageTitle = typeof title === 'string' ? { text: title, language: locale } : title
|
||||||
const fullTitle =
|
const fullTitle =
|
||||||
pageTitle == null
|
pageTitle == null
|
||||||
? siteTitle.text
|
? siteTitle.text
|
||||||
: (includeSiteName ? `${pageTitle.text} | ${siteTitle.text}` : pageTitle.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 (
|
return (
|
||||||
<Helmet>
|
<Helmet>
|
||||||
<title lang={pageTitle?.language ?? siteTitle.language}>{fullTitle}</title>
|
<title lang={pageTitle?.language ?? siteTitle.language}>{fullTitle}</title>
|
||||||
<meta name="description" content={description.text} lang={description.language}/>
|
<meta name="description" content={description.text} lang={description.language}/>
|
||||||
{canonical != null && <link rel="canonical" href={canonical}/>}
|
{pageCanonical != null && <link rel="canonical" href={pageCanonical}/>}
|
||||||
{children}
|
{children}
|
||||||
</Helmet>)
|
</Helmet>)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -103,12 +103,7 @@ const SidebarComponent: FC<Props> = ({
|
|||||||
return Math.min (maxAllowedWidth, Math.max (MIN_SIDEBAR_WIDTH, nextWidth))
|
return Math.min (maxAllowedWidth, Math.max (MIN_SIDEBAR_WIDTH, nextWidth))
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
const [width, setWidth] = useState (() => (
|
const [width, setWidth] = useState (DEFAULT_SIDEBAR_WIDTH)
|
||||||
typeof window === 'undefined'
|
|
||||||
? DEFAULT_SIDEBAR_WIDTH
|
|
||||||
: getClampedWidth (
|
|
||||||
readStoredSidebarWidth (sidebarKey, side),
|
|
||||||
window.innerWidth)))
|
|
||||||
const [resizing, setResizing] = useState (false)
|
const [resizing, setResizing] = useState (false)
|
||||||
const widthRef = useRef (width)
|
const widthRef = useRef (width)
|
||||||
const longPressTimerRef = useRef<ReturnType<typeof setTimeout> | null> (null)
|
const longPressTimerRef = useRef<ReturnType<typeof setTimeout> | null> (null)
|
||||||
|
|||||||
@@ -88,9 +88,9 @@ const BehaviourSettingsSection: FC<Props> = props => {
|
|||||||
const { sectionClassName, onDirtyStateChange } = props
|
const { sectionClassName, onDirtyStateChange } = props
|
||||||
const { label } = useUITranslation ('settings')
|
const { label } = useUITranslation ('settings')
|
||||||
const [savedSettings, setSavedSettings] =
|
const [savedSettings, setSavedSettings] =
|
||||||
useState<ClientBehaviorSettings> (() => getClientBehaviorSettings ())
|
useState<ClientBehaviorSettings> ({ })
|
||||||
const [draftSettings, setDraftSettings] =
|
const [draftSettings, setDraftSettings] =
|
||||||
useState<ClientBehaviorSettings> (() => getClientBehaviorSettings ())
|
useState<ClientBehaviorSettings> ({ })
|
||||||
|
|
||||||
useEffect (() => {
|
useEffect (() => {
|
||||||
const current = getClientBehaviorSettings ()
|
const current = getClientBehaviorSettings ()
|
||||||
|
|||||||
+118
-22
@@ -1,17 +1,23 @@
|
|||||||
import { QueryClient, QueryClientProvider, dehydrate } from '@tanstack/react-query'
|
import { QueryClient, QueryClientProvider, dehydrate, skipToken } from '@tanstack/react-query'
|
||||||
import { renderToString } from 'react-dom/server'
|
import { PassThrough } from 'node:stream'
|
||||||
|
import { renderToPipeableStream } from 'react-dom/server'
|
||||||
import { HelmetProvider } from 'react-helmet-async'
|
import { HelmetProvider } from 'react-helmet-async'
|
||||||
import { I18nextProvider } from 'react-i18next'
|
import { I18nextProvider } from 'react-i18next'
|
||||||
import { createStaticHandler, createStaticRouter,
|
import { createStaticHandler, createStaticRouter,
|
||||||
StaticRouterProvider } from 'react-router-dom/server'
|
StaticRouterProvider } from 'react-router-dom/server'
|
||||||
|
|
||||||
import { AppFrame, appRoutes, resolvePageRoute } from '@/App'
|
import { AppFrame, appRoutes, resolvePageRoute } from '@/App'
|
||||||
|
import { apiGet, isApiError } from '@/lib/api'
|
||||||
import { createUII18n } from '@/lib/i18n'
|
import { createUII18n } from '@/lib/i18n'
|
||||||
import { fetchLocales, isLocaleCode, legacyLocalePath } from '@/lib/locales'
|
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 { withRenderContext } from '@/lib/renderContext.server'
|
||||||
|
|
||||||
import type { HelmetServerState } from 'react-helmet-async'
|
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 = {
|
type RenderedPage = {
|
||||||
status: number
|
status: number
|
||||||
@@ -23,35 +29,82 @@ type RenderedPage = {
|
|||||||
type RouteResponse = { status: 404 | 308; location?: string }
|
type RouteResponse = { status: 404 | 308; location?: string }
|
||||||
|
|
||||||
|
|
||||||
|
const renderDocument = (element: ReactNode): Promise<string> =>
|
||||||
|
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 (
|
export const render = async (
|
||||||
address: string,
|
address: string,
|
||||||
origin: string,
|
origin: string,
|
||||||
apiBaseURL: string,
|
apiBaseURL: string,
|
||||||
): Promise<RenderedPage | RouteResponse | null> => {
|
authentication: Pick<RenderContext, 'transferCode' | 'forwardedFor'> = { },
|
||||||
|
): Promise<RenderedPage | RouteResponse> => {
|
||||||
const url = new URL (address, origin)
|
const url = new URL (address, origin)
|
||||||
const legacy = legacyLocalePath ({ pathname: url.pathname, search: url.search, hash: '' })
|
const legacy = legacyLocalePath ({ pathname: url.pathname, search: url.search, hash: '' })
|
||||||
const pathname = legacy == null ? url.pathname : new URL (legacy, origin).pathname
|
const pathname = legacy == null ? url.pathname : new URL (legacy, origin).pathname
|
||||||
const segment = pathname.split ('/')[1]
|
const segment = pathname.split ('/')[1]
|
||||||
const known = resolvePageRoute (pathname)
|
const known = resolvePageRoute (pathname)
|
||||||
if (!(isLocaleCode (segment)) || known == null)
|
if (legacy != null && known != null)
|
||||||
return { status: 404 }
|
|
||||||
if (legacy != null)
|
|
||||||
return { status: 308, location: legacy }
|
return { status: 308, location: legacy }
|
||||||
const locale = segment
|
const locale = isLocaleCode (segment) ? segment : 'ja'
|
||||||
return withRenderContext ({ pathname: url.pathname, origin, apiBaseURL }, async () => {
|
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: {
|
const client = new QueryClient ({ defaultOptions: {
|
||||||
queries: { retry: false, gcTime: Infinity } } })
|
queries: { retry: false, gcTime: Infinity, staleTime: Infinity } } })
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
const locales = await client.fetchQuery ({
|
const locales = await client.fetchQuery ({
|
||||||
queryKey: localesKeys.index, queryFn: fetchLocales })
|
queryKey: localesKeys.index, queryFn: fetchLocales })
|
||||||
const active = locales.locales.some (entry => entry.code === locale)
|
const active = locales.locales.some (entry => entry.code === locale)
|
||||||
if (!(active))
|
if (authentication.transferCode != null)
|
||||||
return { status: 404 as const }
|
{
|
||||||
const prepare = known.route.handle?.ssr
|
try
|
||||||
if (prepare == null)
|
{
|
||||||
return null
|
const user = await apiGet<Pick<User, 'id' | 'name' | 'role'>> ('/users/session')
|
||||||
const status = await prepare ({ client, params: known.params, locale,
|
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 })
|
searchParams: url.searchParams })
|
||||||
const instance = createUII18n (locale)
|
const instance = createUII18n (locale)
|
||||||
const handler = createStaticHandler (appRoutes)
|
const handler = createStaticHandler (appRoutes)
|
||||||
@@ -59,8 +112,8 @@ export const render = async (
|
|||||||
if (context instanceof Response)
|
if (context instanceof Response)
|
||||||
throw new Error (`Unexpected router response: ${context.status}`)
|
throw new Error (`Unexpected router response: ${context.status}`)
|
||||||
const router = createStaticRouter (handler.dataRoutes, context)
|
const router = createStaticRouter (handler.dataRoutes, context)
|
||||||
const helmetContext: { helmet?: HelmetServerState } = { }
|
let helmetContext: { helmet?: HelmetServerState } = { }
|
||||||
const html = renderToString (
|
const page = () => (
|
||||||
<HelmetProvider context={helmetContext}>
|
<HelmetProvider context={helmetContext}>
|
||||||
<QueryClientProvider client={client}>
|
<QueryClientProvider client={client}>
|
||||||
<I18nextProvider i18n={instance}>
|
<I18nextProvider i18n={instance}>
|
||||||
@@ -70,13 +123,56 @@ export const render = async (
|
|||||||
</I18nextProvider>
|
</I18nextProvider>
|
||||||
</QueryClientProvider>
|
</QueryClientProvider>
|
||||||
</HelmetProvider>)
|
</HelmetProvider>)
|
||||||
|
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 helmet = helmetContext.helmet!
|
||||||
const head = [helmet.title, helmet.meta, helmet.link, helmet.style]
|
const head = [helmet.title, helmet.meta, helmet.link, helmet.style]
|
||||||
.map (entry => entry.toString ()).join ('')
|
.map (entry => entry.toString ()).join ('')
|
||||||
const state = dehydrate (client, { shouldDehydrateQuery: query =>
|
const dehydrated = dehydrate (client, { shouldDehydrateQuery: query =>
|
||||||
query.state.status === 'success'
|
query.state.status !== 'pending'
|
||||||
&& (query.queryKey[0] === 'locales'
|
&& !(query.queryKey[0] === 'help' && query.queryKey[1] === 'edition') })
|
||||||
|| (query.queryKey[0] === 'help' && query.queryKey[1] === 'article')) })
|
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,
|
const bootstrap = JSON.stringify ({ state, status, locale,
|
||||||
url: url.pathname + url.search })
|
url: url.pathname + url.search })
|
||||||
.replace (/</g, '\\u003c').replace (/\u2028/g, '\\u2028')
|
.replace (/</g, '\\u003c').replace (/\u2028/g, '\\u2028')
|
||||||
|
|||||||
+22
-1
@@ -26,7 +26,10 @@ const withUserCode = (opt?: Opt): AxiosRequestConfig => {
|
|||||||
&& { baseURL: currentRenderContext ()!.apiBaseURL, timeout: 10_000 }),
|
&& { baseURL: currentRenderContext ()!.apiBaseURL, timeout: 10_000 }),
|
||||||
...(locale != null && { params: { locale, ...config.params } }),
|
...(locale != null && { params: { locale, ...config.params } }),
|
||||||
headers: { 'X-Transfer-Code': typeof window === 'undefined'
|
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 ?? { }) } }
|
...(config.headers ?? { }) } }
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -37,6 +40,24 @@ const apiP = async <T> (
|
|||||||
body?: unknown,
|
body?: unknown,
|
||||||
opt?: Opt): Promise<T> => {
|
opt?: Opt): Promise<T> => {
|
||||||
const res = await client[method] (path, body ?? { }, withUserCode (opt))
|
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')
|
if (opt?.responseType === 'blob')
|
||||||
return res.data as T
|
return res.data as T
|
||||||
return toCamel (res.data as Record<string, unknown>, { deep: true }) as T
|
return toCamel (res.data as Record<string, unknown>, { deep: true }) as T
|
||||||
|
|||||||
@@ -123,6 +123,8 @@ export const materialsKeys = {
|
|||||||
|
|
||||||
export const wikiKeys = {
|
export const wikiKeys = {
|
||||||
root: ['wiki'] as const,
|
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) =>
|
autolinkCandidates: (language: string, locale: LocaleCode) =>
|
||||||
['wiki', 'autolink', locale, language] as const,
|
['wiki', 'autolink', locale, language] as const,
|
||||||
index: (
|
index: (
|
||||||
@@ -147,3 +149,13 @@ export const helpKeys = {
|
|||||||
diff: (identifier: string, language: string, from: string, to: string) =>
|
diff: (identifier: string, language: string, from: string, to: string) =>
|
||||||
['help', 'diff', identifier, language, from, to] as const,
|
['help', 'diff', identifier, language, from, to] as const,
|
||||||
changes: (language: string) => ['help', 'changes', language] 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 }
|
||||||
|
|||||||
@@ -1,7 +1,10 @@
|
|||||||
export type RenderContext = {
|
export type RenderContext = {
|
||||||
pathname: string
|
pathname: string
|
||||||
origin: string
|
origin: string
|
||||||
apiBaseURL?: string }
|
apiBaseURL?: string
|
||||||
|
transferCode?: string
|
||||||
|
forwardedFor?: string
|
||||||
|
status?: number }
|
||||||
|
|
||||||
|
|
||||||
// The server build replaces this module with the AsyncLocalStorage implementation.
|
// The server build replaces this module with the AsyncLocalStorage implementation.
|
||||||
|
|||||||
@@ -10,8 +10,11 @@ export type PagePreparation = {
|
|||||||
searchParams: URLSearchParams }
|
searchParams: URLSearchParams }
|
||||||
|
|
||||||
export type PageRouteHandle = {
|
export type PageRouteHandle = {
|
||||||
ssr?: (request: PagePreparation) => Promise<number>
|
prepare?: (request: PagePreparation) => Promise<number>
|
||||||
locale?: LocaleCode
|
locale?: LocaleCode
|
||||||
|
robots?: 'noindex'
|
||||||
|
authentication?: 'user'
|
||||||
|
redirectTo?: string
|
||||||
notFound?: boolean }
|
notFound?: boolean }
|
||||||
|
|
||||||
export type PageRoute = { handle?: PageRouteHandle } & (
|
export type PageRoute = { handle?: PageRouteHandle } & (
|
||||||
|
|||||||
@@ -791,7 +791,8 @@ export const seedClientThemeMode = (theme: UserSettings['theme']): void => {
|
|||||||
|
|
||||||
|
|
||||||
const prefersDarkTheme = (): boolean =>
|
const prefersDarkTheme = (): boolean =>
|
||||||
window.matchMedia ('(prefers-color-scheme: dark)').matches
|
typeof window !== 'undefined'
|
||||||
|
&& window.matchMedia ('(prefers-color-scheme: dark)').matches
|
||||||
|
|
||||||
|
|
||||||
export const resolveThemeMode = (
|
export const resolveThemeMode = (
|
||||||
|
|||||||
@@ -0,0 +1,9 @@
|
|||||||
|
import { useSyncExternalStore } from 'react'
|
||||||
|
|
||||||
|
const subscribe = () => () => { }
|
||||||
|
const clientSnapshot = () => true
|
||||||
|
const serverSnapshot = () => false
|
||||||
|
|
||||||
|
|
||||||
|
export const useHydrated = (): boolean =>
|
||||||
|
useSyncExternalStore (subscribe, clientSnapshot, serverSnapshot)
|
||||||
@@ -99,12 +99,16 @@ export const KeyboardShortcutsProvider = ({ children }: PropsWithChildren) => {
|
|||||||
const navigate = useLocaleNavigate ()
|
const navigate = useLocaleNavigate ()
|
||||||
|
|
||||||
const [keyboardSettings, setKeyboardSettingsState] =
|
const [keyboardSettings, setKeyboardSettingsState] =
|
||||||
useState<ClientKeyboardSettings> (() => getClientKeyboardSettings ())
|
useState<ClientKeyboardSettings> ({ enabled: true, bindings: { } })
|
||||||
const [registeredHandlers, setRegisteredHandlers] =
|
const [registeredHandlers, setRegisteredHandlers] =
|
||||||
useState<Record<number, ShortcutHandlers>> ({ })
|
useState<Record<number, ShortcutHandlers>> ({ })
|
||||||
const [shortcutHelpOpen, setShortcutHelpOpen] = useState (false)
|
const [shortcutHelpOpen, setShortcutHelpOpen] = useState (false)
|
||||||
const registrationIdRef = useRef (0)
|
const registrationIdRef = useRef (0)
|
||||||
|
|
||||||
|
useEffect (() => {
|
||||||
|
setKeyboardSettingsState (getClientKeyboardSettings ())
|
||||||
|
}, [])
|
||||||
|
|
||||||
const activeScope = useMemo<ShortcutScope> (
|
const activeScope = useMemo<ShortcutScope> (
|
||||||
() => resolveShortcutScope (location.pathname),
|
() => resolveShortcutScope (location.pathname),
|
||||||
[location.pathname],
|
[location.pathname],
|
||||||
|
|||||||
+10
-4
@@ -63,17 +63,23 @@ const start = () => {
|
|||||||
if (data?.state == null || !(Array.isArray (data.state.queries)))
|
if (data?.state == null || !(Array.isArray (data.state.queries)))
|
||||||
throw new Error ('Invalid SSR query state')
|
throw new Error ('Invalid SSR query state')
|
||||||
hydrate (client, data.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)
|
if (client.getQueryData (localesKeys.index) == null)
|
||||||
throw new Error ('Missing SSR locales')
|
throw new Error ('Missing SSR locales')
|
||||||
const url = new URL (window.location.href)
|
const url = new URL (window.location.href)
|
||||||
const match = resolvePageRoute (url.pathname)
|
const match = resolvePageRoute (url.pathname)
|
||||||
const prepare = match?.route.handle?.ssr
|
const prepare = match?.route.handle?.prepare
|
||||||
if (match == null || prepare == null)
|
if (prepare != null && match != null)
|
||||||
throw new Error ('Missing SSR route preparation')
|
{
|
||||||
const status = await prepare ({ client, params: match.params,
|
const status = await prepare ({ client, params: match.params,
|
||||||
locale: currentViewingLocale (), searchParams: url.searchParams })
|
locale: currentViewingLocale (), searchParams: url.searchParams })
|
||||||
if (status !== data.status)
|
if (status !== data.status)
|
||||||
throw new Error ('Server and browser Help versions differ')
|
throw new Error ('Server and browser page versions differ')
|
||||||
|
}
|
||||||
mount (true)
|
mount (true)
|
||||||
mounted = true
|
mounted = true
|
||||||
for (const event of ['online', 'focus', 'pageshow'])
|
for (const event of ['online', 'focus', 'pageshow'])
|
||||||
|
|||||||
@@ -1,12 +1,13 @@
|
|||||||
import { animate, motion, useMotionTemplate, useMotionValue } from 'framer-motion'
|
import { animate, motion, useMotionTemplate, useMotionValue } from 'framer-motion'
|
||||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
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 { useLocale } from '@/lib/useLocale'
|
||||||
import PrefetchLink from '@/components/PrefetchLink'
|
import PrefetchLink from '@/components/PrefetchLink'
|
||||||
import MainArea from '@/components/layout/MainArea'
|
import MainArea from '@/components/layout/MainArea'
|
||||||
import PageHead from '@/components/common/PageHead'
|
import PageHead from '@/components/common/PageHead'
|
||||||
import { useUITranslation } from '@/lib/useUITranslation'
|
import { useUITranslation } from '@/lib/useUITranslation'
|
||||||
|
import { useHydrated } from '@/lib/useHydrated'
|
||||||
import { expectedAnswerForQuestion,
|
import { expectedAnswerForQuestion,
|
||||||
fetchGekanatorExtraQuestions,
|
fetchGekanatorExtraQuestions,
|
||||||
fetchGekanatorQuestions,
|
fetchGekanatorQuestions,
|
||||||
@@ -3679,23 +3680,32 @@ const expectedAnswerFor = (
|
|||||||
|
|
||||||
|
|
||||||
const GekanatorPage: FC<{ user: User | null }> = ({ user }) => {
|
const GekanatorPage: FC<{ user: User | null }> = ({ user }) => {
|
||||||
|
const hydrated = useHydrated ()
|
||||||
|
const initialSeed = useId ()
|
||||||
const { message } = useUITranslation ('gekanator')
|
const { message } = useUITranslation ('gekanator')
|
||||||
const locale = useLocale ()
|
const locale = useLocale ()
|
||||||
const behaviourSettings = useClientBehaviourSettings ()
|
const behaviourSettings = useClientBehaviourSettings ()
|
||||||
const animationMode = behaviourSettings.animation ?? 'normal'
|
const animationMode = behaviourSettings.animation ?? 'normal'
|
||||||
const storedGame = useMemo (loadStoredGame, [])
|
const storedGame = useMemo (() => hydrated ? loadStoredGame () : null, [hydrated])
|
||||||
const hasStoredRestore = storedGame != null && isStoredPhase (storedGame.phase)
|
const hasStoredRestore = storedGame != null && isStoredPhase (storedGame.phase)
|
||||||
const queryClient = useQueryClient ()
|
const queryClient = useQueryClient ()
|
||||||
const isAdmin = user?.role === 'admin'
|
const isAdmin = user?.role === 'admin'
|
||||||
const canPersistGame = user != null
|
const canPersistGame = user != null
|
||||||
const [recentGames, setRecentGames] = useState<RecentGameSummary[]> (
|
const [recentGames, setRecentGames] = useState<RecentGameSummary[]> (
|
||||||
() => loadRecentGames ())
|
() => hydrated ? loadRecentGames () : [])
|
||||||
const [backgroundMotionMode, setBackgroundMotionMode] = useState<BackgroundMotionMode> (
|
const [backgroundMotionMode, setBackgroundMotionMode] = useState<BackgroundMotionMode> (
|
||||||
() => loadBackgroundMotionMode ())
|
() => hydrated ? loadBackgroundMotionMode () : 'on')
|
||||||
const [prefersReducedMotion, setPrefersReducedMotion] = useState (false)
|
const [prefersReducedMotion, setPrefersReducedMotion] = useState (false)
|
||||||
const [gameSeed, setGameSeed] = useState (
|
const [gameSeed, setGameSeed] = useState (
|
||||||
storedGame?.gameSeed ?? createGameSeed ())
|
storedGame?.gameSeed ?? initialSeed)
|
||||||
const [restorePromptVisible, setRestorePromptVisible] = useState (hasStoredRestore)
|
const [restorePromptVisible, setRestorePromptVisible] = useState (hasStoredRestore)
|
||||||
|
useEffect (() => {
|
||||||
|
if (!hydrated)
|
||||||
|
return
|
||||||
|
setRecentGames (loadRecentGames ())
|
||||||
|
setBackgroundMotionMode (loadBackgroundMotionMode ())
|
||||||
|
setRestorePromptVisible (hasStoredRestore)
|
||||||
|
}, [hydrated, hasStoredRestore])
|
||||||
const [phase, setPhase] = useState<Phase> (
|
const [phase, setPhase] = useState<Phase> (
|
||||||
hasStoredRestore ? 'intro' : storedGame?.phase ?? 'intro')
|
hasStoredRestore ? 'intro' : storedGame?.phase ?? 'intro')
|
||||||
const [scores, setScores] = useState<Map<number, number>> (
|
const [scores, setScores] = useState<Map<number, number>> (
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
import { useLocation } from 'react-router-dom'
|
||||||
|
|
||||||
import PrefetchLink from '@/components/PrefetchLink'
|
import PrefetchLink from '@/components/PrefetchLink'
|
||||||
import LocalisedText from '@/components/LocalisedText'
|
import LocalisedText from '@/components/LocalisedText'
|
||||||
import { menuOutline } from '@/components/TopNav'
|
import { menuOutline } from '@/components/TopNav'
|
||||||
@@ -12,6 +14,7 @@ import type { User } from '@/types'
|
|||||||
|
|
||||||
|
|
||||||
const MorePage: FC = () => {
|
const MorePage: FC = () => {
|
||||||
|
const location = useLocation ()
|
||||||
const { message } = useUITranslation ('shared')
|
const { message } = useUITranslation ('shared')
|
||||||
const menu = menuOutline (
|
const menu = menuOutline (
|
||||||
{ tag: null, wikiId: null, user: { } as User, pathName: location.pathname })
|
{ tag: null, wikiId: null, user: { } as User, pathName: location.pathname })
|
||||||
|
|||||||
@@ -39,8 +39,8 @@ const DeerjikistDetailPage: FC = () => {
|
|||||||
const deerjikists = useMemo (() => qData?.deerjikists ?? [], [qData])
|
const deerjikists = useMemo (() => qData?.deerjikists ?? [], [qData])
|
||||||
|
|
||||||
const [data, setData] =
|
const [data, setData] =
|
||||||
useState<(Omit<Deerjikist, 'platform'> & { platform: Platform | null })[]> ([])
|
useState<(Omit<Deerjikist, 'platform'> & { platform: Platform | null })[]> (deerjikists)
|
||||||
const [disabled, setDisabled] = useState (true)
|
const [disabled, setDisabled] = useState (tag == null)
|
||||||
const { baseErrors, fieldErrors, clearValidationErrors, applyValidationError } =
|
const { baseErrors, fieldErrors, clearValidationErrors, applyValidationError } =
|
||||||
useValidationErrors<DeerjikistFormField> ()
|
useValidationErrors<DeerjikistFormField> ()
|
||||||
|
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ import { localeLanguage } from '@/lib/locales'
|
|||||||
import { inputClass } from '@/lib/utils'
|
import { inputClass } from '@/lib/utils'
|
||||||
import { useValidationErrors } from '@/lib/useValidationErrors'
|
import { useValidationErrors } from '@/lib/useValidationErrors'
|
||||||
import { useUITranslation } from '@/lib/useUITranslation'
|
import { useUITranslation } from '@/lib/useUITranslation'
|
||||||
|
import NotFound from '@/pages/NotFound'
|
||||||
|
|
||||||
import type { FC } from 'react'
|
import type { FC } from 'react'
|
||||||
|
|
||||||
@@ -34,19 +35,20 @@ const MaterialDetailPage: FC = () => {
|
|||||||
const { message, label } = useUITranslation ('materials')
|
const { message, label } = useUITranslation ('materials')
|
||||||
const { id } = useParams ()
|
const { id } = useParams ()
|
||||||
const qc = useQueryClient ()
|
const qc = useQueryClient ()
|
||||||
|
|
||||||
const [exportPath, setExportPath] = useState ('')
|
|
||||||
const [file, setFile] = useState<File | null> (null)
|
|
||||||
const [filePreview, setFilePreview] = useState ('')
|
|
||||||
const [tag, setTag] = useState ('')
|
|
||||||
const [url, setURL] = useState ('')
|
|
||||||
const { baseErrors, fieldErrors, clearValidationErrors, applyValidationError } =
|
|
||||||
useValidationErrors<MaterialFormField> ()
|
|
||||||
|
|
||||||
const { data: material, isError, isLoading } = useQuery ({
|
const { data: material, isError, isLoading } = useQuery ({
|
||||||
queryKey: materialsKeys.show (id ?? '', locale),
|
queryKey: materialsKeys.show (id ?? '', locale),
|
||||||
queryFn: () => fetchMaterial (id ?? '', locale),
|
queryFn: () => fetchMaterial (id ?? '', locale),
|
||||||
enabled: id != null })
|
enabled: id != null })
|
||||||
|
|
||||||
|
const [exportPath, setExportPath] = useState (material?.exportPaths.legacyDrive ?? '')
|
||||||
|
const [file, setFile] = useState<File | null> (null)
|
||||||
|
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<MaterialFormField> ()
|
||||||
|
|
||||||
const materialTitle = material
|
const materialTitle = material
|
||||||
? material.tag?.name ?? message ('unnamed', { id: material.id }).text
|
? material.tag?.name ?? message ('unnamed', { id: material.id }).text
|
||||||
: ''
|
: ''
|
||||||
@@ -97,6 +99,9 @@ const MaterialDetailPage: FC = () => {
|
|||||||
updateMutation.mutate ()
|
updateMutation.mutate ()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (!isLoading && !isError && material == null)
|
||||||
|
return <NotFound/>
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<MainArea>
|
<MainArea>
|
||||||
<PageHead title={material
|
<PageHead title={material
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
||||||
import { motion } from 'framer-motion'
|
import { motion } from 'framer-motion'
|
||||||
import { useEffect, useRef, useState } from 'react'
|
import { useEffect, useRef } from 'react'
|
||||||
import { useParams } from 'react-router-dom'
|
import { useParams } from 'react-router-dom'
|
||||||
import { useLocale } from '@/lib/useLocale'
|
import { useLocale } from '@/lib/useLocale'
|
||||||
import PostEditForm from '@/components/PostEditForm'
|
import PostEditForm from '@/components/PostEditForm'
|
||||||
@@ -56,7 +56,7 @@ const PostDetailPage: FC<Props> = ({ user }) => {
|
|||||||
|
|
||||||
const embedRef = useRef<NiconicoViewerHandle> (null)
|
const embedRef = useRef<NiconicoViewerHandle> (null)
|
||||||
|
|
||||||
const [status, setStatus] = useState (200)
|
const status = (errorFlg && isApiError (error)) ? error.response?.status ?? 200 : 200
|
||||||
|
|
||||||
const changeViewedFlg = useMutation ({
|
const changeViewedFlg = useMutation ({
|
||||||
mutationFn: async (next: boolean) => {
|
mutationFn: async (next: boolean) => {
|
||||||
@@ -79,19 +79,9 @@ const PostDetailPage: FC<Props> = ({ user }) => {
|
|||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
qc.invalidateQueries ({ queryKey: postsKeys.root })} })
|
qc.invalidateQueries ({ queryKey: postsKeys.root })} })
|
||||||
|
|
||||||
useEffect (() => {
|
|
||||||
if (!(errorFlg))
|
|
||||||
return
|
|
||||||
|
|
||||||
const code = isApiError (error) ? error.response?.status : undefined
|
|
||||||
if (code)
|
|
||||||
setStatus (code)
|
|
||||||
}, [errorFlg, error])
|
|
||||||
|
|
||||||
useEffect (() => {
|
useEffect (() => {
|
||||||
scroll (0, 0)
|
scroll (0, 0)
|
||||||
|
|
||||||
setStatus (200)
|
|
||||||
}, [id])
|
}, [id])
|
||||||
|
|
||||||
switch (status)
|
switch (status)
|
||||||
|
|||||||
@@ -614,7 +614,8 @@ const PostImportReviewPage: FC<Props> = ({ user }) => {
|
|||||||
? { duration: .08, ease: 'linear' as const }
|
? { duration: .08, ease: 'linear' as const }
|
||||||
: { duration: .2, ease: 'easeOut' as const }
|
: { duration: .2, ease: 'easeOut' as const }
|
||||||
|
|
||||||
const [rows, setRows] = useState<PostImportRow[] | null> (null)
|
const [rows, setRows] = useState<PostImportRow[] | null> (
|
||||||
|
() => buildInitialRows (parsePostNewReviewUrls (location.search)))
|
||||||
const [metadataLoading, setMetadataLoading] = useState (false)
|
const [metadataLoading, setMetadataLoading] = useState (false)
|
||||||
const [submitting, setSubmitting] = useState (false)
|
const [submitting, setSubmitting] = useState (false)
|
||||||
const [busyRowIds, setBusyRowIds] = useState<Set<number>> (new Set ())
|
const [busyRowIds, setBusyRowIds] = useState<Set<number>> (new Set ())
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ import {
|
|||||||
import { useKeyboardShortcuts } from '@/lib/useKeyboardShortcuts'
|
import { useKeyboardShortcuts } from '@/lib/useKeyboardShortcuts'
|
||||||
import { fetchWikiPageByTitle } from '@/lib/wiki'
|
import { fetchWikiPageByTitle } from '@/lib/wiki'
|
||||||
import { useUITranslation } from '@/lib/useUITranslation'
|
import { useUITranslation } from '@/lib/useUITranslation'
|
||||||
|
import { useHydrated } from '@/lib/useHydrated'
|
||||||
|
|
||||||
import type { FC } from 'react'
|
import type { FC } from 'react'
|
||||||
|
|
||||||
@@ -36,6 +37,7 @@ const parseLimit = (value: string | null): 20 | 50 | 100 | null => {
|
|||||||
|
|
||||||
const PostListPage: FC = () => {
|
const PostListPage: FC = () => {
|
||||||
const locale = useLocale ()
|
const locale = useLocale ()
|
||||||
|
const hydrated = useHydrated ()
|
||||||
const { message, label, siteTitle } = useUITranslation ('posts')
|
const { message, label, siteTitle } = useUITranslation ('posts')
|
||||||
const containerRef = useRef<HTMLDivElement | null> (null)
|
const containerRef = useRef<HTMLDivElement | null> (null)
|
||||||
|
|
||||||
@@ -52,7 +54,7 @@ const PostListPage: FC = () => {
|
|||||||
const page = Number (query.get ('page') ?? 1)
|
const page = Number (query.get ('page') ?? 1)
|
||||||
const limit = (
|
const limit = (
|
||||||
parseLimit (query.get ('limit'))
|
parseLimit (query.get ('limit'))
|
||||||
?? getClientListLimit ('postList')
|
?? (hydrated ? getClientListLimit ('postList') : null)
|
||||||
?? DEFAULT_POST_LIST_LIMIT
|
?? DEFAULT_POST_LIST_LIMIT
|
||||||
)
|
)
|
||||||
const order = 'original_created_at:desc' as const
|
const order = 'original_created_at:desc' as const
|
||||||
@@ -70,8 +72,10 @@ const PostListPage: FC = () => {
|
|||||||
const totalPages = data ? Math.ceil (data.count / limit) : 0
|
const totalPages = data ? Math.ceil (data.count / limit) : 0
|
||||||
|
|
||||||
useEffect (() => {
|
useEffect (() => {
|
||||||
|
if (!hydrated)
|
||||||
|
return
|
||||||
setClientListSettings ('postList', { limit })
|
setClientListSettings ('postList', { limit })
|
||||||
}, [limit])
|
}, [hydrated, limit])
|
||||||
|
|
||||||
const updatePage = (nextPage: number) => {
|
const updatePage = (nextPage: number) => {
|
||||||
const params = new URLSearchParams (location.search)
|
const params = new URLSearchParams (location.search)
|
||||||
|
|||||||
@@ -31,6 +31,7 @@ import { useClientBehaviourSettings } from '@/lib/useClientBehaviourSettings'
|
|||||||
import { useKeyboardShortcuts } from '@/lib/useKeyboardShortcuts'
|
import { useKeyboardShortcuts } from '@/lib/useKeyboardShortcuts'
|
||||||
import { localisedDateString, inputClass, originalCreatedAtString } from '@/lib/utils'
|
import { localisedDateString, inputClass, originalCreatedAtString } from '@/lib/utils'
|
||||||
import { useUITranslation } from '@/lib/useUITranslation'
|
import { useUITranslation } from '@/lib/useUITranslation'
|
||||||
|
import { useHydrated } from '@/lib/useHydrated'
|
||||||
|
|
||||||
import type { FC, FormEvent } from 'react'
|
import type { FC, FormEvent } from 'react'
|
||||||
|
|
||||||
@@ -73,6 +74,7 @@ const parseOrder = (value: string | null): FetchPostsOrder | null => {
|
|||||||
|
|
||||||
const PostSearchPage: FC = () => {
|
const PostSearchPage: FC = () => {
|
||||||
const locale = useLocale ()
|
const locale = useLocale ()
|
||||||
|
const hydrated = useHydrated ()
|
||||||
const { message, label } = useUITranslation ('posts')
|
const { message, label } = useUITranslation ('posts')
|
||||||
const behaviourSettings = useClientBehaviourSettings ()
|
const behaviourSettings = useClientBehaviourSettings ()
|
||||||
const animationMode = behaviourSettings.animation ?? 'normal'
|
const animationMode = behaviourSettings.animation ?? 'normal'
|
||||||
@@ -91,7 +93,7 @@ const PostSearchPage: FC = () => {
|
|||||||
const page = Number (query.get ('page') ?? 1)
|
const page = Number (query.get ('page') ?? 1)
|
||||||
const limit = (
|
const limit = (
|
||||||
parseLimit (query.get ('limit'))
|
parseLimit (query.get ('limit'))
|
||||||
?? getClientListLimit ('postSearch')
|
?? (hydrated ? getClientListLimit ('postSearch') : null)
|
||||||
?? DEFAULT_POST_LIST_LIMIT
|
?? DEFAULT_POST_LIST_LIMIT
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -107,7 +109,7 @@ const PostSearchPage: FC = () => {
|
|||||||
const qUpdatedTo = query.get ('updated_to') ?? ''
|
const qUpdatedTo = query.get ('updated_to') ?? ''
|
||||||
const order = (
|
const order = (
|
||||||
parseOrder (query.get ('order'))
|
parseOrder (query.get ('order'))
|
||||||
?? getClientListOrder<FetchPostsOrder> ('postSearch')
|
?? (hydrated ? getClientListOrder<FetchPostsOrder> ('postSearch') : null)
|
||||||
?? DEFAULT_POST_LIST_ORDER
|
?? DEFAULT_POST_LIST_ORDER
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -140,8 +142,10 @@ const PostSearchPage: FC = () => {
|
|||||||
const totalPages = data ? Math.ceil (data.count / limit) : 0
|
const totalPages = data ? Math.ceil (data.count / limit) : 0
|
||||||
|
|
||||||
useEffect (() => {
|
useEffect (() => {
|
||||||
|
if (!hydrated)
|
||||||
|
return
|
||||||
setClientListSettings ('postSearch', { limit, order })
|
setClientListSettings ('postSearch', { limit, order })
|
||||||
}, [limit, order])
|
}, [hydrated, limit, order])
|
||||||
|
|
||||||
useEffect (() => {
|
useEffect (() => {
|
||||||
setURL (qURL ?? '')
|
setURL (qURL ?? '')
|
||||||
|
|||||||
@@ -11,7 +11,8 @@ import MainArea from '@/components/layout/MainArea'
|
|||||||
import PageHead from '@/components/common/PageHead'
|
import PageHead from '@/components/common/PageHead'
|
||||||
import { toast } from '@/components/ui/use-toast'
|
import { toast } from '@/components/ui/use-toast'
|
||||||
import { CATEGORIES } from '@/consts'
|
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 { postsKeys, tagsKeys, wikiKeys } from '@/lib/queryKeys'
|
||||||
import { fetchTag } from '@/lib/tags'
|
import { fetchTag } from '@/lib/tags'
|
||||||
import { cn, inputClass } from '@/lib/utils'
|
import { cn, inputClass } from '@/lib/utils'
|
||||||
@@ -37,16 +38,17 @@ const TagDetailPage: FC = () => {
|
|||||||
const tagId = String (id ?? '')
|
const tagId = String (id ?? '')
|
||||||
const tagKey = tagsKeys.show (tagId, locale)
|
const tagKey = tagsKeys.show (tagId, locale)
|
||||||
|
|
||||||
const { data: tag, isLoading: loading } = useQuery ({
|
const { data: tag, isLoading: loading, error } = useQuery ({
|
||||||
queryKey: tagKey,
|
queryKey: tagKey,
|
||||||
queryFn: () => fetchTag (tagId, locale) })
|
queryFn: () => fetchTag (tagId, locale) })
|
||||||
|
|
||||||
const [name, setName] = useState ('')
|
const [name, setName] = useState (tag?.name ?? '')
|
||||||
const [category, setCategory] = useState<Category> ('general')
|
const [category, setCategory] = useState<Category> (tag?.category ?? 'general')
|
||||||
const [aliases, setAliases] = useState ('')
|
const [aliases, setAliases] = useState (tag?.aliases.join (' ') ?? '')
|
||||||
const [parentTags, setParentTags] = useState ('')
|
const [parentTags, setParentTags] = useState (tag?.parents.map (entry => entry.name).join (' ')
|
||||||
const [deprecated, setDeprecated] = useState (false)
|
?? '')
|
||||||
const [disabled, setDisabled] = useState (true)
|
const [deprecated, setDeprecated] = useState (tag?.deprecatedAt != null)
|
||||||
|
const [disabled, setDisabled] = useState (tag == null || tag.category === 'nico')
|
||||||
const { baseErrors, fieldErrors, clearValidationErrors, applyValidationError } =
|
const { baseErrors, fieldErrors, clearValidationErrors, applyValidationError } =
|
||||||
useValidationErrors<TagFormField> ()
|
useValidationErrors<TagFormField> ()
|
||||||
|
|
||||||
@@ -100,6 +102,9 @@ const TagDetailPage: FC = () => {
|
|||||||
setDisabled (tag.category === 'nico')
|
setDisabled (tag.category === 'nico')
|
||||||
}, [tag])
|
}, [tag])
|
||||||
|
|
||||||
|
if ((!loading && tag == null) || (isApiError (error) && error.response?.status === 404))
|
||||||
|
return <NotFound/>
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<MainArea>
|
<MainArea>
|
||||||
<PageHead title={tag?.name ?? message ('title')}/>
|
<PageHead title={tag?.name ?? message ('title')}/>
|
||||||
|
|||||||
@@ -27,6 +27,7 @@ import {
|
|||||||
import { useClientBehaviourSettings } from '@/lib/useClientBehaviourSettings'
|
import { useClientBehaviourSettings } from '@/lib/useClientBehaviourSettings'
|
||||||
import { useKeyboardShortcuts } from '@/lib/useKeyboardShortcuts'
|
import { useKeyboardShortcuts } from '@/lib/useKeyboardShortcuts'
|
||||||
import { useUITranslation } from '@/lib/useUITranslation'
|
import { useUITranslation } from '@/lib/useUITranslation'
|
||||||
|
import { useHydrated } from '@/lib/useHydrated'
|
||||||
import { fetchTags } from '@/lib/tags'
|
import { fetchTags } from '@/lib/tags'
|
||||||
import { inputClass, localisedDateString } from '@/lib/utils'
|
import { inputClass, localisedDateString } from '@/lib/utils'
|
||||||
|
|
||||||
@@ -74,6 +75,7 @@ const parseOrder = (value: string | null): FetchTagsOrder | null => {
|
|||||||
|
|
||||||
const TagListPage: FC = () => {
|
const TagListPage: FC = () => {
|
||||||
const locale = useLocale ()
|
const locale = useLocale ()
|
||||||
|
const hydrated = useHydrated ()
|
||||||
const { message, label } = useUITranslation ('tags')
|
const { message, label } = useUITranslation ('tags')
|
||||||
const behaviourSettings = useClientBehaviourSettings ()
|
const behaviourSettings = useClientBehaviourSettings ()
|
||||||
const animationMode = behaviourSettings.animation ?? 'normal'
|
const animationMode = behaviourSettings.animation ?? 'normal'
|
||||||
@@ -86,7 +88,7 @@ const TagListPage: FC = () => {
|
|||||||
const page = Number (query.get ('page') ?? 1)
|
const page = Number (query.get ('page') ?? 1)
|
||||||
const limit = (
|
const limit = (
|
||||||
parseLimit (query.get ('limit'))
|
parseLimit (query.get ('limit'))
|
||||||
?? getClientListLimit ('tagList')
|
?? (hydrated ? getClientListLimit ('tagList') : null)
|
||||||
?? DEFAULT_POST_LIST_LIMIT
|
?? DEFAULT_POST_LIST_LIMIT
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -105,7 +107,7 @@ const TagListPage: FC = () => {
|
|||||||
: null
|
: null
|
||||||
const order = (
|
const order = (
|
||||||
parseOrder (query.get ('order'))
|
parseOrder (query.get ('order'))
|
||||||
?? getClientListOrder<FetchTagsOrder> ('tagList')
|
?? (hydrated ? getClientListOrder<FetchTagsOrder> ('tagList') : null)
|
||||||
?? DEFAULT_TAG_LIST_ORDER
|
?? DEFAULT_TAG_LIST_ORDER
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -138,8 +140,10 @@ const TagListPage: FC = () => {
|
|||||||
const totalPages = data ? Math.ceil (data.count / limit) : 0
|
const totalPages = data ? Math.ceil (data.count / limit) : 0
|
||||||
|
|
||||||
useEffect (() => {
|
useEffect (() => {
|
||||||
|
if (!hydrated)
|
||||||
|
return
|
||||||
setClientListSettings ('tagList', { limit, order })
|
setClientListSettings ('tagList', { limit, order })
|
||||||
}, [limit, order])
|
}, [hydrated, limit, order])
|
||||||
|
|
||||||
useEffect (() => {
|
useEffect (() => {
|
||||||
setName (qName)
|
setName (qName)
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { motion } from 'framer-motion'
|
import { motion } from 'framer-motion'
|
||||||
|
import { useQuery } from '@tanstack/react-query'
|
||||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||||
import { useParams } from 'react-router-dom'
|
import { useParams } from 'react-router-dom'
|
||||||
|
|
||||||
@@ -19,6 +20,7 @@ import { apiDelete, apiGet, apiPatch, apiPost, apiPut, isApiError } from '@/lib/
|
|||||||
import { clientAnimationTransition,
|
import { clientAnimationTransition,
|
||||||
clientScrollBehaviour } from '@/lib/clientAnimation'
|
clientScrollBehaviour } from '@/lib/clientAnimation'
|
||||||
import { fetchPost } from '@/lib/posts'
|
import { fetchPost } from '@/lib/posts'
|
||||||
|
import { theatresKeys } from '@/lib/queryKeys'
|
||||||
import {
|
import {
|
||||||
getClientTheatreLayoutMode,
|
getClientTheatreLayoutMode,
|
||||||
getClientTheatreTagFlow,
|
getClientTheatreTagFlow,
|
||||||
@@ -234,23 +236,30 @@ const TheatreDetailPage: FC<Props> = ({ user }: Props) => {
|
|||||||
|
|
||||||
const [leftSidebarWidth, setLeftSidebarWidth] = useState (256)
|
const [leftSidebarWidth, setLeftSidebarWidth] = useState (256)
|
||||||
const [rightSidebarWidth, setRightSidebarWidth] = useState (256)
|
const [rightSidebarWidth, setRightSidebarWidth] = useState (256)
|
||||||
const [sidebarContainerWidth, setSidebarContainerWidth] = useState (
|
const [sidebarContainerWidth, setSidebarContainerWidth] = useState (0)
|
||||||
() => typeof window === 'undefined' ? 0 : window.innerWidth)
|
|
||||||
const [comments, setComments] = useState<TheatreComment[]> ([])
|
const [comments, setComments] = useState<TheatreComment[]> ([])
|
||||||
const [content, setContent] = useState ('')
|
const [content, setContent] = useState ('')
|
||||||
const [editingPost, setEditingPost] = useState<Post | null> (null)
|
const [editingPost, setEditingPost] = useState<Post | null> (null)
|
||||||
const [loading, setLoading] = useState (false)
|
const [loading, setLoading] = useState (false)
|
||||||
const [programmes, setProgrammes] = useState<TheatreProgramme[]> ([])
|
const { data: initialTheatre, error: theatreError } = useQuery ({
|
||||||
|
queryKey: theatresKeys.show (id ?? '', locale),
|
||||||
|
queryFn: () => apiGet<Theatre> (`/theatres/${ id }`, { locale }),
|
||||||
|
enabled: id != null })
|
||||||
|
const { data: initialProgrammes } = useQuery ({
|
||||||
|
queryKey: theatresKeys.programmes (id ?? '', locale),
|
||||||
|
queryFn: () => apiGet<TheatreProgramme[]> (`/theatres/${ id }/programmes`, {
|
||||||
|
locale, params: { limit: '100' } }), enabled: id != null })
|
||||||
|
const [programmes, setProgrammes] = useState<TheatreProgramme[]> (initialProgrammes ?? [])
|
||||||
const [sending, setSending] = useState (false)
|
const [sending, setSending] = useState (false)
|
||||||
const [status, setStatus] = useState (200)
|
const status = isApiError (theatreError) ? theatreError.response?.status ?? 200 : 200
|
||||||
const [theatre, setTheatre] = useState<Theatre | null> (null)
|
const [theatre, setTheatre] = useState<Theatre | null> (initialTheatre ?? null)
|
||||||
const [theatreInfo, setTheatreInfo] = useState<TheatreInfo> (INITIAL_THEATRE_INFO)
|
const [theatreInfo, setTheatreInfo] = useState<TheatreInfo> (INITIAL_THEATRE_INFO)
|
||||||
const [post, setPost] = useState<Post | null> (null)
|
const [post, setPost] = useState<Post | null> (null)
|
||||||
const [videoLength, setVideoLength] = useState (0)
|
const [videoLength, setVideoLength] = useState (0)
|
||||||
const [weights, setWeights] = useState<TheatrePostSelectionWeights> (INITIAL_WEIGHTS)
|
const [weights, setWeights] = useState<TheatrePostSelectionWeights> (INITIAL_WEIGHTS)
|
||||||
const [layoutMode, setLayoutMode] =
|
const [layoutMode, setLayoutMode] =
|
||||||
useState<TheatreLayoutMode> (() => getClientTheatreLayoutMode ())
|
useState<TheatreLayoutMode> ('threeColumns')
|
||||||
const [tagFlow, setTagFlow] = useState<TagFlow> (() => getClientTheatreTagFlow ())
|
const [tagFlow, setTagFlow] = useState<TagFlow> ('vertical')
|
||||||
const { fieldErrors, clearValidationErrors, applyValidationError } =
|
const { fieldErrors, clearValidationErrors, applyValidationError } =
|
||||||
useValidationErrors<TheatreCommentField> ()
|
useValidationErrors<TheatreCommentField> ()
|
||||||
|
|
||||||
@@ -259,6 +268,11 @@ const TheatreDetailPage: FC<Props> = ({ user }: Props) => {
|
|||||||
setClientTheatreLayoutMode (mode)
|
setClientTheatreLayoutMode (mode)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
useEffect (() => {
|
||||||
|
setLayoutMode (getClientTheatreLayoutMode ())
|
||||||
|
setTagFlow (getClientTheatreTagFlow ())
|
||||||
|
}, [])
|
||||||
|
|
||||||
const changeTagFlow = (flow: TagFlow) => {
|
const changeTagFlow = (flow: TagFlow) => {
|
||||||
setTagFlow (flow)
|
setTagFlow (flow)
|
||||||
setClientTheatreTagFlow (flow)
|
setClientTheatreTagFlow (flow)
|
||||||
@@ -348,39 +362,20 @@ const TheatreDetailPage: FC<Props> = ({ user }: Props) => {
|
|||||||
if (!(id))
|
if (!(id))
|
||||||
return
|
return
|
||||||
|
|
||||||
let cancelled = false
|
|
||||||
|
|
||||||
setComments ([])
|
setComments ([])
|
||||||
setEditingPost (null)
|
setEditingPost (null)
|
||||||
setPost (null)
|
setPost (null)
|
||||||
setProgrammes ([])
|
setProgrammes (initialProgrammes ?? [])
|
||||||
setTheatre (null)
|
setTheatre (initialTheatre ?? null)
|
||||||
theatreInfoReceivedAtRef.current = performance.now ()
|
theatreInfoReceivedAtRef.current = performance.now ()
|
||||||
setTheatreInfo (INITIAL_THEATRE_INFO)
|
setTheatreInfo (INITIAL_THEATRE_INFO)
|
||||||
setVideoLength (0)
|
setVideoLength (0)
|
||||||
setWeights (INITIAL_WEIGHTS)
|
setWeights (INITIAL_WEIGHTS)
|
||||||
lastCommentNoRef.current = 0
|
lastCommentNoRef.current = 0
|
||||||
|
|
||||||
void (async () => {
|
|
||||||
try
|
|
||||||
{
|
|
||||||
const data = await apiGet<Theatre> (`/theatres/${ id }`, { locale })
|
|
||||||
if (!(cancelled))
|
|
||||||
setTheatre (data)
|
|
||||||
}
|
|
||||||
catch (error)
|
|
||||||
{
|
|
||||||
setStatus (isApiError (error) ? error.response?.status ?? 200 : 200)
|
|
||||||
}
|
|
||||||
}) ()
|
|
||||||
|
|
||||||
void refreshProgrammes ()
|
|
||||||
void refreshWeights ()
|
void refreshWeights ()
|
||||||
|
|
||||||
return () => {
|
}, [id, initialProgrammes, initialTheatre, refreshWeights, locale])
|
||||||
cancelled = true
|
|
||||||
}
|
|
||||||
}, [id, refreshProgrammes, refreshWeights, locale])
|
|
||||||
|
|
||||||
useEffect (() => {
|
useEffect (() => {
|
||||||
if (!(id))
|
if (!(id))
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { useCallback, useEffect, useMemo, useState } from 'react'
|
import { useCallback, useEffect, useMemo, useState } from 'react'
|
||||||
|
import { useQuery } from '@tanstack/react-query'
|
||||||
import { useBeforeUnload, useLocation } from 'react-router-dom'
|
import { useBeforeUnload, useLocation } from 'react-router-dom'
|
||||||
|
|
||||||
import { useLocale, useLocaleNavigate } from '@/lib/useLocale'
|
import { useLocale, useLocaleNavigate } from '@/lib/useLocale'
|
||||||
@@ -42,6 +43,8 @@ import { useKeyboardShortcutSettings, useKeyboardShortcuts } from '@/lib/useKeyb
|
|||||||
import { cn, inputClass } from '@/lib/utils'
|
import { cn, inputClass } from '@/lib/utils'
|
||||||
import { useValidationErrors } from '@/lib/useValidationErrors'
|
import { useValidationErrors } from '@/lib/useValidationErrors'
|
||||||
import { useUITranslation } from '@/lib/useUITranslation'
|
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'
|
import type { CSSProperties, Dispatch, FC, KeyboardEvent, ReactNode, SetStateAction } from 'react'
|
||||||
|
|
||||||
@@ -657,6 +660,13 @@ const ThemeSection: FC<ThemeSectionProps> = props => {
|
|||||||
|
|
||||||
|
|
||||||
const SettingPage: FC<Props> = ({ user, setUser }) => {
|
const SettingPage: FC<Props> = ({ 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 { message, label } = useUITranslation ('settings')
|
||||||
const locale = useLocale ()
|
const locale = useLocale ()
|
||||||
const location = useLocation ()
|
const location = useLocation ()
|
||||||
@@ -665,25 +675,27 @@ const SettingPage: FC<Props> = ({ user, setUser }) => {
|
|||||||
const { registerUnsavedChangesSource } = useUnsavedChangesGuard ()
|
const { registerUnsavedChangesSource } = useUnsavedChangesGuard ()
|
||||||
|
|
||||||
const [inheritVsbl, setInheritVsbl] = useState (false)
|
const [inheritVsbl, setInheritVsbl] = useState (false)
|
||||||
const [loading, setLoading] = useState (true)
|
const [loading, setLoading] = useState (initialSettings == null)
|
||||||
const [name, setName] = useState ('')
|
const [name, setName] = useState (user?.name ?? '')
|
||||||
const [settingsError, setSettingsError] = useState<string | null> (null)
|
const [settingsError, setSettingsError] = useState<string | null> (null)
|
||||||
const [savedThemeMode, setSavedThemeMode] =
|
const [savedThemeMode, setSavedThemeMode] =
|
||||||
useState<ClientThemeMode> (getClientThemeMode ())
|
useState<ClientThemeMode> (initialSettings?.theme ?? 'system')
|
||||||
const [draftThemeMode, setDraftThemeMode] =
|
const [draftThemeMode, setDraftThemeMode] =
|
||||||
useState<ClientThemeMode> (getClientThemeMode ())
|
useState<ClientThemeMode> (initialSettings?.theme ?? 'system')
|
||||||
const [savedActiveLightThemeSlotNo, setSavedActiveLightThemeSlotNo] =
|
const [savedActiveLightThemeSlotNo, setSavedActiveLightThemeSlotNo] =
|
||||||
useState<UserThemeSlotNo> (getClientActiveLightThemeSlotNo ())
|
useState<UserThemeSlotNo> (1)
|
||||||
const [draftActiveLightThemeSlotNo, setDraftActiveLightThemeSlotNo] =
|
const [draftActiveLightThemeSlotNo, setDraftActiveLightThemeSlotNo] =
|
||||||
useState<UserThemeSlotNo> (getClientActiveLightThemeSlotNo ())
|
useState<UserThemeSlotNo> (1)
|
||||||
const [savedActiveDarkThemeSlotNo, setSavedActiveDarkThemeSlotNo] =
|
const [savedActiveDarkThemeSlotNo, setSavedActiveDarkThemeSlotNo] =
|
||||||
useState<UserThemeSlotNo> (getClientActiveDarkThemeSlotNo ())
|
useState<UserThemeSlotNo> (1)
|
||||||
const [draftActiveDarkThemeSlotNo, setDraftActiveDarkThemeSlotNo] =
|
const [draftActiveDarkThemeSlotNo, setDraftActiveDarkThemeSlotNo] =
|
||||||
useState<UserThemeSlotNo> (getClientActiveDarkThemeSlotNo ())
|
useState<UserThemeSlotNo> (1)
|
||||||
const [savedThemeSlots, setSavedThemeSlots] =
|
const [savedThemeSlots, setSavedThemeSlots] =
|
||||||
useState<UserThemeSlotMap> (() => ensureCompleteThemeSlots ({ }))
|
useState<UserThemeSlotMap> (() => ensureCompleteThemeSlots (
|
||||||
|
normaliseUserThemeSlots (initialThemeSlots ?? [])))
|
||||||
const [draftThemeSlots, setDraftThemeSlots] =
|
const [draftThemeSlots, setDraftThemeSlots] =
|
||||||
useState<UserThemeSlotMap> (() => ensureCompleteThemeSlots ({ }))
|
useState<UserThemeSlotMap> (() => ensureCompleteThemeSlots (
|
||||||
|
normaliseUserThemeSlots (initialThemeSlots ?? [])))
|
||||||
const [dirtyStates, setDirtyStates] =
|
const [dirtyStates, setDirtyStates] =
|
||||||
useState<Partial<Record<SettingsTab, SettingsTabDirtyState>>> ({ })
|
useState<Partial<Record<SettingsTab, SettingsTabDirtyState>>> ({ })
|
||||||
const [userCodeVsbl, setUserCodeVsbl] = useState (false)
|
const [userCodeVsbl, setUserCodeVsbl] = useState (false)
|
||||||
@@ -701,10 +713,10 @@ const SettingPage: FC<Props> = ({ user, setUser }) => {
|
|||||||
const activeTab = useMemo<SettingsTab> (() => parseTab (location.search), [location.search])
|
const activeTab = useMemo<SettingsTab> (() => parseTab (location.search), [location.search])
|
||||||
const displayedThemeSelection = useMemo (
|
const displayedThemeSelection = useMemo (
|
||||||
() => resolveDisplayedTheme ({
|
() => resolveDisplayedTheme ({
|
||||||
themeMode: draftThemeMode,
|
themeMode: (!hydrated && draftThemeMode === 'system') ? 'light' : draftThemeMode,
|
||||||
activeLightThemeSlotNo: draftActiveLightThemeSlotNo,
|
activeLightThemeSlotNo: draftActiveLightThemeSlotNo,
|
||||||
activeDarkThemeSlotNo: draftActiveDarkThemeSlotNo }, draftThemeSlots),
|
activeDarkThemeSlotNo: draftActiveDarkThemeSlotNo }, draftThemeSlots),
|
||||||
[draftActiveDarkThemeSlotNo,
|
[hydrated, draftActiveDarkThemeSlotNo,
|
||||||
draftActiveLightThemeSlotNo,
|
draftActiveLightThemeSlotNo,
|
||||||
draftThemeMode,
|
draftThemeMode,
|
||||||
draftThemeSlots])
|
draftThemeSlots])
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import type { FC } from 'react'
|
import type { FC } from 'react'
|
||||||
|
|
||||||
import { useEffect, useState } from 'react'
|
import { useQuery } from '@tanstack/react-query'
|
||||||
import { useLocation, useParams } from 'react-router-dom'
|
import { useLocation, useParams } from 'react-router-dom'
|
||||||
|
|
||||||
import { useLocale } from '@/lib/useLocale'
|
import { useLocale } from '@/lib/useLocale'
|
||||||
@@ -9,6 +9,7 @@ import PageTitle from '@/components/common/PageTitle'
|
|||||||
import MainArea from '@/components/layout/MainArea'
|
import MainArea from '@/components/layout/MainArea'
|
||||||
import PageHead from '@/components/common/PageHead'
|
import PageHead from '@/components/common/PageHead'
|
||||||
import { apiGet } from '@/lib/api'
|
import { apiGet } from '@/lib/api'
|
||||||
|
import { wikiKeys } from '@/lib/queryKeys'
|
||||||
import { cn } from '@/lib/utils'
|
import { cn } from '@/lib/utils'
|
||||||
|
|
||||||
import type { WikiPageDiff } from '@/types'
|
import type { WikiPageDiff } from '@/types'
|
||||||
@@ -21,43 +22,18 @@ const WikiDiffPage: FC = () => {
|
|||||||
|
|
||||||
const location = useLocation ()
|
const location = useLocation ()
|
||||||
|
|
||||||
const [result, setResult] = useState<{
|
|
||||||
key: string
|
|
||||||
diff: WikiPageDiff } | null> (null)
|
|
||||||
|
|
||||||
const query = new URLSearchParams (location.search)
|
const query = new URLSearchParams (location.search)
|
||||||
const from = query.get ('from')
|
const from = query.get ('from')
|
||||||
const to = query.get ('to')
|
const to = query.get ('to')
|
||||||
const requestKey = JSON.stringify ([locale, id, from, to])
|
const { data: diff } = useQuery ({
|
||||||
const diff = result?.key === requestKey ? result.diff : null
|
queryKey: wikiKeys.diff (id ?? '', { from, to }, locale),
|
||||||
|
queryFn: () => apiGet<WikiPageDiff> (
|
||||||
|
`/wiki/${ encodeURIComponent (id ?? '') }/diff`, { locale, params: { from, to } }) })
|
||||||
const displayTitle = diff
|
const displayTitle = diff
|
||||||
? `${ diff.title }${ diff.deprecatedAt != null
|
? `${ diff.title }${ diff.deprecatedAt != null
|
||||||
? message ('shared:labels.deprecatedSuffix').text : '' }`
|
? message ('shared:labels.deprecatedSuffix').text : '' }`
|
||||||
: ''
|
: ''
|
||||||
|
|
||||||
useEffect (() => {
|
|
||||||
const controller = new AbortController ()
|
|
||||||
let active = true
|
|
||||||
void (async () => {
|
|
||||||
try
|
|
||||||
{
|
|
||||||
const diff = await apiGet<WikiPageDiff> (`/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 (
|
return (
|
||||||
<MainArea>
|
<MainArea>
|
||||||
<PageHead title={message ('diffTitle', { title: displayTitle })}/>
|
<PageHead title={message ('diffTitle', { title: displayTitle })}/>
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useQueryClient } from '@tanstack/react-query'
|
import { useQuery, useQueryClient } from '@tanstack/react-query'
|
||||||
import MarkdownIt from 'markdown-it'
|
import MarkdownIt from 'markdown-it'
|
||||||
import { useEffect, useState } from 'react'
|
import { useEffect, useState } from 'react'
|
||||||
import MdEditor from 'react-markdown-editor-lite'
|
import MdEditor from 'react-markdown-editor-lite'
|
||||||
@@ -47,12 +47,15 @@ const WikiEditPage: FC<Props> = ({ user }) => {
|
|||||||
|
|
||||||
const qc = useQueryClient ()
|
const qc = useQueryClient ()
|
||||||
|
|
||||||
const [body, setBody] = useState ('')
|
|
||||||
const [loading, setLoading] = useState (true)
|
|
||||||
const [loadedSource, setLoadedSource] = useState ('')
|
|
||||||
const source = `${locale}:${id}`
|
const source = `${locale}:${id}`
|
||||||
|
const { data: wiki, isPending: loading } = useQuery ({
|
||||||
|
queryKey: wikiKeys.show (id ?? '', { }, locale),
|
||||||
|
queryFn: () => apiGet<WikiPage> (`/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 pending = loading || loadedSource !== source
|
||||||
const [title, setTitle] = useState ('')
|
const [title, setTitle] = useState (wiki?.title ?? '')
|
||||||
const { baseErrors, fieldErrors, clearValidationErrors, applyValidationError } =
|
const { baseErrors, fieldErrors, clearValidationErrors, applyValidationError } =
|
||||||
useValidationErrors<WikiFormField> ()
|
useValidationErrors<WikiFormField> ()
|
||||||
|
|
||||||
@@ -85,36 +88,12 @@ const WikiEditPage: FC<Props> = ({ user }) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
useEffect (() => {
|
useEffect (() => {
|
||||||
if (!(editable))
|
if (!(editable) || wiki == null)
|
||||||
return
|
return
|
||||||
|
setTitle (wiki.title)
|
||||||
const controller = new AbortController ()
|
setBody (wiki.body)
|
||||||
let active = true
|
|
||||||
void (async () => {
|
|
||||||
setLoading (true)
|
|
||||||
try
|
|
||||||
{
|
|
||||||
const data = await apiGet<WikiPage> (`/wiki/${ encodeURIComponent (id ?? '') }`, {
|
|
||||||
locale, signal: controller.signal })
|
|
||||||
if (active)
|
|
||||||
{
|
|
||||||
setTitle (data.title)
|
|
||||||
setBody (data.body)
|
|
||||||
setLoadedSource (source)
|
setLoadedSource (source)
|
||||||
setLoading (false)
|
}, [editable, source, wiki])
|
||||||
}
|
|
||||||
}
|
|
||||||
catch (error)
|
|
||||||
{
|
|
||||||
if (active)
|
|
||||||
console.error (error)
|
|
||||||
}
|
|
||||||
}) ()
|
|
||||||
return () => {
|
|
||||||
active = false
|
|
||||||
controller.abort ()
|
|
||||||
}
|
|
||||||
}, [editable, id, locale, source])
|
|
||||||
|
|
||||||
if (!(editable))
|
if (!(editable))
|
||||||
return <Forbidden/>
|
return <Forbidden/>
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { useEffect, useState } from 'react'
|
import { useEffect, useState } from 'react'
|
||||||
|
import { useQuery } from '@tanstack/react-query'
|
||||||
|
|
||||||
import { useLocale } from '@/lib/useLocale'
|
import { useLocale } from '@/lib/useLocale'
|
||||||
import { useUITranslation } from '@/lib/useUITranslation'
|
import { useUITranslation } from '@/lib/useUITranslation'
|
||||||
@@ -8,6 +9,7 @@ import PageTitle from '@/components/common/PageTitle'
|
|||||||
import MainArea from '@/components/layout/MainArea'
|
import MainArea from '@/components/layout/MainArea'
|
||||||
import PageHead from '@/components/common/PageHead'
|
import PageHead from '@/components/common/PageHead'
|
||||||
import { apiGet } from '@/lib/api'
|
import { apiGet } from '@/lib/api'
|
||||||
|
import { wikiKeys } from '@/lib/queryKeys'
|
||||||
import { dateString, inputClass } from '@/lib/utils'
|
import { dateString, inputClass } from '@/lib/utils'
|
||||||
|
|
||||||
import type { FormEvent , FC } from 'react'
|
import type { FormEvent , FC } from 'react'
|
||||||
@@ -20,21 +22,22 @@ const WikiSearchPage: FC = () => {
|
|||||||
const locale = useLocale ()
|
const locale = useLocale ()
|
||||||
const [title, setTitle] = useState ('')
|
const [title, setTitle] = useState ('')
|
||||||
const [text, setText] = useState ('')
|
const [text, setText] = useState ('')
|
||||||
const [results, setResults] = useState<WikiPage[]> ([])
|
const [submittedTitle, setSubmittedTitle] = useState ('')
|
||||||
|
const { data: results = [], refetch } = useQuery ({
|
||||||
const search = async () => {
|
queryKey: wikiKeys.index ({ title: submittedTitle }, locale),
|
||||||
setResults (await apiGet ('/wiki', { locale, params: { title } }))
|
queryFn: () => apiGet<WikiPage[]> ('/wiki', {
|
||||||
}
|
locale, params: { title: submittedTitle } }) })
|
||||||
|
|
||||||
const handleSearch = (ev: FormEvent) => {
|
const handleSearch = (ev: FormEvent) => {
|
||||||
ev.preventDefault ()
|
ev.preventDefault ()
|
||||||
search ()
|
if (submittedTitle === title)
|
||||||
|
void refetch ()
|
||||||
|
else
|
||||||
|
setSubmittedTitle (title)
|
||||||
}
|
}
|
||||||
|
|
||||||
useEffect (() => {
|
useEffect (() => {
|
||||||
void (async () => {
|
setSubmittedTitle ('')
|
||||||
setResults (await apiGet ('/wiki', { locale, params: { title: '' } }))
|
|
||||||
}) ()
|
|
||||||
}, [locale])
|
}, [locale])
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -29,7 +29,8 @@ export default defineConfig ({
|
|||||||
return path.resolve (__dirname, './src/lib/renderContext.server.ts')
|
return path.resolve (__dirname, './src/lib/renderContext.server.ts')
|
||||||
} }, helpAssets (), mdxPlugin, react ()],
|
} }, helpAssets (), mdxPlugin, react ()],
|
||||||
resolve: { alias: { '@': path.resolve (__dirname, './src') } },
|
resolve: { alias: { '@': path.resolve (__dirname, './src') } },
|
||||||
ssr: { noExternal: ['react-helmet-async'] },
|
ssr: { noExternal: [
|
||||||
|
'react-helmet-async', 'react-markdown-editor-lite', 'eventemitter3'] },
|
||||||
server: { host: true,
|
server: { host: true,
|
||||||
port: 5173,
|
port: 5173,
|
||||||
strictPort: true,
|
strictPort: true,
|
||||||
|
|||||||
新しいイシューから参照
ユーザーをブロックする