アイヌ語追加 (#428) (#433)

Reviewed-on: #433
Co-authored-by: miteruzo <miteruzo@naver.com>
このコミットはプルリクエスト #433 でマージされました。
このコミットが含まれているのは:
2026-10-20 20:44:14 +09:00
committed by みてるぞ
コミット 084a090cab
294個のファイルの変更、13978行の追加、3461行の削除
+3 -25
ファイルの表示
@@ -4,35 +4,13 @@
<meta charset="UTF-8" />
<link rel="icon" href="/favicon.ico" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="description" content="ぼざクリ ぼざろクリーチャーシリーズ 伊地知ニジカ リンク集 Wiki ニコニコ" />
<meta name="description" lang="ja" data-rh="true" content="ぼざクリ ぼざろクリーチャーシリーズ 伊地知ニジカ リンク集 Wiki ニコニコ" />
<title>ぼざクリ タグ広場 〜 ぼざろクリーチャーシリーズ綜合リンク集</title>
<!-- helmet -->
</head>
<body>
<div id="root">
<div class="flex flex-col h-screen w-screen">
<nav class="px-3 flex justify-between items-center w-full min-h-[48px]
bg-yellow-200 dark:bg-red-975 md:bg-yellow-50">
<div class="flex items-center gap-2 h-full">
<a class="mx-4 text-xl font-bold text-pink-600 hover:text-pink-400
dark:text-pink-300 dark:hover:text-pink-100"
href="/">
ぼざクリ タグ広場
</a>
<a class="hidden md:flex h-full items-center px-2" href="/posts">広場</a>
<a class="hidden md:flex h-full items-center px-2" href="/tags">タグ</a>
<a class="hidden md:flex h-full items-center px-2" href="/wiki/ヘルプ:ホーム">Wiki</a>
<a class="hidden md:flex h-full items-center px-2" href="/users">ユーザ</a>
</div>
<a href="#" class="md:hidden ml-auto pr-4
text-pink-600 hover:text-pink-400
dark:text-pink-300 dark:hover:text-pink-100">
Menu
</a>
</nav>
</div>
<!-- outlet -->
</div>
<div id="root"><!-- outlet --></div>
<!-- ssr-state -->
<script type="module" src="/src/main.tsx"></script>
</body>
</html>
生成ファイル
+78 -3
ファイルの表示
@@ -25,12 +25,14 @@
"clsx": "^2.1.1",
"framer-motion": "^12.23.26",
"humps": "^2.0.1",
"i18next": "^26.4.7",
"lucide-react": "^0.511.0",
"markdown-it": "^14.1.0",
"path-to-regexp": "^8.3.0",
"react": "^19.1.0",
"react-dom": "^19.1.0",
"react-helmet-async": "^2.0.5",
"react-i18next": "^17.0.20",
"react-markdown": "^10.1.0",
"react-markdown-editor-lite": "^1.3.4",
"react-router-dom": "^6.30.1",
@@ -350,9 +352,9 @@
}
},
"node_modules/@babel/runtime": {
"version": "7.27.6",
"resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.27.6.tgz",
"integrity": "sha512-vbavdySgbTTrmFE+EsiqUTzlOr5bzlnJtUv9PynGCAKvfQqjIXbvFdumPM/GxMDfyuGMJaJAU6TO4zc1Jf1i8Q==",
"version": "7.29.10",
"resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.29.10.tgz",
"integrity": "sha512-NpugTzbKgGrNe45Ui32cN8vCFFMxVtP/9rtfwv0nP5FRDAjvYBxJcLVpYA9mr1jxP6cnIT7x9LUzuDTbWYYNRA==",
"license": "MIT",
"engines": {
"node": ">=6.9.0"
@@ -4872,6 +4874,15 @@
"node": ">=18"
}
},
"node_modules/html-parse-stringify": {
"version": "4.0.1",
"resolved": "https://registry.npmjs.org/html-parse-stringify/-/html-parse-stringify-4.0.1.tgz",
"integrity": "sha512-0zHsZJrK7S3K2aucXWL6ycoYJ/iNtIcFHC/nYQgFklPtrv5LpJctIiSCroWZWeuoXvuyFdzp6KzjJQ+OT5MfFw==",
"license": "MIT",
"funding": {
"url": "https://locize.com"
}
},
"node_modules/html-url-attributes": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/html-url-attributes/-/html-url-attributes-3.0.1.tgz",
@@ -4916,6 +4927,34 @@
"integrity": "sha512-E0eIbrFWUhwfXJmsbdjRQFQPrl5pTEoKlz163j1mTqqUnU9PgR4AgB8AIITzuB3vLBdxZXyZ9TDIrwB2OASz4g==",
"license": "MIT"
},
"node_modules/i18next": {
"version": "26.4.7",
"resolved": "https://registry.npmjs.org/i18next/-/i18next-26.4.7.tgz",
"integrity": "sha512-p/3hqy6EcuqRs6wNwBYwGGC1fzBJSMxV9zMuN814ab/7QjdZbXfjXwuPZdQbngX6KDOTxM01uUiZyqmMB8ghZg==",
"funding": [
{
"type": "individual",
"url": "https://www.locize.com/i18next"
},
{
"type": "individual",
"url": "https://www.i18next.com/how-to/faq#i18next-is-awesome.-how-can-i-support-the-project"
},
{
"type": "individual",
"url": "https://www.locize.com"
}
],
"license": "MIT",
"peerDependencies": {
"typescript": "^5 || ^6 || ^7"
},
"peerDependenciesMeta": {
"typescript": {
"optional": true
}
}
},
"node_modules/iconv-lite": {
"version": "0.6.3",
"resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz",
@@ -7258,6 +7297,33 @@
"react": "^16.6.0 || ^17.0.0 || ^18.0.0"
}
},
"node_modules/react-i18next": {
"version": "17.0.20",
"resolved": "https://registry.npmjs.org/react-i18next/-/react-i18next-17.0.20.tgz",
"integrity": "sha512-rzSSirf0NP9vfcUXHZAksIuyMWl7L/T/7nm8S+sSIFuIzku54qcyyVAOZXbawPNpUxGkDx/eosSv4DrpzcxuOQ==",
"license": "MIT",
"dependencies": {
"@babel/runtime": "^7.29.7",
"html-parse-stringify": "^4.0.1",
"use-sync-external-store": "^1.6.0"
},
"peerDependencies": {
"i18next": ">= 26.2.0",
"react": ">= 16.8.0",
"typescript": "^5 || ^6 || ^7"
},
"peerDependenciesMeta": {
"react-dom": {
"optional": true
},
"react-native": {
"optional": true
},
"typescript": {
"optional": true
}
}
},
"node_modules/react-is": {
"version": "16.13.1",
"resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz",
@@ -8595,6 +8661,15 @@
}
}
},
"node_modules/use-sync-external-store": {
"version": "1.7.0",
"resolved": "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.7.0.tgz",
"integrity": "sha512-6L+EeigHMQhdaIPNIFUKwfWJSwWFQ8gJbJ2DLOs5sDIegTwR9fRxvnM3uciHKjIZhFz+KAv2emhWMRvDmMcY8A==",
"license": "MIT",
"peerDependencies": {
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
}
},
"node_modules/util-deprecate": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz",
+8 -3
ファイルの表示
@@ -5,12 +5,15 @@
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc -b && vite build",
"postbuild": "node scripts/generate-sitemap.js",
"build": "tsc -b && npm run build:client && npm run build:server",
"build:client": "vite build --outDir dist/client",
"build:server": "vite build --ssr src/entry-server.tsx --outDir dist/server",
"start": "node server.mjs",
"lint": "eslint .",
"test": "vitest",
"test:run": "vitest run",
"preview": "vite preview"
"test:ssr": "node --test server.ssr.test.mjs",
"preview": "vite preview --outDir dist/client"
},
"dependencies": {
"@dnd-kit/core": "^6.3.1",
@@ -29,12 +32,14 @@
"clsx": "^2.1.1",
"framer-motion": "^12.23.26",
"humps": "^2.0.1",
"i18next": "^26.4.7",
"lucide-react": "^0.511.0",
"markdown-it": "^14.1.0",
"path-to-regexp": "^8.3.0",
"react": "^19.1.0",
"react-dom": "^19.1.0",
"react-helmet-async": "^2.0.5",
"react-i18next": "^17.0.20",
"react-markdown": "^10.1.0",
"react-markdown-editor-lite": "^1.3.4",
"react-router-dom": "^6.30.1",
+2 -1
ファイルの表示
@@ -11,7 +11,8 @@ const formatDate = (date = new Date) => {
return `${ year }-${ month }-${ day }`
}
const SITE_TITLE = 'ぼざクリ タグ広場'
const SITE_TITLE = JSON.parse (fs.readFileSync (
new URL ('../src/locales/ja/shared.json', import.meta.url), 'utf8')).labels.brand
const DOMAIN = 'https://hub.nizika.monster'
const API_BASE_URL = 'https://hub.nizika.monster/api'
+136
ファイルの表示
@@ -0,0 +1,136 @@
import { createReadStream } from 'node:fs'
import { readFile, stat } from 'node:fs/promises'
import { createServer, request as requestHTTP } from 'node:http'
import { request as requestHTTPS } from 'node:https'
import path from 'node:path'
import { fileURLToPath } from 'node:url'
import { render, renderSitemap } from './dist/server/entry-server.js'
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 types = {
'.js': 'text/javascript', '.css': 'text/css', '.json': 'application/json',
'.png': 'image/png', '.gif': 'image/gif', '.jpg': 'image/jpeg',
'.svg': 'image/svg+xml', '.ico': 'image/x-icon', '.webp': 'image/webp',
'.woff': 'font/woff', '.woff2': 'font/woff2', '.otf': 'font/otf',
'.mp4': 'video/mp4', '.mp3': 'audio/mpeg', '.txt': 'text/plain' }
if (process.env.NODE_ENV === 'production' && !process.env.PUBLIC_ORIGIN)
throw new Error ('PUBLIC_ORIGIN is required in production')
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 === '/api' || request.url.startsWith ('/api/'))
{
const address = new URL (request.url, origin)
const target = new URL (apiBaseURL)
target.pathname = target.pathname.replace (/\/$/, '') + address.pathname.slice (4)
target.search = address.search
const send = target.protocol === 'https:' ? requestHTTPS : requestHTTP
const upstream = send (target, {
method: request.method,
headers: { ...request.headers, host: target.host,
'x-forwarded-host': new URL (origin).host,
'x-forwarded-proto': new URL (origin).protocol.replace (':', ''),
'x-forwarded-for': forwardedFor } }, result => {
response.writeHead (result.statusCode ?? 502, result.headers)
result.on ('error', () => response.destroy ())
result.pipe (response)
})
upstream.setTimeout (10_000, () => upstream.destroy (new Error ('API timeout')))
upstream.on ('error', () => {
if (response.headersSent)
response.destroy ()
else
response.writeHead (503).end ()
})
request.on ('aborted', () => upstream.destroy ())
request.pipe (upstream)
return
}
if (!['GET', 'HEAD'].includes (request.method))
{
response.writeHead (405, { Allow: 'GET, HEAD' }).end ()
return
}
try
{
const url = new URL (request.url, origin)
const pathname = decodeURIComponent (url.pathname)
if (pathname === '/sitemap.xml' || pathname.startsWith ('/sitemap-'))
{
const result = await renderSitemap (url.pathname, origin, apiBaseURL)
response.writeHead (result.status, { 'Content-Type': 'application/xml; charset=utf-8' })
response.end (request.method === 'HEAD' ? undefined : result.body)
return
}
if (pathname === '/robots.txt')
{
response.writeHead (200, { 'Content-Type': 'text/plain; charset=utf-8' })
response.end (request.method === 'HEAD'
? undefined : `Sitemap: ${new URL ('/sitemap.xml', origin).href}\n`)
return
}
const file = path.resolve (root, '.' + pathname)
const allowed = file.startsWith (root)
&& !pathname.split ('/').some (segment => segment.startsWith ('.'))
&& pathname !== '/index.html'
const info = allowed ? await stat (file).catch (() => null) : null
if (info?.isFile ())
{
response.writeHead (200, {
'Content-Type': types[path.extname (file)] ?? 'application/octet-stream',
'Content-Length': info.size,
'Cache-Control': /\/[^/]+-[\w-]{8}\.[^.]+$/.test (pathname)
? 'public, max-age=31536000, immutable' : 'public, max-age=3600' })
if (request.method === 'HEAD')
response.end ()
else
createReadStream (file).on ('error', () => response.destroy ()).pipe (response)
return
}
if (pathname.startsWith ('/assets/'))
{
response.writeHead (404).end ()
return
}
const result = await render (url.pathname + url.search, origin, apiBaseURL, {
cookie: request.headers.cookie, forwardedFor })
if (result?.setCookies?.length > 0)
response.setHeader ('Set-Cookie', result.setCookies)
if (result?.location != null)
{
response.writeHead (result.status, { Location: result.location }).end ()
return
}
const html = result?.html == null
? template.replace ('<!-- helmet -->', result?.status === 404
? '<meta name="robots" content="noindex" data-rh="true">' : '')
: template
.replace ('lang="ja"', `lang="${result.locale}"`)
.replace (/<title>[\s\S]*?<\/title>/, '')
.replace (/<meta name="description"[^>]*>/, '')
.replace ('<!-- helmet -->', () => result.head)
.replace ('<!-- outlet -->', () => result.html)
.replace ('<!-- ssr-state -->',
() => `<script id="ssr-state" type="application/json">${result.bootstrap}</script>`)
response.writeHead (result?.status ?? 200, {
'Content-Type': 'text/html; charset=utf-8' })
response.end (request.method === 'HEAD' ? undefined : html)
}
catch (error)
{
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 ()
}
})
server.listen (Number (process.env.PORT ?? 5173), process.env.HOST ?? '127.0.0.1', () => {
console.info ('[SSR] listening on', server.address ().port)
})
+662
ファイルの表示
@@ -0,0 +1,662 @@
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 = []
// Rails owns encryption and verification; this upstream fixture tests Node's forwarding contract.
const recoveryCode = 'test-recovery-secret'
const users = new Map ([
[recoveryCode, { id: 1, name: 'SSR account', role: 'admin' }],
['second-recovery-secret', { id: 2, name: 'Second account', role: 'member' }]])
const sessions = new Map ()
let sessionSequence = 0
const cookieName = '__Host-btrc-session'
const cookieFor = value => `${cookieName}=${value}`
const sessionValue = request => request.headers.cookie?.split (';')
.map (part => part.trim ()).find (part => part.startsWith (`${cookieName}=`))
?.slice (cookieName.length + 1)
const issueSession = (response, code = null) => {
const value = `opaque-rails-session-${++sessionSequence}`
sessions.set (value, { code, token: `csrf-${sessionSequence}` })
refreshCookie (response, value)
return value
}
const refreshCookie = (response, value) => response.setHeader ('Set-Cookie',
`${cookieFor (value)}; Path=/; Max-Age=630720000; HttpOnly; Secure; SameSite=Lax`)
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/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 === '/posts/2')
return { ...post (locale), id: 2,
url: 'https://www.youtube.com/watch?v=dQw4w9WgXcQ' }
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 []
if (path === '/theatres/1/comments') return []
if (path === '/theatres/1/post_selection_weights')
return { tag_penalties: [], lightest_posts: [], heaviest_posts: [] }
if (path === '/theatres/1/watching')
return { host_flg: false, post_id: null, post_started_at: null, post_elapsed_ms: null,
watching_users: [], skip_vote: {
votes_count: 0, required_count: 1, watching_users_count: 0, voted: false } }
return null
}
// Give startup failures a bounded, visible error instead of waiting indefinitely.
const withTimeout = async (label, promise, milliseconds) => {
let timer
try {
return await Promise.race ([promise, new Promise ((_, reject) => {
timer = setTimeout (() => reject (new Error (
`[SSR TEST] ${label} timed out after ${milliseconds}ms`)), milliseconds)
})])
} finally {
clearTimeout (timer)
}
}
before (async () => {
console.error('[SSR TEST] setup started')
api = createServer (async (request, response) => {
requests.push ({ method: request.method, url: request.url,
cookie: request.headers.cookie, csrf: request.headers['x-csrf-token'],
transferCode: request.headers['x-transfer-code'] })
const url = new URL (request.url, 'http://localhost')
response.setHeader ('Content-Type', 'application/json')
response.setHeader ('Cache-Control', 'private, no-store')
if (failAPI) {
response.writeHead (503).end ()
return
}
if (url.pathname.startsWith ('/users')) {
const value = sessionValue (request)
const session = sessions.get (value)
const user = users.get (session?.code)
if (request.method === 'GET') {
if (url.pathname === '/users/csrf') {
const id = session != null ? value : issueSession (response)
response.end (JSON.stringify ({ token: sessions.get (id).token }))
return
}
if (user == null) {
response.writeHead (401).end ()
return
}
refreshCookie (response, value)
const data = url.pathname === '/users/session' ? user
: (url.pathname === '/users/me' ? { ...user, inheritance_code: session.code }
: responseFor (url))
response.writeHead (data == null ? 404 : 200)
response.end (JSON.stringify (data))
return
}
if (![origin, httpOrigin].includes (request.headers.origin)
|| session == null || request.headers['x-csrf-token'] !== session.token) {
response.writeHead (403, { 'X-CSRF-Invalid': '1' }).end ()
return
}
let body = ''
for await (const chunk of request) body += chunk
const params = JSON.parse (body || '{}')
if (request.method === 'POST' && url.pathname === '/users/verify') {
const code = params.restore && user != null ? session.code : params.code
const verified = users.get (code)
if (verified == null) {
response.end (JSON.stringify ({ valid: false }))
return
}
sessions.delete (value)
issueSession (response, code)
response.end (JSON.stringify ({ valid: true,
user: { ...verified, inheritance_code: code } }))
return
}
if (request.method === 'DELETE' && url.pathname === '/users/session') {
sessions.delete (value)
response.writeHead (204, { 'Set-Cookie':
`${cookieName}=; Path=/; Max-Age=0; HttpOnly; Secure; SameSite=Lax` }).end ()
return
}
if (request.method === 'POST' && url.pathname === '/users/code/renew' && user != null) {
const code = `rotated-recovery-${sessionSequence}`
users.delete (session.code)
users.set (code, user)
sessions.delete (value)
issueSession (response, code)
response.end (JSON.stringify ({ code }))
return
}
response.writeHead (404).end ()
return
}
const data = responseFor (url)
response.writeHead (data == null ? 404 : 200)
response.end (JSON.stringify (data))
})
console.error ('[SSR TEST] API created')
await withTimeout ('mock API listen', new Promise ((resolve, reject) => {
api.once ('error', reject)
api.listen (0, '127.0.0.1', () => {
api.off ('error', reject)
resolve ()
})
}), 15_000)
apiURL = `http://127.0.0.1:${api.address ().port}`
console.error ('[SSR TEST] API ready:', apiURL)
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 },
stdio: ['ignore', 'pipe', 'pipe'] })
console.error ('[SSR TEST] SSR process spawned:', node.pid ?? '(no PID)')
let childOutput = ''
let childError = ''
node.stderr.on ('data', data => {
const text = data.toString ()
childError = (childError + text).slice (-8_192)
process.stderr.write (`[SSR CHILD] ${text}`)
})
httpOrigin = await withTimeout ('SSR server readiness', new Promise ((resolve, reject) => {
const onData = data => {
childOutput = (childOutput + data.toString()).slice(-8192)
// ANSI カラーコードを取り除いてから解析する
const output = childOutput.replace(/\x1b\[[0-9;]*m/g, '')
const match = output.match(/\[SSR\]\s+listening on\s+(\d+)/)
if (!match) return
console.error('[SSR TEST] detected SSR port:', match[1])
resolve(`http://127.0.0.1:${match[1]}`)
}
node.stdout.on ('data', onData)
node.once ('error', reject)
node.once ('exit', (code, signal) => reject (new Error (
`SSR process exited before readiness (code=${code}, signal=${signal})` +
`\nstdout: ${childOutput}\nstderr: ${childError}`)))
}), 30_000).catch (error => {
throw new Error (`${error.message}\nSSR stdout: ${childOutput}\nSSR stderr: ${childError}`,
{ cause: error })
})
console.error ('[SSR TEST] SSR server ready:', httpOrigin)
console.error ('[SSR TEST] starting Vite')
vite = await withTimeout ('Vite initialization',
createViteServer ({ server: { middlewareMode: true }, appType: 'custom' }), 90_000)
console.error ('[SSR TEST] Vite ready')
const { pageRoutes } = await withTimeout ('App.tsx module loading',
vite.ssrLoadModule ('/src/App.tsx'), 90_000)
console.error ('[SSR TEST] App loaded')
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, () => {}))
console.error (`[SSR TEST] setup complete (${routes.length} routes)`)
})
// Report the route on which server-side rendering stalls or fails.
const renderPage = async (url, ...args) => {
console.error (`[SSR TEST] rendering: ${url}`)
const result = await withTimeout (`render ${url}`, render (url, ...args), 60_000)
console.error (`[SSR TEST] rendered: ${url}`)
return result
}
const authenticatedCookie = async (code = recoveryCode) => {
const csrf = await fetch (`${httpOrigin}/api/users/csrf`)
assert.equal (csrf.status, 200)
const { token } = await csrf.json ()
const verified = await fetch (`${httpOrigin}/api/users/verify`, {
method: 'POST', headers: { Origin: origin, 'Content-Type': 'application/json',
Cookie: csrf.headers.get ('set-cookie').split (';')[0], 'X-CSRF-Token': token },
body: JSON.stringify ({ code, restore: true }) })
assert.equal (verified.status, 200)
assert.equal ((await verified.json ()).valid, true)
const cookie = verified.headers.get ('set-cookie')
assert.match (cookie, /HttpOnly; Secure; SameSite=Lax/)
assert.doesNotMatch (cookie, new RegExp (code))
return cookie.split (';')[0]
}
after (async () => {
console.error ('[SSR TEST] teardown started')
const errors = []
if (vite) {
try {
await withTimeout ('Vite shutdown', vite.close (), 30_000)
} catch (error) {
errors.push (error)
}
}
if (node && node.exitCode == null && node.signalCode == null) node.kill ()
if (api?.listening) {
api.closeAllConnections ()
try {
await withTimeout ('mock API shutdown', new Promise ((resolve, reject) => {
api.close (error => error ? reject (error) : resolve ())
}), 10_000)
} catch (error) {
errors.push (error)
}
}
console.error ('[SSR TEST] teardown finished')
if (errors.length) throw new AggregateError (errors, 'SSR teardown failed')
})
test ('every canonical screen route returns page HTML without JavaScript', async t => {
const cookie = await authenticatedCookie ()
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 renderPage (url, origin, apiURL, { cookie })
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-recovery-secret|opaque-rails/)
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 ('YouTube posts return HTTP 200 with the embed rendered by SSR', async t => {
for (const locale of ['ja', 'ain-Kana']) {
await t.test (locale, async () => {
const response = await fetch (`${httpOrigin}/${locale}/posts/2`)
assert.equal (response.status, 200)
const html = await response.text ()
assert.match (html, new RegExp (`<html lang="${locale}"`))
assert.match (html, new RegExp (`SSR post ${locale}`))
assert.match (html, /https:\/\/www\.youtube\.com\/watch\?v=dQw4w9WgXcQ/)
// react-youtube renders its player container before mounting the iframe.
assert.match (html, /<div class=""><div id="" class=""><\/div><\/div>/)
assert.doesNotMatch (html, /Switched to client rendering|template id="B:/)
})
}
})
test ('parallel localized requests keep list data isolated', async () => {
const start = requests.length
const pages = await Promise.all (['ja', 'ain-Kana', 'ja', 'ain-Kana'].map (locale =>
renderPage (`/${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.slice (start).every (request => request.method === 'GET'))
})
test ('unknown resources and legacy URLs have correct status', async () => {
assert.equal ((await renderPage ('/ja/unknown', origin, apiURL)).status, 404)
assert.equal ((await renderPage ('/ja/help/unknown', origin, apiURL)).status, 404)
assert.equal ((await renderPage ('/ja/posts/999', origin, apiURL)).status, 404)
const redirect = await renderPage ('/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 forwards Rails cookies, CSRF, expiration and upstream errors', async () => {
const cookie = await authenticatedCookie ()
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-recovery-secret|opaque-rails-session/)
assert.match (settings.headers.get ('cache-control'), /private, no-store/)
assert.match (settings.headers.get ('set-cookie'), /Max-Age=630720000/)
assert.ok (requests.some (request => request.url === '/users/session'
&& request.cookie === cookie))
assert.ok (requests.every (request => request.transferCode == null))
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)
const publicPage = await fetch (`${httpOrigin}/ain-Kana/posts`)
assert.equal (publicPage.status, 200)
assert.match (await publicPage.text (), /SSR post ain-Kana/)
assert.equal ((await fetch (`${httpOrigin}/_ssr/session`, { method: 'POST' })).status, 405)
const csrf = await fetch (`${httpOrigin}/api/users/csrf`, { headers: { Cookie: cookie } })
const { token } = await csrf.json ()
const write = (csrfToken, requestOrigin = origin) => fetch (
`${httpOrigin}/api/users/verify`, { method: 'POST',
headers: { Cookie: cookie, Origin: requestOrigin, 'Content-Type': 'application/json',
'X-CSRF-Token': csrfToken }, body: JSON.stringify ({ code: recoveryCode }) })
assert.equal ((await write ('invalid')).status, 403)
assert.equal ((await write (token, 'https://other.invalid')).status, 403)
assert.equal ((await fetch (`${httpOrigin}/ja/users/settings`, {
headers: { Cookie: cookie } })).status, 200)
const renewed = await write (token)
assert.equal (renewed.status, 200)
const renewedCookie = renewed.headers.get ('set-cookie').split (';')[0]
assert.notEqual (renewedCookie, cookie)
assert.equal ((await fetch (`${httpOrigin}/ja/users/settings`, {
headers: { Cookie: cookie } })).status, 401)
const renewedCSRF = await fetch (`${httpOrigin}/api/users/csrf`, {
headers: { Cookie: renewedCookie } })
const newToken = (await renewedCSRF.json ()).token
const logout = await fetch (`${httpOrigin}/api/users/session`, { method: 'DELETE',
headers: { Cookie: renewedCookie, Origin: origin, 'X-CSRF-Token': newToken } })
assert.equal (logout.status, 204)
assert.match (logout.headers.get ('set-cookie'), /Max-Age=0/)
assert.equal ((await fetch (`${httpOrigin}/ja/users/settings`, {
headers: { Cookie: renewedCookie } })).status, 401)
failAPI = true
try {
assert.equal ((await fetch (`${httpOrigin}/ja/posts`)).status, 503)
} finally {
failAPI = false
}
})
test ('parallel user and Locale requests never share authentication or query state', async () => {
const cookies = await Promise.all ([authenticatedCookie (),
authenticatedCookie ('second-recovery-secret')])
const inputs = cookies.flatMap ((cookie, user) => ['ja', 'ain-Kana'].map (locale => ({
cookie, locale, name: user === 0 ? 'SSR account' : 'Second account',
other: user === 0 ? 'Second account' : 'SSR account' })))
const pages = await Promise.all (inputs.map (async input => {
const response = await fetch (`${httpOrigin}/${input.locale}/users/settings`, {
headers: { Cookie: input.cookie } })
assert.equal (response.status, 200)
return response.text ()
}))
for (const [index, html] of pages.entries ()) {
const input = inputs[index]
assert.match (html, new RegExp (input.name))
assert.doesNotMatch (html, new RegExp (input.other))
assert.match (html, new RegExp (`<html lang="${input.locale}"`))
assert.doesNotMatch (html, /recovery-secret|opaque-rails-session|csrf-/)
}
})
test ('renewed recovery codes invalidate old sessions and forward the new Rails cookie',
async () => {
const cookie = await authenticatedCookie ('second-recovery-secret')
const csrf = await fetch (`${httpOrigin}/api/users/csrf`, { headers: { Cookie: cookie } })
const { token } = await csrf.json ()
const renewed = await fetch (`${httpOrigin}/api/users/code/renew`, {
method: 'POST', headers: { Cookie: cookie, Origin: origin, 'X-CSRF-Token': token } })
assert.equal (renewed.status, 200)
const newCookie = renewed.headers.get ('set-cookie').split (';')[0]
assert.notEqual (newCookie, cookie)
assert.equal ((await fetch (`${httpOrigin}/ja/users/settings`, {
headers: { Cookie: cookie } })).status, 401)
assert.equal ((await fetch (`${httpOrigin}/ain-Kana/users/settings`, {
headers: { Cookie: newCookie } })).status, 200)
})
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 = [], probeActive = false, delayedChunks = 0, documents = 0
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) {
const delayed = probeActive
&& /\/(MaterialBasePage|MaterialListPage|TagListPage)-[^/]+\.js$/.test (url.pathname)
if (delayed) ++delayedChunks
setTimeout (() => {
void send ('Fetch.continueRequest', { requestId }, message.sessionId)
}, delayed ? 600 : 0)
return
}
const data = 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 (' '))
} else if (message.method === 'Network.requestWillBeSent'
&& message.params.type === 'Document') {
++documents
}
})
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 ('Network.setCacheDisabled', { cacheDisabled: true }, sessionId)
await send ('Fetch.enable', { patterns: [{ urlPattern: '*' }] }, sessionId)
const cookie = await authenticatedCookie ()
await send ('Network.setExtraHTTPHeaders', { headers: {
Cookie: cookie } }, sessionId)
await send ('Page.addScriptToEvaluateOnNewDocument', { source: `
localStorage.setItem('user_code','test-recovery-secret');
localStorage.setItem('btrc_hub.client_settings',JSON.stringify({
lists:{postList:{limit:50},tagList:{limit:50},postSearch:{limit:50}},
behavior:{animation:new URLSearchParams(location.search).get('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});
if(new URLSearchParams(location.search).has('layoutProbe')){
window.__layoutProbe={samples:[],mainLost:false,earlyAnimation:false};
let originalMain=null;
const sample=()=>{
const main=document.querySelector('main');
const layout=document.querySelector('.top-nav-layout');
const controls=document.querySelector('.top-nav-mobile-controls');
if(main&&layout&&controls){
originalMain??=main;
if(!originalMain.isConnected)window.__layoutProbe.mainLost=true;
const ready=layout.dataset.layoutReady==='true';
const state={mobile:getComputedStyle(controls).display!=='none',
measured:layout.hasAttribute('data-desktop'),ready};
const previous=window.__layoutProbe.samples.at(-1);
if(JSON.stringify(previous)!==JSON.stringify(state))
window.__layoutProbe.samples.push(state);
if(!ready&&layout.getAnimations({subtree:true})
.some(animation=>animation.playState==='running'))
window.__layoutProbe.earlyAnimation=true;
}else if(originalMain&&!originalMain.isConnected){
window.__layoutProbe.mainLost=true;
}
requestAnimationFrame(sample);
};
requestAnimationFrame(sample);
}` }, 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)
})
}
}
probeActive = true
for (const width of [375, 1240]) {
await send ('Emulation.setDeviceMetricsOverride', {
width, height: 900, deviceScaleFactor: 1, mobile: width < 768 }, sessionId)
for (const animation of ['off', 'reduced', 'normal']) {
for (const locale of ['ja', 'ain-Kana']) {
for (const page of ['materials', 'tags']) {
await t.test (`stable first paint ${width} ${animation} ${locale} ${page}`,
async () => {
errors = []
documents = 0
delayedChunks = 0
await send ('Page.navigate', { url:
`${httpOrigin}/${locale}/${page}?layoutProbe=1&animation=${animation}` }, sessionId)
let ready = false
for (let attempt = 0; attempt < 100; ++attempt) {
await new Promise (resolve => setTimeout (resolve, 75))
try {
ready = await evaluate (`document.querySelector('.top-nav-layout')
?.dataset.layoutReady==='true'
&&Object.keys(document.getElementById('root')??{})
.some(key=>key.startsWith('__reactContainer'))`)
} catch {}
if (ready) break
}
assert.ok (ready, 'hydration and the first layout measurement must finish')
await new Promise (resolve => setTimeout (resolve, 150))
const probe = await evaluate ('window.__layoutProbe')
assert.ok (delayedChunks >= (page === 'materials' ? 2 : 1))
assert.equal (documents, 1)
assert.deepEqual (errors, [])
assert.equal (probe.mainLost, false, 'SSR page content must stay attached')
assert.equal (probe.earlyAnimation, false, 'first layout must not animate')
const initial = probe.samples.find (sample => !sample.measured)
assert.ok (initial, 'observe the CSS layout before hydration')
assert.equal (initial.mobile, width < 768)
const final = probe.samples.at (-1)
if (!final.mobile)
assert.ok (probe.samples.every (sample => !sample.mobile),
'a desktop page must not initially show the mobile controls')
assert.ok (await evaluate (`document.documentElement.scrollWidth<=innerWidth`),
'the measured layout must fit the viewport')
})
}
}
}
}
} finally {
ws?.close ()
chrome.kill ()
}
})
+290 -125
ファイルの表示
@@ -1,18 +1,31 @@
import { useQuery, useQueryClient } from '@tanstack/react-query'
import { AnimatePresence, LayoutGroup, MotionConfig, motion } from 'framer-motion'
import { Fragment, useEffect, useMemo, useState } from 'react'
import { createElement, Suspense, lazy, useEffect,
useLayoutEffect, useMemo, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { createBrowserRouter,
matchRoutes,
Navigate,
Route,
RouterProvider,
Routes,
useLocation } from 'react-router-dom'
useLocation,
useParams,
useRoutes } from 'react-router-dom'
import DevModeWatermark from '@/components/DevModeWatermark'
import RouteBlockerOverlay from '@/components/RouteBlockerOverlay'
import TopNav from '@/components/TopNav'
import DialogueProvider from '@/components/dialogues/DialogueProvider'
import PageHead from '@/components/common/PageHead'
import { Toaster } from '@/components/ui/toaster'
import { apiPost, isApiError } from '@/lib/api'
import { isApiError } from '@/lib/api'
import { initialiseUserSession } from '@/lib/users'
import { prepareHelpPage } from '@/lib/help'
import { fetchLocales, isLocaleCode, legacyLocalePath,
switchLocalePath, unlocalisedPath } from '@/lib/locales'
import { localesKeys, sessionKeys } from '@/lib/queryKeys'
import { switchPostFilterLocale } from '@/lib/posts'
import { switchWikiLocale } from '@/lib/wiki'
import { useLocale } from '@/lib/useLocale'
import { applyClientAnimationMode,
applyClientAppearance,
fetchUserThemeSlots,
@@ -25,98 +38,196 @@ import { applyClientAnimationMode,
import { useClientBehaviourSettings } from '@/lib/useClientBehaviourSettings'
import { UnsavedChangesGuardProvider } from '@/lib/useUnsavedChangesGuard'
import { KeyboardShortcutsProvider } from '@/lib/useKeyboardShortcuts'
import DeerjikistDetailPage from '@/pages/deerjikists/DeerjikistDetailPage'
import MaterialBasePage from '@/pages/materials/MaterialBasePage'
import MaterialDetailPage from '@/pages/materials/MaterialDetailPage'
import MaterialHistoryPage from '@/pages/materials/MaterialHistoryPage'
import MaterialListPage from '@/pages/materials/MaterialListPage'
import MaterialNewPage from '@/pages/materials/MaterialNewPage'
import MaterialSyncSuppressionsPage from '@/pages/materials/MaterialSyncSuppressionsPage'
import MorePage from '@/pages/MorePage'
import GekanatorPage from '@/pages/GekanatorPage'
import NicoTagListPage from '@/pages/tags/NicoTagListPage'
import NotFound from '@/pages/NotFound'
import TOSPage from '@/pages/TOSPage.mdx'
import HelpDetailPage from '@/pages/help/HelpDetailPage'
import HelpChangesPage from '@/pages/help/HelpChangesPage'
import HelpDiffPage from '@/pages/help/HelpDiffPage'
import HelpListPage from '@/pages/help/HelpListPage'
import PostDetailPage from '@/pages/posts/PostDetailPage'
import PostHistoryPage from '@/pages/posts/PostHistoryPage'
import PostNewPage from '@/pages/posts/PostNewPage'
import PostListPage from '@/pages/posts/PostListPage'
import PostSearchPage from '@/pages/posts/PostSearchPage'
import ServiceUnavailable from '@/pages/ServiceUnavailable'
import SettingPage from '@/pages/users/SettingPage'
import TagDetailPage from '@/pages/tags/TagDetailPage'
import TagHistoryPage from '@/pages/tags/TagHistoryPage'
import TagListPage from '@/pages/tags/TagListPage'
import TheatreDetailPage from '@/pages/theatres/TheatreDetailPage'
import WikiDetailPage from '@/pages/wiki/WikiDetailPage'
import WikiDiffPage from '@/pages/wiki/WikiDiffPage'
import WikiEditPage from '@/pages/wiki/WikiEditPage'
import WikiHistoryPage from '@/pages/wiki/WikiHistoryPage'
import WikiNewPage from '@/pages/wiki/WikiNewPage'
import WikiSearchPage from '@/pages/wiki/WikiSearchPage'
import type { Dispatch, FC, SetStateAction } from 'react'
import type { ComponentType, Dispatch, FC, ReactNode, SetStateAction } from 'react'
import type { RouteMatch } from 'react-router-dom'
import type { ClientAnimationMode } from '@/lib/settings'
import type { AvailableLocale, LocaleCode } from '@/lib/locales'
import type { PageRoute } from '@/lib/routeLocales'
import type { User } from '@/types'
const lazyPage = <Props extends object,> (
load: () => Promise<{ default: ComponentType<Props> }>,
) => {
let loaded: ComponentType<Props> | null = null
let pending: ReturnType<typeof load> | null = null
const preload = () => pending ??= load ().then (module => {
loaded = module.default
return module
}).catch (error => {
pending = null
throw error
})
const Deferred = lazy (preload)
// A preloaded page must render synchronously on the first hydration pass.
const Page = (props: Props) => createElement (loaded ?? Deferred, props)
return Object.assign (Page, { preload })
}
const RouteTransitionWrapper = ({ animationMode, user, setUser }: {
animationMode: ClientAnimationMode
user: User | null
setUser: Dispatch<SetStateAction<User | null>> }) => {
const GekanatorPage = lazyPage (() => import ('@/pages/GekanatorPage'))
const DeerjikistDetailPage = lazyPage (() => import ('@/pages/deerjikists/DeerjikistDetailPage'))
const MaterialBasePage = lazyPage (() => import ('@/pages/materials/MaterialBasePage'))
const MaterialDetailPage = lazyPage (() => import ('@/pages/materials/MaterialDetailPage'))
const MaterialHistoryPage = lazyPage (() => import ('@/pages/materials/MaterialHistoryPage'))
const MaterialListPage = lazyPage (() => import ('@/pages/materials/MaterialListPage'))
const MaterialNewPage = lazyPage (() => import ('@/pages/materials/MaterialNewPage'))
const MaterialSyncSuppressionsPage = lazyPage (
() => import ('@/pages/materials/MaterialSyncSuppressionsPage'))
const MorePage = lazyPage (() => import ('@/pages/MorePage'))
const GekanatorUnavailablePage = lazyPage (() => import ('@/pages/GekanatorUnavailablePage'))
const NicoTagListPage = lazyPage (() => import ('@/pages/tags/NicoTagListPage'))
const TOSPage = lazyPage (() => import ('@/pages/TOSPage'))
const HelpChangesPage = lazyPage (() => import ('@/pages/help/HelpChangesPage'))
const HelpDiffPage = lazyPage (() => import ('@/pages/help/HelpDiffPage'))
const HelpListPage = lazyPage (() => import ('@/pages/help/HelpListPage'))
const PostDetailPage = lazyPage (() => import ('@/pages/posts/PostDetailPage'))
const PostHistoryPage = lazyPage (() => import ('@/pages/posts/PostHistoryPage'))
const PostNewPage = lazyPage (() => import ('@/pages/posts/PostNewPage'))
const PostListPage = lazyPage (() => import ('@/pages/posts/PostListPage'))
const PostSearchPage = lazyPage (() => import ('@/pages/posts/PostSearchPage'))
const SettingPage = lazyPage (() => import ('@/pages/users/SettingPage'))
const TagDetailPage = lazyPage (() => import ('@/pages/tags/TagDetailPage'))
const TagHistoryPage = lazyPage (() => import ('@/pages/tags/TagHistoryPage'))
const TagListPage = lazyPage (() => import ('@/pages/tags/TagListPage'))
const TheatreDetailPage = lazyPage (() => import ('@/pages/theatres/TheatreDetailPage'))
const WikiDetailPage = lazyPage (() => import ('@/pages/wiki/WikiDetailPage'))
const WikiDiffPage = lazyPage (() => import ('@/pages/wiki/WikiDiffPage'))
const WikiEditPage = lazyPage (() => import ('@/pages/wiki/WikiEditPage'))
const WikiHistoryPage = lazyPage (() => import ('@/pages/wiki/WikiHistoryPage'))
const WikiNewPage = lazyPage (() => import ('@/pages/wiki/WikiNewPage'))
const WikiSearchPage = lazyPage (() => import ('@/pages/wiki/WikiSearchPage'))
const GekanatorRoute = ({ user }: { user: User | null }) => {
const locale = useLocale ()
const location = useLocation ()
const pageLocale = resolvePageRoute (location.pathname)?.route.handle?.locale
const routes = (
<Routes location={location}>
<Route path="/" element={<Navigate to="/posts" replace/>}/>
<Route path="/posts" element={<PostListPage/>}/>
<Route path="/posts/new" element={<PostNewPage user={user}/>}/>
<Route path="/posts/search" element={<PostSearchPage/>}/>
<Route path="/posts/:id" element={<PostDetailRoute user={user}/>}/>
<Route path="/posts/changes" element={<PostHistoryPage/>}/>
<Route path="/tags" element={<TagListPage/>}/>
<Route path="/tags/:id" element={<TagDetailPage/>}/>
<Route path="/tags/:id/deerjikists" element={<DeerjikistDetailPage/>}/>
<Route path="/tags/nico" element={<NicoTagListPage user={user}/>}/>
<Route path="/nico/tags" element={<NicoTagListPage user={user}/>}/>
<Route path="/tags/changes" element={<TagHistoryPage/>}/>
<Route path="/theatres/:id" element={<TheatreDetailPage user={user}/>}/>
<Route path="/materials" element={<MaterialBasePage/>}>
<Route index element={<MaterialListPage/>}/>
<Route path="changes" element={<MaterialHistoryPage/>}/>
<Route path="new" element={<MaterialNewPage/>}/>
<Route path="suppressions" element={<MaterialSyncSuppressionsPage/>}/>
<Route path=":id" element ={<MaterialDetailPage/>}/>
</Route>
<Route path="/wiki" element={<WikiSearchPage/>}/>
<Route path="/wiki/:title" element={<WikiDetailPage/>}/>
<Route path="/wiki/new" element={<WikiNewPage user={user}/>}/>
<Route path="/wiki/:id/edit" element={<WikiEditPage user={user}/>}/>
<Route path="/wiki/:id/diff" element={<WikiDiffPage/>}/>
<Route path="/wiki/changes" element={<WikiHistoryPage/>}/>
<Route path="/help" element={<HelpListPage/>}/>
<Route path="/help/changes" element={<HelpChangesPage/>}/>
<Route path="/help/:identifier/diff" element={<HelpDiffPage/>}/>
<Route path="/help/:identifier" element={<HelpDetailPage/>}/>
<Route path="/users/settings" element={<SettingPage user={user} setUser={setUser}/>}/>
<Route path="/settings" element={<Navigate to="/users/settings" replace/>}/>
<Route path="/tos" element={<TOSPage/>}/>
<Route path="/gekanator" element={<GekanatorPage user={user}/>}/>
<Route path="/more" element={<MorePage/>}/>
<Route path="*" element={<NotFound/>}/>
</Routes>)
if (animationMode === 'off')
return routes
if (pageLocale != null && pageLocale !== locale)
return <GekanatorUnavailablePage/>
return (
<AnimatePresence mode="wait">
{routes}
<Suspense fallback={null}>
<GekanatorPage user={user}/>
</Suspense>)
}
export const pageRoutes = (
user: User | null,
setUser: Dispatch<SetStateAction<User | null>>,
): PageRoute[] => [
{ path: '', element: <LocaleRedirect to="/posts"/>,
handle: { redirectTo: '/posts' } },
{ path: 'posts', element: <PostListPage/>,
handle: { preload: PostListPage.preload, switchLocale: switchPostFilterLocale,
sitemap: ['static'] } },
{ path: 'posts/new', element: <PostNewPage user={user}/>,
handle: { preload: PostNewPage.preload, robots: 'noindex' } },
{ path: 'posts/search', element: <PostSearchPage/>,
handle: { preload: PostSearchPage.preload, switchLocale: switchPostFilterLocale } },
{ path: 'posts/:id', element: <PostDetailRoute user={user}/>,
handle: { preload: PostDetailPage.preload, sitemap: ['posts'] } },
{ path: 'posts/changes', element: <PostHistoryPage/>,
handle: { preload: PostHistoryPage.preload } },
{ path: 'tags', element: <TagListPage/>,
handle: { preload: TagListPage.preload, sitemap: ['static'] } },
{ path: 'tags/:id', element: <TagDetailPage/>,
handle: { preload: TagDetailPage.preload, sitemap: ['tags'] } },
{ path: 'tags/:id/deerjikists', element: <DeerjikistDetailPage/>,
handle: { preload: DeerjikistDetailPage.preload, sitemap: ['deerjikists'] } },
{ path: 'tags/nico', element: <NicoTagListPage user={user}/>,
handle: { preload: NicoTagListPage.preload, sitemap: ['static'] } },
{ path: 'nico/tags', element: <NicoTagListPage user={user}/>,
handle: { preload: NicoTagListPage.preload } },
{ path: 'tags/changes', element: <TagHistoryPage/>,
handle: { preload: TagHistoryPage.preload } },
{ path: 'theatres/:id', element: <TheatreDetailPage user={user}/>,
handle: { preload: TheatreDetailPage.preload, sitemap: ['theatres'] } },
{ path: 'materials', element: <MaterialBasePage/>,
handle: { preload: MaterialBasePage.preload }, children: [
{ index: true, element: <MaterialListPage/>,
handle: { preload: MaterialListPage.preload, sitemap: ['static'] } },
{ path: 'changes', element: <MaterialHistoryPage/>,
handle: { preload: MaterialHistoryPage.preload } },
{ path: 'new', element: <MaterialNewPage/>,
handle: { preload: MaterialNewPage.preload, robots: 'noindex' } },
{ path: 'suppressions', element: <MaterialSyncSuppressionsPage/>,
handle: { preload: MaterialSyncSuppressionsPage.preload,
robots: 'noindex', authentication: 'user' } },
{ path: ':id', element: <MaterialDetailPage/>,
handle: { preload: MaterialDetailPage.preload, sitemap: ['materials'] } }] },
{ path: 'wiki', element: <WikiSearchPage/>,
handle: { preload: WikiSearchPage.preload, sitemap: ['static'] } },
{ path: 'wiki/:title', element: <WikiDetailPage/>,
handle: { preload: WikiDetailPage.preload, switchLocale: switchWikiLocale,
sitemap: ['descriptions', 'tag_groups'] } },
{ path: 'wiki/new', element: <WikiNewPage user={user}/>,
handle: { preload: WikiNewPage.preload, robots: 'noindex' } },
{ path: 'wiki/:id/edit', element: <WikiEditPage user={user}/>,
handle: { preload: WikiEditPage.preload, robots: 'noindex',
switchLocale: request => switchWikiLocale (request, 'edit') } },
{ path: 'wiki/:id/diff', element: <WikiDiffPage/>,
handle: { preload: WikiDiffPage.preload,
switchLocale: request => switchWikiLocale (request, 'diff') } },
{ path: 'wiki/changes', element: <WikiHistoryPage/>,
handle: { preload: WikiHistoryPage.preload,
switchLocale: request => switchWikiLocale (request, 'history') } },
{ path: 'help', element: <HelpListPage/>,
handle: { preload: HelpListPage.preload, sitemap: ['static'] } },
{ path: 'help/changes', element: <HelpChangesPage/>,
handle: { preload: HelpChangesPage.preload } },
{ path: 'help/:identifier/diff', element: <HelpDiffPage/>,
handle: { preload: HelpDiffPage.preload } },
{ id: 'help.detail', path: 'help/:identifier', element: <HelpDetailPage/>,
handle: { prepare: prepareHelpPage, sitemap: ['help'] } },
{ path: 'users/settings', element: <SettingPage user={user} setUser={setUser}/>,
handle: { preload: SettingPage.preload, robots: 'noindex', authentication: 'user' } },
{ path: 'settings', element: <LocaleRedirect to="/users/settings"/>,
handle: { redirectTo: '/users/settings' } },
{ path: 'tos', element: <TOSPage/>,
handle: { preload: TOSPage.preload, sitemap: ['static'] } },
{ id: 'gekanator', path: 'gekanator', element: <GekanatorRoute user={user}/>,
handle: { locale: 'ja', sitemap: ['static'], preload: locale => locale === 'ja'
? GekanatorPage.preload () : GekanatorUnavailablePage.preload () } },
{ path: 'more', element: <MorePage/>,
handle: { preload: MorePage.preload, sitemap: ['static'] } },
{ path: '*', element: <NotFound/>, handle: { notFound: true } }]
const matchPageRoutes = (pathname: string) =>
matchRoutes<PageRoute> (pageRoutes (null, () => { }), unlocalisedPath (pathname))
export const preloadPageRoute = async (
pathname: string,
locale: LocaleCode,
): Promise<void> => {
await Promise.all ((matchPageRoutes (pathname) ?? [])
.map (match => match.route.handle?.preload?.(locale)))
}
export const resolvePageRoute = (
pathname: string,
): RouteMatch<string, PageRoute> | null => {
const match = matchPageRoutes (pathname)?.at (-1)
return (match == null || match.route.handle?.notFound) ? null : match
}
const RouteTransitionWrapper = ({ user, setUser }: {
user: User | null
setUser: Dispatch<SetStateAction<User | null>> }) => {
const routes = useRoutes (pageRoutes (user, setUser))
return (
<AnimatePresence mode="wait" initial={false}>
<Suspense fallback={null}>{routes}</Suspense>
</AnimatePresence>)
}
@@ -128,12 +239,23 @@ const PostDetailRoute = ({ user }: { user: User | null }) => {
}
const RoutedApp: FC = () => {
const [user, setUser] = useState<User | null> (null)
const RoutedApp = ({ locales }: { locales: AvailableLocale[] }) => {
const client = useQueryClient ()
const location = useLocation ()
const locale = useLocale ()
const route = resolvePageRoute (location.pathname)
const robots = route?.route.handle?.robots
const switchLocale = async (targetLocale: LocaleCode): Promise<string> => {
const resolve = route?.route.handle?.switchLocale
return (resolve != null && route != null)
? resolve ({ location, params: route.params, locale, targetLocale })
: switchLocalePath (location, targetLocale)
}
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'
const LayoutWrapper = animationMode === 'off' ? Fragment : LayoutGroup
const appLayoutTransition = useMemo (
() => (
@@ -164,36 +286,21 @@ const RoutedApp: FC = () => {
}, [animationMode])
useEffect (() => {
const createUser = async () => {
const data = await apiPost<{ code: string; user: User }> ('/users')
if (data.code)
{
localStorage.setItem ('user_code', data.code)
setUser (data.user)
}
}
const code = localStorage.getItem ('user_code')
if (code)
let cancelled = false
void (async () => {
try
{
void (async () => {
try
{
const data = await apiPost<{ valid: boolean; user: User }> ('/users/verify', { code })
if (data.valid)
setUser (data.user)
else
await createUser ()
}
catch (err)
{
if (isApiError (err))
setStatus (err.response?.status ?? 200)
}
}) ()
const user = await initialiseUserSession ()
if (!cancelled)
setUser (user)
}
else
createUser ()
catch (error)
{
if (!cancelled && isApiError (error))
setStatus (error.response?.status ?? 503)
}
}) ()
return () => { cancelled = true }
}, [])
useEffect (() => {
@@ -228,6 +335,9 @@ const RoutedApp: FC = () => {
return (
<DialogueProvider>
<PageHead>
{robots != null && <meta name="robots" content={robots}/>}
</PageHead>
<UnsavedChangesGuardProvider>
<KeyboardShortcutsProvider>
<MotionConfig
@@ -238,18 +348,17 @@ const RoutedApp: FC = () => {
? 'user'
: 'always'
}>
<LayoutWrapper>
<LayoutGroup>
<motion.div
layout={animationMode === 'off' ? false : 'position'}
transition={{ layout: appLayoutTransition }}
className="relative flex h-dvh w-full flex-col overflow-y-hidden">
<TopNav user={user}/>
<TopNav user={user} locales={locales} resolveLocaleSwitch={switchLocale}/>
<RouteTransitionWrapper
animationMode={animationMode}
user={user}
setUser={setUser}/>
</motion.div>
</LayoutWrapper>
</LayoutGroup>
</MotionConfig>
<Toaster/>
@@ -259,17 +368,73 @@ const RoutedApp: FC = () => {
}
const router = createBrowserRouter ([{
path: '*',
element: <RoutedApp/> }])
const LocaleRoute: FC = () => {
const { locale } = useParams ()
const location = useLocation ()
const { i18n } = useTranslation ()
const { data, isError } = useQuery ({
queryKey: localesKeys.index,
queryFn: fetchLocales,
staleTime: 60_000,
refetchInterval: 60_000,
refetchOnMount: true,
refetchOnWindowFocus: true,
refetchOnReconnect: true })
const activeLocale =
(isLocaleCode (locale)
&& data?.locales.some (entry => entry.code === locale))
? locale
: null
useLayoutEffect (() => {
if (activeLocale != null)
document.documentElement.lang = activeLocale
if (activeLocale != null && i18n.language !== activeLocale)
void i18n.changeLanguage (activeLocale)
}, [activeLocale, i18n])
const redirect = legacyLocalePath (location)
if (!(isLocaleCode (locale)) && redirect == null)
return <NotFound/>
if (isError && data == null)
return <ServiceUnavailable/>
if (data == null)
return null
if (isLocaleCode (locale))
{
return data.locales.some (entry => entry.code === locale)
? <RoutedApp locales={data.locales}/>
: <NotFound/>
}
return redirect == null ? <NotFound/> : <Navigate to={redirect} replace/>
}
const App: FC = () => (
const LocaleRedirect = ({ to }: { to: string }) => {
const locale = useLocale ()
const location = useLocation ()
return <Navigate to={switchLocalePath ({ ...location, pathname: to }, locale)} replace/>
}
export const appRoutes = [
{ path: '/:locale/*', element: <LocaleRoute/> },
{ path: '*', element: <LocaleRoute/> }]
export const AppFrame = ({ children }: { children: ReactNode }) => (
<>
<RouteBlockerOverlay/>
{import.meta.env.DEV && <DevModeWatermark/>}
<RouterProvider router={router}/>
{children}
</>)
const App: FC = () => {
const [router] = useState (() => createBrowserRouter (appRoutes))
return <AppFrame><RouterProvider router={router}/></AppFrame>
}
export default App
+7 -1
ファイルの表示
@@ -1,4 +1,7 @@
import { useTranslation } from 'react-i18next'
import nikumaru from '@/assets/fonts/nikumaru.otf'
import { translateUIMessage } from '@/lib/i18n'
import type { FC } from 'react'
@@ -8,6 +11,8 @@ const COLUMN_COUNT = 12
const DevModeWatermark: FC = () => {
const { i18n } = useTranslation ('shared')
const text = translateUIMessage ('labels.development', 'shared', i18n.language)
return (
<div
aria-hidden="true"
@@ -29,13 +34,14 @@ const DevModeWatermark: FC = () => {
{Array.from ({ length: COLUMN_COUNT }, (_, columnIndex) => (
<span
key={columnIndex}
lang={text.language}
className={
'whitespace-nowrap text-3xl font-bold '
+ 'tracking-[0.3em] text-neutral-950/5 '
+ 'dark:text-white/10'
}
style={{ fontFamily: 'Nikumaru' }}>
開発モード
{text.text}
</span>))}
</div>))}
</div>
+5 -4
ファイルの表示
@@ -1,5 +1,6 @@
import { render, screen } from '@testing-library/react'
import { HelmetProvider } from 'react-helmet-async'
import { MemoryRouter } from 'react-router-dom'
import { describe, expect, it } from 'vitest'
import ErrorScreen from '@/components/ErrorScreen'
@@ -12,9 +13,9 @@ describe ('ErrorScreen', () => {
[503, '鯖死んでるよ(泣)'],
]) ('renders status %s', (status, message) => {
render (
<HelmetProvider>
<MemoryRouter><HelmetProvider>
<ErrorScreen status={status}/>
</HelmetProvider>,
</HelmetProvider></MemoryRouter>,
)
expect (screen.getByText (String (status))).toBeInTheDocument ()
@@ -24,9 +25,9 @@ describe ('ErrorScreen', () => {
it ('throws for unsupported statuses', () => {
expect (() => render (
<HelmetProvider>
<MemoryRouter><HelmetProvider>
<ErrorScreen status={418}/>
</HelmetProvider>,
</HelmetProvider></MemoryRouter>,
)).toThrow ()
})
})
+27 -26
ファイルの表示
@@ -1,9 +1,9 @@
import { Helmet } from 'react-helmet-async'
import nikumaru from '@/assets/fonts/nikumaru.otf'
import errorImg from '@/assets/images/not-found.gif'
import MainArea from '@/components/layout/MainArea'
import { SITE_TITLE } from '@/config'
import PageHead from '@/components/common/PageHead'
import { useUITranslation } from '@/lib/useUITranslation'
import { currentRenderContext } from '@/lib/renderContext'
import type { FC } from 'react'
@@ -11,17 +11,19 @@ type Props = { status: number }
const ErrorScreen: FC<Props> = ({ status }) => {
const [message, rightMsg, leftMsg]: [string, string, string] = (() => {
const context = currentRenderContext ()
if (context != null)
context.status = status
const { message: translate, label } = useUITranslation ('errors')
const [message]: [string, string, string] = (() => {
switch (status)
{
case 403:
return ['権限ないよ(笑)', '帰れ!', '帰れ!']
case 404:
return ['ページないよ(笑)', '帰れ!', '帰れ!']
case 500:
return ['鯖でエラー出たって(嘲笑)', '管理人に', '聯絡を!']
case 503:
return ['鯖死んでるよ(泣)', '管理人に', '聯絡を!']
return [translate (`${ status }.message`).text,
translate (`${ status }.right`).text, translate (`${ status }.left`).text]
default:
throw new Error
}
@@ -29,35 +31,34 @@ const ErrorScreen: FC<Props> = ({ status }) => {
const title = message.replaceAll ('(', ' (').replaceAll (')', ')')
const style = document.createElement ('style')
style.innerHTML = `
@font-face
{
font-family: 'Nikumaru';
src: url(${ nikumaru }) format('opentype');
}`
document.head.appendChild (style)
return (
<MainArea>
<Helmet>
<PageHead title={{ text: title,
language: translate (`${status}.message`).language }}>
<meta name="robots" content="noindex"/>
<title>{title} | {SITE_TITLE}</title>
</Helmet>
<style>{`@font-face { font-family: 'Nikumaru';
src: url(${nikumaru}) format('opentype'); }`}</style>
</PageHead>
<div className="text-6xl font-bold text-transparent
bg-[linear-gradient(90deg,#ff0000,#ff8800,#ffff00,#00ff00,#00ffff,#0000ff,#ff00ff,#ff0000)]
bg-clip-text bg-[length:200%_100%] animate-rainbow-scroll drop-shadow-[0_0_6px_black]
space-y-2 sm:space-y-4 md:space-y-6 text-center flex flex-col justify-center items-center"
style={{ fontFamily: 'Nikumaru' }}>
<p>{status}</p>
<div className="flex flex-row space-x-1 sm:space-x-2 md:space-x-4">
<p style={{ writingMode: 'vertical-rl' }}>{leftMsg}</p>
<img className="max-w-[70vw]" src={errorImg} alt="逃げたギター"/>
<p style={{ writingMode: 'vertical-rl' }}>{rightMsg}</p>
<div className="flex w-full min-w-0 items-center justify-center
space-x-1 sm:space-x-2 md:space-x-4">
<p className="shrink-0" style={{ writingMode: 'vertical-rl' }}>
{label (`${ status }.left`)}
</p>
<img className="aspect-video h-auto min-w-0 max-w-[70vw] object-contain"
width={640} height={360} src={errorImg} alt={translate ('imageAlt').text}/>
<p className="shrink-0" style={{ writingMode: 'vertical-rl' }}>
{label (`${ status }.right`)}
</p>
</div>
<p className="mr-[-.5em]">{message}</p>
<p className="mr-[-.5em]">{label (`${ status }.message`)}</p>
</div>
</MainArea>)
}
export default ErrorScreen
export default ErrorScreen
+47
ファイルの表示
@@ -0,0 +1,47 @@
import { render } from '@testing-library/react'
import { describe, expect, it } from 'vitest'
import LocalisedText from '@/components/LocalisedText'
describe ('LocalisedText', () => {
it.each ([
['ネコ チャペ', ['ネコ', 'チャペ']],
['ネコ、チャペ', ['ネコ、', 'チャペ']],
['著作者人格権、商標権、肖像権、パブリシティー権',
['著作者人格権、', '商標権、', '肖像権、', 'パブリシティー権']],
['ネコ、チャペ。', ['ネコ、', 'チャペ。']],
['ネコ、「チャペ」。', ['ネコ、', '「チャペ」。']],
['ネコ,、」チャペ', ['ネコ,、」', 'チャペ']]]) (
'adds word boundaries without changing %s', (text, words) => {
const { container } = render (<LocalisedText language="ain">{text}</LocalisedText>)
const wrapper = container.firstElementChild!
expect (wrapper).toHaveAttribute ('lang', 'ain-Kana')
expect (wrapper.textContent).toBe (text)
expect (Array.from (wrapper.querySelectorAll ('.ainu-ui-word'), node => node.textContent))
.toEqual (words)
expect (wrapper.querySelectorAll ('wbr')).toHaveLength (words.length - 1)
})
it ('preserves Japanese fallback and links and code inside an Ainu paragraph', () => {
const { container } = render (
<LocalisedText language="ain">
<span lang="ja">日本語、本文</span>
<a href="https://example.com">ネコ チャペ</a>
<code>ネコ チャペ</code>
<strong>ネコ チャペ</strong>
</LocalisedText>)
expect (container.querySelector ('[lang="ja"] .ainu-ui-word')).toBeNull ()
expect (container.querySelector ('a .ainu-ui-word')).toBeNull ()
expect (container.querySelector ('code .ainu-ui-word')).toBeNull ()
expect (container.querySelectorAll ('strong .ainu-ui-word')).toHaveLength (2)
})
it ('does not apply Ainu word splitting to Japanese text', () => {
const { container } = render (
<LocalisedText language="ja">ネコ、チャペ。</LocalisedText>)
expect (container.firstElementChild).toHaveAttribute ('lang', 'ja')
expect (container.querySelector ('.ainu-ui-word')).toBeNull ()
expect (container.textContent).toBe ('ネコ、チャペ。')
})
})
+89
ファイルの表示
@@ -0,0 +1,89 @@
import { Children, cloneElement, Fragment, isValidElement } from 'react'
import { cn } from '@/lib/utils'
import type { ReactNode } from 'react'
export const formatUIString = (text: string, language: string): string =>
(language === 'ain' || language === 'ain-Kana')
? text.replace (/[ \t\r\n]+/g, '\u3000')
: text
const splitAinuText = (text: string): string[] => {
const parts: string[] = []
let part = ''
let boundary = false
for (const character of text)
{
// Keep punctuation runs and closing brackets/quotes with the preceding word.
if (boundary && !/[\u3000、,,。.\p{Pe}\p{Pf}]/u.test (character))
{
parts.push (part)
part = ''
boundary = false
}
part += character
if (/[\u3000、,,。.]/u.test (character))
boundary = true
}
if (part !== '')
parts.push (part)
return parts
}
export const localiseUIChildren = (children: ReactNode, language: string): ReactNode => {
if (language !== 'ain' && language !== 'ain-Kana')
return children
return Children.map (children, child => {
if (typeof child === 'string')
{
const parts = splitAinuText (formatUIString (child, language))
return parts.map ((part, index) => {
const spaces = part.match (/\u3000+$/u)?.[0] ?? ''
const word = part.slice (0, part.length - spaces.length)
return (
<Fragment key={index}>
{word !== '' && <span className="ainu-ui-word">{word}</span>}
{spaces}
{index < parts.length - 1 && <wbr/>}
</Fragment>)
})
}
if (isValidElement<{ children?: ReactNode; lang?: string }> (child))
{
// Only traverse inline formatting owned by the renderer. Components,
// links and code retain their own children and behaviour.
const inline = child.type === Fragment
|| (typeof child.type === 'string'
&& ['span', 'em', 'strong', 'del', 's', 'b', 'i', 'u',
'small', 'mark', 'sup', 'sub'].includes (child.type))
if (!inline
|| (child.props.lang != null && !(child.props.lang.startsWith ('ain'))))
return child
return cloneElement (child, { }, localiseUIChildren (child.props.children, language))
}
return child
})
}
const LocalisedText = (
{ children, language, className }: {
children: ReactNode
language: string
className?: string },
) => (
<span lang={language === 'ain' ? 'ain-Kana' : language}
className={cn ((language === 'ain' || language === 'ain-Kana') && 'ainu-ui-text',
className)}>
{localiseUIChildren (children, language)}
</span>)
export default LocalisedText
+40 -30
ファイルの表示
@@ -1,19 +1,20 @@
import { useQuery } from '@tanstack/react-query'
import { useEffect, useRef, useState } from 'react'
import { useLocation, useNavigate } from 'react-router-dom'
import { useLocation } from 'react-router-dom'
import { useLocale, useLocaleNavigate } from '@/lib/useLocale'
import SidebarComponent from '@/components/layout/SidebarComponent'
import TagLink from '@/components/TagLink'
import { fetchMaterialTagTree, parseMaterialFilter } from '@/lib/materials'
import { materialsKeys } from '@/lib/queryKeys'
import { cn } from '@/lib/utils'
import { useUITranslation } from '@/lib/useUITranslation'
import type { CSSProperties, Dispatch, FC, ReactNode, SetStateAction } from 'react'
import type { MaterialFilter, MaterialSidebarTag, Tag } from '@/types'
const FILTERS: MaterialFilter[] = ['missing', 'present', 'any']
const FILTER_LABELS: Record<MaterialFilter, string> = { present: '有', missing: '無', any: '全' }
const px = (value: string): number => {
@@ -83,7 +84,7 @@ const tagSelectionShellClass = (selected: boolean): string =>
const updateMaterialFilterQuery = (
pathname: string,
locationSearch: string,
navigate: ReturnType<typeof useNavigate>,
navigate: ReturnType<typeof useLocaleNavigate>,
materialFilter: MaterialFilter,
) => {
const qs = new URLSearchParams (locationSearch)
@@ -95,24 +96,27 @@ const updateMaterialFilterQuery = (
const MaterialFilterButtons: FC<{ materialFilter: MaterialFilter
onChange: (materialFilter: MaterialFilter) => void }> = (
{ materialFilter, onChange },
) => (
<div className="flex flex-wrap gap-2 justify-end md:justify-start flex-center">
<label className="my-auto text-sm font-bold">素材:</label>
{FILTERS.map (value => (
<button
key={value}
type="button"
onClick={() => onChange (value)}
className={cn (
'rounded-full border px-3 py-1 text-sm',
(materialFilter === value
? ['border-sky-500 bg-sky-50 text-sky-700 dark:border-sky-400',
'dark:bg-sky-950 dark:text-sky-100']
: ['border-neutral-300 bg-white text-neutral-700 dark:border-stone-700',
'dark:bg-stone-900 dark:text-stone-200']))}>
{FILTER_LABELS[value]}
</button>))}
</div>)
) => {
const { label } = useUITranslation ('materials')
return (
<div className="flex flex-wrap gap-2 justify-end md:justify-start flex-center">
<label className="my-auto text-sm font-bold">{label ('sidebar.label')}</label>
{FILTERS.map (value => (
<button
key={value}
type="button"
onClick={() => onChange (value)}
className={cn (
'rounded-full border px-3 py-1 text-sm',
(materialFilter === value
? ['border-sky-500 bg-sky-50 text-sky-700 dark:border-sky-400',
'dark:bg-sky-950 dark:text-sky-100']
: ['border-neutral-300 bg-white text-neutral-700 dark:border-stone-700',
'dark:bg-stone-900 dark:text-stone-200']))}>
{label (`sidebar.${value}`)}
</button>))}
</div>)
}
const MaterialTreeNode: FC<{
@@ -125,10 +129,11 @@ const MaterialTreeNode: FC<{
tag: MaterialSidebarTag
}> = ({ materialFilter, nestLevel = 0, onChildren, openTags, selectedTagId,
setOpenTags, tag }) => {
const locale = useLocale ()
const open = Boolean (openTags[tag.id])
const { data } = useQuery ({
queryKey: materialsKeys.tree ({ parentId: tag.id, materialFilter }),
queryFn: () => fetchMaterialTagTree ({ parentId: tag.id, materialFilter }),
queryKey: materialsKeys.tree ({ parentId: tag.id, materialFilter }, locale),
queryFn: () => fetchMaterialTagTree ({ parentId: tag.id, materialFilter }, locale),
enabled: open && tag.hasChildren && tag.children.length === 0})
useEffect (() => {
@@ -200,6 +205,7 @@ const MobileMaterialTreeNode: FC<{ depth?: number
tag,
},
) => {
const locale = useLocale ()
const open = Boolean (openTags[tag.id])
const tagColumnRef = useRef<HTMLDivElement | null> (null)
const chipRef = useRef<HTMLDivElement | null> (null)
@@ -210,8 +216,8 @@ const MobileMaterialTreeNode: FC<{ depth?: number
const [tagLinkInlineSizePx, setTagLinkInlineSizePx] = useState<number | null> (null)
const [childAvailableInlineSizePx, setChildAvailableInlineSizePx] = useState<number | null> (null)
const { data } = useQuery ({
queryKey: materialsKeys.tree ({ parentId: tag.id, materialFilter }),
queryFn: () => fetchMaterialTagTree ({ parentId: tag.id, materialFilter }),
queryKey: materialsKeys.tree ({ parentId: tag.id, materialFilter }, locale),
queryFn: () => fetchMaterialTagTree ({ parentId: tag.id, materialFilter }, locale),
enabled: open && tag.hasChildren && tag.children.length === 0})
useEffect (() => {
@@ -382,8 +388,10 @@ const MobileMaterialTreeNode: FC<{ depth?: number
const MaterialSidebar: FC = () => {
const { label } = useUITranslation ('materials')
const locale = useLocale ()
const location = useLocation ()
const navigate = useNavigate ()
const navigate = useLocaleNavigate ()
const qs = new URLSearchParams (location.search)
const materialFilter = parseMaterialFilter (qs.get ('material_filter'), 'any')
const selectedTagId = Number (qs.get ('tag_id') ?? 0) || null
@@ -395,8 +403,8 @@ const MaterialSidebar: FC = () => {
useState<number | null> (null)
const { data: rootTags = [], isLoading, isError } = useQuery ({
queryKey: materialsKeys.tree ({ parentId: null, materialFilter }),
queryFn: () => fetchMaterialTagTree ({ parentId: null, materialFilter })})
queryKey: materialsKeys.tree ({ parentId: null, materialFilter }, locale),
queryFn: () => fetchMaterialTagTree ({ parentId: null, materialFilter }, locale)})
useEffect (() => {
setDesktopTags (rootTags)
@@ -494,10 +502,12 @@ const MaterialSidebar: FC = () => {
materialFilter={materialFilter}
onChange={handleFilterChange}/>
{isLoading && (
<p className="text-sm text-neutral-500 dark:text-stone-400">読込中……</p>)}
<p className="text-sm text-neutral-500 dark:text-stone-400">
{label ('shared:messages.loadingEllipsis')}
</p>)}
{isError && (
<p className="text-sm text-red-600 dark:text-red-300">
タグ一覧の取得に失敗しました.
{label ('sidebar.error')}
</p>)}
{(!isLoading && !isError) && (
<ul>
+3 -3
ファイルの表示
@@ -3,9 +3,9 @@ import type { FC } from 'react'
const MenuSeparator: FC = () => (
<>
<span className="hidden md:inline flex items-center px-2">|</span>
<hr className="block md:hidden w-full opacity-25
<span className="top-nav-separator-vertical items-center px-2">|</span>
<hr className="top-nav-separator-horizontal w-full opacity-25
border-t border-black dark:border-white"/>
</>)
export default MenuSeparator
export default MenuSeparator
+10 -6
ファイルの表示
@@ -1,8 +1,10 @@
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
import { fireEvent, screen, waitFor } from '@testing-library/react'
import { describe, expect, it, vi } from 'vitest'
import { textContentOf } from '@/test/text'
import PostEditForm from '@/components/PostEditForm'
import { buildPost, buildTag } from '@/test/factories'
import { renderWithProviders as render } from '@/test/render'
const postsApi = vi.hoisted (() => ({
updatePost: vi.fn (),
@@ -19,7 +21,8 @@ const toastApi = vi.hoisted (() => ({
vi.mock ('@/lib/posts', () => postsApi)
vi.mock ('@/lib/api', () => api)
vi.mock ('@/components/ui/use-toast', () => toastApi)
vi.mock ('@/lib/dialogues/useDialogue', () => ({
vi.mock ('@/lib/dialogues/useDialogue', async importOriginal => ({
...await importOriginal<typeof import ('@/lib/dialogues/useDialogue')> (),
default: () => ({
choice: vi.fn (),
}),
@@ -60,11 +63,12 @@ describe ('PostEditForm', () => {
parentPostIds: '3 4',
tags: 'general-tag',
}),
{ baseVersionNo: 4 },
{ baseVersionNo: 4 }, 'ja',
)
})
expect (onSave).toHaveBeenCalledWith (expect.objectContaining ({ versionNo: 5 }))
expect (toastApi.toast).toHaveBeenCalledWith ({ description: '更新しました.' })
expect (toastApi.toast).toHaveBeenCalledTimes (1)
expect (textContentOf (toastApi.toast.mock.calls[0][0].description)).toBe ('更新しました.')
})
it ('preserves duration while the video tag is temporarily removed', () => {
@@ -78,7 +82,7 @@ describe ('PostEditForm', () => {
render (<PostEditForm post={post} onSave={vi.fn ()}/>)
expect (screen.getByText ('動画時間').parentElement?.querySelector ('input'))
expect (screen.getByDisplayValue ('180.5'))
.toHaveValue ('180.5')
const tags = screen.getAllByRole ('textbox')[2]
@@ -88,7 +92,7 @@ describe ('PostEditForm', () => {
fireEvent.change (tags, {
target: { value: '動画 general-tag' },
})
expect (screen.getByText ('動画時間').parentElement?.querySelector ('input'))
expect (screen.getByDisplayValue ('180.5'))
.toHaveValue ('180.5')
})
+25 -14
ファイルの表示
@@ -1,5 +1,6 @@
import { useEffect, useMemo, useState } from 'react'
import { useLocale } from '@/lib/useLocale'
import PostOriginalCreatedTimeField from '@/components/PostOriginalCreatedTimeField'
import FieldError from '@/components/common/FieldError'
import PostDurationField from '@/components/posts/PostDurationField'
@@ -12,6 +13,7 @@ import useDialogue from '@/lib/dialogues/useDialogue'
import { updatePost } from '@/lib/posts'
import { msToTime } from '@/lib/utils'
import { useValidationErrors } from '@/lib/useValidationErrors'
import { useUITranslation } from '@/lib/useUITranslation'
import type { FC, FormEvent } from 'react'
@@ -51,6 +53,8 @@ type Props = { post: Post
const PostEditForm: FC<Props> = ({ post, onSave }) => {
const locale = useLocale ()
const { label } = useUITranslation ('posts')
const [disabled, setDisabled] = useState (false)
const [duration, setDuration] = useState<string> (videoMsToDurationValue (post.videoMs))
const { baseErrors, fieldErrors, clearValidationErrors, applyValidationError } =
@@ -61,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 =
@@ -70,12 +74,15 @@ const PostEditForm: FC<Props> = ({ post, onSave }) => {
const dialogue = useDialogue ()
const update = async (...args: Parameters<typeof updatePost>) => {
const update = async (
postData: Parameters<typeof updatePost>[0],
options: Parameters<typeof updatePost>[1],
) => {
clearValidationErrors ()
try
{
const data = await updatePost (...args)
const data = await updatePost (postData, options, locale)
onSave ({ ...post,
versionNo: data.versionNo,
title: data.title,
@@ -86,7 +93,7 @@ const PostEditForm: FC<Props> = ({ post, onSave }) => {
siblingPosts: data.siblingPosts,
originalCreatedFrom: data.originalCreatedFrom,
originalCreatedBefore: data.originalCreatedBefore } as Post)
toast ({ description: '更新しました.' })
toast ({ description: label ('shared:messages.updated') })
}
catch (e)
{
@@ -96,24 +103,28 @@ const PostEditForm: FC<Props> = ({ post, onSave }) => {
{
if (applyValidationError (e))
{
toast ({ description: '更新はできなかったよ……' })
toast ({ description: label ('edit.validationFailed') })
return
}
toast ({ title: '失敗……', description: '入力を確認してください.' })
toast ({ title: label ('shared:messages.failed'),
description: label ('shared:messages.checkInput') })
return
}
const action = await dialogue.choice ({
title: '競合が発生しました.',
title: label ('edit.conflictTitle'),
description: (
<div>
<p>ほかの耕作員が先に更新してゐます.</p>
<p>現在の変更をどう扱ひますか?</p>
<p>{label ('edit.conflictDescription')}</p>
<p>{label ('edit.conflictQuestion')}</p>
</div>),
choices: [...(response?.data?.mergeable ? [{ value: 'merge', label: '差分をマージ' }] : []),
{ value: 'overwrite', label: '強制上書き', variant: 'danger' }] })
cancelText: label ('shared:actions.cancel'),
closeLabel: label ('settings:actions.close'),
choices: [...(response?.data?.mergeable
? [{ value: 'merge', label: label ('edit.merge') }] : []),
{ value: 'overwrite', label: label ('edit.overwrite'), variant: 'danger' }] })
if (action === 'merge')
{
@@ -163,13 +174,13 @@ const PostEditForm: FC<Props> = ({ post, onSave }) => {
<FieldError messages={baseErrors}/>
<PostTextField
label="タイトル"
label={label ('shared:labels.title')}
value={title ?? ''}
disabled={disabled}
onChange={setTitle}/>
<PostTextField
label="親投稿"
label={label ('shared:labels.parentPosts')}
value={parentPostIds}
disabled={disabled}
errors={fieldErrors.parentPostIds}
@@ -202,7 +213,7 @@ const PostEditForm: FC<Props> = ({ post, onSave }) => {
{/* 送信 */}
<Button type="submit" disabled={disabled}>
更新
{label ('shared:actions.update')}
</Button>
</form>)
}
+5 -3
ファイルの表示
@@ -1,8 +1,9 @@
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
import { fireEvent, screen, waitFor } from '@testing-library/react'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import PostEmbed from '@/components/PostEmbed'
import { buildPost } from '@/test/factories'
import { renderWithProviders as render } from '@/test/render'
const dialogue = vi.hoisted (() => ({
confirm: vi.fn (),
@@ -12,8 +13,9 @@ const nicoViewer = vi.hoisted (() => ({
props: vi.fn (),
}))
vi.mock ('@/components/dialogues/DialogueProvider', () => ({
useDialogue: () => dialogue,
vi.mock ('@/lib/dialogues/useDialogue', async importOriginal => ({
...await importOriginal<typeof import ('@/lib/dialogues/useDialogue')> (),
default: () => dialogue,
}))
vi.mock ('@/components/NicoViewer', () => ({
+10 -8
ファイルの表示
@@ -3,9 +3,10 @@ import YoutubeEmbed from 'react-youtube'
import NicoViewer from '@/components/NicoViewer'
import TwitterEmbed from '@/components/TwitterEmbed'
import { useDialogue } from '@/components/dialogues/DialogueProvider'
import useDialogue from '@/lib/dialogues/useDialogue'
import { Button } from '@/components/ui/button'
import { useClientBehaviourSettings } from '@/lib/useClientBehaviourSettings'
import { useUITranslation } from '@/lib/useUITranslation'
import type { FC, RefObject } from 'react'
@@ -36,6 +37,7 @@ const PostEmbed: FC<Props> = (
onError },
) => {
const dialogue = useDialogue ()
const { label } = useUITranslation ('posts')
const behaviourSettings = useClientBehaviourSettings ()
const embedAutoLoadMode = behaviourSettings.embedAutoLoad ?? 'auto'
const [manualLoadRequested, setManualLoadRequested] = useState (false)
@@ -135,7 +137,7 @@ const PostEmbed: FC<Props> = (
const externalLink = (
<a href={post.url} target="_blank" rel="noreferrer">
外部ページを開く
{label ('embed.openExternal')}
</a>)
const manualLoadControl = (
@@ -144,7 +146,7 @@ const PostEmbed: FC<Props> = (
type="button"
variant="outline"
onClick={() => setManualLoadRequested (true)}>
埋め込みを読込
{label ('embed.load')}
</Button>
{externalLink}
</div>)
@@ -239,15 +241,15 @@ const PostEmbed: FC<Props> = (
e.preventDefault ()
setFramed (await dialogue.confirm ({
title: '未確認の外部ページを表示します。',
title: label ('embed.confirmTitle'),
description: (
<div>
<p>悪意のあるスクリプトが実行される可能性があります。</p>
<p>表示しますか?</p>
<p>{label ('embed.risk')}</p>
<p>{label ('embed.question')}</p>
</div>),
confirmText: '表示' }))
confirmText: label ('shared:actions.show') }))
}}>
外部ページを表示
{label ('embed.showExternal')}
</a>)}
</div>)}
</>)
+2 -1
ファイルの表示
@@ -26,7 +26,8 @@ describe ('PostFormTagsArea', () => {
await waitFor (() => {
expect (api.apiGet).toHaveBeenCalledWith (
'/tags/autocomplete',
{ params: { q: '虹', nico: '0' } },
{ locale: 'ja', params: { q: '虹', nico: '0' },
signal: expect.any (AbortSignal) },
)
})
expect (setTags).toHaveBeenCalledWith ('虹夏')
+54 -14
ファイルの表示
@@ -1,13 +1,15 @@
// TODO: TagSearch と共通化する.
import { useRef, useState } from 'react'
import { useEffect, useRef, useState } from 'react'
import { useLocale } from '@/lib/useLocale'
import { useUITranslation } from '@/lib/useUITranslation'
import TagSearchBox from '@/components/TagSearchBox'
import FormField from '@/components/common/FormField'
import TextArea from '@/components/common/TextArea'
import { apiGet } from '@/lib/api'
import type { ComponentPropsWithoutRef, FC, SyntheticEvent } from 'react'
import type { ComponentPropsWithoutRef, FC, ReactNode, SyntheticEvent } from 'react'
import type { Tag } from '@/types'
@@ -34,19 +36,29 @@ const replaceToken = (value: string, start: number, end: number, text: string) =
type Props = Omit<ComponentPropsWithoutRef<'textarea'>, 'value' | 'onChange' | 'onBlur'> & {
tags: string
setTags: (tags: string) => void
errors?: string[] }
errors?: ReactNode[] }
const PostFormTagsArea: FC<Props> = ({ tags, setTags, errors, ...rest }) => {
const { label } = useUITranslation ('navigation')
const locale = useLocale ()
const ref = useRef<HTMLTextAreaElement> (null)
const [bounds, setBounds] = useState<{ start: number; end: number }> ({ start: 0, end: 0 })
const [focused, setFocused] = useState (false)
const [suggestions, setSuggestions] = useState<Tag[]> ([])
const [request, setRequest] = useState ({ query: '' })
const [result, setResult] = useState<{
locale: string
request: { query: string }
tags: Tag[] }> ({ locale, request, tags: [] })
const currentToken = getTokenAt (tags, Math.min (bounds.start, tags.length)).token
const suggestions = (result.locale === locale && result.request === request
&& request.query === currentToken) ? result.tags : []
const [suggestionsVsbl, setSuggestionsVsbl] = useState (false)
const handleTagSelect = (tag: Tag) => {
setSuggestionsVsbl (false)
setRequest ({ query: '' })
const textarea = ref.current!
const newValue = replaceToken (tags, bounds.start, bounds.end, tag.name + ' ')
setTags (newValue)
@@ -58,8 +70,10 @@ const PostFormTagsArea: FC<Props> = ({ tags, setTags, errors, ...rest }) => {
})
}
const recompute = async (pos: number, v: string = tags) => {
const recompute = (pos: number, v: string = tags) => {
const { start, end, token } = getTokenAt (v, pos)
setRequest ({ query: token })
setSuggestionsVsbl (token.trim () !== '')
if (!(token.trim ()))
{
setSuggestionsVsbl (false)
@@ -67,17 +81,43 @@ const PostFormTagsArea: FC<Props> = ({ tags, setTags, errors, ...rest }) => {
}
setBounds ({ start, end })
const data = await apiGet<Tag[]> ('/tags/autocomplete', { params: {
q: token,
nico: '0' } })
const nextSuggestions = data.filter (t => t.postCount > 0)
setSuggestions (nextSuggestions)
setSuggestionsVsbl (nextSuggestions.length > 0)
}
useEffect (() => {
if (request.query.trim () === '')
return
const controller = new AbortController ()
let active = true
void (async () => {
try
{
const tags = await apiGet<Tag[]> ('/tags/autocomplete', {
locale, signal: controller.signal, params: { q: request.query, nico: '0' } })
if (active)
{
setResult ({ locale, request,
tags: tags.filter (tag => tag.postCount > 0) })
}
}
catch
{
if (active)
setResult ({ locale, request, tags: [] })
}
}) ()
return () => {
active = false
controller.abort ()
}
}, [request, locale])
useEffect (() => {
setSuggestionsVsbl (false)
setRequest ({ query: '' })
}, [locale])
return (
<FormField className="relative w-full" label="タグ" messages={errors}>
<FormField className="relative w-full" label={label ('tags')} messages={errors}>
{({ describedBy, invalid }) => (
<>
<TextArea
@@ -89,7 +129,7 @@ const PostFormTagsArea: FC<Props> = ({ tags, setTags, errors, ...rest }) => {
onChange={ev => setTags (ev.target.value)}
onSelect={async (ev: SyntheticEvent<HTMLTextAreaElement>) => {
const pos = (ev.target as HTMLTextAreaElement).selectionStart
await recompute (pos)
await recompute (pos, (ev.target as HTMLTextAreaElement).value)
}}
onFocus={() => setFocused (true)}
onBlur={() => {
+2 -2
ファイルの表示
@@ -26,11 +26,11 @@ describe ('PostList', () => {
expect (screen.getByRole ('link', { name: 'First' })).toHaveAttribute (
'href',
'/posts/1',
'/ja/posts/1',
)
expect (
screen.getByRole ('link', { name: 'https://example.com/second' }),
).toHaveAttribute ('href', '/posts/2')
).toHaveAttribute ('href', '/ja/posts/2')
})
it ('calls the optional click handler', () => {
+3 -1
ファイルの表示
@@ -3,6 +3,7 @@ import { useRef } from 'react'
import { useLocation } from 'react-router-dom'
import PrefetchLink from '@/components/PrefetchLink'
import { useUITranslation } from '@/lib/useUITranslation'
import { useClientBehaviourSettings } from '@/lib/useClientBehaviourSettings'
import { cn } from '@/lib/utils'
import { useSharedTransitionStore } from '@/stores/sharedTransitionStore'
@@ -16,6 +17,7 @@ type Props = { posts: Post[]
const PostList: FC<Props> = ({ posts, onClick }) => {
const { label } = useUITranslation ('shared')
const location = useLocation ()
const behaviourSettings = useClientBehaviourSettings ()
const thumbnailMode = behaviourSettings.thumbnailMode ?? 'normal'
@@ -75,7 +77,7 @@ const PostList: FC<Props> = ({ posts, onClick }) => {
? (
<div className="flex h-full w-full items-center justify-center
bg-muted text-center text-xs text-muted-foreground">
サムネイルなし
{label ('labels.noThumbnail')}
</div>)
: (
<img
+2 -1
ファイルの表示
@@ -1,7 +1,8 @@
import { fireEvent, render, screen } from '@testing-library/react'
import { fireEvent, screen } from '@testing-library/react'
import { describe, expect, it, vi } from 'vitest'
import PostOriginalCreatedTimeField from '@/components/PostOriginalCreatedTimeField'
import { renderWithProviders as render } from '@/test/render'
describe ('PostOriginalCreatedTimeField', () => {
it ('updates from and before values', () => {
+62 -61
ファイルの表示
@@ -1,8 +1,9 @@
import DateTimeField, { toMinutePrecisionIsoUtc } from '@/components/common/DateTimeField'
import FormField from '@/components/common/FormField'
import { Button } from '@/components/ui/button'
import { useUITranslation } from '@/lib/useUITranslation'
import type { FC } from 'react'
import type { FC, ReactNode } from 'react'
type Props = {
disabled?: boolean
@@ -10,7 +11,7 @@ type Props = {
setOriginalCreatedFrom: (x: string | null) => void
originalCreatedBefore: string | null
setOriginalCreatedBefore: (x: string | null) => void
errors?: string[] }
errors?: ReactNode[] }
const PostOriginalCreatedTimeField: FC<Props> = (
@@ -19,71 +20,71 @@ const PostOriginalCreatedTimeField: FC<Props> = (
setOriginalCreatedFrom,
originalCreatedBefore,
setOriginalCreatedBefore,
errors }: Props) => {
errors }: Props,
) => {
const { label } = useUITranslation ('shared')
return (
<FormField label="オリジナルの作成日時" messages={errors}>
<FormField label={label ('labels.originalCreatedAt')} messages={errors}>
{({ describedBy, invalid }) => (
<>
<div className="my-1 flex">
<div className="w-80">
<DateTimeField
className="mr-2"
disabled={disabled ?? false}
aria-describedby={describedBy}
aria-invalid={invalid}
invalid={invalid}
value={originalCreatedFrom ?? undefined}
onChange={setOriginalCreatedFrom}
onBlur={ev => {
const v = ev.target.value
if (!(v))
return
<div className="my-1 flex min-w-0 flex-wrap items-center gap-2">
<DateTimeField
className="min-w-0 max-w-full"
disabled={disabled ?? false}
aria-describedby={describedBy}
aria-invalid={invalid}
invalid={invalid}
value={originalCreatedFrom ?? undefined}
onChange={setOriginalCreatedFrom}
onBlur={ev => {
const v = ev.target.value
if (!(v))
return
const d = new Date (v)
if (d.getMinutes () === 0 && d.getHours () === 0)
d.setDate (d.getDate () + 1)
else
d.setMinutes (d.getMinutes () + 1)
setOriginalCreatedBefore (toMinutePrecisionIsoUtc (d.toISOString ()))
}}/>
以降
</div>
<div>
<Button
type="button"
className="bg-gray-600 text-white rounded"
disabled={disabled}
onClick={() => {
setOriginalCreatedFrom (null)
}}>
リセット
</Button>
</div>
const d = new Date (v)
if (d.getMinutes () === 0 && d.getHours () === 0)
d.setDate (d.getDate () + 1)
else
d.setMinutes (d.getMinutes () + 1)
setOriginalCreatedBefore (toMinutePrecisionIsoUtc (d.toISOString ()))
}}/>
<span className="ainu-ui-label shrink-0 whitespace-nowrap">
{label ('labels.since')}
</span>
<Button
type="button"
className="ainu-ui-label shrink-0 rounded bg-gray-600 text-white
dark:bg-gray-600 dark:text-white"
disabled={disabled}
onClick={() => {
setOriginalCreatedFrom (null)
}}>
{label ('actions.reset')}
</Button>
</div>
<div className="my-1 flex">
<div className="w-80">
<DateTimeField
className="mr-2"
disabled={disabled}
aria-describedby={describedBy}
aria-invalid={invalid}
invalid={invalid}
value={originalCreatedBefore ?? undefined}
onChange={setOriginalCreatedBefore}/>
より前
</div>
<div>
<Button
type="button"
className="bg-gray-600 text-white rounded"
disabled={disabled}
onClick={() => {
setOriginalCreatedBefore (null)
}}>
リセット
</Button>
</div>
<div className="my-1 flex min-w-0 flex-wrap items-center gap-2">
<DateTimeField
className="min-w-0 max-w-full"
disabled={disabled}
aria-describedby={describedBy}
aria-invalid={invalid}
invalid={invalid}
value={originalCreatedBefore ?? undefined}
onChange={setOriginalCreatedBefore}/>
<span className="ainu-ui-label shrink-0 whitespace-nowrap">
{label ('labels.before')}
</span>
<Button
type="button"
className="ainu-ui-label shrink-0 rounded bg-gray-600 text-white
dark:bg-gray-600 dark:text-white"
disabled={disabled}
onClick={() => {
setOriginalCreatedBefore (null)
}}>
{label ('actions.reset')}
</Button>
</div>
</>)}
</FormField>)
+13 -6
ファイルの表示
@@ -1,12 +1,15 @@
import { useQueryClient } from '@tanstack/react-query'
import { forwardRef, useMemo } from 'react'
import { flushSync } from 'react-dom'
import { createPath, useNavigate } from 'react-router-dom'
import { createPath } from 'react-router-dom'
import { localiseTo } from '@/lib/locales'
import { useLocale, useLocaleNavigate } from '@/lib/useLocale'
import { useOverlayStore } from '@/components/RouteBlockerOverlay'
import { prefetchForURL } from '@/lib/prefetchers'
import { useClientBehaviourSettings } from '@/lib/useClientBehaviourSettings'
import { cn } from '@/lib/utils'
import { currentRenderContext } from '@/lib/renderContext'
import type { AnchorHTMLAttributes, MouseEvent, TouchEvent } from 'react'
import type { To } from 'react-router-dom'
@@ -32,16 +35,20 @@ export default forwardRef<HTMLAnchorElement, Props> (({
if ('onClick' in rest)
delete rest['onClick']
const navigate = useNavigate ()
const navigate = useLocaleNavigate ()
const locale = useLocale ()
const qc = useQueryClient ()
const behaviourSettings = useClientBehaviourSettings ()
const linkPreloadMode = behaviourSettings.linkPreload ?? 'intent'
const path = useMemo (
() => typeof to === 'string' ? to : createPath (to),
[to],
() => {
const localised = localiseTo (to, locale)
return typeof localised === 'string' ? localised : createPath (localised)
},
[to, locale],
)
const url = useMemo (() => {
return (new URL (path, window.location.origin)).toString ()
return (new URL (path, currentRenderContext ()?.origin ?? 'http://localhost')).toString ()
}, [path])
const setOverlay = useOverlayStore (s => s.setActive)
@@ -98,7 +105,7 @@ export default forwardRef<HTMLAnchorElement, Props> (({
if (!(ok) && cancelOnError)
return
navigate (to, { replace, ...(state && { state }) })
navigate (path, { replace, ...(state && { state }) })
}
catch (ex)
{
+11 -3
ファイルの表示
@@ -1,6 +1,10 @@
import { useEffect } from 'react'
import { useTranslation } from 'react-i18next'
import { create } from 'zustand'
import LocalisedText from '@/components/LocalisedText'
import { translateUIMessage } from '@/lib/i18n'
import type { FC } from 'react'
type OverlayStore = {
@@ -14,6 +18,9 @@ export const useOverlayStore = create<OverlayStore> (set => ({
const RouteBlockerOverlay: FC = () => {
const { i18n } = useTranslation ('common')
const loading = translateUIMessage ('loading', 'common', i18n.language)
const loadingLabel = translateUIMessage ('loadingLabel', 'common', i18n.language)
const active = useOverlayStore (s => s.active)
useEffect (() => {
@@ -35,14 +42,15 @@ const RouteBlockerOverlay: FC = () => {
return (
<div
role="progressbar"
aria-label="Loading"
aria-label={loadingLabel.text}
lang={loadingLabel.language}
className="fixed inset-0 z-[9999] bg-black/50 backdrop-blur-sm pointer-events-auto">
<div className="absolute inset-0 flex items-center justify-center">
<div className="rounded-2xl bg-black/60 text-white px-6 py-3 text-sm">
Loading...
<LocalisedText language={loading.language}>{loading.text}</LocalisedText>
</div>
</div>
</div>)
}
export default RouteBlockerOverlay
export default RouteBlockerOverlay
+2 -2
ファイルの表示
@@ -17,7 +17,7 @@ describe ('SortHeader', () => {
expect (screen.getByRole ('link', { name: 'タイトル ▲' })).toHaveAttribute (
'href',
'/posts?tags=x&page=1&order=title%3Adesc',
'/ja/posts?tags=x&page=1&order=title%3Adesc',
)
})
@@ -33,7 +33,7 @@ describe ('SortHeader', () => {
expect (screen.getByRole ('link', { name: '更新' })).toHaveAttribute (
'href',
'/posts?page=1&order=updated_at%3Adesc',
'/ja/posts?page=1&order=updated_at%3Adesc',
)
})
})
+5 -3
ファイルの表示
@@ -2,10 +2,12 @@ import { useLocation } from 'react-router-dom'
import PrefetchLink from '@/components/PrefetchLink'
import type { ReactNode } from 'react'
export default <T extends string,>({ by, label, currentOrder, defaultDirection }: {
by: T
label: string
label: ReactNode
currentOrder: `${ T }:${ 'asc' | 'desc' }`
defaultDirection: Record<T, 'asc' | 'desc'> }) => {
const [fld, dir] = currentOrder.split (':')
@@ -23,9 +25,9 @@ export default <T extends string,>({ by, label, currentOrder, defaultDirection }
<PrefetchLink
className="text-inherit visited:text-inherit hover:text-inherit"
to={`${ location.pathname }?${ qs.toString () }`}>
<span className="font-bold">
<span className="inline-flex w-max items-baseline gap-1 whitespace-nowrap font-bold">
{label}
{by === fld && (dir === 'asc' ? ' ▲' : ' ▼')}
{by === fld && <span className="shrink-0">{dir === 'asc' ? '▲' : '▼'}</span>}
</span>
</PrefetchLink>)
}
+43 -25
ファイルの表示
@@ -12,6 +12,7 @@ import { useQueryClient } from '@tanstack/react-query'
import { motion } from 'framer-motion'
import { useEffect, useMemo, useRef, useState } from 'react'
import { useLocale } from '@/lib/useLocale'
import DraggableDroppableTagRow from '@/components/DraggableDroppableTagRow'
import PrefetchLink from '@/components/PrefetchLink'
import TagLink from '@/components/TagLink'
@@ -25,8 +26,11 @@ import { apiDelete, apiGet, apiPatch, apiPost } from '@/lib/api'
import { clientAnimationTransition } from '@/lib/clientAnimation'
import { postsKeys, tagsKeys } from '@/lib/queryKeys'
import { useClientBehaviourSettings } from '@/lib/useClientBehaviourSettings'
import { dateString, originalCreatedAtString } from '@/lib/utils'
import { localisedDateString, originalCreatedAtString } from '@/lib/utils'
import LocalisedText from '@/components/LocalisedText'
import { useUITranslation } from '@/lib/useUITranslation'
import type { LocaleCode } from '@/lib/locales'
import type { CollisionDetection, DragEndEvent } from '@dnd-kit/core'
import type { FC, MutableRefObject, ReactNode } from 'react'
@@ -180,8 +184,10 @@ const buildFlatTagByCategory = (
const changeCategory = async (
tagId: number,
category: Category): Promise<void> => {
await apiPatch (`/tags/${ tagId }`, { category })
category: Category,
locale: LocaleCode = 'ja',
): Promise<void> => {
await apiPatch (`/tags/${ tagId }`, { category }, { locale })
}
@@ -227,6 +233,8 @@ type ActiveTagDrag = {
const TagDetailSidebar: FC<Props> = ({ className, post, sp }) => {
const locale = useLocale ()
const { label } = useUITranslation ('tags')
sp = Boolean (sp)
const qc = useQueryClient ()
@@ -271,7 +279,7 @@ const TagDetailSidebar: FC<Props> = ({ className, post, sp }) => {
useSensor (TouchSensor, { activationConstraint: { delay: 250, tolerance: 8 } }))
const reloadTags = async (): Promise<void> => {
setTags (buildTagByCategory (await apiGet<Post> (`/posts/${ post.id }`)))
setTags (buildTagByCategory (await apiGet<Post> (`/posts/${ post.id }`, { locale })))
qc.invalidateQueries ({ queryKey: postsKeys.root })
qc.invalidateQueries ({ queryKey: tagsKeys.root })
}
@@ -314,14 +322,15 @@ const TagDetailSidebar: FC<Props> = ({ className, post, sp }) => {
return
if (fromParentId != null)
await apiDelete (`/tags/${ fromParentId }/children/${ childId }`)
await apiDelete (`/tags/${ fromParentId }/children/${ childId }`, { locale })
await apiPost (`/tags/${ parentId }/children/${ childId }`, { })
await apiPost (`/tags/${ parentId }/children/${ childId }`, { }, { locale })
await reloadTags ()
toast ({
title: '上位タグ対応追加',
description: `《${ child?.name }》を《${ parent?.name }》の子タグに設定しました.` })
title: label ('relations.added'),
description: label ('relations.addedDescription', {
child: child.name, parent: parent.name }) })
break
}
@@ -333,20 +342,21 @@ const TagDetailSidebar: FC<Props> = ({ className, post, sp }) => {
return
if (child.category !== cat)
await changeCategory (childId, cat)
await changeCategory (childId, cat, locale)
if (fromParentId != null)
await apiDelete (`/tags/${ fromParentId }/children/${ childId }`)
await apiDelete (`/tags/${ fromParentId }/children/${ childId }`, { locale })
const fromParent = fromParentId == null ? null : findTag (tags, fromParentId)
await reloadTags ()
toast ({
title: '上位タグ対応解除',
title: label ('relations.removed'),
description: (
fromParent
? `《${ child.name }》を《${ fromParent.name }》の子タグから外しました.`
: `《${ child.name }》のカテゴリを変更しました.`) })
? label ('relations.removedDescription', {
child: child.name, parent: fromParent.name })
: label ('relations.categoryChanged', { name: child.name })) })
break
}
@@ -354,7 +364,8 @@ const TagDetailSidebar: FC<Props> = ({ className, post, sp }) => {
}
catch
{
toast ({ title: '上位タグ対応失敗', description: '独裁者である必要があります.' })
toast ({ title: label ('relations.failed'),
description: label ('relations.permissionRequired') })
}
finally
{
@@ -468,21 +479,26 @@ const TagDetailSidebar: FC<Props> = ({ className, post, sp }) => {
? undefined
: `post-info-${ sp }`}
transition={{ layout: layoutTransition }}>
<SectionTitle>情報</SectionTitle>
<SectionTitle>{label ('shared:labels.information')}</SectionTitle>
<ul>
<li>Id.: {post.id}</li>
<li>{label ('shared:idPrefix')}{post.id}</li>
<li>
<>耕作者: </>
{label ('shared:labels.uploadedBy')}
{post.uploadedUser
? (
<PrefetchLink to={`/users/${ post.uploadedUser.id }`}>
{post.uploadedUser.name || '名もなきニジラー'}
{post.uploadedUser.name || label ('shared:labels.anonymous')}
</PrefetchLink>)
: 'bot操作'}
: label ('shared:labels.bot')}
</li>
<li>耕作日時: {dateString (post.createdAt)}</li>
<li>
<>リンク: </>
{label ('shared:labels.uploadedAt')}
<LocalisedText language={locale}>
{localisedDateString (post.createdAt, locale)}
</LocalisedText>
</li>
<li>
{label ('shared:labels.link')}
<a
className="break-all"
href={post.url}
@@ -492,13 +508,15 @@ const TagDetailSidebar: FC<Props> = ({ className, post, sp }) => {
</a>
</li>
<li>
<>オリジナルの投稿日時: </>
{originalCreatedAtString (post.originalCreatedFrom,
post.originalCreatedBefore)}
{label ('shared:labels.originalPostedAt')}
<LocalisedText language={locale}>
{originalCreatedAtString (post.originalCreatedFrom,
post.originalCreatedBefore, locale)}
</LocalisedText>
</li>
<li>
<PrefetchLink to={`/posts/changes?id=${ post.id }`}>
履歴
{label ('navigation:history')}
</PrefetchLink>
</li>
</ul>
+2 -2
ファイルの表示
@@ -13,7 +13,7 @@ describe ('TagLink', () => {
expect (screen.getByRole ('link', { name: '虹 夏' })).toHaveAttribute (
'href',
'/posts?tags=%E8%99%B9+%E5%A4%8F',
'/ja/posts?tags=%E8%99%B9+%E5%A4%8F',
)
expect (screen.getByText ('4')).toBeInTheDocument ()
})
@@ -40,7 +40,7 @@ describe ('TagLink', () => {
expect (screen.getByRole ('link', { name: '?' })).toHaveAttribute (
'href',
'/wiki/a%2Fb',
'/ja/wiki/a%2Fb',
)
})
+11 -3
ファイルの表示
@@ -1,5 +1,6 @@
import PrefetchLink from '@/components/PrefetchLink'
import ResponsiveMarqueeText from '@/components/ResponsiveMarqueeText'
import { useUITranslation } from '@/lib/useUITranslation'
import { cn } from '@/lib/utils'
import type { ComponentProps, CSSProperties, FC, HTMLAttributes } from 'react'
@@ -67,6 +68,10 @@ const TagLink: FC<Props> = ({ tag,
style,
title,
...props }) => {
const { message } = useUITranslation ()
const missingMaterial = message ('tagLink.missingMaterial', { name: tag.name })
const missingDeerjikist = message ('tagLink.missingDeerjikist', { name: tag.name })
const missingWiki = message ('tagLink.missingWiki', { name: tag.name })
const colourStyle = {
'--tag-link-colour': `var(--tag-colour-${ tag.category })`,
'--tag-link-hover-colour': `var(--tag-colour-${ tag.category }-hover)`,
@@ -121,7 +126,8 @@ const TagLink: FC<Props> = ({ tag,
to={`/materials/new?tag=${ encodeURIComponent (tag.name) }`}
className="animate-[wiki-blink_.25s_steps(2,end)_infinite]
dark:animate-[wiki-blink-dark_.25s_steps(2,end)_infinite]"
title={`${ tag.name } 素材情報が存在しません.`}>
lang={missingMaterial.language}
title={missingMaterial.text}>
!
</PrefetchLink>)
: (
@@ -131,7 +137,8 @@ const TagLink: FC<Props> = ({ tag,
to={`/tags/${ tag.id }/deerjikists`}
className="animate-[wiki-blink_.25s_steps(2,end)_infinite]
dark:animate-[wiki-blink-dark_.25s_steps(2,end)_infinite]"
title={`${ tag.name } に関する情報が存在しません.`}>
lang={missingDeerjikist.language}
title={missingDeerjikist.text}>
!
</PrefetchLink>)
: (
@@ -139,7 +146,8 @@ const TagLink: FC<Props> = ({ tag,
to={`/wiki/${ encodeURIComponent (tag.name) }`}
className="animate-[wiki-blink_.25s_steps(2,end)_infinite]
dark:animate-[wiki-blink-dark_.25s_steps(2,end)_infinite]"
title={`${ tag.name } Wiki が存在しません.`}>
lang={missingWiki.language}
title={missingWiki.text}>
!
</PrefetchLink>)))}
</span>)}
+60 -19
ファイルの表示
@@ -1,8 +1,10 @@
// TODO: タグ入力系すべてに同様の処理あるため共通化する.
import { useEffect, useState } from 'react'
import { useNavigate, useLocation } from 'react-router-dom'
import { useLocation } from 'react-router-dom'
import { useLocale, useLocaleNavigate } from '@/lib/useLocale'
import { useUITranslation } from '@/lib/useUITranslation'
import { apiGet } from '@/lib/api'
import { inputClass } from '@/lib/utils'
@@ -14,30 +16,64 @@ import type { Tag } from '@/types'
const TagSearch: FC = () => {
const { message } = useUITranslation ('shared')
const locale = useLocale ()
const location = useLocation ()
const navigate = useNavigate ()
const navigate = useLocaleNavigate ()
const [activeIndex, setActiveIndex] = useState (-1)
const [search, setSearch] = useState ('')
const [suggestions, setSuggestions] = useState<Tag[]> ([])
const [request, setRequest] = useState ({ query: '' })
const [result, setResult] = useState<{
locale: string
request: { query: string }
tags: Tag[] }> ({ locale, request, tags: [] })
const suggestions = result.locale === locale && result.request === request
? result.tags : []
const [suggestionsVsbl, setSuggestionsVsbl] = useState (false)
const whenChanged = async (ev: ChangeEvent<HTMLInputElement>) => {
setSearch (ev.target.value)
const q = ev.target.value.trim ().split (' ').at (-1)
if (!(q))
{
setSuggestions ([])
return
}
const data = await apiGet<Tag[]> ('/tags/autocomplete', { params: { q } })
setSuggestions (data.filter (t => t.postCount > 0))
if (suggestions.length > 0)
setSuggestionsVsbl (true)
const whenChanged = (ev: ChangeEvent<HTMLInputElement>) => {
const value = ev.target.value
const query = value.trim ().split (' ').at (-1) ?? ''
setSearch (value)
setRequest ({ query })
setActiveIndex (-1)
setSuggestionsVsbl (query !== '')
}
useEffect (() => {
if (request.query === '')
return
const controller = new AbortController ()
let active = true
void (async () => {
try
{
const tags = await apiGet<Tag[]> ('/tags/autocomplete', {
locale, signal: controller.signal, params: { q: request.query } })
if (active)
{
setResult ({ locale, request,
tags: tags.filter (tag => tag.postCount > 0) })
}
}
catch
{
if (active)
setResult ({ locale, request, tags: [] })
}
}) ()
return () => {
active = false
controller.abort ()
}
}, [request, locale])
useEffect (() => {
setActiveIndex (-1)
setSuggestionsVsbl (false)
}, [locale])
const handleKeyDown = (ev: KeyboardEvent<HTMLInputElement>) => {
switch (ev.key)
{
@@ -81,6 +117,7 @@ const TagSearch: FC = () => {
match }))
.toString () }`)
setSuggestionsVsbl (false)
setRequest ({ query: '' })
}
}
@@ -91,7 +128,8 @@ const TagSearch: FC = () => {
? ('not:' + tag.name)
: tag.name)
setSearch (parts.join (' ') + ' ')
setSuggestions ([])
setRequest ({ query: '' })
setSuggestionsVsbl (false)
setActiveIndex (-1)
}
@@ -100,13 +138,16 @@ const TagSearch: FC = () => {
const anyFlg = query.get ('match') === 'any'
const tagsQuery = `${ query.get ('tags') ?? '' }${ anyFlg ? ' type:or' : '' }`
setSearch (tagsQuery)
setRequest ({ query: '' })
setActiveIndex (-1)
setSuggestionsVsbl (false)
}, [location.search])
return (
<div className="relative w-full">
<input type="text"
data-shortcut-focus-search="true"
placeholder="タグ検索..."
placeholder={message ('labels.tagSearch').text}
value={search}
onChange={whenChanged}
onFocus={() => setSuggestionsVsbl (true)}
+11 -7
ファイルの表示
@@ -1,7 +1,9 @@
import { AnimatePresence, motion } from 'framer-motion'
import { useEffect, useState } from 'react'
import { useLocation, useNavigate } from 'react-router-dom'
import { useLocation } from 'react-router-dom'
import { useLocale, useLocaleNavigate } from '@/lib/useLocale'
import { useUITranslation } from '@/lib/useUITranslation'
import TagLink from '@/components/TagLink'
import TagSearch from '@/components/TagSearch'
import SectionTitle from '@/components/common/SectionTitle'
@@ -22,7 +24,9 @@ type Props = { posts: Post[]
const TagSidebar: FC<Props> = ({ posts, onClick }) => {
const navigate = useNavigate ()
const { label } = useUITranslation ('shared')
const locale = useLocale ()
const navigate = useLocaleNavigate ()
const behaviourSettings = useClientBehaviourSettings ()
const animationMode = behaviourSettings.animation ?? 'normal'
const animationsOff = animationMode === 'off'
@@ -69,7 +73,7 @@ const TagSidebar: FC<Props> = ({ posts, onClick }) => {
const TagBlock = (
<>
<SectionTitle>タグ</SectionTitle>
<SectionTitle>{label ('navigation:tags')}</SectionTitle>
<ul>
{CATEGORIES.flatMap (cat => cat in tags ? (
tags[cat].map (tag => (
@@ -88,7 +92,7 @@ const TagSidebar: FC<Props> = ({ posts, onClick }) => {
</motion.div>)}
</li>))) : [])}
</ul>
<SectionTitle>関聯</SectionTitle>
<SectionTitle>{label ('labels.relations')}</SectionTitle>
<a href="#"
onClick={ev => {
ev.preventDefault ()
@@ -96,7 +100,7 @@ const TagSidebar: FC<Props> = ({ posts, onClick }) => {
try
{
const data = await apiGet<Post> ('/posts/random',
{ params: { tags: tagsQuery.split (' ').filter (e => e !== '').join (' '),
{ locale, params: { tags: tagsQuery.split (' ').filter (e => e !== '').join (' '),
match: (anyFlg ? 'any' : 'all') } })
navigate (`/posts/${ data.id }`)
}
@@ -106,7 +110,7 @@ const TagSidebar: FC<Props> = ({ posts, onClick }) => {
}
}) ())
}}>
ランダム
{label ('actions.random')}
</a>
</>)
@@ -146,7 +150,7 @@ const TagSidebar: FC<Props> = ({ posts, onClick }) => {
ev.preventDefault ()
setTagsVsbl (v => !(v))
}}>
{tagsVsbl ? '▲▲▲ タグ一覧を閉じる ▲▲▲' : '▼▼▼ タグ一覧を表示 ▼▼▼'}
{tagsVsbl ? label ('labels.hideTags') : label ('labels.showTags')}
</a>
</SidebarComponent>)
}
+354 -162
ファイルの表示
@@ -1,12 +1,23 @@
import { useQuery } from '@tanstack/react-query'
import { AnimatePresence, motion } from 'framer-motion'
import { Globe } from 'lucide-react'
import { Fragment, useEffect, useLayoutEffect, useRef, useState } from 'react'
import { useLocation } from 'react-router-dom'
import { useLocale, useLocaleNavigate } from '@/lib/useLocale'
import LocalisedText, { formatUIString } from '@/components/LocalisedText'
import Separator from '@/components/MenuSeparator'
import PrefetchLink from '@/components/PrefetchLink'
import TopNavUser from '@/components/TopNavUser'
import TranslatedText from '@/components/TranslatedText'
import { toast } from '@/components/ui/use-toast'
import { WikiIdBus } from '@/lib/eventBus/WikiIdBus'
import { isLocaleCode,
localeFromPath,
switchLocalePath,
unlocalisedPath } from '@/lib/locales'
import { uiMessage, uiMessageLanguage } from '@/lib/messages'
import { useUITranslation } from '@/lib/useUITranslation'
import { materialsKeys, tagsKeys, wikiKeys } from '@/lib/queryKeys'
import { useClientBehaviourSettings } from '@/lib/useClientBehaviourSettings'
import { canEditContent } from '@/lib/users'
@@ -17,20 +28,33 @@ import { fetchWikiPage } from '@/lib/wiki'
import type { FC, MouseEvent } from 'react'
import type { AvailableLocale, LocaleCode } from '@/lib/locales'
import type { Material, Menu, MenuVisibleItem, Tag, User } from '@/types'
type Props = { user: User | null }
type Props = {
user: User | null
locales: AvailableLocale[]
resolveLocaleSwitch?: (locale: LocaleCode) => Promise<string> }
type NavDensity = 'auto' | 'comfortable' | 'compact'
export const menuOutline = (
{ tag, material, wikiId, user, pathName }: {
{ tag, material, wikiId, user, pathName: fullPathname }: {
tag?: Tag | null
material?: Material | null
wikiId: number | null
user: User | null
pathName: string },
): Menu => {
const pathName = unlocalisedPath (fullPathname)
const locale = localeFromPath (fullPathname) ?? 'ja'
const message = (
key: Parameters<typeof uiMessage>[1],
values?: Record<string, string | number>,
) => ({ name: uiMessage (locale, key, values),
nameLanguage: uiMessageLanguage (locale, key) })
const postCount = tag?.postCount ?? material?.tag?.postCount ?? 0
const editable = canEditContent (user)
@@ -41,75 +65,89 @@ export const menuOutline = (
const materialFlg = /^\/materials\/\d+/.test (pathName)
const helpVsbl = /\/help\/((?!changes)[^\/?]+)/.test (pathName)
const helpVsbl = /\/help\/((?!changes)[^/?]+)/.test (pathName)
const helpIdentifier = pathName.split ('/')[2] ?? ''
return [
{ name: '広場', to: '/posts', subMenu: [
{ name: '一覧', to: '/posts' },
{ name: '検索', to: '/posts/search' },
{ name: '追加', to: '/posts/new', visible: editable },
{ name: '全体履歴', to: '/posts/changes' },
{ name: 'ヘルプ', to: '/wiki/ヘルプ:広場' }] },
{ name: 'タグ', to: '/tags', subMenu: [
{ name: 'マスタ', to: '/tags' },
{ name: 'ニコニコ連携', to: '/tags/nico' },
{ name: '全体履歴', to: '/tags/changes' },
{ name: 'ヘルプ', to: '/wiki/ヘルプ:タグ' },
{ ...message ('posts'), to: '/posts', subMenu: [
{ ...message ('list'), to: '/posts' },
{ ...message ('search'), to: '/posts/search' },
{ ...message ('add'), to: '/posts/new', visible: editable },
{ ...message ('allHistory'), to: '/posts/changes' },
{ ...message ('help'), to: '/wiki/ヘルプ:広場' }] },
{ ...message ('tags'), to: '/tags', subMenu: [
{ ...message ('master'), to: '/tags' },
{ ...message ('nicoLinks'), to: '/tags/nico' },
{ ...message ('allHistory'), to: '/tags/changes' },
{ ...message ('help'), to: '/wiki/ヘルプ:タグ' },
{ component: <Separator/>, visible: tagFlg },
{ name: `広場 (${ postCount || 0 })`,
{ ...message ('postsWithCount', { count: postCount }),
to: `/posts?tags=${ encodeURIComponent (tag?.name ?? '') }`,
visible: tagFlg },
{ name: '履歴', to: `/tags/changes?id=${ tag?.id }`,
{ ...message ('history'), to: `/tags/changes?id=${ tag?.id }`,
visible: tagFlg && tag?.category !== 'nico' }] },
{ name: '素材', to: '/materials', visible: true, subMenu: [
{ name: '一覧', to: '/materials' },
{ name: '追加', to: '/materials/new', visible: editable },
{ name: '抑止', to: '/materials/suppressions',
{ ...message ('materials'), to: '/materials', visible: true, subMenu: [
{ ...message ('list'), to: '/materials' },
{ ...message ('add'), to: '/materials/new', visible: editable },
{ ...message ('suppressions'), to: '/materials/suppressions',
visible: user?.role === 'admin' },
{ name: '全体履歴', to: '/materials/changes' },
{ name: 'ヘルプ', to: '/wiki/ヘルプ:素材管理' },
{ ...message ('allHistory'), to: '/materials/changes' },
{ ...message ('help'), to: '/wiki/ヘルプ:素材管理' },
{ component: <Separator/>, visible: materialFlg },
{ name: `広場 (${ postCount || 0 })`,
{ ...message ('postsWithCount', { count: postCount }),
to: `/posts?tags=${ encodeURIComponent (material?.tag?.name ?? '') }`,
visible: materialFlg && Boolean (material?.tag) },
{ name: '履歴', to: `/materials/changes?material_id=${ material?.id }`,
{ ...message ('history'), to: `/materials/changes?material_id=${ material?.id }`,
visible: materialFlg }] },
{ name: 'Wiki', to: '/wiki', subMenu: [
{ name: '検索', to: '/wiki' },
{ name: '新規', to: '/wiki/new', visible: editable },
{ name: '全体履歴', to: '/wiki/changes' },
{ name: 'ヘルプ', to: '/wiki/ヘルプ:Wiki' },
{ ...message ('wiki'), to: '/wiki', subMenu: [
{ ...message ('search'), to: '/wiki' },
{ ...message ('create'), to: '/wiki/new', visible: editable },
{ ...message ('allHistory'), to: '/wiki/changes' },
{ ...message ('help'), to: '/wiki/ヘルプ:Wiki' },
{ component: <Separator/>, visible: wikiPageFlg },
{ name: `広場 (${ postCount || 0 })`, to: `/posts?tags=${ wikiTitle }`,
{ ...message ('postsWithCount', { count: postCount }), to: `/posts?tags=${ wikiTitle }`,
visible: wikiPageFlg },
{ name: '履歴', to: `/wiki/changes?id=${ wikiId }`, visible: wikiPageFlg },
{ name: '編輯', to: `/wiki/${ wikiId || wikiTitle }/edit`,
{ ...message ('history'), to: `/wiki/changes?id=${ wikiId }`, visible: wikiPageFlg },
{ ...message ('edit'), to: `/wiki/${ wikiId || wikiTitle }/edit`,
visible: wikiPageFlg && editable }] },
{ name: 'ヘルプ', to: '/help/home', base: '/help', subMenu: [
{ name: '検索', to: '/help' },
{ name: '全体履歴', to: '/help/changes' },
{ ...message ('help'), to: '/help/home', base: '/help', subMenu: [
{ ...message ('search'), to: '/help' },
{ ...message ('allHistory'), to: '/help/changes' },
{ component: <Separator/>, visible: helpVsbl },
{ name: '履歴',
{ ...message ('history'),
to: `/help/changes?identifier=${ helpIdentifier }`,
visible: helpVsbl }] },
{ name: 'おたのしみ', visible: false, subMenu: [
{ name: '上映会 (β)', to: '/theatres/1' },
{ name: 'グカネータ (β)', to: '/gekanator' }] },
{ name: 'ユーザ', to: '/users/settings', visible: false, subMenu: [
{ name: '一覧', to: '/users', visible: false },
{ name: 'お前', to: `/users/${ user?.id }`, visible: false },
{ name: '設定', to: '/users/settings', visible: Boolean (user) }] },
{ name: '法規', visible: false, subMenu: [
{ name: '利用規約', to: '/tos' }] }]
{ ...message ('entertainment'), visible: false, subMenu: [
{ ...message ('theatre'), to: '/theatres/1' },
{ ...message ('gekanator'), to: '/gekanator' }] },
{ ...message ('users'), to: '/users/settings', visible: false, subMenu: [
{ ...message ('list'), to: '/users', visible: false },
{ ...message ('currentUser'), to: `/users/${ user?.id }`, visible: false },
{ ...message ('settings'), to: '/users/settings', visible: Boolean (user) }] },
{ ...message ('legal'), visible: false, subMenu: [
{ ...message ('terms'), to: '/tos' }] }]
}
const TopNav: FC<Props> = ({ user }) => {
const TopNav: FC<Props> = ({ user, locales, resolveLocaleSwitch }) => {
const { message, label } = useUITranslation ('shared')
const locale = useLocale ()
const localeName = locales.find (entry => entry.code === locale)?.name
const navigate = useLocaleNavigate ()
const location = useLocation ()
const currentLocation = useRef (location)
currentLocation.current = location
const localeSwitchRequest = useRef (0)
const [switchingLocale, setSwitchingLocale] = useState (false)
useEffect (() => {
++localeSwitchRequest.current
setSwitchingLocale (false)
return () => { ++localeSwitchRequest.current }
}, [location.key])
const behaviourSettings = useClientBehaviourSettings ()
const animationMode = behaviourSettings.animation ?? 'normal'
const animationsOff = animationMode === 'off'
const [layoutReady, setLayoutReady] = useState (false)
const animationsOff = animationMode === 'off' || !layoutReady
const reducedAnimations = animationMode === 'reduced'
const dirRef = useRef<(-1) | 1> (1)
@@ -117,23 +155,35 @@ const TopNav: FC<Props> = ({ user }) => {
const navRef = useRef<HTMLDivElement | null> (null)
const layoutRef = useRef<HTMLDivElement | null> (null)
const barRef = useRef<HTMLElement | null> (null)
const submenuContentRef = useRef<HTMLDivElement | null> (null)
const highlightTargetRef = useRef (-1)
const measure = (idx: number) => {
const nav = navRef.current
const el = itemsRef.current[idx < 0 ? visibleMenu.length : idx]
if (!(nav) || !(el))
{
setHL ({ left: 0, width: 0, visible: true })
return
}
if (nav == null || el == null || nav.offsetWidth === 0
|| layoutRef.current?.dataset.desktop !== 'true')
{
setHL (current => current.visible
? { left: 0, width: 0, visible: false } : current)
return
}
const navRect = nav.getBoundingClientRect ()
const elRect = el.getBoundingClientRect ()
setHL ({ left: elRect.left - navRect.left,
width: elRect.width,
visible: true })
// Layout offsets exclude Framer Motion's temporary projection transforms.
let left = 0
let node: HTMLElement | null = el
while (node != null && node !== nav)
{
left += node.offsetLeft
const parent: Element | null = node.offsetParent
node = parent instanceof HTMLElement ? parent : null
}
const width = node === nav ? el.offsetWidth : 0
const visible = width > 0
setHL (current =>
(current.left === left && current.width === width && current.visible === visible)
? current : { left, width, visible })
}
const [hl, setHL] = useState<{ left: number; width: number; visible: boolean }> ({
@@ -141,62 +191,67 @@ const TopNav: FC<Props> = ({ user }) => {
width: 0,
visible: false })
const [menuOpen, setMenuOpen] = useState (false)
const [desktop, setDesktop] = useState (false)
const menuToggleLabel = message (menuOpen ? 'dialogue.close' : 'navigation:menuToggle')
const [desktop, setDesktop] = useState<boolean | null> (null)
const [density, setDensity] = useState<NavDensity> ('auto')
const [moreVsbl, setMoreVsbl] = useState (false)
const [openItemIdx, setOpenItemIdx] = useState (-1)
const [submenuHeight, setSubmenuHeight] = useState (0)
const [wikiId, setWikiId] = useState<number | null> (WikiIdBus.get ())
const wikiIdStr = String (wikiId ?? '')
const { data: wikiPage } = useQuery ({
enabled: Boolean (wikiIdStr),
queryKey: wikiKeys.show (wikiIdStr, { }),
queryFn: () => fetchWikiPage (wikiIdStr, { }) })
queryKey: wikiKeys.show (wikiIdStr, { }, locale),
queryFn: () => fetchWikiPage (wikiIdStr, { }, locale) })
const tagFlg = /^\/tags\/\d+/.test (location.pathname)
const materialFlg = /^\/materials\/\d+/.test (location.pathname)
const pathname = unlocalisedPath (location.pathname)
const tagFlg = /^\/tags\/\d+/.test (pathname)
const materialFlg = /^\/materials\/\d+/.test (pathname)
const effectiveTitle = (((tagFlg || materialFlg)
? location.pathname.split ('/')[2]
? pathname.split ('/')[2]
: wikiPage?.title)
?? '')
const { data: tag } = useQuery ({
enabled: Boolean (effectiveTitle),
queryKey: tagsKeys.show (effectiveTitle),
queryFn: () => (tagFlg ? fetchTag : fetchTagByName) (effectiveTitle) })
queryKey: tagsKeys.show (effectiveTitle, locale),
queryFn: () => (tagFlg ? fetchTag : fetchTagByName) (effectiveTitle, locale) })
const { data: material } = useQuery ({
enabled: Boolean (effectiveTitle),
queryKey: materialsKeys.show (effectiveTitle),
queryFn: () => fetchMaterial (effectiveTitle) })
queryKey: materialsKeys.show (effectiveTitle, locale),
queryFn: () => fetchMaterial (effectiveTitle, locale) })
const menu = menuOutline ({ tag, material, wikiId, user, pathName: location.pathname })
const visibleMenu = menu.filter ((item): item is MenuVisibleItem => item.visible ?? true)
const menuLabels = visibleMenu.map (item => item.name).join ('\u0000')
const moreMenu = menu.filter (item =>
!(item.visible ?? true)
|| item.subMenu.filter (subItem => subItem.visible ?? true).length > 0)
const activeIdx =
visibleMenu.findIndex (item => location.pathname.startsWith (item.base || item.to))
const submenuHeight = moreVsbl ? 40 * moreMenu.length : (activeIdx < 0 ? 0 : 40)
visibleMenu.findIndex (item => pathname.startsWith (item.base || item.to))
highlightTargetRef.current = moreVsbl ? -1 : activeIdx
const activeSubmenuId = `submenu-${ menu.indexOf (visibleMenu[activeIdx]) }`
const topNavTransition =
animationsOff
? { duration: 0 }
: reducedAnimations
? { duration: .08, ease: 'linear' as const }
: { duration: .2, ease: 'easeOut' as const }
: (reducedAnimations
? { duration: .08, ease: 'linear' as const }
: { duration: .2, ease: 'easeOut' as const })
const opacityTransition =
animationsOff
? { duration: 0 }
: reducedAnimations
? { duration: .08 }
: { duration: .12 }
: (reducedAnimations
? { duration: .08 }
: { duration: .12 })
const highlightTransitionClass =
animationsOff
? undefined
: reducedAnimations
? 'transition-[transform,width] duration-75 ease-linear'
: 'transition-[transform,width] duration-200 ease-out'
: (reducedAnimations
? 'transition-[transform,width] duration-75 ease-linear'
: 'transition-[transform,width] duration-200 ease-out')
const prevActiveIdxRef = useRef<number> (activeIdx)
@@ -215,7 +270,7 @@ const TopNav: FC<Props> = ({ user }) => {
if (layout == null || bar == null || primary == null)
return
const userLink = bar.querySelector<HTMLElement> ('.top-nav-user-desktop')
const account = bar.querySelector<HTMLElement> ('.top-nav-account')
let raf = 0
let active = true
@@ -237,11 +292,16 @@ const TopNav: FC<Props> = ({ user }) => {
{
layout.setAttribute ('data-density', candidate)
const style = getComputedStyle (bar)
const required = primary.getBoundingClientRect ().width
+ (userLink?.getBoundingClientRect ().width ?? 0)
const primaryWidth = Math.max (
primary.getBoundingClientRect ().width, primary.scrollWidth)
const accountWidth =
account == null
? 0
: Math.max (account.getBoundingClientRect ().width, account.scrollWidth)
const required = primaryWidth + accountWidth
+ parseFloat (style.paddingLeft) + parseFloat (style.paddingRight)
+ (userLink == null ? 0 : parseFloat (style.columnGap))
if (required <= bar.clientWidth)
+ (account == null ? 0 : parseFloat (style.columnGap))
if (Math.ceil (required) <= bar.clientWidth)
return candidate
}
return null
@@ -269,8 +329,11 @@ const TopNav: FC<Props> = ({ user }) => {
typeof ResizeObserver === 'undefined' ? null : new ResizeObserver (schedule)
observer?.observe (bar)
observer?.observe (primary)
if (userLink != null)
observer?.observe (userLink)
for (const item of itemsRef.current)
if (item != null)
observer?.observe (item)
if (account != null)
observer?.observe (account)
update ()
addEventListener ('resize', schedule)
document.fonts?.addEventListener ('loadingdone', schedule)
@@ -283,22 +346,117 @@ const TopNav: FC<Props> = ({ user }) => {
removeEventListener ('resize', schedule)
document.fonts?.removeEventListener ('loadingdone', schedule)
}
}, [activeIdx, moreVsbl, desktop, density, user?.name])
}, [activeIdx, moreVsbl, desktop, density, user?.name, locale, locales, menuLabels])
useEffect (() => {
if (desktop == null || layoutReady)
return
let active = true
let frame = 0
void (document.fonts?.ready ?? Promise.resolve ()).then (() => {
if (!active)
return
frame = requestAnimationFrame (() => {
frame = requestAnimationFrame (() => {
if (active)
setLayoutReady (true)
})
})
})
return () => {
active = false
cancelAnimationFrame (frame)
}
}, [desktop, density, layoutReady])
useLayoutEffect (() => {
measure (moreVsbl ? -1 : activeIdx)
if (!(desktop))
setMoreVsbl (false)
}, [activeIdx, moreVsbl, desktop, density, locale, animationMode, menuLabels])
useEffect (() => {
const unsubscribe = WikiIdBus.subscribe (setWikiId)
return () => unsubscribe ()
}, [])
useLayoutEffect (() => {
const content = submenuContentRef.current
if (content == null)
return
const update = () => {
setSubmenuHeight (Math.ceil (content.getBoundingClientRect ().height))
}
const observer =
typeof ResizeObserver === 'undefined' ? null : new ResizeObserver (update)
observer?.observe (content)
update ()
addEventListener ('resize', update)
return () => {
observer?.disconnect ()
removeEventListener ('resize', update)
}
}, [activeIdx, moreVsbl, desktop, density, locale])
useEffect (() => {
setMenuOpen (false)
setOpenItemIdx (activeIdx)
}, [activeIdx, location])
const localeSelector = (
<label className="top-nav-menu-link relative flex h-8 shrink-0 items-center gap-1
rounded px-1 focus-within:ring-2">
<Globe aria-hidden="true" size={16}/>
<span className="top-nav-locale-name whitespace-nowrap text-sm">
<LocalisedText language={locale}>{localeName}</LocalisedText>
</span>
<select
aria-label={formatUIString (localeName ?? '', locale)}
value={locale}
disabled={switchingLocale}
className="absolute inset-0 h-full w-full cursor-pointer opacity-0"
onChange={async event => {
const nextLocale = event.target.value
if (isLocaleCode (nextLocale)
&& locales.some (entry => entry.code === nextLocale))
{
const request = ++localeSwitchRequest.current
setSwitchingLocale (true)
try
{
const path = resolveLocaleSwitch == null
? switchLocalePath (location, nextLocale)
: await resolveLocaleSwitch (nextLocale)
if (request === localeSwitchRequest.current
&& currentLocation.current.key === location.key)
navigate (path)
}
catch
{
if (request === localeSwitchRequest.current)
toast ({ title: label ('common:fetchError') })
}
finally
{
if (request === localeSwitchRequest.current)
setSwitchingLocale (false)
}
}
}}>
{locales.map (entry => isLocaleCode (entry.code) && (
<option key={entry.code} value={entry.code}>
{formatUIString (entry.name, entry.code)}
</option>))}
</select>
</label>)
return (
<div ref={layoutRef} className="top-nav-layout w-full min-w-0 shrink-0"
data-desktop={desktop} data-density={density}>
<nav ref={barRef}
data-desktop={desktop ?? undefined} data-density={density}
data-layout-ready={layoutReady}>
<nav ref={barRef} lang={uiMessageLanguage (locale, 'posts')}
className="top-nav-root top-nav-bar flex justify-between items-center w-full">
<div className="top-nav-primary flex shrink-0 items-center h-12">
<PrefetchLink
@@ -307,7 +465,7 @@ const TopNav: FC<Props> = ({ user }) => {
onClick={() => {
scroll (0, 0)
}}>
ぼざクリ タグ広場
{label ('labels.brand')}
</PrefetchLink>
<div ref={navRef}
@@ -323,14 +481,15 @@ const TopNav: FC<Props> = ({ user }) => {
{visibleMenu.map ((item, i) => (
<motion.div
key={item.to}
initial={false}
className="shrink-0 whitespace-nowrap"
{...(animationsOff
? { }
: { layoutId: `menu-${ item.name }` })}
animate={{ opacity: moreVsbl ? 0 : 1 }}
layoutId={`menu-${ menu.indexOf (item) }`}
onLayoutAnimationComplete={() => measure (highlightTargetRef.current)}
animate={layoutReady ? { opacity: moreVsbl ? 0 : 1 } : undefined}
transition={{ opacity: opacityTransition,
layout: topNavTransition }}
style={{ pointerEvents: moreVsbl ? 'none' : 'auto' }}
style={{ pointerEvents: moreVsbl ? 'none' : 'auto',
opacity: layoutReady ? undefined : (moreVsbl ? 0 : 1) }}
onMouseEnter={() => setMoreVsbl (false)}>
<PrefetchLink
to={item.to}
@@ -339,7 +498,9 @@ const TopNav: FC<Props> = ({ user }) => {
}}
className={cn ('top-nav-menu-link relative z-10 flex h-full items-center',
(i === openItemIdx) && 'font-bold')}>
{item.name}
<LocalisedText language={item.nameLanguage ?? 'ja'}>
{item.name}
</LocalisedText>
</PrefetchLink>
</motion.div>))}
<PrefetchLink
@@ -355,58 +516,73 @@ const TopNav: FC<Props> = ({ user }) => {
className={cn ('top-nav-menu-link relative z-10 flex h-full items-center',
'shrink-0 whitespace-nowrap',
(openItemIdx < 0 || moreVsbl) && 'font-bold')}>
その他 &raquo;
<TranslatedText messageKey="more"/>
</PrefetchLink>
</div>
</div>
<TopNavUser user={user}/>
<div className="top-nav-account ml-auto flex min-w-0 items-center gap-2">
<TopNavUser user={user}/>
{localeSelector}
</div>
<button
type="button"
className="top-nav-mobile-toggle shrink-0 whitespace-nowrap
ml-auto border-0 bg-transparent pr-4"
onClick={() => {
setMenuOpen (!(menuOpen))
}}>
{menuOpen ? '×' : 'Menu'}
</button>
<div className="top-nav-mobile-controls ml-auto flex shrink-0 items-center gap-2">
{menuOpen && localeSelector}
<button
type="button"
className="top-nav-mobile-toggle shrink-0 whitespace-nowrap
border-0 bg-transparent pr-4"
aria-label={menuToggleLabel.text}
lang={menuToggleLabel.language}
onClick={() => {
setMenuOpen (!(menuOpen))
}}>
{menuOpen ? '×' : label ('navigation:menuToggle')}
</button>
</div>
</nav>
<AnimatePresence initial={false}>
<motion.div
key="submenu-shell"
{...(animationsOff ? { }
: { layout: true })}
initial={false}
layout
className="top-nav-desktop-submenu relative z-20 shrink-0
overflow-hidden top-nav-submenu"
animate={{ height: submenuHeight }}
animate={{ height: desktop == null ? 'auto' : submenuHeight }}
onMouseLeave={() => {
if (moreVsbl)
setMoreVsbl (false)
}}
transition={{ layout: topNavTransition }}
transition={{ ...topNavTransition, layout: topNavTransition }}
onAnimationComplete={() => {
measure (moreVsbl ? -1 : activeIdx)
measure (highlightTargetRef.current)
}}
onLayoutAnimationComplete={() => {
measure (highlightTargetRef.current)
}}>
<div ref={submenuContentRef} className={cn (moreVsbl && 'top-nav-more-menu')}>
{moreVsbl
? (
moreMenu.map ((item, i) => (
<div key={i} className="relative h-[40px]">
<div className="absolute inset-0 flex items-center px-3
overflow-x-auto whitespace-nowrap">
<div key={i} className="contents">
<motion.div
transition={topNavTransition}
{...(animationsOff
? { }
: ((item.visible ?? true)
? { layoutId: `menu-${ item.name }` }
: { initial: { x: 40, y: -40, opacity: 0 },
animate: { x: 0, y: 0, opacity: 1 },
exit: { x: 40, y: -40, opacity: 0 } }))}
className="z-10 h-full flex shrink-0 items-center px-3 font-bold w-28">
<h2>{item.name}</h2>
{...((item.visible ?? true)
? { layoutId: `menu-${ menu.indexOf (item) }` }
: (animationsOff ? { }
: { initial: { x: 40, y: -40, opacity: 0 },
animate: { x: 0, y: 0, opacity: 1 },
exit: { x: 40, y: -40, opacity: 0 } }))}
onLayoutAnimationComplete={() => measure (highlightTargetRef.current)}
className="z-10 min-w-0 px-3 py-2 font-bold">
<h2>
<LocalisedText language={item.nameLanguage ?? 'ja'}>
{item.name}
</LocalisedText>
</h2>
</motion.div>
<div className="top-nav-more-links flex min-w-0 flex-wrap items-center">
{item.subMenu
.filter (subItem => subItem.visible ?? true)
.map ((subItem, j) => (
@@ -414,76 +590,77 @@ const TopNav: FC<Props> = ({ user }) => {
? (
<motion.div
key={`c-${ i }-${ j }`}
className="min-w-0 max-w-full"
transition={topNavTransition}
{...(animationsOff
? { }
: ((visibleMenu[activeIdx]?.name
=== item.name)
? { layoutId: `submenu-${ item.name }-${ j }` }
: { initial: { y: -40, opacity: 0 },
animate: { y: 0, opacity: 1 },
exit: { y: -40, opacity: 0 } }))}>
{...(visibleMenu[activeIdx] === item
? { layoutId: `submenu-${ menu.indexOf (item) }-${ j }` }
: (animationsOff ? { }
: { initial: { y: -40, opacity: 0 },
animate: { y: 0, opacity: 1 },
exit: { y: -40, opacity: 0 } }))}>
{subItem.component}
</motion.div>)
: (
<motion.div
key={`l-${ i }-${ j }`}
className="min-w-0 max-w-full"
transition={topNavTransition}
{...(animationsOff
? { }
: ((visibleMenu[activeIdx]?.name
=== item.name)
? { layoutId: `submenu-${ item.name }-${ j }` }
: { initial: { y: -40, opacity: 0 },
animate: { y: 0, opacity: 1 },
exit: { y: -40, opacity: 0 } }))}>
{...(visibleMenu[activeIdx] === item
? { layoutId: `submenu-${ menu.indexOf (item) }-${ j }` }
: (animationsOff ? { }
: { initial: { y: -40, opacity: 0 },
animate: { y: 0, opacity: 1 },
exit: { y: -40, opacity: 0 } }))}>
<PrefetchLink
to={subItem.to}
target={subItem.to.slice (0, 2) === '//' ? '_blank' : undefined}
onClick={() => setMoreVsbl (false)}
className="top-nav-menu-link h-full flex items-center px-3">
{subItem.name}
className="top-nav-menu-link flex min-h-[40px] items-center px-3 py-2">
<LocalisedText language={subItem.nameLanguage ?? 'ja'}>
{subItem.name}
</LocalisedText>
</PrefetchLink>
</motion.div>)))}
</div>
</div>)))
: ((visibleMenu[activeIdx]?.subMenu ?? []).length > 0
&& (
<div className="relative h-[40px]">
<div className="relative min-h-[40px]">
{animationsOff
? (
<div className="absolute inset-0 flex items-center px-3
overflow-x-auto whitespace-nowrap">
<div className="flex min-h-[40px] flex-wrap items-center px-3">
{(visibleMenu[activeIdx]?.subMenu ?? [])
.filter (item => item.visible ?? true)
.map ((item, i) => (
'component' in item
? (
<div key={`c-${ i }`}>
<div key={`c-${ i }`} className="min-w-0 max-w-full">
{item.component}
</div>)
: (
<div key={`l-${ i }`}>
<div key={`l-${ i }`} className="min-w-0 max-w-full">
<PrefetchLink
to={item.to}
target={item.to.slice (0, 2) === '//'
? '_blank'
: undefined}
className="top-nav-menu-link h-full flex items-center px-3">
{item.name}
className="top-nav-menu-link flex min-h-[40px]
items-center px-3 py-2">
<LocalisedText language={item.nameLanguage ?? 'ja'}>
{item.name}
</LocalisedText>
</PrefetchLink>
</div>)))}
</div>)
: (
<AnimatePresence initial={false} custom={dir}>
<AnimatePresence initial={false} custom={dir} mode="popLayout">
<motion.div
key={activeIdx}
custom={dir}
variants={{ enter: (d: -1 | 1) => ({ y: d * 24, opacity: 0 }),
centre: { y: 0, opacity: 1 },
exit: (d: -1 | 1) => ({ y: (-d) * 24, opacity: 0 }) }}
className="absolute inset-0 flex items-center px-3
overflow-x-auto whitespace-nowrap"
className="relative flex min-h-[40px] flex-wrap items-center px-3"
initial="enter"
animate="centre"
exit="exit"
@@ -495,27 +672,33 @@ const TopNav: FC<Props> = ({ user }) => {
? (
<motion.div
key={`c-${ i }`}
className="min-w-0 max-w-full"
transition={{ layout: topNavTransition }}
layoutId={`submenu-${ visibleMenu[activeIdx].name }-${ i }`}>
layoutId={`${ activeSubmenuId }-${ i }`}>
{item.component}
</motion.div>)
: (
<motion.div
key={`l-${ i }`}
className="min-w-0 max-w-full"
transition={{ layout: topNavTransition }}
layoutId={`submenu-${ visibleMenu[activeIdx].name }-${ i }`}>
layoutId={`${ activeSubmenuId }-${ i }`}>
<PrefetchLink
to={item.to}
target={item.to.slice (0, 2) === '//'
? '_blank'
: undefined}
className="top-nav-menu-link h-full flex items-center px-3">
{item.name}
className="top-nav-menu-link flex min-h-[40px]
items-center px-3 py-2">
<LocalisedText language={item.nameLanguage ?? 'ja'}>
{item.name}
</LocalisedText>
</PrefetchLink>
</motion.div>)))}
</motion.div>
</AnimatePresence>)}
</div>))}
</div>
</motion.div>
</AnimatePresence>
@@ -540,7 +723,9 @@ const TopNav: FC<Props> = ({ user }) => {
setOpenItemIdx (i)
}
}}>
{item.name}
<LocalisedText language={item.nameLanguage ?? 'ja'}>
{item.name}
</LocalisedText>
</PrefetchLink>
{i === openItemIdx && (
@@ -561,7 +746,9 @@ const TopNav: FC<Props> = ({ user }) => {
? '_blank'
: undefined}
className="top-nav-mobile-row w-full min-h-[36px] flex items-center pl-12">
{subItem.name}
<LocalisedText language={subItem.nameLanguage ?? 'ja'}>
{subItem.name}
</LocalisedText>
</PrefetchLink>)))}
</div>)}
</Fragment>))}
@@ -570,7 +757,7 @@ const TopNav: FC<Props> = ({ user }) => {
className={cn ('top-nav-mobile-row w-full min-h-[40px] flex items-center pl-8',
((openItemIdx < 0)
&& 'top-nav-mobile-active font-bold'))}>
その他 &raquo;
<TranslatedText messageKey="more"/>
</PrefetchLink>
<TopNavUser user={user} sp/>
<Separator/>
@@ -605,7 +792,9 @@ const TopNav: FC<Props> = ({ user }) => {
setOpenItemIdx (i)
}
}}>
{item.name}
<LocalisedText language={item.nameLanguage ?? 'ja'}>
{item.name}
</LocalisedText>
</PrefetchLink>
<AnimatePresence initial={false}>
@@ -639,7 +828,10 @@ const TopNav: FC<Props> = ({ user }) => {
? '_blank'
: undefined}
className="top-nav-mobile-row w-full min-h-[36px] flex items-center pl-12">
{subItem.name}
<LocalisedText
language={subItem.nameLanguage ?? 'ja'}>
{subItem.name}
</LocalisedText>
</PrefetchLink>)))}
</motion.div>)}
</AnimatePresence>
@@ -649,7 +841,7 @@ const TopNav: FC<Props> = ({ user }) => {
className={cn ('top-nav-mobile-row w-full min-h-[40px] flex items-center pl-8',
((openItemIdx < 0)
&& 'top-nav-mobile-active font-bold'))}>
その他 &raquo;
<TranslatedText messageKey="more"/>
</PrefetchLink>
<TopNavUser user={user} sp/>
<Separator/>
+1 -1
ファイルの表示
@@ -17,7 +17,7 @@ describe ('TopNavUser', () => {
expect (screen.getByRole ('link', { name: '山田' })).toHaveAttribute (
'href',
'/users/settings',
'/ja/users/settings',
)
})
+4 -3
ファイルの表示
@@ -1,6 +1,7 @@
import Separator from '@/components/MenuSeparator'
import PrefetchLink from '@/components/PrefetchLink'
import { cn } from '@/lib/utils'
import { useUITranslation } from '@/lib/useUITranslation'
import type { FC } from 'react'
@@ -11,6 +12,7 @@ type Props = { user: User | null,
const TopNavUser: FC<Props> = ({ user, sp }) => {
const { label } = useUITranslation ('shared')
if (!(user))
return
@@ -23,9 +25,8 @@ const TopNavUser: FC<Props> = ({ user, sp }) => {
return (
<>
{sp && <Separator/>}
<PrefetchLink to="/users/settings"
className={className}>
{user.name || '名もなきニジラー'}
<PrefetchLink to="/users/settings" className={className}>
{user.name || label ('labels.anonymous')}
</PrefetchLink>
</>)
}
+23
ファイルの表示
@@ -0,0 +1,23 @@
import LocalisedText from '@/components/LocalisedText'
import { useUITranslation } from '@/lib/useUITranslation'
type Props = {
messageKey: string
namespace?: string
values?: Record<string, string | number>
className?: string }
const TranslatedText = (
{ messageKey, namespace = 'navigation', values, className }: Props,
) => {
const { message } = useUITranslation (namespace)
const result = message (messageKey, values)
return (
<LocalisedText language={result.language} className={className}>
{result.text}
</LocalisedText>)
}
export default TranslatedText
+28 -11
ファイルの表示
@@ -3,16 +3,20 @@ import { useMemo } from 'react'
import ReactMarkdown from 'react-markdown'
import remarkGFM from 'remark-gfm'
import { useLocale } from '@/lib/useLocale'
import { useUITranslation } from '@/lib/useUITranslation'
import PrefetchLink from '@/components/PrefetchLink'
import { wikiKeys } from '@/lib/queryKeys'
import remarkWikiAutoLink from '@/lib/remark-wiki-autolink'
import { fetchWikiPages } from '@/lib/wiki'
import { fetchWikiAutolinkCandidates } from '@/lib/wiki'
import { ainuMDXComponents, LocalisedDocument } from '@/mdx-components'
import type { FC } from 'react'
import type { Components } from 'react-markdown'
type Props = { title: string
body?: string }
body?: string
language?: string }
const mdComponents = { a: (({ href, children }) => (
['/', '.'].some (e => href?.startsWith (e))
@@ -25,20 +29,33 @@ const mdComponents = { a: (({ href, children }) => (
</a>))) } as const satisfies Components
const WikiBody: FC<Props> = ({ title, body }) => {
const WikiBody: FC<Props> = ({ title, body, language = 'und' }) => {
const { message } = useUITranslation ('wiki')
const locale = useLocale ()
const missingPage = message ('missingPage', {
url: `/wiki/new?title=${ encodeURIComponent (title) }` })
const contentLanguage = body ? language : missingPage.language
const matchLanguage = contentLanguage === 'und'
? 'ja' : (contentLanguage === 'ain-Kana' ? 'ain' : contentLanguage)
const { data } = useQuery ({
enabled: Boolean (body),
queryKey: wikiKeys.index ({ }),
queryFn: () => fetchWikiPages ({ }) })
const pageNames = (data ?? []).map (page => page.title).sort ((a, b) => b.length - a.length)
queryKey: wikiKeys.autolinkCandidates (matchLanguage, locale),
queryFn: () => fetchWikiAutolinkCandidates (matchLanguage, locale) })
const remarkPlugins = useMemo (
() => [() => remarkWikiAutoLink (pageNames), remarkGFM], [pageNames])
() => [() => remarkWikiAutoLink (data ?? [], matchLanguage, locale), remarkGFM],
[data, matchLanguage, locale])
const components = {
...((contentLanguage === 'ain' || contentLanguage === 'ain-Kana')
? ainuMDXComponents : { }),
...mdComponents }
return (
<ReactMarkdown components={mdComponents} remarkPlugins={remarkPlugins}>
{body || `このページは存在しません。[新規作成してください](/wiki/new?title=${ encodeURIComponent (title) })。`}
</ReactMarkdown>)
<LocalisedDocument language={contentLanguage}>
<ReactMarkdown components={components} remarkPlugins={remarkPlugins}>
{body || missingPage.text}
</ReactMarkdown>
</LocalisedDocument>)
}
export default WikiBody
export default WikiBody
+2 -2
ファイルの表示
@@ -1,7 +1,7 @@
import type { FC } from 'react'
import type { FC, ReactNode } from 'react'
type Props = { id?: string
messages?: string[] }
messages?: ReactNode[] }
export const FieldError: FC<Props> = ({ id, messages }: Props) => {
+2 -2
ファイルの表示
@@ -1,7 +1,7 @@
import type { FC } from 'react'
import type { FC, ReactNode } from 'react'
type Props = { id?: string
messages?: string[] }
messages?: ReactNode[] }
export const FieldWarning: FC<Props> = ({ id, messages }: Props) => {
+1 -1
ファイルの表示
@@ -16,7 +16,7 @@ type Props = {
onChange: (event: React.ChangeEvent<HTMLInputElement>) => void }
className?: string
label: ReactNode
messages?: string[] }
messages?: ReactNode[] }
const FormField: FC<Props> = ({ children, checkBox, className, label, messages }: Props) => {
+46
ファイルの表示
@@ -0,0 +1,46 @@
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'
type LocalisedTitle = { text: string; language: string }
type Props = {
title?: string | LocalisedTitle
includeSiteName?: boolean
description?: LocalisedTitle
canonical?: string
children?: ReactNode }
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 ?? 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}/>
{pageCanonical != null && <link rel="canonical" href={pageCanonical}/>}
{children}
</Helmet>)
}
export default PageHead
+3 -3
ファイルの表示
@@ -13,11 +13,11 @@ describe ('Pagination', () => {
expect (screen.getByLabelText ('前のページ')).toHaveAttribute (
'href',
'/posts?tags=abc&page=2',
'/ja/posts?tags=abc&page=2',
)
expect (screen.getByLabelText ('次のページ')).toHaveAttribute (
'href',
'/posts?tags=abc&page=4',
'/ja/posts?tags=abc&page=4',
)
expect (screen.getByText ('3')).toHaveAttribute ('aria-current', 'page')
})
@@ -33,6 +33,6 @@ describe ('Pagination', () => {
rerender (<Pagination page={1} totalPages={2}/>)
expect (screen.getByLabelText ('次のページ')).toHaveAttribute ('href', '/tags?page=2')
expect (screen.getByLabelText ('次のページ')).toHaveAttribute ('href', '/ja/tags?page=2')
})
})
+13 -5
ファイルの表示
@@ -1,6 +1,7 @@
import { useLocation } from 'react-router-dom'
import PrefetchLink from '@/components/PrefetchLink'
import { useUITranslation } from '@/lib/useUITranslation'
import type { FC } from 'react'
@@ -49,6 +50,7 @@ const getPages = (
const Pagination: FC<Props> = ({ page, totalPages, siblingCount = 3 }) => {
const location = useLocation ()
const { message } = useUITranslation ()
const buildTo = (p: number) => {
const qs = new URLSearchParams (location.search)
@@ -59,7 +61,9 @@ const Pagination: FC<Props> = ({ page, totalPages, siblingCount = 3 }) => {
const pages = getPages (page, totalPages, siblingCount)
return (
<nav className="mt-4 flex justify-center" aria-label="Pagination">
<nav className="mt-4 flex justify-center"
lang={message ('pagination.label').language}
aria-label={message ('pagination.label').text}>
<div className="flex items-center gap-2">
{(page > 1)
? (
@@ -67,13 +71,15 @@ const Pagination: FC<Props> = ({ page, totalPages, siblingCount = 3 }) => {
<PrefetchLink
className="md:hidden p-2"
to={buildTo (1)}
aria-label="最初のページ">
lang={message ('pagination.first').language}
aria-label={message ('pagination.first').text}>
|&lt;
</PrefetchLink>
<PrefetchLink
className="p-2"
to={buildTo (page - 1)}
aria-label="前のページ">
lang={message ('pagination.previous').language}
aria-label={message ('pagination.previous').text}>
&lt;
</PrefetchLink>
</>)
@@ -106,13 +112,15 @@ const Pagination: FC<Props> = ({ page, totalPages, siblingCount = 3 }) => {
<PrefetchLink
className="p-2"
to={buildTo (page + 1)}
aria-label="次のページ">
lang={message ('pagination.next').language}
aria-label={message ('pagination.next').text}>
&gt;
</PrefetchLink>
<PrefetchLink
className="md:hidden p-2"
to={buildTo (totalPages)}
aria-label="最後のページ">
lang={message ('pagination.last').language}
aria-label={message ('pagination.last').text}>
&gt;|
</PrefetchLink>
</>)
+1 -1
ファイルの表示
@@ -3,7 +3,7 @@ import type { FC } from 'react'
import React, { useState } from 'react'
import { cn } from '@/lib/utils'
type TabProps = { name: string
type TabProps = { name: React.ReactNode
init?: boolean
children: React.ReactNode }
+6 -3
ファイルの表示
@@ -1,8 +1,9 @@
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
import { fireEvent, screen, waitFor } from '@testing-library/react'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import TagInput from '@/components/common/TagInput'
import { buildTag } from '@/test/factories'
import { renderWithProviders as render } from '@/test/render'
const api = vi.hoisted (() => ({
apiGet: vi.fn (),
@@ -26,7 +27,8 @@ describe ('TagInput', () => {
await waitFor (() => {
expect (api.apiGet).toHaveBeenCalledWith (
'/tags/autocomplete',
{ params: { q: '虹夏', nico: '0' } },
{ locale: 'ja', params: { q: '虹夏', nico: '0' },
signal: expect.any (AbortSignal) },
)
})
expect (setValue).toHaveBeenCalledWith ('ぼっち 虹夏')
@@ -53,7 +55,8 @@ describe ('TagInput', () => {
await waitFor (() => {
expect (api.apiGet).toHaveBeenCalledWith (
'/tags/autocomplete',
{ params: { q: '虹夏', nico: '1' } },
{ locale: 'ja', params: { q: '虹夏', nico: '1' },
signal: expect.any (AbortSignal) },
)
})
})
+53 -18
ファイルの表示
@@ -1,5 +1,6 @@
import { useState } from 'react'
import { useEffect, useState } from 'react'
import { useLocale } from '@/lib/useLocale'
import TagSearchBox from '@/components/TagSearchBox'
import { apiGet } from '@/lib/api'
import { inputClass } from '@/lib/utils'
@@ -24,35 +25,69 @@ const TagInput: FC<Props> = ({
value,
setValue,
}) => {
const locale = useLocale ()
const [activeIndex, setActiveIndex] = useState (-1)
const [suggestions, setSuggestions] = useState<Tag[]> ([])
const [request, setRequest] = useState ({ query: '' })
const [result, setResult] = useState<{
locale: string
includeNico: boolean
request: { query: string }
tags: Tag[] }> ({ locale, includeNico, request, tags: [] })
const suggestions = (result.locale === locale && result.includeNico === includeNico
&& result.request === request
&& request.query === value.trim ().split (' ').at (-1)) ? result.tags : []
const [suggestionsVsbl, setSuggestionsVsbl] = useState (false)
// TODO: TagSearch からのコピペのため,共通化を考へる.
const whenChanged = async (ev: ChangeEvent<HTMLInputElement>) => {
const whenChanged = (ev: ChangeEvent<HTMLInputElement>) => {
setValue (ev.target.value)
const q = ev.target.value.trim ().split (' ').at (-1)
if (!(q))
{
setSuggestions ([])
return
}
const data = await apiGet<Tag[]> ('/tags/autocomplete', { params: {
q,
nico: includeNico ? '1' : '0' } })
const nextSuggestions = data.filter (t => t.postCount > 0)
setSuggestions (nextSuggestions)
setSuggestionsVsbl (nextSuggestions.length > 0)
const query = ev.target.value.trim ().split (' ').at (-1) ?? ''
setRequest ({ query })
setActiveIndex (-1)
setSuggestionsVsbl (query !== '')
}
useEffect (() => {
if (request.query === '')
return
const controller = new AbortController ()
let active = true
void (async () => {
try
{
const tags = await apiGet<Tag[]> ('/tags/autocomplete', {
locale, signal: controller.signal,
params: { q: request.query, nico: includeNico ? '1' : '0' } })
if (active)
{
setResult ({ locale, includeNico, request,
tags: tags.filter (tag => tag.postCount > 0) })
}
}
catch
{
if (active)
setResult ({ locale, includeNico, request, tags: [] })
}
}) ()
return () => {
active = false
controller.abort ()
}
}, [request, locale, includeNico])
useEffect (() => {
setActiveIndex (-1)
setSuggestionsVsbl (false)
setRequest ({ query: '' })
}, [locale, includeNico])
// TODO: TagSearch からのコピペのため,共通化を考へる.
const handleTagSelect = (tag: Tag) => {
const parts = value?.split (' ')
parts[parts.length - 1] = tag.name
setValue (parts.join (' ') + ' ')
setSuggestions ([])
setRequest ({ query: '' })
setActiveIndex (-1)
}
+4 -3
ファイルの表示
@@ -1,6 +1,7 @@
import { useCallback, useEffect } from 'react'
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
import { describe, expect, it, vi } from 'vitest'
import { MemoryRouter } from 'react-router-dom'
import DialogueProvider from '@/components/dialogues/DialogueProvider'
import useDialogue from '@/lib/dialogues/useDialogue'
@@ -49,7 +50,7 @@ describe ('DialogueProvider', () => {
</button>)
}
render (<DialogueProvider><Launcher/></DialogueProvider>)
render (<MemoryRouter><DialogueProvider><Launcher/></DialogueProvider></MemoryRouter>)
fireEvent.click (screen.getByRole ('button', { name: '開く' }))
expect (screen.getByText ('一件目')).toBeInTheDocument ()
@@ -73,7 +74,7 @@ describe ('DialogueProvider', () => {
</button>)
}
render (<DialogueProvider><Launcher/></DialogueProvider>)
render (<MemoryRouter><DialogueProvider><Launcher/></DialogueProvider></MemoryRouter>)
fireEvent.click (screen.getByRole ('button', { name: '開く' }))
const dialogue = screen.getByRole ('dialog')
@@ -101,7 +102,7 @@ describe ('DialogueProvider', () => {
</button>)
}
render (<DialogueProvider><Launcher/></DialogueProvider>)
render (<MemoryRouter><DialogueProvider><Launcher/></DialogueProvider></MemoryRouter>)
fireEvent.click (screen.getByRole ('button', { name: '開く' }))
const action = await screen.findByRole ('button', { name: '左操作' })
fireEvent.click (action)
+20 -14
ファイルの表示
@@ -8,6 +8,7 @@ import { Dialog,
DialogHeader,
DialogTitle } from '@/components/ui/dialog'
import { DialogueContext } from '@/lib/dialogues/useDialogue'
import { useUITranslation } from '@/lib/useUITranslation'
import type { FC, ReactNode } from 'react'
import type { AlertOptions,
@@ -42,6 +43,7 @@ type Props = { children: ReactNode }
const DialogueProvider: FC<Props> = ({ children }) => {
const { label } = useUITranslation ('shared')
const [queue, setQueue] = useState<DialogueRequest[]> ([])
const [pendingIds, setPendingIds] = useState<number[]> ([])
const [formActions, setFormActions] = useState<Record<number, DialogueFormAction[]>> ({ })
@@ -88,8 +90,9 @@ const DialogueProvider: FC<Props> = ({ children }) => {
})
setPendingIds (current => current.filter (pendingId => pendingId !== id))
setFormActions (current => {
const { [id]: _, ...rest } = current
return rest
const rest = { ...current }
delete rest[id]
return rest
})
delete formControls.current[id]
}, [])
@@ -178,6 +181,8 @@ const DialogueProvider: FC<Props> = ({ children }) => {
closeRequest (active.id, active.kind !== 'confirm' && null)
}}>
<DialogContent
closeLabel={(active.kind === 'confirm' || active.kind === 'choice')
? active.options.closeLabel : undefined}
className={
active.kind === 'form'
? `flex max-h-[calc(100dvh-1rem)] ${ active.options.size === 'large'
@@ -217,9 +222,9 @@ const DialogueProvider: FC<Props> = ({ children }) => {
className="shrink-0 flex-col gap-2 pt-4
md:flex-row md:justify-between md:gap-0 md:space-x-0">
<div className="flex w-full flex-col gap-2 md:w-auto md:flex-row">
{startActions.map (action => (
{startActions.map ((action, index) => (
<Button
key={action.label}
key={index}
className="w-full md:w-auto"
variant={action.variant === 'danger'
? 'destructive'
@@ -239,12 +244,12 @@ const DialogueProvider: FC<Props> = ({ children }) => {
onClick={() => closeRequest (active.id)}
disabled={pendingIds.includes (active.id)
|| nestedConfirm?.parentId === active.id}>
{active.options.cancelText ?? '取消'}
{active.options.cancelText ?? label ('actions.cancel')}
</Button>
{endActions.map (action => (
{endActions.map ((action, index) => (
<Button
key={action.label}
key={index}
className="w-full md:w-auto"
variant={action.variant === 'danger'
? 'destructive'
@@ -275,7 +280,7 @@ const DialogueProvider: FC<Props> = ({ children }) => {
<Button
variant="outline"
onClick={() => closeRequest (active.id, false)}>
{active.options.cancelText ?? '取消'}
{active.options.cancelText ?? label ('actions.cancel')}
</Button>
<Button
@@ -283,13 +288,13 @@ const DialogueProvider: FC<Props> = ({ children }) => {
? 'destructive'
: 'default'}
onClick={() => closeRequest (active.id, true)}>
{active.options.confirmText ?? '確定'}
{active.options.confirmText ?? label ('dialogue.confirm')}
</Button>
</>)}
{active.kind === 'alert' && (
<Button onClick={() => closeRequest (active.id)}>
{active.options.okText ?? '確定'}
{active.options.okText ?? label ('dialogue.confirm')}
</Button>)}
{active.kind === 'choice' && (
@@ -297,7 +302,7 @@ const DialogueProvider: FC<Props> = ({ children }) => {
<Button
variant="outline"
onClick={() => closeRequest (active.id, null)}>
{active.options.cancelText ?? '取消'}
{active.options.cancelText ?? label ('actions.cancel')}
</Button>
{active.options.choices.map (choice => (
@@ -322,7 +327,8 @@ const DialogueProvider: FC<Props> = ({ children }) => {
if (!(open))
closeNestedConfirm (false)
}}>
<DialogContent className="px-6 pb-6 pt-7">
<DialogContent className="px-6 pb-6 pt-7"
closeLabel={nestedConfirm.options.closeLabel}>
<DialogHeader className="pl-8">
<DialogTitle>{nestedConfirm.options.title}</DialogTitle>
@@ -336,7 +342,7 @@ const DialogueProvider: FC<Props> = ({ children }) => {
<Button
variant="outline"
onClick={() => closeNestedConfirm (false)}>
{nestedConfirm.options.cancelText ?? '取消'}
{nestedConfirm.options.cancelText ?? label ('actions.cancel')}
</Button>
<Button
@@ -344,7 +350,7 @@ const DialogueProvider: FC<Props> = ({ children }) => {
? 'destructive'
: 'default'}
onClick={() => closeNestedConfirm (true)}>
{nestedConfirm.options.confirmText ?? '確定'}
{nestedConfirm.options.confirmText ?? label ('dialogue.confirm')}
</Button>
</DialogFooter>
</DialogContent>
+2 -1
ファイルの表示
@@ -1,8 +1,9 @@
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
import { fireEvent, screen, waitFor } from '@testing-library/react'
import { HelmetProvider } from 'react-helmet-async'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import SidebarComponent from '@/components/layout/SidebarComponent'
import { renderWithProviders as render } from '@/test/render'
const sidebarWidth = (element: Element | null): string =>
+5 -7
ファイルの表示
@@ -4,6 +4,7 @@ import { Helmet } from 'react-helmet-async'
import { clientAnimationTransition } from '@/lib/clientAnimation'
import { useClientBehaviourSettings } from '@/lib/useClientBehaviourSettings'
import { cn } from '@/lib/utils'
import { useUITranslation } from '@/lib/useUITranslation'
import type { CSSProperties, FC, MouseEvent, PointerEvent, ReactNode } from 'react'
import { useCallback, useEffect, useRef, useState } from 'react'
@@ -70,6 +71,7 @@ const SidebarComponent: FC<Props> = ({
sidebarKey,
side = 'left',
}) => {
const { message } = useUITranslation ('shared')
const behaviourSettings = useClientBehaviourSettings ()
const animationMode = behaviourSettings.animation ?? 'normal'
const layoutTransition = clientAnimationTransition (
@@ -101,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)
@@ -341,7 +338,8 @@ const SidebarComponent: FC<Props> = ({
<div
role="separator"
aria-orientation="vertical"
aria-label="サイドバー幅"
aria-label={message ('sidebarWidth').text}
lang={message ('sidebarWidth').language}
onPointerDown={handlePointerDown}
onPointerMove={handlePointerMove}
onPointerUp={handlePointerEnd}
+5 -4
ファイルの表示
@@ -1,10 +1,11 @@
import PostOriginalCreatedTimeField from '@/components/PostOriginalCreatedTimeField'
import PostTagsField from '@/components/posts/PostTagsField'
import PostTextField from '@/components/posts/PostTextField'
import TranslatedText from '@/components/TranslatedText'
import type { FC, ReactNode } from 'react'
type TextMessages = string[] | undefined
type TextMessages = ReactNode[] | undefined
type CoreField = {
value: string
@@ -42,7 +43,7 @@ type PostCoreDataFieldsProps = {
rows?: number }
parentPostIds: CoreField }
const groupedMessages = (...values: (TextMessages | null | undefined)[]): string[] =>
const groupedMessages = (...values: (TextMessages | null | undefined)[]): ReactNode[] =>
[...new Set (values.flatMap (value => value ?? []))]
@@ -54,7 +55,7 @@ const PostCoreDataFields: FC<PostCoreDataFieldsProps> = (
) => (
<>
<PostTextField
label="タイトル"
label={<TranslatedText namespace="shared" messageKey="labels.title"/>}
value={title.value}
disabled={title.disabled}
warnings={title.warnings}
@@ -82,7 +83,7 @@ const PostCoreDataFields: FC<PostCoreDataFieldsProps> = (
rows={tags.rows}/>
<PostTextField
label="親投稿"
label={<TranslatedText namespace="shared" messageKey="labels.parentPosts"/>}
value={parentPostIds.value}
disabled={parentPostIds.disabled}
warnings={parentPostIds.warnings}
+3 -2
ファイルの表示
@@ -1,11 +1,12 @@
import PostCoreDataFields from '@/components/posts/PostCoreDataFields'
import PostTextField from '@/components/posts/PostTextField'
import TranslatedText from '@/components/TranslatedText'
import type { FC, ReactNode } from 'react'
import type { PostCoreDataFieldsProps } from '@/components/posts/PostCoreDataFields'
type TextMessages = string[] | undefined
type TextMessages = ReactNode[] | undefined
type Props = {
url: {
@@ -29,7 +30,7 @@ const PostCreationDataFields: FC<Props> = (
) => (
<>
<PostTextField
label="URL"
label={<TranslatedText namespace="shared" messageKey="url"/>}
type={url.type}
value={url.value}
disabled={url.disabled}
+2 -1
ファイルの表示
@@ -1,7 +1,8 @@
import { render, screen } from '@testing-library/react'
import { screen } from '@testing-library/react'
import { describe, expect, it, vi } from 'vitest'
import { buildPostImportRow } from '@/test/postImportFactories'
import { renderWithProviders as render } from '@/test/render'
import type { DialogueFormControls } from '@/lib/dialogues/useDialogue'
+5 -5
ファイルの表示
@@ -1,11 +1,12 @@
import PostTextField from '@/components/posts/PostTextField'
import TranslatedText from '@/components/TranslatedText'
import type { FC } from 'react'
import type { FC, ReactNode } from 'react'
type Props = {
value: string
onChange: (value: string) => void
errors?: string[]
errors?: ReactNode[]
disabled?: boolean }
@@ -16,12 +17,11 @@ const PostDurationField: FC<Props> = (
disabled },
) => (
<PostTextField
label="動画時間"
label={<TranslatedText namespace="shared" messageKey="labels.duration"/>}
value={value}
onChange={onChange}
errors={errors}
disabled={disabled}
type="text"/>
)
type="text"/>)
export default PostDurationField
+3 -3
ファイルの表示
@@ -1,13 +1,13 @@
import PostFormTagsArea from '@/components/PostFormTagsArea'
import FieldWarning from '@/components/common/FieldWarning'
import type { ComponentPropsWithoutRef, FC } from 'react'
import type { ComponentPropsWithoutRef, FC, ReactNode } from 'react'
type Props = Omit<ComponentPropsWithoutRef<'textarea'>, 'value' | 'onChange'> & {
tags: string
setTags: (tags: string) => void
warnings?: string[]
errors?: string[] }
warnings?: ReactNode[]
errors?: ReactNode[] }
const PostTagsField: FC<Props> = (
+3 -3
ファイルの表示
@@ -5,11 +5,11 @@ import { inputClass } from '@/lib/utils'
import type { FC, ReactNode } from 'react'
type Props = {
label: string
label: ReactNode
value: string
onChange: (value: string) => void
warnings?: string[]
errors?: string[]
warnings?: ReactNode[]
errors?: ReactNode[]
disabled?: boolean
type?: string
placeholder?: string
+2 -1
ファイルの表示
@@ -1,7 +1,8 @@
import { fireEvent, render, screen } from '@testing-library/react'
import { fireEvent, screen } from '@testing-library/react'
import { describe, expect, it } from 'vitest'
import PostThumbnailPreview from '@/components/posts/PostThumbnailPreview'
import { renderWithProviders as render } from '@/test/render'
describe ('PostThumbnailPreview', () => {
it ('keeps an existing blob preview URL unchanged for normal post forms', () => {
+4 -2
ファイルの表示
@@ -1,6 +1,7 @@
import { useEffect, useState } from 'react'
import { cn } from '@/lib/utils'
import { useUITranslation } from '@/lib/useUITranslation'
import type { FC } from 'react'
@@ -15,10 +16,11 @@ type Props = {
const PostThumbnailPreview: FC<Props> = (
{ url,
file,
alt = 'サムネール',
alt,
className = 'h-16 w-16',
referrerPolicy },
) => {
const { message } = useUITranslation ('shared')
const [failed, setFailed] = useState (false)
const [fileUrl, setFileUrl] = useState<string | null> (null)
@@ -55,7 +57,7 @@ const PostThumbnailPreview: FC<Props> = (
return (
<img
src={resolvedUrl}
alt={alt}
alt={alt ?? message ('labels.thumbnail').text}
referrerPolicy={referrerPolicy}
className={cn (className, 'rounded border border-border object-cover')}
onError={() => setFailed (true)}/>)
+12 -8
ファイルの表示
@@ -1,8 +1,10 @@
import { act, render, screen, waitFor } from '@testing-library/react'
import { act, screen, waitFor } from '@testing-library/react'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import PostImportRowForm from '@/components/posts/import/PostImportRowForm'
import { buildPostImportRow } from '@/test/postImportFactories'
import { renderWithProviders as render } from '@/test/render'
import { textContentOf } from '@/test/text'
import type { DialogueFormAction, DialogueFormControls } from '@/lib/dialogues/useDialogue'
@@ -37,7 +39,7 @@ describe ('PostImportRowForm', () => {
const titleInput = screen.getByDisplayValue ('manual title')
await waitFor (() => expect (actions.at (-1)?.length).toBe (2))
const reset = actions.at (-1)?.find (action => action.label === '変更をリセット')
const reset = actions.at (-1)?.find (action => textContentOf (action.label) === '変更をリセット')
expect (reset).toMatchObject ({ placement: 'start', variant: 'danger', disabled: false })
await act (async () => {
await reset?.onSelect ()
@@ -47,7 +49,7 @@ describe ('PostImportRowForm', () => {
expect (titleInput).toHaveValue ('')
expect (onSave).not.toHaveBeenCalled ()
const save = actions.at (-1)?.find (action => action.label === '編輯内容を保存')
const save = actions.at (-1)?.find (action => textContentOf (action.label) === '編輯内容を保存')
await act (async () => {
await save?.onSelect ()
})
@@ -76,7 +78,7 @@ describe ('PostImportRowForm', () => {
await waitFor (() => expect (actions.length).toBe (2))
await act (async () => {
await actions.find (action => action.label === '変更をリセット')?.onSelect ()
await actions.find (action => textContentOf (action.label) === '変更をリセット')?.onSelect ()
})
expect (screen.getByDisplayValue ('manual title')).toBeInTheDocument ()
@@ -124,7 +126,7 @@ describe ('PostImportRowForm', () => {
await waitFor (() => expect (actions.length).toBe (2))
await act (async () => {
void actions.find (action => action.label === '編輯内容を保存')?.onSelect ()
void actions.find (action => textContentOf (action.label) === '編輯内容を保存')?.onSelect ()
})
expect (onSave).toHaveBeenCalledWith ({
@@ -171,7 +173,7 @@ describe ('PostImportRowForm', () => {
expect (screen.getByDisplayValue ('tag1')).toBeInTheDocument ()
await act (async () => {
await actions.find (action => action.label === '編輯内容を保存')?.onSelect ()
await actions.find (action => textContentOf (action.label) === '編輯内容を保存')?.onSelect ()
})
expect (onSave).toHaveBeenCalledWith ({
@@ -218,7 +220,8 @@ describe ('PostImportRowForm', () => {
onSave={vi.fn ()}/>)
await waitFor (() => expect (actions.length).toBe (2))
expect (actions.find (action => action.label === '変更をリセット')?.disabled).toBe (false)
const reset = actions.find (action => textContentOf (action.label) === '変更をリセット')
expect (reset?.disabled).toBe (false)
})
it (
@@ -249,7 +252,8 @@ describe ('PostImportRowForm', () => {
let savePromise: Promise<boolean | void> | undefined
await act (async () => {
savePromise = actions.find (action => action.label === '編輯内容を保存')?.onSelect ()
const save = actions.find (action => textContentOf (action.label) === '編輯内容を保存')
savePromise = save?.onSelect ()
})
await waitFor (() => {
+24 -19
ファイルの表示
@@ -7,8 +7,11 @@ import PostDurationField from '@/components/posts/PostDurationField'
import PostTextField from '@/components/posts/PostTextField'
import PostImportThumbnailPreview from '@/components/posts/import/PostImportThumbnailPreview'
import { hasThumbnailBaseValue, hasVideoTag } from '@/lib/postImportRows'
import { renderPostImportMessage } from '@/components/posts/import/postImportRowStatus'
import { useLocale } from '@/lib/useLocale'
import { useUITranslation } from '@/lib/useUITranslation'
import type { FC } from 'react'
import type { FC, ReactNode } from 'react'
import type { DialogueFormControls } from '@/lib/dialogues/useDialogue'
import type { PostImportEditableDraft, PostImportRow } from '@/lib/postImportTypes'
@@ -47,9 +50,6 @@ const buildResetDraft = (row: PostImportRow): Draft => ({
duration: String (row.resetSnapshot.attributes.duration ?? ''),
thumbnailFile: undefined })
const groupedMessages = (...values: (string[] | undefined)[]): string[] =>
[...new Set (values.flatMap (value => value ?? []))]
const sameDraft = (left: Draft, right: Draft): boolean =>
left.url === right.url
&& left.title === right.title
@@ -98,6 +98,11 @@ const PostImportRowForm: FC<Props> = (
controls,
onSave },
) => {
const locale = useLocale ()
const { label } = useUITranslation ('imports')
const groupedMessages = (...values: (string[] | undefined)[]): ReactNode[] =>
[...new Set (values.flatMap (value => value ?? []))]
.map (value => renderPostImportMessage (value, locale))
const [draft, setDraft] = useState<Draft> (() => buildDraft (row))
const [messageRow, setMessageRow] = useState<PostImportRow | null> (null)
const [saving, setSaving] = useState (false)
@@ -144,9 +149,9 @@ const PostImportRowForm: FC<Props> = (
return false
const confirmed = await controls.confirm ({
title: '変更をリセットしますか?',
confirmText: 'リセット',
cancelText: '取消',
title: label ('shared:messages.resetChanges'),
confirmText: label ('shared:actions.reset'),
cancelText: label ('shared:actions.cancel'),
variant: 'danger' })
if (!(confirmed))
return false
@@ -156,7 +161,7 @@ const PostImportRowForm: FC<Props> = (
setMessageRow (null)
setCommittedThumbnailBase (resetDraft.thumbnailBase)
return false
}, [controls, resetDisabled, resetDraft])
}, [controls, resetDisabled, resetDraft, label])
const save = useCallback (async (): Promise<boolean> => {
setSaving (true)
@@ -178,16 +183,16 @@ const PostImportRowForm: FC<Props> = (
useEffect (() => {
controls.setActions ([{
label: '変更をリセット',
label: label ('review.reset'),
placement: 'start',
variant: 'danger',
disabled: resetDisabled,
onSelect: reset },
{
label: '編輯内容を保存',
label: label ('review.save'),
disabled: saving,
onSelect: save }])
}, [controls, resetDisabled, reset, save, saving])
}, [controls, resetDisabled, reset, save, saving, label])
return (
<>
@@ -210,17 +215,17 @@ const PostImportRowForm: FC<Props> = (
value: draft.url,
onChange: value => update ('url', value),
disabled: saving,
warnings: displayRow.fieldWarnings.url,
warnings: groupedMessages (displayRow.fieldWarnings.url),
errors: groupedMessages (
displayRow.validationErrors.url,
displayRow.importErrors?.url) }}
thumbnailField={
<>
<PostTextField
label="サムネール"
label={label ('shared:labels.thumbnail')}
value={draft.thumbnailBase}
disabled={saving}
warnings={currentThumbnailWarnings}
warnings={groupedMessages (currentThumbnailWarnings)}
errors={groupedMessages (
displayRow.validationErrors.thumbnailBase,
displayRow.importErrors?.thumbnailBase)}
@@ -244,7 +249,7 @@ const PostImportRowForm: FC<Props> = (
value: draft.title,
onChange: value => update ('title', value),
disabled: saving,
warnings: displayRow.fieldWarnings.title,
warnings: groupedMessages (displayRow.fieldWarnings.title),
errors: groupedMessages (
displayRow.validationErrors.title,
displayRow.importErrors?.title) },
@@ -270,7 +275,7 @@ const PostImportRowForm: FC<Props> = (
value: draft.tags,
onChange: value => update ('tags', value),
disabled: saving,
warnings: displayRow.fieldWarnings.tags,
warnings: groupedMessages (displayRow.fieldWarnings.tags),
errors: groupedMessages (
displayRow.validationErrors.tags,
displayRow.importErrors?.tags),
@@ -293,9 +298,9 @@ const PostImportRowForm: FC<Props> = (
displayRow.validationErrors.videoMs,
displayRow.importErrors?.videoMs)}/>)
: null}/>
<FieldWarning messages={displayRow.baseWarnings}/>
<FieldError messages={displayRow.validationErrors.base}/>
<FieldError messages={displayRow.importErrors?.base}/>
<FieldWarning messages={groupedMessages (displayRow.baseWarnings)}/>
<FieldError messages={groupedMessages (displayRow.validationErrors.base)}/>
<FieldError messages={groupedMessages (displayRow.importErrors?.base)}/>
</div>
</div>
</div>
+25 -16
ファイルの表示
@@ -3,7 +3,11 @@ import PostImportTagLinks from '@/components/posts/import/PostImportTagLinks'
import { Button } from '@/components/ui/button'
import PostImportThumbnailPreview from '@/components/posts/import/PostImportThumbnailPreview'
import PostImportStatusBadge from '@/components/posts/import/PostImportStatusBadge'
import { displayPostImportStatus } from '@/components/posts/import/postImportRowStatus'
import { displayPostImportStatus,
renderPostImportMessage } from '@/components/posts/import/postImportRowStatus'
import LocalisedText from '@/components/LocalisedText'
import { useLocale } from '@/lib/useLocale'
import { useUITranslation } from '@/lib/useUITranslation'
import {
canEditReviewRow,
canRetryResultRow,
@@ -14,6 +18,7 @@ import { cn, originalCreatedAtString } from '@/lib/utils'
import type { FC } from 'react'
import type { PostImportRow } from '@/lib/postImportTypes'
import type { LocaleCode } from '@/lib/locales'
type Props = {
row: PostImportRow
@@ -33,10 +38,10 @@ const summaryWarning = (row: PostImportRow): string | null =>
?? row.baseWarnings?.[0]
?? null
const summaryDate = (row: PostImportRow): string =>
const summaryDate = (row: PostImportRow, locale: LocaleCode): string =>
originalCreatedAtString (
row.attributes.originalCreatedFrom?.toString () ?? null,
row.attributes.originalCreatedBefore?.toString () ?? null)
row.attributes.originalCreatedBefore?.toString () ?? null, locale)
const PostImportRowSummary: FC<Props> = (
@@ -52,6 +57,10 @@ const PostImportRowSummary: FC<Props> = (
showActions = true,
showSkipToggle = false },
) => {
const locale = useLocale ()
const { label } = useUITranslation ('imports')
const localisedRowMessages = rowMessages?.map (value =>
renderPostImportMessage (value, locale))
const warning = summaryWarning (row)
const displayStatus = displayPostImportStatus (row)
const editVisible = onEdit != null
@@ -69,7 +78,7 @@ const PostImportRowSummary: FC<Props> = (
checked={skipChecked}
onChange={event => onToggleSkip?.(event.target.checked)}
disabled={skipDisabled === true}/>
<span>スキップ</span>
<span>{label ('shared:actions.skip')}</span>
</label>)
: null
@@ -96,17 +105,17 @@ const PostImportRowSummary: FC<Props> = (
</div>
<PostImportTagLinks tags={row.displayTags}/>
<div className="text-xs text-neutral-500 dark:text-neutral-400">
{summaryDate (row)}
<LocalisedText language={locale}>{summaryDate (row, locale)}</LocalisedText>
</div>
{showDuration && (
<div className="text-xs text-neutral-500 dark:text-neutral-400">
動画時間 {duration}
{label ('review.duration', { duration })}
</div>)}
{warning && (
<div className="text-xs text-amber-700 dark:text-amber-200">
{warning}
{renderPostImportMessage (warning, locale)}
</div>)}
<FieldError messages={rowMessages}/>
<FieldError messages={localisedRowMessages}/>
</div>
<div className="space-y-1">
{displayStatus != null && <PostImportStatusBadge value={displayStatus}/>}
@@ -120,7 +129,7 @@ const PostImportRowSummary: FC<Props> = (
variant="outline"
onClick={onEdit}
disabled={editDisabled === true || !(editAllowed)}>
編輯
{label ('shared:actions.edit')}
</Button>)}
{showActions && retryAllowed && (
<Button
@@ -128,7 +137,7 @@ const PostImportRowSummary: FC<Props> = (
variant="outline"
onClick={onRetry}
disabled={retryDisabled === true}>
再試行
{label ('shared:actions.retry')}
</Button>)}
</div>
</div>
@@ -156,17 +165,17 @@ const PostImportRowSummary: FC<Props> = (
</div>
<PostImportTagLinks tags={row.displayTags}/>
<div className="text-xs text-neutral-500 dark:text-neutral-400">
{summaryDate (row)}
<LocalisedText language={locale}>{summaryDate (row, locale)}</LocalisedText>
</div>
{showDuration && (
<div className="text-xs text-neutral-500 dark:text-neutral-400">
動画時間 {duration}
{label ('review.duration', { duration })}
</div>)}
{warning && (
<div className="text-xs text-amber-700 dark:text-amber-200">
{warning}
{renderPostImportMessage (warning, locale)}
</div>)}
<FieldError messages={rowMessages}/>
<FieldError messages={localisedRowMessages}/>
{skipControl}
</div>
</div>
@@ -179,7 +188,7 @@ const PostImportRowSummary: FC<Props> = (
variant="outline"
onClick={onEdit}
disabled={editDisabled === true || !(editAllowed)}>
編輯
{label ('shared:actions.edit')}
</Button>)}
{retryAllowed && (
<Button
@@ -188,7 +197,7 @@ const PostImportRowSummary: FC<Props> = (
variant="outline"
onClick={onRetry}
disabled={retryDisabled === true}>
再試行
{label ('shared:actions.retry')}
</Button>)}
</div>)}
</div>
+2 -9
ファイルの表示
@@ -1,4 +1,5 @@
import StatusBadge from '@/components/common/StatusBadge'
import TranslatedText from '@/components/TranslatedText'
import type { FC } from 'react'
@@ -8,14 +9,6 @@ import type { PostImportBadgeValue } from '@/components/posts/import/postImportR
type Props = {
value: PostImportBadgeValue }
const LABELS: Record<PostImportBadgeValue, string> = {
ready: '登録可能',
error: '登録不可',
warning: '警告',
skipped: 'スキップ',
created: '登録済み',
failed: '登録失敗' }
const TONES: Record<PostImportBadgeValue, StatusBadgeTone> = {
ready: 'success',
error: 'warning',
@@ -27,7 +20,7 @@ const TONES: Record<PostImportBadgeValue, StatusBadgeTone> = {
const PostImportStatusBadge: FC<Props> = ({ value }) => (
<StatusBadge tone={TONES[value]}>
{LABELS[value]}
<TranslatedText namespace="imports" messageKey={`status.${ value }`}/>
</StatusBadge>)
export default PostImportStatusBadge
+2 -1
ファイルの表示
@@ -1,7 +1,8 @@
import { fireEvent, render, screen } from '@testing-library/react'
import { fireEvent, screen } from '@testing-library/react'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import PostImportThumbnailPreview from '@/components/posts/import/PostImportThumbnailPreview'
import { renderWithProviders as render } from '@/test/render'
describe ('PostImportThumbnailPreview', () => {
beforeEach (() => {
+1 -1
ファイルの表示
@@ -12,7 +12,7 @@ type Props = {
const PostImportThumbnailPreview: FC<Props> = (
{ url,
file,
alt = 'サムネール',
alt,
className = 'h-16 w-16' },
) => (
<PostThumbnailPreview
+41
ファイルの表示
@@ -1,3 +1,10 @@
import { createElement } from 'react'
import LocalisedText from '@/components/LocalisedText'
import { i18n, translateUIMessage } from '@/lib/i18n'
import type { ReactNode } from 'react'
import type { PostImportRow } from '@/lib/postImportTypes'
export type PostImportDisplayStatus =
@@ -10,6 +17,40 @@ export type PostImportDisplayStatus =
export type PostImportBadgeValue = PostImportDisplayStatus
const IMPORT_MESSAGE_KEYS = [
'validation.duplicateUrl', 'validation.processingFailed',
'validation.tooMany', 'validation.urlTooLong', 'validation.invalidUrl',
'validation.protocol', 'validation.duplicateRow', 'storage.readFailed',
'storage.writeFailed', 'storage.clearFailed', 'source.urlRequired',
'shared:labels.thumbnailMissing']
// Keep stored and compared messages intact; translate only recognised UI messages at display time.
export const renderPostImportMessage = (value: string, locale: string): ReactNode => {
for (const reference of IMPORT_MESSAGE_KEYS)
{
const [namespace, key] =
reference.includes (':')
? reference.split (':')
: ['imports', reference]
const template: unknown = i18n.getResource ('ja', namespace, key)
if (typeof template !== 'string')
continue
const names = [...template.matchAll (/\{\{(\w+)\}\}/g)].map (match => match[1])
const pattern = template.split (/(\{\{\w+\}\})/).map (part =>
part.startsWith ('{{')
? '(\\d+)'
: part.replace (/[.*+?^${}()|[\]\\]/g, '\\$&')).join ('')
const match = value.match (new RegExp (`^${pattern}$`))
if (match == null)
continue
const values = Object.fromEntries (names.map ((name, index) => [name, match[index + 1]]))
const result = translateUIMessage (key, namespace, locale, values)
return createElement (LocalisedText, { language: result.language, children: result.text })
}
return value
}
const hasWarnings = (row: PostImportRow): boolean =>
Object.values (row.fieldWarnings ?? { }).some (messages => messages.length > 0)
|| row.baseWarnings.length > 0
+40 -29
ファイルの表示
@@ -3,7 +3,10 @@
import * as DialogPrimitive from '@radix-ui/react-dialog'
import { X } from 'lucide-react'
import * as React from 'react'
import { useTranslation } from 'react-i18next'
import LocalisedText from '@/components/LocalisedText'
import { translateUIMessage } from '@/lib/i18n'
import { cn } from '@/lib/utils'
const Dialog = DialogPrimitive.Root
@@ -32,37 +35,45 @@ DialogOverlay.displayName = DialogPrimitive.Overlay.displayName
const DialogContent = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Content>
& { closeLabel?: React.ReactNode }
>(
({ className, children, ...props }, ref) => (
<DialogPortal>
<DialogOverlay />
<DialogPrimitive.Content
ref={ref}
className={cn (
'fixed left-[50%] top-[50%] z-50 grid w-[calc(100%-2rem)] max-w-lg',
'translate-x-[-50%] translate-y-[-50%]',
'gap-5 rounded-2xl border border-border',
'bg-background p-6 text-foreground shadow-2xl',
'duration-200',
'data-[state=open]:animate-in data-[state=closed]:animate-out',
'data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0',
'data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95',
className)}
{...props}>
{children}
<DialogPrimitive.Close
({ className, children, closeLabel, ...props }, ref) => {
const { i18n } = useTranslation ('shared')
const close = translateUIMessage ('dialogue.close', 'shared', i18n.language)
return (
<DialogPortal>
<DialogOverlay/>
<DialogPrimitive.Content
ref={ref}
className={cn (
'absolute left-4 top-4 rounded-full p-1',
'text-slate-500 transition-colors',
'hover:bg-slate-200 hover:text-slate-900',
'dark:text-slate-400 dark:hover:bg-slate-700 dark:hover:text-slate-50',
'focus:outline-none focus:ring-2 focus:ring-slate-400')}>
<X className="h-4 w-4"/>
<span className="sr-only">閉ぢる</span>
</DialogPrimitive.Close>
</DialogPrimitive.Content>
</DialogPortal>))
'fixed left-[50%] top-[50%] z-50 grid w-[calc(100%-2rem)] max-w-lg',
'translate-x-[-50%] translate-y-[-50%]',
'gap-5 rounded-2xl border border-border',
'bg-background p-6 text-foreground shadow-2xl',
'duration-200',
'data-[state=open]:animate-in data-[state=closed]:animate-out',
'data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0',
'data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95',
className)}
{...props}>
{children}
<DialogPrimitive.Close
className={cn (
'absolute left-4 top-4 rounded-full p-1',
'text-slate-500 transition-colors',
'hover:bg-slate-200 hover:text-slate-900',
'dark:text-slate-400 dark:hover:bg-slate-700 dark:hover:text-slate-50',
'focus:outline-none focus:ring-2 focus:ring-slate-400')}>
<X className="h-4 w-4"/>
<span className="sr-only">
{closeLabel ?? (
<LocalisedText language={close.language}>{close.text}</LocalisedText>)}
</span>
</DialogPrimitive.Close>
</DialogPrimitive.Content>
</DialogPortal>)
})
DialogContent.displayName = DialogPrimitive.Content.displayName
const DialogHeader = (
+1 -1
ファイルの表示
@@ -11,7 +11,7 @@ import type {
const TOAST_LIMIT = 1
const TOAST_REMOVE_DELAY = 1000000
type ToasterToast = ToastProps & {
type ToasterToast = Omit<ToastProps, 'title'> & {
id: string
title?: React.ReactNode
description?: React.ReactNode
+31 -36
ファイルの表示
@@ -3,6 +3,7 @@ import { useEffect, useMemo, useState } from 'react'
import { Button } from '@/components/ui/button'
import { toast } from '@/components/ui/use-toast'
import { getClientBehaviorSettings, setClientBehaviorSettings } from '@/lib/settings'
import { useUITranslation } from '@/lib/useUITranslation'
import { cn } from '@/lib/utils'
import type { FC, ReactNode } from 'react'
@@ -22,7 +23,7 @@ type Props = {
type SegmentedOption<T extends string> = {
value: T
label: string }
label: ReactNode }
type SegmentedControlProps<T extends string> = {
value: T
@@ -30,27 +31,18 @@ type SegmentedControlProps<T extends string> = {
onChange: (value: T) => void }
type SettingBlockProps = {
title: string
description?: string
title: ReactNode
description?: ReactNode
children: ReactNode }
const animationOptions: SegmentedOption<ClientAnimationMode>[] = [
{ value: 'off', label: 'なし' },
{ value: 'reduced', label: '控えめ' },
{ value: 'normal', label: '通常' }]
const animationOptions: ClientAnimationMode[] = ['off', 'reduced', 'normal']
const embedAutoLoadOptions: SegmentedOption<ClientEmbedAutoLoadMode>[] = [
{ value: 'off', label: '読み込まない' },
{ value: 'manual', label: 'クリック時' },
{ value: 'auto', label: '自動' }]
const embedAutoLoadOptions: ClientEmbedAutoLoadMode[] = ['off', 'manual', 'auto']
const linkPreloadOptions: SegmentedOption<ClientLinkPreloadMode>[] = [
{ value: 'off', label: 'しない' },
{ value: 'intent', label: 'する' }]
const linkPreloadOptions: ClientLinkPreloadMode[] = ['off', 'intent']
const tagRelationDisplayOptions: ClientTagRelationDisplayMode[] = ['flat', 'grouped']
const tagRelationDisplayOptions: SegmentedOption<ClientTagRelationDisplayMode>[] = [
{ value: 'flat', label: 'まとめない' },
{ value: 'grouped', label: 'まとめる' }]
const SegmentedControl = <T extends string,> (
{ value, options, onChange }: SegmentedControlProps<T>,
@@ -92,14 +84,13 @@ const SettingBlock: FC<SettingBlockProps> = (
</div>)
const BehaviourSettingsSection: FC<Props> = (
{ sectionClassName,
onDirtyStateChange },
) => {
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 ()
@@ -122,7 +113,7 @@ const BehaviourSettingsSection: FC<Props> = (
const next = setClientBehaviorSettings (draftSettings)
setSavedSettings (next)
setDraftSettings (next)
toast ({ title: '動作設定を保存しました' })
toast ({ title: label ('behavior.saved') })
}
const handleDiscard = () => {
@@ -141,7 +132,7 @@ const BehaviourSettingsSection: FC<Props> = (
<section className={sectionClassName}>
<div className="flex flex-wrap items-start justify-between gap-3">
<div className="space-y-1">
<h2 className="text-xl font-bold">動作</h2>
<h2 className="text-xl font-bold">{label ('tabs.behavior')}</h2>
</div>
<div className="flex flex-wrap gap-2">
@@ -150,45 +141,49 @@ const BehaviourSettingsSection: FC<Props> = (
variant="destructive"
disabled={hasUnsavedChanges === false}
onClick={handleDiscard}>
変更を破棄
{label ('actions.discard')}
</Button>
<Button
type="button"
disabled={hasUnsavedChanges === false}
onClick={handleSave}>
保存
{label ('actions.save')}
</Button>
</div>
</div>
<div className="space-y-4">
<SettingBlock title="アニメーション">
<SettingBlock title={label ('behavior.animation')}>
<SegmentedControl
value={draftSettings.animation ?? 'normal'}
options={animationOptions}
options={animationOptions.map (value => ({ value,
label: label (`behavior.animationOptions.${value}`) }))}
onChange={value => updateDraft ('animation', value)}/>
</SettingBlock>
<SettingBlock
title="リンク先の先読み"
description="マウスを置いた時点でリンク先のデータを取得します。">
title={label ('behavior.preload')}
description={label ('behavior.preloadDescription')}>
<SegmentedControl
value={draftSettings.linkPreload ?? 'intent'}
options={linkPreloadOptions}
options={linkPreloadOptions.map (value => ({ value,
label: label (`behavior.preloadOptions.${value}`) }))}
onChange={value => updateDraft ('linkPreload', value)}/>
</SettingBlock>
<SettingBlock title="タグの親子関係表示">
<SettingBlock title={label ('behavior.tagRelations')}>
<SegmentedControl
value={draftSettings.tagRelationDisplay ?? 'grouped'}
options={tagRelationDisplayOptions}
options={tagRelationDisplayOptions.map (value => ({ value,
label: label (`behavior.relationOptions.${value}`) }))}
onChange={value => updateDraft ('tagRelationDisplay', value)}/>
</SettingBlock>
<SettingBlock title="埋め込み自動読込">
<SettingBlock title={label ('behavior.embeds')}>
<SegmentedControl
value={draftSettings.embedAutoLoad ?? 'auto'}
options={embedAutoLoadOptions}
options={embedAutoLoadOptions.map (value => ({ value,
label: label (`behavior.embedOptions.${value}`) }))}
onChange={value => updateDraft ('embedAutoLoad', value)}/>
</SettingBlock>
</div>
+21 -14
ファイルの表示
@@ -1,8 +1,6 @@
import type { FC } from 'react'
import { useState } from 'react'
import { useDialogue } from '@/components/dialogues/DialogueProvider'
import { useDialogue } from '@/lib/dialogues/useDialogue'
import { Button } from '@/components/ui/button'
import { Dialog,
DialogContent,
@@ -12,6 +10,10 @@ import { Dialog,
import { Input } from '@/components/ui/input'
import { toast } from '@/components/ui/use-toast'
import { apiPost } from '@/lib/api'
import { useUITranslation } from '@/lib/useUITranslation'
import { rememberUserCode } from '@/lib/users'
import type { FC } from 'react'
import type { User } from '@/types'
@@ -22,14 +24,17 @@ type Props = { visible: boolean
const InheritDialogue: FC<Props> = ({ visible, onVisibleChange, setUser }) => {
const dialogue = useDialogue ()
const { message, label } = useUITranslation ('settings')
const [inputCode, setInputCode] = useState ('')
const handleTransfer = async () => {
if (!(await dialogue.confirm ({
title: '引継ぎを行ってもよろしいですか?',
description: '現在のアカウントからはログアウトされます.',
confirmText: '引継ぐ',
title: label ('transfer.confirm'),
description: label ('transfer.logoutWarning'),
confirmText: label ('transfer.submit'),
cancelText: label ('actions.cancel'),
closeLabel: label ('actions.close'),
variant: 'danger' })))
return
@@ -39,31 +44,33 @@ const InheritDialogue: FC<Props> = ({ visible, onVisibleChange, setUser }) => {
'/users/verify', { code: inputCode })
if (data.valid)
{
localStorage.setItem ('user_code', inputCode)
rememberUserCode (data.user.inheritanceCode)
setUser (data.user)
toast ({ title: '引継ぎ成功!' })
toast ({ title: label ('transfer.success') })
onVisibleChange (false)
}
else
toast ({ title: '認証失敗', description: 'そのコードは使へません.' })
toast ({ title: label ('transfer.authFailed'), description: label ('transfer.invalidCode') })
}
catch
{
toast ({ title: '通信エラー', description: 'またあとで試してね.' })
toast ({ title: label ('transfer.networkError'), description: label ('transfer.retryLater') })
}
}
return (
<Dialog open={visible} onOpenChange={onVisibleChange}>
<DialogContent className="px-6 pp-6 pt-7">
<DialogContent className="px-6 pp-6 pt-7" closeLabel={label ('actions.close')}>
<DialogHeader className="pl-8">
<DialogTitle>ほかのブラウザから引継ぐ</DialogTitle>
<DialogTitle>{label ('account.inherit')}</DialogTitle>
<DialogDescription asChild>
<div className="flex gap-2">
<Input placeholder="引継ぎコードを入力"
<Input placeholder={message ('transfer.placeholder').text}
aria-label={message ('transfer.placeholder').text}
lang={message ('transfer.placeholder').language}
value={inputCode}
onChange={ev => setInputCode (ev.target.value)}/>
<Button onClick={handleTransfer}>引継ぐ</Button>
<Button onClick={handleTransfer}>{label ('transfer.submit')}</Button>
</div>
</DialogDescription>
</DialogHeader>
+31 -26
ファイルの表示
@@ -8,14 +8,15 @@ import {
isSpaceBinding,
keyBindingFromKeyboardEvent,
SHORTCUT_DEFINITIONS,
SHORTCUT_SCOPE_LABELS,
} from '@/lib/keyboardShortcuts'
import { getEffectiveKeyBindings } from '@/lib/settings'
import { useKeyboardShortcutSettings } from '@/lib/useKeyboardShortcuts'
import { useUITranslation } from '@/lib/useUITranslation'
import type { FC } from 'react'
import type { ShortcutActionId } from '@/lib/keyboardShortcuts'
import type { ClientKeyboardSettings } from '@/lib/settings'
import type { FC } from 'react'
type Props = {
sectionClassName: string
@@ -26,10 +27,9 @@ type Props = {
}
const KeyboardSettingsSection: FC<Props> = (
{ sectionClassName,
onDirtyStateChange },
) => {
const KeyboardSettingsSection: FC<Props> = props => {
const { sectionClassName, onDirtyStateChange } = props
const { label } = useUITranslation ('settings')
const {
keyboardSettings,
saveKeyboardSettings,
@@ -92,7 +92,7 @@ const KeyboardSettingsSection: FC<Props> = (
if (isSpaceBinding (binding))
{
toast ({ title: 'Space は今は割り当てられません.' })
toast ({ title: label ('shortcuts.spaceUnavailable') })
return
}
@@ -108,7 +108,7 @@ const KeyboardSettingsSection: FC<Props> = (
document.addEventListener ('keydown', onKeyDown, true)
return () => document.removeEventListener ('keydown', onKeyDown, true)
}, [capturingActionId])
}, [capturingActionId, label])
const rows = useMemo (() => SHORTCUT_DEFINITIONS.map (definition => ({
...definition,
@@ -126,7 +126,7 @@ const KeyboardSettingsSection: FC<Props> = (
const handleSave = () => {
saveKeyboardSettings (draftKeyboardSettings)
toast ({ title: 'キーボード設定を保存しました' })
toast ({ title: label ('shortcuts.saved') })
}
const handleDiscard = () => {
@@ -146,7 +146,7 @@ const KeyboardSettingsSection: FC<Props> = (
<section className={sectionClassName}>
<div className="flex flex-wrap items-start justify-between gap-3">
<div className="space-y-1">
<h2 className="text-xl font-bold">キーボード</h2>
<h2 className="text-xl font-bold">{label ('tabs.keyboard')}</h2>
</div>
<div className="flex flex-wrap gap-2">
@@ -159,20 +159,20 @@ const KeyboardSettingsSection: FC<Props> = (
bindings: { },
}))
}}>
すべて既定に戻す
{label ('actions.resetAll')}
</Button>
<Button
type="button"
variant="destructive"
disabled={!(hasUnsavedChanges)}
onClick={handleDiscard}>
変更を破棄
{label ('actions.discard')}
</Button>
<Button
type="button"
disabled={!(hasUnsavedChanges) || draftConflictActionIds.size > 0}
onClick={handleSave}>
保存
{label ('actions.save')}
</Button>
</div>
</div>
@@ -187,33 +187,35 @@ const KeyboardSettingsSection: FC<Props> = (
enabled: event.target.checked,
}))
}}/>
<span>キーボード・ショートカットを有効にする</span>
<span>{label ('shortcuts.enable')}</span>
</label>
{capturingActionId != null && (
<p className="text-sm text-muted-foreground">
キー入力待ちです.Escape で取消し,Backspace / Delete で未割当にできます.
{label ('shortcuts.captureHelp')}
</p>)}
<div className="overflow-x-auto">
<table className="w-full min-w-[52rem] table-fixed border-collapse text-sm">
<thead className="border-b border-border">
<tr>
<th className="p-2 text-left">操作</th>
<th className="p-2 text-left">適用範囲</th>
<th className="p-2 text-left">現在のキー</th>
<th className="p-2 text-left">{label ('shortcuts.operation')}</th>
<th className="p-2 text-left">{label ('shortcuts.scope')}</th>
<th className="p-2 text-left">{label ('shortcuts.currentKey')}</th>
<th className="p-2 text-left"></th>
<th className="p-2 text-left"></th>
<th className="p-2 text-left">競合状態</th>
<th className="p-2 text-left">{label ('shortcuts.conflictState')}</th>
</tr>
</thead>
<tbody>
{rows.map (row => (
<tr key={row.id} className="border-b border-border/60 last:border-b-0">
<td className="p-2">{row.label}</td>
<td className="p-2">{SHORTCUT_SCOPE_LABELS[row.scope]}</td>
<td className="p-2">{label (`shortcuts.actions.${row.id}`)}</td>
<td className="p-2">{label (`shortcuts.scopes.${row.scope}`)}</td>
<td className="p-2 font-mono">
{formatKeyBinding (row.currentBinding)}
{row.currentBinding == null
? label ('shortcuts.unassigned')
: <span lang="en">{formatKeyBinding (row.currentBinding)}</span>}
</td>
<td className="p-2">
<Button
@@ -225,7 +227,8 @@ const KeyboardSettingsSection: FC<Props> = (
capturingActionId === row.id ? null : row.id,
)
}}>
{capturingActionId === row.id ? '入力待ち…' : '変更'}
{label (capturingActionId === row.id
? 'shortcuts.capturing' : 'actions.change')}
</Button>
</td>
<td className="p-2">
@@ -244,13 +247,15 @@ const KeyboardSettingsSection: FC<Props> = (
}
})
}}>
既定に戻す
{label ('actions.reset')}
</Button>
</td>
<td className="p-2">
{row.hasConflict
? <span className="text-red-600 dark:text-red-400">競合あり</span>
: 'なし'}
? <span className="text-red-600 dark:text-red-400">
{label ('shortcuts.conflicting')}
</span>
: label ('shortcuts.none')}
</td>
</tr>))}
</tbody>
+25 -32
ファイルの表示
@@ -5,19 +5,13 @@ import {
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog'
import {
formatKeyBinding,
SHORTCUT_DEFINITIONS,
SHORTCUT_SCOPE_LABELS,
} from '@/lib/keyboardShortcuts'
import { formatKeyBinding, SHORTCUT_DEFINITIONS } from '@/lib/keyboardShortcuts'
import { useUITranslation } from '@/lib/useUITranslation'
import type {
KeyBinding,
ShortcutActionId,
ShortcutScope,
} from '@/lib/keyboardShortcuts'
import type { FC } from 'react'
import type { KeyBinding, ShortcutActionId, ShortcutScope } from '@/lib/keyboardShortcuts'
type Props = {
open: boolean
onOpenChange: (open: boolean) => void
@@ -28,16 +22,10 @@ type Props = {
}
const ShortcutHelpDialog: FC<Props> = (
{
open,
onOpenChange,
activeScope,
effectiveBindings,
conflictActionIds,
availableActionIds,
},
) => {
const ShortcutHelpDialog: FC<Props> = props => {
const { open, onOpenChange, activeScope, effectiveBindings,
conflictActionIds, availableActionIds } = props
const { label } = useUITranslation ('settings')
const rows = SHORTCUT_DEFINITIONS
.filter (definition =>
definition.scope === 'global' || definition.scope === activeScope)
@@ -45,11 +33,12 @@ const ShortcutHelpDialog: FC<Props> = (
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-w-3xl px-6 pb-6 pt-7">
<DialogContent className="max-w-3xl px-6 pb-6 pt-7"
closeLabel={label ('actions.close')}>
<DialogHeader className="pl-8">
<DialogTitle>キーボード・ショートカット</DialogTitle>
<DialogTitle>{label ('shortcuts.title')}</DialogTitle>
<DialogDescription>
この画面で利用できるショートカットを表示します。
{label ('shortcuts.description')}
</DialogDescription>
</DialogHeader>
@@ -57,24 +46,28 @@ const ShortcutHelpDialog: FC<Props> = (
<table className="w-full min-w-[40rem] table-fixed border-collapse text-sm">
<thead className="border-b border-border">
<tr>
<th className="p-2 text-left">操作</th>
<th className="p-2 text-left">適用範囲</th>
<th className="p-2 text-left">キー</th>
<th className="p-2 text-left">競合</th>
<th className="p-2 text-left">{label ('shortcuts.operation')}</th>
<th className="p-2 text-left">{label ('shortcuts.scope')}</th>
<th className="p-2 text-left">{label ('shortcuts.key')}</th>
<th className="p-2 text-left">{label ('shortcuts.conflict')}</th>
</tr>
</thead>
<tbody>
{rows.map (row => (
<tr key={row.id} className="border-b border-border/60 last:border-b-0">
<td className="p-2">{row.label}</td>
<td className="p-2">{SHORTCUT_SCOPE_LABELS[row.scope]}</td>
<td className="p-2">{label (`shortcuts.actions.${row.id}`)}</td>
<td className="p-2">{label (`shortcuts.scopes.${row.scope}`)}</td>
<td className="p-2 font-mono">
{formatKeyBinding (effectiveBindings[row.id])}
{effectiveBindings[row.id] == null
? label ('shortcuts.unassigned')
: <span lang="en">{formatKeyBinding (effectiveBindings[row.id])}</span>}
</td>
<td className="p-2">
{conflictActionIds.has (row.id)
? <span className="text-red-600 dark:text-red-400">競合あり</span>
: 'なし'}
? <span className="text-red-600 dark:text-red-400">
{label ('shortcuts.conflicting')}
</span>
: label ('shortcuts.none')}
</td>
</tr>))}
</tbody>
+18 -13
ファイルの表示
@@ -1,6 +1,4 @@
import type { FC } from 'react'
import { useDialogue } from '@/components/dialogues/DialogueProvider'
import { useDialogue } from '@/lib/dialogues/useDialogue'
import { Button } from '@/components/ui/button'
import { Dialog,
DialogContent,
@@ -10,6 +8,10 @@ import { Dialog,
DialogTitle } from '@/components/ui/dialog'
import { toast } from '@/components/ui/use-toast'
import { apiPost } from '@/lib/api'
import { useUITranslation } from '@/lib/useUITranslation'
import { rememberUserCode } from '@/lib/users'
import type { FC } from 'react'
import type { User } from '@/types'
@@ -21,18 +23,21 @@ type Props = { visible: boolean
const UserCodeDialogue: FC<Props> = ({ visible, onVisibleChange, user, setUser }) => {
const dialogue = useDialogue ()
const { label } = useUITranslation ('settings')
const handleChange = async () => {
if (!(user))
return
if (!(await dialogue.confirm ({
title: '引継ぎコードを再発行しますか?',
title: label ('userCode.confirm'),
description: (
<div>
<p>再発行するとほかのブラウザからはログアウトされます.</p>
<p>{label ('userCode.logoutWarning')}</p>
</div>),
confirmText: '再発行',
confirmText: label ('userCode.confirmRenew'),
cancelText: label ('actions.cancel'),
closeLabel: label ('actions.close'),
variant: 'danger' })))
return
@@ -40,24 +45,24 @@ const UserCodeDialogue: FC<Props> = ({ visible, onVisibleChange, user, setUser }
{ headers: { 'Content-Type': 'multipart/form-data' } })
if (data.code)
{
localStorage.setItem ('user_code', data.code)
rememberUserCode (data.code)
setUser (user => ({ ...user, inheritanceCode: data.code } as User))
toast ({ title: '再発行しました.' })
toast ({ title: label ('userCode.renewed') })
}
}
return (
<Dialog open={visible} onOpenChange={onVisibleChange}>
<DialogContent className="px-6 pb-6 pt-7">
<DialogContent className="px-6 pb-6 pt-7" closeLabel={label ('actions.close')}>
<DialogHeader className="pl-8">
<DialogTitle>引継ぎコード</DialogTitle>
<DialogTitle>{label ('userCode.title')}</DialogTitle>
<DialogDescription asChild>
<div>
<p>あなたの引継ぎコードはこちらです:</p>
<p>{label ('userCode.description')}</p>
<div className="m-2">{user?.inheritanceCode}</div>
<p className="mt-1 text-sm text-destructive">
このコードはほかの人には教えないでください!
{label ('userCode.secretWarning')}
</p>
</div>
</DialogDescription>
@@ -65,7 +70,7 @@ const UserCodeDialogue: FC<Props> = ({ visible, onVisibleChange, user, setUser }
<DialogFooter>
<Button onClick={handleChange} variant="destructive">
引継ぎコード再発行
{label ('userCode.renew')}
</Button>
</DialogFooter>
</DialogContent>
+1 -6
ファイルの表示
@@ -1,9 +1,4 @@
// frontend/src/config.ts
const ENV: string = 'development'
const config = {
API_BASE_URL: ENV === 'production' ? 'https://hub.nizika.monster/api' : 'http://localhost:3002',
SITE_TITLE: 'ぼざクリ タグ広場'}
const config = { API_BASE_URL: '/api' }
export const API_BASE_URL = config.API_BASE_URL
export const SITE_TITLE = config.SITE_TITLE
+192
ファイルの表示
@@ -0,0 +1,192 @@
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, 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'
export { renderSitemap } from '@/lib/sitemap.server'
type RenderedPage = {
status: number
html: string
head: string
locale: string
bootstrap: string
setCookies?: 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 (
address: string,
origin: string,
apiBaseURL: string,
authentication: Pick<RenderContext, 'cookie' | '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 (legacy != null && known != null)
return { status: 308, location: legacy }
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: RenderContext & { status: number } = {
pathname: url.pathname, origin, apiBaseURL,
...authentication, status: 200 }
return withRenderContext (requestContext, async () => {
const client = new QueryClient ({ defaultOptions: {
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 (authentication.cookie != 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.cookie
}
}
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)
let helmetContext: { helmet?: HelmetServerState } = { }
const page = () => (
<HelmetProvider context={helmetContext}>
<QueryClientProvider client={client}>
<I18nextProvider i18n={instance}>
<AppFrame>
<StaticRouterProvider router={router} context={context} hydrate={false}/>
</AppFrame>
</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 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')
.replace (/\u2029/g, '\\u2029')
return { status, html, head, locale, bootstrap,
setCookies: requestContext.setCookies }
}
finally
{
client.clear ()
}
})
}
+128 -1
ファイルの表示
@@ -4,6 +4,45 @@
@tailwind components;
@tailwind utilities;
.ainu-ui-text
{
white-space: normal !important;
word-break: keep-all !important;
overflow-wrap: normal !important;
}
.ainu-ui-word
{
display: inline-block;
white-space: nowrap !important;
word-break: keep-all !important;
overflow-wrap: normal !important;
}
.ainu-ui-label .ainu-ui-text
{
white-space: nowrap !important;
}
.ainu-ui-label wbr
{
display: none;
}
.localised-document
{
min-width: 0;
max-width: 100%;
overflow-x: auto;
}
.localised-document a:lang(ain)
{
white-space: nowrap;
word-break: keep-all;
overflow-wrap: normal;
}
@layer base
{
:root
@@ -208,6 +247,12 @@ body
column-gap: 4px;
}
.top-nav-account
{
flex-shrink: 0;
width: max-content;
}
.top-nav-layout .top-nav-brand-link
{
margin-inline: 4px;
@@ -216,6 +261,7 @@ body
.top-nav-desktop-menu,
.top-nav-desktop-submenu,
.top-nav-layout .top-nav-account,
.top-nav-user-desktop
{
display: none;
@@ -226,6 +272,39 @@ body
font-size: .875rem;
}
.top-nav-separator-vertical
{
display: inline-flex;
}
.top-nav-separator-horizontal
{
display: none;
}
.top-nav-mobile-menu .top-nav-separator-vertical
{
display: none;
}
.top-nav-mobile-menu .top-nav-separator-horizontal
{
display: block;
}
.top-nav-more-menu
{
display: grid;
grid-template-columns: fit-content(35%) minmax(0, 1fr);
align-items: center;
}
.top-nav-desktop-submenu .top-nav-menu-link
{
white-space: normal;
overflow-wrap: normal;
}
.top-nav-desktop-menu .top-nav-menu-link
{
padding-inline: 8px;
@@ -242,24 +321,67 @@ body
overflow-wrap: anywhere;
}
.top-nav-account .top-nav-user-desktop
{
min-width: 0;
max-width: 24vw;
overflow: hidden;
text-overflow: ellipsis;
}
.top-nav-locale-name
{
display: none;
}
@media (min-width: 768px)
{
.top-nav-layout[data-measuring] .top-nav-primary,
.top-nav-layout[data-measuring] .top-nav-desktop-menu,
.top-nav-layout[data-measuring] .top-nav-account
{
flex: 0 0 auto;
width: max-content;
max-width: none;
}
.top-nav-layout:not([data-desktop]) .top-nav-locale-name,
.top-nav-layout[data-desktop='true'] .top-nav-locale-name,
.top-nav-layout[data-measuring] .top-nav-locale-name
{
display: inline;
}
.top-nav-layout:not([data-desktop]) .top-nav-account .top-nav-user-desktop,
.top-nav-layout[data-desktop='true'] .top-nav-account .top-nav-user-desktop,
.top-nav-layout[data-measuring] .top-nav-account .top-nav-user-desktop
{
max-width: none;
}
.top-nav-layout .top-nav-brand-link
{
font-size: 1.125rem;
}
.top-nav-layout:not([data-desktop]) .top-nav-root,
.top-nav-layout[data-desktop='true'] .top-nav-root
{
background: var(--top-nav-root-bg-desktop);
}
.top-nav-layout:not([data-desktop]) .top-nav-desktop-menu,
.top-nav-layout:not([data-desktop]) .top-nav-account,
.top-nav-layout[data-desktop='true'] .top-nav-desktop-menu,
.top-nav-layout[data-measuring] .top-nav-desktop-menu
.top-nav-layout[data-measuring] .top-nav-desktop-menu,
.top-nav-layout[data-desktop='true'] .top-nav-account,
.top-nav-layout[data-measuring] .top-nav-account
{
display: flex;
}
.top-nav-layout:not([data-desktop]) .top-nav-user-desktop,
.top-nav-layout:not([data-desktop]) .top-nav-desktop-submenu,
.top-nav-layout[data-desktop='true'] .top-nav-user-desktop,
.top-nav-layout[data-measuring] .top-nav-user-desktop,
.top-nav-layout[data-desktop='true'] .top-nav-desktop-submenu
@@ -267,8 +389,13 @@ body
display: block;
}
.top-nav-layout:not([data-desktop]) .top-nav-mobile-controls,
.top-nav-layout:not([data-desktop]) .top-nav-mobile-menu,
.top-nav-layout:not([data-desktop]) .top-nav-user-mobile,
.top-nav-layout[data-desktop='true'] .top-nav-mobile-toggle,
.top-nav-layout[data-measuring] .top-nav-mobile-toggle,
.top-nav-layout[data-desktop='true'] .top-nav-mobile-controls,
.top-nav-layout[data-measuring] .top-nav-mobile-controls,
.top-nav-layout[data-desktop='true'] .top-nav-mobile-menu,
.top-nav-layout[data-desktop='true'] .top-nav-user-mobile
{
+73 -4
ファイルの表示
@@ -28,11 +28,17 @@ vi.mock ('@/config', () => ({
describe ('api helpers', () => {
beforeEach (() => {
vi.resetModules ()
vi.clearAllMocks ()
localStorage.clear ()
mocks.client.get.mockReset ().mockResolvedValue ({ data: { token: 'csrf-token' } })
mocks.client.post.mockReset ().mockResolvedValue ({ data: { ok: true } })
mocks.client.patch.mockReset ().mockResolvedValue ({ data: { ok: true } })
mocks.client.delete.mockReset ().mockResolvedValue ({ data: '' })
mocks.isAxiosError.mockReturnValue (false)
})
it ('adds the transfer code header and camelizes get responses', async () => {
it ('uses Cookies without sending the recovery code and camelises responses', async () => {
localStorage.setItem ('user_code', 'abc123')
mocks.client.get.mockResolvedValueOnce ({
data: { post_id: 1, nested_value: { created_at: 'now' } },
@@ -47,11 +53,13 @@ describe ('api helpers', () => {
expect (mocks.client.get).toHaveBeenCalledWith (
'/posts/1',
{
headers: { 'X-Transfer-Code': 'abc123', 'X-Extra': '1' },
headers: { 'X-Extra': '1' },
params: { page: 2 },
},
)
expect (data).toEqual ({ postId: 1, nestedValue: { createdAt: 'now' } })
const { default: axios } = await import ('axios')
expect (axios.create).toHaveBeenCalledWith ({ baseURL: '/api', withCredentials: true })
})
it ('passes an empty body for post-like requests when body is omitted', async () => {
@@ -63,7 +71,7 @@ describe ('api helpers', () => {
expect (mocks.client.patch).toHaveBeenCalledWith (
'/posts/1',
{},
{ headers: { 'X-Transfer-Code': '' } },
{ headers: { 'X-CSRF-Token': 'csrf-token' } },
)
expect (data.okValue).toBe (true)
})
@@ -88,9 +96,70 @@ describe ('api helpers', () => {
expect (mocks.client.delete).toHaveBeenCalledWith (
'/posts/1',
{ headers: { 'X-Transfer-Code': '' } },
{ headers: { 'X-CSRF-Token': 'csrf-token' } },
)
expect (isApiError (err)).toBe (true)
expect (mocks.isAxiosError).toHaveBeenCalledWith (err)
})
it ('shares initial CSRF acquisition across concurrent writes and reuses the token', async () => {
let complete!: (value: { data: { token: string } }) => void
mocks.client.get.mockReturnValueOnce (new Promise (resolve => { complete = resolve }))
const { apiPost, apiPatch } = await import ('@/lib/api')
const first = apiPost ('/posts', { title: 'one' })
const second = apiPatch ('/posts/1', { title: 'two' })
expect (mocks.client.get).toHaveBeenCalledTimes (1)
expect (mocks.client.post).not.toHaveBeenCalled ()
complete ({ data: { token: 'shared-token' } })
await Promise.all ([first, second])
await apiPatch ('/posts/1')
expect (mocks.client.get).toHaveBeenCalledTimes (1)
expect (mocks.client.post).toHaveBeenCalledWith ('/posts', { title: 'one' }, {
headers: { 'X-CSRF-Token': 'shared-token' } })
expect (mocks.client.patch).toHaveBeenLastCalledWith ('/posts/1', { }, {
headers: { 'X-CSRF-Token': 'shared-token' } })
})
it.each (['/users', '/users/verify', '/users/code/renew']) (
'refreshes CSRF after a session change through %s', async path => {
mocks.client.get.mockResolvedValueOnce ({ data: { token: 'before' } })
mocks.client.get.mockResolvedValueOnce ({ data: { token: 'after' } })
mocks.client.post.mockResolvedValueOnce ({ data: { valid: true } })
const { apiPost, apiPatch } = await import ('@/lib/api')
await apiPost (path)
await apiPatch ('/posts/1')
expect (mocks.client.get).toHaveBeenCalledTimes (2)
expect (mocks.client.patch).toHaveBeenCalledWith ('/posts/1', { }, {
headers: { 'X-CSRF-Token': 'after' } })
})
it ('clears the recovery code and CSRF token after session deletion', async () => {
localStorage.setItem ('user_code', 'backup')
const { apiDelete, apiPatch } = await import ('@/lib/api')
await apiDelete ('/users/session')
expect (localStorage.getItem ('user_code')).toBeNull ()
await apiPatch ('/posts/1')
expect (mocks.client.get).toHaveBeenCalledTimes (2)
})
it ('clears rejected CSRF tokens without replaying a write', async () => {
const error = { response: { status: 403, headers: { 'x-csrf-invalid': '1' } } }
mocks.isAxiosError.mockReturnValue (true)
mocks.client.patch.mockRejectedValueOnce (error)
const { apiPatch } = await import ('@/lib/api')
await expect (apiPatch ('/posts/1')).rejects.toBe (error)
expect (mocks.client.patch).toHaveBeenCalledTimes (1)
await apiPatch ('/posts/1')
expect (mocks.client.get).toHaveBeenCalledTimes (2)
})
it ('allows CSRF acquisition to recover after a failed request', async () => {
const error = new Error ('offline')
mocks.client.get.mockRejectedValueOnce (error)
const { apiPatch } = await import ('@/lib/api')
await expect (apiPatch ('/posts/1')).rejects.toBe (error)
expect (mocks.client.patch).not.toHaveBeenCalled ()
await apiPatch ('/posts/1')
expect (mocks.client.get).toHaveBeenCalledTimes (2)
})
})
+136 -9
ファイルの表示
@@ -2,22 +2,130 @@ import axios from 'axios'
import toCamel from 'camelcase-keys'
import { API_BASE_URL } from '@/config'
import { currentRenderContext } from '@/lib/renderContext'
import type { AxiosError, AxiosRequestConfig } from 'axios'
import type { AxiosError, AxiosRequestConfig, AxiosResponse } from 'axios'
import type { LocaleCode } from '@/lib/locales'
import type { RenderContext } from '@/lib/renderContext'
type Opt = {
locale?: LocaleCode
params?: AxiosRequestConfig['params']
headers?: Record<string, string>
signal?: AbortSignal
responseType?: 'blob' }
const client = axios.create ({ baseURL: API_BASE_URL })
const client = axios.create ({ baseURL: API_BASE_URL, withCredentials: true })
type CSRFState = NonNullable<RenderContext['csrf']>
let browserCSRF: CSRFState = { }
const withUserCode = (opt?: Opt): Opt => ({
...opt,
headers: { 'X-Transfer-Code': localStorage.getItem ('user_code') ?? '',
...(opt?.headers ?? { }) } })
const csrfState = (): CSRFState => {
if (typeof window !== 'undefined')
return browserCSRF
const context = currentRenderContext ()
if (context == null)
throw new Error ('Missing request context for CSRF')
return context.csrf ??= { }
}
const resetCSRF = (): void => {
if (typeof window !== 'undefined')
browserCSRF = { }
else
{
const context = currentRenderContext ()
if (context != null)
context.csrf = { }
}
}
const withSession = (opt?: Opt): AxiosRequestConfig => {
const { locale, ...config } = opt ?? { }
return {
...config,
...(currentRenderContext ()?.apiBaseURL != null
&& { baseURL: currentRenderContext ()!.apiBaseURL, timeout: 10_000 }),
...(locale != null && { params: { locale, ...config.params } }),
headers: { ...(currentRenderContext ()?.cookie != null && {
Cookie: currentRenderContext ()!.cookie }),
...(typeof window === 'undefined' && currentRenderContext () != null && {
'X-Forwarded-Host': new URL (currentRenderContext ()!.origin).host,
'X-Forwarded-Proto': new URL (currentRenderContext ()!.origin)
.protocol.replace (':', '') }),
...(currentRenderContext ()?.forwardedFor != null && {
'X-Forwarded-For': currentRenderContext ()!.forwardedFor }),
...(config.headers ?? { }) } }
}
const withCSRF = async (opt?: Opt): Promise<AxiosRequestConfig> => {
const token = await csrfToken ()
const config = withSession (opt)
return { ...config, headers: { ...config.headers, 'X-CSRF-Token': token } }
}
const csrfToken = async (): Promise<string> => {
const state = csrfState ()
if (state.token != null)
return state.token
state.pending ??= client.get<{ token: string }> ('/users/csrf', withSession ())
.then (response => {
if (csrfState () !== state)
return csrfToken ()
if (typeof response.data.token !== 'string')
throw new Error ('Missing CSRF token')
rememberSessionCookies (response.headers)
state.token = response.data.token
delete state.pending
return state.token
}).catch (error => {
delete state.pending
throw error
})
return state.pending
}
const csrfWrite = async <T,> (
send: (config: AxiosRequestConfig) => Promise<AxiosResponse<T>>,
opt?: Opt,
): Promise<AxiosResponse<T>> => {
try
{
return await send (await withCSRF (opt))
}
catch (error)
{
if (axios.isAxiosError (error)
&& error.response?.headers?.['x-csrf-invalid'] === '1')
resetCSRF ()
throw error
}
}
const rememberSessionCookies = (headers: AxiosResponse['headers']) => {
const context = currentRenderContext ()
const cookies = headers?.['set-cookie']
if (typeof window === 'undefined' && context != null && Array.isArray (cookies))
{
context.setCookies = cookies
const pairs = new Map ((context.cookie ?? '').split (';').filter (Boolean)
.map (value => value.trim ().split (/=(.*)/s).slice (0, 2) as [string, string]))
for (const cookie of cookies)
{
const pair = cookie.split (';')[0]
const separator = pair.indexOf ('=')
pairs.set (pair.slice (0, separator), pair.slice (separator + 1))
}
context.cookie = [...pairs].map (([key, value]) => `${key}=${value}`).join ('; ')
}
}
const apiP = async <T> (
@@ -25,7 +133,11 @@ const apiP = async <T> (
path: string,
body?: unknown,
opt?: Opt): Promise<T> => {
const res = await client[method] (path, body ?? { }, withUserCode (opt))
const res = await csrfWrite (config => client[method] (path, body ?? { }, config), opt)
if (method === 'post' && (path === '/users' || path === '/users/code/renew'
|| (path === '/users/verify' && res.data?.valid)))
resetCSRF ()
rememberSessionCookies (res.headers)
if (opt?.responseType === 'blob')
return res.data as T
return toCamel (res.data as Record<string, unknown>, { deep: true }) as T
@@ -35,7 +147,8 @@ const apiP = async <T> (
export const apiGet = async <T> (
path: string,
opt?: Opt): Promise<T> => {
const res = await client.get (path, withUserCode (opt))
const res = await client.get (path, withSession (opt))
rememberSessionCookies (res.headers)
if (opt?.responseType === 'blob')
return res.data as T
return toCamel (res.data as Record<string, unknown>, { deep: true }) as T
@@ -63,7 +176,21 @@ export const apiPatch = async <T> (
export const apiDelete = async <T = void> (
path: string,
opt?: Opt): Promise<T> => {
const res = await client.delete (path, withUserCode (opt))
const res = await csrfWrite (config => client.delete (path, config), opt)
if (path === '/users/session')
resetCSRF ()
rememberSessionCookies (res.headers)
if (typeof window !== 'undefined' && path === '/users/session')
{
try
{
localStorage.removeItem ('user_code')
}
catch
{
// The server session is already cleared even if storage is unavailable.
}
}
if (res.data == null || res.data === '')
return undefined as T
return toCamel (res.data as Record<string, unknown>, { deep: true }) as T
+11 -9
ファイルの表示
@@ -4,10 +4,11 @@ import type { ReactNode } from 'react'
type DialogueVariant = 'default' | 'danger'
type ConfirmOptions = { title: string
type ConfirmOptions = { title: ReactNode
description?: ReactNode
confirmText?: string
cancelText?: string
confirmText?: ReactNode
cancelText?: ReactNode
closeLabel?: ReactNode
variant?: DialogueVariant }
type AlertOptions = { title: string
@@ -15,16 +16,17 @@ type AlertOptions = { title: string
okText?: string }
type Choice<T extends string> = { value: T
label: string
label: ReactNode
variant?: DialogueVariant }
type ChoiceOptions<T extends string> = { title: string
type ChoiceOptions<T extends string> = { title: ReactNode
description?: ReactNode
choices: Choice<T>[]
cancelText?: string }
cancelText?: ReactNode
closeLabel?: ReactNode }
type DialogueFormAction = {
label: string
label: ReactNode
placement?: 'start' | 'end'
variant?: DialogueVariant
disabled?: boolean
@@ -35,10 +37,10 @@ type DialogueFormControls = {
setActions: (actions: DialogueFormAction[]) => void
confirm: (options: ConfirmOptions) => Promise<boolean> }
type DialogueFormOptions = { title: string
type DialogueFormOptions = { title: ReactNode
description?: ReactNode
body: (controls: DialogueFormControls) => ReactNode
cancelText?: string
cancelText?: ReactNode
size?: 'default' | 'large' }
type DialogueAPI =
+5 -5
ファイルの表示
@@ -52,7 +52,7 @@ describe('Gekanator API functions', () => {
mockedApiGet.mockResolvedValueOnce({ posts })
await expect(fetchGekanatorPosts()).resolves.toEqual(posts)
expect(mockedApiGet).toHaveBeenCalledWith('/gekanator/posts')
expect(mockedApiGet).toHaveBeenCalledWith('/gekanator/posts', { locale: 'ja' })
})
it('returns questions from the Gekanator questions endpoint', async () => {
@@ -60,7 +60,7 @@ describe('Gekanator API functions', () => {
mockedApiGet.mockResolvedValueOnce({ questions })
await expect(fetchGekanatorQuestions()).resolves.toEqual(questions)
expect(mockedApiGet).toHaveBeenCalledWith('/gekanator/questions')
expect(mockedApiGet).toHaveBeenCalledWith('/gekanator/questions', { locale: 'ja' })
})
})
@@ -458,7 +458,7 @@ describe('Gekanator API writers', () => {
original_answer: 'partial',
},
],
})
}, { locale: 'ja' })
})
it('sends question suggestions using snake_case request keys', async () => {
@@ -482,7 +482,7 @@ describe('Gekanator API writers', () => {
gekanator_game_id: 100,
question_text: '喜多ちゃんが泣いてる?',
answer: 'yes',
})
}, { locale: 'ja' })
})
it('sends extra question answers using snake_case request keys', async () => {
@@ -517,7 +517,7 @@ describe('Gekanator API writers', () => {
answer: 'probably_no',
},
],
},
}, { locale: 'ja' },
)
})
})
+31 -15
ファイルの表示
@@ -1,5 +1,6 @@
import { apiGet, apiPost } from '@/lib/api'
import type { LocaleCode } from '@/lib/locales'
import type { Post } from '@/types'
export type GekanatorAnswerValue =
@@ -411,24 +412,30 @@ export const storeGekanatorQuestion = (
exampleAnswers: question.exampleAnswers })
export const fetchGekanatorPosts = async (): Promise<Post[]> => {
const data = await apiGet<{ posts: Post[] }> ('/gekanator/posts')
export const fetchGekanatorPosts = async (
locale: LocaleCode = 'ja',
): Promise<Post[]> => {
const data = await apiGet<{ posts: Post[] }> ('/gekanator/posts', { locale })
return data.posts
}
export const fetchGekanatorQuestions = async (): Promise<StoredGekanatorQuestion[]> => {
const data = await apiGet<{ questions: StoredGekanatorQuestion[] }> ('/gekanator/questions')
export const fetchGekanatorQuestions = async (
locale: LocaleCode = 'ja',
): Promise<StoredGekanatorQuestion[]> => {
const data = await apiGet<{ questions: StoredGekanatorQuestion[] }> ('/gekanator/questions', { locale })
return data.questions
}
export const fetchGekanatorExtraQuestions = async (
gameId: number,
nonce?: string): Promise<GekanatorExtraQuestion[]> => {
nonce?: string,
locale: LocaleCode = 'ja',
): Promise<GekanatorExtraQuestion[]> => {
const data = await apiGet<{ questions: GekanatorExtraQuestion[] }> (
`/gekanator/games/${ gameId }/extra_questions`,
{ params: nonce ? { nonce } : undefined })
{ locale, params: nonce ? { nonce } : undefined })
return data.questions
}
@@ -606,7 +613,8 @@ export const buildGekanatorQuestions = (
}
export const saveGekanatorGame = async ({
export const saveGekanatorGame = async (
{
guessedPostId,
correctPostId,
answers,
@@ -614,7 +622,9 @@ export const saveGekanatorGame = async ({
guessedPostId: number
correctPostId: number
answers: GekanatorAnswerLog[]
}): Promise<{ id: number; learnedExampleCount: number }> =>
},
locale: LocaleCode = 'ja',
): Promise<{ id: number; learnedExampleCount: number }> =>
await apiPost ('/gekanator/games', {
guessed_post_id: guessedPostId,
correct_post_id: correctPostId,
@@ -627,10 +637,11 @@ export const saveGekanatorGame = async ({
effective_question: answer.effectiveQuestion,
learning_question: answer.learningQuestion,
answer: answer.answer,
original_answer: answer.originalAnswer })) })
original_answer: answer.originalAnswer })) }, { locale })
export const saveGekanatorQuestionSuggestion = async ({
export const saveGekanatorQuestionSuggestion = async (
{
gekanatorGameId,
existingQuestionId,
questionText,
@@ -640,22 +651,27 @@ export const saveGekanatorQuestionSuggestion = async ({
existingQuestionId?: number
questionText?: string
answer: GekanatorAnswerValue
}): Promise<{ id: number; count: number }> =>
},
locale: LocaleCode = 'ja',
): Promise<{ id: number; count: number }> =>
await apiPost ('/gekanator/question_suggestions', {
gekanator_game_id: gekanatorGameId,
existing_question_id: existingQuestionId,
question_text: questionText,
answer })
answer }, { locale })
export const saveGekanatorExtraQuestionAnswers = async ({
export const saveGekanatorExtraQuestionAnswers = async (
{
gameId,
answers,
}: {
gameId: number
answers: { questionId: number; answer: GekanatorAnswerValue }[]
}) =>
},
locale: LocaleCode = 'ja',
) =>
await apiPost (`/gekanator/games/${ gameId }/extra_question_answers`, {
answers: answers.map (item => ({
question_id: item.questionId,
answer: item.answer })) })
answer: item.answer })) }, { locale })
+12 -3
ファイルの表示
@@ -29,7 +29,7 @@ describe ('Help file catalogue', () => {
const { api } = createHelpFixture ()
expect (api.parseHelpEdition (edition)).toEqual ({ date: '2026-10-02', revision })
const date = api.helpEditionDate (edition)!
expect ([date.getFullYear (), date.getMonth (), date.getDate ()]).toEqual ([2026, 9, 2])
expect (date.toISOString ()).toBe ('2026-10-01T15:00:00.000Z')
})
it.each (['missing', '2026-02-30', '2026-10-02.0', '2026-10-02.2.extra',
@@ -51,9 +51,9 @@ describe ('Help file catalogue', () => {
expect ((await api.getHelpArticle ('guide'))?.languages.ja.currentEdition)
.toBe ('2026-10-02.10')
expect (await api.getHelpSearchEntries ('ja')).toEqual ([
{ identifier: 'guide', edition: '2026-10-02.10',
{ identifier: 'guide', language: 'ja', edition: '2026-10-02.10',
title: 'ホーム第十版', body: '\nタグを検索する 第十版本文' },
{ identifier: '設定 手順', edition: '2026-10-03',
{ identifier: '設定 手順', language: 'ja', edition: '2026-10-03',
title: '設定', body: '\nタグを設定する' }])
})
@@ -66,6 +66,15 @@ describe ('Help file catalogue', () => {
.not.toContain ('2026-10-02.11')
})
it ('does not substitute Japanese articles, search or changes for missing Ainu content',
async () => {
const { api } = createHelpFixture ()
const article = await api.getHelpArticle ('guide')
expect (api.resolveHelpLanguage (article, 'ain')).toBeNull ()
expect (await api.getHelpSearchEntries ('ain')).toEqual ([])
expect (await api.getHelpChanges ('ain')).toEqual ([])
})
it ('uses generated edition titles without reparsing raw sources', async () => {
const { api, raw } = createHelpFixture ()
expect (await api.getHelpEditionText ('guide', 'ja', '2026-10-01'))
+44 -3
ファイルの表示
@@ -1,5 +1,10 @@
import { localeLanguage } from '@/lib/locales'
import { helpKeys } from '@/lib/queryKeys'
import type { ComponentType } from 'react'
import type { PagePreparation } from '@/lib/routeLocales'
type ArticleLanguage = { currentEdition: string; publishedEditions: string[] }
export type ArticleMeta = {
@@ -13,12 +18,14 @@ export type HelpEditionText = {
export type HelpSearchEntry = HelpEditionText & {
identifier: string
language?: string
edition: string }
export type HelpEditionContent = HelpEditionText & { content: ComponentType }
export type HelpEditionSource = HelpEditionText & { source: string }
export type HelpChange = Pick<HelpSearchEntry, 'identifier' | 'edition' | 'title'> & {
language?: string
pred: string | null
change: 'new' | 'update'
date: string }
@@ -72,7 +79,7 @@ export const compareHelpEditions = (a: string, b: string): number => {
export const helpEditionDate = (edition: string): Date | null => {
const parsed = parseHelpEdition (edition)
return parsed == null ? null : new Date (`${ parsed.date }T00:00:00`)
return parsed == null ? null : new Date (`${ parsed.date }T00:00:00+09:00`)
}
@@ -82,6 +89,40 @@ export const getHelpArticle = async (identifier: string): Promise<ArticleMeta |
}
export const resolveHelpLanguage = (
article: ArticleMeta | null | undefined,
requestedLanguage: string,
): string | null =>
article?.languages[requestedLanguage] != null
? requestedLanguage
: null
// MDX functions stay in memory; both entries load them before rendering.
export const prepareHelpPage = async (
{ client, params, locale, searchParams }: PagePreparation,
): Promise<number> => {
const identifier = params.identifier
if (identifier == null)
return 404
const article = await client.fetchQuery ({
queryKey: helpKeys.article (identifier),
queryFn: () => getHelpArticle (identifier), staleTime: Infinity })
const language = resolveHelpLanguage (article, localeLanguage (locale))
if (language == null)
return 404
const meta = article!.languages[language]
const edition = searchParams.get ('edition') ?? meta.currentEdition
if (!(meta.publishedEditions.includes (edition)) || parseHelpEdition (edition) == null)
return 404
const content = await client.fetchQuery ({
queryKey: helpKeys.edition (identifier, language, edition),
queryFn: () => getHelpEditionContent (identifier, language, edition),
staleTime: Infinity })
return content == null ? 404 : 200
}
export const getHelpEditions = async (
identifier: string,
language: string,
@@ -144,7 +185,7 @@ export const getHelpSearchEntries = async (
language: string,
): Promise<HelpSearchEntry[]> => {
const { default: search } = await import ('virtual:help/search')
return search[language] ?? []
return (search[language] ?? []).map (entry => ({ ...entry, language }))
}
@@ -163,7 +204,7 @@ export const getHelpEditionContent = async (
export const getHelpChanges = async (language: string): Promise<HelpChange[]> => {
const { default: changes } = await import ('virtual:help/changes')
return changes[language] ?? []
return (changes[language] ?? []).map (entry => ({ ...entry, language }))
}
+3 -3
ファイルの表示
@@ -43,7 +43,7 @@ describe ('Help prefetch integration', () => {
<PrefetchLink to="/help/guide">Help</PrefetchLink>
<Routes>
<Route path="/" element={null}/>
<Route path="/help/:identifier" element={<HelpDetailPage/>}/>
<Route path="/ja/help/:identifier" element={<HelpDetailPage/>}/>
</Routes>
</>)
const loader = helpFixture.moduleLoads['../../help/guide/editions/ja/2026-10-02.10.mdx']
@@ -84,14 +84,14 @@ describe ('Help prefetch integration', () => {
<PrefetchLink to={path}>diff</PrefetchLink>
<Routes>
<Route path="/" element={null}/>
<Route path="/help/:identifier/diff" element={<HelpDiffPage/>}/>
<Route path="/ja/help/:identifier/diff" element={<HelpDiffPage/>}/>
</Routes>
</>)
await Promise.all ([prefetchers.prefetchForURL (queryClient, path),
prefetchers.prefetchForURL (queryClient, path)])
fireEvent.click (screen.getByRole ('link', { name: 'diff' }))
expect (await screen.findByRole ('link', { name: '旧ホーム (2026-10-01)' }))
.toHaveAttribute ('href', '/help/guide?edition=2026-10-01')
.toHaveAttribute ('href', '/ja/help/guide?edition=2026-10-01')
expect (screen.getByText ('+ # ホーム第十版')).toBeInTheDocument ()
Object.entries (helpFixture.raw).forEach (([name, load]) => {
const expected = /\/ja\/(2026-10-01|2026-10-02\.10)\.mdx$/.test (name) ? 1 : 0
+141
ファイルの表示
@@ -0,0 +1,141 @@
import { createInstance } from 'i18next'
import { initReactI18next } from 'react-i18next'
import type { i18n as I18n, TOptions } from 'i18next'
import type { LocaleCode } from '@/lib/locales'
// Keep Japanese placeholders editable without reporting them as completed translations.
const translatedResource = (resource: Record<string, unknown>): Record<string, unknown> => {
const untranslated = new Set (
Array.isArray (resource._untranslated) ? resource._untranslated : [])
if (untranslated.has ('*'))
return { }
const select = (entries: Record<string, unknown>, prefix = ''): Record<string, unknown> =>
Object.fromEntries (Object.entries (entries).flatMap (([key, value]) => {
const path = prefix ? `${ prefix }.${ key }` : key
if (key === '_untranslated' || untranslated.has (path))
return []
const translated =
(value != null && typeof value === 'object' && !(Array.isArray (value)))
? select (value as Record<string, unknown>, path)
: value
return [[key, translated]]
}))
return select (resource)
}
const dictionaries = import.meta.glob<Record<string, unknown>> ('../locales/*/*.json', {
eager: true, import: 'default' })
const resources: Record<string, Record<string, Record<string, unknown>>> = { }
for (const [path, dictionary] of Object.entries (dictionaries))
{
const [, , locale, filename] = path.split ('/')
resources[locale] ??= { }
resources[locale][filename.slice (0, -5)] =
locale === 'ain-Kana' ? translatedResource (dictionary) : dictionary
}
const namespaces = Object.keys (resources.ja ?? { })
export const createUII18n = (locale: string): I18n => {
const instance = createInstance ()
void instance.init ({
lng: locale,
fallbackLng: 'ja',
supportedLngs: ['ja', 'ain-Kana'],
load: 'currentOnly',
initAsync: false,
defaultNS: 'navigation',
ns: namespaces,
resources,
interpolation: { escapeValue: false },
returnNull: false,
returnEmptyString: false,
saveMissing: import.meta.env.DEV,
missingKeyHandler: (languages, namespace, key) => {
console.warn ('[i18n] missing key', { languages, namespace, key })
},
react: { useSuspense: false } })
return instance
}
// This instance is used only by legacy helpers with an explicit lng option.
export const i18n = createUII18n ('ja')
initReactI18next.init (i18n)
export const translateUIMessage = (
key: string,
namespace: string,
locale: string,
values: Record<string, string | number> = { },
instance: I18n = i18n,
): { text: string; language: string } => {
const languages = new Set<string> ()
const visiting = new Set<string> ()
const collectLanguages = (reference: string, options: TOptions): string => {
const details = instance.t (reference, { ...options,
returnDetails: true, returnObjects: false, nest: false, skipInterpolation: true })
const identity = `${details.usedLng}:${details.usedNS}:${details.exactUsedKey}`
if (visiting.has (identity) || typeof details.res !== 'string')
return details.usedLng
visiting.add (identity)
const nestingOptions = { ...instance.options.interpolation, ...options,
ns: details.usedNS, lng: details.usedLng }
const remainder = instance.services.interpolator.nest (details.res,
(nestedKey: string, nestedOptions: TOptions) => {
collectLanguages (nestedKey, nestedOptions)
return ' '
}, nestingOptions)
// References, punctuation and supplied values do not define the wrapper's language.
if (/\p{L}/u.test (remainder.replace (/\{\{[^}]*\}\}/g, '')))
languages.add (details.usedLng)
visiting.delete (identity)
return details.usedLng
}
const options = { ...values, ns: namespace, lng: locale }
const resolvedLanguage = collectLanguages (key, options)
// A single lang attribute cannot describe mixed fallback text: use the full Japanese message.
const mixed = languages.size > 1
const language = mixed ? 'ja' : languages.values ().next ().value ?? resolvedLanguage
const text = instance.t (key, { ...options, lng: mixed ? 'ja' : resolvedLanguage,
returnDetails: false, returnObjects: false })
if (typeof text !== 'string')
{
console.warn ('[i18n] UI message must be a string', { key, namespace, locale })
return { text: key, language }
}
return { text, language }
}
export const untranslatedKeys = (
locale: LocaleCode,
namespace = 'navigation',
): string[] => {
const collect = (resource: Record<string, unknown>, prefix = ''): string[] =>
Object.entries (resource).flatMap (([key, value]) => {
const path = prefix ? `${ prefix }.${ key }` : key
return (value != null && typeof value === 'object')
? collect (value as Record<string, unknown>, path)
: [path]
})
const baseline = i18n.getResourceBundle ('ja', namespace) ?? { }
return collect (baseline).filter (key => {
const value = i18n.getResource (locale, namespace, key)
return typeof value !== 'string' || value.length === 0
})
}
if (import.meta.env.DEV)
{
for (const namespace of namespaces)
{
const missing = untranslatedKeys ('ain-Kana', namespace)
if (missing.length > 0)
console.warn (`[i18n] ain-Kana: untranslated ${ namespace } keys`, missing)
}
}
+49 -21
ファイルの表示
@@ -1,3 +1,6 @@
import { unlocalisedPath } from '@/lib/locales'
import { i18n } from '@/lib/i18n'
export type ShortcutScope =
| 'global'
| 'posts'
@@ -37,85 +40,109 @@ export type ShortcutDefinition = {
}
export const SHORTCUT_SCOPE_LABELS: Record<ShortcutScope, string> = {
global: '全画面',
posts: '広場',
postSearch: '広場検索',
tags: 'タグ',
settings: '設定',
wiki: 'Wiki',
materials: '素材',
get global () { return i18n.t ('settings:shortcuts.scopes.global') },
get posts () { return i18n.t ('settings:shortcuts.scopes.posts') },
get postSearch () { return i18n.t ('settings:shortcuts.scopes.postSearch') },
get tags () { return i18n.t ('settings:shortcuts.scopes.tags') },
get settings () { return i18n.t ('settings:shortcuts.scopes.settings') },
get wiki () { return i18n.t ('settings:shortcuts.scopes.wiki') },
get materials () { return i18n.t ('settings:shortcuts.scopes.materials') },
}
export const SHORTCUT_DEFINITIONS: ShortcutDefinition[] = [
{
id: 'global.openShortcutHelp',
label: 'ショートカット一覧を開く',
get label () {
return i18n.t ('settings:shortcuts.actions.global.openShortcutHelp')
},
scope: 'global',
defaultBinding: { key: '?', shift: true },
},
{
id: 'global.focusSearch',
label: '検索欄へ移動',
get label () {
return i18n.t ('settings:shortcuts.actions.global.focusSearch')
},
scope: 'global',
defaultBinding: { key: '/' },
},
{
id: 'navigation.posts',
label: '広場へ移動',
get label () {
return i18n.t ('settings:shortcuts.actions.navigation.posts')
},
scope: 'global',
defaultBinding: { key: 'p' },
},
{
id: 'navigation.tags',
label: 'タグへ移動',
get label () {
return i18n.t ('settings:shortcuts.actions.navigation.tags')
},
scope: 'global',
defaultBinding: { key: 't' },
},
{
id: 'navigation.materials',
label: '素材へ移動',
get label () {
return i18n.t ('settings:shortcuts.actions.navigation.materials')
},
scope: 'global',
defaultBinding: { key: 'm' },
},
{
id: 'navigation.wiki',
label: 'Wiki へ移動',
get label () {
return i18n.t ('settings:shortcuts.actions.navigation.wiki')
},
scope: 'global',
defaultBinding: { key: 'w' },
},
{
id: 'pagination.next',
label: '次のページへ移動',
get label () {
return i18n.t ('settings:shortcuts.actions.pagination.next')
},
scope: 'global',
defaultBinding: { key: 'ArrowRight' },
},
{
id: 'pagination.previous',
label: '前のページへ移動',
get label () {
return i18n.t ('settings:shortcuts.actions.pagination.previous')
},
scope: 'global',
defaultBinding: { key: 'ArrowLeft' },
},
{
id: 'settings.account',
label: '設定: アカウント',
get label () {
return i18n.t ('settings:shortcuts.actions.settings.account')
},
scope: 'settings',
defaultBinding: { key: '1' },
},
{
id: 'settings.behavior',
label: '設定: 動作',
get label () {
return i18n.t ('settings:shortcuts.actions.settings.behavior')
},
scope: 'settings',
defaultBinding: { key: '2' },
},
{
id: 'settings.theme',
label: '設定: テーマ',
get label () {
return i18n.t ('settings:shortcuts.actions.settings.theme')
},
scope: 'settings',
defaultBinding: { key: '3' },
},
{
id: 'settings.keyboard',
label: '設定: キーボード',
get label () {
return i18n.t ('settings:shortcuts.actions.settings.keyboard')
},
scope: 'settings',
defaultBinding: { key: '4' },
},
@@ -249,7 +276,7 @@ const keyLabel = (key: string): string => {
export const formatKeyBinding = (binding: KeyBinding | null): string => {
if (binding == null)
return '未割当'
return i18n.t ('settings:shortcuts.unassigned')
return [
binding.ctrl ? 'Ctrl' : null,
@@ -308,7 +335,8 @@ export const getConflictingShortcutActionIds = (
)
}
export const resolveShortcutScope = (pathname: string): ShortcutScope => {
export const resolveShortcutScope = (fullPathname: string): ShortcutScope => {
const pathname = unlocalisedPath (fullPathname)
if (pathname.startsWith ('/posts/search'))
return 'postSearch'
if (pathname.startsWith ('/posts'))
+55
ファイルの表示
@@ -0,0 +1,55 @@
import { describe, expect, it } from 'vitest'
import { isLocaleCode,
legacyLocalePath,
localeFromPath,
localiseTo,
switchLocalePath,
unlocalisedPath } from '@/lib/locales'
describe ('locale URL generation', () => {
it.each (['ja', 'ain-Kana']) ('recognises the supported locale %s', locale => {
expect (isLocaleCode (locale)).toBe (true)
expect (localeFromPath (`/${ locale }/posts`)).toBe (locale)
})
it.each (['en', 'ain', 'JA', '', undefined]) ('rejects %s', locale => {
expect (isLocaleCode (locale)).toBe (false)
})
it.each ([
'/posts', '/posts/new', '/posts/search', '/posts/42', '/posts/changes',
'/tags', '/tags/42', '/tags/42/deerjikists', '/tags/nico', '/nico/tags', '/tags/changes',
'/materials', '/materials/new', '/materials/42', '/materials/changes',
'/materials/suppressions', '/theatres/42', '/wiki', '/wiki/new', '/wiki/title',
'/wiki/42/edit', '/wiki/42/diff', '/wiki/changes', '/help', '/help/changes',
'/help/article', '/help/article/diff', '/users/settings', '/settings',
'/tos', '/gekanator', '/more']) ('localises and redirects %s', pathname => {
const location = { pathname, search: '?page=2&tag=a%23b', hash: '#section' }
expect (localiseTo (pathname, 'ain-Kana')).toBe (`/ain-Kana${ pathname }`)
expect (legacyLocalePath (location)).toBe (`/ja${ pathname }${ location.search }#section`)
expect (unlocalisedPath (`/ain-Kana${ pathname }`)).toBe (pathname)
})
it ('preserves the resource, query and fragment on locale changes', () => {
expect (switchLocalePath ({ pathname: '/ja/tags/42', search: '?x=1&x=2', hash: '#history' },
'ain-Kana')).toBe ('/ain-Kana/tags/42?x=1&x=2#history')
})
it ('preserves explicit locales and relative, fragment and external targets', () => {
for (const target of ['/ja/posts', '/ain-Kana/posts', '?page=2', '#body',
'../changes', 'https://example.com/posts', '//example.com/posts'])
expect (localiseTo (target, 'ain-Kana')).toBe (target)
})
it ('preserves structured targets', () => {
expect (localiseTo ({ pathname: '/tags/42', search: '?q=one', hash: '#two' }, 'ain-Kana'))
.toEqual ({ pathname: '/ain-Kana/tags/42', search: '?q=one', hash: '#two' })
})
it.each (['/en/posts', '/ain/posts', '/unknown', '/api/tags', '/assets/image.png']) (
'does not redirect invalid or non-page paths: %s', pathname => {
expect (legacyLocalePath ({ pathname, search: '', hash: '' })).toBeNull ()
})
})
+83
ファイルの表示
@@ -0,0 +1,83 @@
import { apiGet } from '@/lib/api'
import { currentRenderContext } from '@/lib/renderContext'
import type { To } from 'react-router-dom'
export const SUPPORTED_LOCALES = ['ja', 'ain-Kana'] as const
export type LocaleCode = typeof SUPPORTED_LOCALES[number]
export type AvailableLocale = { code: string; name: string }
export const fetchLocales = (): Promise<{ locales: AvailableLocale[] }> =>
apiGet ('/locales')
export const LOCALE_LABELS: Record<LocaleCode, string> = {
ja: '日本語', 'ain-Kana': 'ヤウンモシルン アイヌ イタㇰ' }
const LEGACY_ROOTS = [
'posts', 'tags', 'nico', 'theatres', 'materials', 'wiki', 'help',
'users', 'settings', 'tos', 'gekanator', 'more']
export const isLocaleCode = (value: unknown): value is LocaleCode =>
SUPPORTED_LOCALES.some (locale => locale === value)
export const localeLanguage = (locale: LocaleCode): 'ja' | 'ain' =>
locale === 'ja' ? 'ja' : 'ain'
export const localeFromPath = (pathname: string): LocaleCode | null => {
const segment = pathname.split (/[/?#]/)[1]
return isLocaleCode (segment) ? segment : null
}
export const resolveViewingLocale = (
pathname: string,
routeLocale?: unknown,
): LocaleCode =>
isLocaleCode (routeLocale) ? routeLocale : localeFromPath (pathname) ?? 'ja'
export const currentViewingLocale = (): LocaleCode =>
resolveViewingLocale (currentRenderContext ()?.pathname ?? '')
export const unlocalisedPath = (pathname: string): string => {
const locale = localeFromPath (pathname)
return locale == null ? pathname : pathname.slice (locale.length + 1) || '/'
}
export const localiseTo = (to: To, locale: LocaleCode): To => {
const pathname = typeof to === 'string' ? to : to.pathname
if (pathname == null
|| !(pathname.startsWith ('/'))
|| pathname.startsWith ('//')
|| localeFromPath (pathname) != null)
return to
const localised = `/${ locale }${ pathname }`
return typeof to === 'string' ? localised : { ...to, pathname: localised }
}
export const switchLocalePath = (
location: { pathname: string; search: string; hash: string },
locale: LocaleCode,
): string =>
`/${ locale }${ unlocalisedPath (location.pathname) }${ location.search }${ location.hash }`
export const legacyLocalePath = (
location: { pathname: string; search: string; hash: string },
): string | null => {
const root = location.pathname.split ('/')[1]
return (location.pathname === '/' || LEGACY_ROOTS.includes (root))
? `/ja${ location.pathname }${ location.search }${ location.hash }`
: null
}
+38 -17
ファイルの表示
@@ -1,5 +1,6 @@
import { apiGet, isApiError, apiPost, apiPut } from '@/lib/api'
import type { LocaleCode } from '@/lib/locales'
import type { Material,
MaterialIndexResponse,
MaterialVersion,
@@ -36,8 +37,10 @@ export const fetchMaterials = async (
updatedFrom, updatedTo, sort, direction, page,
tagId, includeDescendants, groupBy,
limit }: FetchMaterialsParams,
locale: LocaleCode = 'ja',
): Promise<MaterialIndexResponse> =>
await apiGet ('/materials', { params: {
await apiGet ('/materials', { locale, params: {
...(q && { q }),
tag_state: tagState,
media_kind: mediaKind,
@@ -54,10 +57,13 @@ export const fetchMaterials = async (
limit} })
export const fetchMaterial = async (id: string): Promise<Material | null> => {
export const fetchMaterial = async (
id: string,
locale: LocaleCode = 'ja',
): Promise<Material | null> => {
try
{
return await apiGet (`/materials/${ id }`)
return await apiGet (`/materials/${ id }`, { locale })
}
catch (error)
{
@@ -75,8 +81,10 @@ export const fetchMaterialChanges = async (
eventType?: string
page: number
limit: number
}): Promise<MaterialChangesResponse> =>
await apiGet ('/materials/versions', { params: {
},
locale: LocaleCode = 'ja',
): Promise<MaterialChangesResponse> =>
await apiGet ('/materials/versions', { locale, params: {
...(materialId && { material_id: materialId }),
...(tag && { tag }),
...(eventType && { event_type: eventType }),
@@ -85,19 +93,23 @@ export const fetchMaterialChanges = async (
export const fetchMaterialTagTree = async (
{ parentId, materialFilter }: FetchMaterialTreeParams): Promise<MaterialSidebarTag[]> =>
await apiGet ('/tags/with-depth', { params: {
{ parentId, materialFilter }: FetchMaterialTreeParams,
locale: LocaleCode = 'ja',
): Promise<MaterialSidebarTag[]> =>
await apiGet ('/tags/with-depth', { locale, params: {
...(parentId != null && { parent: String (parentId) }),
material_filter: materialFilter} })
export const fetchMaterialTagByName = async (
name: string,
materialFilter: MaterialFilter): Promise<MaterialTagTree | null> => {
materialFilter: MaterialFilter,
locale: LocaleCode = 'ja',
): Promise<MaterialTagTree | null> => {
try
{
return await apiGet (`/tags/name/${ encodeURIComponent (name) }/materials`,
{ params: { material_filter: materialFilter } })
{ locale, params: { material_filter: materialFilter } })
}
catch (error)
{
@@ -108,19 +120,26 @@ export const fetchMaterialTagByName = async (
}
export const createMaterial = async (formData: FormData): Promise<Material> =>
await apiPost ('/materials', formData)
export const createMaterial = async (
formData: FormData,
locale: LocaleCode = 'ja',
): Promise<Material> =>
await apiPost ('/materials', formData, { locale })
export const updateMaterial = async (
id: string,
formData: FormData): Promise<Material> =>
await apiPut (`/materials/${ id }`, formData)
formData: FormData,
locale: LocaleCode = 'ja',
): Promise<Material> =>
await apiPut (`/materials/${ id }`, formData, { locale })
export const fetchMaterialSyncSuppressions =
async (): Promise<MaterialSyncSuppressionResponse> =>
await apiGet ('/materials/suppressions')
async (
locale: LocaleCode = 'ja',
): Promise<MaterialSyncSuppressionResponse> =>
await apiGet ('/materials/suppressions', { locale })
export const createMaterialSyncSuppression = async (
@@ -130,10 +149,12 @@ export const createMaterialSyncSuppression = async (
drivePath?: string
driveFileId?: string
reason: string
}): Promise<MaterialSyncSuppression> =>
},
locale: LocaleCode = 'ja',
): Promise<MaterialSyncSuppression> =>
await apiPost ('/materials/suppressions', {
source_kind: payload.sourceKind,
source_uri: payload.sourceUri,
drive_path: payload.drivePath,
drive_file_id: payload.driveFileId,
reason: payload.reason})
reason: payload.reason}, { locale })
+19
ファイルの表示
@@ -0,0 +1,19 @@
import { translateUIMessage } from '@/lib/i18n'
import type navigation from '@/locales/ja/navigation.json'
import type { LocaleCode } from '@/lib/locales'
export type UIMessageKey = keyof typeof navigation
export const uiMessage = (
locale: LocaleCode,
key: UIMessageKey,
values: Record<string, string | number> = { },
): string => translateUIMessage (key, 'navigation', locale, values).text
export const uiMessageLanguage = (locale: LocaleCode, key: UIMessageKey): 'ja' | 'ain' =>
translateUIMessage (key, 'navigation', locale).language === 'ain-Kana'
? 'ain'
: 'ja'
+10 -6
ファイルの表示
@@ -1,3 +1,5 @@
import { translateUIMessage } from '@/lib/i18n'
import type { PostImportSourceIssue } from '@/lib/postImportTypes'
const MAX_ROWS = 100
@@ -67,7 +69,8 @@ export const validateImportSource = (
{
issues.push ({
sourceRow,
message: `取込件数は ${ MAX_ROWS } 件までです.`,
message: translateUIMessage ('validation.tooMany', 'imports', 'ja', {
count: MAX_ROWS }).text,
url: displayUrl })
return
}
@@ -75,7 +78,7 @@ export const validateImportSource = (
{
issues.push ({
sourceRow,
message: 'URL が長すぎます.',
message: translateUIMessage ('validation.urlTooLong', 'imports', 'ja').text,
url: displayUrl })
return
}
@@ -84,7 +87,7 @@ export const validateImportSource = (
{
issues.push ({
sourceRow,
message: 'URL の形式が不正です.',
message: translateUIMessage ('validation.invalidUrl', 'imports', 'ja').text,
url: displayUrl })
return
}
@@ -92,7 +95,7 @@ export const validateImportSource = (
{
issues.push ({
sourceRow,
message: 'HTTP または HTTPS の URL ではありません.',
message: translateUIMessage ('validation.protocol', 'imports', 'ja').text,
url: displayUrl })
return
}
@@ -100,7 +103,7 @@ export const validateImportSource = (
{
issues.push ({
sourceRow,
message: 'URL の形式が不正です.',
message: translateUIMessage ('validation.invalidUrl', 'imports', 'ja').text,
url: displayUrl })
return
}
@@ -110,7 +113,8 @@ export const validateImportSource = (
{
issues.push ({
sourceRow,
message: `${ duplicateRow } 行目と同じ URL です.`,
message: translateUIMessage ('validation.duplicateRow', 'imports', 'ja', {
row: duplicateRow }).text,
url: displayUrl })
return
}
+5 -3
ファイルの表示
@@ -1,3 +1,5 @@
import { translateUIMessage } from '@/lib/i18n'
import type { StorageErrorHandler } from '@/lib/postImportTypes'
const SOURCE_DRAFT_KEY = 'post-import-source-draft'
@@ -16,7 +18,7 @@ const readStorage = (
}
catch
{
onError?.('保存済みデータを読み込めませんでした.')
onError?.(translateUIMessage ('storage.readFailed', 'imports', 'ja').text)
return null
}
}
@@ -37,7 +39,7 @@ const writeStorage = (
}
catch
{
onError?.('ブラウザへ保存できませんでした.')
onError?.(translateUIMessage ('storage.writeFailed', 'imports', 'ja').text)
return false
}
}
@@ -56,7 +58,7 @@ const removeStorage = (
}
catch
{
onError?.('保存済みデータを削除できませんでした.')
onError?.(translateUIMessage ('storage.clearFailed', 'imports', 'ja').text)
}
}
+4 -1
ファイルの表示
@@ -48,6 +48,7 @@ describe ('posts API functions', () => {
expect (api.apiGet).toHaveBeenCalledWith (
'/posts',
{
locale: 'ja',
params: {
title: 'title',
tags: 'a b',
@@ -89,6 +90,7 @@ describe ('posts API functions', () => {
original_created_before: '2026-01-02T00:00:00Z',
},
{
locale: 'ja',
params: {
base_version_no: '7',
force: '1',
@@ -113,7 +115,7 @@ describe ('posts API functions', () => {
expect (api.apiGet).toHaveBeenCalledWith (
'/posts/versions',
{ params: { page: 2, limit: 50 } },
{ locale: 'ja', params: { page: 2, limit: 50 } },
)
})
@@ -129,6 +131,7 @@ describe ('posts API functions', () => {
expect (api.apiGet).toHaveBeenCalledWith (
'/posts/versions',
{
locale: 'ja',
params: {
external_tag: '7',
page: 2,
+48 -8
ファイルの表示
@@ -1,17 +1,23 @@
import { apiDelete, apiGet, apiPost, apiPut } from '@/lib/api'
import { switchLocalePath } from '@/lib/locales'
import type { LocaleCode } from '@/lib/locales'
import type { LocaleSwitchRequest } from '@/lib/routeLocales'
import type { FetchPostsParams, Post, PostVersion } from '@/types'
export const fetchPosts = async (
{ url, title, tags, match, createdFrom, createdTo, updatedFrom, updatedTo,
originalCreatedFrom, originalCreatedTo, page, limit, order }: FetchPostsParams): Promise<{
{ url, title, tags, tagReferences, match, createdFrom, createdTo, updatedFrom, updatedTo,
originalCreatedFrom, originalCreatedTo, page, limit, order }: FetchPostsParams,
locale: LocaleCode = 'ja',
): Promise<{
posts: Post[]
count: number }> =>
await apiGet ('/posts', { params: {
await apiGet ('/posts', { locale, params: {
...(url && { url }),
...(title && { title }),
...(tags && { tags }),
...(tagReferences && { tag_refs: tagReferences }),
...(match && { match }),
...(createdFrom && { created_from: createdFrom }),
...(createdTo && { created_to: createdTo }),
@@ -24,7 +30,37 @@ export const fetchPosts = async (
...(order && { order }) } })
export const fetchPost = async (id: string): Promise<Post> => await apiGet (`/posts/${ id }`)
export const switchPostFilterLocale = async (
{ location, locale, targetLocale }: LocaleSwitchRequest,
): Promise<string> => {
const query = new URLSearchParams (location.search)
const tags = query.get ('tags')
if (tags == null || tags.trim () === '')
return switchLocalePath (location, targetLocale)
const { references } = await apiGet<{ references: {
name: string
tagId?: number
externalTagId?: number
negative?: boolean
missing?: boolean }[] }> ('/posts/tag_filters', {
locale, params: { tags, target_locale: targetLocale,
tag_refs: query.get ('tag_refs') ?? undefined } })
query.set ('tags', references.map (reference => reference.name).join (' '))
query.set ('tag_refs', JSON.stringify (references.map (reference => ({
name: reference.name,
negative: reference.negative,
...(reference.tagId != null && { tag_id: reference.tagId }),
...(reference.externalTagId != null && { external_tag_id: reference.externalTagId }),
...(reference.missing && { missing: true }) }))))
return switchLocalePath ({ ...location, search: `?${query}` }, targetLocale)
}
export const fetchPost = async (
id: string,
locale: LocaleCode = 'ja',
): Promise<Post> => await apiGet (`/posts/${ id }`, { locale })
export const fetchPostChanges = async (
@@ -33,10 +69,12 @@ export const fetchPostChanges = async (
tag?: string
externalTag?: string
page: number
limit: number }): Promise<{
limit: number },
locale: LocaleCode = 'ja',
): Promise<{
versions: PostVersion[]
count: number }> =>
await apiGet ('/posts/versions', { params: { ...(post && { post }),
await apiGet ('/posts/versions', { locale, params: { ...(post && { post }),
...(tag && { tag }),
...(externalTag && {
external_tag: externalTag }),
@@ -54,7 +92,9 @@ export const updatePost = async (
{ baseVersionNo, force, merge }: {
baseVersionNo?: number
force?: boolean
merge?: boolean }) =>
merge?: boolean },
locale: LocaleCode = 'ja',
) =>
await apiPut<Post> (
`/posts/${ post.id }`,
{ title: post.title,
@@ -63,7 +103,7 @@ export const updatePost = async (
parent_post_ids: post.parentPostIds,
original_created_from: post.originalCreatedFrom,
original_created_before: post.originalCreatedBefore },
{ params: { ...(baseVersionNo && { base_version_no: String (baseVersionNo) }),
{ locale, params: { ...(baseVersionNo && { base_version_no: String (baseVersionNo) }),
force: force ? '1' : '0',
merge: merge ? '1' : '0' } })
+26 -19
ファイルの表示
@@ -21,6 +21,9 @@ const wikiApi = vi.hoisted (() => ({
fetchWikiPage: vi.fn (),
fetchWikiPageByTitle: vi.fn (),
fetchWikiPages: vi.fn (),
fetchWikiPageByReference: vi.fn (),
fetchWikiAutolinkCandidates: vi.fn (),
fetchWikiTag: vi.fn (),
}))
vi.mock ('@/lib/posts', () => postsApi)
@@ -45,12 +48,15 @@ describe ('prefetchForURL', () => {
wikiApi.fetchWikiPages.mockResolvedValue ([])
wikiApi.fetchWikiPage.mockResolvedValue ({ id: 1 })
wikiApi.fetchWikiPageByTitle.mockResolvedValue (null)
wikiApi.fetchWikiPageByReference.mockResolvedValue (null)
wikiApi.fetchWikiAutolinkCandidates.mockResolvedValue ([])
wikiApi.fetchWikiTag.mockResolvedValue (null)
})
it ('prefetches post indexes from query parameters', async () => {
await prefetchForURL (
qc (),
'http://localhost/posts?tags=a+b&match=any&page=2&limit=5&order=title%3Aasc',
'/ja/posts?tags=a+b&match=any&page=2&limit=5&order=title%3Aasc',
)
expect (postsApi.fetchPosts).toHaveBeenCalledWith (
@@ -60,20 +66,20 @@ describe ('prefetchForURL', () => {
page: 2,
limit: 5,
order: 'title:asc',
}),
}), 'ja',
)
})
it ('prefetches post detail pages', async () => {
await prefetchForURL (qc (), 'http://localhost/posts/12')
await prefetchForURL (qc (), '/ja/posts/12')
expect (postsApi.fetchPost).toHaveBeenCalledWith ('12')
expect (postsApi.fetchPost).toHaveBeenCalledWith ('12', 'ja')
})
it ('prefetches tag indexes from query parameters', async () => {
await prefetchForURL (
qc (),
'http://localhost/tags?post=9&name=x&category=general&page=4&post_count_lte=10',
'/ja/tags?post=9&name=x&category=general&page=4&post_count_lte=10',
)
expect (tagsApi.fetchTags).toHaveBeenCalledWith (
@@ -83,17 +89,17 @@ describe ('prefetchForURL', () => {
category: 'general',
page: 4,
postCountLTE: 10,
}),
}), 'ja',
)
})
it ('prefetches nico tag indexes and their alias from query parameters', async () => {
await prefetchForURL (
qc (),
'http://localhost/tags/nico?name=source&linked_tag=destination'
'/ja/tags/nico?name=source&linked_tag=destination'
+ '&link_status=linked&page=3&limit=10',
)
await prefetchForURL (qc (), 'http://localhost/nico/tags?page=2')
await prefetchForURL (qc (), '/ja/nico/tags?page=2')
expect (tagsApi.fetchNicoTags).toHaveBeenNthCalledWith (1, {
name: 'source',
@@ -102,7 +108,7 @@ describe ('prefetchForURL', () => {
page: 3,
limit: 10,
order: 'updated_at:desc',
})
}, 'ja')
expect (tagsApi.fetchNicoTags).toHaveBeenNthCalledWith (2, {
name: '',
linkedTag: '',
@@ -110,28 +116,29 @@ describe ('prefetchForURL', () => {
page: 2,
limit: 20,
order: 'updated_at:desc',
})
}, 'ja')
})
it ('prefetches wiki show pages and related tag/post data', async () => {
wikiApi.fetchWikiPageByTitle.mockResolvedValueOnce ({
wikiApi.fetchWikiPageByReference.mockResolvedValueOnce ({
id: 3,
title: 'Actual',
body: 'body',
})
await prefetchForURL (qc (), 'http://localhost/wiki/Alias')
await prefetchForURL (qc (), '/ja/wiki/Alias')
expect (wikiApi.fetchWikiPageByTitle).toHaveBeenCalledWith ('Alias', { version: undefined })
expect (wikiApi.fetchWikiPage).toHaveBeenCalledWith ('3', {})
expect (tagsApi.fetchTagByName).toHaveBeenCalledWith ('Actual')
expect (wikiApi.fetchWikiPageByReference).toHaveBeenCalledWith (
'Alias', { version: undefined }, 'ja')
expect (wikiApi.fetchWikiPage).not.toHaveBeenCalled ()
expect (wikiApi.fetchWikiTag).toHaveBeenCalledWith ({ name: 'Actual' }, 'ja')
expect (postsApi.fetchPosts).toHaveBeenCalledWith (
expect.objectContaining ({ tags: 'Actual', limit: 8 }),
expect.objectContaining ({ tags: 'Actual', limit: 8 }), 'ja',
)
})
it ('ignores routes without a prefetcher', async () => {
await prefetchForURL (qc (), 'http://localhost/unknown')
await prefetchForURL (qc (), '/ja/unknown')
expect (postsApi.fetchPosts).not.toHaveBeenCalled ()
expect (tagsApi.fetchTags).not.toHaveBeenCalled ()
@@ -141,14 +148,14 @@ describe ('prefetchForURL', () => {
it ('prefetches external tag post history without treating it as an internal tag', async () => {
await prefetchForURL (
qc (),
'http://localhost/posts/changes?external_tag=12&page=2&limit=50',
'/ja/posts/changes?external_tag=12&page=2&limit=50',
)
expect (postsApi.fetchPostChanges).toHaveBeenCalledWith ({
externalTag: '12',
page: 2,
limit: 50,
})
}, 'ja')
expect (tagsApi.fetchTag).not.toHaveBeenCalled ()
})
+84 -67
ファイルの表示
@@ -5,17 +5,21 @@ import { getHelpArticle,
getHelpChanges,
getHelpDiff,
getHelpEditionContent,
getHelpSearchEntries } from '@/lib/help'
getHelpSearchEntries,
resolveHelpLanguage } from '@/lib/help'
import { localeFromPath, localeLanguage, unlocalisedPath } from '@/lib/locales'
import { fetchPost, fetchPosts, fetchPostChanges } from '@/lib/posts'
import { helpKeys, postsKeys, tagsKeys, wikiKeys } from '@/lib/queryKeys'
import { fetchNicoTags, fetchTagByName, fetchTag, fetchTagChanges, fetchTags } from '@/lib/tags'
import { fetchWikiPage,
fetchWikiPageByTitle,
import { fetchNicoTags, fetchTag, fetchTagChanges, fetchTags } from '@/lib/tags'
import { fetchWikiAutolinkCandidates,
fetchWikiPageByReference,
fetchWikiTag,
fetchWikiPages } from '@/lib/wiki'
import type { Category, FetchPostsOrder, FetchTagsOrder } from '@/types'
import type { LocaleCode } from '@/lib/locales'
import type { Category, FetchPostsOrder, FetchTagsOrder, WikiTagReference } from '@/types'
type Prefetcher = (qc: QueryClient, url: URL) => Promise<void>
type Prefetcher = (qc: QueryClient, url: URL, locale: LocaleCode) => Promise<void>
const mPost = match<{ id: string }> ('/posts/:id')
const mWiki = match<{ title: string }> ('/wiki/:title')
@@ -28,25 +32,27 @@ const boolFromQuery = (value: string | null): boolean =>
['1', 'true', 'on', 'yes', ''].includes ((value ?? '').toLowerCase ())
const prefetchHelpIndex: Prefetcher = async qc => {
const prefetchHelpIndex: Prefetcher = async (qc, _url, locale) => {
await qc.prefetchQuery ({
queryKey: helpKeys.index ('ja'),
queryFn: () => getHelpSearchEntries ('ja'),
queryKey: helpKeys.index (localeLanguage (locale)),
queryFn: () => getHelpSearchEntries (localeLanguage (locale)),
staleTime: Infinity })
}
const prefetchHelpShow: Prefetcher = async (qc, url) => {
const prefetchHelpShow: Prefetcher = async (qc, url, locale) => {
const matched = mHelp (url.pathname)
if (!(matched))
return
const { identifier } = matched.params
const language = 'ja'
const article = await qc.fetchQuery ({
queryKey: helpKeys.article (identifier),
queryFn: () => getHelpArticle (identifier),
staleTime: Infinity })
const language = resolveHelpLanguage (article, localeLanguage (locale))
if (language == null)
return
const edition = url.searchParams.get ('edition') ?? article?.languages[language]?.currentEdition
if (edition == null)
return
@@ -58,15 +64,15 @@ const prefetchHelpShow: Prefetcher = async (qc, url) => {
}
const prefetchHelpChanges: Prefetcher = async qc => {
const prefetchHelpChanges: Prefetcher = async (qc, _url, locale) => {
await qc.prefetchQuery ({
queryKey: helpKeys.changes ('ja'),
queryFn: () => getHelpChanges ('ja'),
queryKey: helpKeys.changes (localeLanguage (locale)),
queryFn: () => getHelpChanges (localeLanguage (locale)),
staleTime: Infinity })
}
const prefetchHelpDiff: Prefetcher = async (qc, url) => {
const prefetchHelpDiff: Prefetcher = async (qc, url, locale) => {
const matched = mHelpDiff (url.pathname)
const fromValues = url.searchParams.getAll ('from')
const toValues = url.searchParams.getAll ('to')
@@ -76,23 +82,30 @@ const prefetchHelpDiff: Prefetcher = async (qc, url) => {
return
const { identifier } = matched.params
const article = await qc.fetchQuery ({
queryKey: helpKeys.article (identifier),
queryFn: () => getHelpArticle (identifier),
staleTime: Infinity })
const language = resolveHelpLanguage (article, localeLanguage (locale))
if (language == null)
return
await qc.prefetchQuery ({
queryKey: helpKeys.diff (identifier, 'ja', from, to),
queryFn: () => getHelpDiff (identifier, 'ja', from, to),
queryKey: helpKeys.diff (identifier, language, from, to),
queryFn: () => getHelpDiff (identifier, language, from, to),
staleTime: Infinity })
}
const prefetchWikiPagesIndex: Prefetcher = async (qc, url) => {
const prefetchWikiPagesIndex: Prefetcher = async (qc, url, locale) => {
const title = url.searchParams.get ('title') ?? ''
await qc.prefetchQuery ({
queryKey: wikiKeys.index ({ title }),
queryFn: () => fetchWikiPages ({ title }) })
queryKey: wikiKeys.index ({ title }, locale),
queryFn: () => fetchWikiPages ({ title }, locale) })
}
const prefetchWikiPageShow: Prefetcher = async (qc, url) => {
const prefetchWikiPageShow: Prefetcher = async (qc, url, locale) => {
const m = mWiki (url.pathname)
if (!(m))
return
@@ -101,35 +114,32 @@ const prefetchWikiPageShow: Prefetcher = async (qc, url) => {
const version = url.searchParams.get ('version') || undefined
const wikiPage = await qc.fetchQuery ({
queryKey: wikiKeys.show (title, { version }),
queryFn: () => fetchWikiPageByTitle (title, { version }) })
queryKey: wikiKeys.show (title, { version }, locale),
queryFn: () => fetchWikiPageByReference (title, { version }, locale) })
if (wikiPage)
{
const effectiveId = String (wikiPage.id ?? '')
await qc.prefetchQuery ({
queryKey: wikiKeys.show (effectiveId, { }),
queryFn: () => fetchWikiPage (effectiveId, { } ) })
if (wikiPage.body)
{
await qc.prefetchQuery ({
queryKey: wikiKeys.index ({ }),
queryFn: () => fetchWikiPages ({ }) })
}
}
if (wikiPage?.body)
{
const contentLanguage = wikiPage.languageCode
?? (wikiPage.resourceType === 'tag_description' ? localeLanguage (locale) : 'und')
const language = contentLanguage === 'und' ? 'ja' : contentLanguage
await qc.prefetchQuery ({
queryKey: wikiKeys.autolinkCandidates (language, locale),
queryFn: () => fetchWikiAutolinkCandidates (language, locale) })
}
const effectiveTitle = wikiPage?.title ?? title
const tagReference: WikiTagReference = (wikiPage != null || !/^\d+$/.test (title))
? { name: effectiveTitle } : { wikiId: title }
await qc.prefetchQuery ({
queryKey: tagsKeys.show (effectiveTitle),
queryFn: () => fetchTagByName (effectiveTitle) })
const tag = await qc.fetchQuery ({
queryKey: wikiKeys.tag (tagReference, locale),
queryFn: () => fetchWikiTag (tagReference, locale) })
if (version)
return
const p = {
tags: effectiveTitle,
tags: wikiPage?.title ?? tag?.name ?? title,
match: 'all',
page: 1,
limit: 8,
@@ -143,12 +153,12 @@ const prefetchWikiPageShow: Prefetcher = async (qc, url) => {
updatedTo: '',
order: 'original_created_at:desc' } as const
await qc.prefetchQuery ({
queryKey: postsKeys.index (p),
queryFn: () => fetchPosts (p) })
queryKey: postsKeys.index (p, locale),
queryFn: () => fetchPosts (p, locale) })
}
const prefetchPostsIndex: Prefetcher = async (qc, url) => {
const prefetchPostsIndex: Prefetcher = async (qc, url, locale) => {
const tags = url.searchParams.get ('tags') ?? ''
const qURL = url.searchParams.get ('url') ?? ''
const title = url.searchParams.get ('title') ?? ''
@@ -165,16 +175,18 @@ const prefetchPostsIndex: Prefetcher = async (qc, url) => {
const keys = {
tags, match: m, page, limit, url: qURL, title,
...(url.searchParams.get ('tag_refs') && {
tagReferences: url.searchParams.get ('tag_refs')! }),
originalCreatedFrom, originalCreatedTo, createdFrom, createdTo,
updatedFrom, updatedTo, order }
await qc.prefetchQuery ({
queryKey: postsKeys.index (keys),
queryFn: () => fetchPosts (keys) })
queryKey: postsKeys.index (keys, locale),
queryFn: () => fetchPosts (keys, locale) })
}
const prefetchPostShow: Prefetcher = async (qc, url) => {
const prefetchPostShow: Prefetcher = async (qc, url, locale) => {
const m = mPost (url.pathname)
if (!(m))
return
@@ -182,12 +194,12 @@ const prefetchPostShow: Prefetcher = async (qc, url) => {
const { id } = m.params
await qc.prefetchQuery ({
queryKey: postsKeys.show (id),
queryFn: () => fetchPost (id) })
queryKey: postsKeys.show (id, locale),
queryFn: () => fetchPost (id, locale) })
}
const prefetchPostChanges: Prefetcher = async (qc, url) => {
const prefetchPostChanges: Prefetcher = async (qc, url, locale) => {
const id = url.searchParams.get ('id')
const tag = url.searchParams.get ('tag')
const externalTag = url.searchParams.get ('external_tag')
@@ -197,23 +209,23 @@ const prefetchPostChanges: Prefetcher = async (qc, url) => {
if (tag)
{
await qc.prefetchQuery ({
queryKey: tagsKeys.show (tag),
queryFn: () => fetchTag (tag) })
queryKey: tagsKeys.show (tag, locale),
queryFn: () => fetchTag (tag, locale) })
}
await qc.prefetchQuery ({
queryKey: postsKeys.changes ({ ...(id && { id }),
...(tag && { tag }),
...(externalTag && { externalTag }),
page, limit }),
page, limit }, locale),
queryFn: () => fetchPostChanges ({ ...(id && { id }),
...(tag && { tag }),
...(externalTag && { externalTag }),
page, limit }) })
page, limit }, locale) })
}
const prefetchTagsIndex: Prefetcher = async (qc, url) => {
const prefetchTagsIndex: Prefetcher = async (qc, url, locale) => {
const postRaw = url.searchParams.get ('post')
const post = postRaw ? Number (postRaw) : null
const name = url.searchParams.get ('name') ?? ''
@@ -237,12 +249,12 @@ const prefetchTagsIndex: Prefetcher = async (qc, url) => {
updatedFrom, updatedTo, deprecated, page, limit, order }
await qc.prefetchQuery ({
queryKey: tagsKeys.index (keys),
queryFn: () => fetchTags (keys) })
queryKey: tagsKeys.index (keys, locale),
queryFn: () => fetchTags (keys, locale) })
}
const prefetchNicoTagsIndex: Prefetcher = async (qc, url) => {
const prefetchNicoTagsIndex: Prefetcher = async (qc, url, locale) => {
const keys = {
name: url.searchParams.get ('name') ?? '',
linkedTag: url.searchParams.get ('linked_tag') ?? '',
@@ -255,12 +267,12 @@ const prefetchNicoTagsIndex: Prefetcher = async (qc, url) => {
| 'updated_at:asc' | 'updated_at:desc' }
await qc.prefetchQuery ({
queryKey: tagsKeys.nicoIndex (keys),
queryFn: () => fetchNicoTags (keys) })
queryKey: tagsKeys.nicoIndex (keys, locale),
queryFn: () => fetchNicoTags (keys, locale) })
}
const prefetchTagShow: Prefetcher = async (qc, url) => {
const prefetchTagShow: Prefetcher = async (qc, url, locale) => {
const m = mTag (url.pathname)
if (!(m))
return
@@ -268,19 +280,19 @@ const prefetchTagShow: Prefetcher = async (qc, url) => {
const { id } = m.params
await qc.prefetchQuery ({
queryKey: tagsKeys.show (id),
queryFn: () => fetchTag (id) })
queryKey: tagsKeys.show (id, locale),
queryFn: () => fetchTag (id, locale) })
}
const prefetchTagChanges: Prefetcher = async (qc, url) => {
const prefetchTagChanges: Prefetcher = async (qc, url, locale) => {
const id = url.searchParams.get ('id')
const page = Number (url.searchParams.get ('page') || 1)
const limit = Number (url.searchParams.get ('limit') || 20)
await qc.prefetchQuery ({
queryKey: tagsKeys.changes ({ ...(id && { id }), page, limit }),
queryFn: () => fetchTagChanges ({ ...(id && { id }), page, limit }) })
queryKey: tagsKeys.changes ({ ...(id && { id }), page, limit }, locale),
queryFn: () => fetchTagChanges ({ ...(id && { id }), page, limit }, locale) })
}
@@ -313,9 +325,14 @@ export const routePrefetchers: { test: (u: URL) => boolean; run: Prefetcher }[]
export const prefetchForURL = async (qc: QueryClient, urlLike: string): Promise<void> => {
const u = new URL (urlLike, location.origin)
if (u.origin !== location.origin)
return
const locale = localeFromPath (u.pathname) ?? 'ja'
u.pathname = unlocalisedPath (u.pathname)
const r = routePrefetchers.find (x => x.test (u))
if (!(r))
return
await r.run (qc, u)
await r.run (qc, u, locale)
}
+37 -5
ファイルの表示
@@ -1,14 +1,46 @@
import { describe, expect, it } from 'vitest'
import { QueryClient } from '@tanstack/react-query'
import { postsKeys, tagsKeys, wikiKeys } from '@/lib/queryKeys'
import type { FetchNicoTagsParams } from '@/types'
describe ('query keys', () => {
it ('uses stable namespaces for posts, tags, and wiki', () => {
expect (postsKeys.show ('3')).toEqual (['posts', '3'])
expect (postsKeys.related ('3')).toEqual (['related', '3'])
expect (tagsKeys.deerjikists ('7')).toEqual (['tags', 'deerjikists', '7'])
expect (postsKeys.show ('3')).toEqual (['posts', 'ja', '3'])
expect (postsKeys.related ('3')).toEqual (['related', 'ja', '3'])
expect (tagsKeys.deerjikists ('7')).toEqual (['tags', 'ja', 'deerjikists', '7'])
expect (wikiKeys.show ('Title', { version: '2' })).toEqual (
['wiki', 'Title', { version: '2' }],
)
['wiki', 'ja', 'Title', { version: '2' }])
})
it ('isolates the same resource by viewing locale', () => {
const client = new QueryClient ()
client.setQueryData (postsKeys.show ('3', 'ja'), { title: '日本語' })
client.setQueryData (postsKeys.show ('3', 'ain-Kana'), { title: 'アイヌ語' })
expect (client.getQueryData (postsKeys.show ('3', 'ja'))).toEqual ({ title: '日本語' })
expect (client.getQueryData (postsKeys.show ('3', 'ain-Kana')))
.toEqual ({ title: 'アイヌ語' })
client.clear ()
})
it ('invalidates both nico locales without invalidating ordinary tags', async () => {
const client = new QueryClient ()
const params: FetchNicoTagsParams = {
name: '', linkedTag: '', linkStatus: 'all', page: 1, limit: 20,
order: 'name:asc' }
const ja = tagsKeys.nicoIndex (params, 'ja')
const ain = tagsKeys.nicoIndex (params, 'ain-Kana')
const ordinary = tagsKeys.show ('3', 'ja')
for (const key of [ja, ain, ordinary])
client.setQueryData (key, [])
await client.invalidateQueries ({ queryKey: tagsKeys.nicoRoot })
expect (client.getQueryState (ja)?.isInvalidated).toBe (true)
expect (client.getQueryState (ain)?.isInvalidated).toBe (true)
expect (client.getQueryState (ordinary)?.isInvalidated).toBe (false)
client.clear ()
})
})
+120 -31
ファイルの表示
@@ -1,66 +1,145 @@
import type { LocaleCode } from '@/lib/locales'
import type {
FetchNicoTagsParams,
FetchMaterialsParams,
FetchPostsParams,
FetchTagsParams,
MaterialFilter,
} from '@/types'
WikiTagReference } from '@/types'
export const localesKeys = {
root: ['locales'] as const,
index: ['locales', 'index'] as const }
export const postsKeys = {
root: ['posts'] as const,
index: (p: FetchPostsParams) => ['posts', 'index', p] as const,
show: (id: string) => ['posts', id] as const,
related: (id: string) => ['related', id] as const,
changes: (p: { post?: string
index: (
p: FetchPostsParams,
locale: LocaleCode = 'ja',
) => ['posts', locale, 'index', p] as const,
show: (
id: string,
locale: LocaleCode = 'ja',
) => ['posts', locale, id] as const,
related: (
id: string,
locale: LocaleCode = 'ja',
) => ['related', locale, id] as const,
changes: (
p: { post?: string
tag?: string
externalTag?: string
page: number
limit: number }) =>
['posts', 'changes', p] as const }
limit: number },
locale: LocaleCode = 'ja',
) =>
['posts', locale, 'changes', p] as const }
export const gekanatorKeys = {
root: ['gekanator'] as const,
posts: () => ['gekanator', 'posts'] as const,
questions: () => ['gekanator', 'questions'] as const,
extraQuestions: (gameId: number, nonce: string) =>
['gekanator', 'games', gameId, 'extra-questions', nonce] as const }
posts: (
locale: LocaleCode = 'ja',
) => ['gekanator', locale, 'posts'] as const,
questions: (
locale: LocaleCode = 'ja',
) => ['gekanator', locale, 'questions'] as const,
extraQuestions: (
gameId: number,
nonce: string,
locale: LocaleCode = 'ja',
) =>
['gekanator', locale, 'games', gameId, 'extra-questions', nonce] as const }
export const tagsKeys = {
root: ['tags'] as const,
index: (p: FetchTagsParams) => ['tags', 'index', p] as const,
index: (
p: FetchTagsParams,
locale: LocaleCode = 'ja',
) => ['tags', locale, 'index', p] as const,
nicoRoot: ['tags', 'nico'] as const,
nicoIndex: (p: FetchNicoTagsParams) => ['tags', 'nico', 'index', p] as const,
externalShow: (id: string) => ['tags', 'nico', id] as const,
show: (name: string) => ['tags', name] as const,
changes: (p: { id?: string; page: number; limit: number }) =>
['tags', 'changes', p] as const,
deerjikists: (id: string) => ['tags', 'deerjikists', id] as const }
nicoIndex: (
p: FetchNicoTagsParams,
locale: LocaleCode = 'ja',
) => ['tags', 'nico', locale, 'index', p] as const,
externalShow: (
id: string,
locale: LocaleCode = 'ja',
) => ['tags', 'nico', locale, id] as const,
show: (
name: string,
locale: LocaleCode = 'ja',
) => ['tags', locale, name] as const,
changes: (
p: { id?: string; page: number; limit: number },
locale: LocaleCode = 'ja',
) =>
['tags', locale, 'changes', p] as const,
deerjikists: (
id: string,
locale: LocaleCode = 'ja',
) => ['tags', locale, 'deerjikists', id] as const }
export const materialsKeys = {
root: ['materials'] as const,
index: (p: FetchMaterialsParams) => ['materials', 'index', p] as const,
changes: (p: {
index: (
p: FetchMaterialsParams,
locale: LocaleCode = 'ja',
) => ['materials', locale, 'index', p] as const,
changes: (
p: {
materialId?: string
tag?: string
eventType?: string
page: number
limit: number
}) => ['materials', 'changes', p] as const,
byTagName: (name: string, materialFilter: MaterialFilter) =>
['materials', 'tag', name, materialFilter] as const,
show: (id: string) => ['materials', id] as const,
suppressions: () => ['materials', 'suppressions'] as const,
tree: (p: {
},
locale: LocaleCode = 'ja',
) => ['materials', locale, 'changes', p] as const,
byTagName: (
name: string,
materialFilter: MaterialFilter,
locale: LocaleCode = 'ja',
) =>
['materials', locale, 'tag', name, materialFilter] as const,
show: (
id: string,
locale: LocaleCode = 'ja',
) => ['materials', locale, id] as const,
suppressions: (
locale: LocaleCode = 'ja',
) => ['materials', locale, 'suppressions'] as const,
tree: (
p: {
parentId?: number | null
materialFilter: MaterialFilter
}) => ['materials', 'tree', p] as const,
unclassified: (p: { page?: number; limit?: number } = { }) =>
['materials', 'unclassified', p] as const}
},
locale: LocaleCode = 'ja',
) => ['materials', locale, 'tree', p] as const,
unclassified: (
p: { page?: number; limit?: number } = { },
locale: LocaleCode = 'ja',
) =>
['materials', locale, 'unclassified', p] as const}
export const wikiKeys = {
root: ['wiki'] as const,
index: (p: { title?: string }) => ['wiki', 'index', p] as const,
show: (title: string, p: { version?: string }) => ['wiki', title, p] as const }
tag: (reference: WikiTagReference, locale: LocaleCode) =>
['wiki', locale, 'tag', reference] 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: (
p: { title?: string },
locale: LocaleCode = 'ja',
) => ['wiki', locale, 'index', p] as const,
show: (
title: string,
p: { version?: string },
locale: LocaleCode = 'ja',
) => ['wiki', locale, title, p] as const,
changes: (p: { id?: string }, locale: LocaleCode = 'ja') =>
['wiki', locale, 'changes', 'index', p] as const }
export const helpKeys = {
root: ['help'] as const,
@@ -72,3 +151,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 }
+76 -51
ファイルの表示
@@ -4,65 +4,90 @@ import remarkWikiAutolink from '@/lib/remark-wiki-autolink'
import type { Root } from 'mdast'
describe ('remarkWikiAutolink', () => {
it ('links matching wiki page names and prefers longer matches', () => {
const tree: Root = {
type: 'root',
children: [{
type: 'paragraph',
children: [{ type: 'text', value: '虹夏 and 虹' }],
}],
}
import type { WikiAutolinkCandidate } from '@/types'
remarkWikiAutolink (['虹', '虹夏']) (tree)
const candidate = (
name: string,
resourceId: number,
overrides: Partial<WikiAutolinkCandidate> = {},
): WikiAutolinkCandidate => ({
name, resourceId, tagId: resourceId + 100, tagDescriptionId: resourceId,
resourceType: 'tag_description', languageCode: 'ja', localeCode: 'ain-Kana',
...overrides })
const paragraph = (value: string): Root => ({
type: 'root', children: [{
type: 'paragraph', children: [{ type: 'text', value }] }] })
describe ('remarkWikiAutolink', () => {
it ('prefers longer names and keeps the viewing locale for Japanese text', () => {
const tree = paragraph ('虹夏 and 虹')
remarkWikiAutolink ([candidate ('虹', 1), candidate ('虹夏', 2)],
'ja', 'ain-Kana') (tree)
expect (tree.children[0]).toMatchObject ({
type: 'paragraph',
children: [
{
type: 'link',
url: '/wiki/%E8%99%B9%E5%A4%8F',
children: [{ type: 'text', value: '虹夏' }],
},
{ type: 'text', value: ' and ' },
{
type: 'link',
url: '/wiki/%E8%99%B9',
children: [{ type: 'text', value: '虹' }],
},
],
})
children: [
{ type: 'link', url: '/ain-Kana/wiki/2',
children: [{ type: 'text', value: '虹夏' }] },
{ type: 'text', value: ' and ' },
{ type: 'link', url: '/ain-Kana/wiki/1',
children: [{ type: 'text', value: '虹' }] }] })
})
it ('does not link text inside existing links or code', () => {
it ('does not rewrite existing links, code or images', () => {
const tree: Root = {
type: 'root',
children: [
{
type: 'paragraph',
children: [{
type: 'link',
url: '/existing',
children: [{ type: 'text', value: '虹' }],
}],
},
{
type: 'code',
value: '虹',
},
],
}
type: 'root', children: [
{ type: 'paragraph', children: [
{ type: 'link', url: '/existing', children: [{ type: 'text', value: '虹' }] },
{ type: 'inlineCode', value: '虹' },
{ type: 'image', url: '/image', alt: '虹' }] },
{ type: 'code', value: '虹' }] }
const original = structuredClone (tree)
remarkWikiAutolink (['虹']) (tree)
remarkWikiAutolink ([candidate ('虹', 1)], 'ja', 'ain-Kana') (tree)
expect (tree).toEqual (original)
})
it ('links an alias to its description rather than its tag ID or matched name', () => {
const tree = paragraph ('別名')
remarkWikiAutolink ([candidate ('別名', 9, { tagId: 123 })],
'ja', 'ain-Kana') (tree)
expect (tree.children[0]).toMatchObject ({
type: 'paragraph',
children: [{
type: 'link',
url: '/existing',
children: [{ type: 'text', value: '虹' }],
}],
})
expect (tree.children[1]).toMatchObject ({ type: 'code', value: '虹' })
children: [{ type: 'link', url: '/ain-Kana/wiki/9' }] })
})
it ('leaves ambiguous names and other locales or languages unlinked', () => {
const tree = paragraph ('虹 名前 name')
const original = structuredClone (tree)
remarkWikiAutolink ([candidate ('虹', 1), candidate ('虹', 2),
candidate ('名前', 3, { localeCode: 'ja' }),
candidate ('name', 4, { languageCode: 'ain' })], 'ja', 'ain-Kana') (tree)
expect (tree).toEqual (original)
})
it ('links Ainu words only at word boundaries', () => {
const tree = paragraph ('チャペ チャペ。チャペア')
remarkWikiAutolink ([candidate ('チャペ', 5, { languageCode: 'ain' })],
'ain', 'ain-Kana') (tree)
expect (tree.children[0]).toMatchObject ({
children: [
{ type: 'link', url: '/ain-Kana/wiki/5' },
{ type: 'text', value: ' ' },
{ type: 'link', url: '/ain-Kana/wiki/5' },
{ type: 'text', value: '。チャペア' }] })
})
it ('preserves tag-group resource identity', () => {
const tree = paragraph ('group')
remarkWikiAutolink ([candidate ('group', 8, {
resourceType: 'tag_group', tagId: null, tagDescriptionId: null })],
'ja', 'ain-Kana') (tree)
expect (tree.children[0]).toMatchObject ({
children: [{ type: 'link', url: '/ain-Kana/wiki/8' }] })
})
})
+114 -84
ファイルの表示
@@ -1,101 +1,131 @@
import type { Content, Parent, Root, RootContent } from 'mdast'
import type { WikiAutolinkCandidate } from '@/types'
import type { LocaleCode } from '@/lib/locales'
const escapeForRegExp = (s: string) => s.replace (/[.*+?^${}()|[\]\\]/g, '\\$&')
export default (pageNames: string[], basePath = '/wiki'): ((tree: Root) => void) => {
const names = [...pageNames].sort ((a, b) => b.length - a.length)
const wordCharacter = /[\p{L}\p{M}\p{N}_]/u
const excluded = ['link', 'linkReference', 'image', 'imageReference',
'code', 'inlineCode', 'html']
if (names.length === 0)
const edgeCharacter = (node: Content, first: boolean): string => {
if (node.type === 'text' || node.type === 'inlineCode')
{
const characters = Array.from (node.value)
return (first ? characters[0] : characters.at (-1)) ?? ''
}
if ('children' in node)
{
const children = first ? node.children : [...node.children].reverse ()
for (const child of children)
{
return () => {
;
}
const character = edgeCharacter (child, first)
if (character !== '')
return character
}
return ''
}
return '\u3000'
}
const re = new RegExp (`(${ names.map (escapeForRegExp).join ('|') })`, 'g')
const remarkWikiAutoLink = (
candidates: WikiAutolinkCandidate[],
language: string,
locale: LocaleCode,
): ((tree: Root) => void) => {
const targets = new Map<string, WikiAutolinkCandidate> ()
const ambiguous = new Set<string> ()
for (const candidate of candidates)
{
if (candidate.languageCode !== language || candidate.localeCode !== locale
|| candidate.name.length === 0)
continue
const previous = targets.get (candidate.name)
if (previous != null
&& (previous.resourceType !== candidate.resourceType
|| previous.resourceId !== candidate.resourceId
|| previous.tagId !== candidate.tagId))
{
ambiguous.add (candidate.name)
continue
}
targets.set (candidate.name, candidate)
}
const names = [...targets.keys ()].sort ((a, b) => b.length - a.length)
if (names.length === 0)
return () => { }
const expression = new RegExp (names.map (escapeForRegExp).join ('|'), 'gu')
const ainu = language === 'ain'
return (tree: Root) => {
const edits: { parent: Parent; index: number; parts: RootContent[] }[] = []
const walk = (node: Content | Root, ancestors: Parent[]) => {
if (!(node) || (typeof node !== 'object'))
const walk = (
node: Content | Root,
parent?: Parent,
preceding = '',
following = '',
) => {
if (excluded.includes (node.type))
return
if (!(ancestors.some (ancestor => ['link',
'linkReference',
'image',
'imageReference',
'code',
'inlineCode'].includes (ancestor?.type)))
&& (node.type === 'text'))
{
const value = node.value ?? ''
if (value)
{
re.lastIndex = 0
let m: RegExpExecArray | null
let last = 0
const parts: RootContent[] = []
while ((m = re.exec (value)) !== null)
{
const start = m.index
const end = start + m[0].length
if (start > last)
parts.push ({ type: 'text', value: value.slice (last, start) })
const name = m[1]
parts.push ({ type: 'link',
url: `${ basePath }/${ encodeURIComponent (name) }`,
title: null,
children: [{ type: 'text', value: name }],
data: { hProperties: { 'data-wiki': '1' } } })
last = end
}
if (parts.length)
{
if (last < value.length)
parts.push ({ type: 'text', value: value.slice (last) })
const parent = ancestors[ancestors.length - 1]
if (parent && Array.isArray (parent.children))
{
const index = parent.children.indexOf (node)
if (index >= 0)
edits.push ({ parent, index, parts })
}
}
}
}
const maybeChidren = 'children' in node ? node.children : undefined
if (Array.isArray (maybeChidren))
{
const parent = node as Parent
for (let i = 0; i < maybeChidren.length; ++i)
{
const child: Content | undefined = maybeChidren[i]
if (!(child))
continue
walk (child, ancestors.concat (parent))
}
}
}
walk (tree, [])
for (let i = edits.length - 1; i >= 0; --i)
if (node.type === 'text' && parent != null)
{
const { parent, index, parts } = edits[i]
if (!(parent) || !(Array.isArray (parent.children)))
continue
if (0 <= index && index < parent.children.length)
parent.children.splice (index, 1, ...parts)
const value = node.value
let last = 0
const parts: RootContent[] = []
for (const match of value.matchAll (expression))
{
const start = match.index
const end = start + match[0].length
const candidate = targets.get (match[0])
const before = Array.from (value.slice (0, start)).at (-1) ?? preceding
const after = Array.from (value.slice (end))[0] ?? following
if (candidate == null || ambiguous.has (match[0])
|| (ainu && (wordCharacter.test (before) || wordCharacter.test (after))))
continue
if (start > last)
parts.push ({ type: 'text', value: value.slice (last, start) })
parts.push ({
type: 'link',
url: `/${locale}/wiki/${encodeURIComponent (candidate.resourceId)}`,
title: null,
children: [{ type: 'text', value: match[0] }],
data: { hProperties: { 'data-wiki': '1' } } })
last = end
}
if (parts.length > 0)
{
if (last < value.length)
parts.push ({ type: 'text', value: value.slice (last) })
edits.push ({ parent, index: parent.children.indexOf (node), parts })
}
return
}
if ('children' in node)
{
const inline = ['paragraph', 'heading', 'emphasis', 'strong',
'delete', 'tableCell'].includes (node.type)
for (let index = 0; index < node.children.length; ++index)
{
const before = index > 0
? edgeCharacter (node.children[index - 1], false) : preceding
const after = index + 1 < node.children.length
? edgeCharacter (node.children[index + 1], true) : following
walk (node.children[index], node as Parent,
inline ? before : '', inline ? after : '')
}
}
}
walk (tree)
for (const { parent, index, parts } of edits.reverse ())
parent.children.splice (index, 1, ...parts)
}
}
export default remarkWikiAutoLink

変更されたファイルが多すぎるため、一部のファイルは表示されません さらに表示