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