Reviewed-on: #433 Co-authored-by: miteruzo <miteruzo@naver.com>
このコミットはプルリクエスト #433 でマージされました。
このコミットが含まれているのは:
+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",
|
||||
|
||||
@@ -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'
|
||||
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 ()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 ('ネコ、チャペ。')
|
||||
})
|
||||
})
|
||||
@@ -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
|
||||
@@ -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,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
|
||||
|
||||
@@ -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')
|
||||
})
|
||||
|
||||
|
||||
@@ -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>)
|
||||
}
|
||||
|
||||
@@ -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', () => ({
|
||||
|
||||
@@ -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>)}
|
||||
</>)
|
||||
|
||||
@@ -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 ('虹夏')
|
||||
|
||||
@@ -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={() => {
|
||||
|
||||
@@ -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,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
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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>)
|
||||
|
||||
@@ -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)
|
||||
{
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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',
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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>)
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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',
|
||||
)
|
||||
})
|
||||
|
||||
|
||||
@@ -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>)}
|
||||
|
||||
@@ -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)}
|
||||
|
||||
@@ -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')}>
|
||||
その他 »
|
||||
<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'))}>
|
||||
その他 »
|
||||
<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'))}>
|
||||
その他 »
|
||||
<TranslatedText messageKey="more"/>
|
||||
</PrefetchLink>
|
||||
<TopNavUser user={user} sp/>
|
||||
<Separator/>
|
||||
|
||||
@@ -17,7 +17,7 @@ describe ('TopNavUser', () => {
|
||||
|
||||
expect (screen.getByRole ('link', { name: '山田' })).toHaveAttribute (
|
||||
'href',
|
||||
'/users/settings',
|
||||
'/ja/users/settings',
|
||||
)
|
||||
})
|
||||
|
||||
|
||||
@@ -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>
|
||||
</>)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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}>
|
||||
|<
|
||||
</PrefetchLink>
|
||||
<PrefetchLink
|
||||
className="p-2"
|
||||
to={buildTo (page - 1)}
|
||||
aria-label="前のページ">
|
||||
lang={message ('pagination.previous').language}
|
||||
aria-label={message ('pagination.previous').text}>
|
||||
<
|
||||
</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}>
|
||||
>
|
||||
</PrefetchLink>
|
||||
<PrefetchLink
|
||||
className="md:hidden p-2"
|
||||
to={buildTo (totalPages)}
|
||||
aria-label="最後のページ">
|
||||
lang={message ('pagination.last').language}
|
||||
aria-label={message ('pagination.last').text}>
|
||||
>|
|
||||
</PrefetchLink>
|
||||
</>)
|
||||
|
||||
@@ -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 }
|
||||
|
||||
|
||||
@@ -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) },
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 =>
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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'
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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> = (
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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)}/>)
|
||||
|
||||
@@ -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 (() => {
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 (() => {
|
||||
|
||||
@@ -12,7 +12,7 @@ type Props = {
|
||||
const PostImportThumbnailPreview: FC<Props> = (
|
||||
{ url,
|
||||
file,
|
||||
alt = 'サムネール',
|
||||
alt,
|
||||
className = 'h-16 w-16' },
|
||||
) => (
|
||||
<PostThumbnailPreview
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 = (
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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,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
|
||||
|
||||
@@ -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
|
||||
{
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 =
|
||||
|
||||
@@ -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 })
|
||||
|
||||
@@ -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 }))
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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'))
|
||||
|
||||
@@ -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 ()
|
||||
})
|
||||
})
|
||||
@@ -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 })
|
||||
|
||||
@@ -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'
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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' } })
|
||||
|
||||
|
||||
@@ -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 ()
|
||||
})
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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 }
|
||||
|
||||
@@ -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' }] })
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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
|
||||
|
||||
変更されたファイルが多すぎるため、一部のファイルは表示されません さらに表示
新しいイシューから参照
ユーザーをブロックする